Medium seguía descartando mi imagen destacada, así que el bridge ahora la pega directamente
Mi pipeline de publicación en Medium por fin funcionó de punta a punta la semana pasada. blog-manager le dice a Postiz que publique, Postiz llama a un sidecar que llamo medium-bridge, y el bridge maneja la página oficial “Import a story” de Medium con una sesión de navegador guardada (Medium mató su API en enero de 2025, así que un Chromium sigiloso haciendo clic en botones es la interfaz con la máquina ahora). El post importado se veía bien: título, cuerpo, enlace canónico de vuelta al sitio.
Faltaba una cosa. La imagen destacada. La historia se importó sin ninguna imagen.
Teoría 1: es el webp
El sitio sirve las imágenes destacadas como webp para los navegadores. El editor de Medium solo acepta JPG, PNG y GIF. La imagen destacada en la página también era una URL relativa. Teoría fácil: el importador busca la imagen, se atraganta con el webp, y la descarta.
Así que cambié la plantilla del blog a un elemento <picture>: <source> en webp para los navegadores, y un <img> de respaldo en jpeg con URL absoluta para cualquier cosa que lea el atributo src.
<picture>
<source srcset={heroImageWebp} type="image/webp" />
<img src={heroImageFallback} alt={entry.data.title} />
</picture>
Lo desplegué, reimporté, seguía sin imagen. Y cuando revisé el historial del bridge desde junio, la teoría se cayó del todo: el importador de Medium solía traer el logo del sitio, un webp con URL relativa, así que maneja ambos casos bien. Hasta había código de limpieza en el bridge para borrar ese logo de cada borrador.
Teoría 2: es la ubicación
Siguiente sospecha: el importador corre un extractor tipo readability, se queda solo con el bloque principal de texto, y mi imagen destacada vivía afuera de él (un hermano del div de contenido). Las imágenes dentro del cuerpo se habían importado bien en junio, lo cual encajaba. Moví la imagen destacada para que fuera el primer hijo del bloque de contenido.
Reimporté. Seguía sin imagen. Peor: la importación nueva tenía cero imágenes, incluyendo el logo para el que existía el código de limpieza viejo. Sea lo que sea que Medium cambió desde junio, su importador ahora descarta todas las imágenes de la página, y cada una de mis teorías costó una importación manual para descartarla.
Dejar de adivinar e insertarla directamente
El bridge ya compensa otras costumbres del importador: reaplica el título (el importador lo guarda vacío) y reinserta la lista de References (el importador se come los elementos <ul>) despachando un ClipboardEvent sintético contra el handler de paste de ProseMirror. Entonces: buscar el og:image del post en el servidor, que ya es un jpeg absoluto, y pegar los bytes como un File.
Eso casi funcionó, y el modo de falla fue traicionero. El editor renderizó una vista previa, corrió el autoguardado, y el documento guardado incluso registró las dimensiones reales de la imagen (data-width="940" data-height="627"). Pero el <img> del documento persistido no tenía src. La subida nunca se adjuntó. En la vista publicada queda un marco gris vacío perfectamente dimensionado, posiblemente peor que no tener imagen.
El camino del input nativo
Lo que finalmente funcionó fue manejar la interfaz real de subida de Medium en vez de simular un paste. Con el cursor en un párrafo vacío, el editor muestra un menú ”+” en línea; su botón de imagen abre un selector de archivos, y Playwright puede interceptar eso y entregar un buffer en memoria:
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') });
Dos trampas en el camino. Presionar Enter al final del título crea un heading vacío, no un párrafo, y el menú ”+” ignora los headings, pero los documentos importados ya traen un párrafo vacío justo debajo del título, así que el bridge hace clic ahí en su lugar. Y el menú nunca llega a un estado que Playwright considere “visible”, así que los clics pasan por el.click() dentro de page.evaluate, el mismo truco que usa el fix del título.
Ahora la verificación es paranoica, porque la falla del paste me enseñó a serlo: esperar a que la figure tenga un data-image-id, esperar el autoguardado, revisar de nuevo que el atributo sobrevivió, y después buscar la URL del CDN desde el servidor y exigir un HTTP 200.
hero: file handed to native upload input
hero: inserted, image-id 1*L8wXdarItJ0XEnVbEm19TA.jpeg - CDN check HTTP 200
Como la imagen destacada es la primera imagen del contenido, Medium también la usa como imagen de la tarjeta de vista previa de la historia. Ese era también el truco de la época de la API vieja; todavía se cumple.
Qué le diría a mi yo del pasado
Los dos arreglos del lado del sitio que publiqué mientras perseguía teorías equivocadas siguieron valiendo la pena, el fallback en jpeg deja más contento a cualquier scraper, no solo al de Medium. Pero depurar una caja negra cambiando la propia salida sale caro: una teoría por importación manual, y la caja puede cambiar en el medio entre experimentos, como pasó acá. La evidencia de junio decía “las imágenes dentro del cuerpo importan bien” y para julio eso ya no era cierto. Cuando un tercero descarta el contenido en silencio y ya se tiene una sesión de navegador abierta en su editor, hay que dejar de negociar con el importador y poner el contenido ahí directamente.
Lo próximo: nada, con suerte. La lista de compensaciones del pipeline ahora es título, references, e imagen destacada. Tengo opiniones sobre una plataforma de publicación donde esa lista siquiera existe.
Lecturas relacionadas
Depurar un checkbox oculto en la interfaz real de Medium
Un input invisible de tamaño cero detrás de un interruptor con estilo, un estado deshabilitado que se traga los clics en silencio, y verificación contra la página real en vez de los propios logs.
El blog que no podía publicar según lo programado
Un diseño de proceso por lotes que empezó con una verificación de cinco minutos del sustrato: las publicaciones con fecha futura no estaban ocultas, eran errores 404, y nada reconstruía el sitio jamás.
Un interruptor por blog, y cuándo no "arreglar" un error
Un booleano que atraviesa cuatro capas, y tres pases de revisión independientes que coinciden en un hallazgo que decidí deliberadamente no arreglar, porque el caché es el mecanismo de deduplicación.