Files
NANOKVM-sipeed_wiki-MIRROR/tools/check_image_links.py
zepan 2821a421bc ci: check that referenced images exist; document shallow clone
断链图片一直在悄悄累积(本次清理前有 230 处),而且线上看不出来:
teedoc-upload 只上传不删除对象存储里已移除的文件,旧副本把断链盖住了,
所以页面看起来正常。CI 里也从来没有任何链接检查。

- tools/check_image_links.py:只认 markdown ![]() 与 <img src>,
  先剥离代码块以免把 `convert in.jpg out.jpg` 这类示例当成引用,
  并按 site_config 的 route 解析站点绝对路径。
- tools/known_broken_images.txt:当前仍存在的 121 处已知断链基线。
  CI 对不在基线中的断链报错,基线只许减不许增。
- .github/workflows/check_links.yml:push 与 PR 均运行。
- README:建议 --depth=1 浅克隆并解释原因——文档站的历史里保留了
  每张图的所有旧版本(约 1GB),而贡献者只需要当前版本。
2026-10-07 01:01:33 +08:00

146 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Check that every image referenced by the docs actually exists.
Broken image links are easy to introduce (moving an assets/ folder, deleting a
file without touching the pages that use it) and hard to notice, because the
published site keeps serving stale copies that were uploaded by earlier builds.
This script catches them at CI time instead.
Only real image references are checked: markdown ``![alt](path)`` and HTML
``<img src=...>``. Fenced and inline code is stripped first, so example commands
like ``convert in.jpg out.jpg`` are not mistaken for links.
Usage:
python3 tools/check_image_links.py # fail on anything not in the baseline
python3 tools/check_image_links.py --list # print every broken link
python3 tools/check_image_links.py --update-baseline
"""
import argparse
import json
import os
import re
import sys
import urllib.parse
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE = os.path.join(ROOT, "tools", "known_broken_images.txt")
SCAN_DIRS = ["docs", "news", "pages", "layout"]
SKIP_DIRS = {".git", "out", ".venv", "node_modules", "__pycache__"}
PAGE_EXTS = {".md", ".html"}
IMG_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"}
FENCED = re.compile(r"```.*?```|~~~.*?~~~", re.S)
INLINE_CODE = re.compile(r"`[^`\n]*`")
MD_IMG = re.compile(r"!\[[^\]]*\]\(\s*<?([^)\s>]+)")
HTML_IMG = re.compile(r"<img\b[^>]*?\bsrc\s*=\s*[\"']([^\"']+)", re.I)
def load_routes():
"""Map URL prefixes to on-disk directories, as teedoc does when building."""
with open(os.path.join(ROOT, "site_config.json"), encoding="utf-8") as f:
cfg = json.load(f)
routes = {}
for group in ("docs", "pages", "assets"):
for url, path in (cfg.get("route", {}).get(group) or {}).items():
routes[url] = path
for url, targets in (cfg.get("translate", {}).get("docs") or {}).items():
for t in targets:
routes[t["url"]] = t["src"]
return dict(sorted(routes.items(), key=lambda kv: -len(kv[0])))
def resolve(ref, page_path, routes):
"""Return the on-disk path a reference points at, or None if it is external."""
ref = ref.strip().strip("'\"")
if ref.startswith(("http://", "https://", "//", "data:", "mailto:")):
return None
ref = urllib.parse.unquote(ref.split("#")[0].split("?")[0])
if not ref or os.path.splitext(ref)[1].lower() not in IMG_EXTS:
return None
if ref.startswith("/"):
for url, path in routes.items():
if ref.startswith(url):
return os.path.normpath(os.path.join(ROOT, path, ref[len(url):]))
return os.path.normpath(os.path.join(ROOT, ref.lstrip("/")))
return os.path.normpath(os.path.join(os.path.dirname(page_path), ref))
def scan():
routes = load_routes()
broken = []
for top in SCAN_DIRS:
for dirpath, dirnames, filenames in os.walk(os.path.join(ROOT, top)):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for name in filenames:
if os.path.splitext(name)[1].lower() not in PAGE_EXTS:
continue
page = os.path.join(dirpath, name)
try:
text = open(page, encoding="utf-8").read()
except (OSError, UnicodeDecodeError):
continue
text = INLINE_CODE.sub(" ", FENCED.sub(" ", text))
rel_page = os.path.relpath(page, ROOT)
for ref in set(MD_IMG.findall(text) + HTML_IMG.findall(text)):
if "{{" in ref or "{%" in ref: # jinja template expression
continue
target = resolve(ref, page, routes)
if target and not os.path.exists(target):
broken.append(f"{rel_page}\t{ref}")
return sorted(set(broken))
def read_baseline():
if not os.path.exists(BASELINE):
return set()
with open(BASELINE, encoding="utf-8") as f:
return {l.rstrip("\n") for l in f if l.strip() and not l.startswith("#")}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--list", action="store_true", help="print all broken links")
ap.add_argument("--update-baseline", action="store_true",
help="record the current broken links as accepted")
args = ap.parse_args()
broken = scan()
if args.update_baseline:
with open(BASELINE, "w", encoding="utf-8") as f:
f.write("# Image links that are already broken and not yet fixed.\n"
"# CI fails on anything NOT listed here, so this file may shrink\n"
"# but must never grow. Regenerate with:\n"
"# python3 tools/check_image_links.py --update-baseline\n")
f.write("\n".join(broken) + "\n")
print(f"baseline updated: {len(broken)} known broken links")
return 0
baseline = read_baseline()
new = [b for b in broken if b not in baseline]
fixed = [b for b in baseline if b not in broken]
if args.list:
for b in broken:
page, ref = b.split("\t")
print(f"{page}: {ref}")
print(f"{len(broken)} broken image links "
f"({len(baseline)} known, {len(new)} new, {len(fixed)} fixed since baseline)")
if fixed:
print(f"\n{len(fixed)} link(s) in the baseline are fixed - please run "
f"`python3 tools/check_image_links.py --update-baseline` and commit.")
if new:
print(f"\nNew broken image links introduced by this change:\n")
for b in new:
page, ref = b.split("\t")
print(f" {page}\n -> {ref}")
print("\nFix the path, add the missing image, or remove the reference.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())