Skip to content
Development

Medium kept dropping my hero image, so the bridge now pastes it in

By Victor Da Luz
playwrightautomationsyndicationdev-logblog-manager

My Medium publish pipeline finally worked end to end last week. blog-manager tells Postiz to publish, Postiz calls a sidecar I call medium-bridge, and the bridge drives Medium’s official “Import a story” page with a stored browser session (Medium killed its API in January 2025, so a stealth Chromium clicking buttons is the machine interface now). The imported post looked right: title, body, canonical link back to my site.

One thing was missing. The hero image. The story imported with no image at all.

Theory 1: it’s the webp

My site serves hero images as webp for browsers. Medium’s editor only accepts JPG, PNG, and GIF. The on-page hero was also a relative URL. Easy theory: the importer fetches the hero, chokes on webp, drops it.

So I changed the blog template to a <picture> element: webp <source> for browsers, absolute-URL jpeg <img> fallback for anything that reads the src attribute.

<picture>
  <source srcset={heroImageWebp} type="image/webp" />
  <img src={heroImageFallback} alt={entry.data.title} />
</picture>

Deployed, re-imported, still no image. And when I dug through the bridge’s history from June, the theory fell apart properly: Medium’s importer used to pull in my site logo - a relative-URL webp - so it handles both fine. There was even cleanup code in the bridge to delete that logo from every draft.

Theory 2: it’s the placement

Next guess: the importer runs a readability-style extractor, keeps only the main text block, and my hero lived outside it (a sibling of the content div). Inline body images had imported fine in June, which fit nicely. I moved the hero to be the first child of the content block.

Re-imported. Still no image. Worse: the fresh import had zero images, including the logo the old cleanup code existed for. Whatever Medium changed since June, its importer now drops every image on the page, and each of my theories cost a manual import to disprove.

Stop guessing, insert it ourselves

The bridge already compensates for the importer’s other habits: it re-applies the title (the importer commits it empty) and re-inserts the References list (the importer eats <ul> elements) by dispatching a synthetic ClipboardEvent at ProseMirror’s paste handler. So: fetch the post’s og:image server-side - it’s already an absolute jpeg - and paste the bytes in as a File.

That almost worked, and the failure mode was sneaky. The editor rendered a preview, autosave ran, and the saved document even recorded the image’s real dimensions (data-width="940" data-height="627"). But the <img> in the persisted doc had no src. The upload never attached. In the published view you get a perfectly sized empty grey frame - arguably worse than no image.

The native input path

What finally worked was driving Medium’s real upload UI instead of faking a paste. With the caret on an empty paragraph, the editor shows an inline ”+” menu; its image button opens a file chooser, and Playwright can intercept that and hand over an in-memory buffer:

const [chooser] = await Promise.all([
  page.waitForEvent('filechooser', { timeout: 15000 }),
  page.evaluate(() => {
    document.querySelector('button[data-action="inline-menu-image"]').click();
  }),
]);
await chooser.setFiles({ name: 'hero.jpeg', mimeType: hero.type,
  buffer: Buffer.from(hero.base64, 'base64') });

Two gotchas on the way there. Pressing Enter at the end of the title creates an empty heading, not a paragraph, and the ”+” menu ignores headings - but imported docs already carry an empty paragraph right under the title, so the bridge clicks that instead. And the menu never reaches a state Playwright considers “visible”, so the clicks go through el.click() in page.evaluate, the same trick the title fix uses.

Verification is paranoid now, because the paste failure taught me to be: wait for the figure to carry a data-image-id, wait for autosave, re-check the attribute survived, then fetch the CDN URL from the server and demand an HTTP 200.

hero: file handed to native upload input
hero: inserted, image-id 1*L8wXdarItJ0XEnVbEm19TA.jpeg - CDN check HTTP 200

Since the hero is the first image in the content, Medium also uses it as the story’s preview card image. That was the old API-era trick too; it still holds.

What I’d tell past me

The two site-side fixes I shipped while chasing wrong theories were still worth keeping - the jpeg fallback makes every scraper happier, not just Medium’s. But debugging a black box by changing your own output is expensive: one theory per manual import, and the box can change under you between experiments, which it did. The June evidence said “inline images import fine” and by July that was no longer true. When a third party silently drops your content and you already own a browser session on their editor, stop negotiating with the importer and put the content there yourself.

Next up: nothing, hopefully. The pipeline’s compensation list is now title, references, and hero. I have opinions about a publishing platform where that list exists at all.

Related reading