在很多企业里,招聘数据并不是一次性使用的数据,而是一种长期、持续积累的业务资产。
我们所在的团队,需要长期跟踪招聘市场的变化趋势,用于支持内部的人力规划、岗位热度分析以及区域用工结构判断。为此,我们搭建了一套持续运行的爬虫系统,定期采集主流招聘平台上的职位信息,并将数据沉淀为时间序列,用于横向和纵向对比。
正是因为这是一个长期运行、稳定输出的数据任务,这次问题才显得格外隐蔽,也更值得被完整复盘。
一、长期运行的招聘爬虫,数据却悄悄“变味”了
我们维护的这套爬虫,核心目标站点是 BOSS 直聘 Web 端职位列表页,采集字段包括:
- 职位名称
- 薪资区间
- 工作城市
- 公司名称
这套任务已经稳定运行了几个月,期间偶尔会有请求失败或封禁问题,但整体数据质量一直可控。
直到某次例行数据抽样时,我们发现了一个非常不符合常识的现象:
同一城市、同一岗位的薪资分布,在短时间内出现了明显断层。
进一步核查后发现:
- 薪资字段大量为空或错位
- 城市字段偶尔被解析成公司名称
- 请求成功率和响应时间一切正常
- 系统日志中没有任何异常信息
换句话说,系统“看起来很健康”,但数据已经不再可信。
二、第一轮排查
在企业级爬虫系统中,一旦出现数据异常,排查通常会从最常见的几个方向开始。
第一步是确认页面是否发生了结构变化。
我们通过浏览器手动访问页面,对比历史 HTML 快照,检查 DOM 层级、标签结构和 class 名,结果发现页面结构保持不变。
第二步是怀疑代理 IP 是否影响了访问结果。
系统中所有请求均通过亿牛云爬虫代理发出,代理配置如下:
proxies = {
"http": "http://用户名:密码@proxy.16yun.cn:端口",
"https": "http://用户名:密码@proxy.16yun.cn:端口"
}
我们分别使用不同代理 IP、关闭代理直连、以及在浏览器中复现请求,得到的 HTML 内容几乎一致。
第三步是检查请求参数本身,包括 User-Agent、Cookie 是否失效。
这一轮检查同样没有发现明显问题。
到这里,所有“常规经验路径”几乎都走到了尽头。
三、真正的问题
转机出现在一次非常细致的对比分析中。
我们将不同时间、不同代理环境下抓取的 HTML 内容逐行比对,发现虽然 DOM 结构一致,但在同一节点层级下,字段的排列顺序发生了变化。
以职位核心信息节点为例,原本的结构逻辑是:
<p class="info-primary">
<span>20-30K</span>
<span>北京</span>
</p>
而在部分请求返回中,这两个字段的顺序被调整为:
<p class="info-primary">
<span>北京</span>
<span>20-30K</span>
</p>
页面视觉上几乎没有差异,但对于依赖位置解析的程序而言,含义已经完全颠倒。
四、为什么会出现这种变化
在长期招聘业务中,平台往往会根据不同用户、不同流量策略动态调整信息呈现方式,包括但不限于:
- 不同地区用户关注点不同
- A/B 测试对信息展示顺序的调整
- 针对招聘效果的细微前端策略优化
这些变化不一定体现在 DOM 结构层面,但会直接影响数据的语义顺序。
这也是企业级爬虫中最容易被忽略的一点:
页面结构的“稳定”,并不等价于数据表达语义的“稳定”。
五、问题根源:解析逻辑隐含了错误的业务假设
回头看最初的解析代码,问题其实非常清晰。
salary = tree.xpath(
'//p[@class="info-primary"]/span[1]/text()'
)
city = tree.xpath(
'//p[@class="info-primary"]/span[2]/text()'
)
这段代码隐含的前提是,薪资字段永远排在第一个,城市字段永远排在第二个。
在短期测试阶段,这个假设成立;
但在长期运行的企业招聘场景中,这种假设本身就是不可靠的。
六、修复思路:从“结构解析”转向“语义解析”
在修复过程中,我们统一了一个原则:
解析规则必须能够解释“为什么是这个字段”。
改进后的核心思路,是基于文本特征而非节点位置进行判断。
import requests
from lxml import etree
url = "https://www.zhipin.com/job_detail/"
headers = {
"User-Agent": "Mozilla/5.0"
}
proxies = {
"http": "http://用户名:密码@proxy.16yun.com:端口",
"https": "http://用户名:密码@proxy.16yun.com:端口"
}
resp = requests.get(
url,
headers=headers,
proxies=proxies,
timeout=10
)
tree = etree.HTML(resp.text)
# 通过文本特征判断薪资信息
salary = tree.xpath(
'//p[@class="info-primary"]/span[contains(text(),"K")]/text()'
)
# 不包含薪资特征的字段,作为城市信息处理
city = tree.xpath(
'//p[@class="info-primary"]/span[not(contains(text(),"K"))]/text()'
)
这种方式在长期运行中明显更稳定,也更符合企业数据采集的实际需求。
七、这次问题带来的工程经验
在问题解决后,我们对招聘数据采集规范做了几条补充约束:
- 核心字段解析不得依赖节点顺序
- 每个字段必须有清晰的语义判断依据
- 数据异常必须通过业务指标而不仅是技术指标发现
这次问题并不是一次简单的代码 Bug,而是一次对“长期数据可靠性”的提醒。页面可能很久不改,但平台的业务策略一定在不断变化。如果解析逻辑仍然停留在“结构假设”,那么迟早会再次遇到类似的问题。
从结构解析走向语义解析,是爬虫绕不开的一步。