Substack 下書き投稿スキル
会話内で生成した文章や添付MDファイルをSubstackエディタへ自動入力し、タイトル・サブタイトル・セクション・SEO・URLスラッグを設定して下書き保存する。タグ設定は人間が行うため、投稿完了後にタグ候補を提示する。特定のニュースレターに固定されない汎用スキルで、投稿先・セクション名・タグ一覧はユーザーごとに異なるため、Claudeのメモリ機能を使って前回までの情報を引き継ぐ。
設計思想
computer ツール(スクリーンショット→視覚判断)は一切使わない
- fetch・ローカルサーバは使わない:Chrome の Private Network Access 制限により HTTPS ページからのローカル fetch はブロックされるため、executor JS を直接
javascript_tool へ渡すインライン方式を使う
- 本文の JSON エンコードは bash に任せる:バッククォートや
${ を含む Markdown を Claude が手動エスケープすると誤りが出るため、python3 に委ねる
- タグ設定は自動化せず、完了後に候補を提示する
- 投稿先ドメイン・セクション名・タグ一覧はハードコードしない:ユーザーごと・ニュースレターごとに異なるため、都度メモリを確認し、指定されていればそれを使う。ファイルへの保存はしない(このスキルはClaudeチャットでの利用を前提とし、セッションをまたぐ永続化はメモリ機能に委ねる)
全体フロー概要
| フェーズ | 内容 |
|---|
| フェーズ 1 | 投稿コンテンツ(本文Markdown)を取得する。1500文字以上なら貼り付け方法も確認対象にする |
| フェーズ 2 | メモリを確認し、不足フィールド(本文貼り付け方法を含む)をユーザーに確認する |
| フェーズ 3 | 「おまかせ」項目とSEO説明文を自動生成する |
| フェーズ 4 | エディタへ移動 → 本文書き込み → JSONエンコード → IIFE生成 → javascript_tool で実行 |
| フェーズ 5 | 完了をリンクで報告し、タグ候補を提示する。新しく判明した情報はメモリへの保存を提案する |
前提条件
- ブラウザで対象のSubstackニュースレターにログイン済み
- Claude in Chrome コネクタが接続済み
navigate — URLへ移動
javascript_tool — アクティブタブで JavaScript を実行
- bash_tool が利用可能(本文の JSON エンコードに使用)
- python3 が利用可能(JSON エンコードに使用)
computer ツールは使用しない
フェーズ 0:投稿先の特定(メモリ確認)
- 会話内で投稿先のSubstackサブドメイン(例:
xxxx.substack.com)がすでに指定されていれば、それを使う(質問しない)
- 指定がなければ、メモリに保存された投稿先ニュースレターの情報を確認する
- 1件だけ見つかった場合:「前回は
xxxx.substack.com に投稿しましたが、今回もこちらでよいですか?」と確認する
- 複数件見つかった場合:候補を提示して選んでもらう
- 見つからない場合:投稿先のサブドメインをユーザーに質問する
- 新しい投稿先が判明した場合は、後述のフェーズ5で記憶を提案する
以降、確定した値を SUBSTACK_DOMAIN(例: xxxx)として扱う。
フェーズ 1:コンテンツを受け取る
| 種類 | 取得方法 |
|---|
| 会話内で生成した文章 | 直前のアシスタント出力をMarkdownとして使用。あるいは、生成されたmdファイル |
| 添付Markdownファイル | ファイル内容を読み込んでMarkdownとして使用 |
本文が取得できない場合は、ユーザーに確認してから進む。
文字数チェック(自動貼り付けの時間・トークン消費に関わる)
本文Markdownの文字数を数える。1500文字以上の場合、フェーズ2の質問に以下を追加する:
本文が約◯◯◯文字あります。JavaScriptによる自動貼り付けは文章量に比例して時間・トークンを消費する場合があります。どちらの方法で本文を反映しますか?
| 選択肢 | 動作 |
|---|
| Claudeが自動で貼り付ける | 従来どおりフェーズ4 Step 4 で自動ペーストする |
| 自分で貼り付ける | フェーズ4 Step 4(本文ペースト)を スキップ。タイトル・サブタイトル・セクション・SEO・URLスラッグは自動設定し、本文だけ手動貼り付けを依頼する |
この選択結果を BODY_PASTE_MODE("auto" または "manual")として以降使用する。1500文字未満の場合は質問せず BODY_PASTE_MODE = "auto" とする。
フェーズ 2:不足フィールドの確認・質問
- 会話内ですでに決まっている項目は質問しない
- セクションについては、まずメモリに
SUBSTACK_DOMAIN でよく使われるセクション名の記録がないか確認する。見つかれば選択肢の先頭に「おまかせ(前回と同じ: ◯◯)」として提示する
- 不足している項目(および該当する場合は上記の本文貼り付け方法)を
ask_user_input_v0 でまとめて質問する
- 各質問の最初の選択肢は必ず「おまかせ」
- SEO説明文は常に自動生成するため質問しない
- タグは質問しない(完了後に候補提示)
質問フィールドと選択肢・生成ルール
| 項目 | 選択肢 | おまかせ時の生成ルール |
|---|
| タイトル(必須) | おまかせ(Claudeが生成) / 指定する | 本文の主題を端的に表す10〜30文字 |
| サブタイトル(任意) | おまかせ(生成する) / 指定する / なし | 本文の要旨を1〜2文で表現 |
| セクション(任意) | おまかせ(メモリにあればそれを使用、なければ「なし」) / 指定する / なし | メモリの記録があればそれを使用。なければセクション未設定のまま投稿 |
| URLスラッグ(任意) | おまかせ(英語で自動生成) / 指定する | タイトルの意味を英訳しハイフン区切り(例: claude-substack-auto-post) |
| SEOタイトル(任意) | おまかせ(タイトルと同じ) / 指定する | タイトルと同じ文字列 |
| 本文貼り付け方法(1500文字以上のみ) | Claudeが自動で貼り付ける / 自分で貼り付ける | 該当なし(必ずどちらか選んでもらう) |
フェーズ 3:おまかせ項目・SEO説明文
「おまかせ」を選んだ項目を上記の生成ルールに従って本文から生成する。
SEO説明文(常に自動生成・質問なし)
Substackは記事冒頭の文章をSEO説明文に自動入力するが内容が不適切なため、必ず上書き生成する。
生成ルール:
- 文字数:50〜160文字
- 記事の価値・対象読者・得られる知識を含む
- 独立した要約文として書く
フェーズ 4:Substackエディタへの入力
ポイント: fetch・ローカルサーバを使わず、変数・本文・executor ロジックを一体化した IIFE を javascript_tool に直接渡す。本文の JSON エンコードだけ bash に委ねることでエスケープ誤りを防ぐ。
Step 1:新規投稿ページへ移動
navigate ツールで、フェーズ0で確定した SUBSTACK_DOMAIN を使い以下のURLへ移動する:
https://{SUBSTACK_DOMAIN}.substack.com/publish/post?type=newsletter
Step 2:本文を書き込む
Write ツールで /tmp/body.md に記事 Markdown をそのまま書き込む(BODY_PASTE_MODE が "manual" の場合もスキップせず書き込む。文字数チェックや万一の自動化に使うため)。
Step 3:本文を JSON エンコードする(bash)
python3 -c "import json; print(json.dumps(open('/tmp/body.md').read()))"
出力は "..." 形式の1行。これを次のステップで BODY_MD の値として使う。
Step 4:executor ロジックを読み込む
Read ツールで references/post_executor.js を1回で全体を読み込む(offset/limit を指定しない)。
Step 5:完全な IIFE を javascript_tool に渡す
Step 3 の JSON 文字列・Step 4 のコードを組み合わせ、以下の形式で javascript_tool の text に渡す。
重要: javascript_tool は渡されたテキストを「単一の式」として評価し、その完了値(Promiseなら解決後の値)を返す。トップレベルの await / return は使えないため、必ず (async () => { ... })() の IIFE 式で包むこと。
(async () => {
const TITLE = '実際のタイトル';
const SUBTITLE = '実際のサブタイトル'; // null なら: null
const SECTION = '実際のセクション名'; // 不要なら: null
const SEO_TITLE = TITLE;
const SEO_DESC = '実際のSEO説明文(50〜160文字)';
const URL_SLUG = 'actual-url-slug';
const BODY_MD = [Step 3 の出力をそのまま貼る];
const BODY_PASTE_MODE = 'auto'; // または 'manual'(フェーズ1.5で選択された値)
// ↓ Step 4 で読み込んだ post_executor.js の内容をそのまま展開 ↓
})()
Step 6:戻り値で分岐
| 戻り値 | 対応 |
|---|
{...R, url}(正常) | フェーズ5へ |
{error:'editor_not_loaded', url} | 再試行しない(下書き重複の恐れ)。ユーザーに報告し、エディタページの再読込を促す |
フェーズ 5:完了確認・報告
JSスクリプトの戻り値(R オブジェクト)を確認し、以下の形式でユーザーに報告する。
タグ候補
- メモリに
SUBSTACK_DOMAIN のタグ一覧(よく使うタグリスト)が保存されていれば、その中から内容に合うものを最大3〜5つ選ぶ
- 保存されていなければ、本文の内容から最大3〜5個のタグ候補を新規に考案する(自動入力はしない)
メモリへの保存提案
以下のいずれかに該当する場合、報告の末尾で「今後のために覚えておきますか?」とユーザーに確認する(強制はしない):
- フェーズ0で新しい投稿先(
SUBSTACK_DOMAIN)が判明した
- フェーズ2でセクション名を新規に指定してもらった(メモリに記録がなかった)
- タグ候補をメモリなしで新規に考案した(ユーザーが今後も使いたいタグ一覧として確定した場合)
ユーザーが同意した内容のみメモリに保存する。
報告フォーマット
✅ Substackへの下書き投稿が完了しました。
📄 投稿を確認: [投稿タイトル](https://{SUBSTACK_DOMAIN}.substack.com/publish/post/[ID])
---
⚠️ 手動設定が必要な項目:
- タグ(候補を下記に提示)
(セクション・SEO等で失敗があればここに追記)
(BODY_PASTE_MODEが"manual"の場合は「本文の貼り付け」もここに追記)
🏷️ タグ候補(設定モーダルの「タグ」欄から追加してください):
1. [タグ名1]
2. [タグ名2]
3. [タグ名3]
BODY_PASTE_MODE が "manual" の場合、上記リンクとともに「本文をエディタに貼り付けてください」と伝え、本文Markdown全文をコピーしやすい形(コードブロック)で併記する。
URL の取得方法
JSスクリプトの戻り値 url フィールド(location.href)を使う。
必ずクリッカブルなMarkdownリンク形式で提示する(コードブロック禁止)。
失敗項目の報告
R フィールド | false だった場合の報告内容 |
|---|
body | false=「本文の貼り付けに失敗しました。手動で貼り付けてください」/'skipped'=BODY_PASTE_MODEが"manual"だったため意図的にスキップ(失敗ではない) |
section | 「セクションの設定に失敗しました。手動で設定してください」 |
modal | 「設定モーダルが開けませんでした。⚙ボタンを手動で押してください」 |
seoForm1 | 「SEOタイトル・説明文の保存に失敗しました。手動で設定してください」 |
seoForm2 | 「URLスラッグの保存に失敗しました。手動で設定してください」 |
done | 「完了ボタンが押せませんでした。モーダルを手動で閉じてください」 |
補足: 「完了」クリック後、モーダルが実際に閉じるまで数秒の退場アニメーションがかかる。R.done が true であれば処理としては成功しており、モーダルが即座に閉じて見えなくてもエラーではない。
参照ファイル
references/post_executor.js — Substackエディタ操作のJSロジック本体。変数宣言なし・IIFEなし。フェーズ4 Step 4 で Read して IIFE 内に展開する
クイックリファレンス
| 項目 | 方式 |
|---|
| 投稿先ドメイン | メモリ確認 → なければ質問(フェーズ0)。ファイルへの保存はしない |
| セクション・タグ一覧 | メモリにあれば「おまかせ」の既定値として提示 |
| 本文が1500文字以上 | 「自分で貼り付け/Claudeが自動貼り付け」を質問(フェーズ1.5)→ BODY_PASTE_MODE |
| 本文書き込み | Write ツールで /tmp/body.md |
| 本文 JSON エンコード | bash: python3 -c "import json; print(json.dumps(open('/tmp/body.md').read()))" |
| executor 読み込み | Read ツールで references/post_executor.js(1回・全体) |
| JS 実行 | 変数 + BODY_MD + executor を IIFE で包んで javascript_tool に直接渡す |
| fetch・サーバー | 使用しない(Chrome PNA 制限によりブロックされる) |
| タイトル | JS: [data-testid="post-title"] に React setter でセット(UI言語非依存) |
| サブタイトル | JS: textarea.subtitle(クラストークン。placeholder不使用・UI言語非依存) |
| セクション | JS: simulatePointerClick → [role="menuitem"](NFKC正規化照合。セクション名はユーザー命名のため言語非依存) |
| 本文 | JS: ClipboardEvent + DataTransfer(HTML + plaintext) |
| 設定モーダル | JS: [data-testid="settings-button"] クリック→[data-testid="settings-modal"] [role="combobox"] の出現で読み込み確認 |
| SEOアコーディオン | JS: モーダル内で svg.lucide-chevron-down/up を持つ cursor-pointer 要素を特定(見出しテキスト不使用) |
| SEO Form 1/2 | JS: アコーディオン内の <form> を2つ取得し、textarea を含む方=タイトル+説明文、input[name="code"] を含む方=URLスラッグ。保存ボタンはラベル文字列ではなく各 <form> 内の button[type="submit"](動的出現)を待ってクリック |
| タグ | 自動入力しない → 完了後に候補を提示 |
| 完了ボタン | JS: [data-modal-role="footer"] button(ラベル文字列不使用)。クリック後、閉じるまで数秒のアニメーション遅延がある |
| 確認URL | Markdownリンク形式(コードブロック禁止) |