review_metrics.py

scripts/review_metrics.py(495 行)

review_metrics.py
#!/usr/bin/env python3
"""review_metrics.py — 記事レビュー用の機械計測(read-only)

記事MD/HTMLを解析し、各レビューエージェントが目視で数えていた項目を機械計測して出力する。
記事ファイルには一切書き込まない。

使い方:
    python -X utf8 scripts/review_metrics.py <記事パス> [--json] [--config config.yaml] [--domain example.com]

設定(config.yaml。無くても既定値で動く):
    site.domain                  自サイトのドメイン(内部/外部リンクの判定)。--domain が優先
    metrics.variant_pairs        表記ゆれ候補ペア [長い形, 短い形]
    metrics.ng_patterns          誇大・断定表現の候補(正規表現)
    metrics.affiliate_domains    広告・計測リンクのドメイン
    metrics.long_sentence_chars  これを超える文を「長い文」として数える

出力セクションと主な利用先:
    [基本]        md5(鮮度スタンプ)・実測字数・title/desc字数      → 全員/legal/seo
    [見出し]      H1数・ツリー・重複/類似見出し                      → seo/consistency
    [リンク]      内部/外部リンク数・内部リンク先一覧                → seo
    [広告]        広告リンクの位置(%)・密度・重複                    → cro/legal
    [文章]        長い文・同一語尾3連続・表記ゆれ候補                → consistency
    [壁テキスト]  見出しなしで続く最長ブロック                       → panel
    [重複段落]    近似重複段落(二重構成の候補)                     → consistency
    [検算リスト]  「N選」「N個」等の件数宣言                          → consistency
    [NG表現]      誇大・断定表現の候補(最終判断は legal が文脈で)  → legal

※計測は「候補の列挙」まで。採点・文脈判断は各レビューエージェントが行う。
"""
import sys
import io
import re
import json
import hashlib
import difflib
import argparse
import urllib.parse
from pathlib import Path

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

# Markdown として扱う拡張子(これ以外は HTML として扱う)
MARKDOWN_SUFFIXES = {".md", ".mdx", ".markdown"}

# 表記ゆれ候補ペアの既定値(短形は長音が続かない場合のみカウント)。config があればそちらを使う
DEFAULT_VARIANT_PAIRS = [
    ("サーバー", "サーバ"), ("ユーザー", "ユーザ"), ("コンピューター", "コンピュータ"),
    ("フォルダー", "フォルダ"), ("プリンター", "プリンタ"), ("メモリー", "メモリ"),
    ("ブラウザー", "ブラウザ"), ("ルーター", "ルータ"), ("ドライバー", "ドライバ"),
    ("パラメーター", "パラメータ"), ("エディター", "エディタ"), ("マネージャー", "マネージャ"),
]

# 誇大・断定表現の候補の既定値(検出=即違反ではない。文脈判断は legal)
DEFAULT_NG_PATTERNS = [
    r"絶対に?", r"必ず", r"最安", r"最強", r"100[%%]", r"日本一", r"No\.?1", r"ナンバーワン",
    r"完璧", r"一番安い", r"誰でも(稼げ|儲か)", r"確実に(稼げ|儲か|勝て)", r"治る", r"効果を保証",
]

DEFAULT_LONG_SENTENCE_CHARS = 80

ENDINGS = ["でしょう", "ました", "でした", "ません", "ます", "です"]


def warn(msg: str):
    print(f"⚠️ {msg}", file=sys.stderr)


def load_config(explicit: str) -> dict:
    """config.yaml を読む。無い・PyYAML 無し・壊れている場合は {} を返し既定値で動く"""
    if explicit:
        cands = [Path(explicit)]
    else:
        cands = [Path.cwd() / "config.yaml", Path(__file__).resolve().parent.parent / "config.yaml"]
    for p in cands:
        if not p.is_file():
            continue
        try:
            import yaml
        except ImportError:
            warn("PyYAML が無いため config を読めません(既定値で計測します): pip install pyyaml")
            return {}
        try:
            data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
        except Exception as e:  # 壊れたYAMLでも計測自体は続ける
            warn(f"config を解釈できません(既定値で計測します): {e}")
            return {}
        return data if isinstance(data, dict) else {}
    if explicit:
        warn(f"config が見つかりません(既定値で計測します): {explicit}")
    return {}


def build_settings(cfg: dict, domain_arg: str) -> dict:
    """config と引数から計測設定を組み立てる(欠けた項目は既定値)"""
    site = cfg.get("site") if isinstance(cfg.get("site"), dict) else {}
    met = cfg.get("metrics") if isinstance(cfg.get("metrics"), dict) else {}

    pairs = []
    for p in met.get("variant_pairs") or []:
        if isinstance(p, (list, tuple)) and len(p) == 2 and all(p):
            pairs.append((str(p[0]), str(p[1])))
    patterns = []
    for pat in met.get("ng_patterns") or []:
        try:
            re.compile(str(pat))
            patterns.append(str(pat))
        except re.error as e:
            warn(f"ng_patterns の正規表現が不正なので飛ばします: {pat!r}({e})")
    try:
        long_chars = int(met.get("long_sentence_chars") or DEFAULT_LONG_SENTENCE_CHARS)
    except (TypeError, ValueError):
        long_chars = DEFAULT_LONG_SENTENCE_CHARS
    domain = (domain_arg or str(site.get("domain") or "")).strip().lower()
    domain = re.sub(r"^https?://", "", domain).strip("/")
    return {
        "domain": domain,
        "variant_pairs": pairs or DEFAULT_VARIANT_PAIRS,
        "ng_patterns": patterns or DEFAULT_NG_PATTERNS,
        "affiliate_domains": [str(d).strip().lower() for d in (met.get("affiliate_domains") or []) if str(d).strip()],
        "long_chars": long_chars,
    }


def read_article(path: Path):
    raw = path.read_bytes()
    text = raw.decode("utf-8", errors="replace")
    md5 = hashlib.md5(raw).hexdigest()
    fm = {}
    body = text
    if path.suffix.lower() in MARKDOWN_SUFFIXES:
        m = re.match(r"\A---\s*\n(.*?)\n---\s*\n", text, re.S)
        if m:
            body = text[m.end():]
            for line in m.group(1).splitlines():
                kv = re.match(r"^(\w+):\s*(.*)$", line)
                if kv:
                    fm[kv.group(1)] = kv.group(2).strip().strip('"').strip("'")
    else:  # html
        t = re.search(r"<title>(.*?)</title>", text, re.S | re.I)
        # content値中の異種引用符で早期終了しないよう、開始と同種の引用符で閉じる
        d = re.search(r'<meta\s+name=["\']description["\']\s+content=(["\'])(.*?)\1', text, re.S | re.I)
        if t:
            fm["title"] = re.sub(r"\s+", " ", t.group(1)).strip()
        if d:
            fm["description"] = d.group(2).strip()
        bm = re.search(r"<body[^>]*>(.*)</body>", text, re.S | re.I)
        if bm:
            body = bm.group(1)
    return md5, fm, body


def strip_to_text(body: str, is_html: bool):
    """タグ・コード・広告タグを除いた可読テキスト(位置関係は概ね維持)"""
    s = body
    s = re.sub(r"<script\b.*?</script>", " ", s, flags=re.S | re.I)
    s = re.sub(r"<style\b.*?</style>", " ", s, flags=re.S | re.I)
    s = re.sub(r"<!--.*?-->", " ", s, flags=re.S)
    if not is_html:
        s = re.sub(r"```.*?```", " ", s, flags=re.S)      # コードブロック
        s = re.sub(r"\{\{ad:[^}]+\}\}", " ", s)            # テンプレート広告タグ
        s = re.sub(r"^:{2,3}\w*\s*", "", s, flags=re.M)    # 囲み記法(:::box 等)の行頭
    s = re.sub(r"<pre\b.*?</pre>", " ", s, flags=re.S | re.I)
    s = re.sub(r"<[^>]+>", " ", s)                          # 残りのHTMLタグ
    if not is_html:
        s = re.sub(r"!\[[^\]]*\]\([^)]*\)(\{[^}\n]*\})?", " ", s)  # 画像(代替テキスト・{width=…} の指定)は本文に数えない
        s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s)   # MDリンク→文言のみ
        s = re.sub(r"\{#[\w-]+\}", " ", s)                # 見出しの {#id} 指定
        s = re.sub(r"[#>*_=`|-]{2,}", " ", s)
    return s


def mask_code(body: str, is_html: bool):
    """コードブロックを同じ長さの空白に置換(オフセット維持・誤検出防止)"""
    blank = lambda m: " " * len(m.group(0))
    s = re.sub(r"<pre\b.*?</pre>", blank, body, flags=re.S | re.I)
    s = re.sub(r"<script\b.*?</script>", blank, s, flags=re.S | re.I)
    if not is_html:
        s = re.sub(r"```.*?```", blank, s, flags=re.S)
        s = re.sub(r"`[^`\n]+`", blank, s)
    return s


def get_headings(masked: str, is_html: bool):
    """[(level, text, char_offset)] — MDはMD見出し+HTML混在見出しの両方を拾う"""
    heads = []
    for m in re.finditer(r"<h([1-4])[^>]*>(.*?)</h\1>", masked, re.S | re.I):
        txt = re.sub(r"<[^>]+>", "", m.group(2))
        heads.append((int(m.group(1)), re.sub(r"\s+", " ", txt).strip(), m.start()))
    if not is_html:
        for m in re.finditer(r"^(#{1,4})\s+(.+)$", masked, re.M):
            txt = re.sub(r"<[^>]+>", "", m.group(2))
            heads.append((len(m.group(1)), txt.strip(), m.start()))
        heads.sort(key=lambda h: h[2])
    return heads


def get_links(body: str, is_html: bool, own_domain: str):
    links = []
    if is_html:
        for m in re.finditer(r'<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', body, re.S | re.I):
            links.append((m.group(1), re.sub(r"<[^>]+>", "", m.group(2)).strip()))
    else:
        for m in re.finditer(r"(?<!\!)\[([^\]]+)\]\(([^)\s]+)[^)]*\)", body):
            links.append((m.group(2), m.group(1).strip()))
        for m in re.finditer(r'<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', body, re.S | re.I):
            links.append((m.group(1), re.sub(r"<[^>]+>", "", m.group(2)).strip()))
    internal, external = [], []
    for url, anchor in links:
        if url.startswith("#"):
            continue
        if url.startswith("http") and (not own_domain or own_domain not in url.lower()):
            external.append((url, anchor))
        else:
            internal.append((url, anchor))
    return internal, external


def host_of(url: str) -> str:
    u = "https:" + url if url.startswith("//") else url
    try:
        return (urllib.parse.urlsplit(u).hostname or "").lower()
    except ValueError:
        return ""


def is_affiliate_host(host: str, domains) -> bool:
    return bool(host) and any(host == d or host.endswith("." + d) for d in domains)


def get_ads(body: str, is_html: bool, affiliate_domains):
    """広告の (種別, 表示用識別子, offset, 重複判定キー)

    - ad-tag         : 本文中のテンプレート広告タグ {{ad:ID}}
    - affiliate-link : 設定した広告・計測ドメイン(metrics.affiliate_domains)へのリンク
    - sponsored-link : rel="sponsored" が付いたリンク(ドメイン未設定でも拾える)
    """
    ads = []
    for m in re.finditer(r"\{\{ad:([^}]+)\}\}", body):
        ident = m.group(1).strip()
        ads.append(("ad-tag", ident, m.start(), "tag:" + ident))
    for m in re.finditer(r"<a\s[^>]*>", body, re.I):
        tag = m.group(0)
        h = re.search(r'href\s*=\s*["\']([^"\']+)["\']', tag, re.I)
        if not h:
            continue
        url = h.group(1).strip()
        host = host_of(url)
        rel = re.search(r'rel\s*=\s*["\']([^"\']*)["\']', tag, re.I)
        if is_affiliate_host(host, affiliate_domains):
            ads.append(("affiliate-link", host, m.start(), "url:" + url))
        elif rel and "sponsored" in rel.group(1).lower():
            ads.append(("sponsored-link", host or "(相対URL)", m.start(), "url:" + url))
    if not is_html:
        for m in re.finditer(r"(?<!\!)\[[^\]]+\]\(([^)\s]+)[^)]*\)", body):
            host = host_of(m.group(1))
            if is_affiliate_host(host, affiliate_domains):
                ads.append(("affiliate-link", host, m.start(), "url:" + m.group(1)))
    ads.sort(key=lambda a: a[2])
    return ads


def split_sentences(text: str):
    parts = re.split(r"(?<=[。!?!?])\s*", text)
    return [re.sub(r"\s+", " ", p).strip() for p in parts if len(p.strip()) >= 8]


def ending_of(sentence: str):
    core = sentence.rstrip("。!?!?  ")
    for e in ENDINGS:
        if core.endswith(e):
            return e
    return None


def analyze(path: Path, st: dict):
    is_html = path.suffix.lower() not in MARKDOWN_SUFFIXES
    md5, fm, body = read_article(path)
    masked = mask_code(body, is_html)
    site = st["domain"]
    long_chars = st["long_chars"]

    text = strip_to_text(body, is_html)
    plain = re.sub(r"\s+", "", text)
    text_len = len(plain)

    r = {"file": str(path), "md5": md5, "site": site or "(未設定)"}

    # 基本
    title = fm.get("title", "")
    desc = fm.get("description", "")
    r["basic"] = {
        "text_len": text_len,
        "title": title, "title_len": len(title),
        "description_len": len(desc),
        "reading_min": fm.get("reading_min", ""),
        "reading_min_estimate": round(text_len / 600, 1),  # 600字/分目安
        "updated": fm.get("updated", ""),
    }

    # 見出し
    heads = get_headings(masked, is_html)
    h1 = [h for h in heads if h[0] == 1]
    norm = [re.sub(r"[\s【】()()..]+", "", h[1]) for h in heads]  # 数字は残す(型番・順位の誤検出防止)
    dup, similar = [], []
    for i in range(len(heads)):
        for j in range(i + 1, len(heads)):
            if not norm[i] or not norm[j]:
                continue
            if norm[i] == norm[j]:
                dup.append((heads[i][1], heads[j][1]))
            elif difflib.SequenceMatcher(None, norm[i], norm[j]).ratio() >= 0.8:
                similar.append((heads[i][1], heads[j][1]))
    r["headings"] = {
        "h1_count": len(h1),
        "tree": [f"{'#' * lv} {t}" for lv, t, _ in heads],
        "duplicates": dup, "similar": similar,
    }

    # リンク
    internal, external = get_links(body, is_html, site)
    r["links"] = {
        "internal_count": len(internal), "external_count": len(external),
        "internal_targets": sorted({u for u, _ in internal}),
    }

    # 広告
    ads = get_ads(body, is_html, st["affiliate_domains"])
    blen = max(len(body), 1)
    ad_list = [{"type": t, "id": i, "pos_pct": round(o * 100 / blen)} for t, i, o, _ in ads]
    keys = [k for _, _, _, k in ads]
    r["ads"] = {
        "count": len(ad_list), "items": ad_list,
        "duplicated_ads": sorted({k.split(":", 1)[1] for k in keys if keys.count(k) > 1}),
        "per_1500chars": round(len(ad_list) / max(text_len / 1500, 0.001), 2) if ad_list else 0,
    }

    # 文章
    sents = split_sentences(text)
    long_sents = [s for s in sents if len(s) > long_chars]
    comma3 = [s for s in sents if s.count("、") >= 3]  # 1文の読点は2回までが目安
    runs, cur, prev = [], 1, None
    for s in sents:
        e = ending_of(s)
        if e and e == prev:
            cur += 1
        else:
            if prev and cur >= 3:
                runs.append((prev, cur))
            cur = 1
        prev = e
    if prev and cur >= 3:
        runs.append((prev, cur))
    variants = []
    for long_f, short_f in st["variant_pairs"]:
        c_long = len(re.findall(re.escape(long_f), text))
        c_short = len(re.findall(re.escape(short_f) + r"(?!ー)", text))
        if c_long and c_short:
            variants.append(f"{long_f}×{c_long} / {short_f}×{c_short}")
    r["copy"] = {
        "sentence_count": len(sents),
        "long_sentence_chars": long_chars,
        "long_sentences": len(long_sents),
        "long_samples": [s[:60] + "…" for s in long_sents[:5]],
        "commas_over2": len(comma3),
        "comma_samples": [s[:60] + "…" for s in comma3[:3]],
        "same_ending_runs": [f"「{e}。」×{n}連続" for e, n in runs],
        "variant_candidates": variants,
    }

    # 壁テキスト
    walls = []
    if heads:
        bounds = [o for _, _, o in heads] + [len(body)]
        for k in range(len(heads)):
            seg = strip_to_text(body[bounds[k]:bounds[k + 1]], is_html)
            walls.append((heads[k][1], len(re.sub(r"\s+", "", seg))))
        walls.sort(key=lambda x: -x[1])
    r["walls"] = {"top3": [{"heading": h, "chars": c} for h, c in walls[:3]]}

    # 近似重複段落
    paras = [re.sub(r"\s+", " ", p).strip() for p in re.split(r"\n\s*\n|</p>", text if not is_html else body)]
    paras = [re.sub(r"<[^>]+>", "", p) for p in paras]
    paras = [p for p in paras if len(p) >= 80][:300]
    dup_paras = []
    for i in range(len(paras)):
        for j in range(i + 1, len(paras)):
            if abs(len(paras[i]) - len(paras[j])) > max(len(paras[i]), len(paras[j])) * 0.3:
                continue
            if difflib.SequenceMatcher(None, paras[i], paras[j]).quick_ratio() >= 0.85 and \
               difflib.SequenceMatcher(None, paras[i], paras[j]).ratio() >= 0.85:
                dup_paras.append({"a": paras[i][:50] + "…", "b": paras[j][:50] + "…"})
    r["dup_paragraphs"] = dup_paras[:10]

    # 検算リスト
    decls = []
    for m in re.finditer(r"(\d{1,3})\s*(選|個|冊|種類|ステップ|つの|台|項目)", text):
        ctx = text[max(0, m.start() - 20):m.end() + 10].replace("\n", " ")
        decls.append(f"「{m.group(0)}」 … {re.sub(r'[ \t]+', ' ', ctx).strip()}")
    seen, uniq = set(), []
    for d in decls:
        key = d.split("…")[0]
        if key not in seen:
            seen.add(key)
            uniq.append(d)
    r["count_declarations"] = uniq[:15]

    # NG表現候補
    ngs = []
    for pat in st["ng_patterns"]:
        for m in re.finditer(pat, text):
            ctx = re.sub(r"\s+", " ", text[max(0, m.start() - 25):m.end() + 25]).strip()
            ngs.append({"hit": m.group(0), "context": ctx})
    r["ng_candidates"] = ngs[:20]
    r["ng_total"] = len(ngs)

    # 開示・広告注記の手掛かり
    r["disclosure_hints"] = {
        "has_ads": bool(ads),
        "pr_words_in_source": bool(re.search(r"アフィリエイト|広告|PR|プロモーション", body)),
        "note": "テンプレートが自動挿入する開示は、ビルド後のHTMLで実在を確認すること",
    }
    return r


def print_report(r):
    b = r["basic"]
    print(f"=== review_metrics: {r['file']} ===")
    print(f"[基本] md5={r['md5']}  site={r['site']}")
    print(f"  実測字数(タグ・コード除外)={b['text_len']:,}字  title={b['title_len']}字  desc={b['description_len']}字")
    if b["reading_min"]:
        print(f"  reading_min={b['reading_min']}分 vs 実測目安 {b['reading_min_estimate']}分(600字/分)")
    h = r["headings"]
    print(f"[見出し] H1={h['h1_count']}個  総数={len(h['tree'])}  重複={len(h['duplicates'])}  類似={len(h['similar'])}")
    for a, bb in h["duplicates"]:
        print(f"  ⚠️重複: 「{a}」=「{bb}」")
    for a, bb in h["similar"]:
        print(f"  ⚠️類似: 「{a}」≒「{bb}」")
    for t in h["tree"]:
        print(f"    {t}")
    l = r["links"]
    print(f"[リンク] 内部={l['internal_count']}  外部={l['external_count']}")
    for u in l["internal_targets"]:
        print(f"    → {u}")
    a = r["ads"]
    print(f"[広告] {a['count']}箇所  密度={a['per_1500chars']}個/1500字  重複={a['duplicated_ads'] or 'なし'}")
    for it in a["items"]:
        print(f"    {it['pos_pct']:>3}%地点  {it['type']}: {it['id']}")
    c = r["copy"]
    print(f"[文章] 全{c['sentence_count']}文  {c['long_sentence_chars']}字超={c['long_sentences']}文  "
          f"読点3+={c['commas_over2']}文  語尾3連続={c['same_ending_runs'] or 'なし'}")
    for s in c["long_samples"]:
        print(f"    長文例: {s}")
    for s in c["comma_samples"]:
        print(f"    読点3+例: {s}")
    if c["variant_candidates"]:
        print(f"  ⚠️表記ゆれ候補: {' / '.join(c['variant_candidates'])}")
    print("[壁テキスト] 見出し間の文字数TOP3:")
    for w in r["walls"]["top3"]:
        flag = " ⚠️" if w["chars"] > 1500 else ""
        print(f"    {w['chars']:,}字{flag} — 「{w['heading']}」の直後")
    print(f"[重複段落] 近似重複={len(r['dup_paragraphs'])}組")
    for d in r["dup_paragraphs"]:
        print(f"  ⚠️「{d['a']}」≒「{d['b']}」")
    print("[検算リスト] 件数宣言(実体と要突合):")
    for d in r["count_declarations"]:
        print(f"    {d}")
    print(f"[NG表現候補] {r['ng_total']}件(文脈判断は legal-reviewer)")
    for n in r["ng_candidates"]:
        print(f"    「{n['hit']}」: …{n['context']}…")
    dh = r["disclosure_hints"]
    print(f"[開示] 広告あり={dh['has_ads']}  PR/広告等の語がソースに存在={dh['pr_words_in_source']}")
    print(f"  ※{dh['note']}")


def main():
    ap = argparse.ArgumentParser(description="記事レビュー用の機械計測(read-only・記事には書き込まない)")
    ap.add_argument("article", help="記事ファイル(.md または .html)")
    ap.add_argument("--json", action="store_true", help="JSONで出力する")
    ap.add_argument("--config", default="", help="config.yaml のパス(省略時はカレント→scripts の親の順に探す)")
    ap.add_argument("--domain", default="", help="自サイトのドメイン(config の site.domain より優先)")
    a = ap.parse_args()

    path = Path(a.article)
    if not path.is_file():
        print(f"ERROR: ファイルが見つかりません: {path}")
        sys.exit(1)
    r = analyze(path, build_settings(load_config(a.config), a.domain))
    if a.json:
        print(json.dumps(r, ensure_ascii=False, indent=1))
    else:
        print_report(r)


if __name__ == "__main__":
    main()

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