做招聘这行,最磨人的从来不是面试,而是面试之前那几百次重复的点击。我们团队三个人盯十二个在招岗位,每天早上打开电脑的第一件事,就是在 boss直聘 的列表里一页页翻简历、一条条点开、再一条条复制粘贴打招呼。这种活干两天还行,干两个月人就开始麻木,到了第三个月,好简历被同行先聊走了,我们还在处理昨天的未读。后来我花了大概两周下班时间,攒出了一个 boss直聘自动招聘助手,把「筛、打招呼、记录、跟进」这四件事里的机械部分交给了脚本,人只留下做判断。
我先把话说清楚:这套东西不群发、不刷量、不碰任何人的隐私数据,它服务的是「你自己账号里本来就该处理的信息」,只是把人工顺序执行的动作变成有节流、有记录、有复盘的流水线。适合中小团队里既要招人又要干活的 HR、技术负责人、创业公司合伙人;不适合想拿它做营销轰炸的人,那类玩法账号活不过一周,也没必要。
1. 先拆需求:招聘助手到底要替人做什么
1.1 一天的时间到底被切碎在哪里
我把连续一周的操作录了屏,逐条统计,结论比我预想的更扎心。一个招聘专员一天的点鼠标次数在 800 到 1200 次之间,其中真正产生判断价值的动作不到 15%。具体拆开看,时间主要碎在四件事上:第一是「列表遍历」,岗位下有新简历就要翻一遍列表,判断这个人值不值得点开,这个动作平均 20 万毫秒级的注意力切换;第二是「详情核对」,点开之后对照 JD 看经验、看技能栈、看到岗时间,再决定要不要打招呼;第三是「首轮触达」,打招呼、发岗位介绍、问期望薪资,话术大同小异;第四是「状态记录」,谁聊到哪一步了、谁约了面试、谁已经不回消息了,全靠脑子和备忘录。
这四件事里,第一、三、四件几乎 100% 是规则化的,第二件里也有七八成可以先用规则粗筛一遍,只把边界案例留给人看。所以助手的目标不是「代替招聘」,而是「把候选人处理流程从人肉串行变成机器串行、人做并行判断」。这个定位很重要,因为它直接决定了后面所有的技术选型——我不需要多聪明的模型,我需要的是一个稳、准、可解释的流程引擎。
1.2 想清楚哪些动作可以放手,哪些必须人来
我在动手之前列了一张「可自动化程度」清单,这张表后来帮我省掉了大量返工。凡是「可逆的、可解释的、有明确规则的」动作交出去;凡是「不可逆的、需要个性化判断的、涉及承诺的」留在人手里。
| 动作 | 可自动化程度 | 处理方式 | 理由 |
|---|---|---|---|
| 列表遍历与去重 | 高 | 完全交给脚本 | 纯机械,无判断 |
| 字段提取与入库 | 高 | 完全交给脚本 | 结构化映射 |
| 规则粗筛打分 | 高 | 脚本打分,人工复核阈值附近的人 | 规则可解释 |
| 首次打招呼 | 中 | 脚本生成,限量发送 | 涉及对外触达 |
| 薪资、到岗时间确认 | 中 | 脚本问,人工决策 | 需要判断弹性 |
| 约面试时间 | 低 | 只做候选时间收集 | 涉及双方协调 |
| 发 offer、谈薪 | 极低 | 完全人工 | 不可逆承诺 |
这张清单我在项目里当成「产品需求文档」用,每写一个模块都回头对一遍,避免写着写着就把不该自动化的东西也塞进去了。后来我给这套东西定了一条内部原则:脚本可以替我做「第一次接触」,但绝不能替我做「第二次承诺」。
1.3 动手前的红线:账号、数据与节奏
有三条线我一开始就画死了,后期无论怎么优化都没越过。
第一条是账号安全。助手只跑在我自己的账号上,登录凭据只存在本机环境变量里,不进 git、不发群、不共享。任何需要人工验证的环节,脚本一律停下来等我处理,绝不尝试绕过。这不是技术能力问题,是账号能不能活到下周的问题。
第二条是数据边界。我只采集和招聘判断直接相关的字段:岗位意向、工作年限、技能关键词、期望城市、活跃状态。手机号、微信号这类联系方式,只在候选人主动提供后才记录,而且加密存本地。抓取范围严格限定在「我账号里能正常看到的页面」,不做全站遍历。
第三条是节奏。脚本的请求频率必须压到跟人手动操作差不多的水平,甚至更低。我给自己定的硬约束是:列表请求每小时不超过 60 次,打招呼每批不超过 20 条且间隔随机,夜间和周末默认停跑。一个健康的账号比一个跑得快的脚本值钱得多,这话我踩过坑之后才真正信。
2. 整体架构:把一个「助手」拆成四层
2.1 三条技术路线,我为什么选了混搭
能实现这个需求的路子基本三条。第一条是浏览器自动化,用 Playwright 或 Selenium 驱动真实浏览器,所见即所得,页面上有的它都能操作,缺点是慢、资源占用高、页面结构一变就要改选择器。第二条是接口直连,抓包分析页面背后的数据请求,用 requests 直接拿 JSON,快、干净、字段完整,缺点是接口参数会变、鉴权字段会过期,稳定性依赖维护。第三条是官方开放能力,如果平台或第三方 HR SaaS 提供了正式的对接通道,那是最稳的,值得先花时间去确认有没有。
我的选择是混搭:读数据走接口,写动作走浏览器。原因很实在——读是高频的,接口直连能把一次列表遍历从 30 秒压到 3 秒;写是低频且不可逆的,浏览器自动化的容错和可视化更好,出问题时我能立刻看到页面到底发生了什么,而不是靠猜。这个组合还有一个隐性好处:写动作走浏览器天然就慢了,慢反过来帮我控住了频率。
2.2 四层模块怎么划分
整套东西我拆成四层,层与层之间只通过数据结构通信,任何一层都能单独替换。
采集层负责拿到原始页面数据,输出统一的候选人字典;处理层负责清洗、去重、补全、落库,输出干净的候选人记录;决策层负责打分排序,输出「这批先处理谁」的待办队列;执行层负责消费队列,执行打招呼、记录状态、安排后续提醒,输出动作日志。
四层分开的好处是调试的时候能精准定位。有一次回复率突然掉了一半,我只需要看决策层的打分分布,五分钟就发现是我把「期望薪资」的权重调太高,导致一批本来很合适的候选人被排到了队尾,根本没来得及打招呼。如果四层糊在一起,这种问题要查一整天。
2.3 状态机:候选人不是一条记录,是一个流程
新手最容易犯的错是把候选人当成数据库里的一行静态数据。实际上每个人都在流程里流动,我用一个简单的状态机来管:
新入库 → 已打分 → 待触达 → 已打招呼 → 已回复 / 未回复 → 沟通中 → 已约面 → 已面试 → 已入职 / 已放弃
每个状态只允许特定的动作,比如「已打招呼」状态的记录不允许再次触发打招呼动作,只能触发跟进提醒;「未回复」超过 5 天自动进入冷却池,冷却期内不再打扰。这个状态机是整个助手的骨架,比任何爬虫技巧都重要,因为它决定了「助手会不会做出让人反感的事」。
3. 采集层的实操细节
3.1 会话保持:别每次都重新登录
接口直连的第一个坑就是登录态。我的做法是在本地维护一个会话对象,复用同一套凭据,而不是每次请求都重新走一遍登录流程——频繁登录本身就是最明显的异常信号。
具体的处理是:把所有需要的请求头统一放在一个配置里,包括常规的浏览器标识、来源页、以及站点自己带的那几个鉴权字段。这些字段会过期,所以我在采集层加了一个探针请求,每隔一段时间拿一个轻量接口试一次,返回异常就把整个采集任务挂起,本地弹通知让我手动登录一次刷新凭据。这样处理的代价是我每周大概要手动处理一两次,但换来的是账号行为始终像一个人在正常使用。
注意:凭据文件永远不要提交到代码仓库,也不要放在共享目录。我用的是本机环境变量加一个权限 600 的本地文件,换机器时手动迁移。
还有个细节值得提:不要在多个设备上同时登录同一个账号跑脚本。我早期图方便,在公司电脑和家里笔记本上各跑一份,结果两边会话互相顶掉,日志里一堆莫名其妙的跳转,查了两个晚上才发现是这个问题。一个账号同一时间只跑一个实例,这是硬规矩。
3.2 列表与详情:字段映射表要先写出来
采集之前先做一件事:把「页面上能看到什么」和「我库里需要什么」列成一张映射表。这张表看着枯燥,但它能帮你避免 80% 的后续返工。
| 数据来源 | 原始字段 | 本地字段 | 处理方式 |
|---|---|---|---|
| 列表页 | 姓名/代号 | name | 原样存储 |
| 列表页 | 岗位意向 | expect_job | 去空格、统一简称 |
| 列表页 | 工作年限 | work_years | 文本转数字 |
| 列表页 | 活跃状态 | active_flag | 映射为枚举 |
| 详情页 | 技能标签 | skills | 拆分列表、去重 |
| 详情页 | 工作经历 | experience | 保留文本,抽取关键项 |
| 详情页 | 教育经历 | education | 抽取最高学历 |
| 详情页 | 期望城市 | expect_city | 统一到城市标准名 |
映射表写完之后,代码基本就是机械翻译。列表页的数据量大但字段少,我每轮只取必要的几个字段,不做过深的翻页;详情页只在打分通过阈值后才请求,这样能显著降低请求总量——这一点非常关键,我自己统计过,加上这层过滤之后,详情页请求量下降了约 70%。
3.3 数据落库:表结构和索引怎么设计
本地库我用的是 SQLite,够用、免运维、单文件好备份。核心两张表,一张存候选人,一张存动作日志。
CREATE TABLE candidate ( uid TEXT PRIMARY KEY, -- 候选人唯一标识 job_id TEXT NOT NULL, -- 对应岗位 name TEXT, expect_job TEXT, work_years INTEGER, education TEXT, skills TEXT, -- JSON 数组字符串 expect_city TEXT, expect_salary TEXT, active_flag INTEGER DEFAULT 0, score INTEGER DEFAULT 0, status TEXT DEFAULT 'new', -- 状态机字段 created_at TEXT, updated_at TEXT, last_touch_at TEXT ); CREATE INDEX idx_job_status ON candidate(job_id, status); CREATE INDEX idx_score ON candidate(score DESC); CREATE TABLE action_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL, action TEXT NOT NULL, -- greet / follow_up / note payload TEXT, -- 动作内容快照 result TEXT, -- success / fail / skip idem_key TEXT UNIQUE, -- 幂等键 created_at TEXT );这里有两个设计点值得展开。第一是uid做主键而不是自增 ID,因为同一候选人可能在多个岗位下出现,用平台侧的唯一标识能天然去重。第二是action_log上的idem_key唯一约束,这是防重复动作的最后一道闸——就算队列因为异常重放,数据库层面也不会重复记录,后面查问题的时候一眼就能看出来有没有重复发送。
3.4 请求节流:把频率压到「像个人」
节流不是简单的sleep(1),我用的是令牌桶加随机抖动。桶的容量决定突发上限,填充速率决定长期均值,随机抖动让间隔不是精确的等距,因为等距的请求节奏本身就很可疑。
import time, random class TokenBucket: def __init__(self, rate_per_hour, burst): self.rate = rate_per_hour / 3600.0 # 每秒补充 self.burst = burst self.tokens = burst self.ts = time.time() def acquire(self): while True: now = time.time() self.tokens = min(self.burst, self.tokens + (now - self.ts) * self.rate) self.ts = now if self.tokens >= 1: self.tokens -= 1 time.sleep(random.uniform(1.5, 4.5)) # 随机抖动 return time.sleep(random.uniform(0.8, 1.6)) # 列表类请求:每小时 60 次,突发上限 3 bucket_list = TokenBucket(rate_per_hour=60, burst=3) # 详情类请求:每小时 40 次,突发上限 2 bucket_detail = TokenBucket(rate_per_hour=40, burst=2)为什么把列表和详情分成两个桶?因为它们的「贵」不一样。列表请求是用来发现候选人的,频次天然高;详情请求是用来做判断的,频次低但单次成本高。分成两个桶之后,我可以独立调节,比如某天发现详情请求触发了异常提示,就只降详情桶的速率,不影响整体的发现流程。实际跑下来,这套节流让我在两个月里没有遇到过任何一次因为频率导致的账号异常。
4. 决策层与执行层:让助手会挑人、会说话
4.1 打分规则:不要上模型,先上权重表
一开始我想过用模型做排序,后来放弃了。原因有两个:一是样本太少,我一个月也就几百条有效数据,训出来的东西自己都不敢信;二是规则可解释,招聘这件事需要能对同事解释「为什么这个人排在前面」。
所以我用了一张人工权重表,每个维度 0 到 10 分,加权求和。权重是这样定的:岗位匹配度 30 分、技能关键词命中 25 分、工作年限区间 15 分、活跃状态 15 分、期望城市 10 分、教育背景 5 分。
def score_candidate(c, jd): s = 0 # 岗位匹配:文本包含关系,命中给满分 if jd["job_keyword"] in (c["expect_job"] or ""): s += 30 # 技能命中:按命中比例给分 if jd["skills"]: hit = len(set(jd["skills"]) & set(c["skills"] or [])) s += int(25 * hit / len(jd["skills"])) # 年限:落在区间内满分,超出区间按偏离度扣 lo, hi = jd["years_range"] y = c["work_years"] or 0 if lo <= y <= hi: s += 15 else: s += max(0, 15 - 3 * abs(y - min(hi, max(lo, y)))) # 活跃度 s += 15 if c["active_flag"] else 0 # 城市 s += 10 if c["expect_city"] in jd["cities"] else 0 # 学历 s += 5 if c["education"] in jd["edu_ok"] else 0 return s跑了两周之后我做了个调整:把「活跃状态」的权重从 15 提到了 20,把「教育背景」降到了 3。原因是数据说话——活跃状态对回复率的影响远比学历大,一个最近三天活跃的候选人,回复概率是沉默两周的人的五六倍。权重表不是拍脑袋定的,是要每周拿实际回复数据回测一遍的,这是整套系统里最值得投入时间的地方。
4.2 打招呼话术:三段式加变量替换
话术这块我踩的坑最多。最开始的版本是纯模板,结果回复率低得可怜;后来改成完全手写,效率又回去了。最终的方案是「三段式模板 + 变量替换 + 两套 A/B」。
三段式指的是:第一句点出为什么找他(具体到他的经历或技能),第二句说清楚岗位和团队的关键信息,第三句给一个低门槛的回应选项。每一句都留变量位。
TEMPLATE_A = ( "你好,看到你在{skill}这块有{year}年经验,我们这边{job}岗在找的正是这个方向。" "团队规模{team_size}人,做{domain}方向,{highlight}。" "方便的话想跟你聊五分钟,你看今天下午还是明天上午合适?" ) def render(tpl, c, jd): return tpl.format( skill=(c["skills"] or ["相关方向"])[0], year=c["work_years"] or "若干", job=jd["job_name"], team_size=jd["team_size"], domain=jd["domain"], highlight=jd["highlight"], )这里有个细节:变量替换必须带兜底值。我最开始没写兜底,遇到技能字段为空的候选人,脚本直接抛异常,那一批二十多个人的打招呼全部失败,日志里只有一行报错,我是第二天才发现有人没收到消息。凡是会进入对外文本的变量,都要有兜底字符串,这条经验值一次教训。
A/B 测试我做得比较粗糙但有效:每周换一套首发话术,记录两套的回复率差异,两周内差异超过 5 个百分点就保留高的那套。三个月下来,我的首发回复率从最初的不到 8% 提到了 20% 出头,靠的就是这种笨办法一点点磨。
4.3 去重与状态流转:别打扰同一个人两次
去重有两层。第一层是数据层,uid做主键,入库用INSERT OR IGNORE,同一个人重复出现不会产生第二条记录。第二层是动作层,每次打招呼之前先查action_log里有没有同一个uid的打招呼记录,有就跳过。
def can_greet(uid): row = db.execute( "SELECT 1 FROM action_log WHERE uid=? AND action='greet' AND result='success'", (uid,) ).fetchone() if row: return False # 未回复超过 5 天的,进入冷却池,不再重复触达 last = db.execute( "SELECT last_touch_at FROM candidate WHERE uid=?", (uid,) ).fetchone() if last and days_since(last[0]) < 5: return False return True状态流转我加了一条「冷却」规则:打过招呼但 5 天内没回复的人,不再发第二条消息。这条规则看起来很保守,但它救了我的账号口碑——早期我为了冲数据,给同一个人连发过三条消息,结果被投诉了一次,那次之后我把冷却期直接写死进代码。招聘这行的口碑是复利的,一个被骚扰过的候选人会在圈子里说很久。
4.4 动作队列:不可逆的操作必须排队
执行层我没有让脚本直接调动作,而是全部走一个队列,队列消费者是单线程、带限速、带退避重试的。
这么做有三个理由。第一是幂等,队列里每个任务带一个幂等键,重放不会重复执行。第二是可暂停,发现异常时我能立刻停掉队列,不影响已经排进去的数据。第三是可观测,所有动作都有日志,出问题能回溯到具体哪一条、哪一秒、返回了什么。
def consume(queue, bucket, max_retry=3): while queue and running_flag: task = queue.pop(0) if not can_greet(task["uid"]): log(task, "skip") continue bucket.acquire() for attempt in range(max_retry): try: ok = do_greet(task) log(task, "success" if ok else "fail") break except TransientError: time.sleep(2 ** attempt * 5 + random.uniform(0, 3)) except FatalError as e: log(task, "fail", str(e)) pause_all() # 致命错误直接全局暂停,通知人工 breakpause_all()这个设计是我后来加的,起因是一次字段结构变化导致所有请求都返回空数据,脚本还在傻乎乎地往下跑,差点把整批候选人标成「未回复」。遇到无法解释的异常,停下来比继续跑更有价值,这句话我写在代码注释里提醒自己。
5. 常见问题与排查实录
5.1 报错速查表
跑了大半年,问题基本集中在下面这几类,整理成表方便快速定位。
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 返回登录页内容 | 会话过期 | 用探针请求确认 | 挂起任务,手动刷新凭据 |
| 返回空列表 | 参数变动或筛选条件失效 | 人工打开同条件页面核对 | 修正参数,回放单条验证 |
| 字段大面积缺失 | 页面结构改版 | 抓 5 条人工比对 | 更新选择器或字段路径 |
| 重复发送打招呼 | 幂等键缺失或队列重放 | 查 action_log | 补唯一约束,清理重复记录 |
| 中文乱码 | 编码声明错误 | 检查响应编码 | 显式指定 UTF-8 |
| 时间字段错位 | 时区不一致 | 打印原始时间戳 | 统一转换到东八区 |
| 请求被限流 | 频率过高 | 看令牌桶日志 | 降速率,加长冷却期 |
这张表我贴在显示器边上,出问题先在表里找对应行,多数情况三分钟能定位。
5.2 登录态失效与验证环节怎么处理
这是所有人问得最多的问题。我的答案是:不要试图自动化验证环节,把它当成一个正常的运维事件。
具体做法是,采集层每隔一段时间发一个轻量探针请求,如果返回的内容不符合预期,立刻做三件事:把当前所有任务状态存盘、把运行标志位关掉、在本地弹出一条通知。我收到通知之后手动登录一次、刷新凭据,再手动把标志位打开,任务从断点继续。
这套机制的好处是,脚本永远不会在「以为自己登录着」的状态下继续跑。早期我没有探针,脚本在凭据失效之后继续跑了四十多分钟,把所有请求都打到了登录页,日志里全是正常返回(因为登录页确实返回了 200),数据却一条没入库。这种「静默失败」是最难查的 bug,探针本质上就是给系统加一个「我是不是还在正常状态」的自检。
注意:任何需要人工判断的环节都不要尝试用程序绕过。绕过一次可能省五分钟,代价是整个账号和积累的数据。
5.3 数据错位和漏抓怎么定位
数据错位通常有三种表现:数量和实际对不上、字段错行、时间不对。我的定位方法是固定的三步。
第一步,抽五条人工核对。在页面上随机挑五个人,把脚本抓到的字段和页面内容逐项对比,通常三次之内就能看出是哪个字段开始偏的。第二步,看分页边界。很多错位发生在翻页处,尤其是用页码偏移的方式翻页时,如果中间有数据变动,就会漏掉或重复。我现在全部改成了游标方式,用上一页最后一条的标识作为下一页的起点,稳定性好了很多。第三步,看时间字段的时区。我遇到过所有时间都差 8 小时的情况,原因是我把本地时间当成 UTC 存储,读出来就错位了。统一转换到东八区之后,这个问题再也没出现过。
漏抓的排查相对简单:对比「页面上显示的总数」和「库里的记录数」,如果差距稳定在某个比例,通常是我主动过滤掉的(比如不满足硬性条件的),把它记在日志里就好;如果差距不规律,那就回到第一步重新抽样。
6. 长期维护与个人心得
6.1 我踩过的五个坑
第一个坑是并发开太猛。最初我用多线程同时跑列表和详情,速度确实快了三倍,但那周遇到了两次异常提示,之后就老老实实改成单线程加队列。
第二个坑是话术写太长。最早的打招呼消息有六行,回复率惨淡。后来砍到三行,把「我们公司怎么样」这种自我介绍删掉,改成直接说岗位和方向,回复率立刻上来了。候选人不关心我们是谁,只关心这个机会跟他有没有关系。
第三个坑是忽略「已读不回」的信号。我一度把沉默的人反复放进队列,浪费了大量额度。现在的处理是沉默即降权,超过冷却期直接进冷池,把额度留给新发现的候选人。
第四个坑是字段映射写死在代码里。页面稍微一改版就要翻代码。现在我把所有映射关系抽成一份配置文件,改版时只改配置,不动逻辑。
第五个坑是日志只打成功不打失败。有一次一批打招呼全部失败,日志里却干干净净,因为我在异常分支里只打了pass。现在所有失败路径都必须落日志,包括被跳过的、被冷却的、被限速的,全都要有记录。日志的价值不在于记录成功,而在于解释失败。
6.2 效果怎么衡量:只看三个指标
指标多了会分散注意力,我只盯三个。
有效触达率,也就是话术成功送到对方手里的比例。这个指标掉下来,通常是技术问题,去看探针日志和报错表。回复率,指触达之后对方回消息的比例。这个指标反映话术和匹配质量,掉下来就去回测权重表和话术 A/B。进入沟通率,指回复的人里最终进入实质沟通(换联系方式、约面试)的比例。这个指标反映的是岗位本身的吸引力,脚本帮不上忙,但它能帮我判断哪些岗位值得继续投入。
我每周花半小时把这周的三个指标和上周对比,再把打分权重重算一遍。三个月下来,整体的候选人处理量大概是原来的四倍多,而我在机械操作上花的时间从每天四小时降到了不到一小时。省下来的时间我全都放到了面试沟通和岗位梳理上,这部分才是真的有价值。
最后分享一个小技巧:把打分阈值设成动态的。我一开始定死 60 分,结果旺季候选人质量高的时候,阈值太低导致队列里塞满了一般的候选人;淡季的时候又太高,一天打不出去几条。后来改成按当周队列长度自动调整阈值,保证每天待触达的人数稳定在 15 到 25 之间,节奏一下就顺了。