源码一行不动也能补上结构化数据:Cloudflare Workers 边缘注入 JSON-LD 的实战记录
适用读者:维护外贸独立站、老 CMS 站点的前端或运维工程师,尤其是拿不到源码权限、改一次模板要排三个月期的人。要求你懂 HTTP 响应头和 CDN 缓存的基本行为,Workers 本身可以零基础跟着做。
去年冬天接了个棘手的活。华东一家做五金工具出口的厂,主打棘轮扳手和套筒组套,站点是十年前用自研 PHP CMS 搭的,两千多个 SKU 详情页,一个结构化数据(Structured Data)标记都没有。运营发现,在几个 AI 搜索里问「1/2 英寸棘轮扳手 72 齿 哪个性价比高」,自家页面基本不被引用,被引用的是贸易平台的聚合页。
十年老站的三道锁
问题从来不是「加个 script 标签」这么简单,下面三道锁决定了所有选型。

第一道锁是源码不敢动:PHP 5.6 的自研框架,模板混着业务逻辑,原开发早已离职。第二道锁是发布流程,改模板要走完整回归,两千多个页面没法逐个验证。第三道锁最隐蔽,产品数据锁在 ERP 里,SEO 同学连一份干净 SKU 清单都拿不到。
AI 引擎读不懂的到底是哪部分
他们的页面不缺内容,标题、参数表、包装清单都写得很全,缺的是机器可读的映射:哪段是价格,哪段是品牌,哪段是库存。人眼扫一眼就知道「72-Tooth」是规格,模型做语义匹配时,这段参数表和旁边的广告横幅是混在一起的。
生成式引擎优化(Generative Engine Optimization, GEO)在这个场景里做的一件事,就是把隐式映射变成显式声明。JSON-LD 不改写正文,只是在旁边补一份「这个页面讲的是一个 Product,brand 是 X,price 是 Y」的说明书。AI 搜索抓到它,理解成本会低一个量级。
两条路:改源码还是改链路
先把两条路的代价摊开,我们最后选了边缘注入。
| 维度 | 源码改造(改模板) | 边缘注入(Cloudflare Workers) |
|---|---|---|
| 源码改动 | 每个模板文件都要动 | 零,源站完全无感 |
| 上线周期 | 排期 + 全量回归,按月计 | 一次部署,分钟级生效 |
| 回滚成本 | 重新发版,影响线上 | 版本秒级回退,或按流量比例关掉 |
| 数据来源 | 直接用渲染时的数据库结果 | 需要另建 KV 或外部 API 通道 |
| 主要风险 | 改坏模板导致白屏 | Schema 与页面正文不一致 |
| 长期维护 | 跟着业务代码演进,自然同步 | 需要独立的数据同步任务盯着 |
选边缘注入的关键不是技术,是组织:他们没有能安全改模板的人,但有能管 CDN 的人。代价也明确,多一条数据链路,正文和 Schema 可能脱节,这个风险得在设计阶段压住。
请求链路:注入发生在哪一跳
flowchart LR
A[访客 / AI 爬虫] --> B[Cloudflare 边缘 PoP 节点]
B --> C{边缘缓存是否命中}
C -->|命中| D[取缓存的原始 HTML]
C -->|未命中| E[回源请求 PHP CMS]
E --> F[源站返回无 JSON-LD 的 HTML]
F --> D
D --> G[HTMLRewriter 流式改写]
G --> H[追加 ld+json script 标签]
H --> I[写入边缘缓存]
I --> J[返回带 Schema 的响应]
J --> A
容易被忽略的一点:HTMLRewriter 跑在缓存之后。不管命中还是回源,改写都要执行一遍,注入开销每次请求都要付,这是免费计划 10ms CPU 需要盯紧的地方。
原理剖析:边缘在哪执行,爬虫看到的是什么
边缘计算(Edge Computing)的执行位置
Workers 不跑在某一台服务器的容器里。它在 Cloudflare 全球两百多个 PoP(Point of Presence)节点上,用 V8 isolate 而非容器隔离,启动开销在毫秒量级。请求从访客到源站,中间那一跳就是代码执行的位置。
这个位置有个特殊性质:它是双向的。请求往源站走时你能读请求头,响应往回走时你能改响应体,源站完全无感。
AI 爬虫抓到的 HTML 与源站响应的差异
这一点必须说透,否则你会误判效果。源站输出的是没有 JSON-LD 的 HTML,爬虫拿到的是注入之后的 HTML,两者在字节层面不同,但爬虫只认最终那一份响应。
由此推出一个实用的验证方法:别看浏览器的「查看源代码」,那是浏览器重新请求的结果,可能命中另一层缓存。直接 curl -H "User-Agent: <爬虫 UA>" 打域名,看返回体里有没有 application/ld+json。我吃过一次亏,浏览器里有、爬虫侧没有,原因是 UA 分流漏了某个爬虫的 UA 前缀。
CDN 缓存层级与 Schema 时效
内容分发网络(Content Delivery Network, CDN)的缓存是分层的,任何一层存了旧响应,Schema 就是旧的。这里有个反直觉的结论:Schema 的时效不取决于你更新 KV 的速度,取决于最长那一层缓存的 TTL。
所以缓存不能只考虑性能。调价后 Schema 里的 price 还是旧值,会被判成信息不一致,比不加结构化数据伤害更大。我的做法是把产品页边缘 TTL 压到十分钟,并给 KV 数据带版本号,版本变化时主动清缓存。
HTMLRewriter:只能改,不能凭空插
这是第一个真正卡住我的地方。HTMLRewriter 是流式解析器,handler 里拿到的是现成的 element 对象,可以在上面 setAttribute、append、remove,但没有任何 API 让你「新建一个兄弟节点」。
第一版我想在文档里凭空构造 script,试下来发现只能在已匹配的 head 内部追加子节点——这其实就够了,用 element.append() 把整段 script 以字符串追加进去。HTMLRewriter 传进去的内容默认不转义,会被当作标签解析,这正是我们要的行为,也是注入场景最危险的地方。
下面是主文件骨架,敏感配置换成了占位符。
// index.js —— 边缘注入主逻辑
// 依赖:wrangler ^3.60.0,本地开发用 miniflare(wrangler dev 内置)
// 运行环境:Cloudflare Workers,compatibility_date 2024-09-01 及以上
// 原则:只处理产品详情页的 GET 请求,其余一律原样透传
export default {
async fetch(request, env, ctx) {
// 非 GET 直接放过,表单提交、API 调用不进改写逻辑
if (request.method !== 'GET') {
return fetch(request);
}
const url = new URL(request.url);
// 详情页路径形如 /product/ratchet-wrench-1234.html
// 非详情页不值得花 CPU,直接透传
const sku = matchProductPath(url.pathname);
if (!sku) {
return fetch(request);
}
// 灰度:未命中灰度的请求完全不改写,风险可控
if (!shouldInject(request, env)) {
return fetch(request);
}
// 向源站发起子请求
// 关键:显式把 Accept-Encoding 收敛为 gzip
// Workers 运行时对 gzip 会透明解压,br 不会,HTMLRewriter 会解析失败
const originReq = new Request(request, {
headers: new Headers(request.headers),
cf: { cacheTtl: 600, cacheEverything: true },
});
originReq.headers.set('Accept-Encoding', 'gzip');
const originRes = await fetch(originReq);
// 只处理 200 的 HTML 响应,其余原样返回
const ctype = originRes.headers.get('Content-Type') || '';
if (originRes.status !== 200 || !ctype.includes('text/html')) {
return originRes;
}
// 从 KV 取产品数据;取不到就降级,只注入 Organization 层
const product = await loadProduct(env, sku, ctx);
// 构造要注入的 JSON-LD 文本
const ldJson = buildJsonLd(product, url);
// 用 HTMLRewriter 在 head 闭合前追加 script
const rewriter = new HTMLRewriter()
.on('head', {
element(el) {
// 去重:页面若已有 ld+json(比如运营手动加过)就不再注入
// 用一个标记属性记录本次请求是否已经追加过
if (el.getAttribute('data-ld-injected')) return;
el.setAttribute('data-ld-injected', '1');
// append 传的是原始 HTML 片段,会被解析成真实标签
el.append(ldJson, { html: true });
},
});
// 返回改写后的流式响应,响应头保持源站原样
const res = rewriter.transform(originRes);
// 加一个调试头,方便用 curl 验证是否命中注入
const out = new Response(res.body, res);
out.headers.set('X-LD-Status', product ? 'product' : 'fallback');
return out;
},
};
// 从路径里提取 SKU,返回 null 表示不是详情页
// 这里按实际站点规则写,不要照抄正则
function matchProductPath(pathname) {
const m = /^\/product\/[a-z0-9-]+-(\d{4,8})\.html$/i.exec(pathname);
return m ? m[1] : null;
}
这段代码有两个反直觉的地方。一是 element.append(str, { html: true }),第二个参数决定内容是否被转义,注入标签传 true,纯文本传 false。二是 new Response(res.body, res),HTMLRewriter 返回的响应 headers 是只读的,要改响应头必须包一层。
产品数据从哪来:KV 兜底与过期策略
产品数据放在 Workers KV(Key-Value, KV)里。KV 是最终一致存储,写入后全球节点收敛需要时间,官方口径通常几秒可见,最坏到一分钟。价格这种字段我接受这个延迟,但得用版本号兜住不一致的窗口。
写入侧是定时任务,解析 ERP 的增量导出,按 sku 作 key 写入并维护 schema_version。读取侧逻辑简单,但每层都要有降级。
// product.js —— KV 读取与 JSON-LD 构造
// 依赖:与 index.js 同一个 Worker,KV namespace 绑定名 PRODUCT_KV
// 设计:三级降级,任何一级失败都不会让页面挂掉
// 带内存缓存的读取,避免同一次请求内重复读 KV
// Workers 的 isolate 是长驻的,模块级变量可以当短缓存用
const memCache = new Map();
async function loadProduct(env, sku, ctx) {
// 一级:进程内缓存,60 秒,挡掉重复请求
const cached = memCache.get(sku);
if (cached && Date.now() - cached.at < 60000) {
return cached.data;
}
// 二级:KV 读取,type: 'json' 直接反序列化,省一次 JSON.parse
let data = null;
try {
data = await env.PRODUCT_KV.get(`sku:${sku}`, { type: 'json' });
} catch (e) {
// KV 抖动时不能让请求失败,记日志后走降级
console.error('kv read failed', sku, e.message);
}
// 三级:KV 空值时尝试外部 API,用 waitUntil 不阻塞响应
if (!data) {
ctx.waitUntil(reportMiss(env, sku));
}
// 写回内存缓存,null 也缓存,避免穿透打到 KV
memCache.set(sku, { at: Date.now(), data });
return data;
}
// 构造 JSON-LD 字符串,注意 XSS:所有值必须转义 </script>
// 这是注入场景里最常见的漏洞来源
function buildJsonLd(product, url) {
const obj = product
? {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.title,
sku: product.sku,
brand: { '@type': 'Brand', name: product.brand },
// offers 是 AI 引擎最常抽取的字段,价格与货币必须成对出现
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: product.currency || 'USD',
// 库存状态用 schema.org 枚举值,不要自己造词
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
url: url.href,
},
}
: // 降级档:拿不到产品数据时只声明站点主体
// 有 Organization 也比完全没有结构化数据强
{
'@context': 'https://schema.org',
'@type': 'Organization',
url: url.origin,
};
// 序列化后把 </script> 打断,防止产品名里的恶意串提前闭合标签
const json = JSON.stringify(obj).replace(/<\//g, '<\\/');
return `<script type="application/ld+json">${json}</script>`;
}
// 未命中上报,用于发现 KV 同步缺口
async function reportMiss(env, sku) {
console.warn('product miss', sku);
}
配置文件里最需要注意的是 compatibility_date 和 KV 绑定,写错一个就会部署失败或运行时报 undefined。
# wrangler.toml —— Worker 配置
# 依赖:wrangler ^3.60.0(wrangler 2.x 的部分字段已废弃,注意版本)
# 部署:wrangler deploy --env production
name = "ld-injector"
main = "index.js"
# compatibility_date 决定运行时行为,不要省略
# 改这个日期可能改变默认行为,升级前先看变更日志
compatibility_date = "2024-09-01"
# KV 命名空间,id 在 wrangler kv:namespace create 之后回填
# preview_id 用于 wrangler dev 本地调试
[[kv_namespaces]]
binding = "PRODUCT_KV"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
preview_id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
# 挂载到产品页路径,其他路径不经过 Worker
# 路由越少,CPU 开销和账单越小
routes = [
{ pattern = "example.com/product/*.html", zone_name = "example.com" }
]
[env.production.vars]
# 灰度百分比,0-100 的整数,改这个值不需要重新部署代码
GRAY_PERCENT = "5"
[observability]
# 打开日志,排查注入是否生效全靠它
enabled = true
灰度:先放 5% 的流量出去
结构化数据写错了不会让页面报错,它会静默地让引擎误判,这种错误最难发现,所以必须灰度。我用两个维度切流量:内部 Cookie 强制命中,外加按 IP 与 UA 哈希的稳定分桶。
flowchart TD
A[请求进入 Worker] --> B{Cookie 含内部标记?}
B -->|是| D[注入 JSON-LD]
B -->|否| C{哈希分桶小于灰度百分比?}
C -->|是| D
C -->|否| E[原样透传]
D --> F{KV 有产品数据?}
F -->|有| G[注入 Product Schema]
F -->|无| H[降级注入 Organization]
G --> I[写缓存并返回]
H --> I
E --> I
分桶必须稳定,同一访客每次请求要落在同一个桶,否则他今天看到 Schema、明天看不到,缓存也跟着抖。用 Cookie 加 IP 做哈希输入,别用 Math.random。
// gray.js —— 灰度与缓存策略
// 依赖:env.GRAY_PERCENT 来自 wrangler.toml 的 vars
// 目标:内测全量、外网按百分比,且分桶稳定
// 简单字符串哈希,FNV-1a 变体,够用且稳定
// 稳定性是硬要求:同一输入永远得到同一输出
function hash(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function shouldInject(request, env) {
// 读灰度百分比,配置异常时按 0 处理,宁可不注入
const percent = parseInt(env.GRAY_PERCENT || '0', 10);
if (!percent) return false;
// 内部 Cookie 直接放行,方便自己和外链审核工具验证
const cookie = request.headers.get('Cookie') || '';
if (cookie.includes('ld_preview=1')) return true;
// 分桶输入:IP + UA,缺 IP 时退化成 UA
// cf-connecting-ip 是 Cloudflare 注入的真实访客 IP
const ip = request.headers.get('cf-connecting-ip') || '';
const ua = request.headers.get('User-Agent') || '';
const bucket = hash(ip + '|' + ua) % 100;
// 落在 [0, percent) 区间内的请求进入灰度
return bucket < percent;
}
灰度跑了四周,Rich Results 覆盖率是抽样五百页用官方校验工具跑出来的。
| 阶段 | 灰度比例 | Schema 覆盖率 | 缓存 TTL | 注入失败率 | 平均 CPU 耗时 |
|---|---|---|---|---|---|
| 第 1 周 | 5% | 61% | 600s | 0.9% | 3.2ms |
| 第 2 周 | 20% | 78% | 600s | 0.4% | 2.8ms |
| 第 3 周 | 50% | 94% | 600s | 0.2% | 2.6ms |
| 第 4 周 | 100% | 97% | 300s | 0.1% | 2.5ms |
覆盖率从 61% 爬到 97%,补的不是代码,是数据。第一周大量 SKU 查不到,原因是 ERP 导出的编码和 URL 里的编码有前缀差异。数据对齐占了项目一半工作量,写代码只占两成。
踩过的坑,按痛感排序
第一个坑是压缩编码。上线第一天零星 502,日志里是 HTMLRewriter 的解析异常。排查发现源站对部分 UA 返回 brotli,Workers 拿到的是压缩字节,解不了。修法是在子请求里把 Accept-Encoding 显式改成 gzip,运行时只对 gzip 透明解压。这个坑在本地 wrangler dev 复现不了,因为本地源站不返回 br。
第二个坑是缓存让 Schema 过期。我一开始用 cacheEverything 加默认 TTL,运营改了价格,页面正文变了,Schema 里的 price 还是三天前的。修法两条并行:产品页 TTL 压到十分钟;数据变化时调用 Cache API 主动 purge。靠 TTL 自然过期解决不了时效问题,必须有主动失效通道。
第三个坑是重复注入。运营后来自己手动加了 ld+json,边缘又注入一份,同页出现两个 Product 节点,校验工具报冲突。我用 data-ld-injected 标记做幂等,但这个属性会写进返回给用户的 HTML,第 4 周改成了先扫描再决定注入。
第四个坑最隐蔽:多语言。欧洲站点用同一套模板,价格显示欧元,KV 里 priceCurrency 却统一写 USD。Schema 和正文对不上,比缺 Schema 更糟,改法是让货币跟着站点路径前缀走。
第五个坑是 CPU 时间。免费计划 10ms 看着宽裕,但一次 KV 读取加序列化加流式改写,实测峰值到过 4ms,再叠加外部 API 就很紧张,所以降级逻辑一律放进 waitUntil 异步做。
什么情况下别这么干
站点源码可控、有正常 CI 和回归流程的话,老老实实在模板里输出 JSON-LD。边缘注入是把复杂度从应用内搬到应用外,多一条链路就多一处不一致,只有「改不了源码」成立时才划算。
另一种不适合的情况是页面没有干净数据源。有站点的产品参数全写在图片里,KV 里也填不出东西,那得先做数据治理。GEO 的活,八成在数据,两成在代码。
别指望注入结构化数据能立刻带来流量。上线四周后,AI 搜索里能被引用到的产品页从个位数涨到一百多,但这是建立在正文质量过关的基础上。结构化数据是给引擎的说明书,不是推荐信。
参考与延伸
- Cloudflare Workers 官方文档与运行时 API 说明:https://developers.cloudflare.com/workers/
- HTMLRewriter 的元素选择器与 append/prepend 行为:https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/
- Workers KV 的一致性模型与读写限额:https://developers.cloudflare.com/kv/
- schema.org 的 Product 与 Offer 类型定义:https://schema.org/Product
关键词:GEO, AI 优化 AIO, Cloudflare Workers, HTMLRewriter, JSON-LD 注入, 边缘计算, 外贸独立站结构化数据