【ITニュース解説】Facets are byte offsets: two traps when you post to Bluesky from a script
2026年11月05日に「Dev.to」が公開したITニュース「Facets are byte offsets: two traps when you post to Bluesky from a script」について初心者にもわかりやすく解説しています。
ITニュース概要
Bluesky APIで投稿する際、リンクやハッシュタグの位置(ファセット)は、絵文字などで文字とバイト数の違いがあるため、バイト単位で正確に指定しないとずれてしまう。また、リトライ時に同じ内容が二重投稿されないよう、投稿キーを自分で指定するか、`swapCommit`を使い、エラーを正しく処理する配慮が必要だ。
ITニュース解説
BlueskyのAPIを利用してプログラムから投稿を行う際、システムエンジニアを目指す初心者が陥りやすい二つの大きな落とし穴がある。これらの罠は、投稿が正常に受理されたように見えても、実際には意図しない結果を生んでしまう可能性があるため、注意が必要である。
一つ目の罠は、投稿に含まれるリンクやメンション、ハッシュタグといった特殊な要素の範囲を示す情報、「ファセット」の指定方法に関するものである。Blueskyのプロトコルでは、これらのファセットが投稿テキストのどこからどこまでを指し示すかを「バイトオフセット」という単位で指定する。しかし、多くの開発者は文字列の長さを「文字数」で数えることに慣れているため、このバイトオフセットの概念で混乱してしまう。
具体的に何が問題となるかというと、英数字や基本的な記号は通常1文字が1バイトだが、日本語や絵文字のような多様な文字は、UTF-8というエンコーディング方式では1文字が複数バイト(例えば2〜4バイト)になる場合がある。例えば、笑顔の絵文字「😀」は、見た目は1文字だが、UTF-8では4バイトのデータとして扱われる。そのため、「😀 #abcdef」という9文字の投稿があったとして、人間が見るとハッシュタグ「#abcdef」は2文字目から始まるように見えるが、バイト単位で数えると絵文字の4バイトと空白の1バイトを合わせた5バイト目からハッシュタグが始まることになる。つまり、ファセットでこのハッシュタグの範囲を指定するには、「5バイト目から12バイト目まで」と正確なバイトオフセットで示す必要がある。もし、文字数ベースで「2文字目から9文字目まで」と指定してしまうと、システムはこれをバイトオフセットとして解釈するため、全く異なる範囲を指し示してしまうことになる。
このバイトオフセットに関する問題は、Blueskyのプロトコル設計段階で議論されており、開発者が慣れている文字数ベースではなく、技術的な正確さからUTF-8バイトオフセットが採用された経緯がある。しかし、この選択が多くの開発者にとって誤解の元となるだろうという懸念も、当時から指摘されていた。実際に、アクセント記号を含む特殊な文字(例えばスペイン語の「ç」や「ã」)は1文字2バイトとなるため、これらが多用された投稿では、文字数とバイト数のずれが累積し、メンションのハイライトがずれるといった問題が発生している。さらに深刻な場合、ファセットの開始バイト位置が文字の途中を指してしまい、Blueskyの公式クライアントでもテキストが正しく表示されず、意味不明な記号(置き換え文字)が表示されてしまう事態も起きている。Blueskyのサーバーは、このような誤ったファセット情報を持つ投稿でも「200 OK」と正常に受け付けてしまうため、投稿した側は問題に気づきにくい。
この問題を回避するためには、ファセットのバイトオフセットを計算する際、常にテキストをUTF-8でエンコードしたバイト列を基準とする必要がある。Pythonの例では、len(text[:m.start()].encode("utf-8")) のように、対象の文字列の前の部分をUTF-8エンコードしてそのバイト長を数えることで、正確な開始バイト位置を得ることができる。また、投稿を送信する直前には、計算したバイト範囲が実際に意図したテキストに正しくデコードされるかを検証する処理(例えば assert raw[start:end].decode("utf-8") == m.group(0) のような確認)を組み込むことが極めて重要である。これにより、もし計算に誤りがあれば、それがネットワークに公開される前に検出できる。
二つ目の罠は、投稿の重複に関する問題である。Blueskyの投稿作成API(com.atproto.repo.createRecord)では、投稿を一意に識別する「レコードキー(rkey)」を指定できるが、これはオプション項目である。多くのスクリプトではこのrkeyを省略して投稿しており、その場合、Blueskyのシステムが自動的に新しいrkeyを生成して投稿を受け入れる。
この自動生成が、ネットワークの不確実性と組み合わさると、意図しない重複投稿を引き起こす可能性がある。例えば、スクリプトが投稿リクエストを送信し、サーバーがその投稿を正常に受け付けたものの、ネットワークの問題(タイムアウトや接続切れなど)により、その成功レスポンスがクライアントに届かなかった場合を考える。クライアントは投稿が成功したか分からないため、多くの場合、同じ内容のリクエストを自動的に再送するだろう。しかし、rkeyを省略していると、サーバーはこれを全く新しい投稿とみなし、再度新しいrkeyを割り当てて同じ内容の投稿を重複して作成してしまう。これが、同じ投稿が二重に公開されてしまう原因である。
この重複投稿を防ぐ方法として、主に二つ考えられる。
一つは、クライアント側で一意のrkeyを生成して投稿リクエストに含める方法である。例えば、投稿ごとに「my-post-001」のようなrkeyを自作して送信する。このrkeyを指定してリトライすると、サーバーは既にそのrkeyを持つ投稿が存在することを認識し、重複作成を防ぐことができる。しかし、現在のBlueskyのPDSでは、この場合に「500 InternalServerError」というエラーが返ってくる。一般的に500番台のエラーはサーバー内部の一時的な問題を示唆するため、クライアントのリトライ機構によっては、エラーが続く限りリトライを繰り返してしまう可能性があり、適切な処理が難しい。
もう一つは、「swapCommit」という仕組みを利用する方法である。Blueskyのリポジトリはコミットの連鎖として管理されており、swapCommitは、ある特定のコミットID(リポジトリの現在のヘッド)であることを条件に書き込みを行う機能である。具体的には、投稿を行う前にリポジトリの現在のヘッドコミットIDを取得し、そのIDをswapCommitパラメータとして投稿リクエストに含める。最初の投稿が成功してリポジトリのヘッドが更新されると、その後に同じswapCommitIDを持つリクエストが送られてきても、サーバーはコミットIDの不一致を理由にリクエストを拒否する(400 InvalidSwapエラー)。これにより、同じリクエストが複数回送信されても、一度しか書き込みが行われないことが保証される。ただし、InvalidSwapエラーは、自分の投稿が成功したことだけを意味するわけではない。他のユーザーの投稿などによってもリポジトリのヘッドは更新され得るため、このエラーが返された場合でも、自分の投稿が成功したのか、失敗したのかは別途確認する必要がある。
これらの二つの罠に共通するのは、「APIが200 OKを返しても、実際には意図しない結果になっている」という点である。そのため、最も確実な対策は、スクリプトで投稿を終えた後に、ネットワークを通じて実際にその投稿を読み戻し、内容が意図通りに公開されているかを検証することである。ファセットの検証はもちろん、重複投稿の可能性を排除するためには、リトライが必要な状況になった際に、単にリクエストを再送するのではなく、まず自分のアカウントの最新投稿履歴を確認し、目的の投稿が既に存在しないかをチェックする手順を加えることが望ましい。これらの確認作業は、誤った情報の公開や重複投稿を防ぎ、信頼性の高いシステム運用を実現するために不可欠なステップとなる。