我第一次给 AI 回答里的引用做排行,排在最前面的一项是 https://mp.weixin.qq.com/s,后面一个参数都没有。这当然不是哪篇文章,是一千多条带签名的公众号临时链接,被我的归一代码删光参数后挤成了一条。
按 URL 数引用,麻烦出在写法上。不同的页面可能被错归成一条,上面那条就是;更常见的是反过来,同一篇文章以好几种写法出现。DeepSeek 喜欢在链接末尾加 #1,引用知乎专栏时还会带上一串 utm_ 参数,同一篇腾讯云社区文章,有的平台给的是 .com,有的给 .com.cn。按原始 URL 计数,一篇文章的引用会被拆成好几份,排行榜就不准。
另一类麻烦是链接已经打不开了,状态码却看不出来。最典型的还是公众号临时链接:返回 200,页面上写着「链接已过期」。
下面先讲怎么把 URL 归一成「页面键」,再讲怎么批量检查链接还能不能打开。代码只用 Python 标准库。
数据来自我们拿自家品牌做的监测(作者在 MaxAEO 做 AI 搜索可见度监测),文中例子和代码里的站点 ID 都是编的。我们固定了 40 个问题,每天在 8 个国内 AI 平台的用户界面上各问一遍,从 2026-09-27 到 10-22,共 26 天。成功拿到的回答有 8,232 条,其中 5,561 条带引用。按我的解析,引用一共拆出 128,873 条,有 31 条连网址都不是,是从回答正文里误抠出来的,比如 https://,后面直接跟着中文。剩下 128,842 条,原始写法各不相同的有 47,834 种。
我把这 4 万多种写法按域名和路径模式过了一遍,常见的情况有这些:
情况 | 例子(ID 是编的) | 这批数据里 |
|---|---|---|
末尾的片段 |
| 9,587 条引用带 |
跟踪参数 | 知乎专栏的 | 参数整串被删掉的有 3,653 条 |
同站多域名 | 腾讯云社区同一篇文章出现在 | 四个域名分别 2,071、624、508、230 条;518 篇文章里有 193 篇被引时用过不止一个域名 |
移动版 / 旧路径 | 头条的 |
|
分享页 | 抖音的 | 2,536 条 |
跳转壳 | 豆包有一部分链接包了一层 | 94 条 |
地方频道子域 | 凤凰网同一个 | 1,682 条 |
临时链接 | 公众号的 | 1,551 条,全部来自元宝 |
短链在这批数据里几乎没有。我按 t.cn、url.cn、dwz.cn、bit.ly、tinyurl 这些常见短链域名查了一遍,26 天只出现 1 次。
上面这些「是同一页」的判断,我都拿真实链接验证过,方法有两种。一种是跟着跳转走:m.toutiao.com/group/ID 会 302 到 www.toutiao.com/article/ID/,dy.163.com/article/ID.html 会跳到 www.163.com/dy/article/ID.html。另一种是比较页面标题和 <link rel="canonical">:腾讯云四个域名下同一个 ID 的标题完全一样;搜狐 m. 和 news. 的 canonical 都指向 www.。没验证过的站点,我不写规则。
page_key() 按顺序做五件事:
None。target= 里就是真实地址,取出来递归再算一遍。developer. 子域、头条的 /group/、抖音分享页)用正则改写。id,公众号留 __biz、mid、idx、sn,其余大站一个都不留。不认识的站点用黑名单,只删 utm_*、spm、from 这类。不认识的站点不能用白名单,因为参数可能就是内容 ID,WordPress 的 ?p=42 删掉就成了首页。#1、#respond、#toc 都扔掉;#/ 和 #! 开头的是前端路由,算页面的一部分,留着。有两处要说明。
__biz 和 mid,只有签名。我第一版代码直接按白名单删参数,结果 1,551 条临时链接全被归成了同一个键 https://mp.weixin.qq.com/s,成了「被引最多的页面」。现在的处理是:白名单里的参数一个都没有,就认不出是哪篇,参数原样留着,每条算一个页面。ifeng.com/c/ID,这个地址本身打不开。另外所有键都统一成 https://,不管原站支不支持。要打开页面,用原始链接。"""把 AI 回答里引用的 URL 归一成「页面键」:同一篇内容,不管带什么参数、从哪个域名进来,键都一样。"""
import re
from urllib.parse import urlsplit, parse_qsl, urlencode
# ---------- 1. 跳转壳:真实地址就在参数里 ----------
UNWRAP = {
"link.wtturl.cn": "target",
"link.zhihu.com": "target",
"link.juejin.cn": "target",
}
# ---------- 2. 同站多域名:只换 host,路径不变 ----------
HOST_ALIAS = {
"cloud.tencent.com.cn": "cloud.tencent.com",
"cloud.tencent.cn": "cloud.tencent.com",
"m.sohu.com": "www.sohu.com",
"news.sohu.com": "www.sohu.com",
"m.163.com": "www.163.com",
"m.jiemian.com": "www.jiemian.com",
"toutiao.com": "www.toutiao.com",
"m.toutiao.com": "www.toutiao.com",
"m.toutiaocdn.com": "www.toutiao.com",
}
# ---------- 3. 路径改写:(host 正则, path 正则, 新 host, 新 path) ----------
PATH_RULES = [
(r"developer\.cloud\.tencent\.com", r"/article/(\d+)", "cloud.tencent.com", "/developer/article/{0}"),
(r"www\.toutiao\.com", r"/(?:article/|group/|a)(\d+)", "www.toutiao.com", "/article/{0}"),
(r"www\.iesdouyin\.com", r"/share/video/(\d+)", "www.douyin.com", "/video/{0}"),
(r"dy\.163\.com", r"/article/(\w+)\.html", "www.163.com", "/dy/article/{0}.html"),
(r"view\.inews\.qq\.com", r"/[ak]/(\w+)", "news.qq.com", "/rain/a/{0}"),
# 地方频道子域:同一 ID 在不同子域都能打开,canonical 却各指一处,只能当键用
(r"[\w-]+\.ifeng\.com", r"/c/(\w+)", "ifeng.com", "/c/{0}"),
]
PATH_RULES = [(re.compile(h), re.compile(p + r"/?"), nh, np) for h, p, nh, np in PATH_RULES]
# ---------- 4. 参数:认识的站点用白名单,其余用黑名单 ----------
KEEP = {
"baijiahao.baidu.com": {"id"},
"mp.weixin.qq.com": {"__biz", "mid", "idx", "sn"},
"www.toutiao.com": set(), "www.douyin.com": set(), "cloud.tencent.com": set(),
"zhuanlan.zhihu.com": set(), "www.zhihu.com": set(), "blog.csdn.net": set(),
"www.sohu.com": set(), "www.163.com": set(), "news.qq.com": set(), "ifeng.com": set(),
"www.jiemian.com": set(), "k.sina.cn": set(), "baike.baidu.com": set(),
}
DROP = re.compile(r"(utm_\w+|spm|scm|from|frompage|source|scene|share\w*|ref|referfrom|trk|"
r"fbclid|gclid|msclkid|msockid|policyid|wfr|chksm|previous_page|upstream_biz|"
r"wid|spss|hubs_\w+|_hsenc|_hsmi)", re.I)
HOST_OK = re.compile(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+")
TRAIL = "))】]》>。,、;;::!!??'\"`*"
def page_key(url):
"""返回页面键;不是合法网址返回 None。键不一定能直接打开(比如凤凰网那条),只用来去重计数"""
url = url.strip().rstrip(TRAIL)
try:
s = urlsplit(url)
host = (s.hostname or "").lower().rstrip(".")
except ValueError:
return None
if s.scheme not in ("http", "https") or not HOST_OK.fullmatch(host):
return None
if host in UNWRAP:
inner = dict(parse_qsl(s.query)).get(UNWRAP[host])
if inner and inner.startswith("http"):
return page_key(inner)
host = HOST_ALIAS.get(host, host)
path = re.sub(r"/{2,}", "/", s.path) or "/"
for hp, pp, nh, np in PATH_RULES:
m = pp.fullmatch(path) if hp.fullmatch(host) else None
if m:
host, path = nh, np.format(*m.groups())
break
if len(path) > 1:
path = path.rstrip("/")
q = parse_qsl(s.query, keep_blank_values=True)
if host in KEEP:
kept = [(k, v) for k, v in q if k in KEEP[host]]
# 白名单里的参数一个都没有(比如带签名的临时链接),认不出是哪篇,参数原样留着
q = kept if kept or not KEEP[host] else q
else:
q = [(k, v) for k, v in q if not DROP.fullmatch(k)]
q = sorted(set(q))
# 片段:#/ 和 #! 是前端路由,算页面的一部分;其他(#1、#respond、#toc)都扔掉
frag = s.fragment if s.fragment.startswith(("/", "!")) else ""
key = "https://" + host + path + ("?" + urlencode(q) if q else "")
return key + ("#" + frag if frag else "")拿 21 种构造的写法试一下:
from collections import defaultdict
from urlnorm import page_key
urls = """
https://blog.csdn.net/someone/article/details/123456789#1#1
https://blog.csdn.net/someone/article/details/123456789
https://zhuanlan.zhihu.com/p/1000000000000000001?utm_medium=openapi_platform&utm_source=abc
https://zhuanlan.zhihu.com/p/1000000000000000001#1
https://baijiahao.baidu.com/s?id=1800000000000000001&wfr=spider&for=pc
https://mp.weixin.qq.com/s?__biz=MzAxMTEx==&mid=2650000001&idx=2&sn=abc123&chksm=ff00aa&scene=27
http://mp.weixin.qq.com/s?mid=2650000001&__biz=MzAxMTEx==&sn=abc123&idx=2
http://mp.weixin.qq.com/s?src=11×tamp=1790000000&ver=6990&signature=xyz
https://cloud.tencent.com.cn/developer/article/2700001?policyId=1003#1#1
https://cloud.tencent.cn/developer/article/2700001?from=15425&frompage=seopage
https://developer.cloud.tencent.com/article/2700001
http://m.toutiao.com/group/7600000000000000001/
https://www.toutiao.com/a7600000000000000001
https://www.toutiao.com/article/7600000000000000001/?upstream_biz=toutiao_pc&wid=1789
https://www.iesdouyin.com/share/video/7600000000000000002
http://i.ifeng.com/c/8abcdEFGhij
https://nb.ifeng.com/c/8abcdEFGhij
https://link.wtturl.cn/?target=https%3A%2F%2Fwww.example.org%2F%3Fp%3D42%26utm_source%3Dx&scene=im
https://www.example.org/?p=42)。
https://app.example.org/#/docs/rate
https://`,或在换行处截断
""".split()
groups = defaultdict(list)
for u in urls:
groups[page_key(u)].append(u)
for k, us in groups.items():
print(f"{len(us)} {k}")
print(f"{len(urls)} 种写法 -> {len([k for k in groups if k])} 个页面,{len(groups.get(None, []))} 条不是网址")输出:
2 https://blog.csdn.net/someone/article/details/123456789
2 https://zhuanlan.zhihu.com/p/1000000000000000001
1 https://baijiahao.baidu.com/s?id=1800000000000000001
2 https://mp.weixin.qq.com/s?__biz=MzAxMTEx%3D%3D&idx=2&mid=2650000001&sn=abc123
1 https://mp.weixin.qq.com/s?signature=xyz&src=11×tamp=1790000000&ver=6990
3 https://cloud.tencent.com/developer/article/2700001
3 https://www.toutiao.com/article/7600000000000000001
1 https://www.douyin.com/video/7600000000000000002
2 https://ifeng.com/c/8abcdEFGhij
2 https://www.example.org/?p=42
1 https://app.example.org/#/docs/rate
1 None
21 种写法 -> 11 个页面,1 条不是网址对一下:CSDN 和知乎去掉了片段和 utm_;公众号两种参数顺序归到同一个键,临时链接单独一个键;腾讯云三个域名、头条三种路径各归成一个;跳转壳里的 ?p=42 留下了,utm_source 删掉了;#/docs/rate 是前端路由,保留;最后一条不是网址,返回 None。
对 128,842 条引用按步骤归一,不同页面的数量变化如下:
步骤 | 不同页面数 |
|---|---|
原始写法 | 47,834 |
去掉片段 | 47,438 |
再处理参数、统一协议、去掉末尾斜杠 | 46,021 |
再拆跳转壳、合并多域名、改写路径 | 45,321 |
总数只少了 5.3%,看起来不多。影响主要在排行榜上:
只看状态码不够。这批数据里碰到的情况有:
所以 classify() 把结果分成七类:
类别 | 含义 | 判断依据 |
|---|---|---|
| 能打开 | 都不符合下面几条 |
| 确定没了 | 404、410 |
| 状态码正常,内容没了 | 正文很短,并且出现「链接已过期」「已被发布者删除」等字样 |
| 要登录 | 跳到了 login、passport 之类的地址,或者正文很短并且提示登录 |
| 被反爬拦了 | 403、412、521 等,或者跳到了验证码页 |
| 前端渲染的空壳 | 去掉脚本和标签后,正文不到 50 个字符 |
| 超时、证书错误、连接失败 | 抛异常,重试一次还是失败 |
「正文很短」这个条件很重要。不加的话,一篇讨论「链接已过期怎么办」的文章也会被判成失效。
fetch() 用 urllib 默认的跳转处理,短链和移动版跳转会自动跟到底,最终地址记在 final 里。页面里有 canonical 的,也一并记下来,可以用来补充第三节的规则表。
"""逐个打开引用链接,判断还能不能看:ok / gone / soft404 / login / blocked / shell / error"""
import concurrent.futures as cf
import json, re, sys, urllib.error, urllib.request
from collections import Counter
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36"
SOFT_404 = ["链接已过期", "已被发布者删除", "因违规无法查看", "内容已删除", "内容不存在", "文章不存在",
"页面不存在", "页面找不到", "该文章已被删除", "page not found", "404 not found"]
LOGIN_URL = re.compile(r"(^|[./])(login|signin|passport|sso)([./?]|$)", re.I)
LOGIN_TEXT = ["登录后查看", "登录后才能查看", "请先登录", "扫码登录"]
BLOCK_TEXT = ["安全验证", "访问过于频繁", "人机验证", "captcha", "just a moment"]
SCRIPT = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.S | re.I)
TAG = re.compile(r"<[^>]+>")
def visible_text(html):
return re.sub(r"\s+", " ", TAG.sub(" ", SCRIPT.sub(" ", html))).strip().lower()
def classify(status, final_url, html):
if status in (404, 410):
return "gone"
path = final_url.split("?")[0]
if LOGIN_URL.search(path):
return "login"
if "captcha" in path.lower():
return "blocked"
if status in (401, 403, 412, 418, 429, 503, 521):
return "blocked"
if status >= 400:
return "error"
text = visible_text(html)
if len(text) < 2000: # 错误页、登录墙的正文都很短,长正文里出现这些词多半是文章内容
for kind, words in (("soft404", SOFT_404), ("login", LOGIN_TEXT), ("blocked", BLOCK_TEXT)):
if any(w in text for w in words):
return kind
if len(text) < 50: # 正文全靠 JS 渲染(抖音、头条都是),静态请求看不出死活
return "shell"
return "ok"
def fetch(url, timeout=15):
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r: # 默认跟随跳转,短链也在这一步展开
return r.status, r.geturl(), r.read(500_000).decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.geturl() or url, e.read(200_000).decode("utf-8", "replace")
def check(url, retries=1):
for attempt in range(retries + 1):
try:
status, final, html = fetch(url)
m = re.search(r'<link[^>]+rel=["\']canonical["\'][^>]*href=["\']([^"\']+)', html, re.I)
return {"url": url, "status": status, "final": final,
"canonical": m.group(1) if m else None, "kind": classify(status, final, html)}
except Exception as e: # 超时、DNS、TLS、连接被重置
err = f"{type(e).__name__}: {e}"[:120]
return {"url": url, "status": None, "final": None, "canonical": None, "kind": "error", "error": err}
def run(urls, workers=16):
with cf.ThreadPoolExecutor(workers) as ex:
return list(ex.map(check, urls))
if __name__ == "__main__":
urls = [l.strip() for l in open(sys.argv[1], encoding="utf-8") if l.strip()]
results = run(urls)
with open(sys.argv[2], "w", encoding="utf-8") as f:
for r in results:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
c = Counter(r["kind"] for r in results)
for k in ("ok", "gone", "soft404", "login", "blocked", "shell", "error"):
print(f"{k:<8}{c[k]:>5} {c[k] / len(results):6.1%}")在本机起一个假站点,几种情况各放一个页面:
"""本机起一个假站点,把几种失效情况各做一个页面,跑一遍 linkcheck"""
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from linkcheck import run
ARTICLE = "<html><head><link rel='canonical' href='https://news.example/a/1'></head><body>" + "正文。" * 800 + "</body></html>"
PAGES = {
"/a/1": (200, {}, ARTICLE),
"/s/xyz": (302, {"Location": "/a/1"}, ""), # 短链
"/a/2": (404, {}, "not found"),
"/s?src=11&signature=abc": (200, {}, "<p>链接已过期</p>"), # 临时链接过期,状态码却是 200
"/p/3": (302, {"Location": "/passport/login?next=/p/3"}, ""),
"/passport/login?next=/p/3": (200, {}, "<p>请先登录</p>"),
"/p/4": (403, {}, "forbidden"),
"/video/5": (200, {}, "<div id='root'></div><script>render()</script>"), # 前端渲染的空壳
}
class H(BaseHTTPRequestHandler):
def do_GET(self):
status, headers, body = PAGES.get(self.path, (404, {}, ""))
self.send_response(status)
for k, v in headers.items():
self.send_header(k, v)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, *a):
pass
srv = ThreadingHTTPServer(("127.0.0.1", 8765), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = "http://127.0.0.1:8765"
urls = [base + p for p in ["/a/1", "/s/xyz", "/a/2", "/s?src=11&signature=abc", "/p/3", "/p/4", "/video/5"]]
urls.append("http://127.0.0.1:9/nothing") # 端口没开,连接失败
for r in run(urls, workers=4):
final = (r["final"] or "-").replace(base, "")
print(f'{r["kind"]:<8}{str(r["status"]):<5}{r["url"].replace(base, ""):<28}-> {final:<28}{r["canonical"] or ""}')
srv.shutdown()输出:
ok 200 /a/1 -> /a/1 https://news.example/a/1
ok 200 /s/xyz -> /a/1 https://news.example/a/1
gone 404 /a/2 -> /a/2
soft404 200 /s?src=11&signature=abc -> /s?src=11&signature=abc
login 200 /p/3 -> /passport/login?next=/p/3
blocked 403 /p/4 -> /p/4
shell 200 /video/5 -> /video/5
error None http://127.0.0.1:9/nothing -> - 短链 /s/xyz 跟到了 /a/1,canonical 也取到了;临时链接虽然返回 200,还是判成了 soft404;跳到登录页的判成 login;前端渲染的空壳单独归成 shell;端口没开的是 error。
10 月 23 日,我从 45,321 个页面里随机抽了 500 个,每个页面用它最常见的那种写法去请求。第一遍 16 个并发、走代理出口;被拦和出错的 69 个,降到每 1.5 秒一个再跑一遍,还不行的换直连出口再跑一遍,最后又救回 19 个。结果:
类别 | 页面数 | 占 500 个 |
|---|---|---|
| 388 | 77.6% |
| 10 | 2.0% |
| 16 | 3.2% |
| 1 | 0.2% |
| 34 | 6.8% |
| 35 | 7.0% |
| 16 | 3.2% |
blocked、shell、error 三类合起来 85 个,判断不了死活,所以失效率只在剩下的 415 个里算:gone、soft404、login 一共 27 个,占 6.5%,95% 置信区间 4.5%~9.3%。按引用次数加权就低得多:这 415 个页面一共被引 1,206 次,失效页面只占 29 次,2.4%。被引得多的页面,大多还活着。
各类的来源很集中:
soft404 的 16 个全是公众号临时链接。样本里的临时链接也正好是这 16 个,全部过期。我又从 1,551 条临时链接里另抽了 100 条,结果也是 100 条全部 soft404,最近一天的也不例外。这 1,551 条临时链接占元宝全部公众号引用的 68.2%,占元宝全部引用的 6.0%。gone 的 10 个多是小站改版后删掉的博客页;其中有一个是 www.example.com/a?utm_source=x,这种是回答里写的示例地址被当成了引用,统计前最好单独过滤。blocked 里知乎专栏 11 个、CSDN 8 个;shell 里抖音 15 个、头条 7 个。临时链接的问题,请求时再怎么处理也补不回来,只能在采集那一刻留住别的信息。好在引用详情里每条都带着标题:1,551 条临时链接都有标题,按标题合并后是 800 篇。所以统计时我对公众号临时链接另做一步(page_key() 只看 URL,不管这个):页面键改成「mp.weixin.qq.com + 标题」,并且标记「链接不可回访」,不进失效率的分母。标题撞车的情况有,但比把 1,551 条各算一篇要准。
前端渲染的页面,静态请求判不了。抖音、头条要判断死活,得用无头浏览器渲染,或者走平台自己的接口。这两家在引用里的量不小,我现在是单独列出来,不算进失效率。被拦的也类似:知乎、CSDN 这类站点,换出口、降速只能救回一部分,剩下的要么接受「判不了」,要么换成渲染的方式再查。
规则表也会过时。站点会改域名、改路径,上面的规则是对着这批数据写的。我现在每次检查链接都顺手记下 final 和 canonical:同一个键出现了不同的 canonical,或者不同的键指向同一个 canonical,就说明规则该补了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。