Skip to content

substack-skill 設計書

最終更新: 2026-05-14(Codex レビュー反映版) 関連: #26 tracking, SUBSTACK_API.md

0. 目的

日本語で技術系コンテンツを発信するユーザーが、

  1. 4種類の入力ソースから記事本文を自動生成し
    • topic … トピック・アイデア(B-1 / TopicSource)
    • url … Web URL(B-2 / UrlSource)
    • pdf … 論文・PDF(B-3 / PdfSource)
    • markdown … 既存 Zenn / Qiita / 議事録 Markdown(B-4 / MarkdownSource)
  2. 挿絵(Higgsfield / OpenAI gpt-image)と(Excalidraw 手描き風)を自動添付し
  3. Substack の下書きとしてアップロードする

までを Claude Code から 1 コマンドで実行できるスキルを提供する。

最終公開は人間が Substack エディタ上で行う(B-2 / B-3 の翻案や図解は AI が誤る前提)。

非ゴール(Phase 1 では実装しない):

  • 自動公開・スケジュール公開
  • 有料記事の課金設定
  • 既存記事の編集
  • ノートブック投稿(Notes)
  • ポッドキャスト/動画

1. アーキテクチャ概観

┌─────────────────────────────────────────────────────────────────────────┐
│  Claude Code (slash commands)                                            │
│  /substack-draft  /substack-from-url  /substack-from-pdf  /substack-...  │
└─────────────────────────────────────────────────────────────────────────┘
                              │ subprocess

┌─────────────────────────────────────────────────────────────────────────┐
│  substack-skill CLI (typer)                                              │
│  src/substack_skill/cli.py                                               │
└─────────────────────────────────────────────────────────────────────────┘
       │ orchestrate

┌──────────────────────────────────────────────────────────────────────┐
│  Pipeline orchestrator (Article IR を中心とした責務分離)               │
│                                                                       │
│  ┌───────────┐  ┌──────────────┐  ┌───────────┐  ┌────────────────┐ │
│  │ sources/  │→ │ generators/  │→ │ assets/   │→ │ publishers/    │ │
│  │ - topic   │  │ - article    │  │ - image   │  │ - substack     │ │
│  │ - url     │  │ - title      │  │ - diagram │  │   draft        │ │
│  │ - pdf     │  │ - tags       │  │ - cache   │  │                │ │
│  │ - markdown│  │              │  │           │  │                │ │
│  └───────────┘  └──────────────┘  └───────────┘  └────────────────┘ │
│                                                                       │
│             共通: ir/model.py (Article IR), errors.py, retry.py       │
└──────────────────────────────────────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────────────┐
│  External services                                                    │
│  - Anthropic Claude API     - Higgsfield API     - OpenAI API         │
│  - Substack (unofficial)    - Excalidraw renderer (Node CLI)          │
└──────────────────────────────────────────────────────────────────────┘

1.1 設計原則

  1. Article IR を唯一のハブにする — 入力源・出力先を疎結合に
  2. 副作用は publishers/ 配下に集約 — 生成系(generators/, assets/)は pure に近づける
  3. 外部API呼び出しは1モジュールに隔離 — 仕様変更時の影響範囲を局所化
  4. 失敗しても再開可能 — 各ステップの中間成果物を output/.work/<run_id>/ に保存
  5. 秘密情報は Keychain のみ — .env 平文禁止(AIProject CLAUDE.md ルール)
  6. 公開行動を絶対に取らない/api/v1/drafts/{id}/publish は本スキルで実装すらしない。CLI 名も publish ではなく push-draft を採用(誤実装の導線を絶つ)。
  7. 個人情報・著作物を不要に保持しない — 中間成果物・ログは既定で 7日でローテーション削除、入力ソース原文は --no-source-cache で完全保存無効化が可能。詳細は §10。

2. ディレクトリ構成

substackTools/
├── pyproject.toml
├── README.md
├── SKILL.md                       # Claude Code が読むエントリ
├── commands/                      # スラッシュコマンド定義(.claude/commands/ にsymlink)
│   ├── substack-draft.md          # ハブ(ソース未指定で対話)
│   ├── substack-from-url.md
│   ├── substack-from-pdf.md
│   ├── substack-convert-zenn.md
│   ├── substack-convert-qiita.md
│   ├── substack-image.md
│   ├── substack-diagram.md
│   └── substack-push-draft.md     # IR → 下書きアップロード(旧 publish 相当)
├── scripts/
│   ├── substack_login.py          # Playwright で対話的Cookie取得
│   ├── substack_verify.py         # 認証疎通確認
│   └── install.sh                 # uv venv + playwright install + Keychain ガイド
├── src/substack_skill/
│   ├── __init__.py
│   ├── cli.py                     # typer エントリ
│   ├── orchestrator.py            # パイプライン制御
│   │
│   ├── ir/
│   │   ├── model.py               # Article / Block / ImageRef / DiagramRef
│   │   └── serialize.py           # JSON ラウンドトリップ
│   │
│   ├── sources/
│   │   ├── base.py                # Source プロトコル
│   │   ├── topic.py               # ゼロ生成 (B-1)
│   │   ├── url.py                 # Webページ要約 (B-2)
│   │   ├── pdf.py                 # PDF/論文 (B-3)
│   │   └── markdown.py            # Zenn/Qiita/議事録 (B-4)
│   │
│   ├── generators/
│   │   ├── article.py             # LLM呼び出し本体
│   │   ├── title.py               # タイトル/サブタイトル/SEO meta (B-5)
│   │   ├── tags.py                # タグ提案 (B-6)
│   │   └── prompts/               # システムプロンプト群
│   │
│   ├── assets/
│   │   ├── image/
│   │   │   ├── base.py            # ImageProvider プロトコル (C-4)
│   │   │   ├── higgsfield.py      # (C-2)
│   │   │   ├── openai.py          # (C-3)
│   │   │   ├── cover.py           # OGP/カバー画像 (C-5)
│   │   │   ├── prompt_extract.py  # 記事 → 画像プロンプト抽出 (C-1)
│   │   │   └── cache.py           # ローカルキャッシュ (C-6)
│   │   └── diagram/
│   │       ├── base.py            # 図 → PNG レンダラー
│   │       ├── excalidraw_gen.py  # 記事 → Excalidraw JSON (D-1)
│   │       ├── excalidraw_render.py # Excalidraw JSON → PNG (D-2)
│   │       ├── placement.py       # 図の挿入位置判定 (D-3)
│   │       └── style.py           # スタイル一貫性 (D-4)
│   │
│   ├── publishers/
│   │   └── substack/
│   │       ├── client.py          # python-substack の薄ラッパ + リトライ
│   │       ├── auth.py            # Keychain Cookie ロード/保存 (E-2)
│   │       ├── prosemirror.py     # IR → ProseMirror JSON 変換 (E-4)
│   │       ├── images.py          # 画像 CDN アップロード (E-3)
│   │       ├── draft.py           # 下書き作成/更新 (E-5)
│   │       └── preview.py         # プレビュー/編集URL組み立て (E-6)
│   │
│   ├── infra/
│   │   ├── keychain.py            # security コマンドラッパ (A-3)
│   │   ├── retry.py               # 指数バックオフ・429ハンドリング (G-1)
│   │   ├── log.py                 # 構造化ログ (G-1)
│   │   ├── workdir.py             # output/.work/<run_id>/ 管理 (G-1)
│   │   └── llm.py                 # Anthropic API クライアント(Claude API skill 活用、プロンプトキャッシュ前提)
│   │
│   └── errors.py                  # 例外階層

├── templates/
│   ├── diagram_style.json         # Excalidraw のデフォルトスタイル (D-4)
│   └── article_skeletons/         # 章立てテンプレート

├── tests/
│   ├── fixtures/                  # F-4 のサンプル入力
│   │   ├── topic_input.txt
│   │   ├── url_sample.txt
│   │   ├── paper_sample.pdf
│   │   └── zenn_sample.md
│   ├── unit/                      # 純関数のテスト
│   ├── contract/                  # 外部API契約テスト(mockサーバ)
│   └── e2e/                       # 手動E2Eチェックリスト (G-3)

└── docs/
    ├── DESIGN.md                  # この文書
    ├── SUBSTACK_API.md            # E-1 調査結果
    ├── AUTH.md                    # E-2 認証手順
    ├── SECRETS.md                 # Keychain 登録手順
    ├── E2E_CHECKLIST.md           # G-3
    └── FAQ.md                     # G-4

3. データモデル: Article IR (A-4)

すべての入力ソースは Article IR を出力し、すべての出力先は Article IR を入力に取る。

3.1 中核モデル

python
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal

@dataclass
class Article:
    title: str
    subtitle: str | None
    body: list[Block]
    cover_image: ImageRef | None
    seo_description: str
    tags: list[str]
    slug: str
    audience: Literal["everyone", "only_free", "only_paid", "founding"] = "everyone"
    canonical_url: str | None = None         # クロスポスト元 URL(B-4)
    source: SourceMeta = field(default_factory=lambda: SourceMeta())
    language: Literal["ja", "en", "ja+en"] = "ja"

@dataclass
class SourceMeta:
    kind: Literal["topic", "url", "pdf", "markdown", "unknown"] = "unknown"
    origin: str | None = None                # URL / file path / topic 文字列
    fetched_at: str | None = None
    citations: list[Citation] = field(default_factory=list)

@dataclass
class Citation:
    label: str
    url: str
    accessed_at: str

# Block 共通: 安定アンカー & 確実な round-trip のため、すべての block は BlockBase を継承
@dataclass
class BlockBase:
    block_id: str = field(default_factory=lambda: uuid4().hex[:12])
    # 画像/図/footnote 等から参照される安定キー。生成元の再現に必要

# Block はタグ付き union(discriminator は _kind、共通フィールドは BlockBase)
Block = (
    "Paragraph | Heading | CodeBlock | BulletList | OrderedList | Quote | "
    "Divider | ImageBlock | DiagramBlock | Callout | LinkCard | MathBlock | "
    "Footnote | Embed | UnknownBlock | RawPmNode"
)

@dataclass
class Paragraph(BlockBase):
    runs: list[Run] = field(default_factory=list)  # inline marks 付きテキスト列
@dataclass
class Run:
    text: str
    marks: list[Literal["strong","em","code","strikethrough"]] = field(default_factory=list)
    link: str | None = None                  # link mark の href
    footnote_ref: str | None = None          # Footnote.id への参照(インライン脚注番号)
    math_inline: str | None = None           # インライン数式(LaTeX)

@dataclass
class Heading(BlockBase):
    level: int = 2                           # 1..6
    text: str = ""

@dataclass
class CodeBlock(BlockBase):
    language: str | None = None
    code: str = ""

@dataclass
class BulletList(BlockBase): items: list[ListItem] = field(default_factory=list)
@dataclass
class OrderedList(BlockBase): items: list[ListItem] = field(default_factory=list)
@dataclass
class ListItem: blocks: list[Block] = field(default_factory=list)

@dataclass
class Quote(BlockBase): blocks: list[Block] = field(default_factory=list)

@dataclass
class Divider(BlockBase): pass

@dataclass
class ImageBlock(BlockBase):
    ref: ImageRef = None
    caption: str | None = None
    alt: str = ""
    anchor_after: str | None = None          # 直前 block の block_id(位置安定化、§4.3)

@dataclass
class ImageRef:
    local_path: Path                         # 生成直後 / 元画像
    cdn_url: str | None = None               # Substack CDN アップロード後(E-3で埋まる)
    cdn_meta: dict | None = None             # internalRedirect, width, height, type, bytes
    provider: Literal["higgsfield","openai","user-supplied"] = "user-supplied"
    prompt: str | None = None                # 再生成用
    cache_key: str | None = None             # sha256(prompt+provider+style+size)

@dataclass
class DiagramBlock(BlockBase):
    ref: DiagramRef = None
    caption: str | None = None
    anchor_after: str | None = None          # 直前 block の block_id(§4.3)
@dataclass
class DiagramRef:
    excalidraw_json: dict                    # 元定義(再編集可能性のため保持)
    rendered_png: Path
    cdn_url: str | None = None
    cdn_meta: dict | None = None

@dataclass
class Callout(BlockBase):
    kind: Literal["note","warn","info"] = "note"
    blocks: list[Block] = field(default_factory=list)

@dataclass
class LinkCard(BlockBase):
    url: str = ""
    preview: dict | None = None              # OGP/Substackレスポンス

@dataclass
class MathBlock(BlockBase):
    latex: str = ""
    display: bool = True                     # True: ブロック式, False: インライン(通常は Run.math_inline を使う)

@dataclass
class Footnote(BlockBase):
    id: str = ""                             # Run.footnote_ref から参照される ID
    blocks: list[Block] = field(default_factory=list)

@dataclass
class Embed(BlockBase):
    provider: Literal["youtube","tweet","github","other"] = "other"
    url: str = ""

# Unsupported / Drift 用エスケープハッチ
@dataclass
class UnknownBlock(BlockBase):
    """ProseMirror で観測したが IR 型化が未確定なノード。原表現を保持して情報損失を防ぐ。"""
    original_kind: str = ""                  # 元のノード type 名(例 "calloutCard")
    payload: dict = field(default_factory=dict)
    rendered_fallback: str | None = None     # paragraph フォールバック時の暫定表示テキスト

@dataclass
class RawPmNode(BlockBase):
    """ProseMirror JSON を直接埋め込みたい場合に使う(変換責務を IR から逃がす)。"""
    pm_json: dict = field(default_factory=dict)

3.2 serialize ルール

  • serialize.to_json(article) -> dict / serialize.from_json(d) -> Article
  • 中間成果物として output/.work/<run_id>/<step>.json に保存(ステップ別、§7.3 参照)
  • 失敗時は同じ JSON を入力に各ステップから再開可能
  • Block の union は {"_kind": "Paragraph", "block_id": "...", ...} の discriminator + 安定ID付きで保存
  • 保持期間: 既定 7日(infra/workdir.py がローテーション削除)。--keep-work で延長、--purge-run <id> で個別削除。詳細 §10
  • PII 含有可能性:
    • source 配下(原文・URL・引用)はソース由来。--no-source-cache で本文の保存をスキップ
    • body 配下(生成済本文)は AI 出力。原則保存、ただし source が PII を含む場合は連動して --no-body-cache 推奨

3.3 確認未取得ノードの扱い

SUBSTACK_API.md §5.3 の通り、以下の ProseMirror ノードは型が未確定:

  • Footnote / FootnoteRef(インラインの参照番号)
  • Callout
  • LinkCard(linkPreview 系)
  • MathBlock / インライン math(Run.math_inline
  • BulletList / OrderedList / ListItem(ProseMirror 標準名と推定だが未確認)

fallback 戦略(情報損失ゼロを優先):

  1. IR レベルでは型を定義し続ける(上記すべて §3.1 に列挙済み)
  2. 実観測前は次の優先順位でフォールバック:
    1. RawPmNode に推定 ProseMirror JSON を入れて送る(リスクは Substack 側 reject)
    2. 失敗したら UnknownBlock に降格、rendered_fallback の文字列を paragraph として送る
    3. paragraph 化時も original_kind / payload を残し、後で再変換可能にする
  3. 読み戻し時の損失ゼロ: IR ↔ JSON は full round-trip。Unknown も完全保持
  4. DevTools 実観測(E-4 / #51)でノード型を確定したら、 UnknownBlock → 専用 IR 型への自動マイグレーションを serialize レイヤで行う

4. パイプライン詳細

4.1 入力ソース層 (sources/)

すべて class Source(Protocol): def load(self) -> SourceInput: を実装。

Sourceload 出力LLM への前処理
TopicSource (B-1)短文テーマ + ヒントアウトライン生成 → 章ごとに本文生成
UrlSource (B-2)本文HTML (trafilatura抽出) + meta本文要約 + 自分の視点コメント。robots.txt遵守、ペイウォール検知
PdfSource (B-3)テキスト抽出 (pdfplumber) + ページごとの構造Abstract/Methods/Results を識別して章立て生成
MarkdownSource (B-4)フロントマター除去後の Markdown + 元URL読者層差を意識した冒頭追記 + canonical_url 設定

robots.txt / 著作権ガード:

  • UrlSource は robots.txt を尊重し、disallow なら作業を止める
  • 全引用は本文に原文URL + アクセス日を明記(IR.citations)
  • 翻案性のチェックは LLM プロンプトで「要約 + 独自分析」を強制

4.2 生成層 (generators/)

SourceInput


ArticleGenerator (Claude 4.7 / 4.6 切替可)
    │  └─ プロンプトキャッシュ前提(システム+テンプレ+資料)

Article(IR)  ← title/tags/slug は別ジェネレータで上書き

    ├─→ TitleGenerator    (3-5案 → 1案選択 or 候補返却)
    ├─→ TagsGenerator     (既存タグとの揺れ吸収)
    └─→ SeoGenerator      (155字 description, OGP題材)

LLM呼び出しの統一: infra/llm.py でラップ。

  • システムプロンプトを prompts/ 配下に分割
  • Anthropic の prompt caching を必ず使う(claude-api skill ガイドに準拠)
  • リトライ・トークン上限・モデル切替を共通化

4.3 アセット層 (assets/)

4.3.1 挿入位置の安定化(M1 対応)

画像・図ともに挿入位置を anchor_after = <block_id> で表現する(block_index ではない)。理由:

  • block_index は本文の編集・再生成で簡単にずれる
  • block_id は serialize ラウンドトリップで安定保持される
  • 再開時(--resume)に位置が再現できる
  • 統合フェーズで「同じ block の直後に画像と図が競合する」場合のみ調整が必要

4.3.2 統合フロー(画像 / 図を1パスで決定

Article(IR, body だけ完成)


asset_planner.plan(article):
    │  1. prompt_extract.extract(article) → 画像候補 (anchor_after, prompt, alt, caption, style)
    │  2. placement.suggest(article)       → 図候補    (anchor_after, description, style)
    │  3. 競合解決: 同じ anchor_after に複数候補 → 重要度上位を残し他を1つ前/後ろの block へ移動
    │  4. 上限制御: --max-images / --max-diagrams を反映、本文長から自動推定
    │  5. AssetPlan を返す(IR は未改変)

AssetPlan = [ {anchor_after, asset_kind: image|diagram, params...} ]

AssetPlan を中間 JSON として 02b_asset_plan.json に保存し、再生成時はこのプランから再開可能(プロンプトキャッシュ的効果)。

4.3.3 画像生成 (C-1〜C-6)

  1. AssetPlan から asset_kind=image を取り出す
  2. プロバイダ選択(環境変数 SUBSTACK_SKILL_IMAGE_PROVIDER or --image-provider
  3. キャッシュ確認(sha256(prompt+provider+style+size))→ ヒットしたら API スキップ
  4. 失敗時はセカンダリプロバイダに自動切替(C-4)
  5. 1456px 縮小 + JPEG 85品質で保存(ローカル)
  6. Article.bodyanchor_after 直後に ImageBlock を挿入

4.3.4 図生成 (D-1〜D-4)

  1. AssetPlan から asset_kind=diagram を取り出す
  2. excalidraw_gen.generate(description) で JSON 生成
  3. style.apply(json, style_template) で色/線幅統一(templates/diagram_style.json)
  4. excalidraw_render.render(json) -> Path で PNG 化(Node CLI @excalidraw/excalidraw-cli を採用。インストール手順は F-5)
  5. Article.bodyanchor_after 直後に DiagramBlock を挿入

4.4 出力層 (publishers/substack/)

C1(チェックポイント順序)と C2(冪等性)を踏まえた確定パイプライン:

Article(IR + ImageBlock(local_path) + DiagramBlock(rendered_png))


auth.load_cookies() → resolve_identity() → SubstackClient
    │   ↑ §5.2 で /user/profile/self を叩き publication_url / user_id を確定

images.upload_all(article)
    │   # ImageBlock.ref.local_path / DiagramBlock.ref.rendered_png を
    │   # /api/v1/image に POST して cdn_url + cdn_meta (internalRedirect等) を書き戻す
    │   # === ここで `04_uploaded.json` を保存(C1)===

prosemirror.convert(article) → dict (ProseMirror JSON)
    │   # アップロード済 cdn_url を src に埋め込む必要があるため、必ず upload 後
    │   # === ここで `05_prosemirror.json` を保存(C1)===

draft.create_or_update(article, prosemirror_doc, *, slug_lock=...)  → draft_id
    │   # === ここで `06_draft.json` を保存(draft_id 含む)===

preview.urls(draft_id) → (edit_url, preview_url)

4.4.1 冪等性: slug ベースの create-or-update(C2 対応)

「同じ slug の下書きは1本だけ」をローカルlock + リモート再照合で保証する。

def create_or_update(article, prosemirror_doc):
    slug = article.slug
    cache_path = workdir.global_cache() / f"slug_index.json"  # slug → draft_id

    with file_lock(cache_path, key=slug):                      # ① ローカル排他ロック
        cached = load_cache(cache_path).get(slug)
        # ② リモート再照合(commit直前にもう一度引く、race防止)
        remote_id = find_remote_draft_by_slug(slug)
        draft_id = remote_id or cached
        if draft_id:
            api.put(f"/api/v1/drafts/{draft_id}", body=...)    # 更新
        else:
            resp = api.post("/api/v1/drafts", body=...)        # 新規作成
            draft_id = resp["id"]
        update_cache(cache_path, slug, draft_id)
        return draft_id

find_remote_draft_by_slug の実装:

  • GET /api/v1/drafts?filter=draft&offset={n}&limit=100offset 増分で全件走査
  • 該当 slug が見つかった時点で early-return
  • 走査結果はメモリキャッシュ(同一実行内)。永続キャッシュは slug_index.json のみ
  • 全走査でも 1000件以上は警告(運用上の異常値)

追加の安全弁:

  • create 直前に draft.user_id == self_user_id を確認(誤投稿防止)
  • 並行実行は 同 publication 内で同時に max 1(環境ロック ~/.cache/substack-skill/publication.lock
  • create 失敗時は cache を更新しない(次回再試行で再作成)

4.4.2 公開(参考)

本スキルでは POST /api/v1/drafts/{id}/publish実装しない(§1.1 #6)。 x-news-auto トラック(#64)で別途実装する。


5. 認証設計 (A-3 / E-2)

5.1 Keychain 経由のシークレット取得

infra/keychain.py:

python
def get(key: str, *, account: str | None = None, default: str | None = None) -> str | None:
    """security find-generic-password -s <key> -a <user> -w を実行。
    1) os.environ[key] があればそれを優先(CI/test 用)
    2) Keychain から取得
    3) 無ければ default → None
    値はメモリ保持のみ、ファイルに書かない。
    """
def require(key: str, *, account: str | None = None) -> str:
    """無ければ KeychainMissingError を投げる"""

5.2 Substack 認証フロー

5.2.1 Cookie取得とアイデンティティの自動解決(M4 対応)

状態動作
SUBSTACK_COOKIES_STRING が Keychain に有るApi(cookies_string=...) を初期化し、直後に resolve_identity() を必ず実行
無い / 401返却AuthRequiredError を投げ、scripts/substack_login.py 起動を案内
取得後login スクリプトが publication / user_id まで自動解決

resolve_identity() の責務(推奨デフォルト):

GET /api/v1/user/profile/self

profile.publicationUsers[*] を走査
  ├─ is_primary=true の publication を採用(複数なら最初の primary)
  └─ profile.id を user_id として保持

返却値:
  {
    user_id: 12345678,           # draft_bylines[*].id に使う
    publication_id: "...",
    publication_url: "https://yourpub.substack.com",
    publication_subdomain: "yourpub",
    custom_domain: "newsletter.example.com" | None,
  }

旧形式 (profile.primaryPublication) からの移行に備え、publicationUsers が空なら primaryPublication に fallback する(SUBSTACK_API.md §4.1 / 補足表)。

手動 override(fallback 扱い):

  • SUBSTACK_PUBLICATION_URL を Keychain に登録しておけば resolve_identity() の結果より優先
  • 複数 publication を運営する場合の切替に使う

5.2.2 login スクリプトの動作

  1. Playwright Chromium 起動(headed mode、ユーザー操作前提)
  2. https://substack.com/sign-in を開く
  3. ユーザーがメール/パスワード(または magic link)でログイン
  4. ログイン完了を /api/v1/user/profile/self への成功リクエストで検知
  5. context.cookies() で全Cookieを取得、substack.sid substack.lli sid を抽出
  6. security add-generic-password -s SUBSTACK_COOKIES_STRING -a $USER -w "..." -U
  7. resolve_identity() を実行し、検出した publication / user_id を画面表示してユーザー承認
  8. 承認されたら publication_url を Keychain に保存(複数 publication 時のみ)

5.2.3 verify スクリプト

  • GET /api/v1/user/profile/self を叩いてユーザー名・publication 一覧 + primary を表示
  • 401なら明確に substack_login.py 再実行を促す
  • --json で機械可読出力(CI/smoke test 用)

5.3 必要なシークレット一覧

Keychain Key必須/任意用途取得方法
SUBSTACK_COOKIES_STRING必須substack.sid 等のセッションsubstack_login.py で取得
SUBSTACK_PUBLICATION_URL任意(override)複数 publication 運営時の指定login で表示 → 必要なら手動保存
ANTHROPIC_API_KEY必須LLM呼び出し既登録
HIGGSFIELD_API_KEY必須画像生成 primary既登録
OPENAI_API_KEY必須画像生成 fallback既登録
  • user_id / publication_idシークレット非該当(公開情報)。~/.cache/substack-skill/identity.json にキャッシュ
  • このキャッシュは resolve_identity() が初回・Cookie変更時に再生成
  • 検証用に substack-skill auth verify --json で内容確認可能

5.4 漏洩防止

  • スクリプト/コードでトークンを print しない(ログには ***masked***
  • 例外メッセージに値を含めない
  • output/.work/ に書く中間ファイルは生Cookieを保存しない
  • .gitignore*.cookies.json, substack_session.json, output/, .tmp/, .cache/ を除外(実装済み)

6. CLI / スラッシュコマンド (A-5 / F-2)

6.1 CLI 体系(typer)

substack-skill draft --topic "..." [--with-search] [--outline-only]
substack-skill draft --url <URL>   [--style commentary|summary|tutorial]
substack-skill draft --pdf <PATH>  [--arxiv-id 2501.12345]
substack-skill convert --from {zenn|qiita} --path <PATH or URL>
substack-skill image  --prompt "..." --provider {higgsfield|openai}
substack-skill diagram --description "..."
substack-skill push-draft --article <article.json> [--update <draft_id>]   # 旧 publish。下書きアップロードのみ
substack-skill auth login
substack-skill auth verify [--json]
substack-skill workdir purge [--older-than 7d | --run-id <id>]              # §10 retention

共通フラグ:

フラグデフォルト説明
--output-diroutput/<timestamp>/中間成果物の保存先
--run-id自動採番既存 run と紐付ける
--resume <run_id>中間成果物から続きから実行(C1 のチェックポイント参照)
--dry-runfalse外部API呼び出し・書き込みを行わない
--verbose / --quietログレベル制御
--no-cachefalse画像キャッシュをバイパス
--image-providerhiggsfieldhiggsfield / openai
--max-images5画像生成上限(コスト爆発防止、§10)
--max-diagrams3図生成上限
--no-images / --no-diagramsfalse各アセット生成を完全スキップ
--no-source-cachefalse入力ソース原文を output/.work/ に保存しない(PII対応)
--no-body-cachefalse生成済本文も保存しない
--keep-workfalse自動削除(7日)を無効化

6.2 スラッシュコマンド

commands/*.md.claude/commands/ に symlink で配置。各コマンドは中で CLI を起動。 ファイル名と slash 名は 1対1で揃える(n1 対応)。

Slash 名commands/ ファイル等価 CLI
/substack-draftsubstack-draft.mdtyper に対話的に引数を尋ねつつ substack-skill draft ... 実行
/substack-from-url <URL>substack-from-url.mdsubstack-skill draft --url <URL>
/substack-from-pdf <PATH>substack-from-pdf.mdsubstack-skill draft --pdf <PATH>
/substack-convert-zenn <PATH>substack-convert-zenn.mdsubstack-skill convert --from zenn --path <PATH>
/substack-convert-qiita <URL>substack-convert-qiita.mdsubstack-skill convert --from qiita --path <URL>
/substack-image <PROMPT>substack-image.mdsubstack-skill image --prompt <PROMPT>
/substack-diagram <DESC>substack-diagram.mdsubstack-skill diagram --description <DESC>
/substack-push-draftsubstack-push-draft.mdsubstack-skill push-draft --article <article.json>(旧 /substack-publish を改名)

7. エラーハンドリング・リトライ (G-1)

7.1 例外階層

python
class SubstackSkillError(Exception): ...
class AuthRequiredError(SubstackSkillError): ...       # Cookie失効 → login再実行
class KeychainMissingError(SubstackSkillError): ...
class ProviderError(SubstackSkillError): ...           # 画像/LLM プロバイダ共通
class RateLimitError(ProviderError): ...               # 429
class TransientError(ProviderError): ...               # 5xx / network
class ContentPolicyError(ProviderError): ...           # OpenAI policy 違反
class SubstackApiError(SubstackSkillError): ...
class SchemaDriftError(SubstackSkillError): ...        # ProseMirror/API 仕様変更検知

7.2 retry ポリシー

infra/retry.py:

  • @retry(max_attempts=5, base=2.0, cap=60.0, on=(TransientError, RateLimitError))
  • 429 は Retry-After ヘッダ尊重
  • リトライごとに構造化ログ

7.3 再開可能性

各ステップは output/.work/<run_id>/<step>.json を書く。順序は §4.4 のパイプラインと一致させる(C1 対応):

ファイル内容作成タイミング
01_source.jsonSourceMeta + 原文入力ソースロード後
02_article.jsonArticle IR(body 完成・cover/tags/seo 確定、画像/図 未挿入)生成層完了後
02b_asset_plan.jsonAssetPlan(画像・図候補と挿入位置)§4.3.2 統合フェーズ後
03_with_assets.jsonArticle IR(ImageBlock / DiagramBlock 挿入済、ローカルパスのみ、CDN URL 未)画像・図生成完了後
04_uploaded.jsonArticle IR(CDN URL + internalRedirect 等 cdn_meta が埋まった完成版)Substack CDN アップロード後
05_prosemirror.jsonProseMirror JSON document変換後(CDN URL を反映した最終形)
06_draft.jsondraft_id + 編集URL + プレビューURLSubstack 下書き作成/更新後

--resume <run_id> の挙動:

  • 最新の存在する 0N_*.json を起点に、次のステップから実行
  • たとえば 04_uploaded.json まで存在すれば、再開時は prosemirror.convert(...) から開始
  • 5xx でアップロード途中失敗の場合は 04_uploaded.json不完全状態で書かない → 次回は upload を再実行
  • 各ステップは atomic write(.tmp → rename)で半端な JSON を残さない

最終ステップだけ失敗した場合:

  • 05_prosemirror.json まで揃っていれば、substack-skill push-draft --resume <run_id>draft のみ再試行
  • slug が同じなら §4.4.1 の cache 経由で既存 draft を update

7.4 監視 (G-3 系)

  • 構造化ログ(JSON Lines)→ ~/.local/share/substack-skill/logs/<date>.log
  • ログマスキング: 値が substack.sid / ANTHROPIC_API_KEY / HIGGSFIELD_API_KEY / OPENAI_API_KEY / Cookie 系のいずれかにマッチすれば ***masked***(正規表現テストで自動検査、§8.3)
  • メトリクス: 各ステップの所要時間・LLMトークン消費・画像枚数・コスト見積
  • スキーマドリフト検知: API レスポンスの想定外フィールド欠落で SchemaDriftError
  • 週次 smoke test: 下書き作成→即削除(CI またはローカル cron)
  • 週次 live contract suite(M6 対応): 実 Substack に対して最小1ラウンド、以下のレスポンス形状を assertion:
    • /api/v1/user/profile/selfpublicationUsers[*].publication.id / subdomain / custom_domain, is_primary の存在
    • POST /api/v1/imageurl / imageUrl / imageWidth / imageHeight / imageType / internalRedirect の存在
    • POST /api/v1/draftsid / draft_body / draft_title / audience / draft_bylines の存在
    • PUT /api/v1/drafts/{id} … 上記フィールドの round-trip
    • GET /api/v1/drafts?filter=draft&offset=0&limit=100[*].id / slug の存在 + pagination 動作
    • DELETE /api/v1/drafts/{id} … 後始末
  • contract suite が失敗 → Slack 通知 + 該当ハンドラに SchemaDriftError を投げるよう実装の場所を明示

8. テスト戦略

8.1 レイヤ別

レイヤ対象手法頻度
UnitIR 変換、ProseMirror 組立、URL組立、retry デコレータ、slug lockpytest + 純関数pre-commit / PR
Contract(mock)Substack API への期待リクエスト/レスポンス形状respx で HTTP モックPR
Live Contract(M6)実 Substack API のレスポンス形状固定 assertionテストアカウントで実行、tests/live_contract/ 配下、@pytest.mark.live_contract週次 + リリース前
IntegrationLLM/画像APIは限定的に実呼び出し(CI でskip)環境変数で gate(SUBSTACK_SKILL_LIVE_LLM=1 等)nightly(任意)
E2E実Substackテストアカウントへの draft 作成→更新→削除手動チェックリスト + 任意で nightlyリリース前 / G-3

Live Contract と Unit Contract の役割分担:

  • Unit Contract(mock)= 自分が出すリクエストが正しいことを保証
  • Live Contract = Substack のレスポンス形状が変わっていないことを保証
  • 両方が pass しないと「drift してないが自分のコードがバグってる」「自分は正しいがSubstack が変わった」のどちらか不明になる

8.2 fixture (F-4)

  • tests/fixtures/topic_input.txt
  • tests/fixtures/url_sample.txt(安定したblog URL)
  • tests/fixtures/paper_sample.pdf(arXiv 公開論文)
  • tests/fixtures/zenn_sample.md
  • 期待出力は 構造チェック(LLM出力なので exact match しない)

8.3 セキュリティテスト

  • ログにトークン値が含まれないか正規表現で検査
  • output/.work/ 配下にCookieが書かれていないか
  • 例外メッセージにシークレット値が漏れないか

9. 配置・配布 (A-2 / F-3 / F-5)

9.1 開発時

  • 本リポ substackTools/ で開発
  • uv venv && uv sync で依存解決
  • uv run substack-skill ... で実行

9.2 スキルとして使う側

scripts/install.sh が以下を実施:

  1. uv venv + uv sync + playwright install chromium
  2. Keychain 必須キーの存在チェック、不足なら登録手順を対話表示
  3. SKILL.mdcommands/*.md を以下のいずれかに配置(環境検知):
    • 優先 1: /Volumes/AIWorkSSD/AIWorkSpace/Skills/substack-skill/ への symlink
    • 優先 2: ~/Documents/WorkSpace/otani/Skills/substack-skill/
  4. ~/.claude/commands/commands/*.md を symlink
  5. /Volumes/AIWorkSSD/CLAUDE.md のスキル一覧テーブルに登録(既存行があれば skip)

9.3 アンインストール

scripts/uninstall.sh: symlink削除 + CLAUDE.md行削除 + venv削除。Keychain値は触らない(ユーザ判断)。


10. リスクと回避策

10.1 技術・運用リスク

リスク影響回避策
Substack 仕様変更API呼び出し失敗publishers/substack/client.py に隔離、mock + live contract 両建てテスト(§8.1)、依存ピン
Cookie失効全機能停止AuthRequiredError 自動検知 + login再案内
BANアカウント停止rate制御(10 reqs/60s, 画像1秒間隔)、UA偽装、自パブのみ
画像生成費用爆発コスト過大キャッシュ必須、--max-images(default 5)/ --max-diagrams(default 3)、provider 1日上限
LLM トークン費用同上prompt caching、章ごと分割、--draft-mode で短文生成
著作権・robots.txt違反法務リスクUrlSource で robots チェック、citations 必須、ペイウォール検知
internalRedirect 等の未確定スキーマエディタ表示崩れprepublish エンドポイントで事前検証、E-3 で必須値補完
Playwright バージョン依存install失敗pyproject.toml でピン、playwright install 手順をinstall.sh に含める
Excalidraw レンダラの選定未完了D-2 が止まるNode CLI 採用前提だが、確証なし → スパイクで実証する issue を追加

10.2 プライバシー・データ保護(M5 対応)

10.2.1 取り扱うデータの分類

分類取り扱い
入力ソース原文URL本文、PDF論文、議事録 Markdown--no-source-cache で完全保存無効化可能。既定では 01_source.json に保持
生成済本文記事 IR02_article.json 以降に保持。--no-body-cache で保持しない設定可能
生成画像挿絵 PNGoutput/.work/<run_id>/images/、グローバルキャッシュ ~/.cache/substack-skill/images/
認証情報Cookie / API キーKeychainのみ。中間ファイルに書かない(§5.4)。ログマスキング
メトリクスLLMトークン消費、所要時間個人特定情報なし、長期保持可

10.2.2 保持期間と削除

  • 既定: output/.work/<run_id>/7日でローテーション削除infra/workdir.py がスキル起動時に古いものを掃除)
  • --keep-work: 個別 run の自動削除を無効化
  • substack-skill workdir purge --older-than 7d: 手動全削除
  • substack-skill workdir purge --run-id <id>: 個別削除
  • グローバル画像キャッシュは 30日 で LRU 削除(容量上限 5GB)

10.2.3 PII 検出と運用ルール

  • MarkdownSource / UrlSourcePII正規表現検出(メール / 電話 / マイナンバー / クレカ番号パターン)
  • 検出時:
    • --strict-pii: エラーで停止(CI/自動化推奨)
    • 既定: 警告ログ + 対象段落をハイライト表示し、ユーザー確認待ち
  • 議事録系(社内文書)は必ず手動レビュー前提(生成側 LLM プロンプトに「個人名・社名は伏字化」を含める)

10.2.4 法令対応

  • 日本: 個人情報保護法
    • 個人情報を含む入力ソースは利用目的を限定(記事化の単一目的)
    • 第三者提供にあたる外部API送信(Anthropic / OpenAI / Higgsfield / Substack)は本人同意を前提
    • 議事録原本など個人データを含む可能性のあるソースは --no-source-cache を推奨デフォルトとする
  • EU: GDPR
    • 本スキルは EU 居住者の個人データを処理しない前提(記事化対象は日本語コンテンツ中心)
    • ただし EU 関係者の発言を引用する場合は最小化原則を遵守、引用元 URL 表記必須
    • 「忘れられる権利」相当の対応として workdir purge --run-id を提供
  • 米国: COPPA / CCPA
    • 子どもに関する個人データを扱わない(記事化対象外)
    • 米国居住者は対象外と明記

11. 未確定事項・要追加調査

すべての項目に担当 issue を紐付け。実装着手時のスパイクで確定し、本書を改訂する。

  1. Excalidraw → PNG レンダラの最終選定: @excalidraw/excalidraw-cli が現存・動作するか実証必要。代替 excalidraw_export も比較。日本語フォント描画の確認まで含める。→ D-2 (#45) でスパイク
  2. ProseMirror 未確定ノード(footnote / callout / linkCard / math + bulletList / orderedList / listItem): 実観測で確定。fallback は UnknownBlock / RawPmNode で情報損失なし(§3.3)→ E-4 (#51) の最初に DevTools セッションを実施
  3. スキル配置先: /Volumes/AIWorkSSD/AIWorkSpace/Skills/ vs ~/Documents/WorkSpace/otani/Skills/ の決定。 claude-skills-marketplace 経由配布の要否。→ A-2 (#28)
  4. Higgsfield API の現行仕様: ドキュメント取得とエンドポイント確認。→ C-2 (#39) の冒頭で実施
  5. internalRedirect フィールドの実必須性: 実観測で確認。→ E-3 (#50)
  6. rate limit の実数値: 連続リクエストで観測。テストアカウントで実施。→ G-1 (#59) に統合(推定 10 req/min を仮置き)
  7. PDF テキスト抽出ライブラリの選定: pdfplumber vs pypdf を実論文で比較。→ B-3 (#34)

12. マイルストーン

Milestone含まれる Phase目標日(暫定)受入条件(明確な成果物境界)
M0: 設計確定DESIGN.md(本文書) + Codexレビュー反映2026-05-15レビュー指摘ゼロ件で本PR マージ
M1: 認証&骨格A-1〜A-5 + E-1, E-22026-05-22substack-skill auth verify --jsonloggedIn=true を返し、resolve_identity() で publication / user_id が解決される
M2: 下書きアップロード最小経路B-1(トピック)+ B-5(タイトル)+ E-4(ProseMirror 変換)+ E-5(下書き作成)+ E-6(プレビューURL)+ §4.4.1 冪等性2026-06-05画像/図なしのテキスト記事を draft 作成できる。同 slug の再実行で update され、重複 draft を生まない
M3: 入力源拡充B-2, B-3, B-4, B-62026-06-194種類の入力(topic / url / pdf / markdown)すべてで draft 作成できる
M4: 画像統合(CDN含む)C-1〜C-6 + E-3(画像 Substack CDN アップロード)2026-07-03挿絵付き draft で captionedImage ノードに正しい CDN URL が埋まっている。Higgsfield/OpenAI 両対応。--max-images で枚数制御
M5: 図統合D-1〜D-42026-07-17Excalidraw 図入り draft 作成。M4 と同様に CDN アップロードまで完走
M6: スキル統合・運用F-1〜F-5 + G-1〜G-4 + §10 retention / PII 一式2026-07-31Claude Code から /substack-draft 実行可能、workdir purge 動作、live contract suite が CI で週次パス、README/FAQ整備

依存関係:

  • M2 は M1 完了が前提
  • M4 は M2 完了が前提(CDN URL 埋め込みに E-3 必要、E-4 ProseMirror 変換は M2 で完成)
  • M3 / M5 / M6 は M2 完了後に並行可能(M3-M5 がそれぞれ M6 受け入れに必要)

13. 変更履歴

  • 2026-05-14 (初版): Codex レビュー前
  • 2026-05-14 (rev2): Codex レビュー指摘(C1, C2, M1-M7, m1-m3, n1-n2)を反映
    • C1: §4.4 / §7.3 のチェックポイント順序を確定(uploaded → prosemirror → draft)
    • C2: §4.4.1 で slug ベース冪等性(offset 全件走査 / ローカルlock / commit直前再照合)
    • M1: Block に block_id、画像/図に anchor_after を導入
    • M2: UnknownBlock / RawPmNode / FootnoteRef / MathInline を IR に追加
    • M3: list 系を §3.3 未確定対象に含める
    • M4: resolve_identity() で publication / user_id を /user/profile/self から自動解決
    • M5: §3.2 / §10.2 で保持期間・PII・GDPR・個人情報保護法を明文化
    • M6: §7.4 / §8.1 に live contract suite を追加(mock contract と二段構え)
    • M7: §11 全項目に issue 番号、§12 マイルストーン受入条件を成果物境界で再定義
    • m1: publishpush-draft に改名(CLI / Slash / commands ファイル名すべて)
    • m2: --max-images / --max-diagrams を CLI 共通フラグに明記
    • m3: --resume <run_id> / --no-source-cache / --no-body-cache / --keep-work を CLI 共通フラグに明記
    • n1: commands/ ファイル名と slash 名を 1対1で揃え(zenn / qiita を分離)
    • n2: §0 を「4種類の入力」に統一

substack-skill 内部設計書 — 公開非対象