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')
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}")
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: falseevents 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
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()
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()
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)