TL;DR, Réponse Rapide
10 min de lectureUn 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. »
| Champ | Type | Contrainte dans le lexicon | Signification |
|---|---|---|---|
index.byteStart | integer | minimum: 0 | Premier octet de la plage annotée, inclus |
index.byteEnd | integer | minimum: 0 | Octet suivant le dernier octet annoté, exclu |
features | array | union de #mention, #link, #tag | Une 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.

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 comptage | Valeur pour cette chaîne | Où Bluesky l'utilise |
|---|---|---|
| Graphèmes | 32 | maxGraphemes: 300 sur text dans app.bsky.feed.post |
| Unités de code UTF-16 | 33 | Nulle part. C'est ce que renvoie text.length en JavaScript |
| Octets UTF-8 | 36 | byteStart 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éfixe | Unités de code UTF-16 | Octets UTF-8 |
|---|---|---|
Café | 5 | 6 |
Café 🥐 | 8 | 11 |
Café 🥐 hours | 14 | 17 |
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
Essai gratuit de 7 jours
Analyses multiplateforme
Boîte sociale
Assistant IA
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 | $type | Champ requis | Contrainte | Formulation du 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'). » |
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.

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
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.
Mettez cela en pratique avec AdaptlyPost
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
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


Comment fonctionne un générateur de feeds Bluesky, du lexicon au feed en direct
Un générateur de feeds Bluesky est un service HTTPS qui répond à une requête XRPC. Les lexicons, l'entrée du document DID, le JWT et les zones d'ombre.


Pourquoi la limite de taille d'image de Bluesky est de 2,000,000 octets
Bluesky limite chaque image de post à 2,000,000 octets, fixés par maxSize dans le lexicon images. Les avatars et bannières s'arrêtent à 1,000,000 octets.


Convertir la limite de taux de l'API Bluesky en publications par heure
La limite de taux de l'API Bluesky en écriture est un budget de points, pas un compte de requêtes : 5,000 points par heure, 3 par post, soit 1,666 posts.
Articles Connexes


Les deux enregistrements derrière un handle de domaine personnalisé Bluesky
Un handle de domaine personnalisé Bluesky exige un enregistrement : TXT sur _atproto ou texte brut sur /.well-known/atproto-did. Les valeurs et les TLD exclus.


Meta fixe à 1 000 la limite de caractères d'alt_text dans l'API Instagram
Meta fixe à 1 000 la limite de caractères d'alt_text dans l'API Instagram et le réserve aux images fixes. Reels et Stories n'acceptent aucun texte alternatif.


Derrière l'étiquette IA de TikTok se cachent deux étiquettes
L'étiquette IA de TikTok existe en deux versions : celle que vous posez avec is_aigc, et celle que TikTok ajoute via ses effets ou C2PA, impossible à retirer.

