JS渲染实测131个站:担心的三件事,两件没发生

JS渲染实测131个站:担心的三件事,两件没发生
张文保 更新 43 分钟阅读 4,011 阅读
本文目录
  1. 关掉脚本再抓一次,到底是在量什么?
  2. 三个口径,必须在同一次访问里取
  3. 那段字确实在响应体里,只是不以文字的形式
  4. 为什么不能拿服务器抓的HTML跟浏览器比
  5. 等1秒和等10秒,结论会不会变?
  6. 131这个分母是怎么剩下来的
  7. 131个首页,关掉脚本之后丢了多少字?
  8. 为什么先量字数,而不是先量结构
  9. 分布是两头沉的
  10. 丢失的绝对量,和丢失的比例不是一回事
  11. 完全空掉的那8个站
  12. 还有10个站,关掉脚本之后文字反而更多
  13. 对照组不是可选项
  14. 39.5% 的站两轮几乎一模一样
  15. 没有它,前面每个数字都可能是抖动
  16. 顺带说一次尺子自己出的问题
  17. 关掉脚本,导航链接会不会跟着一起没?
  18. 中位只丢2.8%
  19. “字在路不在”这种情况,全样本里只有一个
  20. 把六类结构件放在一张表里看
  21. 结构化数据里的东西,是脚本放进去的吗?
  22. 131个站里只有1个
  23. 这条负结果比正结果有用
  24. 那“把内容塞进结构化数据”这个招还灵吗
  25. 这是不是某个建站平台的锅?
  26. 两个数字互相打架
  27. 换个分组方式,画像清楚多了
  28. 这个决定通常是谁做的
  29. 归因该停在哪里
  30. 开着脚本反而抓不到,这是怎么回事?
  31. 六个站两轮标题不同
  32. 三个是被反爬拦下的
  33. 一个站从美国站变成了越南站
  34. 同一个403背后至少三种判据
  35. 这批拒绝里有明显的集团特征
  36. 不执行脚本的那一方,到底是谁?
  37. 搜索引擎这一侧的现状
  38. 抓内容的那一层,情况不一样
  39. 自己造一个页面,让各种提取器来读
  40. 摘录式抓取让这件事更要紧
  41. 被推荐和被引用,本来就是两件事
  42. 十分钟自查,和三种对应的修法
  43. 先做那个减法
  44. 三种结果,三种修法
  45. 什么不必改
  46. 哪些模块本来就不该被索引
  47. 如果短期内改不了渲染方式
  48. 怎么跟不同的人说这件事
  49. 把它接进流水线
  50. 这一趟量下来,最值钱的是那三条负结果
  51. 常见问题解答
  52. Google既然能渲染脚本,我是不是可以完全不管?
  53. 关掉脚本测出来剩60%,算严重吗?
  54. 我的站是单页应用,是不是一定有问题?
  55. 为什么不用命令行工具抓一次HTML就当作“关掉脚本”的版本?
  56. 结构化数据能不能替代读不到的正文?
  57. 首页测完了,其他页面要不要都测?
  58. 这次的数字能不能直接套到中文站上?
  59. 两轮之间要不要多跑几次取平均?
  60. 丢的那部分内容,会不会过一会儿就被渲染补上了?
  61. 为什么有的站关掉脚本反而字更多?
  62. 权威参考资料

摘要:171个海外独立站的首页各抓两遍,一遍让脚本正常跑,一遍把脚本关掉,其余条件全部相同,131个站两遍都拿到200。一半的站丢掉的正文不到9.3%,39.5% 的站两遍几乎一样,5.6% 的站关掉脚本之后一个字不剩——分布是两头沉的,不是人人有份。真正意外的是三个流行担心里有两个半站不住:导航链接丢失中位只有2.8%,“字都在、路断了”的站全样本里只有1个;结构化数据靠脚本注入的只有1个,95.2% 的站两轮块数分毫不差;至于“某个建站平台的锅”,中位数和尾部两个数字互相打架。测量本身反倒出了三次事,其中一次是脚本跑起来之后才被反爬拦下。

这件事的起点特别土。有天翻一个客户的首页源码,想找一段促销文案在哪,翻了半天没找着,最后在一个 <script> 标签里的JSON字符串里翻到了。那段字在页面上明明白白显示着,在HTML里却不以文字的形式存在。

顺着这个念头就想量一量:如果一个访问者不执行脚本,它拿到的这个页面,和执行脚本之后的那个页面,差多少。

这个问题被讨论过无数次,但绝大多数讨论停在“应该用服务端渲染”这个结论上,很少有人真去数一数差多少、差在哪、以及有多少站其实根本不差。数字缺席的时候,恐惧会自己填满空白,然后变成一堆没必要的改造预算。

关掉脚本再抓一次,到底是在量什么?

先把这次量的东西说死,因为同一个页面可以有好几个“内容量”,混着说就没法比。

三个口径,必须在同一次访问里取

一个网页上的文字,至少可以按三种口径来数:

  • 不执行脚本的一方拿到的字:把浏览器的脚本开关关掉,页面加载完之后文档树里有多少文字。这一档对应的是只读HTML的抓取器。
  • 文档树里的全部文字:脚本正常跑完之后,整棵树里的文字总量,包括那些被藏起来看不见的。
  • 用户实际看得见的字:脚本正常跑完之后,渲染树里真正显示出来的那部分。

这三个数必须在同一次会话、同一个浏览器实例里取,否则不可比。取词规则也得统一:先把节点复制一份,删掉 scriptstylenoscripttemplatesvgiframe 这六类不属于正文的节点,再取文字,最后把连续空白压成一个空格。

删这六类不是随手定的。前四类装的是代码和待用模板,不是给人读的内容;矢量图里常有大段路径数据和标题文本,算进去会把一个满是图标的页面撑得很虚;内嵌框架里的内容属于另一个文档,不该算在本页头上。文档树里的文字和渲染树里的文字本来就是两个东西,MDN关于innerText的说明把这个分界写得很清楚。

这次的主角是前两个数的比值。第三个数留到另一篇讲,它讲的是完全不同的一件事。

那段字确实在响应体里,只是不以文字的形式

开头翻源码那件事,值得多说两句,因为它是这套口径的由来。

前端框架为了在浏览器里接管页面,通常会把服务端已经准备好的那份数据,序列化成一段JSON塞进 <script> 标签。浏览器拿到之后,脚本读这段数据、生成节点、挂到页面上。整个过程里那些文字一次都没有以文本节点的形式出现在HTML里,它们是字符串常量。

这个形态很要命,因为它同时满足两个互相矛盾的直觉:用文本搜索去grep响应体,能搜到;用任何一个按标签解析的工具去读正文,读不到。于是“内容在不在HTML里”这个问题,答案取决于你用什么去问。

更绕的一层是,这份JSON通常是整页内容的完整副本。也就是说页面把同一批文字装了两遍——一遍在脚本里,一遍在渲染出来的节点里。字节账单按两遍算,可读性按半遍算。

为什么不能拿服务器抓的HTML跟浏览器比

一个很自然的偷懒办法是:用命令行工具抓一份HTML当作“不执行脚本”的版本,再用浏览器抓一份当作“执行脚本”的版本,两边一减。这个办法有个致命问题——两边的身份不一样。

命令行工具发出去的请求,缺 accept-language、缺 sec-fetch-* 那一串、缺客户端提示头,很多站会因此给出完全不同的响应,甚至直接拒绝。这时候量出来的差值里,混着“脚本的贡献”和“身份的贡献”两样东西,分不开。本文后半段有一组对照实验,专门量了这个“身份的贡献”到底有多大。

所以这次两轮都用同一个浏览器内核、同一个用户代理字符串、同一个视口尺寸,唯一的变量是那个脚本开关。页面加载到 domcontentloaded 之后再多等3.5秒,给延迟执行的脚本留时间。

3.5秒这个数是个折中:等得太短会把慢的脚本算成“没渲染”,等得太长整批跑不完,也会把无限轮询的模块算进来。这个值到底影响多大,与其猜不如测。

等1秒和等10秒,结论会不会变?

挑落差最大的25个站,再配12个两轮几乎不变的站当对照,每个站在1秒、3.5秒、10秒三个等待档位各跑一次,只看脚本跑完后的文字量。结果分三层:

  • 中位数完全没动:1秒到3.5秒的增幅中位是0.0%,3.5秒到10秒也是0.0%。对多数站来说,脚本该拼的第一秒内就拼完了。
  • 尾部动得很厉害:1秒到3.5秒之间涨超20% 的有8个站,sostrenegrene从44个字符涨到31052;3.5秒到10秒之间还在涨超20% 的有5个站,brooklinen从6414涨到13873,innisfree从2234涨到7316。
  • 两组的差距说明这个分组本身分对了:重度依赖组从1秒到10秒的增幅中位是8.3%,对照组只有0.6%。

这组数据推翻了我原本的假设。动手之前我以为等待时间只影响绝对量、不影响分档,实测下来正好相反:它对中位数毫无影响,对尾部影响巨大——而尾部恰恰是这篇文章唯一关心的部分。所以3.5秒这个口径是偏保守的,还有5个站在第10秒仍在往上涨,它们在正文里被算成了“丢得比实际更多”。

还有一个反常现象:temu在1秒时2236个字符,3.5秒时3742,到10秒反而掉到300。等得越久越可能撞上风控——多等一会儿不总是拿到更多东西。

131这个分母是怎么剩下来的

起手是171个站的首页,都是海外做得比较像样的独立站和品牌站,覆盖服装、家居、户外、3C、美妆、母婴几个大类。两轮都跑成功的有161个,两轮状态码都是200的剩131个。中间流失的30个不是技术失败,是被拒绝了。这批站后面单独说,它们贡献了本文最有意思的一段。

131里面还有7个站两轮的正文都不足500字符(首页就是一张大图加几个按钮那种),算比值的时候会把分母搞得很难看,所以涉及比值的统计用的是124个站,涉及链接的统计还要再要求链接数大于20,剩115个。每张表下面都会标清楚分母是多少,因为分母一换,同一个现象的百分比能差出一倍。

131个首页,关掉脚本之后丢了多少字?

先给最直接的那个数:中位数是0.907。也就是说,一半的站在这个数之上,关掉脚本之后正文还剩九成多。

为什么先量字数,而不是先量结构

一个合理的疑问是:为什么第一个量的是字数这么粗的东西,而不是直接量“商品名在不在”“价格在不在”这些具体的字段。

因为字段级的检查需要先知道每个站的模板长什么样。171个站有171套模板,选择器写不完,写完了也维护不了,而且一旦某个站改版,那条规则就默默失效——它不会报错,只会静静地返回0,然后被统计成“这个站没有商品名”。

字数这个量的好处是它对模板一无所知,因此不会因为模板变化而失效。代价是粗:它告诉你“有多少字没了”,不告诉你“没的是哪些字”。所以这次的做法是先用字数把站分档,再对落差最大的那批逐个看结构件。先用一把不会坏的尺子划范围,再用会坏的尺子看细节。

分布是两头沉的

光看中位数会得出“没什么问题”的结论,但这个分布不是钟形的,它两头都有货。

关掉脚本后剩余的正文比例站数占比怎么理解
0.95以上4939.5%脚本基本不参与内容,两遍几乎一样
0.80 ~ 0.952520.2%脚本补一点点,多半是推荐位、评价条
0.50 ~ 0.802419.4%有一整块内容靠脚本,通常是商品列表
0.25 ~ 0.50108.1%主体内容里超过一半是脚本拼的
0.05 ~ 0.2586.5%只剩骨架和页脚
0.05以下75.6%几乎什么都没有

分母124。四分位数比中位数更能说明问题:25% 分位是0.627,10% 分位是0.207。四分之一的站丢掉三分之一以上的正文,十分之一的站丢掉将近八成。

换个说法:这不是一个“大家或多或少都有点问题”的分布,而是“大部分站没事,一小撮站把几乎全部工作都交出去了”。这两种分布对应的行动完全不同。前者要做全面改造,后者只需要把那一小撮找出来,剩下的预算省下来干别的。

丢失的绝对量,和丢失的比例不是一回事

比例容易骗人。一个正文只有800字的极简首页丢掉40%,绝对量是320个字符;一个正文四万字的站丢掉10%,绝对量是四千。

按绝对量算:124个站里真正出现丢失的有90个,丢失量的中位数是1629个字符,最大的一个丢了93286个。中位数那个量级大概相当于三四段商品描述,说多不多说少不少。

落差最大的那几个站不在上面两张表里,因为它们两轮都有内容,只是差得离谱:jackery从43291涨到136577,bugaboo从13437涨到64784,flyingtiger从12178涨到60306,joolz从5508涨到51756。这些站关掉脚本时的内容其实完全够用,脚本补上来的那三五万字符是另一类东西——把它们的隐藏结构拆开看,很大一部分是同意管理弹层里的完整条款文本,跟商品毫无关系。这一层留到另一篇细讲。

完全空掉的那8个站

关掉脚本之后正文一个字符都没有的,有8个:aliexpress、bolia、braun、narwal、prose、sostrenegrene、uniqlo、weber。

这里必须补一句诚实的话:这8个里面不是所有站都“选择了客户端渲染”。至少有两个是被反爬机制挡在了一个几乎空白的挑战页上——它返回200,页面上没有内容,但原因跟渲染方式无关。这一层这次没有逐站分辨,所以8这个数字应该读成上界,不是精确值。

另外8个站正文不足300字符,一句完整的导航都装不下:

站点关掉脚本后的正文字符数脚本跑完之后
arcteryx10约1.1万
gymshark1415148
montbell26约5千
avocadogreenmattress3413542
charleskeith81约8千
pandora123约1.9万
rituals171约1.2万
zalando298约1.5万

两批加起来16个站,占131的12.2%。这是这次实测里唯一一个确实值得紧张的数字。站内之前用另一套方法量过一批国际化独立站的首页,判据是“原始HTML里可读字符不足1200”,结论是132份里有28份中招,那次的角度和口径都不同,可以对照着看空壳首页与可读正文的实测。两次结论方向一致,比例差别主要来自样本构成和判据线。

还有10个站,关掉脚本之后文字反而更多

这一条是跑完之后才发现的,也是这批数据里最有用的一段。124个站里有10个(8.1%)关掉脚本之后正文反而更长。

按差值排开,这10个分成截然不同的两拨:

  • 差值超过1000字符的有3个:dreametech关掉脚本时17709字、开着脚本只剩142字,标题直接变成拒绝访问;temu从2197掉到300,标题变空;bigcommerce从9167掉到1031,而它两轮的标题完全相同。前两个是风控,第三个没能解释,只能存疑记着。
  • 剩下7个差值在1到761字符之间:baseus 761、oclean 504、anker 268、thefarmersdog 151、beistravel 68、warbyparker 34、quince 1。

第二拨才是重点。这7个站的差值方向“不合理”——关掉脚本不可能凭空多出内容——所以它们量的其实是别的东西:同一个站、同一个身份、两次访问之间的自然波动。轮播位换了一张图的文案、库存提示变了一句、时间戳滚了一格,都会造成几十到几百字符的差。

这就给出了一条噪声底线:几百个字符以内的差值不该被当成结论。前面那张分布表里0.95以上那一档之所以划在0.95而不是0.99,就是为了把这个量级的噪声关在门外。

对照组不是可选项

上面每一个数字,都建立在一个前提上:两轮之间的差异真的来自那个脚本开关,而不是来自页面自己在抖。

39.5% 的站两轮几乎一模一样

124个站里有49个两轮的正文相差不到5%,其中47个相差不到2%。这批站就是这次的对照组,而且是白捡的:不用额外跑一轮,它们自己站出来了。

它们的意义在于:如果测量工具本身有随机性,比如两次加载的时机不同、广告位轮换、推荐算法给了不同的商品,那么这47个站也应该出现差异。它们没有。这说明工具是稳的,前面那些落差是被测站点自己的性质。

这一条不是形式主义。之前量页面内容差异的时候,第一轮跑出74.4% 的站“爬虫版和用户版不一样”,补上一组什么都不改的对照请求之后,真正由那个变量造成的差异只剩2.2%,剩下的全是页面自己在抖。那次的教训写在页面抖动基线与差异误报那篇里,从那以后每一次对比实验都先配对照。

没有它,前面每个数字都可能是抖动

更麻烦的是,抖动和真差异在数字上长得一模一样。你拿到一个“38% 的站有差异”,光看这个数没法判断它是38% 的站真有问题,还是测量本身有38% 的噪声。能把这两种可能分开的只有对照组,而它的成本不过是多跑一轮。

做GEO类实验的时候这条更要紧,因为那边缺一个天然的对照,很多结论其实是把噪声当成了效果,这件事在给GEO建议配一个注定无效的对照组里单独讲过。

顺带说一次尺子自己出的问题

这批数据在另一个维度上翻过一次车,值得记一笔。最早统计隐藏内容的时候,有个量算出来“被隐藏的文字比整页的文字还多”,最夸张的一个站是8.5倍,那一批125个站里有22个出现这种情况。

原因很蠢:整页文字那个数删掉了脚本和样式节点,隐藏文字那个数没删。于是脚本里的代码被当成了“隐藏的正文”。同一个量在两个地方用了两套剔除规则,出来的比值就是废的。

修法是给统计脚本加一条断言:隐藏文字不得超过整页文字,超了就把这个站标出来。改完重跑,131个站里触发断言的是0个。这条断言比修复本身更值钱,因为它下次还会替你挡一回。

关掉脚本,导航链接会不会跟着一起没?

这是被问得最多的一个担心,逻辑听上去很顺:既然内容靠脚本拼,那菜单大概也是脚本拼的,抓取器读不到菜单,就走不到下一层页面。

中位只丢2.8%

实测下来,关掉脚本之后链接数量的丢失率中位数只有2.8%。绝大多数站的导航结构原本就在HTML里。

这里有个容易读错的地方,顺手说一下。同一批数据里,关掉脚本时的链接数中位是177条,脚本跑完是200条——拿这两个数相除会得出丢了11.5%,跟2.8% 差了四倍。

两个数都没错,它们只是不同的东西:2.8% 是先给每个站各算一个丢失率、再取这些丢失率的中位数;11.5% 是两个中位数相除。中位数不能这么除,因为贡献这两个中位数的根本不是同一批站。凡是看到“中位数A比中位数B少了百分之几”这种句式,都得回去问一句它是怎么算的。

不过这个分布同样是长尾的:90% 分位的丢失率是67.9%,有12个站关掉脚本之后链接少掉一半以上。

站点关掉脚本后的链接数脚本跑完后的链接数
gymshark1432
avocadogreenmattress2225
bolia043
narwal076
sostrenegrene091
uniqlo0127
nespresso1969
graza3995
on72231
thirdlove73361
brooklinen79345
assos104324

把这12个站跟前面那16个空壳站对一遍:重合6个,正好一半——gymshark、avocadogreenmattress、bolia、narwal、sostrenegrene、uniqlo。

另一半不重合,而这一半才有意思。nespresso、graza、on、thirdlove、brooklinen、assos这6个站正文并不算少,丢的偏偏是链接。它们多半是把商品列表和分类入口做成了异步模块,而品牌介绍和文案留在了服务端。

所以链接丢失和正文丢失只是部分重叠,不是同一件事的两种说法。查的时候两样都得看。

“字在路不在”这种情况,全样本里只有一个

真正要验证的不是“有没有站丢链接”,而是“有没有站文字都在、路却断了”。因为这才是那个担心的完整形态:抓取器读到了内容,以为一切正常,却发现不了下一层。

把条件写死——正文丢失不到15%、同时链接丢掉四成以上——在115个可比站里符合的只有1个:ouraring,正文丢14.3%,链接从47条降到25条。

反过来的情况倒有9个:byredo、cluse、iittala、jackery、mackweldon、menuspace、outdoorvoices、parachutehome、shein,它们是文字丢了不少,链接一条没少。脚本补的是文案和描述,骨架早就在HTML里躺着了。

所以这个担心的方向本身就反了。真实世界里更常见的形态是“路都在,字丢了一部分”。

把六类结构件放在一张表里看

只看链接容易以偏概全,把页面里能数的结构件一起量了一遍,情况就清楚多了。分母124:

结构件关掉脚本时的中位数脚本跑完后的中位数两轮相同的站脚本跑完后变多的站
结构化数据块11118(95.2%)3(2.4%)
页面主标题11113(91.1%)7(5.6%)
二级标题6.5874(59.7%)42(33.9%)
商品链接101780(64.5%)40(32.3%)
全部链接177200.535(28.2%)79(63.7%)
图片456438(30.6%)77(62.1%)

这张表有个很整齐的梯度:越是“给机器看的东西”,越不受脚本影响;越是“给人看的东西”,越依赖脚本。结构化数据和主标题几乎纹丝不动,图片和链接则有六成以上的站在脚本跑完后变多。

商品链接那一行值得单独看:中位数从10涨到17,接近一半的商品入口是脚本放上去的。这条跟另一条已知机制会叠加——页面体积超过抓取上限时,被截掉的往往正是排在后面的那批商品链接。两件事撞在一起,一个分类页可能同时丢掉“没渲染出来的”和“没读到那么远的”两批入口,那个体积维度在页面字节预算与抓取截断实测里量过一遍。商品图那一层也有类似的情况,缩略图带没带出来直接决定了后面几张图会不会被翻到,细节在商品图廊的截断与指路里。

结构化数据里的东西,是脚本放进去的吗?

第二个流行的担心是:既然页面靠脚本渲染,那结构化数据大概也是脚本注入的,抓取器一样读不到。

131个站里只有1个

这条实测下来几乎完全不成立。关掉脚本时结构化数据块数为0、脚本跑完后才有的站,只有gymshark一个,而它本来就是整页交给脚本的极端案例。结构化数据块数两轮完全相同的站占95.2%,两轮之间数量变多的只有3个,变少的也有3个。

页面主标题是类似的情况:两轮相同的占91.1%,关掉脚本后主标题掉到0的站只有4个。

这条负结果比正结果有用

负结果通常不受欢迎,因为它不给人事做。但这一条能省掉很多无谓的排查。如果你的站结构化数据没被识别,基本可以直接排除渲染这个方向,去查语法、必填字段、页面与标记是否一致这些更常见的原因。

顺带说一句,这也解释了一个常见现象:很多站结构化数据里写着一套、页面上显示的是另一套。两边根本不是同一个环节生成的,一边来自模板变量,一边来自内容编辑。首页上标题、社交卡片标题和主标题各说各话的情况,在首页六份自我描述的实测里量过,那批不一致跟渲染方式没什么关系。

那“把内容塞进结构化数据”这个招还灵吗

有一种流传很广的做法:正文渲染不出来没关系,把关键信息塞进结构化数据里,机器照样能读。

技术上确实可行,但它的前提被上面这条负结果推翻了一半——结构化数据本来就不靠脚本注入,它并不构成“渲染问题的解药”,它只是本来就在那儿。而结构化数据要求与页面可见内容一致,用它承载页面上根本不存在的信息,是在给自己埋雷。

这是不是某个建站平台的锅?

第三个担心带着一点甩锅的味道:是不是用了某个建站平台,就注定要把渲染交给客户端。

两个数字互相打架

把124个站按是否使用某主流电商平台分成两组,结果很有意思:

该平台的67个站,剩余正文比例中位数0.817,25% 分位0.605,丢一半以上的有12个(17.9%)。非该平台的57个站,中位数0.972,25% 分位0.743,丢一半以上的有13个(22.8%)。

中位数说的是一个故事:平台站点更依赖脚本。尾部说的是相反的故事:严重依赖脚本的站,在非平台组里反而更多。

这两个数不矛盾,它们描述的是分布的不同部位。平台站点因为模板里带了不少异步模块——推荐位、评价、库存状态、加购抽屉——中位数被拉低了一档,但很少走到极端;自建站两极分化,做得稳的完全服务端渲染,做得激进的整页交给前端框架。

换个分组方式,画像清楚多了

既然按平台分不出结论,就换个分法:直接按测出来的结果分成“几乎不变组”和“重度依赖组”,再看这两组站长什么样。

指标几乎不变组(50个站)重度依赖组(25个站)
正文字符数中位50178092
链接数中位149241
请求数中位78143
第三方域数中位510

四项全部翻了将近一倍。这个画像跟“小站省事偷懒”的直觉正好相反:重度依赖脚本的不是简陋的站,是内容更多、功能更多、外部依赖也更多的站。

顺着这条线还有个数字:关掉脚本时全站请求数中位是50,脚本跑完是129.5,脚本把请求数放大了2.6倍。这多出来的八十来个请求里,绝大部分打向的是第三方域——那是另一篇的题目,这里只说一件事:脚本承担的从来不只是“渲染内容”这一件工作。

这个决定通常是谁做的

数据看到这里,“是谁的锅”这个问题其实已经换了形状。它不是平台的锅,也很少是某一个人的锅,它是一个没人认领的默认值。

把这几十个重度依赖脚本的站的情况倒推一遍,路径大同小异:选主题的时候看的是视觉效果和功能清单,装插件的时候看的是能不能解决当下那个业务需求,两件事都不会有人问一句“这个模块的内容进不进HTML”。等到半年后有人发现商品列表抓不到,改造成本已经变成了换主题。

更常见的一种情况是权责被切开:站是外包做的,SEO是另一拨人接手的,第三方模块是运营自己在后台装的。三方各自都没做错,合起来就成了这个结果。问题不在能力,在于渲染方式这件事从来没出现在任何一次验收清单上。

所以真要改这件事,最有效的动作往往不在代码里,而是把“关掉脚本还剩多少”这一条加进选主题和装插件的验收项。它只要占一行,就能挡掉后面一整轮返工。

归因该停在哪里

这次没有做更细的归因,比如按前端框架分组。原因很简单:样本里带 generator 标记的站只有8个,绝大多数站根本不声明自己用了什么。靠特征串猜框架可以做,但准确率没法验证,猜错了整张归因表就废了。

宁可把归因停在一个能站住的粗粒度上,也不要给一张看着很专业、底下是猜的表。审计工具报出来的高准确率往往就是这么来的——分母是自己划的,不是考出来的,这件事在审计工具准确率的分母问题里专门拆过。

真正能站住的一句归因是:分界线不是平台,是这个站有没有人在渲染这件事上做过决定。没做过决定的,就跟着主题和插件的默认值走,而默认值从来不为你的抓取效果负责。第三方脚本的默认行为会自己变,这一点两天半的连续观测就能看出来,记录在第三方脚本默认值的静默漂移里。

开着脚本反而抓不到,这是怎么回事?

这次最意外的一段收获,来自一个本来只是用来做校验的检查项:两轮的页面标题是不是同一个。

六个站两轮标题不同

131个站里有6个(4.7%)两轮标题不一样。按常理标题应该是最稳定的东西,两轮不同意味着拿到的根本不是同一个页面。

站点关掉脚本时的标题脚本跑完后的标题发生了什么
aliexpress验证码拦截脚本跑起来之后触发了风控
dreametech正常的品牌标题Access Denied同上
temu正常的品牌标题同上
gymshark正常的品牌标题整页由脚本渲染
fahertybrand品牌标题品牌标题+促销后缀脚本改写了标题
oclean美国站标题越南语标题脚本做了地区跳转

三个是被反爬拦下的

前三个是同一类:关掉脚本那一轮反而顺利拿到了页面,脚本跑起来之后被拦了。原因不难猜——风控本身也要靠脚本执行,关掉之后它跑不起来,这次访问就滑过去了。

这个现象打掉了一个隐含假设:多做一步、做得更像真人,不一定能拿到更多东西。有时候正好相反。做站点巡检的时候如果只用无头浏览器渲染,就会在这几个站上永远拿到拦截页,还以为是对方站坏了。爬虫身份和实际能不能拿到内容之间的落差,之前按另一个角度量过一次,名单上写着允许、实际仍然进不去的站相当多,细节在robots允许与实际投递的落差实测里。

一个站从美国站变成了越南站

oclean那一行更值得琢磨。关掉脚本时拿到的是美国站的标题,脚本跑完之后变成了越南语。这说明地区分发是在客户端做的——服务端先给一个默认版本,脚本判断之后再跳转。

对不执行脚本的访问者来说,它永远停在那个默认版本上。如果这个站的多语言版本靠语言标注声明,那声明的目标和实际到达的页面就对不上了,而这类互指不一致往往查不出源头,因为问题出在另一端,这一点在hreflang互指与第三方实测里有过一次完整的溯源。

同一个403背后至少三种判据

前面提到流失的30个站,绝大多数返回403。为了搞清楚它们到底在拦什么,另做了一组对照:挑14个站,用四种身份各访问一次——用户代理字符串完全相同,只改“是不是无头模式”和“有没有补齐浏览器请求头”这两个变量。

站点无头+裸请求头无头+补齐请求头有头+裸请求头有头+补齐请求头
asos、bershka、chewy403200200200
arket、mango403403200200
patagonia404404200200
cos403403403200
aesop、hoka、purple、salomon、solostove、yeti403403403403
wayfair429429429429

三种完全不同的判据藏在同一个状态码后面:3个站拦的是缺失的浏览器请求头,补上就放行;3个站拦的是无头模式本身的指纹;1个站两个条件都要满足;还有6个站怎么都进不去,判据在更深的层面。所谓“被拦了”这句话本身没有信息量,得说清楚是被哪一层拦的。

其中patagonia那一行最该记住:它用的是404而不是403。它不告诉你“我拒绝你”,它告诉你“这个页面不存在”。任何按状态码统计站点存活率的脚本,都会把它记成一个死站。

这也是本文全部数字的口径声明:主实测跑的是“无头+裸请求头”这一档,所以那30个被排除的站里至少有6个其实是活的,131这个分母偏保守。

这批拒绝里有明显的集团特征

把30个被拒的站按母公司排一遍,名单立刻变得不像随机分布。同一个西班牙服装集团旗下的六个牌子全在里面,同一个北欧服装集团旗下的四个牌子也成组出现,而且拦截页的形态、字节数、返回的状态码都一模一样。

这说明拒绝策略是在集团层面统一下发的,不是每个品牌站各自配置的。对做竞品监测的人来说这条很实用:遇到一个站抓不到,先查它的兄弟站,往往能省掉一整轮调参。如果兄弟站也全部拒绝,那多半是集团策略,调UA调请求头都没用;如果只有一个站拒绝,才值得往指纹方向排查。

另一个跨批次的对照更有意思:这几个牌子之前对搜索引擎爬虫和社交平台抓取器也一律返回403。也就是说它们拦的不是某一类身份,是所有非浏览器流量。这是一种明确的选择,不是配置失误。

不执行脚本的那一方,到底是谁?

前面所有数字都建立在一个假设上:真的存在不执行脚本的重要访问者。这个假设值得单独检查。

搜索引擎这一侧的现状

主流搜索引擎具备渲染能力,这一点Google的JavaScript SEO基础文档写得很清楚:抓取、渲染、索引是三个分开的阶段,渲染会发生,但它排在一个独立的队列里。所以问题从来不是“会不会渲染”,而是“什么时候轮到你、以及那一刻的执行环境是什么样”。

这也是为什么渲染方式的选择不该只看“能不能被渲染”。不同渲染模式把成本放在了什么位置——服务端渲染、客户端渲染、静态生成、增量再生——各有各的账,web.dev关于网页渲染方式的那篇长文把这几种模式的代价排得很整齐,值得对照着自己的站读一遍。框架站具体怎么配,站内写过一篇React与Next.js框架站的渲染模式选择,这里不重复。抓取、渲染、提取这三段在AI场景下各自会卡在哪,另有一篇GEO技术端的抓取渲染提取做过拆解。

抓内容的那一层,情况不一样

真正的变化发生在另一侧。现在有大量抓取行为不是搜索引擎发起的,而是各种内容管线:正文提取器、检索增强管线、内容采集框架。这些工具绝大多数不带浏览器内核。

这一点可以直接测。用一个本地服务造一个页面,把同一段带唯一标记的文字用不同方式放进去,再让不同的工具去读。结果是:这段文字如果是脚本在页面加载后注入的,常用的正文提取器和文档转换工具都读不到它——尽管那段文字确实存在于响应体里,只不过是以脚本内部一个JSON字符串的形式存在的。trafilatura这类正文提取库的文档里也说明了它工作在HTML层、不执行脚本。

换句话说,对这一层读取方来说,“内容在响应体里”和“内容被读到”完全是两回事。它们不会替你执行那一步。不同渲染模式下AI引用率的差别,站内之前按引用结果量过一轮,见AI爬虫与CSR/SSR/ISR引用率实测;再往底层一点的读取与引用机制,另有一篇AI读取与引用网页的底层机制

自己造一个页面,让各种提取器来读

上一段那个结论不是猜的,是测出来的,方法也很简单,值得抄。

在本机起一个小服务,让它按参数生成一批只有一处不同的页面。每个页面里都埋一段带唯一标记词的文字,标记词随页面编号变化;同时在每个页面上另放一段永远正常显示的文字,也带唯一标记,充当锚点。然后把这批页面的地址喂给要测的工具,看它们的输出里能搜到哪些标记词。

那个锚点是整套设计里最关键的一步。如果连锚点都没读到,说明这次抓取根本没成功,跟被测的形态无关。没有锚点,“读不到”和“没抓到”这两件事会混在一起,而它们的结论完全相反。这次就靠锚点抓出过一次问题:第一版用某个接口去读无障碍树,连完全正常显示的锚点都读不到,说明那个读法本身就是错的,换了取法之后锚点才全部命中,前面的数据也才敢用。

台子必须搭在本机,不能搭在线上。线上的运行环境会自动补上一些响应头,网页服务器还会再补一次,想造出“什么都不声明”的场景根本做不到。这个坑之前在别的实验里踩过一次,代价是整批数据作废。

摘录式抓取让这件事更要紧

还有一个更新的变量。有人跟踪了ChatGPT搜索工具调用格式的变化,发现新的查询语言里带一个长度参数,取值是长、中、短三档,这份对新查询格式的逐行拆解推测它控制的是每个结果取回多少文本。如果这个读法成立,那么多数抓取拿走的是页面的一段有界摘录,而不是整页。

这就把两件事叠在一起了:内容要先能被读到,还得排在足够靠前的位置。一个靠脚本在页面底部补出来的段落,在这两道关上都不占便宜。

被推荐和被引用,本来就是两件事

顺着这条线还有个数字值得放在这儿。有机构测了三个主流AI工具在购物类问题上给出的1851个来源,属于品牌自有页面的只有2.8%;即便在AI明确点名推荐某个品牌的场景里,引用品牌自己页面的也只占31%。同一份分析里还有个细节:能干净测到原始HTML的173家店里,有27家的商品页自有文案不足50个词,这份关于AI推荐与引用来源的分析把这两个数放在了一起。

它没有证明这两件事之间有因果,作者自己也强调了这一点。但方向是清楚的:页面上能被直接读到的自有文字太少,是一个真实存在的普遍状况,而渲染方式只是造成这个状况的原因之一。至于爬虫和用户看到的页面到底哪里不一样,站内有一款专门做这件事的渲染对比器,比手工翻源码快得多。

十分钟自查,和三种对应的修法

把这件事落到自己站上,不需要复杂工具。

先做那个减法

最快的办法是在浏览器开发者工具里把脚本执行关掉,硬刷新一次首页和一个商品页,然后做三件事:全选复制页面文字看看剩多少,数一数导航还在不在,看看主标题还在不在。

要量化就跑两轮自动化:同一个浏览器、同一个身份,只改脚本开关,两轮都用那套统一的取词规则。关键是两轮之间别改任何其他东西,包括别在其中一轮加代理、换地区、改窗口大小。

另外记得配对照:找一个你确定是服务端渲染的页面(比如一篇静态博文)一起跑,如果它两轮也差很多,那说明是测量方法有问题,不是站有问题。

三种结果,三种修法

测出来的样子大概率的原因该做什么
剩余比例0.9以上脚本只补了边角模块什么都不用做
剩余0.5 ~ 0.9,丢的是列表和推荐位异步加载的模块判断这些模块要不要被索引,多数不需要
剩余0.5以下,主体内容缺失整页交给客户端渲染把首屏主体改成服务端输出,其余可以留着
两轮差值只有几百字符页面自然抖动不是结论,别写进报告
两轮标题就不一样拿到的不是同一个页面先查是不是被风控拦了,别急着改渲染

什么不必改

这次实测最实用的一条,可能是那几条负结果给出的“不用管”清单:导航链接绝大多数本来就在HTML里;结构化数据95.2% 的站不受渲染影响;主标题91.1% 不受影响。这三样是排查时最容易被顺手改一遍的东西,而数据说它们基本没坏。

还有一个常见的过度反应是给整站补 noscript 兜底内容。这个做法在极少数场景下有意义,但维护成本极高,而且很容易变成两套内容不一致的源头。与其维护两份,不如把首屏那一块真的搬回服务端。

哪些模块本来就不该被索引

发现某块内容抓不到之后,第一反应通常是“得让它抓得到”。这个反应在一半以上的情况下是错的。

把首页上常见的异步模块过一遍就明白了。猜你喜欢、最近浏览、个性化推荐这三类,内容因人而异,让它们进索引只会制造一堆内容不稳定的页面;实时库存和倒计时属于状态而不是内容,抓到了也马上过期;评价流和社交墙的原文属于第三方,抓到了还要多考虑一层归属问题。这几类的正确做法恰恰是让它们留在客户端。

真正必须搬回服务端的,是那些“这个页面之所以是这个页面”的东西:商品名、价格、核心卖点、分类结构、正文。判断方法有个土办法——把这块内容删掉,这个页面还成立吗?不成立的就搬,成立的就留着。

按这个标准过一遍,那25个重度依赖脚本的站里,真正需要动的往往只有首屏那一块,而不是整套渲染架构。改造范围一下就从“换主题”缩到“改一个模板”。

如果短期内改不了渲染方式

有些情况确实动不了:平台不开放模板、主题是买的、排期在三个月后。这时候有几件成本低得多的事可以先做。

最直接的是把最关键的那几句话——商品名、一句话卖点、价格区间——用服务端能输出的位置再写一遍,比如页面标题、描述、面包屑、结构化数据。这不是钻空子,这几个位置本来就该写这些内容,只是很多站把它们空着或者填了模板默认值。

其次是检查首屏那块内容有没有可能改成“服务端先出一份、脚本再接管”。很多前端框架支持这个模式,切换成本远低于重构。

最后一件是给自己留个记录:把当前的剩余比例、测量日期、口径写进项目文档。等到有排期的时候,这份记录就是排期理由,比临时再量一遍有说服力得多。

怎么跟不同的人说这件事

这类检查最后卡住的地方常常不是技术,是说不动人。同一个结论对着三种人要换三种说法。

对前端说,落点是“首屏这块内容要服务端出一份”,别说成“你们的渲染方式有问题”——后者听上去是在否定整套架构,前者是一个明确的、有边界的活儿。对运营说,落点是“你在后台装的那几个模块,内容进不了搜索引擎”,配一张关掉脚本的截图比任何数字都管用。对老板说,落点是“我们有多少商品入口是机器看不到的”,把那个绝对数字说出来,比说百分比有效。

三种说法背后是同一份数据,区别只在于把哪个数放在第一句。报告写成一份,讲的时候拆成三份。

把它接进流水线

这个检查值得做成常态。做法是在构建流水线里加一步:对几个关键模板各跑一次双轮抓取,把剩余比例写进构建日志,跌破阈值就告警。阈值不必统一,用自己上一次的数当基线就行。

阈值之外还要记一个东西:那7个站给出的噪声底。把“多大的差值才算差值”这个数一起存下来,否则半年后有人看到一个3% 的波动,会当成回归缺陷去查一整天。

保哥的习惯是给这类检查配一条“这次没有算什么”的说明,跟数字放在一起。因为半年后回头看那个数字的人,多半不记得当初的口径,而口径变了数字就不可比。

这一趟量下来,最值钱的是那三条负结果

回头看,这次实测真正改变判断的不是“5.6% 的站关掉脚本一个字不剩”那个吓人的数——那个数只是把一个已知的坏情况量化了。

真正有用的是三条否定:导航基本不丢、结构化数据基本不丢、平台不背这个锅。因为它们各自砍掉了一个方向的排查工作,而排查工作是有成本的。一个团队如果按“渲染出问题了”这个假设去查结构化数据没被识别的原因,可以白花两周。

把脚本关掉再看一眼,本身只要十分钟。这十分钟给出的不是“要不要改”,而是“该不该往这个方向查下去”。大多数时候它给的答案是不用,而这个答案值钱得多。

常见问题解答

Google既然能渲染脚本,我是不是可以完全不管?

不能完全不管,但也不必当成头等大事。渲染确实会发生,只是它在一个独立队列里,时机不受你控制。更实际的理由是另一侧:大量内容抓取管线不带浏览器内核,它们只读HTML。判断标准很简单——如果你在乎被这些管线读到,就得管;如果只在乎搜索排名,优先级可以往后放。

关掉脚本测出来剩60%,算严重吗?

不算。这次实测的中位数是90.7%,25% 分位是62.7%,剩60% 差不多正好在四分位线上,属于中间偏下但很常见的一档。真正值得动手的是剩余比例掉到50% 以下、而且丢的是主体内容那一类。丢的如果是推荐位、评价、猜你喜欢,多数情况下反而不该让它们被索引。

我的站是单页应用,是不是一定有问题?

不一定。单页应用只是说明客户端接管了路由和交互,它完全可以配服务端渲染或预渲染,首屏内容照样在HTML里。判断依据是实测数字,不是技术栈名称。这次样本里有几个明显由前端框架驱动的站,两轮差异不到2%。

为什么不用命令行工具抓一次HTML就当作“关掉脚本”的版本?

因为身份不同。命令行工具默认缺一大批浏览器才会发的请求头,很多站会因此给出不同的响应甚至直接拒绝。这样量出来的差值里混着“脚本的贡献”和“身份的贡献”,分不开。这次专门做了一组对照,14个被拒的站里有3个只要补齐请求头就放行了。

结构化数据能不能替代读不到的正文?

不建议。技术上机器确实读得到,但结构化数据要求与页面可见内容保持一致,用它承载页面上不存在的信息会带来风险。而且这次实测发现结构化数据本来就极少受渲染影响,95.2% 的站两轮块数完全相同,它不是渲染问题的解药,它只是本来就在那儿。

首页测完了,其他页面要不要都测?

按模板测,不按页面测。同一套模板出来的页面渲染行为基本一致,挑首页、分类页、商品详情页、内容页各一个就够。真正需要单独测的是那些用了不同模板的落地页,它们常常是另一个团队做的,也常常是唯一没走标准构建流程的那一批。

这次的数字能不能直接套到中文站上?

不能直接套。样本全是海外独立站和品牌站,技术栈选择和国内站有系统性差别。可以借用的是方法和口径,数字要自己量。一个站的基线只对它自己有意义。

两轮之间要不要多跑几次取平均?

值得。这次每站每轮只跑了一次,是为了控制总时长,代价是没法区分单次抖动。如果是给自己的站建基线,建议每轮跑三次取中位数,同时记录三次之间的离散程度——那个离散程度本身就是你的噪声底,比任何单次数字都有用。

丢的那部分内容,会不会过一会儿就被渲染补上了?

对搜索引擎来说有这个可能,渲染确实会在稍后发生。但有两个前提要注意:一是渲染排队的时机不受你控制,新页面和改动页面的时效会受影响;二是渲染执行的是抓取器真正取到的那部分代码,如果页面体积超了抓取上限被截断,后半段的脚本压根不会执行。对不带浏览器内核的抓取管线来说则完全没有这个可能,它们只有一次机会。

为什么有的站关掉脚本反而字更多?

两种原因。差值超过一千字符的,基本都是风控:脚本跑起来触发了拦截,拿到的是一张拒绝页。差值只有几十到几百字符的,就是页面自然抖动,轮播文案、库存提示、时间戳都会造成这种差别。判断方法是看标题:标题变了就查风控,标题没变就是噪声。

权威参考资料

分享到
标签
版权声明

本文标题:《JS渲染实测131个站:担心的三件事,两件没发生》

本文链接:https://zhangwenbao.com/js-rendering-loss-three-myths-audit.html

版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交