link_check.py

scripts/link_check.py(260 行)

link_check.py
# -*- coding: utf-8 -*-
"""リンク保守の一括健診(link-audit スキル用)

使い方:
    python -X utf8 scripts/link_check.py [--config config.yaml] [--root <public/>] [--base-url <https://example.com>]
                                         [--deny ドメイン,ドメイン] [--out report.md]
                                         [--offline] [--limit N] [--timeout 12]

- root 配下の *.html から <a href> を収集して健診する。
- 外部リンクは HEAD→GET フォールバックで実査(同一ドメイン1秒1リクエスト)。
- ⚠️ 広告・計測ドメイン(DENYLIST)には一切リクエストしない(自己クリック防止)。
  DENYLIST = config の metrics.affiliate_domains + --deny。rel="sponsored" のリンクも同じ扱い。
- DENYLIST が空のまま HTTP 実査はしない(広告を置いていないサイトは --deny none を明示する)。
- 内部リンクはローカルファイルの存在を確認する。
- --root / --base-url を省略すると config の paths.public / site.domain を使う。
"""
import argparse
import html as html_mod
import io
import re
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import date
from pathlib import Path

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

SKIP_SCHEMES = ("mailto:", "tel:", "javascript:", "data:")
UA = "Mozilla/5.0 (compatible; link-audit/1.0)"


def load_config(explicit: str) -> dict:
    """config.yaml を読む。無い・PyYAML 無し・壊れている場合は {} を返す"""
    p = Path(explicit) if explicit else Path.cwd() / "config.yaml"
    if not p.is_file():
        if explicit:
            print(f"⚠️ config が見つかりません: {explicit}", file=sys.stderr)
        return {}
    try:
        import yaml
    except ImportError:
        print("⚠️ PyYAML が無いため config を読めません(--deny / --root / --base-url で指定してください)", file=sys.stderr)
        return {}
    try:
        data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
    except Exception as e:
        print(f"⚠️ config を解釈できません: {e}", file=sys.stderr)
        return {}
    return data if isinstance(data, dict) else {}


def norm_domain(d: str) -> str:
    d = re.sub(r"^https?://", "", str(d).strip().lower())
    return d.split("/")[0]


def build_denylist(cfg: dict, deny_arg: str):
    """(DENYLIST, 明示的に「広告なし」と宣言されたか) を返す"""
    met = cfg.get("metrics") if isinstance(cfg.get("metrics"), dict) else {}
    deny = [norm_domain(d) for d in (met.get("affiliate_domains") or []) if str(d).strip()]
    declared_none = False
    for d in (deny_arg or "").split(","):
        d = d.strip()
        if not d:
            continue
        if d.lower() == "none":
            declared_none = True
        else:
            deny.append(norm_domain(d))
    return sorted(set(d for d in deny if d)), declared_none


def is_denied(url: str, denylist) -> bool:
    host = urllib.parse.urlsplit(url.lower()).hostname or ""
    return any(host == d or host.endswith("." + d) for d in denylist)


def collect_links(root: Path, limit: int):
    """{url: set(記事相対パス)}・rel="sponsored" 付きURLの集合・ファイル数 を返す"""
    links = defaultdict(set)
    sponsored = set()
    files = sorted(root.rglob("*.html"))
    if limit:
        files = files[:limit]
    for f in files:
        html = f.read_text(encoding="utf-8", errors="replace")
        rel = f.relative_to(root).as_posix()
        for tag in re.finditer(r"<[a-zA-Z][^>]*\bhref\s*=\s*[\"'][^\"']+[\"'][^>]*>", html):
            m = re.search(r'href\s*=\s*["\']([^"\']+)["\']', tag.group(0), re.I)
            url = html_mod.unescape(m.group(1).strip())  # &amp; 等を実URLに戻す
            if url.startswith("//"):
                url = "https:" + url  # プロトコル相対は外部扱いに正規化
            if not url or url.startswith("#") or url.lower().startswith(SKIP_SCHEMES):
                continue
            links[url].add(rel)
            r = re.search(r'\brel\s*=\s*["\']([^"\']*)["\']', tag.group(0), re.I)
            if r and "sponsored" in r.group(1).lower():
                sponsored.add(url)
    return links, sponsored, len(files)


def resolve_internal(root: Path, base_url: str, url: str, from_page: str):
    """内部リンクをローカルファイル候補に解決。存在すれば True"""
    u = url
    if u.startswith(base_url):
        u = u[len(base_url):] or "/"
    path = urllib.parse.urlsplit(u).path
    if not path:
        return True  # クエリ/フラグメントのみ
    if path.startswith("/"):
        local = root / path.lstrip("/")
    else:
        local = (root / from_page).parent / path
    try:
        local = local.resolve()
    except OSError:
        return False
    cands = [local, local / "index.html", Path(str(local).rstrip("/\\") + ".html")]
    return any(c.is_file() for c in cands)


def http_status(url: str, timeout: float):
    """(status, final_url, note) を返す。エラーは status<=0"""
    ctx = ssl.create_default_context()
    for method in ("HEAD", "GET"):
        req = urllib.request.Request(url, method=method, headers={"User-Agent": UA, "Accept-Language": "ja,en;q=0.8"})
        try:
            with urllib.request.urlopen(req, timeout=timeout, context=ctx) as res:
                return res.status, res.geturl(), ""
        except urllib.error.HTTPError as e:
            if method == "HEAD":
                continue  # HEADに404/403等を返すサイトがある→必ずGETで確定
            return e.code, url, ""
        except urllib.error.URLError as e:
            reason = str(getattr(e, "reason", e))
            if "SSL" in reason or "certificate" in reason.lower():
                return -1, url, f"SSL: {reason}"  # ローカル証明書ストア起因の可能性→要目視
            return 0, url, f"URLError: {reason}"
        except Exception as e:  # SSL/timeout等
            return -1, url, f"{type(e).__name__}: {e}"
    return -1, url, "unreachable"


def main():
    ap = argparse.ArgumentParser(description="リンクの一括健診(広告・計測ドメインへは HTTP リクエストしない)")
    ap.add_argument("--config", default="", help="config.yaml のパス(省略時はカレントの config.yaml)")
    ap.add_argument("--root", default="", help="走査する公開フォルダ(省略時は config の paths.public)")
    ap.add_argument("--base-url", default="", help="サイトのURL(省略時は config の site.domain から作る)")
    ap.add_argument("--deny", default="", help="HTTPリクエスト禁止ドメイン(カンマ区切り。config の metrics.affiliate_domains に追加)。"
                                               "広告を置いていないサイトは none")
    ap.add_argument("--out", default="", help="レポートの出力先(.md)")
    ap.add_argument("--offline", action="store_true", help="HTTPリクエストなし(内部+静的検査のみ)")
    ap.add_argument("--limit", type=int, default=0, help="走査するHTMLファイル数の上限(動作確認用)")
    ap.add_argument("--timeout", type=float, default=12.0)
    a = ap.parse_args()

    cfg = load_config(a.config)
    paths = cfg.get("paths") if isinstance(cfg.get("paths"), dict) else {}
    site = cfg.get("site") if isinstance(cfg.get("site"), dict) else {}
    root_arg = a.root or str(paths.get("public") or "")
    base = a.base_url or (("https://" + norm_domain(site.get("domain"))) if site.get("domain") else "")
    if not root_arg or not base:
        ap.error("--root と --base-url を指定するか、config.yaml に paths.public と site.domain を書いてください")
    root = Path(root_arg).resolve()
    if not root.is_dir():
        ap.error(f"走査フォルダがありません: {root}")
    base = base.rstrip("/")

    denylist, declared_none = build_denylist(cfg, a.deny)
    if not a.offline and not denylist and not declared_none:
        print("中止: DENYLIST が空です。広告・計測ドメインへ誤ってリクエストしないよう、HTTP 実査を始めません。\n"
              "  - config.yaml の metrics.affiliate_domains にドメインを書く、または --deny で渡す\n"
              "  - 広告を置いていないサイトなら --deny none を明示する\n"
              "  - 動作確認だけなら --offline を使う", file=sys.stderr)
        sys.exit(2)

    links, sponsored, n_files = collect_links(root, a.limit)

    internal_missing, dead, moved, warn, ok_count = [], [], [], [], 0
    skipped = defaultdict(set)
    last_hit = {}  # domain -> 最終リクエスト時刻(1秒1リクエスト)

    external = []
    for url, pages in sorted(links.items()):
        page = sorted(pages)[0]
        is_http = url.lower().startswith(("http://", "https://"))
        if is_http and (is_denied(url, denylist) or url in sponsored):
            skipped[urllib.parse.urlsplit(url).hostname or url].update(pages)
        elif url.startswith(base) or not is_http:
            if not resolve_internal(root, base, url, page):
                # サーバー側の書き換え等でローカル実体なしでも生きている場合がある→本番で最終確認
                if not a.offline and url not in sponsored:
                    full = url if is_http else base + "/" + url.lstrip("/")
                    status, _, _ = http_status(full, a.timeout)
                    if 200 <= status < 400:
                        ok_count += 1
                        continue
                internal_missing.append((url, pages))
        else:
            external.append((url, pages))

    if not a.offline:
        for i, (url, pages) in enumerate(external, 1):
            dom = urllib.parse.urlsplit(url).hostname or ""
            wait = 1.0 - (time.time() - last_hit.get(dom, 0))
            if wait > 0:
                time.sleep(wait)
            status, final, note = http_status(url, a.timeout)
            last_hit[dom] = time.time()
            if status in (404, 410) or (status == 0):
                dead.append((url, status, note, pages))
            elif status in (301, 302, 307, 308):
                moved.append((url, final, pages))
            elif 200 <= status < 400:
                if urllib.parse.unquote(final).rstrip("/") != urllib.parse.unquote(url).rstrip("/"):
                    moved.append((url, final, pages))
                else:
                    ok_count += 1
            else:
                warn.append((url, status, note, pages))
            print(f"  [{i}/{len(external)}] {status if status > 0 else 'ERR'} {url[:90]}", file=sys.stderr)

    lines = [f"# リンク監査レポート {date.today().isoformat()}", ""]
    lines += [f"- 走査: {n_files} HTMLファイル / ユニークリンク {len(links)}件(外部 {len(external)}・内部他 {len(links)-len(external)})",
              f"- 結果: ✅OK {ok_count} / 🔴dead {len(dead)} / 🔁moved {len(moved)} / ⚠️要目視 {len(warn)} / 内部欠損 {len(internal_missing)}",
              "- ⏭️ 広告・計測ドメインは **HTTPリクエストせず** 静的集計のみ: "
              + (", ".join(f"{d}({len(p)}ページ)" for d, p in sorted(skipped.items())) or "なし"),
              f"- DENYLIST: {', '.join(denylist) if denylist else '(空)'} + rel=\"sponsored\" のリンク",
              "" if not a.offline else "- ⚠️ --offline 実行=外部リンクは未実査", ""]

    def emit(title, rows, fmt):
        lines.append(f"## {title}({len(rows)}件)")
        for r in rows:
            lines.append(fmt(r))
        lines.append("")

    emit("🔴 dead(404/410/接続不能)=修正必須", dead,
         lambda r: f"- `{r[0]}` … {r[1] if r[1] > 0 else r[2]}(使用: {', '.join(sorted(r[3])[:5])})")
    emit("🔴 内部リンク欠損(ローカルにファイルなし)", internal_missing,
         lambda r: f"- `{r[0]}`(使用: {', '.join(sorted(r[1])[:5])})")
    emit("⚠️ 要目視(403/429/timeout等=ブラウザで開いて確認)", warn,
         lambda r: f"- `{r[0]}` … {r[1] if r[1] > 0 else r[2]}(使用: {', '.join(sorted(r[3])[:5])})")
    emit("🔁 moved(恒久移動=直リンク化を推奨)", moved,
         lambda r: f"- `{r[0]}` → `{r[1]}`(使用: {', '.join(sorted(r[2])[:5])})")

    report = "\n".join(lines)
    if a.out:
        Path(a.out).parent.mkdir(parents=True, exist_ok=True)
        Path(a.out).write_text(report, encoding="utf-8")
        print(f"レポート出力: {a.out}", file=sys.stderr)
    print(report)


if __name__ == "__main__":
    main()

MIT ライセンスで配布しています。