第 0006 课 · 你的爬虫漏掉了什么
JS 渲染缺口
你的页面有两个版本:服务器发出的 HTML,以及 JavaScript 运行之后的 DOM。一半的 bot 永远只看到第一个版本——而它们决定了你存不存在。
回顾 第 0002 课:你运行了 crawl_audit.py,它说“可抓取 + 可索引”。但那个工具是用 urllib 抓取的——它读取的是原始响应,从不运行一行 JS。那么它放行的那个页面,究竟含有任何东西吗?这一课就来找出这道缺口。
Google 并不是一次性就把你的页面索引(index)了。它先抓取(crawl)原始 HTML(raw HTML),然后——单独地、在之后、在它的资源允许时——一个 headless Chrome 会渲染(render)这个页面,运行 JavaScript,再把 JS 产出的东西重新索引。[1]渲染是一个延迟队列,不是抓取的一部分。如果你的内容只在 JS 之后才存在,那么在第二波到来之前它都是不可见的——而对很多 bot 来说,则是永远不可见。
render_gap.py,得到一份逐信号的差异对比——词数、链接、JSON-LD、标题——精确标出哪些信号只在 JavaScript 之后才存在。那正是一个不跑 JS 的(no-JS)bot 永远看不到、而 Google 看得很晚的内容。 两波,不是一波
直接引自 Google 的 JavaScript-SEO 文档:页面是分阶段处理的,而渲染(render)位于它自己的队列里。[1]
▲ 渲染(Render)是它自己的延迟队列——可能要几秒,也可能更久得多。
Google 确实会“使用近期版本的 Chrome,以类似浏览器渲染页面的方式”运行你的 JavaScript。[2]问题在于什么时候,以及还有谁不运行。
你的审计工具是个不跑 JS 的 bot——大多数 AI 爬虫也是
第 0002 课里的 crawl_audit.py,正是那种只看得到第一波的抓取器:
# crawl_audit.py — the fetch is plain HTTP. No browser, no JS.
req = urllib.request.Request(url, headers={"User-Agent": "Googlebot"})
body = urllib.request.urlopen(req).read() # <- raw HTML only
所以如果你的 <title>、内容和 canonical 是 React 在加载之后注入的,crawl_audit.py 就看不到它们——而它可能仍然会对着一个近乎空壳的页面打出 PASS。同样的盲区也重重地打在 AEO 这一侧:独立的 AI 爬虫通常抓取原始 HTML(raw HTML)并且不执行 JavaScript,而且控制它们也没有定型的标准。[3]那些 Googlebot 最终会渲染的客户端内容,可能根本到不了答案引擎(answer engine)那里。
HTML 响应里已经含有文本、链接和 JSON-LD。JS 只是锦上添花。
第一波 Googlebot、不跑 JS 的 AI 爬虫、你自己的审计——所有人,都立刻看得到。
✓ 现在就被索引 · 现在就可被引用原始 HTML 就是 <div id="root"></div>。所有内容都通过 JS 送达。
对不跑 JS 的 bot 不可见;Googlebot 只在延迟渲染波时才索引它——前提是它渲染得干净。
✗ 对 Google 延迟 · 对 AEO 缺失工具:对比两个版本
你给 render_gap.py 两样东西——原始 HTML(它可以自己抓,就像第 0002 课的工具那样)和渲染后的 DOM(rendered DOM)(这个由你来捕获:DevTools → Elements → 右键点 <html> → Copy outerHTML)。它从两者中提取相同的信号,并标记出任何从 ≈0 跳到很多的信号:
# share of each signal present WITHOUT JS
visible = raw[key] / rendered[key] # 1.0 = fully server-rendered
js_dep = rendered[key] > 0 and visible < 0.5 # needs JS to exist
# headline keys on visible TEXT: >=90% server-rendered, <=10% client-rendered
- 自检(离线):
python3 tools/render_gap.py --demo - 挑一个页面。把它渲染后的 DOM 存为
rendered.html(DevTools → Copy outerHTML),然后:python3 tools/render_gap.py https://yoursite.com/page rendered.html - 用同样的方式测一个已知的 SPA(一个 React/Vue 应用的路由)——看着每个信号都被标成 JS-DEP。那就是一个不跑 JS 的爬虫看到的东西。
$ python3 tools/render_gap.py https://app.example.com/blog/geo rendered.html Render gap — raw HTML (no JS) vs rendered DOM (after JS) ────────────────────────────────────────────── [FAIL] visible words no JS 0 · with JS 209 · visible 0% · JS-DEP [FAIL] a links no JS 0 · with JS 3 · visible 0% · JS-DEP [FAIL] JSON-LD blocks no JS 0 · with JS 1 · visible 0% · JS-DEP [FAIL] h1 headings no JS 0 · with JS 1 · visible 0% · JS-DEP [FAIL] h2 headings no JS 0 · with JS 1 · visible 0% · JS-DEP ────────────────────────────────────────────── VERDICT: CLIENT-SIDE RENDERED — the raw HTML is an empty shell. No-JS AI crawlers see ~nothing; Googlebot indexes it only on the deferred render wave.
schema_tool.py 能验证它,Googlebot 可能在第二波时拾取它,但一个不跑 JS 的 AI 爬虫一个属性都看不到。[3]客户端渲染的 <title> 和 canonical 也是同样的故事:crawl_audit.py 可以在原始响应是空壳的情况下放一个页面过关。通过抓取/索引这道关卡 ≠ 内容真的在那里。把事实放进服务器发出的 HTML 里。 要知道的上限:render_gap.py 不运行浏览器——渲染后的 DOM 由你提供,就像第 0005 课的追踪器拿的是你捕获的引擎输出一样。它的“visible words”统计的是文本节点,不是布局;一个完全由 JS 构建、Google 渲染起来毫无问题的页面,在这里仍然会被标记,而这正是重点——它展示的是不跑 JS 的视角,那是一个真实存在的受众,而不是对 Googlebot 的最终裁决。
提取练习 · 不许偷看
留意这道缺口
凭记忆作答——正是这份努力让知识留得住。每题只有一次机会;在看其他选项前先选。
crawl_audit.py 会对一个没有任何可见内容的页面打出 PASS?render_gap.py 把它叫作什么,修法是什么?来自引擎自己的说法:三个阶段(crawl → render → index),以及为什么渲染(render)是 一个延迟队列。把它和 <a href="https://developers.google.com/search/docs/fundamentals/how-search-works">“How Search Works”</a> 里的渲染说明,以及 <a href="/zh/resources/"><code>RESOURCES.md</code></a> 里的 AI 爬虫控制缺口配合着看。