TL;DR, Respuesta Rápida
10 min de lecturaUn facet es una entrada del array facets de un registro app.bsky.feed.post. Tiene un index, que es un byteSlice de byteStart y byteEnd, y un array features con una uri de enlace, un did de mención o una cadena tag. Los offsets cuentan bytes del texto codificado en UTF-8, inicio incluido, fin excluido. El propio lexicon avisa de que lenguajes como JavaScript indexan cadenas por UTF-16, así que indexOf devuelve el número equivocado en cuanto tu texto lleva un acento o un emoji, y la publicación resultante sigue siendo válida según el esquema.
¿Qué son los facets de Bluesky y qué miden byteStart y byteEnd?
Bluesky guarda el texto de la publicación como una cadena plana, sin marcado y sin detección automática de enlaces, así que los offsets Bluesky facets byteStart byteEnd son lo único que le dice a un cliente qué tramo de esa cadena es un enlace, una mención o un hashtag. El lexicon app.bsky.richtext.facet describe un facet como una "Annotation of a sub-string within rich text." Todo facet requiere dos campos: index, que es un #byteSlice, y features, un array de anotaciones que se aplican a ese tramo.
Esta es la forma, tomada directamente de la definición del lexicon:
{
"index": { "byteStart": 17, "byteEnd": 36 },
"features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }]
}La definición de byteSlice fija la regla de conteo y la de los límites a la vez: "Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text."
| Campo | Tipo | Restricción en el lexicon | Qué significa |
|---|---|---|---|
index.byteStart | integer | minimum: 0 | Primer byte del rango anotado, incluido |
index.byteEnd | integer | minimum: 0 | Byte posterior al último byte anotado, excluido |
features | array | unión de #mention, #link, #tag | Una o más anotaciones sobre ese rango de bytes |
Vuelve a leer la columna de restricciones. minimum: 0 es la única regla numérica sobre cualquiera de los dos offsets. No hay límite superior ligado a la longitud de tu texto, ni regla que obligue a que byteEnd supere a byteStart, ni regla que exija que el rango termine en un límite de carácter. Por eso unos offsets erróneos no producen ningún error.
¿Por qué mi enlace de Bluesky se publica como texto plano?
Un enlace se muestra como texto plano porque el registro de la publicación no contiene ningún facet cuyo rango de bytes cubra esa URL. La URL que está en el campo text no hace nada por sí sola. Los clientes de Bluesky no rastrean el texto en busca de http://, leen el array facets y anotan exactamente los rangos de bytes que allí se indican.
El fallo es silencioso en ambas direcciones. Si omites el facet, obtienes texto plano. Si envías un facet con offsets desviados en tres, el cliente enlaza obedientemente los bytes que le señalaste, que pueden ser la cola de la palabra anterior más casi toda la URL. En ambos casos com.atproto.repo.createRecord devuelve una respuesta de éxito normal, porque un registro con offsets equivocados sigue siendo un registro válido según el esquema.
El lexicon deja varios comportamientos sin definir, y conviene decirlo sin rodeos en lugar de suponer. app.bsky.richtext.facet no especifica qué debe hacer un renderizador cuando un rango de bytes se pasa del final del texto, cuando dos facets se solapan o cuando un rango parte por la mitad un carácter multibyte. Tampoco fija ningún maxLength para el array facets de app.bsky.feed.post, mientras que el array hermano tags del mismo registro está limitado explícitamente a maxLength: 8. El límite de tags está documentado; el de facets, si existe en la práctica, no.

¿Por qué JavaScript devuelve un byteStart incorrecto?
JavaScript devuelve un byteStart incorrecto porque String.prototype.indexOf y String.prototype.length cuentan unidades de código UTF-16, y los facets cuentan bytes UTF-8. El lexicon lo dice en una nota que casi nadie lee, porque vive dentro de un archivo JSON y no en un tutorial:
NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.
Con texto ASCII puro los dos sistemas coinciden, y eso es justo lo que hace que este error llegue a producción. Pruebas con Opening hours https://example.com, cada offset cuadra y el código sale a producción. Luego una publicación incorpora una palabra acentuada o un emoji y los offsets se desplazan.
Toma esta cadena:
Café 🥐 hours https://example.comTres sistemas de conteo no se ponen de acuerdo sobre ella:
| Unidad de conteo | Valor para esta cadena | Dónde la usa Bluesky |
|---|---|---|
| Grafemas | 32 | maxGraphemes: 300 sobre text en app.bsky.feed.post |
| Unidades de código UTF-16 | 33 | En ningún sitio. Es lo que devuelve text.length en JavaScript |
| Bytes UTF-8 | 36 | byteStart y byteEnd, más maxLength: 3000 sobre text |
La divergencia viene de dos caracteres. é es una unidad de código UTF-16 y dos bytes UTF-8. 🥐 es un par suplente, o sea dos unidades de código UTF-16 y cuatro bytes UTF-8. Al llegar a la URL, los dos sistemas de índices se separan en tres:
| Prefijo | Unidades de código UTF-16 | Bytes UTF-8 |
|---|---|---|
Café | 5 | 6 |
Café 🥐 | 8 | 11 |
Café 🥐 hours | 14 | 17 |
Así que text.indexOf('https://example.com') devuelve 14, y el byteStart correcto es 17. Si usas el número de JavaScript, Bluesky anota los bytes del 14 al 32, que se decodifican como rs https://example. La palabra "hours" pierde sus dos últimas letras a manos del enlace, el com final queda fuera y nadie recibe ningún error.
AdaptlyPost
Empieza tu prueba gratis de 7 días
Analíticas multiplataforma
Bandeja Social
Asistente con IA
¿Cómo calculo byteStart y byteEnd correctamente?
Codifica el texto a bytes UTF-8 con TextEncoder y deriva ambos offsets de las longitudes codificadas en lugar de los índices de cadena. Codificar el prefijo anterior a una coincidencia te da byteStart; codificar la coincidencia misma te da la longitud que hay que sumar para byteEnd.
Esto funciona tal cual en Node 18 o posterior y en cualquier navegador moderno:
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function detectFacets(text) {
const bytes = encoder.encode(text);
const facets = [];
const add = (match, feature) => {
const byteStart = encoder.encode(text.slice(0, match.index)).length;
const byteEnd = byteStart + encoder.encode(match[0]).length;
if (decoder.decode(bytes.slice(byteStart, byteEnd)) !== match[0]) {
throw new Error(`byte range ${byteStart}..${byteEnd} does not cover "${match[0]}"`);
}
facets.push({ index: { byteStart, byteEnd }, features: [feature] });
};
for (const m of text.matchAll(/https?:\/\/[^\s]+/g)) {
add(m, { $type: 'app.bsky.richtext.facet#link', uri: m[0] });
}
for (const m of text.matchAll(/#([A-Za-z0-9_]+)/g)) {
add(m, { $type: 'app.bsky.richtext.facet#tag', tag: m[1] });
}
return facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
}
console.log(JSON.stringify(detectFacets('Café 🥐 hours https://example.com #bakery'), null, 2));Salida:
[
{
"index": { "byteStart": 17, "byteEnd": 36 },
"features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }]
},
{
"index": { "byteStart": 37, "byteEnd": 44 },
"features": [{ "$type": "app.bsky.richtext.facet#tag", "tag": "bakery" }]
}
]La cláusula de guarda dentro de add es la parte que merece la pena conservar. Recorta el array de bytes codificado, lo decodifica y lo compara con el texto que se encontró. Como el protocolo nunca te va a avisar de que el rango estaba mal, esta comprobación es la única señal de fallo que tienes, y cuesta una comparación por facet.
Dos detalles de ese fragmento vienen del lexicon, no del gusto personal. El feature tag lleva bakery, no #bakery, porque el lexicon dice que la referencia "should not" incluir el prefijo. Y features es un array, así que un mismo rango de bytes puede llevar más de una anotación.
¿Qué va dentro del array features?
Cada entrada de features es uno de los tres miembros de la unión definidos en el mismo archivo del lexicon, distinguidos por su cadena $type.
| Feature | $type | Campo obligatorio | Restricción | Texto del lexicon |
|---|---|---|---|---|
| Mention | app.bsky.richtext.facet#mention | did | format: did | "The text is usually a handle, including a '@' prefix, but the facet reference is a DID." |
| Link | app.bsky.richtext.facet#link | uri | format: uri | "The text URL may have been simplified or truncated, but the facet reference should be a complete URL." |
| Tag | app.bsky.richtext.facet#tag | tag | maxLength: 640, maxGraphemes: 64 | "The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags')." |
Las menciones llevan un paso extra. El texto visible es un handle como @example.com, pero el facet almacena un DID, así que primero resuelves el handle con la consulta com.atproto.identity.resolveHandle, que recibe un parámetro handle y devuelve un did. Su único error documentado es HandleNotFound, descrito como "The resolution process confirmed that the handle does not resolve to any DID." Guardar un DID en lugar de un handle es lo que mantiene una mención antigua apuntando a la cuenta correcta después de que esa cuenta se cambie el nombre.
El texto sobre el enlace importa para lo que se muestra. Como el facet guarda la URL completa mientras que el texto puede estar truncado, puedes mostrar example.com/very-long... en la publicación y aun así llevar a la gente a la dirección completa, siempre que el rango de bytes cubra el texto acortado que escribiste de verdad.
¿Por qué los ejemplos de código antiguos usan start y end?
Los ejemplos antiguos usan start y end porque el registro de la publicación llevaba antes otro campo con reglas de conteo distintas, y las dos versiones siguen hoy en los archivos del lexicon. app.bsky.feed.post define un array entities marcado como "DEPRECATED: replaced by app.bsky.richtext.facet." Su tipo de índice es #textSlice, cuya propia descripción dice: "Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings."
O sea que el protocolo publicó un tipo de slice UTF-16, lo sustituyó por uno UTF-8 y dejó las dos definiciones en el esquema. Cualquier fragmento que construya { start, end } apunta al campo obsoleto, y copiar su aritmética de índices a un byteSlice reproduce exactamente el error del que avisa la NOTE más reciente. Comprueba los nombres de los campos antes de fiarte de los offsets: byteStart y byteEnd significan bytes; start y end significaban unidades de código.

¿Dónde encaja una herramienta de programación aquí?
Un programador de publicaciones que publique en Bluesky tiene que construir el array facets por ti, porque el registro que escribe es el mismo app.bsky.feed.post que escribirías a mano. AdaptlyPost programa publicaciones de Bluesky junto a las demás redes en las que publica, de modo que la aritmética de bytes ocurre una vez en la capa de publicación y no en cada script que escribas. La misma publicación puede salir a varias cuentas y redes en una sola acción, y la interacción de Bluesky aterriza después en las analíticas de Bluesky.
Nada de eso cambia el protocolo. El límite de 300 grafemas, el de 3.000 bytes y los offsets UTF-8 pertenecen a Bluesky, y se aplican igual tanto si escribe el registro una herramienta como si lo escribes tú. Lo que una herramienta elimina es la posibilidad de publicar un byteStart desviado en tres justo en la publicación que llevaba un emoji.
Preguntas frecuentes
¿Necesito un facet si la URL completa ya se ve en el texto de la publicación?
Sí. Bluesky no extrae enlaces del texto de la publicación en absoluto, así que una URL escrita entera se muestra como texto plano no clicable salvo que un facet con un feature #link cubra su rango de bytes. Es la sorpresa más común para quien llega de una API que enlaza automáticamente.
¿byteEnd es el índice del último byte o el del siguiente?
El del siguiente. El lexicon dice "Start index is inclusive, end index is exclusive," lo que significa que byteEnd menos byteStart equivale a la longitud exacta en bytes del texto anotado. Para https://example.com, diecinueve caracteres ASCII, la diferencia es 19.
AdaptlyPost
Empieza tu prueba gratis de 7 días
Analíticas multiplataforma
Bandeja Social
Asistente con IA
¿Qué pasa si byteStart y byteEnd son incorrectos?
El registro se acepta. La única restricción numérica que el lexicon impone a cualquiera de los dos campos es minimum: 0, así que unos offsets que apunten a los bytes equivocados, o más allá del final del texto, siguen validando. Te enteras mirando la publicación renderizada, y por eso la comprobación de decodificar y comparar del código de arriba merece la pena.
¿Debo quitar la almohadilla de un facet de tipo tag?
Quítala. El lexicon dice que el texto visible "usually includes a '#' prefix, but the facet reference should not," con una salvedad que llama "double hash tags." El rango de bytes sigue cubriendo el # del texto; solo la cadena tag lo omite.
¿Puede un mismo facet llevar un enlace y una etiqueta?
Sí. features está definido como un array de una unión de #mention, #link y #tag, así que un mismo byteSlice puede contener más de una anotación. El lexicon no dice cómo debe renderizar un cliente un rango con dos features en conflicto, así que quédate con una por rango salvo que hayas probado el resultado.
¿Cuántos facets puede tener una sola publicación?
El lexicon no lo dice. El array facets de app.bsky.feed.post no tiene maxLength, a diferencia del array tags del mismo registro, limitado a maxLength: 8. Como no hay ningún límite publicado, trata el techo práctico como desconocido en lugar de dar por hecho que no existe.
¿Cuál es la diferencia entre el campo obsoleto entities y facets?
El array entities en app.bsky.feed.post está marcado como obsoleto en favor de app.bsky.richtext.facet, y usaba un tipo de índice distinto llamado #textSlice. textSlice contaba start y end en unidades de código UTF-16, las mismas unidades que usan indexOf y length en JavaScript, mientras que byteSlice cuenta bytes UTF-8. Ambas definiciones siguen presentes en los archivos del lexicon, así que un ejemplo de código que construye { start, end } apunta al campo retirado, no al que leen los clientes de Bluesky ahora.
¿Por qué un facet de mención en Bluesky guarda un DID en lugar del texto del handle?
El texto visible de una mención suele ser un handle como @example.com, pero el lexicon exige que la referencia del facet sea un DID, resuelto mediante la consulta com.atproto.identity.resolveHandle. Un handle puede cambiar cuando alguien renombra su cuenta, pero un DID no cambia, así que guardar el DID mantiene una mención antigua apuntando a la persona correcta después de un cambio de nombre. El único error documentado de la consulta, HandleNotFound, aparece cuando el proceso de resolución confirma que el handle no resuelve a ningún DID.
¿El texto del enlace que se muestra puede ser distinto de la URL real?
La función de enlace conserva la URL completa en el facet aunque el texto mostrado esté acortado. El lexicon indica que la URL del texto "puede haberse simplificado o truncado", pero que la referencia del facet "debe ser una URL completa". Esa separación permite mostrar algo como example.com/very-long... en la publicación mientras el rango de bytes sigue llevando a la dirección completa, siempre que cubra el texto acortado que se escribió de verdad.
¿Qué límites de caracteres y bytes tiene una publicación de Bluesky?
Bluesky limita el texto de la publicación a 300 grafemas mediante maxGraphemes en el campo text de app.bsky.feed.post, y por separado a 3.000 bytes UTF-8 mediante maxLength. Son dos unidades de conteo sobre el mismo campo: grafemas para el límite de visualización, bytes para el límite de almacenamiento, y byteStart/byteEnd usan ese mismo conteo de bytes. Un emoji o un carácter acentuado consume más del presupuesto de bytes que del de grafemas, la misma brecha que hace tropezar a los offsets de byteStart.
Ponlo en práctica con AdaptlyPost
¿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
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


Cómo funciona un generador de feeds de Bluesky, del lexicon al feed en vivo
Un generador de feeds de Bluesky es un servicio HTTPS que responde una consulta XRPC. Los lexicons, la entrada del documento DID, el JWT y los huecos.


Cómo publicar un GIF en Bluesky que siga en movimiento
Cómo llega un GIF en Bluesky a un post: el selector de KLIPY, subidas que la app pasa a vídeo, embeds de imagen con un fotograma y qué envía AdaptlyPost.


Cómo un threadgate de Bluesky decide quién puede responder a una publicación
Con un threadgate de Bluesky solo responden los mencionados, tus seguidores, a quien sigues o una lista. Cómo funciona el registro y el postgate de citas.
Artículos Relacionados


Cinco contadores de interacción: ¿Bluesky tiene analíticas?
Respuesta corta a si Bluesky tiene analíticas: no. El lexicon da a postView cinco contadores de interacción, sin vistas, impresiones ni endpoint.


Meta documenta cuatro límites distintos para scheduled_publish_time de Facebook
El campo scheduled_publish_time de Facebook exige una hora al menos 10 minutos por delante, y Meta le da cuatro máximos: 29 días, 30 días, 75 días y 6 meses.


Horas que se saltan, horas que se repiten y cómo gestionan las zonas horarias los programadores de redes sociales
Así es cómo gestionan las zonas horarias los programadores de redes sociales: UTC más una zona IANA, horas saltadas y repetidas, y formatos de cada API.

