substack-skill 設計書
最終更新: 2026-05-14(Codex レビュー反映版) 関連: #26 tracking, SUBSTACK_API.md
0. 目的
日本語で技術系コンテンツを発信するユーザーが、
- 4種類の入力ソースから記事本文を自動生成し
topic… トピック・アイデア(B-1 / TopicSource)url… Web URL(B-2 / UrlSource)pdf… 論文・PDF(B-3 / PdfSource)markdown… 既存 Zenn / Qiita / 議事録 Markdown(B-4 / MarkdownSource)
- 挿絵(Higgsfield / OpenAI gpt-image)と図(Excalidraw 手描き風)を自動添付し
- 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 設計原則
- Article IR を唯一のハブにする — 入力源・出力先を疎結合に
- 副作用は publishers/ 配下に集約 — 生成系(generators/, assets/)は pure に近づける
- 外部API呼び出しは1モジュールに隔離 — 仕様変更時の影響範囲を局所化
- 失敗しても再開可能 — 各ステップの中間成果物を
output/.work/<run_id>/に保存 - 秘密情報は Keychain のみ — .env 平文禁止(AIProject CLAUDE.md ルール)
- 公開行動を絶対に取らない —
/api/v1/drafts/{id}/publishは本スキルで実装すらしない。CLI 名もpublishではなくpush-draftを採用(誤実装の導線を絶つ)。 - 個人情報・著作物を不要に保持しない — 中間成果物・ログは既定で 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-43. データモデル: Article IR (A-4)
すべての入力ソースは Article IR を出力し、すべての出力先は Article IR を入力に取る。
3.1 中核モデル
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(インラインの参照番号)CalloutLinkCard(linkPreview 系)MathBlock/ インライン math(Run.math_inline)BulletList/OrderedList/ListItem(ProseMirror 標準名と推定だが未確認)
fallback 戦略(情報損失ゼロを優先):
- IR レベルでは型を定義し続ける(上記すべて §3.1 に列挙済み)
- 実観測前は次の優先順位でフォールバック:
RawPmNodeに推定 ProseMirror JSON を入れて送る(リスクは Substack 側 reject)- 失敗したら
UnknownBlockに降格、rendered_fallbackの文字列を paragraph として送る - paragraph 化時も
original_kind/payloadを残し、後で再変換可能にする
- 読み戻し時の損失ゼロ: IR ↔ JSON は full round-trip。Unknown も完全保持
- DevTools 実観測(E-4 / #51)でノード型を確定したら、
UnknownBlock→ 専用 IR 型への自動マイグレーションを serialize レイヤで行う
4. パイプライン詳細
4.1 入力ソース層 (sources/)
すべて class Source(Protocol): def load(self) -> SourceInput: を実装。
| Source | load 出力 | 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-apiskill ガイドに準拠) - リトライ・トークン上限・モデル切替を共通化
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)
AssetPlanからasset_kind=imageを取り出す- プロバイダ選択(環境変数
SUBSTACK_SKILL_IMAGE_PROVIDERor--image-provider) - キャッシュ確認(
sha256(prompt+provider+style+size))→ ヒットしたら API スキップ - 失敗時はセカンダリプロバイダに自動切替(C-4)
- 1456px 縮小 + JPEG 85品質で保存(ローカル)
Article.bodyのanchor_after直後にImageBlockを挿入
4.3.4 図生成 (D-1〜D-4)
AssetPlanからasset_kind=diagramを取り出すexcalidraw_gen.generate(description)で JSON 生成style.apply(json, style_template)で色/線幅統一(templates/diagram_style.json)excalidraw_render.render(json) -> Pathで PNG 化(Node CLI@excalidraw/excalidraw-cliを採用。インストール手順は F-5)Article.bodyのanchor_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_idfind_remote_draft_by_slug の実装:
GET /api/v1/drafts?filter=draft&offset={n}&limit=100をoffset増分で全件走査- 該当 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:
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 スクリプトの動作
- Playwright Chromium 起動(headed mode、ユーザー操作前提)
https://substack.com/sign-inを開く- ユーザーがメール/パスワード(または magic link)でログイン
- ログイン完了を
/api/v1/user/profile/selfへの成功リクエストで検知 context.cookies()で全Cookieを取得、substack.sidsubstack.llisidを抽出security add-generic-password -s SUBSTACK_COOKIES_STRING -a $USER -w "..." -Uresolve_identity()を実行し、検出した publication / user_id を画面表示してユーザー承認- 承認されたら 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-dir | output/<timestamp>/ | 中間成果物の保存先 |
--run-id | 自動採番 | 既存 run と紐付ける |
--resume <run_id> | — | 中間成果物から続きから実行(C1 のチェックポイント参照) |
--dry-run | false | 外部API呼び出し・書き込みを行わない |
--verbose / --quiet | — | ログレベル制御 |
--no-cache | false | 画像キャッシュをバイパス |
--image-provider | higgsfield | higgsfield / openai |
--max-images | 5 | 画像生成上限(コスト爆発防止、§10) |
--max-diagrams | 3 | 図生成上限 |
--no-images / --no-diagrams | false | 各アセット生成を完全スキップ |
--no-source-cache | false | 入力ソース原文を output/.work/ に保存しない(PII対応) |
--no-body-cache | false | 生成済本文も保存しない |
--keep-work | false | 自動削除(7日)を無効化 |
6.2 スラッシュコマンド
commands/*.md を .claude/commands/ に symlink で配置。各コマンドは中で CLI を起動。 ファイル名と slash 名は 1対1で揃える(n1 対応)。
| Slash 名 | commands/ ファイル | 等価 CLI |
|---|---|---|
/substack-draft | substack-draft.md | typer に対話的に引数を尋ねつつ substack-skill draft ... 実行 |
/substack-from-url <URL> | substack-from-url.md | substack-skill draft --url <URL> |
/substack-from-pdf <PATH> | substack-from-pdf.md | substack-skill draft --pdf <PATH> |
/substack-convert-zenn <PATH> | substack-convert-zenn.md | substack-skill convert --from zenn --path <PATH> |
/substack-convert-qiita <URL> | substack-convert-qiita.md | substack-skill convert --from qiita --path <URL> |
/substack-image <PROMPT> | substack-image.md | substack-skill image --prompt <PROMPT> |
/substack-diagram <DESC> | substack-diagram.md | substack-skill diagram --description <DESC> |
/substack-push-draft | substack-push-draft.md | substack-skill push-draft --article <article.json>(旧 /substack-publish を改名) |
7. エラーハンドリング・リトライ (G-1)
7.1 例外階層
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.json | SourceMeta + 原文 | 入力ソースロード後 |
02_article.json | Article IR(body 完成・cover/tags/seo 確定、画像/図 未挿入) | 生成層完了後 |
02b_asset_plan.json | AssetPlan(画像・図候補と挿入位置) | §4.3.2 統合フェーズ後 |
03_with_assets.json | Article IR(ImageBlock / DiagramBlock 挿入済、ローカルパスのみ、CDN URL 未) | 画像・図生成完了後 |
04_uploaded.json | Article IR(CDN URL + internalRedirect 等 cdn_meta が埋まった完成版) | Substack CDN アップロード後 |
05_prosemirror.json | ProseMirror JSON document | 変換後(CDN URL を反映した最終形) |
06_draft.json | draft_id + 編集URL + プレビューURL | Substack 下書き作成/更新後 |
--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/self…publicationUsers[*].publication.id / subdomain / custom_domain,is_primaryの存在POST /api/v1/image…url / imageUrl / imageWidth / imageHeight / imageType / internalRedirectの存在POST /api/v1/drafts…id / draft_body / draft_title / audience / draft_bylinesの存在PUT /api/v1/drafts/{id}… 上記フィールドの round-tripGET /api/v1/drafts?filter=draft&offset=0&limit=100…[*].id / slugの存在 + pagination 動作DELETE /api/v1/drafts/{id}… 後始末
- contract suite が失敗 → Slack 通知 + 該当ハンドラに
SchemaDriftErrorを投げるよう実装の場所を明示
8. テスト戦略
8.1 レイヤ別
| レイヤ | 対象 | 手法 | 頻度 |
|---|---|---|---|
| Unit | IR 変換、ProseMirror 組立、URL組立、retry デコレータ、slug lock | pytest + 純関数 | pre-commit / PR |
| Contract(mock) | Substack API への期待リクエスト/レスポンス形状 | respx で HTTP モック | PR |
| Live Contract(M6) | 実 Substack API のレスポンス形状固定 assertion | テストアカウントで実行、tests/live_contract/ 配下、@pytest.mark.live_contract | 週次 + リリース前 |
| Integration | LLM/画像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.txttests/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 が以下を実施:
uv venv+uv sync+playwright install chromium- Keychain 必須キーの存在チェック、不足なら登録手順を対話表示
SKILL.mdとcommands/*.mdを以下のいずれかに配置(環境検知):- 優先 1:
/Volumes/AIWorkSSD/AIWorkSpace/Skills/substack-skill/への symlink - 優先 2:
~/Documents/WorkSpace/otani/Skills/substack-skill/
- 優先 1:
~/.claude/commands/にcommands/*.mdを symlink/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 に保持 |
| 生成済本文 | 記事 IR | 02_article.json 以降に保持。--no-body-cache で保持しない設定可能 |
| 生成画像 | 挿絵 PNG | output/.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/UrlSourceで PII正規表現検出(メール / 電話 / マイナンバー / クレカ番号パターン)- 検出時:
--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 を紐付け。実装着手時のスパイクで確定し、本書を改訂する。
- Excalidraw → PNG レンダラの最終選定:
@excalidraw/excalidraw-cliが現存・動作するか実証必要。代替excalidraw_exportも比較。日本語フォント描画の確認まで含める。→ D-2 (#45) でスパイク - ProseMirror 未確定ノード(footnote / callout / linkCard / math +
bulletList/orderedList/listItem): 実観測で確定。fallback はUnknownBlock/RawPmNodeで情報損失なし(§3.3)→ E-4 (#51) の最初に DevTools セッションを実施 - スキル配置先:
/Volumes/AIWorkSSD/AIWorkSpace/Skills/vs~/Documents/WorkSpace/otani/Skills/の決定。claude-skills-marketplace経由配布の要否。→ A-2 (#28) - Higgsfield API の現行仕様: ドキュメント取得とエンドポイント確認。→ C-2 (#39) の冒頭で実施
internalRedirectフィールドの実必須性: 実観測で確認。→ E-3 (#50)- rate limit の実数値: 連続リクエストで観測。テストアカウントで実施。→ G-1 (#59) に統合(推定 10 req/min を仮置き)
- PDF テキスト抽出ライブラリの選定:
pdfplumbervspypdfを実論文で比較。→ B-3 (#34)
12. マイルストーン
| Milestone | 含まれる Phase | 目標日(暫定) | 受入条件(明確な成果物境界) |
|---|---|---|---|
| M0: 設計確定 | DESIGN.md(本文書) + Codexレビュー反映 | 2026-05-15 | レビュー指摘ゼロ件で本PR マージ |
| M1: 認証&骨格 | A-1〜A-5 + E-1, E-2 | 2026-05-22 | substack-skill auth verify --json が loggedIn=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-6 | 2026-06-19 | 4種類の入力(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-4 | 2026-07-17 | Excalidraw 図入り draft 作成。M4 と同様に CDN アップロードまで完走 |
| M6: スキル統合・運用 | F-1〜F-5 + G-1〜G-4 + §10 retention / PII 一式 | 2026-07-31 | Claude 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:
publish→push-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種類の入力」に統一