页面没变,但解析全错了:问题到底出在哪?

16 阅读5分钟

在很多企业里,招聘数据并不是一次性使用的数据,而是一种长期、持续积累的业务资产。

我们所在的团队,需要长期跟踪招聘市场的变化趋势,用于支持内部的人力规划、岗位热度分析以及区域用工结构判断。为此,我们搭建了一套持续运行的爬虫系统,定期采集主流招聘平台上的职位信息,并将数据沉淀为时间序列,用于横向和纵向对比。

正是因为这是一个长期运行、稳定输出的数据任务,这次问题才显得格外隐蔽,也更值得被完整复盘。

一、长期运行的招聘爬虫,数据却悄悄“变味”了

我们维护的这套爬虫,核心目标站点是 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,而是一次对“长期数据可靠性”的提醒。页面可能很久不改,但平台的业务策略一定在不断变化。如果解析逻辑仍然停留在“结构假设”,那么迟早会再次遇到类似的问题。

从结构解析走向语义解析,是爬虫绕不开的一步。