用 Cloudflare Workers 做边缘 SEO
在 CDN 边缘修复并优化 SEO —— 注入标签、批量重定向、修补改不动的 CMS。
边缘 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。