工具说canonical在head,浏览器说在body,做SEO的该信哪一边
本文目录
- 一个写在head里的canonical,凭什么会跑到body里去?
- 三方证词都是真的,只是问的不是同一个问题
- HTML标准到底怎么规定head的结束?
- 这份白名单里有两个容易看走眼的成员
- 54组样本喂给四个解析器,分歧出在哪几组?
- 分歧集中在哪几类
- 按四类分组看,结果的形状更清楚
- Python自带那个解析器,在这类问题上是全瞎的
- lxml的错法更危险,因为它错得很像对的
- 连按标准写的那个库也会错
- 哪些东西一进head,就会把它当场关掉?
- 安全的:进去了也不会关掉head
- 危险的:一进去head就没了
- 三条特别值得单拎出来讲
- 注释这一类,没有你以为的那么安全
- 还有一个只吃掉一个标签的写法
- 开着脚本和关着脚本的解析器,为什么给出相反的答案?
- 标准里确实写了两条路
- 怎么自己验一遍
- 有没有真实的大站,正在犯这个错?
- 但根因跟前面那些样本完全不同
- 121个站扫下来,这个坑到底有多常见?
- 修正之后,那5个站还得再筛一遍
- 写标准的那家自己不写 </head>
- 那么现网的标签排在第几位?
- 顺手量到的三个背景数字
- 你手上那个SEO工具,用的是哪个解析器?
- 三类工具的表现
- 写脚本的话,三行就能换掉那个默认解析器
- 该怎么自查,又该怎么把它挡在上线之前?
- 自查:四步,十分钟能跑完
- 加固:把规则写进模板,而不是写进检查清单
- 一条别搞反的边界
- 常见问题解答
- canonical标签写在body里,谷歌到底认不认?
- 怎么快速判断我的head是不是被提前关掉了?
- 把GTM的noscript那段放进head,到底有没有事?
- 用BeautifulSoup写的检查脚本为什么一直报正常?
- 页面里多了一个 </head>,要紧吗?
- 结构化数据放在body里需要挪回head吗?
- 用框架做的站,这件事要怎么盯?
- 权威参考资料
摘要:浏览器不是读到
</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%。
分歧集中在哪几类
| 样本 | Chrome | html5lib | lxml | html.parser |
|---|---|---|---|---|
head里插 <div> | body | body | body | head |
head里插 <img> | body | body | body | head |
head里插 <svg> | body | body | head | head |
head里插 <main> | body | body | head | head |
head里插 <input> | body | body | head | head |
自定义元素 <my-widget> | body | body | head | head |
head里插 <template> | head | body | head | head |
<title> 忘了闭合 | 不存在 | 不存在 | head | 不存在 |
多写了一个 </head> | head | head | 找不到 | 游离 |
这张表里最该被记住的是最后两行,还有html.parser那一整列。
按四类分组看,结果的形状更清楚
| 分组 | 组数 | canonical留在head | 掉进body | 从DOM里消失 |
|---|---|---|---|---|
| 标准白名单元素 | 12 | 12 | 0 | 0 |
| 明确越界的元素 | 17 | 0 | 17 | 0 |
| 现网真实事故形态 | 15 | 3 | 9 | 3 |
| 畸形写法与边界 | 10 | 4 | 4 | 2 |
第一行和第二行是干净的:白名单里的东西全部安全,名单外的东西全部越界,没有例外。真正的麻烦全在后两行——那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> | 各类嵌入代码 |
| 裸文本(中英文都算) | 模板变量没渲染出来,或者少写了一对标签 |
| 后端报错信息 | 某个提示直接打印在了页面最前面 |
| 文件中间混进的字节顺序标记 | 多文件拼接时带进来的 |
这类实体 | 可视化编辑器插的 |
| 自定义元素 | 组件库在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子元素 | canonical | title | description |
|---|---|---|---|---|---|
| 第1、2次 | 473217 | 72 | body | body | body |
| 第3次 | 444243 | 96 | head | head | head |
同一个域名,同一分钟内,两个版本同时在线。这本身就够说明问题了:这类故障不是常驻状态,是跟着构建产物走的。你今天查是好的,明天客户截图给你是坏的,两个人都没看错。
但根因跟前面那些样本完全不同
保哥原以为是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.org | head里出现 <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 | 压根没有 |
|---|---|---|---|
| title | 116 | 1 | 4 |
| meta charset | 108 | 0 | 13 |
| viewport | 115 | 1 | 5 |
| description | 104 | 1 | 16 |
| canonical | 98 | 1 | 22 |
| hreflang | 73 | 0 | 48 |
| meta robots | 48 | 1 | 72 |
| JSON-LD | 50 | 20 | 51 |
第三,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复核那一小撮。这跟日志分析里先粗筛再精算是一个路数。
该怎么自查,又该怎么把它挡在上线之前?
自查:四步,十分钟能跑完
- 先看渲染后的树。控制台跑
document.head.children.length,再跑一次判断canonical父级的那行。head只有个位数子元素,基本可以确定被提前关掉了。 - 再看没执行脚本的那一遍。用上面那段DOMParser的代码比对。两遍不一致,问题就在noscript或者框架的运行时搬运上。
- 数字节。把
</head>的偏移和canonical的偏移打出来,后者大于前者就说明它本来就写在body里,跟解析器无关。 - 换个解析器再跑一遍。如果你的审计脚本一直报正常,先怀疑解析器,别怀疑页面。
加固:把规则写进模板,而不是写进检查清单
- 把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