写爬虫最怕的就是目标网站悄无声息地改版。很多团队把抽取规则写死在代码里,比如用一条固定的xpath路径去取商品标题,结果前端重构后外层容器换了标签,爬虫立刻抓不到内容。要让Python爬虫具备识别页面结构变化并自动适配的能力,不能只靠人工盯守,必须把结构感知和规则重算做成可运行的机制。
基于DOM特征锚点的结构感知原理
所谓结构变化,不一定是整页重做,更多是局部容器的标签名、类名或层级深度发生了偏移。如果爬虫只认绝对路径,比如/html/body/div[3]/ul/li[1]/a,那只要前面插了一个div整个规则就废了。特征锚点法的核心是放弃绝对路径,改为在DOM树中寻找语义稳定、属性或文本不易变的节点作为参照物。例如商品列表里每个条目都带有data-sku属性,或者价格前面永远有中文弯引号“¥”这样的文本标记,这些就是高稳定性的锚点。
在Python中可以用BeautifulSoup或lxml先把页面解析成树,然后写一个小函数去递归搜索带有关键属性或关键文本的节点。锚点不需要唯一,只要在同类型数据块中重复出现即可。拿到锚点后,再以它为基准用相对路径向下取数,这样即使外层包装从<div>变成<section>,只要锚点还在,抽取就不会断。下面示例展示如何用lxml根据data-sku找锚点并取标题:
from lxml import html
def extract_by_anchor(page_text):
tree = html.fromstring(page_text)
# 以data-sku作为锚点,不依赖外层结构
anchors = tree.xpath('//*[@data-sku]')
result = []
for node in anchors:
# 相对路径取标题,容错层级变化
title = node.xpath('.//*[contains(@class,"title")]/text()')
if not title:
title = node.xpath('.//a/text()')
result.append({
'sku': node.get('data-sku'),
'title': (title[0] if title else '').strip()
})
return result
这种方法的优势是抗结构性干扰强,前端怎么换皮都不怕。缺点是锚点选择需要一点业务理解,如果网站连数据属性都抹掉,就只能退而用文本模式匹配。实际项目中通常把锚点配置化成字典,方便随时调整。
利用历史快照做差异检测与变化预警
光有锚点还不够,得知道页面什么时候变了、变了多少。差异检测的思路是给每次成功抓取的解析后结构留一个轻量快照,比如记录锚点数量、各锚点到目标字段的相对路径深度、页面主要区块的文本长度分布。当下次请求回来的页面解析完,立刻和最近稳定快照比对,算出结构偏移分数。
具体可用Python的difflib或自己写节点相似度算法。简单的做法是把锚点下的局部HTML序列化后做序列相似度,低于设定阈值就认为结构大变。这时爬虫不应盲目抽,而该触发告警或进入自动重算流程。下面代码演示用difflib比对两次页面的锚点区域:
import difflib
def structure_changed(old_fragment, new_fragment):
# old_fragment和new_fragment是锚点区域的序列化文本
ratio = difflib.SequenceMatcher(None, old_fragment, new_fragment).ratio()
# 相似度低于0.8认为结构变化明显
return ratio < 0.8
# 示例
old = '<div class="item"><span>书名</span></div>'
new = '<section class="item"><span>书名</span></section>'
print(structure_changed(old, new))
差异检测能拦住大部分隐性改版,但它只是哨兵,真正让爬虫活下来的是后面的自动适配。快照建议存本地或Redis,保留最近五到十次成功样本,避免误判。如果网站做A/B测试导致页面随机两种结构,快照法可能频繁报警,此时要配合多模板并行抽取。
规则自动重算与降级抽取的实现
当差异检测确认结构变化,爬虫就要尝试自动重算规则。最实用的办法是准备一个候选选择器池:把历史上生效过的xpath、css选择器,以及基于文本正则的提取式都存着。重算时依次用候选池去新页面试抽,哪个能抽出非空且数量合理的字段就临时启用哪个,并写回配置。
如果候选池全失效,就启动降级模式:用更笨但更稳的方式,比如用正则直接搜“¥d+(.d+)?”抓价格,用标题常见的长度区间和标点特征抓文本。降级虽不优雅,但能保证数据不断。以下示例展示候选池试抽逻辑:
candidates = {
'title': [
'//*[@data-sku]//*[@class="title"]/text()',
'//*[@data-sku]//a/text()',
'//h2/text()'
]
}
def auto_adapt(tree, field='title'):
for expr in candidates[field]:
vals = tree.xpath(expr)
if vals and len(vals) > 0:
return vals[0].strip(), expr
return None, None
# 使用
from lxml import html
t = html.fromstring(new_page)
val, used = auto_adapt(t)
print('采用规则:', used, '取值:', val)
自动重算最好加频率限制,比如同一域名十分钟只重算一次,防止网站抖动引发反复重写。配合前面的锚点与快照,整套动态策略能让Python爬虫在无人干预下跨过多数改版坑。长期看,把锚点配置、快照阈值、候选池都做成可热更新的远程配置,才是工程化终局。