源码一行不动也能补上结构化数据:Cloudflare Workers 边缘注入 JSON-LD 的实战记录

2026-09-21 01:35:39 1 次浏览
GEOAI搜索Cloudflare WorkersJSON-LD边缘计算外贸独立站

适用读者:维护外贸独立站、老 CMS 站点的前端或运维工程师,尤其是拿不到源码权限、改一次模板要排三个月期的人。要求你懂 HTTP 响应头和 CDN 缓存的基本行为,Workers 本身可以零基础跟着做。

去年冬天接了个棘手的活。华东一家做五金工具出口的厂,主打棘轮扳手和套筒组套,站点是十年前用自研 PHP CMS 搭的,两千多个 SKU 详情页,一个结构化数据(Structured Data)标记都没有。运营发现,在几个 AI 搜索里问「1/2 英寸棘轮扳手 72 齿 哪个性价比高」,自家页面基本不被引用,被引用的是贸易平台的聚合页。

十年老站的三道锁

问题从来不是「加个 script 标签」这么简单,下面三道锁决定了所有选型。 CDN 边缘节点向网页注入结构化数据

第一道锁是源码不敢动: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 注入, 边缘计算, 外贸独立站结构化数据

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。