# -*- coding: utf-8 -*-
"""レビューログ&frontmatterインデックス
全レビューMD(reviews/ と reviews/archive/)の冒頭 YAML frontmatter を読み、
reviews/review_log.md を再生成する(1レビュー=1行の時系列ログ・手編集禁止)。
「追記」ではなく毎回フル再生成する設計: 情報の正はレビューMD自身の frontmatter に
あるので、ログはいつでも作り直せる派生物。書き忘れ・ファイル移動(archive/退避)・
再取得のどれが起きてもログが壊れない。
frontmatter 形式(article-review スキル STEP 1 の規約):
---
reviewer: legal
date: 2026-01-15 10:30:15 # 秒まで(ファイル名のTSと同時刻。日付のみも許容)
articles:
sample-article: 97
another-article: null # 点数未確定は null
---
使い方:
python -X utf8 scripts/review_log.py [--root <プロジェクトルート>] [--config config.yaml]
フォルダは config の paths.reviews / paths.articles を使う(無ければ reviews / content/articles)。
PyYAML が無い環境では、上の形式だけを読める簡易パーサに切り替える。
"""
import os, re, sys, io, glob, datetime, argparse
try:
import yaml
except ImportError:
yaml = None
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
DEFAULT_REVIEWERS = {'legal', 'consistency', 'seo', 'cro', 'panel', 'confidentiality'}
IGNORE_BASENAMES = {'review_log.md'}
FM_RE = re.compile(r'^---\s*\n(.*?)\n---\s*(?:\n|$)', re.S)
def default_root():
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
def load_config(path):
"""config.yaml を読む。無い・PyYAML 無し・壊れている場合は {} を返し既定値で動く"""
if not os.path.isfile(path):
return {}
if yaml is None:
print('⚠️ PyYAML が無いため config を読めません(既定のフォルダ名で動きます): pip install pyyaml', file=sys.stderr)
return {}
try:
with open(path, encoding='utf-8') as fh:
data = yaml.safe_load(fh) or {}
except Exception as e:
print(f'⚠️ config を解釈できません(既定のフォルダ名で動きます): {e}', file=sys.stderr)
return {}
return data if isinstance(data, dict) else {}
def simple_frontmatter(block):
"""PyYAML が無いとき用の簡易パーサ(reviewer / date / articles の3キーだけ読む)"""
data, arts, in_articles = {}, {}, False
for line in block.splitlines():
line = re.sub(r'\s+#.*$', '', line).rstrip()
if not line.strip():
continue
if in_articles and line[:1] in (' ', '\t'):
k, _, v = line.strip().partition(':')
v = v.strip().strip('"').strip("'")
arts[k.strip().strip('"').strip("'")] = None if v in ('', 'null', '~') else v
continue
in_articles = False
k, _, v = line.partition(':')
k, v = k.strip(), v.strip().strip('"').strip("'")
if k == 'articles':
in_articles = True
elif k:
data[k] = v
if arts:
data['articles'] = arts
return data
def parse_frontmatter(path):
"""ファイル冒頭の --- ... --- ブロックを dict で返す。無ければ None。"""
try:
with open(path, encoding='utf-8', errors='ignore') as fh:
head = fh.read(4000)
except OSError:
return None
if not head.startswith('---'):
return None
m = FM_RE.match(head)
if not m:
return None
if yaml is None:
return simple_frontmatter(m.group(1))
try:
data = yaml.safe_load(m.group(1))
except yaml.YAMLError:
return None
return data if isinstance(data, dict) else None
def known_reviewers(cfg):
gate = cfg.get('gate') if isinstance(cfg.get('gate'), dict) else {}
names = set(DEFAULT_REVIEWERS)
for key in ('tier1', 'tier2'):
names.update(str(n).strip() for n in (gate.get(key) or []) if str(n).strip())
return names
def build_index(root, reviews_rel='reviews', articles_rel=os.path.join('content', 'articles'), reviewers=None):
"""reviews/(+archive/)の frontmatter を走査する。
返り値:
entries : [{slug, reviewer, date, score, file, archived}] (ログ用・全件)
files : {basename: {'entries': [...], 'multi': bool}} (reviews/ 直下のみ)
warnings : [str] (frontmatter はあるが内容が規約外のもの)
"""
reviewers = reviewers or DEFAULT_REVIEWERS
slugs = set()
for ext in ('*.md', '*.html'):
slugs.update(os.path.splitext(os.path.basename(p))[0]
for p in glob.glob(os.path.join(root, articles_rel, ext)))
entries, files, warnings = [], {}, []
for sub, archived in ((reviews_rel, False), (os.path.join(reviews_rel, 'archive'), True)):
d = os.path.join(root, sub)
if not os.path.isdir(d):
continue
for f in sorted(os.listdir(d)):
if not f.endswith('.md') or f in IGNORE_BASENAMES:
continue
p = os.path.join(d, f)
if not os.path.isfile(p):
continue
fm = parse_frontmatter(p)
if fm is None:
continue
reviewer = str(fm.get('reviewer', '')).strip()
date = fm.get('date')
arts = fm.get('articles')
# PyYAML は "2026-01-15 10:30:15" を datetime、"2026-01-15" を date に変換する
if isinstance(date, datetime.datetime):
date = date.strftime('%Y-%m-%d %H:%M:%S')
elif isinstance(date, datetime.date):
date = date.isoformat()
date = str(date or '').strip().replace('T', ' ')
if not re.fullmatch(r'\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?', date):
warnings.append(f"{sub}/{f}: date が YYYY-MM-DD[ HH:MM:SS] でない({date!r})→ スキップ")
continue
if reviewer not in reviewers:
warnings.append(f"{sub}/{f}: 未知の reviewer {reviewer!r}(続行はする)")
if not isinstance(arts, dict) or not arts:
warnings.append(f"{sub}/{f}: articles が空/辞書でない → スキップ")
continue
f_entries = []
for slug, score in arts.items():
slug = str(slug).strip()
if slug not in slugs:
warnings.append(f"{sub}/{f}: 未知の記事slug {slug!r} → この行だけスキップ")
continue
if score is not None:
try:
score = int(score)
except (TypeError, ValueError):
warnings.append(f"{sub}/{f}: {slug} の点数 {score!r} が整数でない → 点数なし扱い")
score = None
e = {'slug': slug, 'reviewer': reviewer, 'date': date,
'score': score, 'file': f, 'archived': archived}
entries.append(e)
f_entries.append(e)
if not archived and f_entries:
files[f] = {'entries': f_entries, 'multi': len(f_entries) > 1}
return entries, files, warnings
def write_log(root, entries, reviews_rel='reviews'):
"""reviews/review_log.md を再生成する。1レビュー=1行・日付昇順。"""
out = os.path.join(root, reviews_rel, 'review_log.md')
lines = [
'# レビューログ(自動生成・手編集禁止)',
'# scripts/review_log.py が各レビューMD冒頭の frontmatter から毎回フル再生成する。',
'# 形式: 日時 | 記事slug | レビュアー | 点数 | 成果物ファイル(同一日時=同一巡)',
'# 記事の履歴を見るには、このファイルを記事slugで検索する。',
'',
]
for e in sorted(entries, key=lambda e: (e['date'], e['slug'], e['reviewer'], e['file'])):
score = str(e['score']) if e['score'] is not None else '—'
lines.append(f"{e['date']} | {e['slug']} | {e['reviewer']} | {score} | {e['file']}")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, 'w', encoding='utf-8', newline='\n') as fh:
fh.write('\n'.join(lines) + '\n')
return out, len(entries)
def main():
ap = argparse.ArgumentParser(description='レビューMDの frontmatter から reviews/review_log.md を再生成する')
ap.add_argument('--root', default='', help='プロジェクトルート(省略時はこのスクリプトの1つ上のフォルダ)')
ap.add_argument('--config', default='', help='config.yaml のパス(省略時は <root>/config.yaml)')
args = ap.parse_args()
root = os.path.abspath(args.root) if args.root else default_root()
cfg = load_config(args.config or os.path.join(root, 'config.yaml'))
paths = cfg.get('paths') if isinstance(cfg.get('paths'), dict) else {}
reviews_rel = str(paths.get('reviews') or 'reviews')
articles_rel = str(paths.get('articles') or os.path.join('content', 'articles'))
entries, files, warnings = build_index(root, reviews_rel, articles_rel, known_reviewers(cfg))
out, n = write_log(root, entries, reviews_rel)
print(f"OK: {out} entries={n} frontmatter付きファイル={len(files)}(直下)")
for w in warnings:
print(f"⚠️ {w}")
if __name__ == '__main__':
main()
MIT ライセンスで配布しています。