Publicación cruzada en Hashnode dentro del gestor de blog
Vengo automatizando de a poco la publicación cruzada de mi blog. Medium fue el primero, después Dev.to. Hashnode es el tercer destino, y resultó ser el más interesante de implementar porque usa GraphQL en vez de REST.
Por qué Hashnode
Dev.to tiene una audiencia bruta más grande, pero Hashnode se inclina más hacia lo técnico. Los posts con código tienden a tener mejor engagement ahí. También admite dominios personalizados, lo que significa que los lectores llegan a username.hashnode.dev pero la URL canónica sigue apuntando de vuelta a mi sitio, exactamente lo que busco para SEO.
El modelo de autenticación es simple: un Personal Access Token desde hashnode.com/settings/developer, pasado como encabezado Authorization en las mutations. Las queries (para sincronizar el estado de publicación) son públicas, no hace falta ninguna autenticación.
Sin gem de GraphQL
El blog manager ya tiene un patrón para clientes HTTP con conexiones inyectables para testing: tanto Medium::Client como Devto::Client reciben un Proc connection: en sus constructores. Las pruebas pasan un lambda que devuelve una respuesta enlatada. Sin WebMock, sin VCR.
GraphQL sobre HTTP es solo un POST con un cuerpo JSON que contiene {query:, variables:}. Así que mantuve el mismo patrón en vez de traer graphlient o graphql-client:
def graphql(query, variables, auth: true)
uri = URI.parse("https://gql.hashnode.com/")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = @token if auth
req.body = { query: query, variables: variables }.to_json
connect = @connection || Net::HTTP.method(:start)
res = connect.call(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
handle_response(res)
end
El flag auth: importa acá. Mutations como createDraft necesitan el token, pero la query de listado para sincronizar es pública. Pasar auth: false omite el encabezado.
El flujo de publicación en dos pasos
La mutation createDraft de Hashnode crea un borrador. No se puede publicar directamente vía API en la misma llamada, es una mutation separada, publishDraft. Eso, de hecho, encaja bien con el flujo de esta herramienta:
- Importar a Hashnode → llama a
createDraft, guarda el ID y la URL del borrador, cambia el estado a:draft. Aparece un enlace “View draft →” en la interfaz. - Revisar en el editor de Hashnode: comprobar que la imagen de portada se renderizó, que la URL canónica está configurada, y que las tags se ven bien.
- Publicar: ya sea haciendo clic en “Publish” en la interfaz del blog manager (que llama a
publishDraftde forma síncrona), o publicando desde el editor de Hashnode y dejando que el job de sincronización diario lo detecte.
Sincronización por slug, no por ID
Esta fue la única decisión de diseño no obvia. El job de sincronización llama a publication(host:).posts para obtener la lista de posts publicados, y después la compara contra los borradores locales. La comparación es por slug, no por ID de borrador.
¿Por qué? La API de listado devuelve posts publicados. Una vez que se publica un borrador, la relación entre el ID del borrador y el post no queda clara en la respuesta de la API. Los slugs son estables, no cambian entre la creación del borrador y la publicación. Entonces:
remote_slugs = remote_posts.map { |p| p["slug"] }.to_set
@blog.posts
.where(hashnode_status: Post.hashnode_statuses[:draft])
.where(slug: remote_slugs.to_a)
.each { |post| post.update!(hashnode_status: :published, ...) }
Simple. Sin necesidad de cruzar IDs entre mutations y queries.
Tres columnas en Blog, no una
Medium necesita dos campos: token + author ID. Dev.to necesita uno: API key. Hashnode necesita tres:
hashnode_token: el PAT, encriptado vía Active Record Encryptionhashnode_publication_id: un UUID usado en las mutations (crear/publicar borradores)hashnode_publication_host: un hostname comousername.hashnode.dev, usado para la query pública de listado
La separación existe porque las dos operaciones de la API usan identificadores distintos. Las mutations necesitan el UUID opaco de la publicación. La query pública publication(host:) usa el hostname. No es posible usar uno en lugar del otro.
El anuncio de LinkedIn sigue disparando una sola vez
El servicio Syndication::LinkedInAnnounce corre después de cada job de sincronización; lo llaman Medium, Dev.to y Hashnode por igual. Encuentra posts que están publicados en cualquier plataforma pero todavía no anunciados en LinkedIn. La query de elegibilidad usa .or():
@blog.posts
.where(medium_status: :published)
.or(posts.where(devto_status: :published))
.or(posts.where(hashnode_status: :published))
.select { |p| p.linkedin_not_posted? || (p.linkedin_failed? && p.linkedin_attempts < 3) }
Un post publicado en las tres plataformas el mismo día aparece una sola vez en este conjunto; DISTINCT se encarga de eso a nivel SQL. Así que LinkedIn recibe un solo anuncio, no tres.
Qué cambiaría
El enfoque de tres columnas para las credenciales de Hashnode es un poco incómodo de explicar en la interfaz. Un tooltip ayudaría. También dejé afuera cualquier descubrimiento de publicación, hay que buscar el ID de publicación manualmente. Un equivalente a GET /users/me permitiría que la interfaz lo obtuviera al guardar el token, pero eso es un nice-to-have.
El próximo destino de sindicación en mi lista es newsletter (Substack o ConvertKit). Eso probablemente signifique repensar la interfaz de la fila por post. Se está poniendo ancha.
Lecturas relacionadas
Eliminar una integración de publicación que acababa de construir
Eliminar la integración nativa de Hashnode que nunca se usó: 31 archivos, +14/-1126, un grep que mintió un poco, y la pregunta de arquitectura que forzó la eliminació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.