返回博客

静态站点怎么检测访客国家——别用时区猜,Cloudflare 早给你留好后门了

给一个纯静态站加「访客在中国大陆则提示切换镜像」的功能,第一反应往往是用 navigator.language === 'zh-CN' 或者读时区 Intl.DateTimeFormat().resolvedOptions().timeZone === 'Asia/Shanghai'。写起来三行代码,看起来挺合理。

但上线一周后收到反馈:开了 VPN 的用户在国内一直看不到提示;把 OS 语言设成英文的用户同样被漏掉。这两种启发式本质都是「猜用户偏好」,不是「定位客户端 IP」,完全不是同一件事。

现象

  • 静态站(Next.js output: export + Cloudflare Pages)没有 Server,拿不到 CF-IPCountry 请求头
  • navigator.language 判断:设英文系统的国内用户全部漏掉
  • 用时区判断:挂着美国节点 VPN 的用户会被错判为海外
  • 想装个 Cloudflare Worker 来读请求头,为了一个 banner 显得过于重型

根因

Cloudflare 在代理层(橙云模式)做了 IP 归因,这个信息只有边缘节点才有。浏览器端能拿到的 navigator.language、时区、Accept-Language header 都是客户端的偏好设置,和物理位置没有可靠关联。

真正精准的数据藏在 Cloudflare 的内置调试端点里:

GET /cdn-cgi/trace

这个路径在任何 Cloudflare 橙云代理的域名上都存在,由 Cloudflare 边缘托管,与你的站点代码无关。它返回纯文本的 key=value 格式,其中 loc 字段就是边缘节点判定的客户端 ISO 国家码:

fl=123abc
ip=1.2.3.4
ts=1700000000.123
visit_scheme=https
loc=CN          ← 这就是你要的
tls=TLSv1.3
colo=HKG
坑:navigator.language / 时区不等于地理位置

navigator.language === 'zh-CN' 只能告诉你用户的系统语言设置,VPN 用户、英文系统用户、海外华人都会被漏判或误判。时区同理。如果你真正需要的是「访客 IP 来自哪个国家」,这两个方案从根子上就错了。

正解

在浏览器端 fetch('/cdn-cgi/trace'),解析 loc 字段即可。

// utils/cf-trace.ts
export function parseCfLoc(traceText: string): string | null {
  for (const line of traceText.split("\n")) {
    const eq = line.indexOf("=");
    if (eq === -1) continue;
    // 精确匹配 "loc",避免把 "colo=" 也捞进来
    if (line.slice(0, eq).trim() === "loc") {
      return line.slice(eq + 1).trim().toUpperCase() || null;
    }
  }
  return null;
}

在 React 客户端组件里调用:

// "use client"
useEffect(() => {
  // 只在 Cloudflare 主站触发,镜像站没有 /cdn-cgi/trace
  if (location.hostname !== MAIN_HOST) return;
 
  fetch("/cdn-cgi/trace", { cache: "no-store" })
    .then((r) => (r.ok ? r.text() : Promise.reject()))
    .then((t) => {
      if (parseCfLoc(t) === "CN") setShowBanner(true);
    })
    .catch(() => {
      // geo 未知时静默失败,不打扰用户
    });
}, []);

几个实现细节不能省:

  1. 只在主站触发:镜像站(自建 nginx / 阿里云等)没有 /cdn-cgi/trace,fetch 会 404。用 location.hostname === MAIN_HOST 做门卫,保证只在 CF 代理的域名上调用。
  2. Banner 而非自动跳转:加 localStorage 记录「用户已关闭」,避免反复弹出;跳转镜像时保留当前路径 https://${MIRROR}${location.pathname}${location.search}${location.hash}
  3. SSR 安全:组件初始返回 null,effect 触发后才切换显示,不产生 hydration mismatch。
正解:fetch('/cdn-cgi/trace') 拿 loc 字段,零后端、零 Worker

只要站点走了 Cloudflare 橙云代理,这个端点就一定存在。浏览器直接调,拿到的是 Cloudflare 边缘节点基于 IP 数据库判定的国家码,准确度足够「推荐切换镜像」这类场景使用。服务端如果有 Edge Function / Worker,改用 request.cf.countryCF-IPCountry 请求头更简洁。

实测验证

本地开发时 /cdn-cgi/trace 不可用(localhost 没经过 CF 边缘),可以先用 curl 确认部署后能拿到:

curl https://<your-cf-domain>/cdn-cgi/trace

单元测试只需覆盖 parseCfLoc 纯函数:正常 loc=CN → 返回 "CN"loc= 空值 → 返回 nullcolo=HKG → 不被匹配;CRLF 换行容错。

一句话外卖

Cloudflare 在每个橙云代理域名上都内置了 /cdn-cgi/traceloc 字段就是边缘判定的国家码——静态站检测访客地区,先查这个,别猜时区。