DEV Community

孫昊
孫昊

Posted on

How I Programmatically Publish to Substack via Chrome DevTools Protocol

TL;DR: Substack has no public write API in 2026, but its TipTap editor accepts CDP automation reliably. Here's the 50-line Python script that publishes any markdown file to Substack as a fully formatted post in 45 seconds. Saves 5+ minutes per post for a publishing batch of 30+.


Why CDP, not API?

Substack offers a public read API (RSS, API for subscriber list) but no public write API. Their internal API (/api/v1/posts) requires session cookies and CSRF tokens that change per session.

CDP attaches to a real Chrome you've already logged into. No reverse-engineering, no broken sessions — your browser does what it always does.

Setup (one-time, 60 sec)

chrome.exe ^
  --remote-debugging-port=9222 ^
  --remote-allow-origins=* ^
  --user-data-dir="C:\Users\you\AppData\Local\Google\Chrome\User Data\AutomationProfile"
Enter fullscreen mode Exit fullscreen mode

Open this profile, log into Substack manually once. The profile stays logged in indefinitely.

The 50-line Python publisher

from playwright.sync_api import sync_playwright
import re, time, pyperclip
from pathlib import Path

def publish(md_path, target_substack_publish_url):
    raw = Path(md_path).read_text(encoding='utf-8-sig')
    # Strip YAML frontmatter
    raw = re.sub(r'^---\s*\n.*?\n---\s*\n', '', raw, flags=re.DOTALL)
    title_match = re.search(r'^# (.+)$', raw, re.MULTILINE)
    title = title_match.group(1) if title_match else 'Untitled'
    body = re.sub(r'^# .+\n', '', raw, count=1, flags=re.MULTILINE).strip()

    with sync_playwright() as p:
        browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222")
        ctx = browser.contexts[0]
        page = ctx.new_page()
        page.goto(f"{target_substack_publish_url}/publish/posts?type=newsletter")
        page.wait_for_load_state('networkidle')

        page.click("text=新規投稿")  # or "New post" in EN
        page.wait_for_selector('textarea[aria-label="title"]', timeout=10_000)

        page.fill('textarea[aria-label="title"]', title)
        time.sleep(1)

        editor = page.locator("div.ProseMirror").first
        editor.click()
        time.sleep(0.5)
        pyperclip.copy(body)
        page.keyboard.press("Control+V")
        time.sleep(2)

        # Wait for autosave indicator
        page.wait_for_selector('text=下書き保存しました', timeout=15_000)

        page.click("text=続ける")
        page.wait_for_selector("text=今すぐ全員に送信", timeout=10_000)
        page.click("text=今すぐ全員に送信")
        page.wait_for_url(re.compile(r'.*/p/[a-z0-9-]+'), timeout=20_000)

        published_url = page.url
        return published_url

# Usage
url = publish("reports/substack-issue-23.md", "https://autoappnotes.substack.com")
print(f"Published: {url}")
Enter fullscreen mode Exit fullscreen mode

What this saves you

Per issue manual flow:

  • Open Substack publish UI: 5 sec
  • Copy title manually: 5 sec
  • Open editor, paste body: 30 sec
  • Wait for autosave: 10 sec
  • Click 続ける: 2 sec
  • Click 今すぐ全員に送信: 3 sec
  • Confirm: 5 sec

Total manual: ~60 sec per issue, but with attention required throughout.

CDP automated: 45 sec per issue, fully unattended — you can batch 10 issues from CSV in 8 minutes.

Common errors and fixes

"下書き保存しました" never appears

  • Substack lost focus on the editor. Click into ProseMirror again first.

Title accepted but body cut off

  • Body has frontmatter leak. Strip with re.sub(r'^---\s*\n.*?\n---\s*\n', '', body, flags=re.DOTALL).

Ctrl+V pastes nothing

  • Editor not focused. Use editor.click() before pyperclip.copy().

"今すぐ全員に送信" button missing

  • Substack has multiple publish modal variants. Use page.click('button:has-text("今すぐ"):visible') for fallback.

Markdown not rendering as headings/lists

  • TipTap doesn't auto-parse markdown on paste. Either:

    • (a) Convert markdown → HTML first, paste HTML
    • (b) Convert markdown → ProseMirror JSON, dispatch via editor.evaluate()
    • For most cases, (a) with markdown-it works:
    from markdown_it import MarkdownIt
    md = MarkdownIt()
    html = md.render(body)
    pyperclip.copy(html)  # TipTap accepts HTML clipboard via Ctrl+V
    

Why this scales

Once the script works, you can drive your full content backlog:

for f in reports/substack-issue-*.md; do
    python publish.py "$f"
    sleep 60  # rate limit
done
Enter fullscreen mode Exit fullscreen mode

In one evening I went from "writing in advance" to "publishing 4 weeks of content in 30 minutes."

Source

Full Substack TipTap publish script + ProseMirror JSON converter (for richer formatting):

AutoApp Dashboard ($39) includes:

  • substack_publish.py (50 lines, this article)
  • substack_publish_with_html.py (markdown→HTML→TipTap)
  • substack_publish_batch.py (CSV-driven batch)
  • substack_subscriber_export.py (read API helper)

If you're publishing to Substack regularly and not automating, you're spending 5 hours per month on copy-paste. CDP solves it in 50 lines.

Top comments (0)