TL;DR, Resposta Rápida
10 min de leituraUm facet é uma entrada do array facets de um registro app.bsky.feed.post. Ele tem um index, que é um byteSlice de byteStart e byteEnd, e um array features com uma uri de link, um did de menção ou uma string tag. Os offsets contam bytes do texto codificado em UTF-8, início incluído, fim excluído. O próprio lexicon avisa que linguagens como JavaScript indexam strings por UTF-16, então indexOf devolve o número errado assim que o seu texto tem um acento ou um emoji, e a publicação resultante continua válida perante o esquema.
O que são os facets do Bluesky e o que byteStart e byteEnd medem?
O Bluesky guarda o texto da publicação como uma string simples, sem marcação e sem detecção automática de links, então os offsets Bluesky facets byteStart byteEnd são a única coisa que diz a um cliente qual trecho dessa string é um link, uma menção ou uma hashtag. O lexicon app.bsky.richtext.facet descreve um facet como uma "Annotation of a sub-string within rich text." Todo facet exige dois campos: index, que é um #byteSlice, e features, um array de anotações que se aplicam a esse trecho.
Este é o formato, tirado direto da definição do lexicon:
{
"index": { "byteStart": 17, "byteEnd": 36 },
"features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }]
}A definição de byteSlice fixa a regra de contagem e a das bordas de uma vez: "Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text."
| Campo | Tipo | Restrição no lexicon | O que significa |
|---|---|---|---|
index.byteStart | integer | minimum: 0 | Primeiro byte do intervalo anotado, incluído |
index.byteEnd | integer | minimum: 0 | Byte seguinte ao último byte anotado, excluído |
features | array | união de #mention, #link, #tag | Uma ou mais anotações sobre esse intervalo de bytes |
Leia de novo a coluna de restrições. minimum: 0 é a única regra numérica sobre qualquer um dos dois offsets. Não há limite superior ligado ao tamanho do seu texto, nenhuma regra de que byteEnd precise ser maior que byteStart, e nenhuma regra de que o intervalo tenha de terminar numa fronteira de caractere. É por isso que offsets errados não geram erro.
Por que meu link do Bluesky é publicado como texto simples?
Um link aparece como texto simples porque o registro da publicação não contém nenhum facet cujo intervalo de bytes cubra aquela URL. A URL parada no campo text não faz nada sozinha. Os clientes do Bluesky não varrem o texto atrás de http://, eles leem o array facets e anotam exatamente os intervalos de bytes que ele indica.
A falha é silenciosa nos dois sentidos. Se você omite o facet, recebe texto simples. Se envia um facet com offsets deslocados em três, o cliente linka obedientemente os bytes que você apontou, que podem ser o final da palavra anterior mais quase toda a URL. Nos dois casos, com.atproto.repo.createRecord devolve uma resposta de sucesso normal, porque um registro com offsets errados continua sendo um registro válido perante o esquema.
O lexicon deixa vários comportamentos indefinidos, e vale dizer isso com todas as letras em vez de adivinhar. app.bsky.richtext.facet não especifica o que um renderizador deve fazer quando um intervalo de bytes passa do fim do texto, quando dois facets se sobrepõem, ou quando um intervalo corta um caractere multibyte ao meio. Ele também não define maxLength para o array facets em app.bsky.feed.post, enquanto o array irmão tags no mesmo registro é explicitamente limitado a maxLength: 8. O limite de tags está documentado; o de facets, se existe algum na prática, não está.

Por que o JavaScript devolve o byteStart errado?
O JavaScript devolve o byteStart errado porque String.prototype.indexOf e String.prototype.length contam unidades de código UTF-16, e os facets contam bytes UTF-8. O lexicon diz isso numa nota que quase ninguém lê, porque ela vive dentro de um arquivo JSON e não em um 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.
Com texto ASCII puro os dois sistemas concordam, e é exatamente isso que faz esse bug chegar em produção. Você testa com Opening hours https://example.com, cada offset bate, e o código sobe. Depois uma publicação ganha uma palavra acentuada ou um emoji, e os offsets escorregam.
Pegue esta string:
Café 🥐 hours https://example.comTrês sistemas de contagem discordam sobre ela:
| Unidade de contagem | Valor para esta string | Onde o Bluesky usa |
|---|---|---|
| Grafemas | 32 | maxGraphemes: 300 em text no app.bsky.feed.post |
| Unidades de código UTF-16 | 33 | Em lugar nenhum. É o que text.length devolve no JavaScript |
| Bytes UTF-8 | 36 | byteStart e byteEnd, mais maxLength: 3000 em text |
A divergência vem de dois caracteres. é é uma unidade de código UTF-16 e dois bytes UTF-8. 🥐 é um par substituto, ou seja, duas unidades de código UTF-16 e quatro bytes UTF-8. Quando você chega à URL, os dois sistemas de índice estão três posições afastados:
| Prefixo | Unidades de código UTF-16 | Bytes UTF-8 |
|---|---|---|
Café | 5 | 6 |
Café 🥐 | 8 | 11 |
Café 🥐 hours | 14 | 17 |
Então text.indexOf('https://example.com') devolve 14, e o byteStart correto é 17. Use o número do JavaScript e o Bluesky anota os bytes de 14 a 32, que decodificam como rs https://example. A palavra "hours" perde as duas últimas letras para o link, o com final fica de fora, e ninguém recebe erro nenhum.
AdaptlyPost
Comece seu teste grátis de 7 dias
Análises multiplataforma
Caixa Social
Assistente com IA
Como calculo byteStart e byteEnd corretamente?
Codifique o texto para bytes UTF-8 com TextEncoder e derive os dois offsets dos comprimentos codificados, não dos índices da string. Codificar o prefixo anterior a uma ocorrência dá o byteStart; codificar a própria ocorrência dá o comprimento a somar para o byteEnd.
Isto roda como está no Node 18 ou posterior e em qualquer 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));Saída:
[
{
"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" }]
}
]A cláusula de guarda dentro de add é a parte que vale a pena manter. Ela recorta o array de bytes codificado, decodifica e compara com o texto que foi encontrado. Como o protocolo nunca vai avisar que o intervalo estava errado, essa verificação é o único sinal de falha que você tem, e custa uma comparação por facet.
Dois detalhes desse trecho vêm do lexicon, não de gosto pessoal. O feature tag carrega bakery, não #bakery, porque o lexicon diz que a referência "should not" incluir o prefixo. E features é um array, então um mesmo intervalo de bytes pode carregar mais de uma anotação.
O que vai dentro do array features?
Cada entrada de features é um dos três membros da união definidos no mesmo arquivo de lexicon, distinguidos pela string $type.
| Feature | $type | Campo obrigatório | Restrição | Texto do 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')." |
As menções pedem um passo a mais. O texto visível é um handle como @example.com, mas o facet guarda um DID, então você resolve o handle primeiro com a query com.atproto.identity.resolveHandle, que recebe um parâmetro handle e devolve um did. Seu único erro documentado é HandleNotFound, descrito como "The resolution process confirmed that the handle does not resolve to any DID." Guardar um DID em vez de um handle é o que mantém uma menção antiga apontando para a conta certa depois que essa conta muda de nome.
O texto sobre o link importa para o que aparece na tela. Como o facet guarda a URL completa enquanto o texto pode estar truncado, você pode mostrar example.com/very-long... na publicação e ainda assim levar as pessoas ao endereço completo, desde que o intervalo de bytes cubra o texto encurtado que você realmente escreveu.
Por que exemplos de código mais antigos usam start e end?
Os exemplos antigos usam start e end porque o registro da publicação carregava antes um campo diferente com outras regras de contagem, e as duas versões continuam hoje nos arquivos de lexicon. app.bsky.feed.post define um array entities marcado como "DEPRECATED: replaced by app.bsky.richtext.facet." Seu tipo de índice é #textSlice, cuja própria descrição diz: "Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings."
Ou seja, o protocolo lançou um tipo de slice UTF-16, substituiu por um UTF-8 e manteve as duas definições no esquema. Qualquer trecho que construa { start, end } mira no campo obsoleto, e copiar a aritmética de índices dele para um byteSlice reproduz exatamente o bug contra o qual a NOTE mais recente avisa. Confira os nomes dos campos antes de confiar nos offsets: byteStart e byteEnd significam bytes, start e end significavam unidades de código.

Onde uma ferramenta de agendamento entra nisso?
Um agendador que publica no Bluesky precisa montar o array facets por você, porque o registro que ele escreve é o mesmo app.bsky.feed.post que você escreveria à mão. O AdaptlyPost agenda publicações do Bluesky junto com as outras redes em que publica, então a aritmética de bytes acontece uma vez na camada de publicação em vez de em cada script que você escreve. A mesma publicação pode sair para várias contas e redes em uma única ação, e o engajamento do Bluesky cai depois no analytics do Bluesky.
Nada disso muda o protocolo. O limite de 300 grafemas, o de 3.000 bytes e os offsets UTF-8 pertencem ao Bluesky, e valem igual quer uma ferramenta escreva o registro, quer você escreva. O que uma ferramenta tira de cena é a chance de publicar um byteStart deslocado em três justo na publicação que tinha um emoji.
Perguntas frequentes
Preciso de um facet se a URL completa já aparece no texto da publicação?
Sim. O Bluesky não extrai links do texto da publicação de jeito nenhum, então uma URL escrita por extenso aparece como texto simples não clicável a menos que um facet com um feature #link cubra o intervalo de bytes dela. Essa é a surpresa mais comum para quem vem de uma API que cria links sozinha.
byteEnd é o índice do último byte ou o do seguinte?
O do seguinte. O lexicon afirma "Start index is inclusive, end index is exclusive," o que significa que byteEnd menos byteStart é exatamente o comprimento em bytes do texto anotado. Para https://example.com, dezenove caracteres ASCII, a diferença é 19.
AdaptlyPost
Comece seu teste grátis de 7 dias
Análises multiplataforma
Caixa Social
Assistente com IA
O que acontece se byteStart e byteEnd estiverem errados?
O registro é aceito. A única restrição numérica que o lexicon coloca em qualquer um dos dois campos é minimum: 0, então offsets que apontam para os bytes errados, ou para além do fim do texto, ainda assim validam. Você descobre olhando a publicação renderizada, e é por isso que a checagem de decodificar e comparar do código acima vale a pena.
Devo tirar a cerquilha de um facet de tag?
Tire. O lexicon diz que o texto visível "usually includes a '#' prefix, but the facet reference should not," com uma ressalva que ele chama de "double hash tags." O intervalo de bytes continua cobrindo o # no texto; só a string tag dispensa ele.
Um mesmo facet pode carregar um link e uma tag?
Sim. features é definido como um array de uma união de #mention, #link e #tag, então um único byteSlice pode conter mais de uma anotação. O lexicon não diz como um cliente deve renderizar um intervalo que carrega dois features conflitantes, então fique com uma por intervalo enquanto não testar o resultado.
Quantos facets uma única publicação pode ter?
O lexicon não diz. O array facets em app.bsky.feed.post não tem maxLength, ao contrário do array tags no mesmo registro, limitado a maxLength: 8. Como nenhum limite foi publicado, trate o teto prático como desconhecido em vez de supor que não existe.
Qual é a diferença entre o campo obsoleto entities e os facets?
O array entities em app.bsky.feed.post está marcado como obsoleto em favor de app.bsky.richtext.facet, e usava um tipo de índice diferente chamado #textSlice. textSlice contava start e end em unidades de código UTF-16, as mesmas unidades que indexOf e length usam em JavaScript, enquanto byteSlice conta bytes UTF-8. As duas definições ainda estão nos arquivos do lexicon hoje, então um exemplo de código que monta { start, end } aponta para o campo aposentado, não para o que os clientes do Bluesky leem agora.
Por que um facet de menção do Bluesky guarda um DID em vez do texto do handle?
O texto visível de uma menção costuma ser um handle como @example.com, mas o lexicon exige que a referência do facet seja um DID, resolvido pela consulta com.atproto.identity.resolveHandle. Um handle pode mudar quando alguém renomeia a conta, mas um DID não muda, guardar o DID mantém uma menção antiga apontando para a pessoa certa depois de uma renomeação. O único erro documentado da consulta, HandleNotFound, aparece quando o processo de resolução confirma que o handle não resolve para nenhum DID.
O texto do link mostrado pode ser diferente da URL real?
O recurso de link mantém a URL completa no facet mesmo quando o texto exibido está encurtado. O lexicon registra que a URL do texto "pode ter sido simplificada ou truncada", mas que a referência do facet "deve ser uma URL completa". Essa separação permite mostrar algo como example.com/very-long... na publicação enquanto o intervalo de bytes continua levando ao endereço completo, desde que cubra o texto encurtado que foi realmente digitado.
Quais são os limites de caracteres e bytes de uma publicação do Bluesky?
O Bluesky limita o texto da publicação a 300 grafemas por meio de maxGraphemes no campo text de app.bsky.feed.post, e separadamente a 3.000 bytes UTF-8 por meio de maxLength. São duas unidades de contagem para o mesmo campo: grafemas para o limite de exibição, bytes para o limite de armazenamento, e byteStart/byteEnd usam essa mesma contagem de bytes. Um emoji ou um caractere acentuado consome mais do orçamento de bytes do que do orçamento de grafemas, a mesma diferença que atrapalha os offsets de byteStart.
Coloque isso em prática com o AdaptlyPost
Este artigo foi útil para você?
Conte-nos o que você achou!
Veja-nos mais no Google
Um clique marca a AdaptlyPost como fonte preferida e nossos artigos passam a aparecer mais acima nas suas Principais notícias, no modo IA e nas visões gerais com IA.
Antes de ir...
AdaptlyPost
Agende seu conteúdo em todas as plataformas
Gerencie todas as suas contas de redes sociais em um só lugar com o AdaptlyPost.
Análises multiplataforma
Caixa Social
Assistente com IA
Termos relacionados do glossário


Como funciona um gerador de feeds do Bluesky, do lexicon ao feed no ar
Um gerador de feeds do Bluesky é um serviço HTTPS que responde a uma consulta XRPC. Os lexicons, a entrada no documento DID, o JWT e as lacunas nos docs.


Como postar um GIF no Bluesky que continua animado
Como um GIF no Bluesky chega a um post: o seletor KLIPY, uploads que viram vídeo, embeds de imagem com um só quadro e o que o AdaptlyPost envia.


Como um threadgate do Bluesky decide quem pode responder a um post
Um threadgate do Bluesky limita respostas a mencionados, seguidores, quem você segue ou uma lista. Como o registro funciona e o postgate que barra citações.
Artigos Relacionados


Cinco contadores de engajamento: o Bluesky tem métricas?
Resposta curta para quem pergunta se o Bluesky tem métricas: não. O lexicon dá ao postView cinco contadores de engajamento, sem views nem impressões.


A Meta documenta quatro limites diferentes para o scheduled_publish_time do Facebook
O campo scheduled_publish_time do Facebook exige um horário pelo menos 10 minutos à frente, e a Meta dá quatro máximos: 29 dias, 30 dias, 75 dias e 6 meses.


Horas puladas, horas repetidas e como agendadores de redes sociais lidam com fusos horários
Veja como agendadores de redes sociais lidam com fusos horários: UTC mais uma zona IANA, as horas puladas e repetidas e os formatos de cada API.

