El rastreador de sindicación que no podía responder su propia pregunta
Construí blog-manager por una razón principal: publico de forma cruzada los artículos de vdaluz.com en Medium y Dev.to, y quería un solo lugar que supiera qué ya estaba dónde. Esta semana me senté a usarlo de verdad para eso y me di cuenta de que no podía. Los datos estaban todos ahí, en la base de datos. La interfaz simplemente se negaba a decírmelo.
El problema de la ausencia invisible
El índice de posts tenía una columna de sindicación con pequeñas pastillas de estado: Medium, Dev.to, Social. Mi decisión de diseño original fue renderizar una pastilla solo cuando algo había pasado en ese destino, así que un post que nunca se sindicó quedaba “silencioso,” con un guion apagado. En su momento se sintió prolijo.
En la práctica invirtió la señal. Mi pregunta real es “¿qué posts todavía NO están en Medium?” y la respuesta quedaba codificada como la ausencia de una pastilla, algo que no se puede rastrear a simple vista entre 86 filas. Peor todavía, una fila que solo mostraba una pastilla de Social era ambigua: ¿falta Medium, está pendiente, o nunca se intentó? El estado exacto vivía en el color de la pastilla más un tooltip al pasar el cursor. Los usuarios daltónicos, los usuarios táctiles, y yo mismo a las 11 de la noche, todos pierden.
La corrección fue vergonzosamente pequeña. Renderizar siempre las tres pastillas, y agregar un estado explícito de “no publicado” con un punto hueco:
STATES = {
done: { css: "status-set", label: "done" },
pending: { css: "status-soon", label: "in progress" },
failed: { css: "status-expired", label: "failed" },
none: { css: "status-none", label: "not published" }
}.freeze
Más filtros. El índice ya tenía un patrón ?filter=missing_hero, así que agregué needs_medium, needs_devto, y failed. Una complicación: el estado por destino vive en una columna JSON serializada (postiz_publish), así que estos filtran en Ruby con @posts.select(&:needs_medium?) en vez de SQL. Con 86 posts sin paginar, esa es la decisión correcta; dejé un comentario que lo explica para mi yo futuro, que inevitablemente va a intentar “arreglarlo.”
Callejones sin salida en el flujo de publicación
Publicar un artículo en Medium pasa por Postiz como borrador, y después un humano (yo) hace clic en publicar dentro de la interfaz de Postiz. La app mostraba los borradores en cola como texto apagado que decía “Awaiting publish in Postiz,” con la explicación en un tooltip. Indicaba dónde estaba el siguiente paso y no daba ninguna forma de llegar ahí. Ahora es un enlace al calendario de lanzamientos de Postiz, derivado de la URL base de la API que ya estaba en la configuración:
def postiz_web_url
postiz_base_url.sub(%r{/api/public/v\d+/?\z}, "")
end
Mismo tratamiento para los prerrequisitos no cumplidos. Publicar en Dev.to necesita una URL en vivo, una imagen de portada, y un token de GitHub, y el botón de Publicar deshabilitado explicaba eso solo al pasar el cursor. Ahora dice en línea qué falta: “needs a header image and a GitHub token.”
También extendí el compartir para que los textos sociales puedan enlazar la copia de Medium o Dev.to en vez de solo la URL canónica. El cableado ya estaba tendido (share_url(source) existía pero ignoraba su argumento), así que toda la funcionalidad se reducía a hacer que shareable_urls devolviera los release_urls por proveedor cuando existían. La interfaz de selección apareció sola; ya estaba programada para mostrarse cuando hay más de una fuente.
Lo que atrapó la revisión
La pasada de revisión se ganó su lugar esta vez. El hallazgo que importó: una prueba de sistema todavía verificaba el viejo texto “Awaiting publish in Postiz.” Nunca lo noté porque bin/rails test no corre las pruebas de sistema, y el job de pruebas de sistema en CI es continue-on-error. Se habría fusionado en rojo sin que nadie lo notara y habría quedado roto en main. La revisión también me empujó a hacer que las validaciones del controlador llamaran al mismo predicado needs_medium? que usan los filtros, en vez de mantener una copia hecha a mano de la condición que con el tiempo se desalinearía.
Después el build falló, dos veces, y no era culpa de mi código
Todos los checks de código pasaron y el job de build de Docker murió resolviendo docker.io/docker/dockerfile:1. Esa es la imagen de frontend de BuildKit que jala la directiva # syntax= en la línea 1 del Dockerfile generado por Rails. Ya había movido la imagen base a un mirror de registro porque Docker Hub le pone límite de tasa a la IP saliente del homelab, pero la directiva de sintaxis era una segunda dependencia de Docker Hub, más escondida. La borré; el frontend integrado cubre todo lo que usa un Dockerfile común y corriente.
La segunda corrida entonces también dio timeout contra el mirror, lo cual significaba que nunca había sido un límite de tasa. Los contenedores del runner autoalojado no tenían conectividad saliente en absoluto. El host bien, los contenedores muertos, reglas NAT presentes, política FORWARD en ACCEPT. La pista estaba en los contadores: los paquetes entraban a la cadena DOCKER-USER y nunca salían. Un cambio paralelo del homelab había agregado ahí reglas de firewall para restringir el acceso entrante a los puertos del proxy de la app, con coincidencia solo por puerto de destino. DOCKER-USER ve tráfico reenviado en ambas direcciones, así que esas reglas también descartaban cada conexión saliente de los contenedores a los puertos 80 y 443: los pulls del registro, y, incómodamente, las llamadas de producción a Postiz, GitHub, y Pexels. Las reglas se habían guardado con netfilter-persistent y quedaron dormidas hasta que ambos contenedores reiniciaron esa noche. La corrección fue acotarlas con -i eth0 para que solo coincidieran con tráfico que llegara desde la LAN.
Dos lecciones que me llevo. Primera, “el CI está en rojo” y “el diff está mal” son afirmaciones distintas; los jobs de código estuvieron verdes todo el tiempo. Segunda, los contadores de reglas de iptables (iptables -Z, generar tráfico, leer -L -v -n) encontraron en cinco minutos lo que leer logs no pudo: los paquetes me dijeron exactamente qué regla se los comió.
Lo que sigue
Dev.to todavía está vacío, así que el backfill arranca ahora: 78 posts, un clic de Publicar a la vez. Si eso se vuelve tedioso rápido, tengo bosquejado un diseño de staging masivo con tope. La revisión también dejó una lista de seguimiento que deliberadamente no metí de más en este PR: una única fuente de verdad syndication_state(provider) en vez de cuatro parseos de estado dispersos, y un componente de tarjeta de estadística para el markup copiado y pegado del dashboard.
Lecturas relacionadas
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.
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ó.