#!/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 ライセンスで配布しています。