◈ 夜宵吧

搜索引擎看到的你,和访客看到的不一样

准备把这站提交给搜索引擎,动手前先自己抓了一遍。结果不太好看:十四篇文章,返回的 HTML 标题全是同一个。

单页应用的正文由浏览器渲染,爬虫拿到的只是一份空壳。这篇讲三件事:为什么会这样、服务端该怎么补、以及一条不用注册账号就能提交收录的路(IndexNow——一套推送协议,把 URL 直接 POST 给搜索引擎,不用等它来爬)。

读完你能自己验证任何单页站点的首屏 HTML 是不是「一份壳」,也能判断该在哪一层补 meta。

十四份一样的标题

$ curl -s https://example.site/post/a | grep -o '<title>[^<]*</title>'
<title>夜宵吧 · tech · notes · thoughts</title>

$ curl -s https://example.site/post/b | grep -o '<title>[^<]*</title>'
<title>夜宵吧 · tech · notes · thoughts</title>

两篇完全不同的文章,返回的 head 一模一样。description 也一样,没有 canonical,没有 og,没有结构化数据。

原因不神秘:前端是单页应用,index.html 只是一层壳,真正的标题要等 JS 跑起来才写进 DOM。而抓取这件事,大部分引擎不会等你。

于是十四篇文章在爬虫眼里是十四份重复内容,谁都不会被当成独立页面。分享卡片抓到的也是那句通用副标题。

head 不该是共享的

修法只有一条路:让服务端按路由生成 head,而不是让客户端事后补。

静态外壳还是那一份,但返回之前先按路径算出一组元信息,替换进去:

spaMeta(rel) → { title, desc, canonical, type, jsonld?, noindex?, notFound? }

几个细节值得记下来:

canonical 里的中文要编码。 站点的 slug 是中文标题本身,直接拼进 URL 会让 canonical 和 sitemap 里的写法对不上。两边都用 encodeURIComponent,再逐条比对一遍。

私密路径要 noindex。 登录、注册、写文章、后台这一批,就算被爬到也不该进索引:

// 登录、注册、写文章、后台这一批,命中就标 noindex
if (isPrivate(rel)) meta.noindex = true;

结构化数据里不能有 </script>。 JSON-LD 是直接内联进 HTML 的,正文里出现这个序列会把脚本提前截断:

const json = JSON.stringify(meta.jsonld).replace(/</g, '\\u003c');

改完之后,文章的 head 从 3 个标签变成 11 个,sitemap 里 21 条地址和 canonical 逐条一致。

软 404 比 404 更糟

顺手发现一个更麻烦的东西:不存在的文章返回的不是 404,是 200 + 一个空壳页面。

因为单页应用的回退逻辑是「找不到文件就回退到首页」,文章不存在时前端照样渲染,只是内容区空着。人看不出问题,爬虫看到的是「这个地址有效,但内容为空」。

这类页面在索引里叫软 404,比直接 404 更难清理。现在改成:先在服务端确认文章存在且已发布,不存在就直接 404,连壳都不发。

顺带把整条回退链改成白名单——只有已知的前端路由才回退,其余一律 404。这个改动之前已经在 harden 里做过一次,这次是把它补齐到「文章存在性」这一层。

IndexNow:不用注册的那条路

搜索引擎提交通常要注册账号、验证所有权、进后台点提交。三个平台三个账号。

但有一条路不需要账号:IndexNow。协议很简单——

  1. 生成一个随机 key,放到站点根目录的 {key}.txt,内容就是 key 本身
  2. 往它的接口 POST 一份 URL 列表
  3. 完事
{
  "host": "example.site",
  "key": "…",
  "keyLocation": "https://example.site/…txt",
  "urlList": ["https://example.site/post/a", …]
}

第一次推返回 202(key 还没被校验过),五秒后再推就是 200。之后接进发布流程:文章一发布就推首页和这篇文章的地址,更新就推文章地址,草稿不推。

有个小细节值得加:六十秒内同一个地址只推一次。写文章时反复保存,不设这个会把自己刷成噪音。

后来日志又抓出两处

提交之后没急着庆祝,翻了一遍访问日志——两个爬虫代理的请求当场暴露了我自己挖的坑。

第一处:OPTIONS 返回 405。

OPTIONS /  405

浏览器在跨域预检时会发这个,爬虫偶尔也会探。我的路由只认 GET/HEAD,其余一律 405。技术上没错——静态资源确实不支持别的动词——但对预检来说,正确的回应是 204 加一串 Allow:

if (method === 'OPTIONS') {
  res.writeHead(204, {
    'Allow': 'GET, HEAD, OPTIONS',
    'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
    'Access-Control-Max-Age': '86400',
  });
  return res.end();
}

第二处:?cachebuster= 被我自己的规则拦了。

/?cachebuster=750534648  404

这是验证代理在绕缓存。而我之前为了挡 WooCommerce 扫描器,写过一条「根路径带查询串就 404」——一刀切,把这个也切了。

改成白名单,只放行这一种:

if (p === '/' && url.search.length > 1) {
  if (!/^\?cachebuster=\d+$/.test(url.search)) return notFound(res);
}

结果

改完第三天,日志里能看到:

Googlebot   /post/…  200
Googlebot   /home    200
Bingbot     /        200
Bingbot     /home    200

两条爬虫都在按 sitemap 逐篇走。至少它们看到的是十几篇不同的页面,而不是十几份一样的壳。

顺手留下一个教训:「提交给搜索引擎」这个动作,只占整件事的一成。剩下九成是确认爬虫拿到的东西和访客看到的是同一个。

两周后的数字

现在回头看,这件事最值得记的不是「改完就好了」,而是改完之后的日志长什么样。

最近 7 天,3757 次请求里 1475 次是 404,占 39.3%。

最近 24 小时是 381 次里 66 次 404,占 17.3%。

这两个数差得有点多,说明 404 里有一部分是一次性的扫描波——某个扫描器拿着字典扫过来,几小时内打出上千个不存在的路径。扫完之后它自己就走了,所以短窗口中比例会回落。

最有意思的是 404 的内容。/wp-… 开头的路径,7 天内 600 次:/wp-json/wp/v2/users/me、/wp-login.php、/wp-admin/…、/wp-content/plugins/…。

这个站从来没跑过 WordPress。这些请求来自按「全网常见 CMS」写死的扫描字典——它们的假想敌不是这本站,是互联网上现存的几十万个 WordPress。

也就是说:这 39% 的 404 大部分不是「有人找错门」,而是「有人在按别人家的门牌号敲门」。

这个视角对判断「要不要处理」很有用:

  • 如果是真人找错地址,那 404 页要问候、要给搜索框、要给最近文章
  • 如果是扫描器,那它只关心状态码是多少

两者都要满足,所以现在的做法是:状态码给真 404,页面上给人用的那些东西一个不少。前面 harden 里说的「白名单」就是这个意思。

代价

服务端注入意味着每个页面的 HTML 都不同。CDN 那种「首页缓存一天」的做法不能用了——这里用的是 Cache-Control: no-cache 加内容哈希 ETag,每次请求都要回源校验。

对一个日访问量三位数的站点,这笔开销可以忽略;对高流量站点,得另想办法(比如把注入结果缓存到文章更新为止)。

另一个限制:IndexNow 只有 Bing、Yandex 这类引擎认,Google 至今不支持。它解决的是「不用等它自己来爬」,不是「一定被收录」。