如果你想了解某个 B2B 竞品本季度在哪些国家投广告、投放持续了多久、量级大致如何,以及它在切分哪些受众,这些信息通常都能在其公开广告资料库中找到。许多平台出于广告透明度合规要求,必须公开这类数据。问题是,打开 DevTools 翻找半天后,你往往看不到干净的 JSON 接口,只有一整页服务端渲染出来的 HTML。于是你写了解析器,数据也顺利拿到了;三周后平台上线改版,解析器没有报错,却一个字段也提不出来,悄无声息地返回一堆空值。要不是及时发现,你差点就拿着空数据做决策。
这篇文章讨论的,是怎样让这类解析器在页面改版后继续可维护,以及模型究竟能在维护环节做什么。先说明数据边界:文中所有数据均来自各平台公开的广告资料库和创意中心,通过自己的账户正常登录访问;不使用签名,不绕过限制,也不调用非公开接口。后面会看到,这条边界被明确写进了解析器的设计中。
B2B 广告情报为什么往往只能解析 HTML
广告资料库对外暴露数据的方式,大致分成三类:部分面向消费者的平台,例如 Meta 广告资料库,提供结构化搜索和 JSON;另一类即使搜索也需要会话状态;而绝大多数 B2B 平台的广告资料库,则纯粹是服务端渲染的 HTML,根本没有 JSON 端点。根本原因在于,这些广告资料库是合规产物,不是面向开发者的产品 API。
它们的存在目的,是满足广告透明度监管要求,而不是供开发者调用。因此不会有版本号、更新日志,更不会承诺向后兼容。页面是给人看的,服务端生成 HTML 返回给浏览器,而你能拿到的唯一“API”,就是这个网页本身。
这意味着脆弱性无法避免:你依赖的是别人 UI 的实现细节,对方随时可以改,也没有义务通知你。JSON API 改字段,至少还算一次明确的“变更”;HTML 页面重构在对方看来,只是普通的前端迭代。既然躲不开,就应当把解析器写成即使出问题也能清晰退化、改版后也容易修复的形式,而不是静默返回空字段。
手写流式解析器,省下的不是代码而是认知负担
很多人的第一反应,是用 lxml 或 BeautifulSoup 把整页构造成 DOM 树,再一路 .find() 往下找。这样当然能做,但对这类目标并不合适。DOM 树是浏览器渲染页面时产生的中间结构——MDN 对 DOM 的定义也是将文档解析为可按结构访问的节点树——而你的目标只是抽取几个字段。既不需要整棵树,也不该把自己绑死在它的层级结构上。
我最后采用的方案,是继承 Python 标准库 HTMLParser,写一个略多于八百行的解析器,准确说是 881 行。它完全流式运行:通过少量 starttag / data / endtag 回调驱动状态机,一边扫描一边累积数据;遇到卡片边界时就输出一条记录、清空状态并继续,始终不会构建完整 DOM 树。
流式处理节省的成本很具体。首先是内存:一个详情页的 HTML 很容易达到几十到数百 KB,DOM 树会把整页结构都留在内存中;流式解析只保留“当前扫描到哪里、当前卡片解析到什么程度”这些状态。更重要的是认知负担。一旦你写出 .find('div').find('div')[2],解析逻辑就绑定到了 DOM 层级位置;而页面改版最爱动的恰恰是层级——多套一层容器、拆出一个包装节点,每个位置都变了。状态机则迫使你只关注一件事:当前扫描到的内容,在语义上是不是卡片开始、曝光量数字或定向标签。位置会变,语义通常不会。
让解析器扛住改版的三条设计原则
归根结底,就是三条原则,而且每一条都来自一次真实的页面改版。
第一,锚定语义,不锚定位置。状态机应依赖语义信号推进:字段标签文本,例如“总曝光量”“投放日期”这类可读文字;承载角色含义的标记;一个区块的语义边界。不要依赖“从顶部数第三个节点”。判断标准很简单:如果对方移动了这个元素,或者外面又包了一层容器,解析还能不能成立?能成立,才是有效锚点。位置锚点第一次改版就会碎,语义锚点通常能扛住纯样式层面的调整。
第二,字段缺失时降级,不要抛异常。每张卡片开始累积前,都先从一个字段齐全的模板初始化:文本字段默认空字符串,数值字段默认 None,列表默认空数组。能解析到什么就填什么,解析不到就保留为空。任何单一字段提取失败,都不该毁掉整张卡片,更不该让整页解析失败。一条广告缺了 CTA 文案,你仍然需要它的曝光量和目标国家。让一个不重要字段的缺失毁掉页面本可提供的情报,是最差的设计。
第三,为结果标记完整性,别让“确实为空”和“解析坏了”看起来一样。这一点最容易被忽略,代价也最高。“解析到 0 条广告”有两种完全不同的含义:一种是真的没有广告,例如该广告主本季度没有投放;另一种是页面结构变了,解析器没有匹配到任何锚点。返回结果必须能区分两者。做法是同时附上佐证信息:捕获到的卡片数、页面自身声明的总数以及分页状态。比如“卡片数为 0,但页面元数据表明应当存在一批结果,而且没有下一页标记”这一组合,就应判定为结构变更,而不是确实为空;此时应抛出清晰错误,而不是平静地返回空列表。
前面提到的数据边界,也是在这一层落地。解析器会检查是否被跳转到了登录页;一旦发现页面标题是登录或注册页面,就会直接报错,不再继续解析。它只处理你用自己账户正常可见的公开页面,遇到登录墙就停止,绝不尝试穿透。
真正有价值的是可筛选的情报维度
说到这里,你可能会以为重点是把每条广告的所有字段都干净提取出来。其实不是。单条广告字段本身价值有限,真正有价值的是你可以按哪些维度切分这些广告。广告资料库的搜索筛选项,本身就是现成的情报维度;把它们组织成可编程的查询参数,得到的就不再是“某一条广告”,而是“某个竞品的一段投放切片”:
国家:它在哪些市场投放,又避开了哪些市场。B2B 公司突然开始在某个国家投广告,往往比官网更早暴露其市场扩张动作。
投放周期(开始和结束日期):一套创意持续跑了多久。长时间运行的广告是最强信号,因为没人会持续为不转化的创意付费。投放时长本身,就是对方用真金白银替你完成的一次 A/B 测试结果。
曝光区间(最小值/最大值):粗粒度的花费代理指标。绝对数值并不精确,但足以用来判断哪些创意是重点投放。
定向维度:纳入或排除哪些定向条件。这是最直接的受众情报:对方认为哪些人会购买它的产品。
字段提取只是手段,维度才是终点。写解析器时应该反过来思考:为了支持按这些维度查询和排序,我最少需要稳定提取哪些字段?其余那些看似花哨的字段,即便不解析,也不会损失核心情报价值。
页面改版后,让模型比对新旧 HTML 给出修复建议
这类解析器一定会在改版时失效,而模型真正适合介入的环节就在修复阶段。注意,它不该参与解析本身。解析是确定性工作,应由硬编码状态机完成,不该往里面塞模型调用(不要把确定性工作交给模型,这和逆向工程场景中的原则一致)。模型的职责是维护。
具体流程是:用自己的账户打开广告资料库,保留改版前存下来的 HTML,以及改版后的新 HTML;将两份页面、当前解析器提取的字段清单一起交给模型,让它基于新旧 diff 判断哪些字段的语义锚点发生变化、新锚点应当落在哪里,以及最小修改应涉及哪几行。这是典型的推理层任务:模型需要找出新结构中对应的语义是否仍然存在,并给出可实施的修复方案,而不是重复一句“页面结构调整了”。不同步骤对模型的要求不同,所有任务强行使用同一层模型,不是浪费成本,就是牺牲准确度:
步骤 | 所需能力 | 推荐选择 | model id |
|---|---|---|---|
读取完整 SSR HTML,对齐新旧页面结构 | 长上下文能力,能一次容纳几十到数百 KB 的详情页 | Kimi K3 |
|
改版后阅读新旧 diff,判断锚点漂移位置并给出最小修复 | 强推理能力,能够基于结构解释,而不是复述现象 | Claude Opus 5 |
|
批量将数百个广告主的卡片归一化、标注为情报 | 成本低,适合数百到数千次高并发调用 | Claude Sonnet 5 |
|
fixture 对比失败时进行差异归因 | 中等推理能力,能够围绕“预期字段与实际提取结果”解释问题 | GPT-5.6 Sol |
|
第二层是核心,也是切换模型后最容易看出结果差异的一步。值不值得用推理层,不必听我说,自己测一次即可,流程很短:
保存一份改版前的页面 HTML 和一份改版后的页面 HTML(用自己的账户打开广告资料库后保存页面)。
将两份 HTML 和当前解析器的字段清单,同时交给
claude-opus-5和gpt-5.6-sol。只看一件事:它是否能指出具体的语义锚点变化,例如“旧版依赖‘总曝光量’标签作为锚点,新版中该标签所在容器的角色变了,应改为锚定 X”;还是只会泛泛地说“结构调整了,建议重新适配”。
前者可以直接落地,后者几乎没有信息价值。这就是选择标准,也直接决定改版当天你要经历多少轮盲目试错。
跑一轮就能看出差异,比任何基准测试都更直接。
真正麻烦的是模型切换成本
这四个层级来自三家供应商,对应三套 SDK、三种认证方案和三种错误格式。为了不同步骤接入三个客户端,大多数人算完账都会觉得不值得:毕竟只是“偶尔修一次解析器,再加上批量清洗数据”。最后往往全流程只用一个模型,页面改版修复时拿到一句“建议重新适配”,白白耗时间,还不知道问题卡在哪。
AIReiter 把这一层复杂度压平了:一个 key、一个兼容 OpenAI 的接口,四个层级都在后面;切换模型时只需要改请求体里的 model 字段。
# 改版修复:使用推理层
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<新旧 HTML diff + 当前字段清单,询问锚点漂移的位置>"}]
}'
# 批量归一化情报:只改 model 字段,其余不变
# "model": "claude-sonnet-5"
# 用长上下文读取整页:"model": "kimi-k3"
# 差异归因: "model": "gpt-5.6-sol"
如果你已经在使用 OpenAI SDK,把 base_url 指向 https://aireiter.com/api/v1,其他都不用改。使用 Anthropic SDK 时,则用同一个 key 请求 POST /api/v1/messages。
价格方面,Claude 模型为官方定价的七折,GPT 模型为五折,Kimi K3 也能使用同一个 key 调用。对这套流程来说,折扣正好覆盖主要成本:最贵的不是偶尔发生、低频但高价值的改版修复,而是批量归一化广告情报。你可能跟踪二十个竞品广告主,每家都有几十到数百张卡片;把它们全部送给模型,提取卖点、受众和投放周期,才是调用最密集的环节,并且会运行在七折的 Claude Sonnet 上。其次是读取整页并对齐结构所需的长上下文输入。成本最高的两个部分,恰好都落在折扣上。
免注册试用:手动把一组改版前后的 HTML 交给两个模型,比较谁能真正指出锚点漂移的位置,再决定是否接入。
修复完成、原始卡片也完成批量归一化后,下一步就是将这些情报送入创意流程,用于决策和素材生成。这正是从关键词到成品广告的完整闭环所负责的部分;而本文讨论的,是这条链路最前端、可靠拉取公开数据的一段。
字段是否正确,最终由 fixture 裁决
模型给出的修复方案,在验证之前都只是建议。它说“锚点应该改成 X”,听起来很合理,但没人能保证 X 在所有卡片类型上都成立。B2B 广告可能是图文、纯文本、轮播,也可能带落地页或不带落地页;模型看到的两个样本未必覆盖全部情况。
真正能阻止问题的,是和逆向工程中防止模型幻觉相同的方法:固定向量对比。把一批已知输入——几份保存下来的真实页面 HTML——及其已知正确的输出——你手工核验过一次的字段结果——作为 fixture 提交到仓库。每次修改解析器后,无论是自己改的还是按模型建议改的,都重新跑一遍 fixture,并逐字段比较。这是唯一能让你在上游页面改版后迅速判断“是我应用建议时改错了,还是页面又变了”的方法:全部通过,说明修改正确;只有少数失败,失败案例中的字段会直接告诉你问题出在哪一层。模型负责生成修复建议,fixture 负责裁定建议是否正确,两者不能混为一谈。完整的差分对比方法见差分测试文章,广告资料库解析器用的是同一道闸门。缺少这一层,你等于把模型的自信当成正确性,很容易出现“应用建议、没有报错、直接上线,三天后才发现某个国家的数据一直是空的”这种情况。
结语
B2B 广告情报之所以往往只能从 HTML 中解析,是因为广告资料库是合规产物,不是产品 API;它没有 API 契约,也可能随时改版。抵抗改版依赖三项设计:依赖语义而非位置作为锚点;字段缺失时降级而非抛错;为结果附加完整性标记,让“确实为空”和“解析损坏”能够区分。真正有价值的也不是单条广告字段,而是国家、投放周期、曝光区间和定向维度这些能够切分竞品投放的维度。模型的位置很明确:不参与解析——那是确定性状态机的工作——而是参与维护;页面改版后,比对新旧 HTML 并给出修复建议是推理层最有价值的用武之地,而批量将卡片转化为情报则适合低成本、高并发层。但字段是否正确,最终永远由 fixture 决定。把这些层级接到统一接口后,剩下的摩擦就只是一处 model 字段的切换,而这可以通过选择你的模型解决。