从 0 搭一个 SERP API:Google 搜索结果结构化到底有哪些坑

📅 2026/7/19 6:24:21
从 0 搭一个 SERP API:Google 搜索结果结构化到底有哪些坑
前阵子花了大概三周时间从零写了一个 Google SERP 的解析层。一开始觉得这事不难——不就是抓 HTML 写解析器嘛CSS selector 一把梭。结果一脚踩进去坑连着坑。这篇记一下给后面想做类似事的人一个参考。下面以 SerpBase 的接口为例说明。它是我后来用着比较顺的一个实现但里面的设计——POST JSON 进来、结构化 JSON 出去、错误码表、字段归一化——算是这一类工具的通用形态。一、先把页面抓下来没你想的那么简单最先遇到的问题就是抓不到。验证码连续搜十几条Google 就会跳 reCAPTCHA。住宅 IP 池又贵又脆封得比换得快。A/B 实验同一个 UA 在不同 IP、不同时间段拿到的 DOM 居然不一样。今天.g明天div.xYselector 写死的全挂。移动端和桌面端的 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_storyknowledge_graph 右侧又塞一个 PAA。解析器必须按模块边界切不能简单按节点遍历否则顺序全乱。我的做法是先把整个 HTML 解析成一棵带类型标签的节点树再按模块 id分桶每个桶内部用各自的子解析器。这样新增模块的时候只要加一个桶老逻辑不用动。三、字段归一化同一件事 Google 给三种名字这是最容易被低估的工程量。用户期望的字段Google 实际给的备注rankrank / position不同模块叫法不同urllink / url / source_urllink 是跳转包装url 才是真实目标datedate / published_at / time新闻和视频时间格式还不一样thumbnailimage / thumbnail / thumbnail_url有时候只是 favicon定一个对外的字段表解析层内部全部归一化到这套字段。SerpBase 的做法类似对外暴露的字段里position是rank的别名url优先取真实目标 URLpublished_at是归一化后的时间。这样调用方拿到的是稳定 schema不会因为 Google 改版而要改业务代码。举个真实例子下面是一段 organic 结果的原始 HTML解析层要同时处理这几种情况divclassgahref/url?qhttps://example.com/foosaUh3Example Title/h3/adivclassVwiC3bsnippet text 1 hour ago/div/divhref是 Google 包装过的跳转链接要从?q后面解出真实 URLsnippet里1 hour ago是相对时间存进数据库之前要转成绝对时间戳。h3里的amp;也要还原成。这一套脏活每一行都得自己写。四、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 SUCCESS1000 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、middot;、零宽字符统一 strip 掉。完整 organic 列表可能有 10 条连同 PAA 一起塞进 prompt 容易撑爆只取 top 5-8 条。模型对中段信息的利用度本来就不高截短了反而更聚焦。URL 带utm_source/gclid这些 tracking 参数的提前剥掉让 LLM 输出的引用 URL 更干净也方便下游做缓存。多轮对话里每次搜索结果都拼进上下文几轮之后 token 就炸。常见做法是只保留最近两轮的完整结果老轮次只留 query 和一句话总结。一个最小清洗流程大概长这样importredefclean_snippet(s:str,max_len:int280)-str:ifnots:returnsre.sub(r\s, ,s).strip()ss.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 里经常混着amp;#39;quot;这些喂给 LLM 之前要么全部解掉要么全部转义混着来 LLM 会懵。零宽字符U200B、UFEFF 这种会偷偷混进 title做 diff 和去重时记得 strip 一下。AI Overview2024 年起 Google 在搜索结果顶部开始塞 AI 生成的总结块DOM 结构跟传统 SERP 完全不同又是一套解析逻辑。设备指纹mobile 端的 SERP 里 organic 只有 4-5 条PC 端 10 条。统计或监控类应用传错 device看到的数据完全不一样。locale同样的关键词en-US 和 zh-CN 拿到的结果差异巨大。工具类一定要支持hlgl否则只能搜到英文世界。九、LBS 类请求别忘了 lat/lng给 Agent 做附近有什么这类查询时光传q是不够的。比如上海外滩附近的咖啡店如果只传q: 咖啡店加gl: cnGoogle 只能根据 IP 猜位置结果可能跑到徐汇去了。正确做法是同时传lat/lng/zoom强制按坐标搜。zoom 14 大概是街区级13 是商圈10 是城市。三个参数绑在一起缺一个另外两个没意义。十、最后从 0 写一个 SERP API工程量比想象大得多。HTML 解析、字段归一化、错误码、地区语言、设备切换每一项单拎出来都不算难叠在一起就是另一个量级。我后来在生产里直接用了 SerpBase 这类成品服务省掉自己维护反爬和解析的活。它的 文档把字段和错误码都写得很清楚调试的时候省了不少事。如果你也在做类似的事建议先认真评估一下自建 vs 用现成的的成本——光反爬对抗一项就够你喝一壶