When Medium doesn't have an API: RSS polling and LinkedIn cross-posting
I’ve been building blog-manager to automate the boring parts of publishing: push to GitHub, it shows up on Medium and LinkedIn without me touching three different dashboards. This week I wired up the last two pieces: detecting when a Medium draft actually goes live, and automatically cross-posting to LinkedIn when it does.
The Medium problem
When I first scoped this out in a spike, I discovered that the Medium API has no GET /posts/{id} endpoint. There’s no way to poll a post’s status by ID. The only way to know if a draft went live is to check the user’s RSS feed, https://medium.com/feed/@username, which only contains published posts. Drafts never appear.
So the detection flow is: store the Medium URL when a draft is created (already done in the import work), then daily, fetch the RSS feed and check if any stored draft URLs appear. If they do, the post is live.
This is a bit inelegant; I’m essentially polling a public URL meant for feed readers. But it works, and Medium makes no promises about an alternative. The feed is capped at 10 posts, so I match by URL rather than position.
Building Medium::RssPoller
Ruby’s stdlib includes an rss gem for parsing RSS, but it needs to be listed explicitly in the Gemfile under bundler. Learned that the hard way when it worked in isolation but blew up under bundle exec. One-line fix: gem "rss" in the Gemfile.
The poller is straightforward: fetch the feed, parse it, return a Set of normalized URLs (lowercase, trailing slashes stripped, query strings dropped). The normalization matters because the URL stored in the database might come back from the Medium API slightly differently than what shows up in the RSS <link> tag.
For testing without hitting the network, I inject the HTTP connection as a proc:
def initialize(username, connection: nil)
@username = username
@connection = connection || Net::HTTP.method(:start)
end
Pass in a proc in tests that returns a fake HTTP object, and the poller doesn’t know the difference. This is the same pattern I used for Medium::Client.
The LinkedIn side
LinkedIn has been moving away from the old /v2/ugcPosts endpoint toward /rest/posts. The new endpoint requires four headers, and missing any of them returns a cryptic error:
Authorization: Bearer {token}Content-Type: application/jsonX-Restli-Protocol-Version: 2.0.0LinkedIn-Version: 202504, a rolling monthly version string that LinkedIn rotates periodically
That last header is the one that catches people. It’s mandatory, and LinkedIn silently sunsets old versions on a roughly 12-month rolling schedule. I defined it as a constant with a comment to bump it annually:
LINKEDIN_VERSION = "202504" # bump periodically per LinkedIn's rolling version policy
The response on success is a 201 with the post URN in the x-restli-id response header, not the body. Easy to miss if you’re looking in the wrong place.
The job
MediumSyncJob runs daily at 15:00 UTC (09:00 Costa Rica) via Solid Queue’s recurring job feature. For each blog with a Medium token:
- Extract the Medium username from any existing post URL (regex on
medium.com/@username/) - Poll the RSS feed for published URLs
- For each draft/scheduled post whose URL appears in the feed, mark it published
- For each newly-published post (and any failed LinkedIn posts under 3 attempts), call
LinkedIn::PostCreator
The job wraps each blog in a rescue so one bad blog doesn’t kill the whole run. LinkedIn posting errors are caught per-post for the same reason.
The Zeitwerk / migration gotcha
Rails uses ActiveSupport’s inflector to both autoload files and constantize migration class names. I added inflect.acronym "LinkedIn" so that Zeitwerk would load app/services/linkedin/client.rb as LinkedIn::Client instead of Linkedin::Client.
That worked great for autoloading. And then it broke migration rollback in CI. When Rails rolls back a migration, it camelizes the filename to find the class. With the acronym rule in place, add_linkedin_attempts_to_post becomes AddLinkedInAttemptsToPost (capital I). But the migration generator had written AddLinkedinAttemptsToPost (lowercase i). CI caught it on the rollback check before I noticed locally.
Fix was simple, rename the class in the migration file, but it’s the kind of thing that only shows up when you run db:rollback, which I wasn’t doing locally.
Testing without stub
The job tests were the trickiest part. I wanted to inject fake pollers and creators so tests don’t hit the network. My first instinct was Medium::RssPoller.stub(:new, fake_poller), but Minitest 6 removed the stub method from minitest/mock. Requiring it raises LoadError.
The workaround: use class_attribute from ActiveSupport to make the service classes injectable at the class level:
class MediumSyncJob < ApplicationJob
class_attribute :poller_class, default: Medium::RssPoller
class_attribute :creator_class, default: LinkedIn::PostCreator
...
end
In tests, swap the class attribute to a fake class and reset it in teardown:
setup do
@original_poller = MediumSyncJob.poller_class
MediumSyncJob.poller_class = Class.new do
define_method(:initialize) { |_username, **| }
define_method(:call) { Set.new(["https://medium.com/@user/post-abc"]) }
end
end
teardown { MediumSyncJob.poller_class = @original_poller }
define_method with a block creates a closure, so the fake class can capture local variables from the test method. This is useful for the “error recovery” test where the poller should raise on the first call but succeed on the second.
There’s also a subtle Ruby gotcha with define_singleton_method: inside the method body, self becomes the target object, not the test instance. So helper methods on the test class (like make_response) aren’t accessible. Solution: capture the return value as a local variable before entering define_singleton_method, and close over it.
What’s next
The LinkedIn OAuth re-auth flow is still missing. The token expires every 60 days, and right now there’s no way to renew it from the app. The token expiry warning chip on the blog index page is there, but clicking it doesn’t do anything useful. That’s the next issue.
Related reading
Building a blog syndication backfill
72 Medium posts my database knew nothing about: the RSS cap, an undocumented GraphQL workaround, Unicode apostrophes, and the kamal stdin trap.
Building Medium cross-posting for blog-manager
A push-to-Medium button on a deprecated-but-working API: injectable Net::HTTP, on-demand markdown rendering, and a state machine split between job and service.
Tracking scheduled Medium posts without a browser extension
Medium's API has no concept of a scheduled post. Phase 1: model the state manually with an append-only enum, and see if the friction justifies automation.