同一文档可能由多个地址提供:跟踪参数、别名主机或末尾斜杠变化,都可能不改变有意义的内容。canonical 链接用于指出合适重复表示中的首选版本,帮助接收方理解它们的关系,但不会自动重定向 HTTP 请求,也不能强制搜索引擎采用声明的网址。只有目标与周围信号符合真实内容关系,这份声明才有价值;标签存在本身并不等于规范化已经完成。

先确定身份,再写标签

先决定哪个公开网址代表这份文档,再让站内链接和相关 sitemap 条目与这个选择一致。不能机械删除所有查询参数:跟踪参数可能多余,语言、页码或商品选择却可能实质改变表示。如果没有比较含义就把它们全部折叠到一个地址,会把不同文档指向错误的首选页面。规范化应当反映资源关系,而不是只让网址看起来更短。

HTML 中应把带有 rel="canonical" 的 link 元素放入有效 head,并提供清晰目标。对于 PDF 等其他表示,也可以用 HTTP Link 响应头表达这一关系。优先采用完整公开网址,减少 base 地址或部署前缀造成的歧义。生成时应依据站点预期的公开配置,而不是意外把代理内部主机名写进页面。实际发布结果需要再次检查,不能仅查看模板参数。

canonical 链接关系本身不要求目标必须位于同一个源站。跨域声明需要仔细审查内容与控制关系,声明一个值并不能证明拥有目标。对翻译页面,如果同语言的合适规范页面存在,通常应选择该语言目标。语言替代与内容重复是不同关系,把所有中文页面都指向英文首页,会抹掉有意义的文档区别,不能称为准确的去重处理。

坏掉的样子

声明目标可能报错、继续重定向,或者又指向另一个 canonical。模板、插件和响应头各自生成声明,也可能产生相互冲突的值。应检查完整响应,而不是只搜索有没有一个标签。首选目标需要能够使用,声明也要与实际表示一致。多个位置各自维护相同事实时,更应明确权威来源,避免一次修改只更新了其中一处。

普通链接关系不会改变原始地址的可访问性,除非路由另外配置了行为。如果旧路径需要对所有客户端迁移,就应设置合适的 HTTP 重定向,并验证它对方法的影响;如果变体仍有用途,也可以继续成功响应并提供 canonical。不能把元数据提示等同于跳转、访问授权,或者爬虫必须执行的命令。用户与搜索系统可能观察到不同层面的结果。

搜索引擎会综合多种信号,也可能选择另一份规范网址。因此,标签正确不等于收录已经收敛。应区分源站实际提供的声明与搜索服务报告的选择,排查站内链接、sitemap、跳转或语言元数据是否互相矛盾。发生分歧时,不应只是在更多位置重复添加同一个标签,而应先确认它表达的内容关系是否真实且一致。

快速验证

保存为 canonical.py 并执行 python3 canonical.py。脚本创建临时回环 HTTP 源站,用 Python HTML 解析器读取实际响应,提供英文文档、跟踪参数变体、中文文档和两个故意不一致的元数据案例。本地网址只是演示身份,并不会提交给搜索引擎,也没有检查任何真实搜索账号或收录记录。

from html.parser import HTMLParser
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import json


class Canonical(HTMLParser):
    def __init__(self):
        super().__init__()
        self.urls = []

    def handle_starttag(self, tag, attrs):
        values = dict(attrs)
        if tag == 'link' and 'canonical' in values.get('rel', '').split():
            self.urls.append(values.get('href'))


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        origin = f'http://127.0.0.1:{self.server.server_port}'
        chinese = self.path.startswith('/zh/')
        target = '/zh/article' if chinese else '/article'
        if self.path == '/zh/wrong':
            target = '/article'
        link = f'<link rel="canonical" href="{origin}{target}">'
        if self.path == '/conflict':
            link += f'<link rel="canonical" href="{origin}/another">'
        title = '中文文章' if chinese else 'English article'
        data = f'<!doctype html><html lang="{"zh" if chinese else "en"}"><head><meta charset="utf-8"><title>{title}</title>{link}</head><body><h1>{title}</h1></body></html>'.encode()
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):
        pass


server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
    origin = f'http://127.0.0.1:{server.server_port}'

    def fetch(path):
        connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
        try:
            connection.request('GET', path)
            response = connection.getresponse()
            text = response.read().decode()
            parsed = Canonical()
            parsed.feed(text)
            return response.status, response.getheader('Location'), text, parsed.urls
        finally:
            connection.close()

    variant = fetch('/article?utm_source=example')
    primary = fetch('/article')
    translated = fetch('/zh/article')
    assert variant[:2] == (200, None) and variant[2] == primary[2]
    assert variant[3] == [origin + '/article']
    assert translated[3] == [origin + '/zh/article']
    wrong = fetch('/zh/wrong')
    conflict = fetch('/conflict')
    assert wrong[3] != [origin + '/zh/article']
    assert len(conflict[3]) == 2
    print(json.dumps({'variant_status': variant[0], 'variant_redirect': variant[1],
                      'same_representation': variant[2] == primary[2],
                      'english_target': variant[3], 'chinese_target': translated[3],
                      'wrong_language_detected': True, 'conflicting_targets': len(conflict[3]),
                      'search_engine_selection_tested': False}))
finally:
    server.shutdown()
    thread.join()
    server.server_close()

跟踪参数变体返回二百,没有 Location 响应头;其字节与英文主页面一致,canonical 指向 /article。中文页面则指向 /zh/article。这同时验证了示例里的重复关系,以及两种语言表示之间的区别。仅提供 canonical 不会让这次 HTTP 请求变成跳转,因此客户端取得的仍是所请求地址的成功响应。

错误语言案例把中文响应指向英文目标;冲突案例则输出两个不同的规范目标。两者都仍然返回二百,所以只看状态的检查会漏掉问题。断言通过读取元数据并比较预期语言映射,识别了这两种具体不一致,并没有猜测搜索引擎面对错误声明时必定采用哪一种处理方式。

这里没有爬虫参与,小解析器也不是完整规范化验证器。实验说明服务器提供了什么声明、怎样响应,不代表 Google 选择了哪个 canonical,更不代表排名结果。调整生产身份之前,应把等价内容、公开目标、已有入口和语言关系一起审阅,并保留原地址或有意设置的重定向,避免元数据整理悄悄破坏用户已经保存的链接。

延伸阅读

来源