前阵子花了大概三周时间,从零写了一个 Google SERP 的解析层。一开始觉得这事不难——不就是抓 HTML + 写解析器嘛,CSS selector 一把梭。结果一脚踩进去,坑连着坑。这篇记一下,给后面想做类似事的人一个参考。
下面以 SerpBase 的接口为例说明。它是我后来用着比较顺的一个实现,但里面的设计——POST JSON 进来、结构化 JSON 出去、错误码表、字段归一化——算是这一类工具的通用形态。
一、先把页面抓下来,没你想的那么简单
最先遇到的问题就是抓不到。
- 验证码:连续搜十几条,Google 就会跳 reCAPTCHA。住宅 IP 池又贵又脆,封得比换得快。
- A/B 实验:同一个 UA 在不同 IP、不同时间段拿到的 DOM 居然不一样。今天
.g明天div.xY,selector 写死的全挂。 - 移动端和桌面端的 SERP 长得完全不一样,光 m-dot 和 www 之间的差异就够你写两个解析器。
最稳的方案其实是用真实浏览器跑,但 headless 又会被指纹检测识别,配 stealth 插件也只多撑几天。绕了一大圈之后你就会发现,自建这条路在反爬对抗上的投入是个无底洞。
二、HTML 解析才是真坑
拿到 HTML 之后才是噩梦的开始。
Google 的 SERP 不是文档,是组件树。一个页面里同时塞了 organic、top_stories、people_also_ask、knowledge_graph、related_searches、ai_overview、image pack、video carousel……每个模块的 DOM 都不一样。
更恶心的是模块之间会互相穿插。organic 列表中间会插一条 top_story,knowledge_graph 右侧又塞一个 PAA。解析器必须按"模块边界"切,不能简单按节点遍历,否则顺序全乱。
我的做法是先把整个 HTML 解析成一棵带类型标签的节点树,再按"模块 id"分桶,每个桶内部用各自的子解析器。这样新增模块的时候只要加一个桶,老逻辑不用动。
三、字段归一化:同一件事 Google 给三种名字
这是最容易被低估的工程量。
| 用户期望的字段 | Google 实际给的 | 备注 |
|---|---|---|
| rank | rank / position | 不同模块叫法不同 |
| url | link / url / source_url | link 是跳转包装,url 才是真实目标 |
| date | date / published_at / time | 新闻和视频时间格式还不一样 |
| thumbnail | image / thumbnail / thumbnail_url | 有时候只是 favicon |
定一个对外的字段表,解析层内部全部归一化到这套字段。SerpBase 的做法类似,对外暴露的字段里position是rank的别名,url优先取真实目标 URL,published_at是归一化后的时间。这样调用方拿到的是稳定 schema,不会因为 Google 改版而要改业务代码。
举个真实例子,下面是一段 organic 结果的原始 HTML,解析层要同时处理这几种情况:
<divclass="g"><ahref="/url?q=https://example.com/foo&sa=U"><h3>Example Title</h3></a><divclass="VwiC3b">snippet text 1 hour ago</div></div>href是 Google 包装过的跳转链接,要从?q=后面解出真实 URL;snippet里"1 hour ago"是相对时间,存进数据库之前要转成绝对时间戳。h3里的&也要还原成&。这一套脏活每一行都得自己写。
四、Maps 的两阶段设计
Google Maps 的数据比 search 复杂得多,单次请求拿不全。所以业内主流做法都是两步:
1. /maps/search → 拿一堆 places,每个有 feature_id 2. /maps/detail → 用 feature_id 拿单个 place 的完整信息feature_id是一串0x...:0x...的东西,类似数据库主键。第一步只给名称、坐标、评分这种轻量信息,第二步才给营业时间、照片、评论摘要、attributes 这些重数据。
为什么要拆?search 结果要的就是列表,列表里塞 30 张照片会把响应撑到几百 KB。detail 是点开单个才需要的,懒加载才合理。
参数上要注意:传lat/lng时zoom才有意义,范围 1-21,不传时默认 14。三个参数要么都传要么都不传,单独传zoom没用。
五、错误码设计
把内部错误拍平到一份对外的码表,是 SERP API "产品工程"里最容易被忽略的一块。
我最初直接把上游状态码透出去,结果调用方要面对 4xx / 5xx / 网络错误 / HTML 解析失败四种来源,写重试逻辑写得很痛苦。
最后定的码表大概是这样:
- 0 SUCCESS
- 1000 INVALID_REQUEST — 参数错
- 1001 UNAUTHORIZED — Key 失效
- 1004 NOT_FOUND — 资源不存在
- 1020 INSUFFICIENT_CREDITS — 余额不足
- 1029 RATE_LIMITED — QPS 触顶
- 1500 / 1502 / 1503 / 1504 — 服务端问题,全部可重试
关键是分清哪些是调用方的错(1000/1001/1004/1020),哪些是服务方的错(1500 系列 + 1029)。前者不该重试,后者可以指数退避带抖动重试 3-4 次。每次重试的request_id一定要打到日志里,排查时直接给客服。
六、喂给 LLM 之前还要过一遍
如果你的 SERP API 是给 LLM / Agent 用的,结构化 JSON 出来还没完,还要再过一道。
- snippet 里经常混着 emoji、
·、零宽字符,统一 strip 掉。 - 完整 organic 列表可能有 10 条,连同 PAA 一起塞进 prompt 容易撑爆,只取 top 5-8 条。模型对中段信息的利用度本来就不高,截短了反而更聚焦。
- URL 带
utm_source/gclid这些 tracking 参数的,提前剥掉,让 LLM 输出的引用 URL 更干净,也方便下游做缓存。 - 多轮对话里,每次搜索结果都拼进上下文,几轮之后 token 就炸。常见做法是只保留最近两轮的完整结果,老轮次只留 query 和一句话总结。
一个最小清洗流程大概长这样:
importredefclean_snippet(s:str,max_len:int=280)->str:ifnots:return""s=re.sub(r"\s+"," ",s).strip()s=s.replace("\u00b7","·").replace("\u2022","·")returns[:max_len]snippet 一般 60-200 字符,强截断到 280 不会丢信息,反而能逼 LLM 抓重点。
七、缓存也别忘了
自建的时候很容易忽略这一块。同一组(q, hl, gl, device, page)的结果短时间内基本不变,Redis 加 5-10 分钟 TTL就能砍掉 30%-50% 的重复请求。监控类场景里尤其有用——同一个关键词 1 分钟查 10 次和 10 分钟查 10 次对结果几乎没有影响,但前者白白烧 9 次额度。
如果结果里包含ai_overview或者新闻模块的 recent 列表,缓存时间要相应缩短,这两类数据时效性最强。
八、其他几个让人抓狂的细节
HTML 实体编码:snippet 里经常混着&'"这些,喂给 LLM 之前要么全部解掉要么全部转义,混着来 LLM 会懵。
零宽字符:U+200B、U+FEFF 这种会偷偷混进 title,做 diff 和去重时记得 strip 一下。
AI Overview:2024 年起 Google 在搜索结果顶部开始塞 AI 生成的总结块,DOM 结构跟传统 SERP 完全不同,又是一套解析逻辑。
设备指纹:mobile 端的 SERP 里 organic 只有 4-5 条,PC 端 10 条。统计或监控类应用传错 device,看到的数据完全不一样。
locale:同样的关键词,en-US 和 zh-CN 拿到的结果差异巨大。工具类一定要支持hl+gl,否则只能搜到英文世界。
九、LBS 类请求:别忘了 lat/lng
给 Agent 做"附近有什么"这类查询时,光传q是不够的。
比如"上海外滩附近的咖啡店",如果只传"q": "咖啡店"加gl: "cn",Google 只能根据 IP 猜位置,结果可能跑到徐汇去了。
正确做法是同时传lat/lng/zoom,强制按坐标搜。zoom 14 大概是街区级,13 是商圈,10 是城市。三个参数绑在一起,缺一个另外两个没意义。
十、最后
从 0 写一个 SERP API,工程量比想象大得多。HTML 解析、字段归一化、错误码、地区语言、设备切换,每一项单拎出来都不算难,叠在一起就是另一个量级。
我后来在生产里直接用了 SerpBase 这类成品服务,省掉自己维护反爬和解析的活。它的 文档把字段和错误码都写得很清楚,调试的时候省了不少事。
如果你也在做类似的事,建议先认真评估一下"自建 vs 用现成的"的成本——光反爬对抗一项就够你喝一壶