工具说canonical在head,浏览器说在body,做SEO的该信哪一边

工具说canonical在head,浏览器说在body,做SEO的该信哪一边
张文保 更新 28 分钟阅读 2,550 阅读
本文目录
  1. 一个写在head里的canonical,凭什么会跑到body里去?
  2. 三方证词都是真的,只是问的不是同一个问题
  3. HTML标准到底怎么规定head的结束?
  4. 这份白名单里有两个容易看走眼的成员
  5. 54组样本喂给四个解析器,分歧出在哪几组?
  6. 分歧集中在哪几类
  7. 按四类分组看,结果的形状更清楚
  8. Python自带那个解析器,在这类问题上是全瞎的
  9. lxml的错法更危险,因为它错得很像对的
  10. 连按标准写的那个库也会错
  11. 哪些东西一进head,就会把它当场关掉?
  12. 安全的:进去了也不会关掉head
  13. 危险的:一进去head就没了
  14. 三条特别值得单拎出来讲
  15. 注释这一类,没有你以为的那么安全
  16. 还有一个只吃掉一个标签的写法
  17. 开着脚本和关着脚本的解析器,为什么给出相反的答案?
  18. 标准里确实写了两条路
  19. 怎么自己验一遍
  20. 有没有真实的大站,正在犯这个错?
  21. 但根因跟前面那些样本完全不同
  22. 121个站扫下来,这个坑到底有多常见?
  23. 修正之后,那5个站还得再筛一遍
  24. 写标准的那家自己不写 </head>
  25. 那么现网的标签排在第几位?
  26. 顺手量到的三个背景数字
  27. 你手上那个SEO工具,用的是哪个解析器?
  28. 三类工具的表现
  29. 写脚本的话,三行就能换掉那个默认解析器
  30. 该怎么自查,又该怎么把它挡在上线之前?
  31. 自查:四步,十分钟能跑完
  32. 加固:把规则写进模板,而不是写进检查清单
  33. 一条别搞反的边界
  34. 常见问题解答
  35. canonical标签写在body里,谷歌到底认不认?
  36. 怎么快速判断我的head是不是被提前关掉了?
  37. 把GTM的noscript那段放进head,到底有没有事?
  38. 用BeautifulSoup写的检查脚本为什么一直报正常?
  39. 页面里多了一个 </head>,要紧吗?
  40. 结构化数据放在body里需要挪回head吗?
  41. 用框架做的站,这件事要怎么盯?
  42. 权威参考资料

摘要:浏览器不是读到</head>才关掉head的,而是读到第一个不该出现在那里的东西就当场关掉。保哥拿54组只改一个变量的样本,喂给四个HTML解析器,canonical落点一致的只有20组,分歧34组;同一段谷歌代码管理器的安装片段,关掉脚本的解析器把canonical判进body、开着脚本的判在head,两边都合规。121个站扫下来,真正把标签写越界的极少,但工具查不出来这件事一直在。

一个写在head里的canonical,凭什么会跑到body里去?

先说清楚这篇要处理的是什么问题。它不是把canonical手写在body里那种低级错误,也不是模板变量没渲染出来。它是这么一件事:你打开源代码,一眼看过去canonical明明白白排在 </head> 上面第三行;可浏览器解析完,它在body里。

这不是浏览器有毛病。HTML标准里head的结束条件从来就不只有 </head> 这一个。解析器一边读一边判,读到某个它认为不属于head的东西,就地把head合上、把body打开,然后接着往下走。你写的那个闭合标签,很可能在它眼里早就是多余的了。

这件事之所以值得单独写一篇,是因为它的失效方式特别安静。谷歌官方在讲规范网址那份文档里写得很明确,rel=canonical必须放在head区域里;放在别处,它不认。而你手上大部分的检查工具,用的解析器跟浏览器不是同一个,它们会告诉你一切正常。

三方证词都是真的,只是问的不是同一个问题

保哥第一次撞上这类问题时,最费解的就是这一点:抓取工具说canonical在head,浏览器开发者工具说在body,源代码看上去又在head。三边都没撒谎。工具报的是它自己那个解析器建出来的树,浏览器报的是渲染引擎建出来的树,而源代码只是一串字节——把字节变成树的那一步,才是分歧发生的地方。

HTML标准到底怎么规定head的结束?

去翻标准的解析章节,会看到一个叫in head的插入模式。解析器进到head之后就处在这个模式里,它只接受一份很短的白名单:

类别元素说明
元数据base、link、meta、title日常写SEO标签用的就是这几个
脚本与样式script、style、template、noscript内容按原始文本处理,不当标记解析
历史遗留basefont、bgsound、noframes老元素,今天基本不会写
其他注释、空白字符不影响判定

名单之外的任何起始标签,或者任何非空白的字符,都会触发同一个动作:解析器把head弹出栈,切到after head模式,然后重新处理刚才那个东西。重新处理的结果通常是打开body。也就是说,head的边界是被内容推出来的,不是被闭合标签划出来的。

这份白名单里有两个容易看走眼的成员

一个是 noscript。它在白名单里,但它内部允许放什么,取决于解析器有没有开启脚本这个标志——这条待会儿会变成本篇最反直觉的一段。另一个是 template,它2014年才进标准,很多老解析器压根不认,认不认的差别会直接体现在canonical的落点上。

54组样本喂给四个解析器,分歧出在哪几组?

光读标准没用,得量。保哥搭了一套单变量样本:同一份骨架,head里固定放charset和title,然后插入一段东西,再跟上canonical、description、hreflang、robots四个标签,最后写 </head>。每组样本之间只有插入的那一段不同,其余一个字节都不差。

一共54组,分成四类:标准白名单里的元素、明确越界的元素、现网真实事故形态、畸形写法与边界。四个解析器分别是:

  • html5lib:Python生态里按标准实现的那个,被当成参照物用
  • lxml:底层是libxml2,速度快,大量爬虫和SEO脚本用它
  • html.parser:Python自带,也是BeautifulSoup不指定解析器时的默认选择
  • Chrome 150:走两条路,一条是DOMParser,一条是真实文档导航

结果先给总数:四个解析器对canonical落点意见一致的只有20组,分歧34组,分歧率63%。

分歧集中在哪几类

样本Chromehtml5liblxmlhtml.parser
head里插 <div>bodybodybodyhead
head里插 <img>bodybodybodyhead
head里插 <svg>bodybodyheadhead
head里插 <main>bodybodyheadhead
head里插 <input>bodybodyheadhead
自定义元素 <my-widget>bodybodyheadhead
head里插 <template>headbodyheadhead
<title> 忘了闭合不存在不存在head不存在
多写了一个 </head>headhead找不到游离

这张表里最该被记住的是最后两行,还有html.parser那一整列。

按四类分组看,结果的形状更清楚

分组组数canonical留在head掉进body从DOM里消失
标准白名单元素121200
明确越界的元素170170
现网真实事故形态15393
畸形写法与边界10442

第一行和第二行是干净的:白名单里的东西全部安全,名单外的东西全部越界,没有例外。真正的麻烦全在后两行——那15组事故形态里,有3组安全、9组掉进body、3组让canonical整个消失消失这一档是最难查的,因为你的检查脚本会报告“页面上没有canonical”,而你打开源代码明明看见它写在那里。

Python自带那个解析器,在这类问题上是全瞎的

17组明确越界的样本里,html.parser 全部报告canonical在head,而浏览器全部判在body。它的问题不在于判错,而在于它压根不建head和body这个层级——它只是把标签串起来,谁包着谁全看写法。用BeautifulSoup又没指定解析器写出来的审计脚本,跑这一类检查等于没跑。

lxml的错法更危险,因为它错得很像对的

lxml在 <div><p> 这些老元素上判得对,但碰到svg、main、input、button和自定义元素就判成head。也就是说,越是现代前端写出来的页面,它越容易漏报。最要命的是title忘闭合那一组:浏览器认为整个文档从title之后全被吞掉、canonical根本不存在,lxml却报告canonical好端端在head里——这是彻头彻尾的假阴性。

连按标准写的那个库也会错

html5lib在template那一组判成body,而Chrome判head。template在标准白名单里,Chrome是对的。拿标准当依据没错,但标准的实现有版本、有滞后,把某个库的输出直接当成标准本身,迟早会栽。

哪些东西一进head,就会把它当场关掉?

把54组按结果重排,能得到一份可以直接拿去对照模板的清单。以Chrome的判定为准。

安全的:进去了也不会关掉head

写法canonical落点备注
<script> 内联或外链head内容当原始文本,里面写什么都不影响
<style>head同上
HTML注释head包括IE条件注释那种老写法
空行、缩进、换行head空白字符不参与判定
<template>head标准允许,但老解析器不一定跟得上
孤零零一个 </div>head没有配对的结束标签被直接忽略
多写一个 </head>head见下文
重复的 <html><head>head属性会被合并,标签本身忽略

危险的:一进去head就没了

写法典型来源
<div><span><p><a><h1>第三方挂件的安装片段整段贴进head
<img>统计像素图,那种1×1的透明图
<iframe>各类嵌入代码
裸文本(中英文都算)模板变量没渲染出来,或者少写了一对标签
后端报错信息某个提示直接打印在了页面最前面
文件中间混进的字节顺序标记多文件拼接时带进来的
&nbsp; 这类实体可视化编辑器插的
自定义元素组件库在head里注册占位
<div/> 这种自闭合写法写惯了别的模板语言

三条特别值得单拎出来讲

第一条,多写一个 </head> 反而没事。这个结果第一次跑出来的时候保哥是不信的:明明提前把head关了,后面的canonical居然还在head里。原因在标准的after head模式——这个模式遇到link、meta这类元素,会把它重新塞回head元素里去。换句话说,解析器允许你在关门之后再往里塞元数据,但不允许你在关门之前塞别的。

第二条,<div/></div> 只差一个斜杠的位置,结果完全相反。前者被当成起始标签,越界,head当场关掉;后者是没有配对的结束标签,被忽略,一切照旧。习惯了自闭合写法的人,在手写head时特别容易踩这一条。

第三条,script或style忘了闭合,canonical不是掉进body,是直接消失。因为这两个元素的内容按原始文本处理,找不到结束标签就一路吃到文件末尾。这一组样本在真实浏览器里加载,连h2都渲染不出来——整个页面变成了一段脚本。这跟编辑器带进来的隐形字节把页面搞白屏是同一类故障:现象吓人,根因只有一个字符。

注释这一类,没有你以为的那么安全

上面那张安全表里写了注释不影响判定,这话只对写规范的注释成立。两组样本对比一下:

写法结果为什么
<!--[if lt IE 9]>…<![endif]-->安全整段就是一个合法注释,解析器直接跳过
<!-- a <!-- b --> c -->越界注释在第一个 --> 就结束了

第二行这个形态在真实项目里出现得比想象中多:有人想把一段已经含注释的代码整体注释掉,于是在外面又包了一层。HTML的注释不支持嵌套,解析器读到第一个 --> 就收工,剩下的 c --> 成了裸文本,head当场关闭。这个坑的隐蔽之处在于,编辑器的语法高亮通常会把整段染成注释色,看上去毫无破绽。

同一类还有一个:< div>,标签名前面多了个空格。这在HTML里不构成标签,整段被当成文本,照样把head关掉。而它在编辑器里也是一副人畜无害的样子。

还有一个只吃掉一个标签的写法

<meta name="x" content="y 的引号漏掉一个,结果是canonical消失,而后面的description、hreflang、robots还好端端在head里。属性值里的引号没闭合,会把紧跟着的那个标签整个吞进属性值,吞完就恢复正常。这种只掉一个的形态最难查,因为整页看上去就是少了一个标签,谁都会先怀疑是模板没输出。

开着脚本和关着脚本的解析器,为什么给出相反的答案?

这是本篇最值得看的一段。

谷歌代码管理器的安装说明分两块:一块script放head,一块noscript放body开头。保哥见过不止一次有人图省事,把两块一起贴进了head。于是就有了这组样本:head里放一个 <noscript>,里面裹着一个 <iframe>

解析路径脚本标志canonical落点
Chrome的DOMParser关闭body
Chrome真实文档导航开启head
Chrome的iframe写入开启head
html5lib关闭body

同一段HTML,同一个浏览器内核,两种结果。而且两种都是对的

标准里确实写了两条路

解析器进到head里的noscript时会看一个脚本标志。开着的时候,noscript的内容按原始文本处理,里面塞什么都无所谓,head不受影响。关着的时候,解析器进入一个叫in head noscript的模式,这个模式只接受link、meta、style那一小撮,iframe不在其中——于是它退出noscript、关掉head,canonical跟着掉进body。

把这条对照拉长了看:同一个页面,执行脚本的那一遍和不执行脚本的那一遍,head的边界可以不一样。而搜索引擎抓取本来就分两个阶段,第一阶段拿到HTML先解析一遍、渲染排在后面。抓取和渲染分几步这件事过去主要影响的是内容能不能被看到,这里它影响的是元数据算不算数。

怎么自己验一遍

不需要装任何东西。在浏览器控制台里跑这两行,比较结果:

// 路径一:不执行脚本
var d1 = new DOMParser().parseFromString(html, 'text/html');
console.log(d1.head.contains(d1.querySelector('link[rel=canonical]')));

// 路径二:当前这个真实文档
console.log(document.head.contains(document.querySelector('link[rel=canonical]')));

两行结果不一致,说明你的head里有东西正卡在这条分界线上。保哥的建议是把这两行做成书签脚本,比记住哪些标签越界实用得多。

有没有真实的大站,正在犯这个错?

有,而且它的形态跟前面所有实验样本都不一样。

普查里Airtable的首页被标记出来:canonical、description、og:title、og:image四个标签全部落在body。保哥去复现,连着抓了六次,抓到了两个不同的构建产物——

版本字节数head子元素canonicaltitledescription
第1、2次47321772bodybodybody
第3次44424396headheadhead

同一个域名,同一分钟内,两个版本同时在线。这本身就够说明问题了:这类故障不是常驻状态,是跟着构建产物走的。你今天查是好的,明天客户截图给你是坏的,两个人都没看错。

但根因跟前面那些样本完全不同

保哥原以为是head里混进了越界元素,扫了一遍,</head> 之前只有html、head、link、meta、script、title六种标签,一个越界的都没有。于是去数字节:

  • </head> 在第 12208 字节,正常闭合
  • <link rel="canonical"> 在第 470581 字节
  • 两者相隔 458373 字节,也就是448 KB
  • 整份HTML里 根本没有 <title> 标签

这不是解析器把标签踢出去了,是服务端就把它们输出在body里。这是新一代框架的流式服务端渲染带来的形态:组件树里任何位置都可以声明元数据,运行时再由框架把这些节点搬到head去。搬运这一步靠JavaScript,只抓HTML不执行脚本的那一遍,看到的就是body里的canonical和一个没有标题的页面。

这跟前后端分离之后SEO基建得自己重搭是同一条脉络:框架把便利给了写组件的人,把验证的责任留给了你。

121个站扫下来,这个坑到底有多常见?

先说方法,再说结论,因为方法里踩了一个必须交代的坑。

样本是125个域名的首页,抓到正文并解析成功的121个。第一版扫描脚本判定99.2%的站在 </head> 之前有越界内容——这个数字大得离谱,一看就不对。查下来是扫描器的问题:它逐个匹配标签名之后,指针只跳到标签名末尾,属性那一段被当成了裸文本。改成完整消费一对尖括号、并正确跳过引号里的 > 之后,数字变成 4.1%,5个站

这条教训跟数据本身一样重要:一个高得离谱的比例,八成不是发现了新大陆,是量错了。先怀疑分母和判据,再怀疑现网。

修正之后,那5个站还得再筛一遍

4.1%这个数字也不是终点。逐个打开看,5个里有4个根本不算事故:

站点被标记的原因复核结论
两个技术站越界内容是一堆乱码字节抓取时没解压,是我这边的假阳性
一个谷歌系文档站head里出现 <a><p>抓到的是它的404页,不是首页
whatwg.orghead里出现 <header>故意写的,见下文
一个电商平台第6字节就是 <body>页面本身就是个跳转壳子

所以最终结论是这样的:121个站里,因为越界元素把SEO标签挤进body的,一个都没有。唯一真出问题的是前面那个流式渲染的案例,而它的机制完全不同。

写标准的那家自己不写 </head>

whatwg.org这条特别值得说。它的首页源码里根本没有 </head>,也没有 <body>——写完title、link、meta、style之后,直接一个 <header> 起头开始写页面内容。head就是被这个 <header> 关掉的。

这不是疏忽。HTML标准明文允许省略这几个标签,而制定标准的那帮人显然对自己的解析规则有充分信心,干脆用官网演示了一遍:边界从来就是被内容推出来的,闭合标签只是一种可选的写法。整篇文章讲了半天的机制,官方文档站用一份不到五千字节的首页给了个活样本,还挺幽默的。

当然,这个写法有个前提:它把所有元数据都排在了那个 <header> 前面。顺序对了,省不省闭合标签都无所谓;顺序错了,写十个 </head> 也救不回来。

那么现网的标签排在第几位?

既然顺序是唯一真正的保险,就该量一量大家排得怎么样。98个站的canonical在head里的排位:

分位canonical排在第几个
最靠前第3个
四分之一分位第15个
中位第24个
四分之三分位第48个
九成分位第73个
最靠后第169个(该站head共181个元素)

把它翻译成风险语言:只有2个站把canonical排在前5位,而23个站排在第50位之后。排在第169位意味着它前面挤了168个元素,任何一个出问题都能把它甩出head。前面那条建议不是纸上谈兵,是因为现实里几乎没人这么做。

顺手量到的三个背景数字

第一,head有多长。子元素数中位65、四分之三分位90、九成分位120,最长一个477。按字节算更夸张:某新闻站的 </head> 落在第2398966字节,也就是 head本身就有2.3 MB;另一个建站平台是1.7 MB。head长到这个程度,它已经不是元数据区,是个货仓。

第二,各类标签的落点与缺失情况。这份表顺手回答了另一个问题——大家到底写没写:

标签在head在body压根没有
title11614
meta charset108013
viewport11515
description104116
canonical98122
hreflang73048
meta robots48172
JSON-LD502051

第三,JSON-LD那一行是对照组,别看错。结构化数据放body完全合法,谷歌两边都读,20个站放body一点问题没有。看到JSON-LD在body别慌,它和canonical在body是两回事。调试结构化数据时这一点经常被搞混,顺带在这里说清楚。

你手上那个SEO工具,用的是哪个解析器?

这才是这篇最该落地的地方。前面所有分歧最终都会变成一句话:工具告诉你的,是它那个解析器看到的世界。

三类工具的表现

工具形态底层解析这类问题查得出来吗
浏览器扩展、开发者工具渲染引擎本身能,但看的是渲染后的DOM
桌面抓取工具多数内嵌浏览器内核能,注意是否开了JS渲染
自己写的Python脚本看你选了哪个默认那个基本查不出来
正则匹配式的检查没有解析完全查不出来

正则那一类值得多说一句。grep 'rel="canonical"' 这种检查,只能回答页面里有没有这串字符,回答不了它在树的哪个位置。而这篇讲的整件事,恰恰只发生在位置上。整页meta标签体检这类工具如果只做字符串匹配,评分再细也绕不开这一层。

写脚本的话,三行就能换掉那个默认解析器

# 不要这么写,默认解析器不建 head/body 层级
soup = BeautifulSoup(html, 'html.parser')

# 按标准来,慢但准
soup = BeautifulSoup(html, 'html5lib')
can = soup.select_one('link[rel="canonical"]')
print(can.find_parent(['head', 'body']).name)

html5lib比lxml慢一个数量级,全站扫描时确实肉疼。折中办法是分两轮:先用lxml快扫一遍拿出候选,再用html5lib复核那一小撮。这跟日志分析里先粗筛再精算是一个路数。

该怎么自查,又该怎么把它挡在上线之前?

自查:四步,十分钟能跑完

  1. 先看渲染后的树。控制台跑 document.head.children.length,再跑一次判断canonical父级的那行。head只有个位数子元素,基本可以确定被提前关掉了。
  2. 再看没执行脚本的那一遍。用上面那段DOMParser的代码比对。两遍不一致,问题就在noscript或者框架的运行时搬运上。
  3. 数字节。</head> 的偏移和canonical的偏移打出来,后者大于前者就说明它本来就写在body里,跟解析器无关。
  4. 换个解析器再跑一遍。如果你的审计脚本一直报正常,先怀疑解析器,别怀疑页面。

加固:把规则写进模板,而不是写进检查清单

  • 把SEO标签排在head最前面。charset、title、description、canonical这几个紧跟着 <head> 写。前面越干净,越界点在它们后面的概率越大。这是成本最低、收益最直接的一条。
  • 第三方代码只贴它指定的那一块。安装说明写着body的那段就放body,别整块复制。谷歌代码管理器那两块分开是有原因的。
  • 模板变量输出前先判空。裸文本进head的最大来源就是变量没渲染出来,或者调试信息漏出来。
  • 持续集成里加一条断言。用按标准实现的解析器解一遍产物,断言canonical、description、robots的父节点是head。这条断言的成本是几秒钟,能挡住的是几周的收录波动。
  • 框架站要单独验一遍不执行脚本的产物。看curl拿到的原始HTML里,这些标签到底在哪一段。

一条别搞反的边界

不是所有元数据都非head不可。结构化数据放body合法;开放图谱那套标签虽然规范上要求在head,但主流抓取方普遍容错。真正卡得死的是canonical和hreflang,这两个规范网址与多语言标注相关的标签,位置错了就是不算数。用响应头下发这些指令是个绕开位置问题的办法,代价是改起来要动服务器配置。

常见问题解答

canonical标签写在body里,谷歌到底认不认?

不认。谷歌在合并重复网址那份文档里明确写了rel=canonical要放在head区域。放在body里,它会被当成没写,谷歌转而用自己那套选规范页的逻辑去挑一个,挑中的未必是你想要的那个。

怎么快速判断我的head是不是被提前关掉了?

控制台跑 document.head.children.length。正常站点这个数字在几十上下,中位是65。如果只有两三个,基本可以确定head在很靠前的位置就被关掉了,后面所有元数据都掉进了body。

把GTM的noscript那段放进head,到底有没有事?

执行脚本的解析路径没事,不执行脚本的路径会把它之后的元数据全部挤进body。既然分歧存在,就没必要冒这个险——安装说明让放body开头,照做就行,改动量是零。

用BeautifulSoup写的检查脚本为什么一直报正常?

大概率是没指定解析器,用了Python自带的那个。它不建head和body的层级,17组明确越界的样本它全部报告canonical在head。把第二个参数换成html5lib再跑一遍,结果会很不一样。

页面里多了一个 </head>,要紧吗?

实测四个解析器里有两个照常把后面的link和meta收回head,Chrome也是这么处理的。标准的after head模式允许这么做。虽然不影响,但这通常说明模板拼接有问题,顺手修掉更好。

结构化数据放在body里需要挪回head吗?

不需要。JSON-LD放body是合法的,普查里121个站有20个就放在body。这一项和canonical不是一回事,别一起改。

用框架做的站,这件事要怎么盯?

盯没执行脚本的那一份产物。用curl拿原始HTML,找 </head> 和canonical各自的字节偏移,后者大就说明它输出在了body里,靠运行时才搬回去。这一类站还要顺带确认 <title> 在不在原始HTML里——本篇那个案例连title都是脚本生成的。

权威参考资料

分享到
标签
版权声明

本文标题:《工具说canonical在head,浏览器说在body,做SEO的该信哪一边》

本文链接:https://zhangwenbao.com/head-boundary-canonical-parsed-into-body-seo.html

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

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