We built a pipeline that generates content and ships it to several platforms. Generation turned out to be the easy half. Publishing is where the real engineering was — and where I burned the most hours. Here is what I'd tell my past self.
The rule I landed on: API when it exists, browser only when it doesn't
Not every platform exposes a publishing API. The tempting shortcut is to drive a headless browser everywhere. Don't. Two reasons:
- Some platforms explicitly forbid it. X's automation rules are blunt: "Non-API-based forms of automation, such as scripting the X website, may result in permanent suspension." If an API exists, scripting the site is not a clever workaround — it's a ban waiting to happen.
- Browser automation is inherently fragile. It breaks the moment someone renames a CSS class.
So the rule: API adapter by default. Browser automation only where no API exists, and only for actions the account owner is allowed to perform.
What platforms actually allow
I checked the primary docs (not blog posts) for each. Official publishing APIs where automation of your own account is permitted:
| Platform | Auth | Content |
|---|---|---|
| Telegram (Bot API) | static bot token | short / channel posts |
| Bluesky (AT Protocol) | app password → session JWT | short |
| Mastodon | OAuth token | short |
| DEV.to / Forem |
api-key header |
long-form Markdown |
| Ghost | Admin API key → JWT | long-form |
OAuth (w_member_social) |
short/medium |
The universal enforcement rule is remarkably consistent across all of them:
Posting your content to your account: fine.
Identical content across multiple accounts, bulk/aggressive actions, unsolicited notification spam: banned.
Vary content per platform, respect rate limits, don't automate interactions. That's the whole game.
Browser automation gotchas (the expensive ones)
1. "Am I logged in?" — don't assert on text anonymous users also see
My first check looked for words like "My feed" and "Write". Both are visible to logged-out visitors. The script cheerfully reported success while completely unauthenticated.
Assert on the absence of the thing that shouldn't be there:
async function isLoggedIn(page: Page): Promise<boolean> {
const loginBtn = page.getByRole("button", { name: "Sign in", exact: true }).first();
return !(await loginBtn.isVisible().catch(() => false));
}
2. Native confirm() dialogs are not in the DOM
The publish button fired a native confirm(). I spent an embarrassing amount of time hunting for a second DOM button that never existed. Playwright auto-dismisses native dialogs unless you handle them:
page.on("dialog", (d) => d.accept());
3. Rich editors: the body block may not exist yet
The empty editor had only a title field. The body block is created when you press Enter from the title. My naive "click the editor and type" appended the first paragraph into the title:
Title: My Article HeadlineThe first paragraph of my article...
The fix is to type like a human:
await titleField.click();
await page.keyboard.type(title);
await page.keyboard.press("Enter"); // <- this creates the body block
for (const [i, p] of paragraphs.entries()) {
await page.keyboard.type(p);
if (i < paragraphs.length - 1) await page.keyboard.press("Enter");
}
And then verify by reading it back. Don't trust a screenshot that "looked fine":
const titleText = await titleField.innerText();
const firstBody = await page.locator(".editor-text-tool").first().innerText();
That single assertion caught the bug that three screenshots missed.
4. Sessions expire, quietly
A saved storageState worked beautifully — for about two to three weeks. Then a run came back auth_required. Build re-authentication in as a normal automated step, not a manual emergency.
API gotchas
Versioned APIs expire
LinkedIn requires a LinkedIn-Version: YYYYMM header and keeps roughly the last 12 months active. A hardcoded default that was fine at write-time returns this at run-time:
HTTP 426 — Requested version 20250501 is not active
Anything with a date baked into it belongs in config, not in a constant. Same for token lifetime (60 days for LinkedIn) — treat rotation as a scheduled task, not a surprise.
The architecture that held up
-
One adapter contract per platform:
validate → prepare → publish → verify → collectResult. Adding a platform means writing one file, not touching the orchestrator. -
Secrets in env, never in the database. Outward (UI / API / logs) we expose only masked booleans:
configured,publishEnabled. A token has never once been logged. -
Idempotency by request hash:
sha256(draftId + destinationId + contentHash + mode). A priorsuccess→ skip, return the existing URL. A priorunknown(the network died mid-request, we genuinely don't know if it landed) → never blind-retry; flagneeds_manual_review. Duplicate posts are worse than a missing one. -
Two flags to publish for real:
dryRun=falseandpublishConfirm=true. Defaults are safe, so an accidental run can only ever produce a preview. -
Stop, don't bypass. Captcha, 2FA, an emailed verification code → status
waiting_human. We never solve a challenge. That line is what separates "a robot operating the owner's account" from "a bot pretending to be a human".
Takeaways
- API-first. Browser automation is a fallback, not a default — and on some platforms it's a ban.
- Read the automation policy before writing the adapter. It changes what you build, not just whether you're allowed.
- Assert on absence, not on presence of a happy-path string.
- Anything dated (API versions, tokens) goes in config. It will expire while you sleep.
- Make "publish for real" require two deliberate flags. Your future self will hit Enter on the wrong terminal eventually.
The unglamorous truth: the model that writes the post is the commodity. The reliability layer around it — idempotency, gating, honest failure states — is the actual product.
Top comments (0)