# -*- 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()) # & 等を実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 ライセンスで配布しています。