搜索引擎看到的你,和访客看到的不一样
准备把这站提交给搜索引擎,动手前先自己抓了一遍。结果不太好看:十四篇文章,返回的 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。协议很简单——
- 生成一个随机 key,放到站点根目录的
{key}.txt,内容就是 key 本身 - 往它的接口 POST 一份 URL 列表
- 完事
{
"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 至今不支持。它解决的是「不用等它自己来爬」,不是「一定被收录」。