为什么要做全站链接审计
单篇文章发布前检查,能确认当前页面有 canonical、标题、结构化数据和可访问的站内链接;但它不能回答全站结构是否健康。一个页面可能返回 200,却没有任何其他页面链接到它;一条链接也可能写对了路径,却最终跳到 404、登录页或已经合并的旧地址。
全站链接审计要回答四个问题:
- 从首页或专题入口出发,能走到多少篇已发布文章?
- 哪些页面没有任何入链,属于孤立页面?
- 哪些站内链接返回 4xx、5xx 或被多次重定向?
- 从入口到每个页面要经过几层链接,是否有过深页面?
这些结果可以帮助排查“首页能打开,但深层文章长期没有被发现”的结构问题。它们仍然只是网站信号,发现孤立页不等于搜索引擎一定收录;收录还要结合抓取、索引、内容质量和搜索平台数据。可以先参考网站页面不被收录排查清单,再用SEO 进阶测量与验证记录变化。
先定义审计范围
一个安全的审计器应该只抓取明确指定的站点,并限制并发、请求间隔、页面数量和单页大小。不要把它做成接收任意 URL 的公共接口,否则可能被用来请求内网地址或第三方站点。
本文脚本默认只访问与 base_url 同源的 HTTP(S) 链接,并跳过以下路径:
/admin/、/login/、/logout/、/register/和搜索页面;- 图片、字体、压缩包等非 HTML 资源;
- 带查询参数的链接,避免把搜索结果和追踪 URL 当成独立页面;
mailto:、javascript:、片段链接和外部域名。
如果站点使用多域名、语言子域名或特殊的文章路径,应先明确哪些 host 和前缀属于审计范围,再修改脚本中的规则。
用 Python 建立链接图
下面的脚本只依赖 requests,使用标准库解析 HTML 和 CSV。它从入口页开始广度优先抓取,记录每个页面的状态码、规范化 URL、入链数、页面深度和失败原因,并输出 link-audit.csv。
from __future__ import annotations
import argparse
import csv
import time
from collections import Counter, deque
from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urldefrag, urljoin, urlparse, urlunparse
import requests
from urllib import robotparser
SKIP_PREFIXES = ("/admin", "/login", "/logout", "/register", "/search")
SKIP_SUFFIXES = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".css", ".js", ".pdf", ".zip")
@dataclass
class Page:
url: str
depth: int
status: int = 0
content_type: str = ""
title: str = ""
canonical: str = ""
links: list[str] = field(default_factory=list)
error: str = ""
class LinkParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.links: list[str] = []
self.title = ""
self.in_title = False
self.canonical = ""
def handle_starttag(self, tag, attrs):
attributes = dict(attrs)
tag = tag.lower()
if tag == "a" and attributes.get("href"):
self.links.append(attributes["href"])
elif tag == "title":
self.in_title = True
elif tag == "link":
rel = {item.strip().lower() for item in attributes.get("rel", "").split()}
if "canonical" in rel:
self.canonical = attributes.get("href", "")
def handle_endtag(self, tag):
if tag.lower() == "title":
self.in_title = False
def handle_data(self, data):
if self.in_title:
self.title += data.strip()
def normalize(url: str) -> str:
url, _ = urldefrag(url)
parsed = urlparse(url)
path = parsed.path or "/"
if path != "/" and path.endswith("/") is False and "." not in path.rsplit("/", 1)[-1]:
path += "/"
return urlunparse((parsed.scheme.lower(), parsed.netloc.lower(), path, "", "", ""))
def is_allowed(url: str, hosts: set[str]) -> bool:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or parsed.netloc not in hosts:
return False
if parsed.query or parsed.fragment:
return False
path = parsed.path.lower()
if any(path.startswith(prefix) for prefix in SKIP_PREFIXES):
return False
return not path.endswith(SKIP_SUFFIXES)
def make_robot_parser(base_url: str) -> robotparser.RobotFileParser:
robots_url = urljoin(base_url, "/robots.txt")
parser = robotparser.RobotFileParser(robots_url)
try:
parser.read()
except OSError:
# A missing robots file should be reported by the caller, not crash the crawl.
parser.parse([])
return parser
def crawl(base_url: str, start_paths: list[str], max_pages: int, delay: float) -> dict[str, Page]:
base_url = normalize(base_url)
hosts = {urlparse(base_url).netloc}
robots = make_robot_parser(base_url)
session = requests.Session()
session.headers["User-Agent"] = "BlogLinkAudit/1.0 (+manual-audit)"
queue = deque((normalize(urljoin(base_url, path)), 0) for path in start_paths)
pages: dict[str, Page] = {}
while queue and len(pages) < max_pages:
url, depth = queue.popleft()
if url in pages or not is_allowed(url, hosts):
continue
page = Page(url=url, depth=depth)
pages[url] = page
if not robots.can_fetch(session.headers["User-Agent"], url):
page.error = "robots.txt disallows this URL"
continue
try:
response = session.get(url, timeout=(3.05, 10), allow_redirects=True)
page.status = response.status_code
page.content_type = response.headers.get("content-type", "")
final_url = normalize(response.url)
if final_url != url:
page.error = f"redirected to {final_url}"
if response.status_code >= 400:
page.error = page.error or f"HTTP {response.status_code}"
elif "text/html" in page.content_type.lower():
if len(response.content) > 2_000_000:
page.error = "HTML response exceeds 2 MB limit"
else:
parser = LinkParser()
parser.feed(response.text)
page.title = " ".join(parser.title.split())
page.canonical = normalize(urljoin(response.url, parser.canonical)) if parser.canonical else ""
for href in parser.links:
target_raw = urljoin(response.url, href)
if is_allowed(target_raw, hosts):
target = normalize(target_raw)
page.links.append(target)
if target not in pages:
queue.append((target, depth + 1))
except requests.RequestException as exc:
page.error = str(exc)
if delay:
time.sleep(delay)
return pages
def build_report(pages: dict[str, Page]) -> list[dict[str, object]]:
incoming = Counter(link for page in pages.values() for link in page.links)
rows = []
for url, page in pages.items():
rows.append({
"url": url,
"status": page.status,
"depth": page.depth,
"incoming_links": incoming[url],
"outgoing_links": len(set(page.links)),
"title": page.title,
"canonical": page.canonical,
"error": page.error,
})
return sorted(rows, key=lambda row: (int(row["depth"]), str(row["url"])))
def write_csv(rows: list[dict[str, object]], output: Path) -> None:
fieldnames = ["url", "status", "depth", "incoming_links", "outgoing_links", "title", "canonical", "error"]
with output.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def main() -> int:
parser = argparse.ArgumentParser(description="审计同源网站的站内链接结构")
parser.add_argument("base_url", help="站点根地址,例如 https://blog.zenleak.cn")
parser.add_argument("--start", action="append", default=["/"], help="抓取入口路径,可重复指定")
parser.add_argument("--max-pages", type=int, default=300)
parser.add_argument("--delay", type=float, default=0.3, help="请求间隔秒数")
parser.add_argument("--output", default="link-audit.csv")
args = parser.parse_args()
if args.max_pages < 1 or args.delay < 0:
parser.error("--max-pages 必须大于 0,--delay 不能为负数")
pages = crawl(args.base_url, args.start, args.max_pages, args.delay)
rows = build_report(pages)
write_csv(rows, Path(args.output))
isolated = [row["url"] for row in rows if row["incoming_links"] == 0 and row["url"].rstrip("/") != args.base_url.rstrip("/")]
broken = [row for row in rows if int(row["status"]) >= 400 or row["error"]]
deep = [row for row in rows if int(row["depth"]) >= 4]
print(f"抓取 {len(rows)} 个页面,孤立页面 {len(isolated)} 个,异常链接 {len(broken)} 个,深度 >= 4 的页面 {len(deep)} 个")
print(f"CSV 报告:{args.output}")
for label, values in (("孤立页面", isolated[:20]), ("异常链接", [row["url"] for row in broken[:20]]), ("过深页面", [row["url"] for row in deep[:20]])):
print(f"\n{label}:")
for value in values:
print(f"- {value}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
运行方式:
python link_audit.py \
https://blog.zenleak.cn \
--start / --start /topics/seo/ --start /posts/ \
--max-pages 350 --delay 0.4 --output link-audit.csv
脚本会从三个入口开始抓取,最多访问 350 个页面,每次请求间隔 0.4 秒。实际运行时应根据站点规模调整上限,不要为了“抓全”而取消限制。对生产站点做审计时,最好安排在访问量较低的时间,并在服务器日志中确认请求量符合预期。
如何阅读 CSV 报告
报告中最有用的字段不是单独的状态码,而是入链数、深度和最终错误的组合:
incoming_links=0:在本次抓取范围内没有发现其他页面指向它。它可能是真正的孤立页,也可能因为入口不全、robots 限制或页面没有被抓到;先扩大入口和范围再下结论。status>=400:链接目标返回错误。若目标是旧文章 URL,应该确认是否有永久重定向;若是已经删除的内容,应修复链接或给出明确替代页面。depth>=4:从入口页走了至少四层。深度本身不是硬性规则,但它可以提醒你检查专题页、分页和相关文章模块是否把重要内容埋得太深。canonical与url不一致:页面可能是参数页、旧 URL 或重复内容。需要结合页面用途决定是保留 canonical 指向主页面,还是设置永久重定向。
不要把 CSV 中的孤立页面全部批量删除。先确认页面是否仍有搜索需求、外部链接或历史价值,再通过专题页、分类页、相关文章模块和正文上下文补充入口。已有的锚文本优化文章可以帮助选择能说明目标内容的链接文字,而不是简单重复“点击这里”。
断链修复要按原因分类
发现异常链接后,先区分四种情况:
- 路径写错:修正正文或模板中的 URL,并重新运行审计;
- 文章改过 slug:保留旧地址的 301 重定向,让 canonical、sitemap 和新链接统一使用新地址;
- 内容已合并:旧文章链接到承接主题的页面,同时在目标页面说明关系;
- 临时或辅助页面:搜索、登录、管理和带参数页面不应成为 SEO 内容入口,应该从抓取范围和模板链接中排除。
站点的 URL 结构优化指南适合用来检查路径设计,网站结构优化三部曲可以继续检查导航、分页和内部链接策略。链接修复后不要只验证 HTML 源码,还要验证最终 HTTP 响应和移动端页面是否可见。
让审计结果进入发布流程
可以把全站链接审计放在每周例行检查,也可以在大量文章发布或合并后运行一次:
- 保存上一次 CSV 报告,记录抓取入口、页面上限和运行时间;
- 对比本次新增的 4xx、5xx、孤立页面和深度变化;
- 优先修复新出现的断链和重要文章的入链减少;
- 用SEO 数据监控实战记录修复前后的抓取、索引和点击信号;
- 页面修复并通过发布前 SEO 检查后,再提交百度和 IndexNow。
提交 URL 是发现提示,不是链接质量检查。即使百度或 IndexNow 接收了 URL,页面仍可能因为重复、低质量、不可抓取或其他原因没有进入索引。因此,提交前先修复结构问题,提交后再用SEO 访问日志分析观察搜索爬虫是否真正访问页面。
结语
站内链接审计的目标,是让重要内容从稳定入口可达,让读者和搜索爬虫都能沿着清晰路径继续阅读。Python 脚本可以快速列出链接图中的异常,但最终是否补链、重定向、合并或下线,仍需要结合内容主题、用户意图和维护成本判断。
当首页、专题页、分类页和正文链接形成连续路径后,sitemap 和主动提交才有更好的基础。审计结果应被当作工程反馈,而不是收录或排名的保证。
评论 (0)
暂无评论,快来抢沙发吧!