Glossaire

Pourquoi Bluesky facets byteStart byteEnd comptent des octets, pas des caractères

Taras Shynkarenko
Taras Shynkarenko
Mis à jour : 10 min de lecture
Pourquoi Bluesky facets byteStart byteEnd comptent des octets, pas des caractèresPourquoi Bluesky facets byteStart byteEnd comptent des octets, pas des caractères

TL;DR, Réponse Rapide

10 min de lecture

Un facet est une entrée du tableau facets d'un enregistrement app.bsky.feed.post. Il possède un index, qui est un byteSlice composé de byteStart et byteEnd, et un tableau features contenant une uri de lien, un did de mention ou une chaîne tag. Les offsets comptent les octets du texte encodé en UTF-8, début inclus, fin exclue. Le lexicon lui-même prévient que des langages comme JavaScript indexent les chaînes en UTF-16, donc indexOf renvoie le mauvais nombre dès que votre texte contient un accent ou un emoji, et la publication obtenue reste valide au regard du schéma.

Que sont les facets de Bluesky, et que mesurent byteStart et byteEnd ?

Bluesky stocke le texte d'une publication sous forme de chaîne brute, sans balisage ni détection automatique des liens, si bien que les offsets Bluesky facets byteStart byteEnd sont la seule chose qui indique à un client quelle portion de cette chaîne est un lien, une mention ou un hashtag. Le lexicon app.bsky.richtext.facet décrit un facet comme une « Annotation of a sub-string within rich text. » Deux champs sont obligatoires sur chaque facet : index, qui est un #byteSlice, et features, un tableau d'annotations portant sur cette portion.

Voici la structure, tirée directement de la définition du lexicon :

{
  "index": { "byteStart": 17, "byteEnd": 36 },
  "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }]
}

La définition de byteSlice pose la règle de comptage et celle des bornes d'un coup : « Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. »

ChampTypeContrainte dans le lexiconSignification
index.byteStartintegerminimum: 0Premier octet de la plage annotée, inclus
index.byteEndintegerminimum: 0Octet suivant le dernier octet annoté, exclu
featuresarrayunion de #mention, #link, #tagUne ou plusieurs annotations sur cette plage d'octets

Relisez la colonne des contraintes. minimum: 0 est la seule règle numérique sur l'un ou l'autre offset. Aucune borne supérieure liée à la longueur de votre texte, aucune règle imposant que byteEnd dépasse byteStart, aucune règle exigeant que la plage s'arrête sur une frontière de caractère. Voilà pourquoi de mauvais offsets ne produisent aucune erreur.

Pourquoi mon lien Bluesky est-il publié en texte brut ?

Un lien s'affiche en texte brut parce que l'enregistrement de la publication ne contient aucun facet dont la plage d'octets couvre cette URL. L'URL présente dans le champ text ne fait rien toute seule. Les clients Bluesky ne parcourent pas le texte à la recherche de http://, ils lisent le tableau facets et annotent exactement les plages d'octets qu'il désigne.

L'échec est silencieux dans les deux sens. Omettez le facet et vous obtenez du texte brut. Envoyez un facet dont les offsets sont décalés de trois, et le client relie sagement les octets que vous avez désignés, c'est-à-dire peut-être la fin du mot précédent plus l'essentiel de l'URL. Dans les deux cas, com.atproto.repo.createRecord renvoie une réponse de succès normale, parce qu'un enregistrement aux offsets faux reste un enregistrement valide au regard du schéma.

Le lexicon laisse plusieurs comportements indéfinis, et mieux vaut le dire clairement que le deviner. app.bsky.richtext.facet ne précise pas ce qu'un moteur de rendu doit faire quand une plage d'octets dépasse la fin du texte, quand deux facets se chevauchent, ou quand une plage coupe un caractère multi-octet en deux. Il ne fixe pas non plus de maxLength sur le tableau facets dans app.bsky.feed.post, alors que le tableau voisin tags du même enregistrement est explicitement plafonné à maxLength: 8. Le plafond des tags est documenté, celui des facets, s'il en existe un en pratique, ne l'est pas.

Un développeur tape du code sur un ordinateur portable, le genre de correctif JavaScript rapide qui casse silencieusement les décalages d'octets sur du texte non ASCII.

Pourquoi JavaScript donne-t-il un byteStart erroné ?

JavaScript donne un byteStart erroné parce que String.prototype.indexOf et String.prototype.length comptent des unités de code UTF-16, tandis que les facets comptent des octets UTF-8. Le lexicon le dit dans une note que presque personne ne lit, parce qu'elle vit dans un fichier JSON plutôt que dans un tutoriel :

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.

Sur du texte purement ASCII, les deux systèmes s'accordent, et c'est précisément ce qui fait passer ce bug en production. Vous testez avec Opening hours https://example.com, chaque offset tombe juste, et le code part en production. Puis une publication récupère un mot accentué ou un emoji, et les offsets glissent.

Prenez cette chaîne :

Café 🥐 hours https://example.com

Trois systèmes de comptage s'en font une idée différente :

Unité de comptageValeur pour cette chaîneOù Bluesky l'utilise
Graphèmes32maxGraphemes: 300 sur text dans app.bsky.feed.post
Unités de code UTF-1633Nulle part. C'est ce que renvoie text.length en JavaScript
Octets UTF-836byteStart et byteEnd, plus maxLength: 3000 sur text

L'écart tient à deux caractères. é vaut une unité de code UTF-16 et deux octets UTF-8. 🥐 est une paire de substitution, soit deux unités de code UTF-16 et quatre octets UTF-8. Arrivé à l'URL, les deux systèmes d'index sont séparés de trois :

PréfixeUnités de code UTF-16Octets UTF-8
Café 56
Café 🥐 811
Café 🥐 hours 1417

Donc text.indexOf('https://example.com') renvoie 14, alors que le bon byteStart est 17. Utilisez le nombre de JavaScript et Bluesky annote les octets 14 à 32, qui se décodent en rs https://example. Le mot « hours » perd ses deux dernières lettres au profit du lien, le com final passe à côté, et personne ne reçoit d'erreur.

AdaptlyPost
AdaptlyPost

Essai gratuit de 7 jours

Analyses multiplateforme

Boîte sociale

Assistant IA

Comment un byteStart erroné passe inaperçu
Le texte contient des accents ou un emoji
indexOf compte des unités UTF-16, pas des octets
byteStart tombe sur le mauvais octet
L'enregistrement passe quand même la validation du schéma
Le lien pointe vers le mauvais texte
Rien dans cette chaîne ne déclenche d'erreur, le mauvais lien est donc publié sans avertissement.

Comment calculer byteStart et byteEnd correctement ?

Encodez le texte en octets UTF-8 avec TextEncoder, puis dérivez les deux offsets des longueurs encodées plutôt que des index de chaîne. Encoder le préfixe qui précède une correspondance donne byteStart ; encoder la correspondance elle-même donne la longueur à ajouter pour byteEnd.

Ceci tourne tel quel sur Node 18 ou plus récent et dans tout navigateur moderne :

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));

Sortie :

[
  {
    "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 clause de garde dans add est la partie à conserver. Elle redécoupe le tableau d'octets encodé, le décode et le compare au texte trouvé. Puisque le protocole ne vous dira jamais que la plage était fausse, cette assertion est le seul signal d'échec dont vous disposez, et elle coûte une comparaison par facet.

Deux détails de cet extrait viennent du lexicon et non du goût personnel. La feature tag porte bakery, pas #bakery, parce que le lexicon dit que la référence « should not » inclure le préfixe. Et features est un tableau, donc une même plage d'octets peut porter plus d'une annotation.

Que met-on dans le tableau features ?

Chaque entrée de features est l'un des trois membres d'union définis dans le même fichier de lexicon, distingués par leur chaîne $type.

Feature$typeChamp requisContrainteFormulation du lexicon
Mentionapp.bsky.richtext.facet#mentiondidformat: did« The text is usually a handle, including a '@' prefix, but the facet reference is a DID. »
Linkapp.bsky.richtext.facet#linkuriformat: uri« The text URL may have been simplified or truncated, but the facet reference should be a complete URL. »
Tagapp.bsky.richtext.facet#tagtagmaxLength: 640, maxGraphemes: 64« The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags'). »

Les mentions demandent une étape de plus. Le texte visible est un handle comme @example.com, mais le facet stocke un DID, vous résolvez donc d'abord le handle avec la requête com.atproto.identity.resolveHandle, qui prend un paramètre handle et renvoie un did. Sa seule erreur documentée est HandleNotFound, décrite ainsi : « The resolution process confirmed that the handle does not resolve to any DID. » Stocker un DID plutôt qu'un handle est ce qui garde une ancienne mention pointée sur le bon compte après que ce compte a changé de nom.

La formulation sur le lien compte pour le texte affiché. Comme le facet contient l'URL complète alors que le texte peut être tronqué, vous pouvez afficher example.com/very-long... dans la publication et envoyer quand même les gens à l'adresse complète, à condition que la plage d'octets couvre le texte raccourci que vous avez réellement écrit.

Pourquoi les anciens exemples de code utilisent-ils start et end ?

Les anciens exemples utilisent start et end parce que l'enregistrement de publication portait autrefois un champ différent avec d'autres règles de comptage, et les deux versions figurent encore aujourd'hui dans les fichiers de lexicon. app.bsky.feed.post définit un tableau entities marqué « DEPRECATED: replaced by app.bsky.richtext.facet. » Son type d'index est #textSlice, dont la description dit : « Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings. »

Le protocole a donc livré un type de slice UTF-16, l'a remplacé par un type UTF-8, et a gardé les deux définitions dans le schéma. Tout extrait qui construit { start, end } vise le champ obsolète, et recopier son arithmétique d'index dans un byteSlice reproduit exactement le bug contre lequel la NOTE plus récente met en garde. Vérifiez les noms de champs avant de faire confiance aux offsets : byteStart et byteEnd signifient des octets, start et end signifiaient des unités de code.

Une personne planifie des publications sur un ordinateur portable, le type de flux de travail qu'un outil de programmation doit traduire en plages d'octets de facet correctes.

Où un outil de planification intervient-il ?

Un planificateur qui publie sur Bluesky doit construire le tableau facets à votre place, parce que l'enregistrement qu'il écrit est le même app.bsky.feed.post que vous écririez à la main. AdaptlyPost planifie les publications Bluesky aux côtés des autres réseaux sur lesquels il publie, si bien que l'arithmétique des octets se fait une fois dans la couche de publication plutôt que dans chaque script que vous écrivez. La même publication peut partir vers plusieurs comptes et réseaux en une seule action, et l'engagement Bluesky atterrit ensuite dans les analyses Bluesky.

Rien de tout cela ne change le protocole. Le plafond de 300 graphèmes, celui de 3 000 octets et les offsets UTF-8 appartiennent à Bluesky, et s'appliquent de la même façon qu'un outil écrive l'enregistrement ou que vous le fassiez. Ce qu'un outil supprime, c'est le risque de livrer un byteStart décalé de trois sur la seule publication qui contenait un emoji.

Questions fréquentes

Ai-je besoin d'un facet si l'URL complète est déjà visible dans le texte ?

Oui. Bluesky n'extrait pas du tout les liens du texte d'une publication, donc une URL écrite en entier s'affiche en texte brut non cliquable tant qu'un facet doté d'une feature #link ne couvre pas sa plage d'octets. C'est la surprise la plus fréquente pour quiconque arrive d'une API qui crée les liens automatiquement.

byteEnd est-il l'index du dernier octet, ou celui d'après ?

Celui d'après. Le lexicon indique « Start index is inclusive, end index is exclusive, » ce qui veut dire que byteEnd moins byteStart égale la longueur exacte en octets du texte annoté. Pour https://example.com, dix-neuf caractères ASCII, la différence est 19.

AdaptlyPost
AdaptlyPost

Essai gratuit de 7 jours

Analyses multiplateforme

Boîte sociale

Assistant IA

Que se passe-t-il si byteStart et byteEnd sont faux ?

L'enregistrement est accepté. La seule contrainte numérique que le lexicon place sur l'un ou l'autre champ est minimum: 0, donc des offsets qui désignent les mauvais octets, ou qui dépassent la fin du texte, se valident quand même. Vous vous en apercevez en regardant la publication rendue, et c'est pour cela que la vérification par décodage et comparaison du code ci-dessus mérite d'être gardée.

Faut-il retirer le dièse d'un facet de type tag ?

Retirez-le. Le lexicon dit que le texte visible « usually includes a '#' prefix, but the facet reference should not, » avec une exception qu'il nomme « double hash tags ». La plage d'octets couvre toujours le # dans le texte ; seule la chaîne tag s'en passe.

Un même facet peut-il porter à la fois un lien et un tag ?

Oui. features est défini comme un tableau d'une union de #mention, #link et #tag, donc un même byteSlice peut contenir plus d'une annotation. Le lexicon ne dit pas comment un client doit rendre une plage portant deux features contradictoires, alors tenez-vous-en à une par plage tant que vous n'avez pas testé le résultat.

Combien de facets une publication peut-elle contenir ?

Le lexicon ne le dit pas. Le tableau facets dans app.bsky.feed.post n'a pas de maxLength, contrairement au tableau tags du même enregistrement, plafonné à maxLength: 8. Comme aucune limite n'est publiée, traitez le plafond pratique comme inconnu plutôt que de supposer qu'il n'y en a pas.

Quelle est la différence entre le champ obsolète entities et les facets ?

Le tableau entities dans app.bsky.feed.post est marqué comme obsolète au profit de app.bsky.richtext.facet, et utilisait un type d'index différent appelé #textSlice. textSlice comptait start et end en unités de code UTF-16, les mêmes unités que indexOf et length utilisent en JavaScript, alors que byteSlice compte des octets UTF-8. Les deux définitions figurent encore aujourd'hui dans les fichiers du lexicon, donc un exemple de code qui construit { start, end } vise le champ retiré, pas celui que lisent les clients Bluesky actuels.

Pourquoi un facet de mention Bluesky stocke-t-il un DID plutôt que le texte du handle ?

Le texte visible d'une mention est généralement un handle comme @example.com, mais le lexicon exige que la référence du facet soit un DID, résolu via la requête com.atproto.identity.resolveHandle. Un handle peut changer quand quelqu'un renomme son compte, mais un DID ne change pas, stocker le DID garde donc une ancienne mention pointée vers la bonne personne après un renommage. La seule erreur documentée de la requête, HandleNotFound, survient quand le processus de résolution confirme que le handle ne correspond à aucun DID.

Le texte du lien affiché peut-il différer de l'URL réelle ?

La fonctionnalité de lien conserve l'URL complète dans le facet même quand le texte affiché est raccourci. Le lexicon précise que l'URL du texte "peut avoir été simplifiée ou tronquée", mais que la référence du facet "doit être une URL complète". Cette séparation permet d'afficher quelque chose comme example.com/very-long... dans le post tout en gardant la plage d'octets pointée vers l'adresse complète, tant qu'elle couvre le texte raccourci réellement saisi.

Quelles sont les limites de caractères et d'octets d'un post Bluesky ?

Bluesky limite le texte d'un post à 300 graphèmes via maxGraphemes sur le champ text de app.bsky.feed.post, et séparément à 3 000 octets UTF-8 via maxLength. Ce sont deux unités de comptage sur le même champ : les graphèmes pour la limite d'affichage, les octets pour la limite de stockage, et byteStart/byteEnd utilisent ce même comptage d'octets. Un emoji ou un caractère accentué consomme davantage du budget d'octets que du budget de graphèmes, le même écart qui fait dériver les offsets byteStart.

Cet article vous a-t-il été utile ?

Dites-nous ce que vous en pensez !

Nous voir plus souvent sur Google

Un clic définit AdaptlyPost comme source préférée. Nos articles remontent alors dans vos À la une, en mode IA et dans les aperçus IA.

Avant de partir...

AdaptlyPost

AdaptlyPost

Planifiez vos contenus sur toutes les plateformes

Gérez tous vos comptes de réseaux sociaux en un seul endroit avec AdaptlyPost.

Analyses multiplateforme

Boîte sociale

Assistant IA

Termes connexes du glossaire

Articles Connexes