Reconciliar publicaciones cruzadas de Medium que blog-manager nunca registró
Uso blog-manager para rastrear el estado de sindicación de cada post que publico de forma cruzada desde vdaluz.com hacia Dev.to y Medium a través de Postiz. El problema: algunas de las publicaciones cruzadas en Medium pasaron antes de que blog-manager existiera, y unas cuantas más se programaron a mano en el propio panel de Medium, por fuera de cualquier flujo manejado por Postiz. blog-manager no tenía registro de ninguno de los dos grupos, así que las mostraba como “no sincronizadas” aunque estuvieran ahí mismo en Medium.
El pedido era simple en el papel: revisar los artículos activos de vdaluz.com y marcar los que ya estaban en Medium. Con lo que realmente me encontré fue con una cadena de momentos de “esperá, eso no era lo que asumía” que hicieron la función más interesante de lo que sugería el ticket.
Qué construí
blog-manager ya rastrea el estado de publicación por canal en una columna JSON de Post, postiz_publish, indexada por el integration id de Postiz, con entradas como { "state" => "PUBLISHED", "provider" => "medium", "release_url" => "..." }. Un accessor postiz_medium recorre ese hash buscando la entrada donde provider == "medium", sin importarle cuál sea la clave. Ese último detalle resultó importar mucho.
Medium retiró su API en enero, así que el único camino de integración que queda es un sidecar de automatización de navegador en el homelab que maneja el flujo “Import a story” de Medium. Eso es de una sola vía: solo publicar, nada de consultar. Pero Medium todavía sirve un feed RSS público por perfil, sin necesidad de autenticación, así que construí Medium::FeedReconciler: obtiene el perfil de Medium conectado desde el endpoint /integrations de Postiz, deriva su URL de feed (medium.com/feed/@handle), lo descarga, y hace coincidir los elementos con los posts locales por título normalizado exacto.
Como el reconciliador no tiene un integration id real de Postiz para un post que descubre de esta forma, le di a Post#backfill_medium! una clave sintética y una regla de precedencia: PUBLISHED le gana a SCHEDULED, que le gana a DRAFT, y solo mejora, nunca degrada. Eso también permitió manejar un segundo caso: posts que ya estaban en la cola privada “Scheduled” de Medium, que no se pueden descubrir de forma automática (más sobre esto abajo), pero que se pueden hacer backfill una vez que se conocen, y después ver cómo se actualizan automáticamente a PUBLISHED cuando la siguiente corrida del reconciliador los detecta publicados.
Decisiones que tomé y por qué
Decidí no construir una interfaz de coincidencia difusa tipo “¿posible coincidencia, confirmar?” para títulos que no coinciden exactamente. Mi primer instinto fue que las publicaciones cruzadas históricas podían tener títulos editados, así que necesitaría algo de tolerancia. Resultó ser un problema más chico de lo esperado (ver abajo), y los dos casos de coincidencia de título que sí hicieron falta son lo bastante puntuales como para que una anulación manual en una rake task le gane a todo un flujo de confirmación, por ahora. Tres líneas parecidas le ganan a una abstracción prematura.
También mantuve el reconciliador automatizado y el backfill histórico como dos rake tasks separadas (medium:reconcile frente a medium:seed_scheduled / medium:seed_published) en vez de intentar unificarlas. Tienen niveles de confianza genuinamente distintos: una corre contra un feed activo y verificable, la otra corre contra un volcado de datos manual y único que no se puede reverificar de forma automática. Mezclarlas habría escondido esa diferencia.
Lo que me sorprendió
El feed RSS solo devuelve las 10 publicaciones más recientes. Sin paginación, sin parámetro de límite, nada. Tengo 78 posts publicados en Medium, y el feed simplemente… se corta en 10. Eso no está documentado en ningún lugar obvio, lo descubrí descargando el feed real y contando los elementos. Significa que el job automatizado va a capturar correctamente cada post nuevo de ahí en adelante, pero nunca puede recorrer hacia atrás el historial de publicaciones. Para eso necesitaba la lista real, lo que significaba exportar a mano el propio panel Published de Medium.
El primer intento de esa exportación no funcionó. Guardé la página como HTML y obtuve una foto genérica de la página de inicio sin sesión iniciada, sin ninguna mención de mi usuario en ningún lado, porque el panel de Medium se renderiza del lado del cliente después de iniciar sesión y un guardado de página simple no espera a que eso pase. Lo que sí funcionó fue copiar el texto renderizado directamente, de la misma forma que ya lo había hecho para la pestaña Scheduled.
Una vez que tuve títulos reales contra los cuales comparar, corrí la lógica de coincidencia exacta contra los 78 y obtuve 76 aciertos automáticos. De los dos que fallaron, uno fue una edición genuina de título (“my website” pasó a ser “this site” en el post actual, confirmado leyendo el archivo real) y el otro fue un post legítimamente distinto y sin relación que resulta compartir un nombre muy parecido. Ninguno necesitaba coincidencia difusa, necesitaban que una persona mirara dos casos puntuales, que es exactamente la escala a la que pertenece una anulación manual.
También encontré código muerto mientras estaba en la zona: una rake task de Dev.to que llama a una clase que no existe en ningún lugar de la app, claramente un resto de antes de que la publicación en Dev.to se mudara a Postiz. No la toqué, la registré como su propio pendiente en vez de expandir el alcance de esta.
Qué sigue
76 de los 78 posts históricos y los 8 programados ya tienen backfill. De ahora en adelante, el job recurrente cada hora mantiene el panorama al día por su cuenta. La pregunta abierta es si vale la pena extender el mismo sidecar de automatización de navegador que ya publica en Medium para que también pueda extraer directamente las listas autenticadas Published/Scheduled, cerrando el hueco que el RSS no alcanza sin otra exportación manual la próxima vez. Quedó registrado como una decisión para después, no como algo para construir ahora.
Lecturas relacionadas
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.
El bug de normalización que solo aparece con etiquetas hechas de nada
Un normalizador basado en strip se topa con una etiqueta de puro signo de puntuación: string vacío como clave de hash, sustitución de etiqueta equivocada, y un autocompletado que hace match con todo. Tres síntomas, una sola causa raíz.
La misma decisión de botón me costó un bug más grande de lo esperado
Incrustar el flujo de imagen destacada en el editor parecía la opción más chica, hasta que 'reemplazar' se topó con 166 archivos reales que nunca habían pasado por el camino de solo inserción, y una migración sin backfill.