Glosario

Cómo funciona un generador de feeds de Bluesky, del lexicon al feed en vivo

Taras Shynkarenko
Taras Shynkarenko
Actualizado: 10 min de lectura
Cómo funciona un generador de feeds de Bluesky, del lexicon al feed en vivoCómo funciona un generador de feeds de Bluesky, del lexicon al feed en vivo

TL;DR, Respuesta Rápida

10 min de lectura

Un generador de feeds de Bluesky es un servicio web que implementa una sola consulta del AT Protocol, app.bsky.feed.getFeedSkeleton, y devuelve una lista de AT-URIs de publicaciones sin nada del contenido dentro. La AppView de Bluesky hidrata esas URIs hasta convertirlas en publicaciones reales antes de que el cliente las vea. El feed en sí se declara con un registro app.bsky.feed.generator en el repo de quien lo crea, que solo exige did, displayName y createdAt, y apunta al DID del servicio. El servicio demuestra que le pertenece ese DID con un .well-known/did.json que lleva una entrada de servicio de tipo BskyFeedGenerator. Las peticiones llegan con un JWT firmado con la clave de firma del repo de quien consulta, y limit tiene un tope de 100.

¿Qué es un generador de feeds de Bluesky?

Cada feed personalizado de la app de Bluesky lo sirve un generador de feeds de Bluesky, un servicio HTTPS sencillo que responde una sola consulta y devuelve nada más que una lista de direcciones de publicaciones. No guarda el texto de ninguna publicación, ni avatares, ni recuentos de me gusta. Ordena, y ya está.

El kit de inicio oficial lo dice sin rodeos: "the server receives a request from a user's server and returns a list of post URIs with some optional metadata attached. Those posts are then hydrated into full views by the requesting server and sent back to the client."

Ese reparto es todo el diseño. Tu servicio decide qué publicaciones y en qué orden. La infraestructura de Bluesky convierte las direcciones en publicaciones renderizables.

¿Qué lexicon implementa un generador de feeds?

Exactamente uno: app.bsky.feed.getFeedSkeleton. El lexicon lo describe como "Get a skeleton of a feed provided by a feed generator. Auth is optional, depending on provider requirements, and provides the DID of the requester. Implemented by Feed Generator Service."

Cuatro lexicons rodean el flujo, y saber qué lado implementa cuál te ahorra un día de depuración:

LexiconTipoLo implementaPara qué sirve
app.bsky.feed.generatorrecordel repo de quien crea el feedDeclara que el feed existe y nombra el DID del servicio
app.bsky.feed.getFeedSkeletonquerytu generador de feedsDevuelve la lista ordenada de URIs de publicaciones
app.bsky.feed.describeFeedGeneratorquerytu generador de feedsEnumera qué URIs de feed sirve este servicio
app.bsky.feed.getFeedqueryla AppView de BlueskyDevuelve el feed hidratado que el cliente renderiza

describeFeedGenerator no es un método de la AppView, y así está dicho: "Does not require auth; implemented by Feed Generator services (not App View)." Si lo llamas en la AppView, recibes {"error":"MethodNotImplemented","message":"Method Not Implemented"}, mientras que la misma llamada al servicio Discover de Bluesky en discover.bsky.app devuelve todas las URIs de feed que ese servicio aloja.

¿Qué va en el registro app.bsky.feed.generator?

El registro es lo que hace aparecer el feed en la app, y vive en tu propio repo y no en tu servidor. El lexicon lo llama un "Record declaring of the existence of a feed generator, and containing metadata about it. The record can exist in any repository."

CampoTipoObligatorioRestricción
didstring, format: didEl DID del servicio que sirve el feed
displayNamestringmaxGraphemes: 24, maxLength: 240
createdAtstring, format: datetime
descriptionstringnomaxGraphemes: 300, maxLength: 3000
descriptionFacetsarraynoentradas de app.bsky.richtext.facet
avatarblobnoaccept: image/png, image/jpeg, maxSize: 1000000
acceptsInteractionsbooleannoApunta el feed a app.bsky.feed.sendInteractions
contentModestringnocontentModeUnspecified o contentModeVideo

Tres campos son obligatorios y ninguno de ellos es el algoritmo. Fíjate en el tope de 24 grafemas de displayName, lo bastante corto para que la mayoría de los nombres de feed se recorten antes de que lo notes, y en el techo de 1.000.000 de bytes del avatar. La distinción entre grafema y byte que hay detrás de esos números es la misma con la que la gente tropieza en los offsets de bytes de los facets de Bluesky.

contentMode es el único campo que cambia el renderizado. app.bsky.feed.defs#contentModeVideo es un token que "Declares the feed generator returns posts containing app.bsky.embed.video embeds," y pasa el cliente al reproductor de vídeo a pantalla completa.

El rkey del registro se convierte en el identificador público del feed. El propio feed Discover de Bluesky se direcciona como at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot, donde el DID pertenece a la cuenta bsky.app y whats-hot es la clave del registro.

¿Cómo encuentra Bluesky mi servicio?

A través de un documento DID con una entrada de servicio de tipo BskyFeedGenerator. El registro le da a Bluesky un DID. Resolver ese DID le da a Bluesky una URL.

El kit de inicio lo monta como un did:web, así que tu propio dominio sirve el documento en /.well-known/did.json. El servicio Discover de Bluesky en producción hace lo mismo, y puedes descargarlo:

{
  "id": "did:web:discover.bsky.app",
  "service": [
    {
      "id": "#bsky_fg",
      "type": "BskyFeedGenerator",
      "serviceEndpoint": "https://discover.bsky.app"
    }
  ]
}

El id #bsky_fg y el type BskyFeedGenerator son cadenas fijas las dos. Equivócate en cualquiera de ellas y el feed no resuelve a nada.

Dos restricciones de alojamiento vienen del README y no de ninguna especificación. "Your feed will need to be accessible at the value supplied to the FEEDGEN_HOSTNAME environment variable," y "The service must be set up to respond to HTTPS queries over port 443." HTTP a secas no es una opción, y un puerto no estándar tampoco.

El kit de inicio también señala una trampa de migración que conviene atender antes de elegir dominio: puede que quieras did:plc en vez de did:web "if you expect this Feed Generator to be long-standing and possibly migrating domains." Un did:web es tu nombre de host. Cambia de alojamiento y se rompe todo registro publicado que apunte a él. Un did:plc sobrevive a la mudanza.

AdaptlyPost
AdaptlyPost

Prueba gratis de 7 días

Analíticas multiplataforma

Bandeja Social

Asistente con IA

Un rack de servidores, en representación del servicio HTTPS simple que responde a una petición getFeedSkeleton con una lista de URIs de publicaciones.

¿Qué devuelve getFeedSkeleton?

Un cursor y un array de entradas app.bsky.feed.defs#skeletonFeedPost, cada una de las cuales es una AT-URI más contexto opcional. Esta es una respuesta real del servicio Discover de Bluesky, recortada a dos elementos:

{
  "cursor": "eyJvIjoiMjAyNi0wOS0xMVQxMDo0OTowNi42NTI4NDk4NzVaIiwibiI6IjIwMjYtMDktMTFUMjI6NDk6MDYuNjUyODQ5ODc1WiIsInNzIjoiMjAyNi0wOS0xMVQyMjo0OTowNi42NTI4NDk4NzVaIiwibHUiOiIyMDI2LTA5LTExVDIyOjQ5OjA2LjY1NjYyMzMwM1oiLCJwZyI6MSwibSI6IkFRQUFBQUFBQUFBSkFBQUFPakFBQUFJQUFBRHNWZ0FBa1ZjQUFCZ0FBQUFhQUFBQUtMTTNrUT09In0=",
  "feed": [
    {
      "feedContext": "th-nature_animals_pets",
      "post": "at://did:plc:mpvivcve3y2fdlw7pmq7niwz/app.bsky.feed.post/3mvb4r2qh4k2r"
    },
    {
      "feedContext": "th-science_research",
      "post": "at://did:plc:fb57h2fswm7s2qqzxflizlk7/app.bsky.feed.post/3mvasir35ec2e"
    }
  ]
}

En cada entrada solo post es obligatorio. feedContext es opcional y tiene un tope de maxLength: 2000, descrito en el lexicon como "Context that will be passed through to client and may be passed to feed generator back alongside interactions." Bluesky lo usa para etiquetas de tema, como muestra la cadena th-nature_animals_pets de arriba. La respuesta puede llevar además reqId, un "Unique identifier per request," con un tope de 100 caracteres.

El parámetro limit tiene un suelo y un techo documentados: minimum: 1, maximum: 100, default: 50, y la AppView los hace cumplir. Pedir 101 devuelve:

{ "error": "InvalidRequest", "message": "Invalid app.bsky.feed.getFeed params: integer too big (maximum 100, got 101)" }

La paginación se apoya en un cursor opaco que inventa tu servicio. El kit de inicio es concreto sobre la forma que quiere: "We strongly encourage that the cursor be unique per feed item to prevent unexpected behavior in pagination. We recommend, for instance, a compound cursor with a timestamp + a CID."

¿Cómo se autentica la petición?

Con un JWT firmado con la clave de firma del repo de quien consulta, y el kit de inicio detalla tanto la cabecera como el payload:

const header = {
  type: 'JWT',
  alg: 'ES256K', // (key algorithm) - in this case secp256k1
};
const payload = {
  iss: 'did:example:alice', // (issuer) the requesting user's DID
  aud: 'did:example:feedGenerator', // (audience) the DID of the Feed Generator
  exp: 1683643619, // (expiration) unix timestamp in seconds
};

Verificarlo es opcional, y la recomendación depende de lo que haga tu feed: "If you are creating a generic feed that does not differ for different users, you do not need to check auth. But if a user's state (such as follows or likes) is taken into account, we strongly encourage you to validate their auth token." Un feed que personaliza según a quién sigues sin comprobar el iss es un feed que cualquiera puede consultar haciéndose pasar por cualquiera.

El propio servicio Discover de Bluesky responde peticiones getFeedSkeleton sin autenticar con un esqueleto real, una forma barata de poner a prueba lo que has entendido del endpoint.

Un desarrollador programando en un portátil, el tipo de trabajo detrás de suscribirse al firehose y filtrar publicaciones para un feed.

¿Debe el feed verificar el JWT?
Mismo feed para todos
  • Sin follows, sin likes, sin estado por usuario
  • La comprobación de auth es opcional
Clasifica por follows o likes
  • El ranking lee el DID de iss
  • Sin verificación, cualquiera puede consultar como cualquiera
Un feed que solo clasifica por interacción puede saltarse la comprobación del JWT; uno que lee los follows o likes de un usuario no puede.

¿De dónde salen las publicaciones?

Del firehose, en casi toda implementación real: "For most use cases, we recommend subscribing to the firehose at com.atproto.sync.subscribeRepos. This websocket will send you every record that is published on the network."

Como un generador de feeds nunca sirve contenido, puede tirar casi todo eso. "Unless your algorithm is intended to provide posts you missed or something similar, you can likely garbage collect any data that is older than 48 hours."

Las tres formas que esboza el kit de inicio cubren la mayoría de los feeds que la gente construye. Un feed de lo más popular cuenta los me gusta por publicación y devuelve las recientes por encima de un umbral. Un feed de comunidad compila una lista de DIDs y devuelve todo lo que publican. Un feed temático pasa el texto de la publicación por un buscador de palabras clave o por un modelo. El firehose convive con los demás límites de tasa de la API de Bluesky dentro de los que vas a trabajar.

¿Qué dejan sin definir los docs?

Varias cosas, y esos huecos son donde los generadores de feeds se rompen en producción en lugar de en pruebas.

El error declarado no coincide con el error que llega. Tanto getFeedSkeleton como getFeed declaran exactamente un error en sus lexicons, UnknownFeed. Pídele a la AppView un feed que no existe y recibes {"error":"InvalidRequest","message":"could not find feed"}. Pídeselo al propio servicio Discover de Bluesky y recibes {"message":"no such feed: \"nope-not-real\""}, sin campo error alguno. Dos formas en producción, y ninguna es el nombre documentado.

El kit de inicio es más viejo que el lexicon que describe. Su sección sobre los metadatos del esqueleto dice "for now, the only defined reason is a repost, but this is open to extension." El skeletonFeedPost actual tiene una unión reason de dos miembros, #skeletonReasonRepost y #skeletonReasonPin. Si programas contra el tipo Reason del README, no vas a enterarte de que existe el fijado.

AdaptlyPost
AdaptlyPost

Prueba gratis de 7 días

Analíticas multiplataforma

Bandeja Social

Asistente con IA

La historia de la hidratación también se ha movido. El README dice "In the future, the PDS will hydrate the feed with the help of an App View, but for now, the PDS handles hydration itself," mientras que el lexicon de getFeed ahora dice sin más "Implemented by App View."

No hay ningún límite publicado sobre el esqueleto en sí. limit topa una petición en 100 elementos, pero el array feed de la respuesta no lleva maxLength, y nada indica qué pasa si un generador devuelve más de los pedidos. getFeedGenerator devuelve isOnline e isValid, descritos como si el servicio "has been online recently, or else seems to be inactive" y si "is compatible with the record declaration." Ni "recently" ni "compatible" están definidos en ninguna parte.

¿Cómo publico el feed?

Escribiendo el registro con com.atproto.repo.putRecord en tu propio repo. El script de publicación del kit de inicio hace justo eso, con collection puesto en app.bsky.feed.generator, el rkey puesto en el nombre corto que pasa a ser la URL del feed, y did puesto en did:web:$FEEDGEN_HOSTNAME salvo que hayas indicado un DID de servicio propio. Cambiar el nombre, la descripción o el avatar es la misma llamada con el mismo rkey. No hay cola de aprobación.

Nada de esto sustituye a publicar como siempre. Un generador de feeds decide qué ve quien lee; meter tus propias publicaciones en la red sigue siendo programar publicaciones en Bluesky mediante una escritura de registro normal, que AdaptlyPost gestiona junto a las demás redes en las que publica, con la interacción aterrizando después en las analíticas de Bluesky.

Preguntas frecuentes

¿Necesito un servidor para ejecutar un generador de feeds de Bluesky?

Sí. El registro del feed vive en tu repo, pero a getFeedSkeleton tiene que responder un servicio que alojes tú, por HTTPS en el puerto 443 y en el nombre de host al que apunta tu documento DID. No hay forma de definir un feed personalizado únicamente dentro de Bluesky.

¿Puede un generador de feeds ver el contenido de las publicaciones?

Solo lo que recopile él mismo, normalmente del firehose. El esqueleto que devuelve lleva AT-URIs y nada más, y la hidratación ocurre en el lado de Bluesky después de que tu servicio haya respondido.

¿Cuál es la diferencia entre getFeed y getFeedSkeleton?

getFeedSkeleton es tuyo y devuelve entradas skeletonFeedPost, que son URIs a secas. getFeed es de la AppView y devuelve entradas feedViewPost, que son publicaciones completas con perfiles de autor y recuentos. Los clientes llaman a getFeed. Tu servicio nunca.

¿Cómo sé si mi feed está roto?

Llama a app.bsky.feed.getFeedGenerator con la AT-URI de tu feed. Devuelve isOnline e isValid junto a la vista del feed. Bluesky no publica cuán recientemente debe haber respondido un servicio para contar como online, así que trata un false como un aviso para revisar tus propios logs.

¿Puedo cobrar por un feed o restringir quién lo ve?

El protocolo te da el gancho y ninguna facturación. La autenticación es opcional en getFeedSkeleton, y cuando está presente el JWT lleva el DID de quien consulta en la reclamación iss, así que filtrar por una lista de DIDs es directo. El cobro, y cómo se le comunica el rechazo a alguien, quedan enteramente en manos de tu servicio.

¿Cómo mido el rendimiento de un feed?

Bluesky publica un solo número sobre un feed, el likeCount de su generatorView. Todo lo demás sobre cómo rinde el feed tienes que registrarlo tú, a partir de las peticiones que llegan a tu propio servicio. Esa carencia es la misma que se trata en ¿tiene Bluesky analíticas?.

¿Qué ocurre si un cliente pide más de 100 elementos a un feed?

La AppView impone ese tope ella misma. Pedir un limit de 101 a getFeed devuelve un error InvalidRequest que dice que el entero es demasiado grande, con un máximo de 100. El parámetro limit tiene un mínimo de 1, un máximo de 100 y un valor por defecto de 50, así que un generador de feeds nunca tiene que construir ese rechazo por su cuenta.

¿Por qué el starter kit recomienda did:plc en vez de did:web para algunos generadores de feeds?

Un did:web es literalmente tu nombre de dominio, así que cambiar de dominio rompe todos los registros publicados que apuntan a él. Un did:plc sobrevive a una mudanza, por eso el starter kit lo recomienda para cualquier feed pensado para durar o con posibilidad de migrar.

¿Cómo debería construirse el cursor de paginación de un generador de feeds?

El lexicon declara cursor como un simple string sin restricciones, y el starter kit llama al valor opaco y enteramente a elección del generador. El starter kit recomienda un cursor compuesto, una marca de tiempo junto a un CID, e insiste en que sea único por elemento del feed para evitar fallos de paginación.

¿Qué hace acceptsInteractions en el registro app.bsky.feed.generator?

Poner acceptsInteractions en true inscribe el feed en app.bsky.feed.sendInteractions, permitiendo que los clientes informen con qué publicaciones interactuó un usuario. Es un campo opcional y booleano, y nada en la documentación lo exige para que un feed funcione.

¿Te resultó útil este artículo?

¡Cuéntanos qué te parece!

Vernos más en Google

Un clic marca AdaptlyPost como fuente preferida y nuestros artículos aparecen más arriba en tus Noticias destacadas, el modo IA y los resúmenes con IA.

Antes de irte...

AdaptlyPost

AdaptlyPost

Programa tu contenido en todas las plataformas

Gestiona todas tus cuentas de redes sociales en un solo lugar con AdaptlyPost.

Analíticas multiplataforma

Bandeja Social

Asistente con IA

Términos relacionados del glosario

Artículos Relacionados