项目文件夹

文件
wehub-resource-sync 3a46cbf880
Site (mkdocs + mdBook) → GitHub Pages / Build mkdocs (root) + mdBook (/book/) (push) Has been cancelled
Site (mkdocs + mdBook) → GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:13:20 +08:00

155 行
5.4 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env python3
"""
check-links.py — 掃描所有 markdown 檔案的 URL,回報 4xx / 5xx / timeout。
用法:
python scripts/check-links.py # 檢查所有 .md 檔
python scripts/check-links.py --fast # 只查 GitHub repos(最容易 404
python scripts/check-links.py --quiet # 只印失敗
環境需求:
pip install requests
"""
import argparse
import re
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Iterable
try:
import requests
except ImportError:
print("ERROR: 需要 requests。請先執行:pip install requests", file=sys.stderr)
sys.exit(1)
REPO_ROOT = Path(__file__).resolve().parent.parent
MD_GLOB = "**/*.md"
EXCLUDE_DIRS = {".git", ".ai", "node_modules", "_build", ".venv"}
# 抓 markdown link [text](url) 的正則。處理 url 內可能含巢狀 ()。
# 用「至少 1 個非空白非右括號字元,後接任意可選 (...) 對」的策略。
LINK_RE = re.compile(
r"\[([^\]]+)\]"
r"\((https?://[^\s()]+(?:\([^\s()]*\))?[^\s)]*)\)"
)
TIMEOUT = 15
MAX_WORKERS = 10
def find_md_files(root: Path) -> list[Path]:
files = []
for fp in root.glob(MD_GLOB):
if any(part in EXCLUDE_DIRS for part in fp.parts):
continue
files.append(fp)
return files
def extract_urls(md_path: Path) -> list[tuple[int, str]]:
"""回傳 [(line_no, url), ...],跳過程式碼區塊內的 URL。"""
urls = []
text = md_path.read_text(encoding="utf-8")
in_fenced_code = False
for line_no, line in enumerate(text.splitlines(), start=1):
# Toggle fenced code block state on ``` or ~~~
stripped = line.lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fenced_code = not in_fenced_code
continue
if in_fenced_code:
continue
# 也跳過 inline code(粗略:只在 ` ` 之間的 URL 不算)
# Markdown 規範允許 inline code 內含 link 但通常不是真 link
for match in LINK_RE.finditer(line):
url = match.group(2).rstrip(".,;:!?")
urls.append((line_no, url))
return urls
def check_url(url: str, fast_mode: bool = False) -> tuple[str, int | None, str]:
"""回傳 (url, final_status_code or None, message)。allow_redirects=True 表示
final_status 不會是 3xx(會被 follow 到 2xx 或 4xx/5xx)。"""
if fast_mode and "github.com" not in url:
return url, None, "skipped (--fast)"
try:
r = requests.head(url, timeout=TIMEOUT, allow_redirects=True,
headers={"User-Agent": "awesome-agentic-ai-zh-link-check/1.0"})
# 有些 server 不接受 HEAD,fallback 用 GET
if r.status_code in (405, 403):
r = requests.get(url, timeout=TIMEOUT, allow_redirects=True, stream=True,
headers={"User-Agent": "awesome-agentic-ai-zh-link-check/1.0"})
r.close()
return url, r.status_code, ""
except requests.exceptions.RequestException as e:
return url, None, str(e)[:80]
def main():
parser = argparse.ArgumentParser(description="Check markdown links for rot.")
parser.add_argument("--fast", action="store_true", help="只查 GitHub URL")
parser.add_argument("--quiet", action="store_true", help="只印失敗")
args = parser.parse_args()
files = find_md_files(REPO_ROOT)
print(f"Scanning {len(files)} markdown files...", file=sys.stderr)
# 收集所有 URL(去重,但記下出現位置)
occurrences: dict[str, list[tuple[Path, int]]] = {}
for fp in files:
for line_no, url in extract_urls(fp):
occurrences.setdefault(url, []).append((fp, line_no))
print(f"Found {len(occurrences)} unique URLs.", file=sys.stderr)
failures = []
ok_count = 0
skipped = 0
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex:
futures = {ex.submit(check_url, url, args.fast): url for url in occurrences}
for i, fut in enumerate(as_completed(futures), start=1):
url, status, msg = fut.result()
if status is None and msg.startswith("skipped"):
skipped += 1
continue
if status is None:
failures.append((url, f"ERROR: {msg}"))
if not args.quiet:
print(f"[{i}/{len(occurrences)}] ❌ {url}{msg}")
elif status >= 400:
failures.append((url, f"HTTP {status}"))
if not args.quiet:
print(f"[{i}/{len(occurrences)}] ❌ {url} — HTTP {status}")
else:
# 200-299 (3xx 已被 allow_redirects 跟過去 → final 是 2xx 或 4xx/5xx)
ok_count += 1
if not args.quiet:
print(f"[{i}/{len(occurrences)}] ✓ {url}")
# 報告
print()
print("=" * 60)
print(f"Total checked: {len(occurrences) - skipped}")
print(f"OK (2xx): {ok_count}")
print(f"Failed: {len(failures)}")
if args.fast:
print(f"Skipped (--fast): {skipped}")
print()
if failures:
print("=== Failures by file ===")
for url, reason in failures:
print(f"\n{url} [{reason}]")
for fp, line_no in occurrences[url]:
rel = fp.relative_to(REPO_ROOT)
print(f" {rel}:{line_no}")
sys.exit(1 if failures else 0)
if __name__ == "__main__":
main()