我的市场应用是用 Expo 导出的 React Native Web 应用。在 JavaScript 跑起来之前,每一个商品页都只是一个 script 标签加一个空 div。这对人没问题,对搜索却是致命的:Google 有时能执行那段 JS,Bing 不会,而对一个在新西兰的韩语社区站点最要紧的 Naver 爬虫,肯定不会。
这篇讲的是我怎么让这些页面被看见,以及之后那条以我没预料到的方式把成果抵消掉的缓存规则。
动态渲染,不是伪装#
办法很老,而且 Google 认可:识别爬虫,给它返回一份服务端渲染的 HTML,内容和 SPA 本该画出来的一样。不是不同的内容,是同样的内容,只是更早。
于是 /tabs/used-good-view?id=... 分叉了。浏览器拿到 Expo 外壳。爬虫拿到一份真正的文档:一个 <h1>,一个列事实的 <dl>(价格、成色、分类、地区、是否在售、照片数量、发布日期),每张照片一个带生成 alt 的 <img>,一段正文摘录,以及通往八个同类商品的链接,好让这页不至于成为孤儿。
最后那点比我预想的重要得多。在这些内链存在之前,大约一千个商品页没有任何东西指向它们,Search Console 里能看到抓取一路沉到每天 7 到 11 次请求。找不到你页面的爬虫,不会在乎那些页面有多好。
陷阱:厂商的名字不是它家爬虫的名字#
爬虫识别是 user-agent 匹配,问题就出在这里。
韩国平台用的爬虫名字和公司名一点都不像。Naver 的爬虫叫 Yeti,Daum 的叫 Daumoa,Kakao 的链接抓取器叫 kakaotalk-scrap。三个都不是厂商名。
与此同时,同样这几家还发布带内嵌浏览器的 App,那些浏览器发送的是真实浏览器 UA,后面缀上 App 的名字。KakaoTalk 的内置浏览器说自己是 KAKAOTALK/10.5.1,Kakao 的抓取器说自己是 kakaotalk-scrap/1.0。
用 kakao 去匹配,两个都会中。我当初正是这么干的,结果每一个打开链接的 KakaoTalk、Naver App、Daum App 用户,拿到的都是爬虫页:没有 JavaScript,没有导航,没有登录。一份挑不出毛病的文档,对人却毫无用处。
修复是一条顺序规则,值得写成规则:
def is_crawler(request):
"""内嵌浏览器永远不是爬虫,哪怕它的 UA 里带着与该厂商爬虫
相同的名字——所以这个判断放在最前面。"""
ua = request.META.get("HTTP_USER_AGENT", "")
if not ua or _INAPP_BROWSER_RE.search(ua):
return False
return bool(_CRAWLER_RE.search(ua))内嵌浏览器先判断,而且它赢。并且每一个爬虫 token 都必须指向一个机器人,而不是一个厂商:
# 韩国搜索引擎。Naver 以 Yeti 抓取,Daum 以 Daumoa,Kakao 的链接预览
# 抓取器以 kakaotalk-scrap。三者都不是厂商名本身。
r"yeti/|naverbot|daumoa|kakaotalk-scrap|kakao[a-z]*bot|"然后我在前面放了 Cloudflare#
源站是悉尼的一台共享 CPU 机器,能撑住每秒二三十个请求。Googlebot 要走大约 995 个 URL,而它们全都打到了悉尼,因为每一个 URL 的 cf-cache-status 都是 DYNAMIC:除非有规则明说,Cloudflare 不缓存 HTML。 整个 zone 大约 16% 被缓存,而真正要紧的那部分一个也不在里面。
那就给爬虫页加一条缓存规则。很简单。除了一点:一个商品 URL 并不只有一个响应体,它有三个。
- 要 HTML 的爬虫拿到服务端渲染页。
- 人拿到 Expo 外壳。
- 发送
Accept: text/markdown的 agent 拿到 Markdown,从数据库拼出来的,因为把一个空的 HTML 外壳转换一遍只会得到一份空文档。
源站会发 Vary: Accept,而对 user-agent 做 Vary 本来也没什么指望。然后就是我不知道的这件事:
Cloudflare 在 HTML 上忽略
Vary。 三个响应体共用一个缓存键。
哪个先到,哪个就占住这个 URL,对该边缘节点之后的所有访客生效,持续源站 s-maxage 允许的一小时,再加上一天的 stale-while-revalidate。
我在生产环境两个方向都亲眼见到了:
- 一个 Naver App 用户用爬虫页预热了某个商品 URL,随后普通的 iPhone Safari 就在一次 HIT 上拿到了那个页面。一个人盯着一份没有 JavaScript 的文档。
- 一个人用 Expo 外壳预热了某个 URL,Googlebot 拿到了空外壳。整套 SEO 工作要防的正是这个失败,如今它更快了,还是从边缘发出来的。
靠收窄来修复的规则#
你没法让 Cloudflare 在 HTML 上尊重 Vary。你能做的,是确保三个响应体里只有一个有资格进缓存:
(starts_with(http.request.uri.path, "/tabs/")
and not any(http.request.headers["accept"][*] contains "text/markdown")
and (lower(http.user_agent) contains "googlebot" or ...)
and not (lower(http.user_agent) contains "kakaotalk/" or ...))只匹配爬虫 UA,并排除 Markdown 请求。现在人的请求不匹配任何规则,于是读到 DYNAMIC 并像以前一样回源。只有爬虫请求读写缓存,而能进到缓存里的响应体只有爬虫那一个。
边缘 TTL 用 respect_origin,于是有效期仍归渲染这个页面的代码所有,而不是被钉死在控制台里。
一个容易漏掉的运维细节:收窄规则并不会驱逐已经在缓存里的东西。 你得清一次,否则你会继续发出那些你刚刚才停止生产的被污染条目。
两份清单,以及它们的错误为何不对称#
这条规则需要一份爬虫 UA 清单,而 Django 里已经有一份。同一份知识在两个系统里存两份通常是坏味道,但这里有意思的是它们漂移时会发生什么,因为并不对称:
- Cloudflare 里的机器人清单必须始终是 Django 那份的子集。 一个 Cloudflare 认为是机器人而 Django 不认的 token,会把 SPA 外壳缓存到爬虫专用键下,然后发给真正的爬虫。
- 内嵌浏览器的排除清单必须是 Django 那份的超集。 排除得太多,代价只是少几次缓存命中。
任何一边朝安全方向错,你只是少缓存一点。第一条朝另一个方向错,你就又在给 Googlebot 发空页面了。顺带一提,这种不对称也正是这些清单用 contains 而不是正则的原因:matches 需要 Business 套餐,而对一份允许在已知安全方向上粗糙的清单,contains 够用了。
一条让构建失败、而不是让页面失败的不变式#
爬虫页里嵌着有效期七天的预签名图片 URL。页面本身在边缘缓存一小时,外加一天的 stale-while-revalidate。
这两个数字是相关的,一旦缓存寿命超过图片寿命,边缘就会开始发出每张照片都 403 的页面。不会有任何告警,因为 HTML 本身没问题。
所以持有这些常量的那个模块,在关系被破坏时会拒绝导入:
CRAWLER_IMAGE_TTL = 7 天
# 若 s-maxage + stale-while-revalidate >= CRAWLER_IMAGE_TTL,则在 import 时抛错两个分处不同文件的常量之间的约束,在代码评审里看不见,在进程启动时却一目了然。把它放在进程启动的地方。
Cloudflare 做的两件、我不得不撤销的事#
Vary: Origin 把打包产物挡在了缓存之外。 django-cors-headers 会在它经手的每个响应上打 Vary: Origin,而 Cloudflare 拒绝缓存任何在 Accept-Encoding 之外还有 vary 的响应。这就悄悄把关键路径上最大的那个 1.6 MB JavaScript 包整个排除在边缘之外。修法是把一个中间件放在列表最前面,让它在出站方向最后执行,重写 /_expo/ 资源路径上的 Vary。
边缘压缩比源站压缩更差。 让 Cloudflare 现场做 Brotli 得到 2,041,742 字节,而源站自己的 gzip 是 1,658,115 字节。为了不用动脑,代价是大了 23%。即时压缩优化的是 CPU,不是压缩率;源站预压缩的资源会赢。
不等抓取就告诉搜索引擎#
两个协议,加在一起覆盖面也令人失望。
IndexNow 接受一次 ping,覆盖 Bing、Yandex 和 Seznam。Google 和 Naver 不参与。它在 post_save 里以守护线程运行,超时五秒,跳过已售出和已关闭的条目,没有 key 时什么也不做。网络失败只记一条警告,绝不让触发它的那次保存失败——这是一个尽力而为的旁路通道唯一可以接受的行为。
Google 的 Indexing API 只接受两种 schema 类型,其中之一正是我有的 JobPosting。在招的职位发 URL_UPDATED,关闭的发 URL_DELETED。那条删除通知之所以诚实,是因为关闭的条目同时输出了 noindex,代码里也这么写着——好让日后拿掉 noindex 的人知道自己还顺手破坏了什么。
那里我花了两次时间。配额是每天 200 个 URL,而一次抓取导入会把信号触发几百次,所以导入进来的条目会被跳过,否则一次导入就把当天预算全花光。另外端点是带冒号的 urlNotifications:publish。用斜杠的话,Google 返回一个普通的 HTML 404,看起来和 API 没启用一模一样。
结构化数据教给我的#
三条规则,每一条都是被 Search Console 数落出来的:
- 解析不了的就别输出。 薪资是自由文本。我以前把原始字符串当作
baseSalary.value输出,那是无效的,还让每一条职位都被标记缺少单位。现在解析不了的薪资干脆不出现。 - 别为了满足 schema 编造字段。 招聘广告很少带街道地址。人会很想给
PostalAddress填个看着像样的东西。招聘广告上编造的地址比不完整的地址更糟,所以只放城市和国家。 - 有效期不是可选项。 Google 会丢掉没有
validThrough的JobPosting,所以要推导一个,并加上下限,免得一条旧条目声称自己昨天就过期了。
还有关于站点地图的第四条:别给所有条目都声称 changefreq: daily。 要求 Googlebot 每天重新抓取一千个条目,会被读成一台过载的主机,而它的回应是降低整站的抓取速率。从这东西实际上多久变一次来推导。
站点地图还有一道质量下限:标题加正文合计四十个字符。Search Console 曾把大约一千条报成"已抓取 – 尚未编入索引",其中某个二手页面的全部独有内容,是一个单词的标题加一个电话号码。四十这个数字是故意定低的,因为 484 条里有 471 条是韩语,而韩语单位字符承载的信息更多;按英语调的阈值会砍掉这个市场的一半。内容单薄的条目仍然通过索引页保持可抓取,一旦有人编辑就会自动重新进入站点地图。
我会从中带走的#
- 在一个 URL 上返回不同的响应体,首先是一个缓存键问题。 弄清楚你的 CDN 用什么做键,并且在你于那个确切的 content type 上验证之前,就假定它忽略
Vary。 - 当两个系统必须就一份清单达成一致时,先弄清哪个方向的错误是安全的,然后把它写在两份副本旁边。“它们可以漂移,而且只允许朝这个方向漂”,胜过假装它们不会漂。
- 按机器人名字匹配机器人,永远别按厂商名字。 尤其在英语世界之外,那个厂商同时还做着你用户手里的那个浏览器。
- 把相距遥远的常量之间的不变式,放在会大声失败的地方。 import 时机是免费的,而且没人能跳过。

