Glossar

Warum Bluesky facets byteStart byteEnd Bytes zählen, keine Zeichen

Taras Shynkarenko
Taras Shynkarenko
Aktualisiert: 9 Min. Lesezeit
Warum Bluesky facets byteStart byteEnd Bytes zählen, keine ZeichenWarum Bluesky facets byteStart byteEnd Bytes zählen, keine Zeichen

TL;DR, Kurze Antwort

9 Min. Lesezeit

Ein Facet ist ein Eintrag im facets-Array eines app.bsky.feed.post-Records. Es hat einen index, der ein byteSlice aus byteStart und byteEnd ist, und ein features-Array mit einer Link-uri, einer Mention-did oder einem tag-String. Die Offsets zählen Bytes des UTF-8-kodierten Textes, Start inklusive, Ende exklusive. Das Lexicon selbst warnt, dass Sprachen wie JavaScript Strings nach UTF-16 indizieren. Sobald dein Text einen Akzent oder ein Emoji enthält, liefert indexOf also die falsche Zahl, und der daraus entstehende Post ist trotzdem schemakonform.

Was sind Bluesky facets, und was messen byteStart und byteEnd?

Bluesky speichert Post-Text als einfachen String ohne Markup und ohne automatische Link-Erkennung, deshalb sind Bluesky facets byteStart byteEnd die einzige Angabe, die einem Client verrät, welcher Abschnitt dieses Strings ein Link, eine Erwähnung oder ein Hashtag ist. Das Lexicon app.bsky.richtext.facet beschreibt ein Facet als „Annotation of a sub-string within rich text.“ Zwei Felder sind bei jedem Facet Pflicht: index, ein #byteSlice, und features, ein Array von Annotationen für genau diesen Abschnitt.

So sieht die Struktur aus, direkt aus der Lexicon-Definition:

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

Die Definition von byteSlice legt Zählregel und Grenzregel in einem Satz fest: „Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text.“

FeldTypConstraint im LexiconBedeutung
index.byteStartintegerminimum: 0Erstes Byte des annotierten Bereichs, eingeschlossen
index.byteEndintegerminimum: 0Byte nach dem letzten annotierten Byte, ausgeschlossen
featuresarrayUnion aus #mention, #link, #tagEine oder mehrere Annotationen für diesen Byte-Bereich

Lies die Constraint-Spalte noch einmal. minimum: 0 ist die einzige numerische Regel für beide Offsets. Es gibt keine Obergrenze, die an die Länge deines Textes gekoppelt wäre, keine Regel, dass byteEnd größer als byteStart sein muss, und keine Regel, dass der Bereich auf einer Zeichengrenze enden muss. Genau deshalb erzeugen falsche Offsets keinen Fehler.

Ein Link erscheint als reiner Text, weil der Post-Record kein Facet enthält, dessen Byte-Bereich diese URL abdeckt. Die URL im Feld text bewirkt für sich genommen nichts. Bluesky-Clients durchsuchen den Post-Text nicht nach http://, sie lesen das facets-Array und annotieren exakt die Byte-Bereiche, die dort stehen.

Der Fehler bleibt in beide Richtungen still. Lässt du das Facet weg, bekommst du reinen Text. Schickst du ein Facet mit Offsets, die um drei danebenliegen, verlinkt der Client brav genau die Bytes, auf die du gezeigt hast, also womöglich das Ende des vorherigen Wortes plus den größten Teil der URL. In beiden Fällen liefert com.atproto.repo.createRecord eine normale Erfolgsantwort, denn ein Record mit falschen Offsets ist immer noch ein schemakonformer Record.

Das Lexicon lässt mehrere Verhaltensweisen offen, und diese Lücke sollte man klar benennen statt zu raten. app.bsky.richtext.facet legt nicht fest, was ein Renderer tun muss, wenn ein Byte-Bereich über das Textende hinausläuft, wenn sich zwei Facets überlappen oder wenn ein Bereich ein Mehrbyte-Zeichen zerschneidet. Es setzt auch keine maxLength für das facets-Array in app.bsky.feed.post, während das benachbarte tags-Array im selben Record ausdrücklich auf maxLength: 8 begrenzt ist. Das Limit für tags ist dokumentiert, das Limit für facets, falls es in der Praxis eines gibt, nicht.

Ein Entwickler tippt Code auf einem Laptop, genau die Art schneller JavaScript-Änderung, die bei Nicht-ASCII-Text die Byte-Offsets unbemerkt zerstört.

Warum liefert JavaScript den falschen byteStart?

JavaScript liefert den falschen byteStart, weil String.prototype.indexOf und String.prototype.length UTF-16-Code-Units zählen, während Facets UTF-8-Bytes zählen. Das Lexicon sagt es in einer Notiz, die kaum jemand liest, weil sie in einer JSON-Datei steht und nicht in einem 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.

Bei reinem ASCII-Text stimmen beide Systeme überein, und genau deshalb geht dieser Bug in Produktion. Du testest mit Opening hours https://example.com, jeder Offset passt, und der Code geht live. Dann enthält ein Post ein Wort mit Akzent oder ein Emoji, und die Offsets verrutschen.

Nimm diesen String:

Café 🥐 hours https://example.com

Drei Zählsysteme sind sich darüber uneinig:

ZähleinheitWert für diesen StringWo Bluesky sie verwendet
Grapheme32maxGraphemes: 300 für text in app.bsky.feed.post
UTF-16-Code-Units33Nirgends. Das ist der Wert, den text.length in JavaScript liefert
UTF-8-Bytes36byteStart und byteEnd, dazu maxLength: 3000 für text

Die Abweichung geht auf zwei Zeichen zurück. é ist eine UTF-16-Code-Unit und zwei UTF-8-Bytes. 🥐 ist ein Surrogatpaar, also zwei UTF-16-Code-Units und vier UTF-8-Bytes. Bis zur URL sind die beiden Indexsysteme drei Stellen auseinander:

PräfixUTF-16-Code-UnitsUTF-8-Bytes
Café 56
Café 🥐 811
Café 🥐 hours 1417

text.indexOf('https://example.com') liefert also 14, der korrekte byteStart ist aber 17. Nimmst du die JavaScript-Zahl, annotiert Bluesky die Bytes 14 bis 32, die zu rs https://example. dekodieren. Dem Wort „hours“ fehlen dann die letzten beiden Buchstaben, das abschließende com fällt aus dem Link heraus, und niemand bekommt einen Fehler.

AdaptlyPost
AdaptlyPost

7-Tage-Testversion starten

Plattformübergreifende Analysen

Sozialer Posteingang

KI-gestützter Assistent

Wie ein falscher Offset unbemerkt bleibt
Text enthält Akzente oder Emoji
indexOf zählt UTF-16-Einheiten, keine Bytes
byteStart landet auf dem falschen Byte
Der Datensatz besteht die Schema-Validierung trotzdem
Der Link zeigt auf den falschen Text
Nichts in dieser Kette löst einen Fehler aus, der falsche Link wird trotzdem veröffentlicht.

Wie berechne ich byteStart und byteEnd korrekt?

Kodiere den Text mit TextEncoder zu UTF-8-Bytes und leite beide Offsets aus den Längen der kodierten Daten ab, nicht aus String-Indizes. Die Kodierung des Präfixes vor einem Treffer ergibt byteStart, die Kodierung des Treffers selbst ergibt die Länge, die du für byteEnd addierst.

Das läuft unverändert unter Node 18 oder neuer und in jedem modernen Browser:

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

Ausgabe:

[
  {
    "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" }]
  }
]

Die Guard Clause in add ist der Teil, den du behalten solltest. Sie schneidet den Bereich aus dem kodierten Byte-Array heraus, dekodiert ihn und vergleicht ihn mit dem gefundenen Text. Da das Protokoll dir nie sagen wird, dass der Bereich falsch war, ist diese Prüfung das einzige Fehlersignal, das du bekommst, und sie kostet einen Vergleich pro Facet.

Zwei Details in diesem Snippet stammen aus dem Lexicon, nicht aus Geschmacksfragen. Das tag-Feature trägt bakery, nicht #bakery, weil das Lexicon sagt, die Referenz „should not“ das Präfix enthalten. Und features ist ein Array, ein einzelner Byte-Bereich kann also mehr als eine Annotation tragen.

Was steht im features-Array?

Jeder Eintrag in features ist eines von drei Union-Mitgliedern aus derselben Lexicon-Datei, unterschieden durch seinen $type-String.

Feature$typePflichtfeldConstraintFormulierung im 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').“

Mentions brauchen einen zusätzlichen Schritt. Der sichtbare Text ist ein Handle wie @example.com, das Facet speichert aber eine DID. Du löst das Handle also zuerst mit der Query com.atproto.identity.resolveHandle auf, die einen Parameter handle nimmt und eine did zurückgibt. Ihr einziger dokumentierter Fehler ist HandleNotFound, beschrieben als „The resolution process confirmed that the handle does not resolve to any DID.“ Dass eine DID statt eines Handles gespeichert wird, sorgt dafür, dass eine alte Erwähnung auch nach einer Umbenennung des Kontos noch auf das richtige Konto zeigt.

Die Formulierung zum Link ist für den Anzeigetext wichtig. Weil das Facet die vollständige URL hält, während der Text gekürzt sein darf, kannst du im Post example.com/very-long... anzeigen und die Leute trotzdem zur vollen Adresse schicken, solange der Byte-Bereich den gekürzten Text abdeckt, den du tatsächlich geschrieben hast.

Warum verwenden ältere Code-Beispiele stattdessen start und end?

Ältere Beispiele verwenden start und end, weil der Post-Record früher ein anderes Feld mit anderen Zählregeln trug und beide Versionen bis heute in den Lexicon-Dateien stehen. app.bsky.feed.post definiert ein entities-Array, markiert mit „DEPRECATED: replaced by app.bsky.richtext.facet.“ Sein Index-Typ ist #textSlice, dessen eigene Beschreibung lautet: „Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings.“

Das Protokoll hat also einen UTF-16-Slice-Typ ausgeliefert, ihn durch einen UTF-8-Typ ersetzt und beide Definitionen im Schema belassen. Jedes Snippet, das { start, end } baut, zielt auf das veraltete Feld, und wer dessen Index-Arithmetik in ein byteSlice kopiert, reproduziert genau den Bug, vor dem die neuere NOTE warnt. Prüfe die Feldnamen, bevor du den Offsets vertraust: byteStart und byteEnd bedeuten Bytes, start und end bedeuteten Code-Units.

Eine Person plant Beiträge auf einem Laptop, genau der Arbeitsablauf, den ein Planungstool in korrekte Facet-Byte-Bereiche übersetzen muss.

Wo passt ein Planungstool hier hinein?

Ein Scheduler, der auf Bluesky veröffentlicht, muss das facets-Array für dich bauen, denn der Record, den er schreibt, ist derselbe app.bsky.feed.post-Record, den du von Hand schreiben würdest. AdaptlyPost plant Bluesky-Posts zusammen mit den anderen Netzwerken, auf denen es veröffentlicht, sodass die Byte-Arithmetik einmal in der Publishing-Schicht passiert statt in jedem Skript, das du schreibst. Derselbe Post kann an mehrere Konten und Netzwerke in einer Aktion gehen, und die Bluesky-Interaktionen landen danach in den Bluesky Analytics.

Am Protokoll ändert das nichts. Das Limit von 300 Graphemen, das Limit von 3.000 Bytes und die UTF-8-Offsets gehören zu Bluesky und gelten gleichermaßen, ob nun ein Tool den Record schreibt oder du selbst. Was ein Tool wegnimmt, ist die Chance, ausgerechnet bei dem einen Post mit Emoji einen um drei verschobenen byteStart auszuliefern.

Häufig gestellte Fragen

Brauche ich ein Facet, wenn die vollständige URL schon im Post-Text steht?

Ja. Bluesky parst Links überhaupt nicht aus dem Post-Text, eine ausgeschriebene URL erscheint also als nicht klickbarer reiner Text, solange kein Facet mit einem #link-Feature ihren Byte-Bereich abdeckt. Das ist die häufigste Überraschung für alle, die von einer API kommen, die automatisch verlinkt.

Ist byteEnd der Index des letzten Bytes oder des Bytes danach?

Des Bytes danach. Das Lexicon sagt „Start index is inclusive, end index is exclusive,“ was bedeutet: byteEnd minus byteStart ergibt exakt die Byte-Länge des annotierten Textes. Für https://example.com, neunzehn ASCII-Zeichen, ist die Differenz 19.

AdaptlyPost
AdaptlyPost

7-Tage-Testversion starten

Plattformübergreifende Analysen

Sozialer Posteingang

KI-gestützter Assistent

Was passiert, wenn byteStart und byteEnd falsch sind?

Der Record wird akzeptiert. Die einzige numerische Beschränkung, die das Lexicon für eines der beiden Felder setzt, ist minimum: 0. Offsets, die auf die falschen Bytes oder über das Textende hinaus zeigen, validieren also trotzdem. Du merkst es erst am gerenderten Post, und genau deshalb lohnt sich die Dekodier-und-Vergleich-Prüfung aus dem Code oben.

Soll ich die Raute aus einem Tag-Facet entfernen?

Entferne sie. Das Lexicon sagt, der sichtbare Text „usually includes a '#' prefix, but the facet reference should not,“ mit einer Ausnahme, die es „double hash tags“ nennt. Der Byte-Bereich deckt das # im Text weiterhin ab, nur der tag-String lässt es weg.

Ja. features ist als Array einer Union aus #mention, #link und #tag definiert, ein einzelnes byteSlice kann also mehr als eine Annotation halten. Das Lexicon sagt nicht, wie ein Client einen Bereich mit zwei widersprüchlichen Features rendern soll, bleib also bei einer Annotation pro Bereich, solange du das Ergebnis nicht getestet hast.

Wie viele Facets kann ein einzelner Post haben?

Das Lexicon sagt es nicht. Das facets-Array in app.bsky.feed.post hat keine maxLength, anders als das tags-Array im selben Record, das auf maxLength: 8 begrenzt ist. Da kein Limit veröffentlicht ist, behandle die praktische Obergrenze als unbekannt, statt anzunehmen, es gebe keine.

Was ist der Unterschied zwischen dem veralteten Feld entities und facets?

Das Array entities in app.bsky.feed.post gilt als veraltet zugunsten von app.bsky.richtext.facet und verwendete einen anderen Index-Typ namens #textSlice. textSlice zählte start und end in UTF-16-Codeeinheiten, denselben Einheiten, die indexOf und length in JavaScript verwenden, während byteSlice UTF-8-Bytes zählt. Beide Definitionen stehen noch heute in den Lexikon-Dateien, ein Codebeispiel, das { start, end } aufbaut, zielt also auf das ausrangierte Feld, nicht auf das, was Bluesky-Clients heute lesen.

Warum speichert ein Bluesky-Mention-Facet eine DID statt des Handle-Texts?

Der sichtbare Text einer Erwähnung ist meist ein Handle wie @example.com, doch das Lexikon verlangt, dass die Facet-Referenz eine DID ist, aufgelöst über die Abfrage com.atproto.identity.resolveHandle. Ein Handle kann sich ändern, wenn jemand sein Konto umbenennt, eine DID bleibt jedoch bestehen, das Speichern der DID hält eine alte Erwähnung also auch nach einer Umbenennung an der richtigen Person. Der einzige dokumentierte Fehler der Abfrage, HandleNotFound, tritt auf, wenn der Auflösungsprozess bestätigt, dass das Handle zu keiner DID aufgelöst werden kann.

Kann der angezeigte Linktext von der tatsächlichen URL abweichen?

Das Link-Feature behält die vollständige URL im Facet, auch wenn der angezeigte Text gekürzt ist. Das Lexikon vermerkt, dass die Text-URL "vereinfacht oder gekürzt worden sein kann", die Facet-Referenz aber "eine vollständige URL sein sollte". Diese Trennung erlaubt es, im Post etwas wie example.com/very-long... zu zeigen, während der Byte-Bereich weiterhin auf die vollständige Adresse verweist, solange er den gekürzten Text abdeckt, der tatsächlich getippt wurde.

Welche Zeichen- und Byte-Grenzen gelten für einen Bluesky-Post?

Bluesky begrenzt den Post-Text über maxGraphemes auf dem Feld text in app.bsky.feed.post auf 300 Grapheme und separat über maxLength auf 3.000 UTF-8-Bytes. Das sind zwei Zähleinheiten für dasselbe Feld: Grapheme für die Anzeigegrenze, Bytes für die Speichergrenze, und byteStart/byteEnd verwenden dieselbe Byte-Zählung. Ein Emoji oder ein akzentuiertes Zeichen verbraucht mehr vom Byte-Budget als vom Graphem-Budget, dieselbe Lücke, die byteStart-Offsets zum Stolpern bringt.

War dieser Artikel hilfreich?

Teilen Sie uns Ihre Meinung mit!

Sieh uns öfter bei Google

Ein Klick macht AdaptlyPost zu einer bevorzugten Quelle. Unsere Artikel stehen dann weiter oben in deinen Top-Meldungen, im KI-Modus und in den KI-Übersichten.

Bevor Sie gehen...

AdaptlyPost

AdaptlyPost

Planen Sie Ihre Inhalte für alle Plattformen

Verwalten Sie alle Ihre Social-Media-Konten an einem Ort mit AdaptlyPost.

Plattformübergreifende Analysen

Sozialer Posteingang

KI-gestützter Assistent

Verwandte Glossarbegriffe

Verwandte Artikel