Every developer I know has the same content problem: you write something good, then spend more time figuring out where and how to post it than you spent writing it. LinkedIn wants different formatting than Dev.to. Hashnode has its own publication ID. X needs brevity, Mastodon needs federation. The platforms are fragmented; your time is not.
Here's how I solved it.
The Architecture
The core idea is a 4-tier routing decision that fires for every (brand × platform) pair:
1. API tier — direct REST call (Bluesky, Dev.to, Hashnode, Gumroad, Mastodon)
2. Bridge tier — Chrome extension bridge (X/Twitter, Threads, Instagram, LinkedIn)
3. Playwright — headless persistent-context (TikTok, Bandcamp, Etsy, KDP)
4. Desktop — PyAutoGUI for native file dialogs (YouTube, rare edge cases)
Each tier has a tier_api(), tier_bridge(), or tier_browser() method on a BasePublisher subclass. The routing logic picks the best available tier at runtime based on what credentials exist:
class BasePublisher:
DEFAULT_TIERS = ["api", "bridge", "browser"]
def publish_with_fallback(self, content, metadata, content_type="post"):
for tier in self.DEFAULT_TIERS:
method = getattr(self, f"tier_{tier}", None)
if method is None:
continue
result = method(content, metadata)
if result.ok:
return result
return PublishResult.fail(...)
Simple. Each platform subclass overrides DEFAULT_TIERS to reflect what's actually implemented. Bluesky only has ["api"]. X has ["bridge", "api"] — bridge first because it uses the logged-in session, API as fallback if OAuth credentials exist.
The Gate Layer
Before any publish fires, a gate checks four things:
def can_publish_now(brand: str, platform: str) -> tuple[bool, str]:
limits = limits_for(brand, platform)
# 1. Hard daily cap
if today_count(brand, platform) >= limits["daily_max"]:
return False, "daily_max hit"
# 2. Burst prevention (platform-wide, cross-brand)
if last_60min_count(platform) >= limits["burst_limit_60min"]:
return False, f"burst_limit tripped"
# 3. Minimum gap between posts for this brand
if time_since_last_post(brand, platform) < limits["min_gap_minutes"] * 60:
return False, "min_gap not met"
# 4. Consecutive failure circuit breaker
if consecutive_fail_count(brand, platform) >= FAIL_THRESHOLD:
return False, "circuit_breaker: credentials may be expired"
return True, "ok"
The burst limit is cross-brand and platform-wide. This matters because multiple brands can share the same underlying account — you don't want 9 brands firing at once and tripping Twitter's rate limiter.
The Race Condition Fix
Here's the subtle part. If you have 4 parallel CPU wakes all checking the gate simultaneously:
Wake A: checks gate → 0 posts in last 60min → allowed ✓
Wake B: checks gate → 0 posts in last 60min → allowed ✓ ← RACE
Wake C: checks gate → 0 posts in last 60min → allowed ✓ ← RACE
Wake A: posts, logs → 1 post
Wake B: posts, logs → 2 posts ← rate limit violation
The fix: a platform-wide file-lock wraps the entire gate-check → publish → log sequence:
with platform_lock(platform, timeout=90.0):
allowed, reason = can_publish_now(brand, platform)
if not allowed:
return gated_outcome(reason)
return do_publish_and_log(brand, platform, content)
Now wake B has to wait for wake A's log entry to land before its gate check runs. It will see count=1, the burst limit will trip, and it will correctly back off.
URL Verification
The last piece: every publish must return a real post URL, not a profile homepage. A publish_verifier module checks the URL against platform-specific regex patterns:
PLATFORM_PATTERNS = {
"bluesky": [
r"https://bsky\.app/profile/[^/]+/post/[A-Za-z0-9_-]+", # real post
r"https://bsky\.app/profile/[^/]+", # profile (ok with flag)
],
"x_twitter": [
r"https://(?:twitter|x)\.com/[^/]+/status/\d+", # real post
r"https://(?:twitter|x)\.com/[^/]+", # profile (ok: bridge doesn't return permalinks)
],
...
}
The profile_only_ok flag lets you accept profile URLs for platforms where the bridge path can't return a post permalink — X's compose box doesn't redirect you to the tweet after posting. You get the profile as confirmation it worked; the tweet is live. This is distinct from a failed publish that returns the platform homepage.
Results
After wiring this up across 9 content brands and 15+ platforms:
- Bluesky: 100% success rate when gate is open
- Hashnode: 4/4 real post URLs today
- X/Twitter: ~42% real posts (rest are rate-gated correctly)
- Threads: ~68% real posts (burst limit enforces natural spacing)
- YouTube: blocked by API quota + CDP file-upload protection (that's a separate war)
The 200 lines of routing logic eliminated manual posting. The gate layer eliminated spam. The race-condition fix eliminated the parallel-wake over-posting bug. The URL verifier eliminated phantom success logs.
The code is unglamorous, but it works.
codevault builds developer tools and automation utilities. The publisher stack is part of a larger autonomous business operations system.
Related: When automation starts surfacing unexpected data anomalies and you are chasing hardware ghosts, the bug is often in the compiler, not the silicon. The One-Line Fix That Took 24 Hours to Find covers how a missing volatile qualifier corrupted sensor readings across 3,000 boots in a production embedded system.
Top comments (0)