news 2026/7/19 6:24:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从 0 搭一个 SERP API:Google 搜索结果结构化到底有哪些坑

前阵子花了大概三周时间,从零写了一个 Google SERP 的解析层。一开始觉得这事不难——不就是抓 HTML + 写解析器嘛,CSS selector 一把梭。结果一脚踩进去,坑连着坑。这篇记一下,给后面想做类似事的人一个参考。

下面以 SerpBase 的接口为例说明。它是我后来用着比较顺的一个实现,但里面的设计——POST JSON 进来、结构化 JSON 出去、错误码表、字段归一化——算是这一类工具的通用形态。

一、先把页面抓下来,没你想的那么简单

最先遇到的问题就是抓不到。

  1. 验证码:连续搜十几条,Google 就会跳 reCAPTCHA。住宅 IP 池又贵又脆,封得比换得快。
  2. A/B 实验:同一个 UA 在不同 IP、不同时间段拿到的 DOM 居然不一样。今天.g明天div.xY,selector 写死的全挂。
  3. 移动端和桌面端的 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 实际给的备注
rankrank / position不同模块叫法不同
urllink / url / source_urllink 是跳转包装,url 才是真实目标
datedate / published_at / time新闻和视频时间格式还不一样
thumbnailimage / thumbnail / thumbnail_url有时候只是 favicon

定一个对外的字段表,解析层内部全部归一化到这套字段。SerpBase 的做法类似,对外暴露的字段里positionrank的别名,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里的&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/lngzoom才有意义,范围 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 出来还没完,还要再过一道。

  1. snippet 里经常混着 emoji、&middot;、零宽字符,统一 strip 掉。
  2. 完整 organic 列表可能有 10 条,连同 PAA 一起塞进 prompt 容易撑爆,只取 top 5-8 条。模型对中段信息的利用度本来就不高,截短了反而更聚焦。
  3. URL 带utm_source/gclid这些 tracking 参数的,提前剥掉,让 LLM 输出的引用 URL 更干净,也方便下游做缓存。
  4. 多轮对话里,每次搜索结果都拼进上下文,几轮之后 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 里经常混着&amp;&#39;&quot;这些,喂给 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 用现成的"的成本——光反爬对抗一项就够你喝一壶

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/19 6:23:51

MoveIt!实战入门:面向真实机械臂的运动规划配置指南

1. 这不是“又一个ROS教程”&#xff0c;而是一份给真实机器人开发者的MoveIt!启动地图你打开ROS Wiki&#xff0c;点开MoveIt!文档&#xff0c;页面上密密麻麻的“Configuration Assistant”、“Planning Scene Monitor”、“Motion Planning API”……再往下翻&#xff0c;是…

作者头像 李华
网站建设 2026/7/19 6:23:43

基于Unity ML-Agents与MA-POCA的多智能体足球强化学习实战

1. 项目概述&#xff1a;当AI学会踢足球如果你玩过或了解过《FIFA》或《实况足球》这类游戏&#xff0c;可能会对其中球员的AI行为印象深刻——他们会跑位、传球、协防。但你想过没有&#xff0c;如果这些球员不是由预设的脚本控制&#xff0c;而是由一个个独立的“大脑”通过学…

作者头像 李华
网站建设 2026/7/19 6:23:14

PX4编译系统解析:从CMake到固件生成的完整流程

1. 项目概述&#xff1a;PX4编译系统的核心机制每次在PX4项目目录下输入make px4_fmu-v4_default这样的命令时&#xff0c;背后其实触发了一整套复杂的编译工具链协作过程。这个看似简单的命令实际上完成了从源码到可执行固件的完整转换&#xff0c;涉及CMake配置生成、交叉编译…

作者头像 李华
网站建设 2026/7/19 6:22:50

AM62L Cortex调试配置寄存器:深入理解CSWREG、TAREG与DRWREG

1. 深入理解AM62L Cortex调试配置寄存器的核心价值在嵌入式系统开发&#xff0c;尤其是基于TI AM62L这类复杂多核Sitara™处理器的项目中&#xff0c;调试和底层配置能力往往是决定项目成败的关键。很多开发者习惯于依赖高级的IDE和调试器&#xff0c;但当系统在启动早期崩溃、…

作者头像 李华
网站建设 2026/7/19 6:22:01

Android 10 UI开发:Material Design与暗黑模式实践

1. Android 10.0 UI开发基础认知第一次接触Android 10.0的UI开发时&#xff0c;最让我惊讶的是其完善的Material Design组件库。与早期版本相比&#xff0c;Android 10&#xff08;API 29&#xff09;引入了暗黑模式、手势导航等新特性&#xff0c;这对UI开发提出了更高要求。在…

作者头像 李华
网站建设 2026/7/19 6:20:19

BepInEx ConfigurationManager:Unity Mod可视化配置管理实战指南

1. 项目概述&#xff1a;为什么你的Unity插件需要一个配置管理器&#xff1f;如果你是一个Unity游戏的Mod开发者&#xff0c;尤其是使用BepInEx框架为游戏制作插件&#xff0c;那么你一定遇到过这样的场景&#xff1a;你写了一个功能强大的插件&#xff0c;比如修改了游戏角色的…

作者头像 李华