Saltar al contenido
Development

Cómo construí una extensión de Firefox para sincronizar posts programados de Medium

Por Victor Da Luz
firefoxwebextensionsrailsdev-logblog-manager

He estado rastreando posts de blog en blog-manager, una app de Rails que construí para administrar la sindicación entre plataformas. Acababa de agregar los campos medium_status y medium_scheduled_at (la fase manual) para poder ver qué posts estaban en cola en Medium. El problema: tenía que actualizarlos a mano. Cada vez que programaba algo en Medium, abría blog-manager y escribía la fecha manualmente. Suficientemente tedioso como para que se me siguiera olvidando.

La solución obvia: automatizarlo con una extensión de navegador.

La suposición equivocada

El issue describía interceptar la solicitud GraphQL de Medium para la lista de historias programadas. Medium es una app fuertemente basada en GraphQL, así que parecía razonable. Inyecté un interceptor de fetch en la página en vivo para capturar las operaciones de GraphQL a medida que se disparaban.

No se disparó nada.

Medium renderiza la lista de historias programadas en el servidor al cargar la página. Los datos no vienen de una solicitud de red, ya están incrustados en window.__APOLLO_STATE__, el caché de Apollo Client que Medium embebe en cada página. Diecisiete posts programados, todos ahí sentados en la variable global, sin necesidad de ninguna llamada de red.

Esto en realidad hizo la extensión más simple. Sin permiso webRequest, sin correr contra una respuesta de red. Solo leer los datos y hacer POST.

Cómo se ve el estado de Apollo

Cada post en window.__APOLLO_STATE__ está indexado como Post:<id>. Un post programado tiene isPublished: false y un publishSchedule.publishAt en milisegundos Unix. Un borrador tiene publishSchedule: null. El filtro son tres condiciones: __typename === "Post", isPublished === false, publishSchedule?.publishAt truthy.

La arquitectura de la extensión

Firefox MV3, cinco archivos. El content script corre en https://medium.com/me/stories*, lee el caché de Apollo, y le manda un mensaje al background script. El background script lee un bearer token desde browser.storage.local y hace POST a http://localhost:3000/medium/sync. Un pequeño popup me deja pegar el secreto compartido una sola vez.

Lograr que esto funcionara tomó varios intentos fallidos.

Intento fallido 1: world: “MAIN”

Mi primer instinto fue correr el content script en world: "MAIN" para que pudiera acceder directamente a window.__APOLLO_STATE__. Puede, pero los scripts en world: "MAIN" corren en el contexto de JS de la página, lo que significa que no hay APIs de extensión browser.*. browser.runtime.sendMessage lanza ReferenceError: browser is not defined.

Firefox 128 agregó soporte para world: "MAIN" en los content scripts de MV3, pero el costo es perder todo el acceso a las APIs de WebExtension. Se pueden leer las variables globales de la página, pero no se puede hablar con el background script.

Mi siguiente intento fue un relevo de dos scripts: el script en world MAIN lee el estado de Apollo y llama a window.postMessage, el script en world aislado escucha y retransmite vía browser.runtime.sendMessage. Más limpio sobre el papel. En la práctica tuvo problemas con la verificación event.source === window (los proxies del world aislado de Firefox no comparan igual con la ventana real de la página) y posibles condiciones de carrera de tiempo entre ambos scripts registrándose en document_idle.

El arreglo real: window.wrappedJSObject

Los content scripts de Firefox usan Xray wrappers, lo que significa que obtienen una vista limpia del DOM pero no pueden ver variables globales definidas por el script de la página como window.__APOLLO_STATE__. Solución específica de Firefox: window.wrappedJSObject evita el Xray wrapper y da acceso a la ventana real de la página.

const state = window.wrappedJSObject.__APOLLO_STATE__;

Un solo script, world aislado, acceso completo a browser.*, lee las variables globales de la página directamente. No hace falta relevo. Es un comportamiento de Firefox de larga data, no está atado a ninguna versión reciente.

Intento fallido 2: CORS

El endpoint de Rails necesitaba headers CORS porque el content script corre en https://medium.com y hace POST a http://localhost:3000. Agregué rack-cors configurado para permitir Origin: https://medium.com.

La extensión seguía fallando. El fetch en un background script no lleva el origin medium.com, viene de moz-extension://.... rack-cors lo estaba rechazando porque el origin no coincidía.

Como el endpoint ya está protegido por un bearer token, la restricción de origin de CORS no agrega nada. Lo cambié a origins "*" para /medium/sync.

Intento fallido 3: permisos de host en Firefox MV3

Después de arreglar CORS, la extensión seguía sin hacer nada. Los content scripts no se estaban inyectando. Sin errores, sin logs.

Firefox MV3 cambió cómo funcionan los permisos de host. En MV2, declarar host_permissions en el manifest los otorgaba automáticamente. En MV3, son opt-in. El usuario tiene que otorgar acceso explícitamente vía about:addons → extensión → pestaña de Permisos. Hasta entonces, los content scripts simplemente no corren, sin avisar.

Para un complemento temporal cargado vía about:debugging, esto no es obvio. No hay ningún aviso al instalar. Agregué una nota al README de la extensión.

El lado de Rails

Un controller delgado que delega a Medium::ScheduledImporter, un PORO que sigue la misma forma que el servicio PostScanner ya existente. Toma un array de hashes { id, title, publish_at }, busca cada uno por medium_draft_id, y actualiza medium_status: :scheduled y medium_scheduled_at. Devuelve el conteo de posts encontrados y no encontrados.

La autenticación por bearer token usa ActiveSupport::SecurityUtils.secure_compare para evitar ataques de temporización. El secreto compartido vive en config/credentials.yml.enc.

Cómo se ve funcionando

Al visitar https://medium.com/me/stories y revisar los logs de Rails: POST /medium/sync 200 con 17 consultas disparadas. La mayoría son misses por ahora, todavía no hice el backfill de medium_draft_id en los posts existentes. A medida que publique posts nuevos de aquí en adelante, el ID quedará establecido y la sincronización los recogerá automáticamente.

Lecturas relacionadas

Development

El botón de commit del editor es un botón de deploy

Confirmar un borrador a main despliega el blog automáticamente. En cuanto eso quedó claro, sync vs. async dejó de ser una cuestión de estilo, más el caso especial de afiliado heredado que un validador nuevo casi rompió.

Leer