URL 最终返回 200 OK,读者却落到了错误的方案页面。究竟是哪一跳改错了目的地?只看最终状态码无法回答。先保留每一步响应,再找出路径或有意义的参数第一次意外改变的位置。
本文使用一个刻意设置故障的本地网站,实测于 2026 年 10 月 28 日,工具为 curl 8.7.1 和 Chromium 浏览器。这是合成实验,不是生产事故、Fennec 产品界面实测,也不是搜索效果证据。你会复现并对比三组故障与修正路线:参数丢失、重定向循环,以及只有 JavaScript 运行后才发生的导航。
图中数据来自本地实验。规则标签由示例服务器明确设置,不能用来判断真实网站的响应由哪一层基础设施产生。
1. 从原始请求开始记录
跟随跳转前,先复制起始 URL,记录请求方法、查询字符串、时间、登录与 Cookie 状态,以及浏览器环境。页面导航先用 GET。HEAD 可以辅助排查,但不能代替发生问题的 GET:不同方法可能走不同的应用路径。
用 HTTP 状态码参考解释响应。Google 的重定向文档区分 301/308 等永久重定向和 302/307 等临时重定向;应按迁移意图选择。链中出现不同状态码,本身不能证明有故障。
本实验把 plan=annual 设为有意义的输入。只有参数保留下来,终点才显示 plan=annual。实际网站要先明确哪些参数影响读者任务;保留所有追踪参数不一定是正确设计。
2. 启动这个小型本地网站
将以下内容保存为 redirect-lab.mjs。代码只使用 Node.js 内置模块。运行 node redirect-lab.mjs 后保留该终端,在另一个终端执行 curl。服务器只监听本机的 4398 端口;若端口被占用,请同时修改代码和命令中的端口。
import http from 'node:http';
// Synthetic loopback-only lab. This is not a production incident or a Fennec feature test.
export const labVersion = 'redirect-lab-1';
export function createLab() {
return http.createServer((req, res) => {
const u = new URL(req.url, 'http://127.0.0.1');
const hop = (status, target, rule) => {
res.writeHead(status, { Location: target, 'Cache-Control': 'no-store', 'X-Lab-Rule': rule });
res.end();
};
if (u.pathname === '/start') return hop(301, '/legacy/' + u.search, 'entry');
if (u.pathname === '/legacy/') return hop(302, '/final', 'legacy-map');
if (u.pathname === '/fixed') return hop(301, '/final' + u.search, 'direct-map');
if (u.pathname === '/loop') return hop(308, '/loop/', 'add-slash');
if (u.pathname === '/loop/') return hop(307, '/loop', 'remove-slash');
if (u.pathname === '/loop-fixed') return hop(308, '/final', 'single-slash-policy');
if (u.pathname === '/client-fixed') return hop(302, '/final?plan=annual', 'http-route');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
if (u.pathname === '/client') {
res.end('<!doctype html><title>Client navigation</title><h1>Client navigation</h1>' +
'<script>location.replace("/final?plan=annual")</script>');
return;
}
if (u.pathname === '/final') {
res.end('<!doctype html><title>Plan destination</title><h1>Plan destination</h1><p id="plan">plan=' +
(u.searchParams.get('plan') === 'annual' ? 'annual' : 'default') + '</p>');
return;
}
res.statusCode = 404;
res.end('<!doctype html><title>Missing lab route</title><h1>Missing lab route</h1>');
});
}
if (process.argv[1] === new URL(import.meta.url).pathname) {
createLab().listen(4398, '127.0.0.1', () => console.log(labVersion + ' http://127.0.0.1:4398'));
}
完成后用 Ctrl+C 停止服务器。示例设置 no-store,减少本实验中的缓存干扰;它没有模拟 HTTPS、边缘缓存、身份验证或 Googlebot。
3. 找到丢掉参数的那份响应
先只检查起始响应。此命令丢弃正文,把响应头写到标准输出,并且不跟随跳转:
curl --noproxy '*' -sS --max-time 10 -D - -o /dev/null \
'http://127.0.0.1:4398/start?plan=annual'
然后保存完整 HTTP 链。-L 跟随 HTTP 重定向,--max-redirs 3 限制本次实验的跳数;-D - 输出每份响应头,末行记录最后的状态、已跟随跳数和有效 URL。这些选项可在 curl 官方手册核对。
curl --noproxy '*' -sS -L --max-redirs 3 --max-time 10 -D - \
-w '\nSUMMARY %{http_code} %{num_redirects} %{url_effective}\n' \
'http://127.0.0.1:4398/start?plan=annual'
以下是实际输出中的关键行,省略了常规日期和连接头,没有更改状态码或目的地:
HTTP/1.1 301 Moved Permanently
Location: /legacy/?plan=annual
X-Lab-Rule: entry
HTTP/1.1 302 Found
Location: /final
X-Lab-Rule: legacy-map
HTTP/1.1 200 OK
...<p id="plan">plan=default</p>
SUMMARY 200 2 http://127.0.0.1:4398/final
第一份响应保留了参数;/legacy/?plan=annual 返回的响应却把目的地写成不带参数的 /final。这就是第一处错误转换。最终 200 说明送达了一份响应,正文则说明方案不对。
相对 Location 必须根据返回该响应的 URL 解析,不要直接拼到最初的起始 URL 上。记录原始响应头时,同时记录解析后的下一跳 URL。
把完整链命令的路径换为 /fixed?plan=annual 再运行。本地修正入口直接发送到预期目的地:
HTTP/1.1 301 Moved Permanently
Location: /final?plan=annual
X-Lab-Rule: direct-map
HTTP/1.1 200 OK
...<p id="plan">plan=annual</p>
SUMMARY 200 1 http://127.0.0.1:4398/final?plan=annual
这是可用的对比路线,没有修改 /start。真实修复需要修改已经定位的规则,再重测原来失败的 URL,同时直接访问终点。跳数减少本身不能证明读者任务保留正确。
4. 不增加跳数上限,也能识别循环
把完整链命令的路径换为 /loop,原始响应会交替出现:
| 返回响应的路径 | 状态 | Location | 本实验中的规则 |
|---|---|---|---|
/loop | 308 | /loop/ | add-slash |
/loop/ | 307 | /loop | remove-slash |
/loop | 308 | /loop/ | add-slash |
/loop/ | 307 | /loop | remove-slash |
curl 以退出码 47 停止,提示 Maximum (3) redirects followed。初始请求加上三次已跟随的重定向,会读到下一份重定向响应,所以输出中有四组响应头。这是本实验选择的 curl 上限,不是 Googlebot 上限。证明循环的是 URL 序列重复;只有退出码也可能只是链很长、尚未重复。
对比路线 /loop-fixed 采用单一斜杠策略,一次 308 后到达 /final。实际网站应核对应用路由及上游跳转配置中的斜杠规则。Server 响应头或服务商名称只是线索,不能证明具体规则在哪一层执行;要结合配置或能关联该请求的日志确认。本实验的 X-Lab-Rule 可用于归因,是因为服务器明确写入了这些标签。
5. 解释 curl 看到 200,浏览器却换页的原因
对 /client 执行完整链命令,结果是 200、零次 HTTP 重定向,有效 URL 仍为 /client。返回 HTML 内含:
<script>location.replace("/final?plan=annual")</script>
curl 获取 HTML,但不执行这段脚本。浏览器实测中,地址变为 /final?plan=annual,页面显示 plan=annual。第二次文档请求来自成功响应后的 JavaScript 导航,并不是 curl 记录中藏着一份 3xx。
在 Chrome 中可这样核对:
- 访问
/client之前打开 DevTools → Network。 - 勾选 Preserve log 和 Disable cache,再导航到准确的起始 URL。
- 查看
/client和/final?plan=annual两次文档请求及各自状态。 - 查看第二次请求的 Initiator 信息,与首份响应中的脚本对照。
Chrome 的 Network 参考文档说明了这些控件及发起者信息。检查其他网站时,如果遇到浏览器自动升级或缓存跳转,应先区分它们和新鲜服务器响应,再判断规则归属。
对比路线 /client-fixed 用 HTTP 302 到达相同终点,所以 curl 也能跟随一跳。这只是交付方式的本地对照。Google 建议尽可能使用服务器端重定向,并说明 JavaScript 重定向的发现依赖渲染。本地浏览器成功导航不能证明 Google 的渲染或索引状态。若需要进一步比较原始 HTML 和渲染 DOM,可使用 JavaScript SEO 排障指南。
6. 为真实修复保存一份简明证据表
| 需要保存 | 用途 |
|---|---|
| 完整输入 URL、方法及相关请求环境 | 能重复触发原始问题 |
| 每份响应的 URL、状态、原始 Location 与解析后目的地 | 定位第一次错误转换 |
| 最终 URL 和页面上的实际任务结果 | 区分成功送达与行为正确 |
| 可确认疑似规则的配置或日志证据 | 避免仅凭响应头猜测基础设施责任 |
| 修改后对原始输入的复测 | 证明原来报告的故障已修复 |
修复交付问题后,检查重要内链是否直接指向预期目的地,再按多语言 SEO 指南核对终点的 canonical 与语言信号。这些检查不能代替 HTTP 链记录。若页面交付正确而索引问题仍在,可继续按 SEO 诊断流程排查。
如果多个 URL 共享已确认的规则故障,批量审计修复指南可帮助组织修复批次,避免合并无关症状。把原始失败输入保留在复测集合里;目标是每个请求都稳定到达正确目的地,缩短跳转链只有在保留该行为时才有意义。