从 Prompt 到 Parser:一次知乎采集的曲折经历
知乎爬虫采集实践:从大模型解析到多层防护方案 本文记录了使用大模型自动解析知乎问答页面的尝试过程及优化方案。最初设想通过Playwright获取页面HTML后直接由大模型提取结构化数据,但在实践中遭遇了三大核心问题:异步加载导致数据缺失、页面结构频繁变动引发解析失效、以及反爬机制触发验证码阻拦。通过分析失败案例,作者提出了三层改进方案:渲染层确保DOM稳定加载,适配器层结合模型解析与静态选择器兜底
在写爬虫的工作中,总会遇到那些“看起来简单,做起来崩溃”的任务。知乎采集就是这样一个典型的案例。本来想借助大模型,把网页结构交给它自动理解,然后直接吐出 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 的方案太脆弱。它确实能帮忙,但还得配合一整套兜底机制,才能在真实场景里跑得下去。
问题复盘
回头看,那些故障其实都能归结为几个根源:
- 渲染时机不对
评论和回答是异步注入的,如果没等 DOM 稳定就丢给模型,必然缺失数据。 - 解析方式过于依赖 DOM 表象
模型“看”到的页面结构变化太快,今天能用,明天就失效。 - 反爬策略干扰
知乎的风控相对严格,频繁访问触发验证码几乎是必然。 - 缺乏兜底与报警
出了问题只能人工排查,没有任何回退机制,效率极低。
改进思路:三层防护
为了让方案能撑住长期运行,我们重新梳理了架构,补上了几道保险。
- 渲染层
- 用 Playwright 把页面完整加载出来。
- 设定等待规则,不只是
networkidle
,还要等到回答和评论数量稳定才开始解析。 - 尽量复用 Cookie 会话,降低频繁触发验证码的概率。
- 适配器层
- 给模型设计固定的 Prompt 模板,明确要哪些字段。
- 加上备用的 CSS/XPath 选择器,当模型失灵时用来兜底。
- 定期检查解析结果,把表现稳定的 Prompt 固化下来,逐步形成“适配器库”。
- 回退与监控
- 如果模型和备用规则都失效,就保存 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 就像一个聪明的助手,但它不是万能的。真正的稳定性,还是要靠架构设计和防线层层叠加来保证。
更多推荐
所有评论(0)