爬虫代理

在写爬虫的工作中,总会遇到那些“看起来简单,做起来崩溃”的任务。知乎采集就是这样一个典型的案例。本来想借助大模型,把网页结构交给它自动理解,然后直接吐出 JSON 格式的结果。但从最初的顺风顺水,到后面的一连串问题,整个过程几乎像一场“事故调查”。

事件回放:时间轴上的波动

上午 9:00
一开始的设想很简单:用 Playwright 打开知乎问题页面,拿到渲染后的 HTML,再把它丢给大模型。让它帮忙识别出问题标题、第一条回答,以及前五条评论。为了避免请求被拦截,还加上了爬虫代理。理论上,这样就能轻松完成数据抓取。

上午 10:10
最初的效果非常好。在几个测试页面里,模型准确地把标题和回答内容提取出来,评论也能顺利解析。我甚至开始庆幸,终于能摆脱写那些又臭又长的 XPath 规则了。

上午 11:45
麻烦很快出现。部分页面的回答和评论是延迟加载的,刚打开时 DOM 里根本没有完整内容。模型拿到的 HTML 其实不全,结果返回的数据缺胳膊少腿。日志里不断冒出 missing field: comments

下午 1:30
还没等我缓过来,知乎页面的小改版又来了一刀。回答卡片的 class 名字改了,嵌套层次也调整过,模型生成的规则一下子就失灵了。结果不是空数据,就是掺杂了一堆广告推荐内容。

下午 3:00
接下来更糟。随着采集频率提高,知乎的风控机制直接把我拉进了“人机校验”流程。验证码、滑块轮番上阵,代理再好也救不了。这时整个采集队列等于是停摆。

下午 4:30
最后的会议上,大家一致认定:仅靠 Prompt-to-Parser 的方案太脆弱。它确实能帮忙,但还得配合一整套兜底机制,才能在真实场景里跑得下去。

问题复盘

回头看,那些故障其实都能归结为几个根源:

  1. 渲染时机不对
    评论和回答是异步注入的,如果没等 DOM 稳定就丢给模型,必然缺失数据。
  2. 解析方式过于依赖 DOM 表象
    模型“看”到的页面结构变化太快,今天能用,明天就失效。
  3. 反爬策略干扰
    知乎的风控相对严格,频繁访问触发验证码几乎是必然。
  4. 缺乏兜底与报警
    出了问题只能人工排查,没有任何回退机制,效率极低。

改进思路:三层防护

为了让方案能撑住长期运行,我们重新梳理了架构,补上了几道保险。

  1. 渲染层
    • 用 Playwright 把页面完整加载出来。
    • 设定等待规则,不只是 networkidle,还要等到回答和评论数量稳定才开始解析。
    • 尽量复用 Cookie 会话,降低频繁触发验证码的概率。
  2. 适配器层
    • 给模型设计固定的 Prompt 模板,明确要哪些字段。
    • 加上备用的 CSS/XPath 选择器,当模型失灵时用来兜底。
    • 定期检查解析结果,把表现稳定的 Prompt 固化下来,逐步形成“适配器库”。
  3. 回退与监控
    • 如果模型和备用规则都失效,就保存 HTML 快照,丢进人工工单系统。
    • 出现大量验证码时,自动降低采集速率,而不是死磕。
    • 对异常情况设置告警,让问题暴露得更及时。

改进后的小片段代码

下面这段示例,是我后来用来测试的核心流程(删掉了一些公司内部的逻辑,保留了关键部分)。主要思路就是:先渲染,再交给模型,如果解析失败就用备用选择器。

import asyncio
import json
import time
from typing import Optional
from playwright.async_api import async_playwright, Page
import openai  # 假设使用 OpenAI 风格 API;替换为你自己的 LLM 接口

# === 爬虫代理(亿牛云示例www.16yun.cn,仅作配置展示) =====
# 请替换成真实的代理信息或使用环境变量管理
PROXY_HOST = "proxy.16yun.cn"
PROXY_PORT = 3100
PROXY_USER = "16YUN"
PROXY_PASS = "16IP"

# Playwright 接受的 proxy 字段需要字典形式
PLAYWRIGHT_PROXY = {
    "server": f"http://{PROXY_HOST}:{PROXY_PORT}",
    "username": PROXY_USER,
    "password": PROXY_PASS
}

# ======= 基本配置 =======
# LLM API key 通过环境变量/安全配置提供(此处仅示意)
# openai.api_key = "YOUR_API_KEY"

# ======= 工具函数:等待 DOM 稳定 =======
async def wait_for_content_stable(page: Page, selector: str, timeout: int = 10000, stable_rounds: int = 3):
    """
    等待指定 selector 出现并且节点数量在连续 stable_rounds 次检查中保持不变,
    以应对异步分页/延迟注入的问题。
    """
    end_time = time.time() + timeout / 1000.0
    last_count = -1
    stable = 0

    await page.wait_for_selector(selector, timeout=timeout)  # 首次出现
    while time.time() < end_time:
        nodes = await page.query_selector_all(selector)
        count = len(nodes)
        if count == last_count:
            stable += 1
            if stable >= stable_rounds:
                return True
        else:
            stable = 0
            last_count = count
        await asyncio.sleep(0.5)
    return False

# ======= 备份静态选择器(回退规则) =======
# 这些选择器为示例,需在真实环境中调试与维护
FALLBACK_SELECTORS = {
    "title": "h1.QuestionHeader-title",
    "first_answer_author": ".AnswerCard .AuthorInfo .UserLink",
    "first_answer_time": ".AnswerCard time",
    "first_answer_content": ".AnswerCard .RichContent-inner",
    "comments": ".CommentItem"
}

# ======= 主流程:渲染并调用 LLM 解析 =======
async def fetch_zhihu_question(url: str) -> dict:
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True, proxy=PLAYWRIGHT_PROXY)
        context = await browser.new_context(
            # 模拟常见 UA,Cookie 可通过 add_cookies 恢复登录态(如有合法权限)
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121 Safari/537.36",
            locale="zh-CN"
        )

        # 在每个新页面注入脚本,尝试减少 navigator.webdriver 指示(注意合规边界)
        await context.add_init_script("""() => { Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); }""")

        page = await context.new_page()
        await page.goto(url, timeout=60000)

        # 等待关键内容稳定(回答区)——知乎回答列表的容器可能使用类似 .AnswerList
        stable = await wait_for_content_stable(page, ".AnswerCard", timeout=15000, stable_rounds=4)
        # 若未稳定,可进一步尝试长等待或抓取 API(如合法)
        # 截取渲染后的 HTML 快照
        html = await page.content()
        await browser.close()

    # ======= Prompt-to-Parser:构建 Prompt 并调用 LLM =======
    prompt = f"""
    你是一个网页解析助手。请从下面的知乎问题页渲染后 HTML 中提取:
    - question_title: 问题标题(字符串)
    - first_answer: 包含 author, time, content 字段(文本)
    - comments: 前 5 条评论(每条包含 author, time, text)

    HTML:
    {html[:4000]}
    请返回纯 JSON,不要包含额外解释。如果某个字段缺失,请用 null。
    """

    try:
        # 使用 LLM(此处为伪代码示例,请替换为实际调用)
        resp = openai.ChatCompletion.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "system", "content": "你是结构化数据提取专家。"},
                {"role": "user", "content": prompt}
            ],
            max_tokens=1200,
            temperature=0
        )
        llm_text = resp["choices"][0]["message"]["content"]
        parsed = json.loads(llm_text)
    except Exception as e:
        parsed = {"error": f"LLM 提取失败: {e}"}

    # ======= 验证字段完整性,若关键字段缺失则触发回退解析(静态选择器) =======
    def is_valid(p: dict):
        return isinstance(p.get("question_title"), str) and p.get("first_answer") and p["first_answer"].get("content")

    if not is_valid(parsed):
        # 回退:用预定义 CSS 选择器直接从 DOM 中抽取(需要重新渲染或保存的 html->dom 工具)
        # 为简化示例:使用 playwright 在 headless 浏览器里再次提取(同步复用上次渲染结果更好)
        # 这里演示回退思路:在真实实现中请把 html 保存在可重载环境,然后用 lxml/bs4/CSS 解析
        fallback = {"question_title": None, "first_answer": None, "comments": []}
        # (伪代码)——实际应重建 Playwright 上下文或使用保存的 DOM 进行解析
        # 以下仅作为结构示例
        fallback["question_title"] = "回退:无法从 LLM 获得标题,需人工/选择器检查"
        parsed = {"fallback_used": True, **fallback}

    # 添加一些元数据
    parsed_meta = {
        "timestamp": int(time.time()),
        "url": url,
        "llm_used": True,
        "fallback": parsed.get("fallback_used", False),
        "result": parsed
    }
    return parsed_meta

# ======= 运行示例 =======
if __name__ == "__main__":
    sample_url = "https://www.zhihu.com/question/xxxxxx"  # 替换为目标问题
    result = asyncio.run(fetch_zhihu_question(sample_url))
    print(json.dumps(result, ensure_ascii=False, indent=2))

最后的感受

知乎这样的站点,页面结构复杂、更新频繁,还有不小的风控压力。指望一次性的 Prompt 就能解决所有问题是不现实的。它确实能大大减少我们写解析规则的工作量,但要想在生产环境里跑稳,必须加上渲染等待、备用选择器、回退机制和监控告警。

换句话说,Prompt-to-Parser 就像一个聪明的助手,但它不是万能的。真正的稳定性,还是要靠架构设计和防线层层叠加来保证。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐