这篇文章记录一次真实的线上排查过程:一个 Next.js 热榜聚合项目,本地运行正常,部署到 Cloudflare Workers 后,哔哩哔哩、虎扑、快手、懂车帝等来源加载失败。

这次案例来自我自己维护的热榜聚合站:soso.re。它聚合了微博、知乎、哔哩哔哩、虎扑、快手、V2EX 等多个来源,线上环境部署在 Cloudflare Workers 上。如果你想先看最终效果,可以直接打开 https://soso.re 体验。

Snipaste_2026-07-07_09-31-03-kpnxvahb.png

最终采用的方案是:

  • Cloudflare Workers 继续负责前端和大部分 API。
  • 对容易被限制的热榜源,走一台自己的轻量服务器做固定路径代理。
  • 对已经需要登录验证、页面结构不稳定的源,不继续硬绕,直接换成更稳定的公开源。

本文的配置示例以域名 example.com 代替真实域名,密钥也全部用占位符,请不要直接复制弱密钥到生产环境。

问题现象

项目部署到 Cloudflare Workers 后,部分 API 在线上返回空数据或错误,但本地开发环境正常。

典型表现:

/api/bilibili   失败
/api/hupu       失败
/api/kuaishou   失败
/api/dongchedi  解析为空或需要登录验证

本地请求正常,说明代码解析逻辑未必有问题;线上失败,更多是运行环境、出口 IP、请求头、平台反爬策略导致。

第一步:先加调试日志,不要盲改

最开始不要直接换接口或乱加请求头,先让 Worker 把上游响应状态、内容类型和响应体开头打印出来。

示例调试函数:

const HOT_SOURCE_DEBUG_PREFIX = '[hot-source-debug]';
const BODY_PREVIEW_LENGTH = 300;

export async function readHotSourceText(
  source: string,
  upstreamUrl: string,
  response: Response,
) {
  const body = await response.text();

  console.error(HOT_SOURCE_DEBUG_PREFIX, {
    source,
    upstreamUrl,
    status: response.status,
    ok: response.ok,
    contentType: response.headers.get('content-type'),
    bodyLength: body.length,
    bodyStart: body.slice(0, BODY_PREVIEW_LENGTH),
  });

  return body;
}

然后在 Cloudflare Dashboard 里打开:

Workers 和 Pages -> 你的 Worker -> Observability -> Workers Logs

可以按 [hot-source-debug] 搜索日志。

实际排查结果

线上日志里看到的情况大概是这样:

来源Worker 直连结果说明
哔哩哔哩412 text/html上游拒绝了 Cloudflare Workers 的请求特征
虎扑405 text/html页面请求在 Workers 环境下被拒绝
快手525 或页面结构缺失TLS/反爬/页面结构都可能影响
懂车帝200 text/html 但解析为空页面进入登录或验证态,原解析字段不存在

结论:这不是简单的网络波动,而是部分平台对 Cloudflare Workers 的出口、请求头或请求环境有限制。

方案选择

我最终没有选择把所有数据源都换成第三方聚合 API,因为这样会引入新的不确定性:第三方接口可能限流、变更、失效,甚至无法确认数据来源。

最终方案:

  1. 哔哩哔哩、虎扑、快手:走自己的服务器做固定路径代理。
  2. 懂车帝:由于页面需要验证登录,不继续绕,替换成 V2EX 官方热门主题接口。
  3. Worker 代码里保留原始上游地址作为 fallback,没有代理变量时仍可本地调试。

整体架构

soso.re 为例,用户访问的是 Cloudflare Workers 上的站点;大部分热榜源由 Worker 直接请求,少数容易被限制的来源再转到自己的服务器代理。

浏览器
  |
  v
Cloudflare Workers / Pages
  |
  | 大部分热榜源直接 fetch
  |
  | 对特殊来源:
  v
自有服务器 Nginx / OpenResty
  |
  v
Bilibili / Hupu / Kuaishou 上游

代理服务器只开放固定路径,例如:

/hot/bilibili
/hot/hupu
/hot/kuaishou

不要做成通用代理,也不要把目标 URL 作为参数暴露出去。

Worker 侧代码

项目里增加一个代理选择函数:

type ProxySource = 'bilibili' | 'hupu' | 'kuaishou';

const proxyPaths: Record<ProxySource, string> = {
  bilibili: '/hot/bilibili',
  hupu: '/hot/hupu',
  kuaishou: '/hot/kuaishou',
};

export function getHotProxyRequest(
  source: ProxySource,
  fallbackUrl: string,
  init?: RequestInit,
): { url: string; init?: RequestInit; proxied: boolean } {
  const proxyBase = process.env.HOT_PROXY_BASE?.replace(/\/$/, '');
  const proxyToken = process.env.HOT_PROXY_TOKEN;

  if (!proxyBase || !proxyToken) {
    return { url: fallbackUrl, init, proxied: false };
  }

  const headers = new Headers(init?.headers);
  headers.set('x-proxy-token', proxyToken);

  return {
    url: `${proxyBase}${proxyPaths[source]}`,
    init: {
      ...init,
      headers,
    },
    proxied: true,
  };
}

业务接口里使用:

const upstreamUrl = 'https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=all&callback=__jp0';
const request = getHotProxyRequest('bilibili', upstreamUrl, {
  headers: {
    Referer: 'https://www.bilibili.com/ranking/all',
    'User-Agent':
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
  },
});

const response = await fetch(request.url, request.init);
const text = await readHotSourceText('bilibili', request.url, response);

这样有两个好处:

  • 本地没有配置代理变量时,仍然走原始上游地址。
  • 线上配置了代理变量后,只影响指定数据源。

Cloudflare 变量配置

在 Cloudflare 后台添加运行时变量:

HOT_PROXY_BASE=https://example.com
HOT_PROXY_TOKEN=一段足够长的随机密钥

注意入口:

Workers 和 Pages -> 项目 -> 设置 -> 变量和密钥

这里要选 变量和密钥,不是 构建变量和密钥

原因是:

  • HOT_PROXY_BASEHOT_PROXY_TOKEN 是运行时请求 API 时使用的。
  • 构建变量只在 build 阶段可用,不能保证 Worker 运行时拿得到。

如果你使用 GitHub Actions、Wrangler 或 OpenNext 自动部署,还要注意:部署配置可能覆盖 Dashboard 里手动添加的变量。部署命令需要保留 Dashboard 变量,例如 OpenNext/Wrangler 场景中使用类似 --keep-vars 的参数。

Nginx / OpenResty 代理配置

下面配置放在原网站的同一个 server 块里,并且要放在普通 location / 之前。

先准备一个强随机密钥:

openssl rand -hex 32

假设得到:

REPLACE_WITH_LONG_RANDOM_TOKEN

哔哩哔哩代理

location = /hot/bilibili {
    if ($http_x_proxy_token != "REPLACE_WITH_LONG_RANDOM_TOKEN") {
        return 403;
    }

    proxy_pass_request_headers off;

    proxy_ssl_server_name on;
    proxy_ssl_name api.bilibili.com;

    proxy_set_header Host api.bilibili.com;
    proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
    proxy_set_header Accept "application/json, text/plain, */*";
    proxy_set_header Accept-Language "zh-CN,zh;q=0.9,en;q=0.8";
    proxy_set_header Referer "https://www.bilibili.com/";
    proxy_set_header Connection "";

    proxy_pass https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=all&callback=__jp0;
}

这里最关键的是:

proxy_pass_request_headers off;

它的作用是不要把 Cloudflare Worker 请求 example.com 时带来的原始请求头继续透传给 B 站。否则上游仍然能看到一些异常请求特征,可能继续返回 412

虎扑代理

location = /hot/hupu {
    if ($http_x_proxy_token != "REPLACE_WITH_LONG_RANDOM_TOKEN") {
        return 403;
    }

    proxy_pass_request_headers off;

    proxy_ssl_server_name on;
    proxy_ssl_name bbs.hupu.com;

    proxy_set_header Host bbs.hupu.com;
    proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
    proxy_set_header Accept "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
    proxy_set_header Accept-Language "zh-CN,zh;q=0.9,en;q=0.8";
    proxy_set_header Referer "https://bbs.hupu.com/";
    proxy_set_header Connection "";

    proxy_pass https://bbs.hupu.com/all-gambia;
}

快手代理

location = /hot/kuaishou {
    if ($http_x_proxy_token != "REPLACE_WITH_LONG_RANDOM_TOKEN") {
        return 403;
    }

    proxy_pass_request_headers off;

    proxy_ssl_server_name on;
    proxy_ssl_name www.kuaishou.com;

    proxy_set_header Host www.kuaishou.com;
    proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
    proxy_set_header Accept "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
    proxy_set_header Accept-Language "zh-CN,zh;q=0.9,en;q=0.8";
    proxy_set_header Referer "https://www.kuaishou.com/";
    proxy_set_header Connection "";

    proxy_pass https://www.kuaishou.com/?isHome=1;
}

原网站 location 不要被影响

如果这台服务器原本还跑着网站后台,例如 Halo、WordPress、管理面板等,原来的 location / 一定不要加 proxy_pass_request_headers off

推荐保持类似这样:

location / {
    proxy_pass_request_headers on;

    proxy_set_header Host $host;
    proxy_set_header Cookie $http_cookie;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_http_version 1.1;
    proxy_pass http://127.0.0.1:8090;
}

修改完成后检查并重载:

nginx -t
systemctl reload nginx

测试代理是否正常

不带密钥应该返回 403

curl -I https://example.com/hot/bilibili

带密钥应该返回 200

curl -I \
  -H "x-proxy-token: REPLACE_WITH_LONG_RANDOM_TOKEN" \
  https://example.com/hot/bilibili

再分别测试:

curl -I -H "x-proxy-token: REPLACE_WITH_LONG_RANDOM_TOKEN" https://example.com/hot/hupu
curl -I -H "x-proxy-token: REPLACE_WITH_LONG_RANDOM_TOKEN" https://example.com/hot/kuaishou

如果本机 curl 是 200,但 Worker 日志里仍然是上游错误,就继续看 Worker 调用代理后的日志。

Worker 日志如何判断问题

[hot-source-debug] 里的 upstreamUrl

如果还是原始地址:

https://api.bilibili.com/...

说明 Worker 没有读到代理变量,重点检查 Cloudflare 的运行时变量。

如果已经变成代理地址:

https://example.com/hot/bilibili

说明变量生效了,问题在代理服务器到上游这一段。

常见状态码判断:

状态可能原因处理方式
403代理密钥不匹配,或请求头没带上检查 HOT_PROXY_TOKEN 和 Nginx 里的密钥是否完全一致
404Nginx 没匹配到 /hot/* location检查配置是否放在正确的 server 块里
412上游识别到异常请求特征检查 /hot/* 是否设置了 proxy_pass_request_headers off 和浏览器请求头
405上游拒绝当前请求方式或环境使用固定路径代理,并伪装正常浏览器请求头
525SSL 握手或上游 TLS 问题检查 proxy_ssl_server_name onproxy_ssl_name
200 但解析为空页面结构变了,或进入登录/验证页面打印 bodyStart,必要时换源

懂车帝为什么换成 V2EX

懂车帝这类页面如果进入登录验证态,HTML 仍然可能返回 200,但页面里的业务数据已经没有了。

这时继续堆请求头、Cookie、解析规则,维护成本会很高,而且容易反复失效。

我的处理方式是换成 V2EX 热门主题接口:

https://www.v2ex.com/api/topics/hot.json

接口返回 JSON,结构清晰,不需要解析 HTML,比继续绕验证页稳定。

GitHub Actions 会不会影响仓库代码

单纯用 GitHub Actions 部署,不会自动修改仓库代码。

它通常只是:

拉取代码 -> 安装依赖 -> 构建 -> 部署到 Cloudflare

只有你的 workflow 里明确执行了 git commitgit push 这类命令,才会改仓库。

真正需要注意的是变量:

  • GitHub Actions 里的 Secrets 是给 CI 流程用的。
  • Cloudflare Dashboard 的 Variables and Secrets 是给 Worker 运行时用的。
  • 如果部署工具会同步 Wrangler 配置,可能覆盖 Dashboard 里手动设置的变量,所以要使用保留变量的部署参数,或把变量也纳入部署配置管理。

坑点总结

1. 本地正常不代表 Workers 正常

Cloudflare Workers 的出口、TLS、请求头和普通本机环境不一样。部分站点会对这类请求环境做限制。

2. 先看上游响应,不要只看前端失败

如果只看前端页面,会误以为是组件或缓存问题。实际要看 Worker 日志里的:

status
contentType
bodyStart
upstreamUrl

3. proxy_pass_request_headers off 只能用于热榜代理

这个配置不能放在 server 级别,也不能放进原网站的 location /

否则会导致原网站登录后台拿不到 Cookie、CSRF 等请求信息,表现为:

点击登录后直接 Access Denied

4. /cdn-cgi/rum 返回 204 是正常的

这是 Cloudflare 的 Web Analytics / RUM 上报接口。

204 No Content 表示已接收但不返回内容,不是登录失败的原因。

5. Cloudflare Rocket Loader 可能影响后台登录页

如果你的网站后台登录页依赖前端 JS 加密密码或处理表单,Cloudflare 的 Rocket Loader、Auto Minify JS 等优化可能改写脚本执行时机。

遇到登录异常时,可以对后台路径关闭这些优化,例如:

/login*
/admin*
/console*

6. 代理密钥不要用弱口令

不要用 123456password 这类简单密钥。生成长随机值:

openssl rand -hex 32

如果密钥曾经在聊天记录、日志、截图或仓库里出现过,要立即更换。

7. 不要做开放代理

只开放固定路径:

/hot/bilibili
/hot/hupu
/hot/kuaishou

不要提供:

/proxy?url=https://...

开放代理很容易被滥用,也会给服务器带来安全和流量风险。

最终检查清单

上线前可以按这个顺序检查:

  • Nginx nginx -t 通过。
  • 不带 x-proxy-token 访问 /hot/* 返回 403
  • x-proxy-token 访问 /hot/* 返回 200
  • Cloudflare 运行时变量里配置了 HOT_PROXY_BASE
  • Cloudflare 运行时变量里配置了 HOT_PROXY_TOKEN
  • Worker 日志里的 upstreamUrl 已变成代理地址。
  • /api/bilibili/api/hupu/api/kuaishou 返回正常数据。
  • 原网站后台登录仍然正常。
  • 公开过的代理密钥已经轮换。

结论

这类问题的核心不是“Cloudflare Workers 不能用”,而是热榜项目依赖了大量第三方站点页面和接口。第三方站点对请求环境非常敏感,本地正常、线上失败很常见。

比较稳的处理方式是:

  • 能直连的源继续直连。
  • 容易被限制但仍然能稳定获取的源,走自有服务器固定路径代理。
  • 需要登录验证或结构频繁变化的源,及时换成更稳定的数据源。
  • 保留足够的调试日志,用状态码和响应体片段定位问题,而不是猜。

这样整体维护成本会低很多,也不会因为一个热榜源失效拖垮整个项目。

我已经把这套方案用在 soso.re 上。如果你也在做热榜聚合、导航页、信息流聚合这类项目,可以参考上面的代理方式;如果只是想直接看各平台热点,也欢迎把 soso.re 当作日常入口。