0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ループエンジニアリング応用 - 公開記事の反応を「外に何も出さずに」集める — allowlist × count-only スキャン × fail-closed で作る自動メトリクス収穫ループ(Python 標準ライブラリだけ)

0
Last updated at Posted at 2026-08-28

自分が Qiita / note に書いた記事の「反応(いいね・スキ・コメント)」を毎日集めて、次に何を書くと効くかを機械的に出す——そういう自分用のループを作りました。

作るときに引っかかった落とし穴を先に並べます。

  • 収穫スクリプトが、うっかり手元の絶対パス(/Users/自分名/...)を成果物に書き込む
  • 非公式 API( note の内部エンドポイント)は、いつ壊れてもおかしくない
  • 「数字が 0 になった」のか「取得に失敗した」のかが、後から区別できない
  • 秘密情報の検査をしたのに、そのログに秘密の値そのものを出力してしまう
  • 収穫→分析を自動で回すと、AI が勝手に外部へ何かを送りかねない

この記事は、これらを 設計(構造)で潰すための実装パターンです。プロダクトの宣伝ではなく、そのまま移植できる形で書きます。

※この記事は 2026 年 8 月時点の一次情報に基づきます。ツールやサービスの仕様は変わるので、実行前に公式ドキュメントで最新を確認してください。組織のデータを扱う場合は、自組織の情報セキュリティ部門に確認のうえ判断してください。


結論:3 つだけ覚えて帰ってください

  1. 入力は allowlist、生成は「メモリ→検査→合格したものだけ書く」。書いてから検査するのでは、汚れた行がもうディスクに残る。
  2. スキャナは count-only。秘密や禁止語を「見つけた」と報告するのは良いが、その値そのものは画面にもログにも出さない(出した瞬間、スクショやターミナル履歴・CI ログという新しい経路ができる)。
  3. 非公式 API は壊れる前提source_status(ok / rss_only / unavailable / parse_failed)と縮退レベルを最初から設計に入れ、数字が取れなくてもパイプラインは止めない

1. 全体像:3 つの入れ子ループ + 安全ゲート

この仕組みは、速度の違う 3 つのループでできています。

🔁 速い:  記事を書く → 公開前ゲート → 公開
🔁 中速:  公開後の反応を収穫 → 正規化して蓄積 → 週次で「次に書くネタ」を提案
🔁 遅い:  ログ・成果物を分析 → ルール/スクリプトを改善 → 蒸留

この記事の主役は 中速ループ(反応の収穫) と、それを貫く 安全ゲート です。

安全ゲートは 3 か所に置きます。

ゲート 位置 仕事
入力 allowlist 収穫の入口 決めたソース以外は取りに行かせない
出力検査(count-only) 書き込みの直前 秘密・禁止語・ローカルパスを検出したら停止(値は出さない)
ログ前点検 遅いループの入口 ログを分析にかける前に中身を数えて確認

🔰 fail-closed とは

「異常時に閉じる(=止まる・通さない)ほうに倒す」設計方針です。逆は fail-open(異常時も通してしまう)。秘密が混じる可能性がある処理は、迷ったら閉じる側に倒します。


2. 収穫パイプライン:allowlist → 正規化 → 3 層保存

入力は 1 枚の allowlist ファイルに集約します。ここに載っていないものは取りに行きません。

# public_sources.yml — 取りに行ってよい「公開ソース」だけを列挙する
sources:
- name: zenn
  kind: git
  path_env: ZENN_CONTENT_DIR      # 絶対パスは書かず、環境変数で外から渡す
  path_suffix: articles
  url_template: https://zenn.dev/<user>/articles/{slug}
- name: qiita
  kind: api
  url: https://qiita.com/api/v2/users/<user>/items
  rss_url: https://qiita.com/<user>/feed     # API が落ちたら RSS へ縮退
- name: note
  kind: json
  url: https://note.com/api/v2/creators/<user>/contents?kind=note
  rss_url: https://note.com/<user>/rss

ポイントは、手元の絶対パスを追跡ファイルに書かないことです。path_env で環境変数名だけを持ち、実際のパスは実行時に渡します。こうすると、リポジトリを将来公開してもローカルの構成が漏れません。

allowlist は読み込み時に検証します。ホストも固定します。

ALLOWED_REMOTE_HOSTS = {"qiita.com", "note.com"}

def validate_sources(sources: list[Source]) -> None:
    for s in sources:
        if s.kind not in {"git", "api", "json"}:
            raise HarvestError(f"unsupported kind: {s.kind}")
        for url in (s.url, s.rss_url):
            if not url:
                continue
            host = urllib.parse.urlparse(url).netloc
            if host not in ALLOWED_REMOTE_HOSTS:
                raise HarvestError(f"host not allowlisted: {url}")  # 想定外は閉じる

収穫は「公開済みだけ」に絞ります。Zenn はローカルの Markdown を読み、フロントマターの published: true だけを対象にします。

def harvest_zenn(source: Source, collected_at: str) -> list[ArticleMetric]:
    base = Path(os.environ[source.path_env]) / source.path_suffix
    out = []
    for md in sorted(base.glob("*.md")):
        fm = parse_frontmatter(md.read_text(encoding="utf-8"))
        if fm.get("published") is not True:     # 下書きは構造的に入らない
            continue
        out.append(ArticleMetric(
            collected_at=collected_at, source="zenn", public_id=md.stem,
            url=source.url_template.format(slug=md.stem),
            title=str(fm.get("title", md.stem)),
            likes=None, comments=None, source_status="ok",  # Zenn いいねは取得対象外→None
        ))
    return out

保存は 3 層に分けます。役割が違うからです。

ファイル 役割
生ログ metrics/raw/<source>.jsonl 取得結果を最小フィールドで追記(本文は保存しない)
正規化 metrics/snapshots/article_metrics.csv 分析用の 1 枚テーブル(時系列で追記)
週次 metrics/reports/YYYY-MM-DD-weekly.md 人間が読むサマリ+提案

CSV の列はこうしました。source_status を必ず入れるのがキモです。

collected_at, source, public_id, url, title, published_at,
likes, comments, stocks, views, source_status

3. 「取れなかった」を記録する — source_status と縮退レベル

非公式 API は落ちます。落ちたときに数字を空にするだけだと、後で「反応が減った」のか「取れなかった」のか分かりません。だから 状態そのものを列に残します

def harvest_qiita(source, collected_at):
    try:
        payload = fetch_json(source.url)          # まず公式 API
    except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
        return harvest_rss_fallback(source, collected_at)   # 落ちたら RSS へ
    # ... likes_count / comments_count を拾って source_status="ok"

縮退は 5 段階で設計しておくと、どこで何が起きても破綻しません。

レベル 状態 動作
L0 通常 git + 公開反応数を取得
L1 反応 API 失敗 RSS / git で同期し、反応数は unavailable
L2 RSS も一部失敗 成功したソースだけ同期、失敗はレポートに明記
L3 全取得失敗 既存データだけで処理、提案は出さない
L4 出力検査で不合格 書き込み・以降の処理を停止し、差分だけ提示

思想は「反応数はあれば使う補助信号」。パイプラインの本体は収穫と正規化で、メトリクスが取れなくても止まらない設計にします。


4. fail-closed の核:書く前に検査し、値は出さない

ここが一番大事なところです。

(1) 書き込みの「前」に検査する

生成物を一度メモリ上で組み立て、検査に通ったものだけディスクへ書きます。書いてから検査すると、追記型ファイル(JSONL / CSV)には汚れた行がもう残ってしまうからです。

def run(articles, report_date, forbidden_terms):
    planned = {
        "raw": render_jsonl(articles),
        "csv": render_csv(articles),
        "report": build_report(articles, report_date),
    }
    findings = []
    for label, text in planned.items():
        findings += scan_text(label, text, forbidden_terms)   # 先に検査
    if findings:
        for f in findings:
            print(f"FAIL: {f}", file=sys.stderr)
        return 2                                               # 書かずに停止(fail-closed)
    commit_to_disk(planned)                                    # 合格分だけ書く
    return 0

(2) 検査は count-only(値そのものを出さない)

検出したことは報告しますが、検出した値は出しません。禁止語を報告メッセージに埋めると、それが CI ログやターミナル履歴に転記され、新しい経路になってしまうためです。

def scan_text(label, text, forbidden_terms):
    findings = []
    normalized = text.casefold()
    for term in forbidden_terms:
        if term and term.casefold() in normalized:
            findings.append(f"{label}: forbidden term matched (redacted)")  # 語は出さない
    if LOCAL_PATH_PATTERN.search(text):
        findings.append(f"{label}: local path detected")
    for pat in SECRET_PATTERNS:
        if pat.search(text):
            findings.append(f"{label}: secret-like pattern matched")        # 値は出さない
    return findings

(3) 禁止語リストの指定を必須にする(未指定なら止める)

禁止語スキャンを「オプション」にすると、指定し忘れた回だけ無防備になります。そこで 未指定なら実行しない(= fail-closed)にしました。リスト自体はリポジトリの外に置きます。

def load_forbidden_terms(path: Path | None) -> list[str]:
    if path is None:
        env = os.environ.get("FORBIDDEN_TERMS_FILE")
        if not env:
            raise HarvestError("--forbidden-terms or FORBIDDEN_TERMS_FILE is required")
        path = Path(env)
    return [ln.strip() for ln in path.read_text(encoding="utf-8").splitlines()
            if ln.strip() and not ln.startswith("#")]

🔰 count-only 原則

見つけたことは言う。見つけたモノは言わない」。検査ツールが値を復唱すると、検査ログ自体が漏洩面になります。件数と種別(secret-like pattern matched)だけを出し、値は伏せます。この原則は、この後のログ点検ツールでも同じです。


5. ログを分析にかける「前」に、ログを点検する

遅いループでは、作業ログ(.jsonl)を分析素材に使います。ただしログには、トークンやメール、社外秘のコードが含まれうるため、分析へ渡す前に中身を点検します。

点検ツールは徹底して受け身に作ります。通信しない・読むだけ・数えるだけ・値は伏字

DETECTORS = [
    ("メールアドレス",  re.compile(r"\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b")),
    ("ホームディレクトリ", re.compile(r"/(?:Users|home)/[^/\s\"']+")),
]

# 出力は件数だけ。値は一切出さない。
#   1件  AWS アクセスキー
#   1件  認証情報の代入

ひとつ、公式仕様に沿った設計上の判断があります。

Claude Code のセッションログ(.jsonl)は内部形式でありバージョン間で変わりうるため、公式は「直接パースするな」としています。正しい入口は /exportclaude -p --output-format json、hooks の transcript_path、Agent SDK の 4 つです。

この点検ツールは スキーマを解釈せず、生テキストを正規表現で走査するだけなので、内部形式に依存しません=バージョン差で壊れません。一方、ログから「ルール」を抽出する用途にはこれを使わず、上記の公式入口を使います。用途で入口を分けるのが要点です。

そして、検出ゼロは安心の根拠にしないこと。正規表現が拾えるのは既知パターンだけで、社外秘のコードや固有名詞は拾えません。最後は人の目で見ます。


6. 実装を縛る公式仕様(先に知っておくと事故らない)

自動ループを組む前に踏んでおくべき一次情報です。

① ログ保持期間:セッションログは既定で 30 日で自動削除(cleanupPeriodDays)。資産として使うなら、退避先を先に決めておく。

② ルールを溜めすぎると効かない:指示を 500 個与えると、最良モデルでも遵守率は 68% という報告があります(Jaroslawicz et al. 2025・arXiv/数値は要最終確認)。前方が効きやすい位置バイアス("Lost in the Middle")もある。→ ルールファイルは蒸留・剪定を回し、重要ルールを前に置く。

③ SessionEnd フックの実行予算は既定 1.5 秒:ここで LLM を呼ぶと時間超過で静かに失敗する。フックに載せるのは高速・非 LLM の処理だけ(前述のログ点検は純正規表現なので載る)。timeout で最大 60 秒まで拡張可能。

④ セッション間連携は公式機能化SendMessage / ListAgents(macOS / Linux)。エージェント間の受け渡しを自前で発明する必要は薄い。


7. 複数エージェントで回すときの境界

このループは、役割の違う 2 系統のエージェント(文章・設計担当と、コード・集計担当)で回しています。衝突と混入を防ぐための境界を、ファイル単位で引きました。

  • 生成物の書き込みは PR 経由main への反映は人間が承認(human gate)。
  • 公開コンテンツ本体(content/)を編集してよいのは片方だけ。もう片方はスクリプトとメトリクスだけを触る。
  • 収穫の入力は前述の public_sources.yml の allowlist に限定。ブラウザや外部コネクタは、この収穫では使わない。

境界を「口約束」でなく allowlist とディレクトリ担当という構造で持たせると、どちらのエージェントが動いても越境が起きません。

チェックリスト(そのまま流用可)

  • 入力は allowlist ファイルに集約したか(ホスト・パスを固定)
  • 追跡ファイルに絶対パス(/Users/... 等)が残っていないか
  • 生成は「メモリ→検査→合格分だけ書く」順か
  • 検査は count-only か(値を出力していないか)
  • 禁止語リスト未指定で fail-closed に止まるか
  • source_status と縮退レベルを持っているか
  • ログ点検ツールは通信ゼロ・読むだけか

まとめ — 「止める設計」は、後から足すより先に引くほうが安い

反応メトリクスの収穫は、突きつめると「取りに行く先を決め、書く前に検査し、値は出さない」の 3 つに集約されます。

  • allowlist で入口を絞る=取りに行く先を決める
  • メモリ→検査→書き込み=書く前に検査する
  • count-only=見つけたことは言い、見つけたモノは言わない

派手な仕組みではありません。ですが、この 3 つを構造として持たせておくと、あとで自動化を足しても越境や漏洩が起きにくくなります。全部を一度に完璧にする必要はなく、まず allowlist と「書く前の検査」の 2 つから始めれば十分に効きます。


一次情報(参考文献)

  1. Anthropic「Loop engineering: Getting started with loops」(2026-06-30)https://claude.com/blog/getting-started-with-loops
  2. IBM Think「What Is Loop Engineering?」(2026-07-17)https://www.ibm.com/think/topics/loop-engineering
  3. Martin Alderson「Self-improving CLAUDE.md files」(2026-02-08)https://martinalderson.com/posts/self-improving-claude-md-files/
  4. CISA ほか 5 か国合同「Careful Adoption of Agentic AI Services」(2026-04-30)※エージェント型 AI が API キー等の機微情報を保持しうる点の一次情報
  5. IPA「テキスト生成 AI の導入・運用ガイドライン」(2024-07-31)
  6. Jaroslawicz et al.(2025, arXiv)指示数と遵守率に関する報告 ※数値は公開前に再確認

※各リンク・巻号・数値は公開前に最新の一次情報で照合してください。


⚠️ 免責

本記事は筆者の現時点での個人的見解であり、法的助言ではありません。
本記事で触れた各種ガイドライン・規制は概要の紹介にとどまります。実際の適用可否は、必ず自組織の情報セキュリティ部門・法務・所属組織の責任者と相談のうえ判断してください。
筆者は、本記事の内容を実際の業務で用いたことに起因するいかなる損害についても責任を負いません。


著者プロフィール

臨床工学技士 × AIエンジニア / 11年間、病院の医療機器の現場に立ち続けてきました。
いまは AI エンジニアとしても活動しながら、酪農学園大学の研究生として論文博士の取得を目指しています。研究テーマの主軸は遺伝子医療の未来。医療現場と地続きにある病院の IT・サイバーセキュリティについても、現場の課題と一次情報を突き合わせながら調べ続けています。

質問・誤りの指摘・「うちではこうしている」という事例の共有、いつでも歓迎します。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?