SPA 站 SEO 的 5 个静默失败点:117 个工具页的排查复盘

构建日志全程显示成功,产出的 HTML 里却没有内容。复盘 Vite + React 单页应用静态壳注入失效、跨条目正则取错值、静态壳与运行时抢 title、内链饿死长尾等五个静默失败点,附可直接抄的产物自查命令。

SPA 站 SEO 的 5 个静默失败点:117 个工具页的排查复盘

我们给 pokitx 做了一轮 SEO 改造。这是一个 Vite + React 的单页应用,117 个在线工具,构建时会为每条路由生成静态 HTML 外壳(中英双语,共 416 个页面)。

改造过程中最值得写下来的不是"做对了什么",而是发现了五个一直在静默失败的地方——构建日志全程显示成功,脚本没报过一次错,但产出的 HTML 里关键内容根本不存在。

这类问题的共同特征是:失败路径和成功路径产出的都是一个"看起来正常"的页面。你不去 grep 产物,就永远不会知道。


失败点 1:注入正文的正则从来没匹配上过

我们的构建后脚本会为每条路由生成静态 HTML,把真实的 <h1>、工具说明、FAQ、分类内链写进 #root,供不执行 JS 的爬虫抓取。注入代码是这样的:

html = html.replace(/<div id="root">\s*<\/div>/, `<div id="root">${SPLASH_HTML}${fallback}</div>`)

这条正则要求 #root 是一个空 div。但某个时间点,index.html 里的 #root 被手工塞进了品牌闪屏和一段首页兜底文案:

<div id="root">
  <div id="app-splash" aria-hidden="true">…</div>
  <div id="ssr-fallback">
    <h1>… 首页标题 …</h1>
    …
  </div>
</div>

于是正则永远匹配不上。String.prototype.replace 匹配不到时不报错,原样返回——脚本继续跑完,日志打印"wrote 416 static route shells",一切正常。

实际后果是全部 416 个页面原样继承了首页那份写死的兜底内容:

grep -ho "<h1>[^<]*</h1>" dist/tools/*/index.html | sort -u | wc -l
# 1

117 个工具页,1 个唯一 h1。 中文页面拿到的还是英文文案。工具说明、FAQ、烘焙给 AI 爬虫的价格表、分类链接列表,一个都没进 HTML。

修法是按 div 配对扫描出 #root 的真实闭合位置:

function replaceRootContent(html, inner) {
  const OPEN = '<div id="root">'
  const open = html.indexOf(OPEN)
  if (open === -1) return null
  const tag = /<\/?div\b[^>]*>/gi
  tag.lastIndex = open + OPEN.length
  let depth = 1, m
  while ((m = tag.exec(html))) {
    depth += m[0][1] === '/' ? -1 : 1
    if (depth === 0) return html.slice(0, open) + OPEN + inner + '</div>' + html.slice(m.index + m[0].length)
  }
  return null
}

但比修法更重要的是匹配不到时抛错

const injected = replaceRootContent(html, SPLASH_HTML + fallback)
if (!injected) throw new Error(`#root not found or unbalanced (route ${r.path})`)

一个静默降级了半年的 bug,和一个让构建立刻红掉的 bug,成本差了好几个数量级。


失败点 2:跨条目的惰性正则会取到邻居的值

我们的工具注册表是一个数组,构建脚本用正则从源码里把字段抠出来:

const re = /slug:\s*'([a-z0-9-]+)'[\s\S]*?category:\s*'([^']+)'[\s\S]*?name:\s*T\('…'\)[\s\S]*?desc:\s*T\('…'\)/g

这条正则在所有条目都有全部字段时能正常工作。问题出在我们想加一个可选字段 seoTitle 的时候——[\s\S]*? 是跨条目惰性匹配的,一旦某个条目没有这个字段,它会一路吃到下一个条目里,把邻居的值当成自己的。

这种错误不会抛异常,只会让某几个页面的标题莫名其妙变成隔壁工具的标题。

正确做法是先切块再取字段

// 按行首的 "  { slug: '...'" 把数组切成一段一个工具
const starts = [...txt.matchAll(/^  \{ slug: '([a-z0-9-]+)'/gm)]
const chunk = txt.slice(m.index, next ? next.index : txt.length)
// 再在 chunk 内部单独取每个字段,取不到就是真的没有

一般性的教训:用正则解析结构化文本时,先确定边界,再在边界内取值。跨越记录边界的惰性量词迟早会咬你一口。


失败点 3:静态壳和前端框架抢 <title>

SPA 的静态壳有个容易被忽略的前提:它只在 JS 执行前有效

我们的静态壳写入 title A,React 挂载后 setSeo() 又写入 title B。Google 是渲染 JS 的,所以它索引的是 B。静态壳里那个精心优化过的标题,只有不执行 JS 的爬虫看得到。

这次我们把工具页标题从"工具名 + 站名"改成写死关键词的形式:

改前:Merge PDF - PocketKit
改后:Merge PDF Free — In Your Browser, No Upload | PocketKit

如果只改静态壳而忘了改运行时,Google 看到的还是改前的版本,等于白做。

所以标题必须同源——静态壳和运行时读同一份数据、走同一套拼接逻辑。我们的做法是把标题写进注册表的 seoTitle 字段,两边都从这里读,品牌后缀抽成常量并在两处都写上"改这里必须同步改那里"的注释。

自查方式很简单:打开页面,等 JS 挂载完,再看标签页标题和 curl 拿到的是不是同一个。


失败点 4:内链取"前 N 个"会把长尾饿死

给工具页之间加相关内链时,最直觉的写法是取同分类的前 N 个:

const related = sameCategory.slice(0, 8)

这在小分类里没问题,但我们的开发工具分类有 30 个工具——结果就是这 30 个页面全部指向同一批 8 个工具,剩下 22 个一条入链都没有。权重全堆在头部,长尾页面依然是孤儿。

改成从自己的位置往后循环取

const at = sibs.findIndex((t) => t.slug === slug)
for (let k = 1; k < sibs.length && picked.length < 8; k++) picked.push(sibs[(at + k) % sibs.length])

这样每个工具的入链数和出链数都均匀。改完实测:

入链分布 — min: 1  max: 8  零入链页面: 0

min: 1 出现在只有 2 个工具的小分类里,符合预期。


失败点 5:写了却从来没被用上的字段

我们的注册表里,有 32 个工具认真写了 keywords 字段。但构建脚本里:

tools.forEach((t) => add(`/tools/${t.slug}`, t.name, t.desc, t.name))   // 只传了 4 个参数
games.forEach((g) => add(`/html-games/${g.slug}`, g.name, g.desc, g.name, g.keywords))  // 传了 5 个

add() 的第 5 个参数是 keywords。游戏和对比页都传了,只有工具页漏了。这 32 组关键词从来没有进过静态 HTML。

这里要说句实话:修这个 bug 的排名收益约等于零——Google 和 Bing 早就不看 meta keywords 了。真正的价值是它暴露了一个思维误区:我们把关键词写进了一个不影响排名的字段,然后以为关键词做完了。

那些词真正该去的地方是 <title><meta description>。所以我们最后做的不是补传参数,而是把这些词全部重写进 117 个工具页的标题里


一份可以直接抄的产物自查清单

这些命令针对构建产物跑,几秒钟就能发现上面大部分问题:

# 1. h1 是否每页唯一 —— 出现 "117 <h1>同一句话</h1>" 就是注入失败
grep -ho "<h1>[^<]*</h1>" dist/tools/*/index.html | sort | uniq -c | sort -rn | head

# 2. title 唯一数是否等于页面数
echo "unique: $(grep -ho '<title>[^<]*</title>' dist/tools/*/index.html | sort -u | wc -l) / $(ls dist/tools | wc -l)"

# 3. 静态壳里到底有没有正文(打印某页 ssr-fallback 开头 600 字)
node -e "const h=require('fs').readFileSync('dist/tools/pdf-merge/index.html','utf8');
const i=h.indexOf('ssr-fallback');console.log(h.slice(i,i+600).replace(/<[^>]+>/g,' '))"

# 4. canonical / hreflang 有没有重复注入
grep -c 'rel="canonical"' dist/tools/pdf-merge/index.html   # 应为 1

# 5. 中文页面是不是真的是中文
grep -o "<h1>[^<]*</h1>" dist/zh/index.html

第 3 条最值得养成习惯。"构建成功"和"产出正确"是两件事,而静态壳这类东西,唯一可靠的验证就是去读产物本身。


附:为什么我们放弃了百度

顺带记一个结论。我们的站是双语的,中文页面在 /zh 下。调研之后决定不为百度投入任何预算,原因是两条硬约束叠加:

  1. 百度对 ICP 备案极其看重,未备案域名基本拿不到排名(可能被收录,但排不上去);
  2. 站点托管在 Cloudflare,其 IP 段在国内访问不稳定,蜘蛛抓取成功率低。

所以 /zh 继续做,但目标受众不是百度——是 Google 的中文查询、Bing 中文、海外华人用户,以及 AI 回答引擎的中文语料。

想吃百度流量需要的是国内主体备案 + 国内节点,那是一次基建投入,不是 SEO 能解决的问题。在那之前,任何百度 SEO 动作都是沉没成本。


用得上的工具

这次改造里反复用到的几个,都是纯浏览器本地运行、不上传数据: