2.2 网页采集:requests 与解析陷阱

场景:每天抓三十个行业资讯站

做 RAG 知识库之前,我得先有料。老板拍板:每天早上把三十来个行业资讯站的更新抓下来,去重入库,晚上喂给大模型出一份日报摘要。听起来就是 requests 加 BeautifulSoup 的入门活,结果第一周我被同一类问题绊倒了三次:状态码 200,但内容根本不是我要的。

这类脚本最大的幻觉在于跑通不等于正确。requests.get 没报错、状态码也正常,但解析出来空空如也。下面按我实际踩坑的顺序讲下来,这些经验后来也沉淀到了团队AI 工具箱的采集模块里。

方案:分层防御的抓取函数

抓取写法的坑集中在三处:请求头、编码、解析选择器。我的函数后来固定成这个样子,每个坑都有对应的一行防御:

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
      "AppleWebKit/537.36 (KHTML, like Gecko) "
      "Chrome/120.0 Safari/537.36")

def fetch_article(url, timeout=15):
    resp = requests.get(
        url,
        headers={"User-Agent": UA, "Referer": url},
        timeout=timeout,
    )
    resp.raise_for_status()
    resp.encoding = resp.apparent_encoding   # 乱码的救星
    soup = BeautifulSoup(resp.text, "lxml")
    title = soup.select_one("h1.article-title")
    body = soup.select_one("div.article-content")
    if not title or not body:
        raise ValueError(f"结构缺失: {url}")
    text = "\n".join(p.get_text(strip=True) for p in body.select("p"))
    for a in body.select("a[href]"):
        _ = urljoin(url, a["href"])          # 相对链接补全
    return title.get_text(strip=True), text

几个关键点单独说一下。resp.encoding = resp.apparent_encoding 是乱码唯一的解:requests 默认按 HTTP 头声明的编码解析,很多中文站点头里压根不声明,或者声明错,拿到的就是一堆问号菱形。不过 apparent_encoding 也不是万能,碰到 GB2312 站点偶尔会猜成 GBK,最稳的还是显式判断头、再 resp.content 自己解码。

解析的坑在选择器。select_one 配合 CSS 选择器写起来快,但很多站点类名是 article-title 今天、art_title 明天。我的做法是给每个站点维护一份选择器配置,解析失败立刻抛 ValueError,而不是静默返回空字符串——空字符串会一路流向大模型,最后日报里出现莫名其妙的空白摘要,排查链路能跟到天黑。

反爬手段与应对对照

做过一段时间采集你会发现,每个站点封你的姿势都不一样,整理成下面这张表,新人入职先看这个:

反爬手段 现象 应对方式 代价
UA 检测 403 Forbidden 带浏览器 UA 头 零成本
Referer 检测 403 或跳转验证页 带来源页地址 零成本
IP 限频 偶发 429 或 503 降频加随机延时 采集变慢
JS 动态渲染 拿到空壳 HTML 上 headless 浏览器 资源消耗高
登录墙 内容被 redirect 放弃或走授权 API 看站点价值

决策原则很现实:前两种零成本直接上;限频就老老实实 sleep,我见过同事拿多线程猛冲把对方站拖崩,第二天自己 IP 段被封,道歉也没用;动态渲染是最贵的方案,国内资讯站只要提供 RSS 我就绝不渲染页面——能用 feed 就别爬 HTML,这是采集最大的省力秘诀,腾讯云实践里也有类似的数据源治理思路。

RSS 的解析比 HTML 稳定一个量级,结构标准化,不怕改版:

import feedparser

def fetch_feed(url):
    d = feedparser.parse(url)
    for e in d.entries:
        yield {
            "title": e.get("title", "").strip(),
            "link": e.get("link", ""),
            "published": e.get("published", ""),
            "summary": e.get("summary", "")[:200],
        }

拿到 feed 后第一步是去重:用 URL 的 md5 做主键,存一个已抓集合,今天的和昨天的比对一遍,只有增量才进入解析流程。三十个站一天更新可能只有几十条,全量解析是浪费,增量处理把后续清洗的压力也一起降下来了。

踩坑与排查

坑一:相对链接。 正文里抽到的链接是 /news/123.html,直接存库后面全点开全是 404。一行 urljoin(url, href) 搞定,但一定要在抓取时补,不要等到入库后批量修。

坑二:优雅地失败。 三十个站总有三五个当天抽风,一个站抛异常整个任务就挂了。外层必须套 try/except,失败的 URL 记进失败队列,第二天重试,连续失败三天再报警。

坑三:HTML 结构变更。 某天发现一个站突然零更新,一查是人家改版了。后来解析函数加了完整性校验:标题和正文任一为空就报错,入库前还做了字数阈值检查,低于 200 字直接进人工确认队列。

坑四:自己被自己坑。 压测时把并发开太高,云端出口 IP 被对方封了,连公司正常访问都受影响。此后团队立了规矩:采集频率默认保守,想提速必须走云端部署的独立出口,并且给目标站留够缓冲。

坑五:缓存和重复抓取。 早期没做 URL 归一化,同一篇文章带跟踪参数 ?from=timeline 和不带参数被抓了两次,日报里同一条新闻出现两遍。归一化的做法是抓的时候先 urllib.parse.urlparse 拆开,去掉 utm_* 之类的跟踪参数再算哈希。这个去重逻辑后来也复用到了知识库入库环节,和RAG 检索那篇的文档去重是同一套代码。

坑六:重定向黑洞。 有的站点 302 跳登录页,状态码还是 200,正文是登录广告。所以校验不能只看状态码,正文长度、标题关键词都要纳入断言。requests 默认允许重定向,调试时可以关掉看清楚跳转链,链路里多一跳往往就多一个坑。

小结

采集脚本的核心不是 requests 怎么调,而是怎么在站点天天变的生态环境里保持稳定输出。请求头分层、解析配置外部化、失败可重试可观测,这三条比任何奇技淫巧都管用。这些原始数据攒够之后,接下来就是清洗入库的活,我们用 pandas 处理 Excel 与 CSV,是下一节的内容。整个采集流水线上云之后用到了云函数定时触发,可以参考Serverless 部署入门。

延伸阅读