Construindo uma extensão de Firefox para sincronizar posts agendados do Medium
Eu venho rastreando posts de blog no blog-manager, um app Rails que construí para gerenciar a syndication entre plataformas. Eu tinha acabado de adicionar os campos medium_status e medium_scheduled_at (a fase manual) para conseguir ver quais posts estavam na fila no Medium. O problema: eu tinha que atualizar isso na mão. Toda vez que eu agendava algo no Medium, eu abria o blog-manager e digitava a data manualmente. Chato o bastante para eu ficar esquecendo.
A correção óbvia: automatizar isso com uma extensão de navegador.
A suposição que estava errada
A tarefa descrevia interceptar a requisição GraphQL do Medium para a lista de scheduled-stories. Medium é um app GraphQL pesado, então isso parecia razoável. Eu injetei um interceptador de fetch na página ao vivo para capturar as operações GraphQL conforme elas disparassem.
Nada disparou.
O Medium renderiza a lista de scheduled-stories no servidor, no carregamento da página. Os dados não vêm de uma requisição de rede - já estão embutidos em window.__APOLLO_STATE__, o cache do Apollo Client que o Medium embute em cada página. Dezessete posts agendados, todos ali sentados na global, sem precisar de nenhuma chamada de rede.
Isso na verdade deixou a extensão mais simples. Sem permissão webRequest, sem correr atrás de uma resposta de rede. Só ler os dados e mandar um POST.
A cara do estado do Apollo
Cada post em window.__APOLLO_STATE__ é indexado como Post:<id>. Um post agendado tem isPublished: false e um publishSchedule.publishAt em milissegundos Unix. Um rascunho tem publishSchedule: null. O filtro é três condições: __typename === "Post", isPublished === false, publishSchedule?.publishAt truthy.
A arquitetura da extensão
Firefox MV3, cinco arquivos. O content script roda em https://medium.com/me/stories*, lê o cache do Apollo e manda mensagem para o background script. O background script lê um bearer token de browser.storage.local e faz POST para http://localhost:3000/medium/sync. Um popup pequeno me deixa colar o segredo compartilhado uma vez.
Fazer isso funcionar levou alguns becos sem saída.
Beco sem saída 1: world: “MAIN”
Meu primeiro instinto foi rodar o content script em world: "MAIN" para que ele pudesse acessar window.__APOLLO_STATE__ diretamente. E consegue - só que scripts em world: "MAIN" rodam no contexto JS da página, o que significa nenhuma API browser.* de extensão. browser.runtime.sendMessage lança ReferenceError: browser is not defined.
O Firefox 128 adicionou suporte a world: "MAIN" para content scripts MV3, mas o trade-off é perder todo o acesso à API WebExtension. Você consegue ler globais da página, mas não consegue falar com o background script.
Minha próxima tentativa foi um relay com dois scripts: o script no MAIN world lê o estado do Apollo e chama window.postMessage, o script no isolated world escuta e retransmite via browser.runtime.sendMessage. Mais limpo no papel. Na prática, tinha problemas com a checagem event.source === window (os proxies do isolated world do Firefox não comparam iguais à window crua da página) e possíveis corridas de tempo entre os dois scripts se registrando no document_idle.
A correção de verdade: window.wrappedJSObject
Content scripts do Firefox usam Xray wrappers, o que significa que eles têm uma visão limpa do DOM mas não conseguem ver globais definidas por scripts da página, como window.__APOLLO_STATE__. O contorno específico do Firefox: window.wrappedJSObject passa por cima do Xray wrapper e te dá a window real da página.
const state = window.wrappedJSObject.__APOLLO_STATE__;
Um script só, isolated world, acesso completo a browser.*, lê globais da página diretamente. Sem precisar de relay. É um comportamento antigo do Firefox, não está atrelado a nenhum release recente.
Beco sem saída 2: CORS
O endpoint do Rails precisava de headers CORS porque o content script roda em https://medium.com e faz POST para http://localhost:3000. Eu adicionei o rack-cors configurado para permitir Origin: https://medium.com.
A extensão continuava falhando. O fetch num background script não carrega a origin medium.com - ela vem de moz-extension://.... O rack-cors estava rejeitando porque a origin não batia.
Já que o endpoint já é protegido por um bearer token, a restrição de origin do CORS não agrega nada. Mudei para origins "*" em /medium/sync.
Beco sem saída 3: host permissions do Firefox MV3
Depois de corrigir o CORS, a extensão continuava sem fazer nada. Os content scripts não estavam injetando. Sem erros, sem logs.
O Firefox MV3 mudou como as host permissions funcionam. No MV2, declarar host_permissions no manifest concedia elas automaticamente. No MV3, elas são opt-in. O usuário precisa conceder acesso explicitamente via about:addons → extensão → aba Permissions. Até lá, os content scripts simplesmente não rodam, sem avisar nada.
Para um add-on temporário carregado via about:debugging, isso não é óbvio. Não tem nenhum prompt na instalação. Adicionei uma nota ao README da extensão.
O lado do Rails
Um controller fino que delega para Medium::ScheduledImporter, um PORO seguindo o mesmo formato do service PostScanner já existente. Ele recebe um array de hashes { id, title, publish_at }, procura cada um por medium_draft_id, e atualiza medium_status: :scheduled e medium_scheduled_at. Retorna contagens de posts encontrados e não encontrados.
A autenticação por bearer token usa ActiveSupport::SecurityUtils.secure_compare para evitar timing attacks. O segredo compartilhado vive em config/credentials.yml.enc.
Como fica funcionando
Visite https://medium.com/me/stories, confira os logs do Rails: POST /medium/sync 200 com 17 queries disparadas. A maioria é miss por enquanto - eu ainda não fiz backfill do medium_draft_id nos posts existentes. Conforme eu for publicando posts novos daqui pra frente, o ID vai ser preenchido e o sync vai pegar eles automaticamente.
Leitura relacionada
Uma correção de desvio de documentação que não foi tão chata quanto parecia
Três itens da auditoria que cada um virou outra coisa: uma alegação meio corrigida, uma recuperação de senha silenciosamente morta, e um e-mail de staging que apontaria para produção.
Um 500 escondido dentro das rotas isoladas de uma engine montada
O painel de jobs retornava um 500 em vez de uma página de login: helpers de rota sem qualificação resolvem contra a engine, não contra a aplicação. Uma linha, mais o gêmeo dormente dela.
Apagando código morto, e pegando um motivo errado para uma resposta certa
Uma issue de limpeza com uma justificativa errada, uma varredura de documentação que não era necessária, e a credencial órfã que uma revisão pegou.
Você também pode achar útil
RackNerd VPS
Hospedagem VPS econômica para serviços leves que funcionam continuamente.
Como afiliado da RackNerd, ganho com compras qualificadas.
Saiba maisAdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba maisNordPass
Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.
Como afiliado da NordPass, ganho com compras qualificadas.
Saiba mais