Saltar al contenido
Development

Publicación cruzada en Hashnode dentro del gestor de blog

Por Victor Da Luz
railsrubyhashnodegraphqldev-logblog-manager

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:

  1. 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.
  2. 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.
  3. Publicar: ya sea haciendo clic en “Publish” en la interfaz del blog manager (que llama a publishDraft de 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 Encryption
  • hashnode_publication_id: un UUID usado en las mutations (crear/publicar borradores)
  • hashnode_publication_host: un hostname como username.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