用 Cloudflare Workers 做边缘 SEO

在 CDN 边缘修复并优化 SEO —— 注入标签、批量重定向、修补改不动的 CMS。

📖 12 分钟阅读 🕑 更新于 2026-06-22

边缘 SEO 是这样一种做法:在你的源服务器生成页面之后、内容到达客户端之前,改变浏览器或爬虫最终收到的东西。改写发生在 CDN 上——对你而言,就是位于访客与站点之间的一个 Cloudflare Worker。

可以把它想象成一位守在邮局的文字编辑。作者(你的 CMS)已经写好并封好了信。你没法让他们重写——也许他们用的是老旧平台,也许模板归另一个团队管,也许这个改动很紧急。于是编辑在最后一刻拆开信封,修正 canonical 标签里的一处错字,给一个失效 URL 盖上重定向的章,再把信寄出去。收信人完全不知道这封信被动过。

由于本站本就跑在 Cloudflare Pages 上,你距离这项能力只差一个 Worker。你不需要新的供应商、标签管理器,也不需要源站访问权限。你需要的是一个 fetch 处理器,以及 Cloudflare 的流式 HTML 解析器 HTMLRewriter。本指南余下部分会告诉你:什么时候该这么做、有哪些值得掌握的模式、一个完整的实战示例,以及它会以哪些方式悄悄出问题。

何时使用

边缘 SEO 是一把精准的工具,而非默认选项。当正常路径——在源站编辑模板——被堵死或太慢时,才去用它。

场景为什么边缘更胜一筹
老旧或被锁死的 CMS平台负责渲染 <head> 而你动不了模板。无论后端如何,边缘都能改写其输出。
SaaS / 托管平台Shopify、营销建站工具、你无法端到端掌控的 headless 商城。没有服务器可以 SSH 进去——但 Worker 可以挡在前面。
全站一致性你需要在 50,000 个 URL 上使用同一套 canonical 或 hreflang 逻辑。一个 Worker 即可强制统一;你不会指望 50,000 个模板彼此一致。
紧急热修复一次糟糕的发布把 noindex 推上了生产环境。开发排期还得等两周。一个 Worker 修复 60 秒内即可上线,而且可回滚。
A/B SEO 实验在某个版块向 50% 的流量测试一种标题标签写法,无需 fork 模板。

🧑‍💻 开发者视角:当改动是横切的(按规则作用于大量页面)或时间紧迫的(源站发布是瓶颈)时,边缘就是合适的那一层。如果某个改动确实只针对单个页面,而你又掌控着模板,那就在源站做——它本就属于那里,下一位工程师也会去那里找它。

另一面:每一条边缘规则都是一段游离于你仓库正常评审流程之外的逻辑。把它当作生产基础设施来对待,而不是一张便利贴。我们会在「注意事项」中再谈这点。

常见模式

下面这些是主力。每一个都只是寥寥几行 HTMLRewriter——它会流式处理响应,并允许你把处理器挂到 CSS 选择器上——这样你永远不必把整篇文档缓冲进内存。

注入或修复 canonical 与 hreflang

最常见的边缘 SEO 任务。某个 CMS 发出的 canonical 指向了错误的主机(http、暂存域名、尾斜杠不匹配),或者在双语站点上干脆漏掉了 hreflang。

class CanonicalFixer {
  constructor(url) {
    this.canonical = `https://example.com${new URL(url).pathname}`;
    this.found = false;
  }
  element(el) {
    // Rewrite an existing canonical to the correct absolute URL.
    el.setAttribute("href", this.canonical);
    this.found = true;
  }
}

你会再给 <head> 配上一个 end 处理器,以便在标签缺失时注入它——完整写法见下文的实战示例。

批量重定向(301 / 302)

站点迁移会留下成千上万个失效 URL。与其把源站配置或 _redirects 撑爆,不如用一个由映射表(或大规模场景下用 KV)驱动的 Worker 来处理。

const REDIRECTS = {
  "/old-pricing": "/pricing",
  "/blog/2019/seo-tips": "/guides/seo-basics",
};

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const target = REDIRECTS[url.pathname];
    if (target) {
      return Response.redirect(`${url.origin}${target}`, 301);
    }
    return fetch(request); // pass through to origin
  },
};

💡 提示:面对几万条重定向时,把映射表挪进 Workers KV 并按 key 查找。KV 读取在边缘有缓存,所以查找依旧很快,你也省去了每次冷启动都加载一个巨大对象。

修补 meta 标签与结构化数据

补上缺失的 meta description、剥掉一个多余的 noindex,或注入 CMS 产不出来的 JSON-LD。注入结构化数据无非就是往 <head> 追加一段 <script type="application/ld+json">

class JsonLdInjector {
  constructor(data) {
    this.json = JSON.stringify(data);
  }
  element(head) {
    head.append(
      `<script type="application/ld+json">${this.json}</script>`,
      { html: true }
    );
  }
}

SEO A/B 实验

确定性地切分流量(按哈希后的 cookie 或路径分桶),给其中一个桶提供变体的标题/描述,然后在 Search Console 中衡量展示量与点击率(CTR)的差异。对用户保持内容完全一致——你测试的是面向 SERP 的摘要片段,而不是伪装成不同的页面。

按 User-Agent 做爬虫感知处理

识别 Googlebot 或 Bingbot 并据此应用规则——例如提供一份完整渲染的兜底页面,或加上为爬虫调优的缓存头。只有当爬虫与用户收到相同的实质内容时,这才是正当做法。让内容本身产生分歧就是 cloaking(伪装);参见「注意事项」。

const ua = request.headers.get("user-agent") || "";
const isBot = /googlebot|bingbot|duckduckbot/i.test(ua);

实战示例

下面是一个完整、贴近生产形态的 Worker,它为本站这样的双语站点做了一件最有价值的事:它保证每个页面都有正确的 canonical 和一套匹配的 hreflang——标签存在就修,不存在就注入。它是幂等的(运行两次结果不变),其余内容则原样放行。

const SITE = "https://example.com";

// Map a path to its language alternates. Adapt to your routing.
function alternatesFor(pathname) {
  const en = pathname.startsWith("/zh/")
    ? pathname.replace(/^\/zh\//, "/en/")
    : pathname;
  const zh = pathname.startsWith("/en/")
    ? pathname.replace(/^\/en\//, "/zh/")
    : pathname;
  return [
    { lang: "en", href: `${SITE}${en}` },
    { lang: "zh", href: `${SITE}${zh}` },
    { lang: "x-default", href: `${SITE}${en}` },
  ];
}

// Rewrite an existing canonical to the canonical absolute URL.
class CanonicalRewriter {
  constructor(canonical) {
    this.canonical = canonical;
    this.seen = false;
  }
  element(el) {
    el.setAttribute("href", this.canonical);
    this.seen = true;
  }
}

// On </head>, inject anything the page was missing.
class HeadCloser {
  constructor(canonical, alternates, rewriter) {
    this.canonical = canonical;
    this.alternates = alternates;
    this.rewriter = rewriter;
  }
  element(head) {
    if (!this.rewriter.seen) {
      head.append(
        `<link rel="canonical" href="${this.canonical}">`,
        { html: true }
      );
    }
    // Always (re)assert hreflang. We strip old ones first (below),
    // so appending here yields exactly one correct set.
    for (const alt of this.alternates) {
      head.append(
        `<link rel="alternate" hreflang="${alt.lang}" href="${alt.href}">`,
        { html: true }
      );
    }
  }
}

export default {
  async fetch(request) {
    const response = await fetch(request);

    // Only rewrite HTML; leave assets, JSON, redirects untouched.
    const type = response.headers.get("content-type") || "";
    if (!type.includes("text/html")) return response;

    const url = new URL(request.url);
    const canonical = `${SITE}${url.pathname}`;
    const alternates = alternatesFor(url.pathname);
    const rewriter = new CanonicalRewriter(canonical);

    return new HTMLRewriter()
      // Remove any stale hreflang so we don't duplicate them.
      .on('link[rel="alternate"][hreflang]', { element: (el) => el.remove() })
      .on('link[rel="canonical"]', rewriter)
      .on("head", new HeadCloser(canonical, alternates, rewriter))
      .transform(response);
  },
};

让它能放心上线的原因:

  • 它按内容类型限定范围。 非 HTML 的响应原封不动地放行——你绝不会破坏一张图片或一个 JSON API。
  • 它是幂等的。 先剥掉过期的 hreflang 标签,再追加恰好一套正确的。重复运行产生的结果完全相同。
  • 它是流式的。 HTMLRewriter 在响应流动时即对其处理;没有整篇文档的缓冲,几乎不带来延迟成本。
  • 它一次性完成 canonical 的修复或注入CanonicalRewriter 修正已存在的标签,而 HeadCloser 仅在一个都没见到时才补上一个。

用 Wrangler 部署它,并把它路由到你的 zone:

npx wrangler deploy
# Then bind a route in wrangler.toml, e.g.
# routes = [{ pattern = "example.com/*", zone_name = "example.com" }]

🧑‍💻 开发者视角:把 SITE 常量和 alternatesFor 映射集中放在一处,并对这个映射函数写单元测试。语言路由逻辑正是边缘 SEO bug 的藏身之地——一个对 /en/en/ 或首页处理不当的正则,会在全站静默地发出错误的 alternates。

注意事项

边缘 SEO 之所以强大,恰恰因为它隐形——而这也是它反咬你的方式。在把一个 Worker 接入生产流量之前,请把下面这些消化透。

  • 让每一次改写都幂等。 如果源站日后开始发出你正在注入的那个标签,你的 Worker 绝不能产生重复项。上面的模式之所以采取先剥后追,正是为此。重复的 canonical 比没有还糟。
  • 别和源站对着干。 边缘与 CMS 绝不能声明不同的 canonical 或相互冲突的 robots 指令。当未来某次模板改动与一条边缘规则相撞时,Google 会看到自相矛盾的信号,于是你得到不确定的索引结果。把每条边缘规则都记录下来,让源站团队知道它的存在。
  • 留意缓存。 Cloudflare 可能会缓存改写后的 HTML。如果你的改写依赖于请求(User-Agent、cookie、地理位置),就必须相应地区分缓存键,否则你会把同一个变体发给所有人。对于依赖请求的逻辑,要刻意设置 Cache-Control / Vary,或者为这些路由绕过缓存。
  • 盯紧你的延迟与错误预算。 一个在边缘情形下抛出异常的 Worker,可能会给爬虫返回一个空白页面。把有风险的逻辑包进 try/catch,并失败时放行——返回未经改动的原始 response,而不是一个 500。
  • 像对待源站代码一样监控它。 接好 wrangler tail,记录改写次数,并在每次改动后观察 Search Console 的*网页(Pages)删除(Removals)*报告。一个边缘 bug 表现为索引异常,而不是堆栈跟踪。
  • 绝不用它来做 cloaking。 给爬虫提供与用户不同的内容——给 Googlebot 塞满关键词的文本,给真人一个干净的页面——这违反 Google 的垃圾内容政策,并有招致人工处罚的风险。基于 UA 的处理用于内容投递和缓存是可以的;它不是展示两个不同页面的许可证。

⚠️ 注意:边缘 SEO 的头号大忌,是用户所见与爬虫所见之间的静默分歧。用 Search Console 的网址检查工具(「查看抓取的网页」)核实:机器人拿到的渲染后 HTML 与普通浏览器拿到的一致。如果二者在实质上有差异,那你就是在做 cloaking——无论有意还是无意。

它在体系中的位置

边缘 SEO 是你技术栈中更宏大的「构建层」里的一种战术——当模板改不动时,它就是你强制落实技术 SEO 的手段。关于它所依托的根基(渲染、canonical 策略、内部链接),参见构建层。一旦你的 Worker 开始塑形响应,务必让你的抓取指令与之保持一致:用 robots 与 sitemap 工具校验 robots.txt 与 sitemap,好让边缘、源站和你的抓取规则向 Google 讲述同一个故事。

关键要点

  • ✅ 用边缘在源站之后、客户端之前修复 SEO——非常适合被锁死的 CMS、托管平台、全站规则和紧急热修复。
  • HTMLRewriter 以流式处理响应,因此注入 canonical、hreflang、meta 或 JSON-LD 几乎不带来延迟,也没有整篇文档的缓冲。
  • ✅ 让每一次改写都幂等(先剥后追),这样它永远不会重复源站日后可能发出的标签。
  • ✅ 把改写范围限定在 text/html,把逻辑包进 try/catch,并在失败时放行回原始响应。
  • ✅ 把 Workers 当作生产代码:纳入版本管理,记录每条规则以免它与源站相撞,并在每次改动后监控 Search Console。
  • ✅ 绝不让用户与爬虫之间的内容产生分歧——UA 处理用于内容投递,而非 cloaking。