mirror of
https://github.com/sipeed/sipeed_wiki.git
synced 2026-11-09 17:44:31 -06:00
断链图片一直在悄悄累积(本次清理前有 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),而贡献者只需要当前版本。
146 lines
5.7 KiB
Python
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 ```` 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())
|