Reconciling Medium cross-posts blog-manager never staged
I use blog-manager to track syndication status for every post I cross-post from vdaluz.com to Dev.to and Medium through Postiz. The problem: some of my Medium cross-posts happened before blog-manager existed, and a few more got scheduled by hand in Medium’s own dashboard, outside of any Postiz-driven flow. blog-manager had no record of either group, so it showed them as “not synced” even though they were sitting right there on Medium.
The ask was simple on paper: check the live vdaluz.com articles and mark the ones already on Medium. What I actually ran into was a chain of “wait, that’s not what I assumed” moments that made the feature more interesting than the ticket suggested.
What I built
blog-manager already tracks per-channel publish state in a JSON column on Post, postiz_publish, keyed by Postiz’s integration id, with entries like { "state" => "PUBLISHED", "provider" => "medium", "release_url" => "..." }. A postiz_medium accessor scans that hash for the entry where provider == "medium", not caring what the key is. That last detail turned out to matter a lot.
Medium retired its API back in January, so the only integration path left is a homelab browser-automation sidecar that drives Medium’s “Import a story” flow. That’s one-way: publish only, nothing to query. But Medium still serves a public RSS feed per profile, no auth needed, so I built Medium::FeedReconciler: pull the connected Medium profile off Postiz’s /integrations endpoint, derive its feed URL (medium.com/feed/@handle), fetch it, and match items against local posts by exact normalized title.
Since the reconciler has no real Postiz integration id for a post it discovers this way, I gave Post#backfill_medium! a synthetic key and a precedence rule: PUBLISHED beats SCHEDULED beats DRAFT, and it only ever upgrades, never downgrades. That let me also handle a second case: posts already sitting in Medium’s private “Scheduled” queue, which I can’t discover automatically at all (more on that below), but can backfill once I know about them, and later watch get upgraded to PUBLISHED automatically once the reconciler’s next run catches them going live.
Decisions I made and why
I decided against building a fuzzy-match “possible match, confirm?” UI for titles that don’t match exactly. My first instinct was that historical cross-posts might have edited titles, so I’d need some tolerance. Turned out to be a smaller problem than I expected (see below), and the two title-matching cases I did need are narrow enough that a manual override in a rake task beats a whole confirmation flow for now. Three similar lines beat a premature abstraction.
I also kept the automated reconciler and the historical backfill as two separate rake tasks (medium:reconcile vs medium:seed_scheduled / medium:seed_published) instead of trying to unify them. They have genuinely different trust levels: one runs against a live, verifiable feed; the other runs against a one-time manual data dump I can’t re-verify automatically. Mixing them would have hidden that difference.
What surprised me
The RSS feed only returns the 10 most recent published stories. No pagination, no limit param, nothing. I have 78 posts published on Medium, and the feed just… stops at 10. That’s not documented anywhere obvious; I found it by fetching the real feed and counting items. It means the automated job will correctly catch every new post going forward, but it can never crawl backward through my own publish history. For that I needed the actual list, which meant exporting Medium’s own Published dashboard by hand.
The first attempt at that export didn’t work. I saved the page as HTML and got a generic, logged-out homepage snapshot, zero mentions of my handle anywhere in it, because Medium’s dashboard is rendered client-side after login and a plain page save doesn’t wait around for that to happen. The thing that did work was copying the rendered text directly, the same way I’d already done for the Scheduled tab.
Once I had real titles to match against, I ran the exact matching logic against all 78 and got 76 automatic hits. Of the two misses, one was a genuine title edit (“my website” became “this site” on the current post, confirmed by reading the actual file) and the other was a legitimately different, unrelated post that happens to share a very similar name. Neither needed fuzzy matching; they needed a human to look at two specific cases, which is exactly the scale a manual override belongs at.
I also found dead code while I was in the neighborhood: a Dev.to rake task calling a class that doesn’t exist anywhere in the app, clearly left over from before Dev.to publishing moved to Postiz. Didn’t touch it, filed it as its own follow-up instead of scope-creeping this one.
What’s next
76 of 78 historical posts and all 8 scheduled ones are backfilled. Going forward, the recurring hourly job keeps the picture current on its own. The one open question is whether it’s worth extending the same browser-automation sidecar that already publishes to Medium so it can also scrape the authenticated Published/Scheduled lists directly, closing the gap RSS can’t reach without another manual export next time. Filed as a decision for later rather than something to build now.
Related reading
A per-blog toggle, and when not to "fix" a bug
A boolean threaded through four layers, and three independent review passes agreeing on a finding I deliberately didn't fix - because the cache is the dedup mechanism.
The normalization bug that only shows up on tags made of nothing
A strip-based normalizer meets an all-punctuation tag: empty string as a hash key, wrong-tag substitution, and an autocomplete that matches everything. Three symptoms, one root cause.
The same button choice cost me a bigger bug than expected
Embedding the hero flow in the editor looked like the smaller option - until 'replace' met 166 real files that had never gone through the insertion-only path, and a migration with no backfill.