DEV Community

孫昊
孫昊

Posted on

I Published 12 Newsletters via CDP Automation (Without Touching Substack UI)

TL;DR: After bashing my head against Reddit / IH / Twitter Lexical anti-bot for 2 hours, I tried the same approach on Substack and it just worked. TipTap is friendlier to programmatic input than Lexical (with anti-bot). Below: the exact selectors + the working 30-line script.


Context

I'm at Day 60 of an indie iOS experiment. Distribution requires posting to:

  • Reddit (Lexical) — body input blocked
  • IndieHackers (Lexical) — body input blocked
  • Twitter X (Lexical) — body input blocked
  • Substack (TipTap) — works
  • dev.to (REST API) — works
  • Apple ASC web UI (React SPA) — works with JS click

This article is about the Substack TipTap path because it's the only rich-text editor I found that actually accepts CDP-driven input.

The selectors

Substack's editor has 3 input fields per post:

// Title
document.querySelector('textarea[aria-label="title"]')

// Subtitle
Array.from(document.querySelectorAll('textarea'))
  .find(t => (t.placeholder||'').includes('サブ'))  // JP locale: サブタイトル
  // or .includes('subtitle') for EN

// Body (TipTap editor)
document.querySelector('.tiptap.ProseMirror')
Enter fullscreen mode Exit fullscreen mode

The body editor is .tiptap.ProseMirror — TipTap's standard class names exposed in the DOM.

The working flow (30 lines)

import time
from playwright.sync_api import sync_playwright

with sync_playwright() as pw:
    browser = pw.chromium.connect_over_cdp("http://localhost:9222", timeout=30000)
    ctx = browser.contexts[0]
    page = ctx.new_page()
    page.goto("https://your-newsletter.substack.com/publish/post", wait_until="domcontentloaded", timeout=30000)
    time.sleep(8)  # let SPA hydrate

    # Title
    page.evaluate(f"""
        () => {{
            const t = document.querySelector('textarea[aria-label="title"]');
            t.focus();
            Object.getOwnPropertyDescriptor(t.constructor.prototype, 'value').set.call(t, 'My Title');
            t.dispatchEvent(new Event('input', {{bubbles: true}}));
        }}
    """)
    time.sleep(1)

    # Body via clipboard + Ctrl+V (TipTap accepts this)
    body_md = "## Body content\nYour markdown here..."
    page.evaluate(f"() => navigator.clipboard.writeText({body_md!r})")
    time.sleep(0.5)
    page.evaluate("() => document.querySelector('.tiptap.ProseMirror').focus()")
    time.sleep(0.5)
    page.keyboard.press("Control+V")
    time.sleep(4)

    # Click Continue (続ける in JP, Continue in EN)
    page.locator('button:has-text("続ける")').first.click(timeout=8000)
    time.sleep(8)

    # Click Send Now (今すぐ全員に送信 in JP, Send Now in EN)
    page.locator('button:has-text("今すぐ全員に送信")').first.click(timeout=8000)
    time.sleep(10)

    # Verify URL changed to /share-center
    print(f"Published: {page.url}")
Enter fullscreen mode Exit fullscreen mode

Why this works (vs Lexical anti-bot)

TipTap doesn't have anti-bot guards built-in. The standard editor accepts:

  • Native React setter for text inputs (title/subtitle)
  • Clipboard paste via Ctrl+V (body)
  • Programmatic button clicks (Continue/Send)

Lexical has the same APIs but additional event filtering:

  • isTrusted: false events get rejected
  • Editor reconciler reverts external DOM mutations
  • Synthetic keypresses get filtered

Substack chose TipTap for their editor; Reddit/IH/Twitter chose Lexical. The choice cascades.

Edge cases

1. UTF-8 BOM in source files

Substack posts often have YAML frontmatter that starts with BOM (Windows files especially). Strip it:

text = Path(file).read_text(encoding="utf-8-sig")  # strips BOM
Enter fullscreen mode Exit fullscreen mode

2. Frontmatter leakage

If you don't strip the YAML frontmatter from your markdown body, it leaks into the published post. Strip with regex:

import re
m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
if m:
    text = text[m.end():].lstrip()
Enter fullscreen mode Exit fullscreen mode

3. Locale detection

Substack switches button labels by browser locale. Japanese (JP): 続ける / 今すぐ全員に送信. English: Continue / Send Now. Match both:

page.locator('button:has-text("続ける"), button:has-text("Continue")').first.click()
Enter fullscreen mode Exit fullscreen mode

4. Multiple drafts queue

If you publish multiple posts in rapid succession (e.g., 3 in 5 min), Substack's UI may queue them. Wait 30+ seconds between publishes.

5. Slug collision

If you publish a title that matches an existing post's slug, Substack auto-appends a numeric suffix (-693, etc.) to make it unique. Track via final URL, not predicted slug.

What I shipped using this flow

In one /autoiter session today:

  • Substack #21: B2B funnel 15 pages → LIVE
  • Substack #22: 90-min IAP setup → LIVE
  • Substack #23: Day 60 milestone → LIVE

Each took ~30 seconds wall-clock from script invocation to public URL.

Source

Working script: dashboard/substack_publish_22.py in autoapp-toolkit (MIT).

For Lexical (Reddit / IH / Twitter), see my earlier article: Why Reddit Lexical Editors Block Programmatic Input.

When to use this approach

  • You have a Substack newsletter
  • You want to publish multiple drafts programmatically (CI/CD style)
  • You don't want to manually paste-publish each post

When NOT to use this approach

  • You have <1 post per week (manual is fine)
  • You haven't already automated everything else (this is end-stage automation)
  • Substack's TipTap version updates regularly (selectors may drift; verify each major version)

If you're indie iOS or indie SaaS and want the playbook with all the CDP automation chapters: iOS Indie Launch Playbook ($19).

Top comments (0)