Topic Filter for Xはいかにしてツイートを描画前に取り除くか
Topic Filter for Xは、選んだ話題をXで恒久的に非表示にするChrome拡張機能です。何ができるかは公開告知の記事に書きました。本稿では、開発を面白くした3つのエンジニアリング課題を扱います。仮想化されたタイムラインのフィルタリング、誤検知しないキーワード照合、そしてサーバーなしで動くセマンティック分類器です。
課題1:ツイートは「隠す」だけでは済まない
コンテンツをブロックする拡張機能の素直な設計は、DOMでの非表示です。描画済みのツイートを見つけ、テキストを照合し、`display: none`を当てる。Xでは、これは目に見える形で破綻します。
Xのホームタイムラインは仮想化されています。各ツイートは、明示的な高さを持つコンテナの中に、固定のオフセットで絶対配置されたセルです。Reactはそのオフセットを、取得したデータから計算します。DOMが現在何を表示しているかは関係ありません。セルを隠してもレイアウトは組み直されず、ツイート大の空白の穴が残ります。代わりにセルの高さを潰しても、穴のでき方が変わるだけ。残りのセルを自前で並べ直す方法も試しましたが、今度はスクロールが完全に壊れました。3つの行き止まりはすべて、机上ではなく本物のサイトで確認済みです。
結論:仮想化リストにおいて、DOMはデータの投影にすぎません。ツイートを消したければ、リストがデータを見る前に、データから消えていなければなりません。
課題2:DOMではなくデータを濾過する
XのウェブアプリはGraphQLリクエストでタイムラインを読み込みます。そこで拡張機能は、Xのコードが動き出す前の`document_start`でページのMAINワールドにスクリプトを注入し、`XMLHttpRequest`と`fetch`の両方にパッチを当てます。タイムラインのレスポンスが返ってくると、インターセプタがそれをパースし、`TimelineAddEntries`の命令を辿り、条件に合うツイートをエントリ配列から削除してから、レスポンスをXのコードに渡します。Reactは濾過済みのリストを描画し、それに対して正しいオフセットを計算するので、フィードはネイティブに見えます。穴もちらつきもなく、無限スクロールも自然に続きます。
反復を要した細部:
コンテントスクリプトは通常、隔離されたワールドで動き、ページ側の`XMLHttpRequest`が見えません。インターセプタは`"world": "MAIN"`を宣言し、もう一つの隔離されたスクリプトが`window.postMessage`でユーザー設定を橋渡しします。
Xのタイムラインの転送はXHRであり、fetchではありません(検証済み。fetchへのパッチは保険です)。XHRレスポンスの書き換えは、インスタンスの`responseText`ゲッターの上書きで行います。これは順序に依存しないという便利な性質があります。
ツイート本文は複数の形に隠れています。`tweet_results.result.legacy.full_text`、入れ子の`.tweet.legacy`変種、長文投稿の`note_tweet`、そして本物のツイートを一段深く包むリツイート。動画投稿は`extended_entities.media[].type`から検出します。一つでも形を見落とすと、その種類の投稿は静かにフィルタをすり抜けます。
不正な、あるいは未知のレスポンスはそのまま通します。 タイムラインを壊しうるフィルタは、フィルタがないより悪い。
課題3:恥をかかせないキーワード照合
各話題には英語・スペイン語の大きなキーワードリストが付属し、すべてのキーワードは単語境界とアクセント無視、任意の複数形で照合されます。これにより「$BTC」「#IA」「elections」は確実に一致し、スペイン語の「diaria」が「ia」に、「supermarket」が「market」に誤って一致することはありません。
骨が折れたのはむしろ引き算でした。日常的な意味と話題的な意味が衝突する単語をすべて刈り込むこと。スペイン語はこの種の語だらけです。「bolsa」は株式市場でもあり買い物袋でもある。「acciones」は株式でもあり、ただの行動でもある。「sin」は「〜なしで」という前置詞であって罪ではない。こうした語は削除するか曖昧さのない言い回しに置き換え、日常文のリグレッションテストが再発を防いでいます。
課題4:サーバーのない分類器
キーワードは言い換えを取りこぼします。「市場は昨日の発表に悪い反応を示した」にはリスト上の語がひとつもありませんが、明らかに金融の話です。オプションのセマンティックモードは、多言語の文埋め込みモデル(MiniLMの変種)でこれを捕捉します。transformers.jsによりブラウザ内で完結し、MV3のoffscreenドキュメントの中で、WebGPU(WASMフォールバック付き)で動きます。重みのダウンロードは一度きり(約240MB)で、以後はオフライン。生成系LLMではなく埋め込みモデルであり、ツイートのテキストがマシンの外に出ることはありません。
共有する価値のある発見:ツイートと話題プロトタイプ文の生のコサイン類似度では、話題内と話題外がまったく分離しませんでした。話題外のツイートが平然と話題内のスコア帯に入ってきます。効いたのは対照的マージンです。各ツイートを話題のプロトタイプ群と、「日常生活」の中立アンカー文群の両方に対してスコアリングし、その差で分類する。テストセットでは、使いものにならなかった信号がきれいな分離に変わり、感度設定はマージンの閾値に対応付けました。プロトタイプは具体的でツイートらしい文である必要があります。「政治」のような抽象ラベルは、人が実際に書く文から遠すぎるのです。
製品たらしめる地味な部分
インターセプタと照合器は単一のマッチングコアを共有し、両方のワールドに読み込まれ、CIで走るユニットテストに覆われています。リリースは自動化されており、manifestのバージョンを上げてpushすれば、テスト、パッケージング、Chrome Web Store APIへのアップロード、自動公開まで一気通貫で進みます。そして全体が設計としてプライベートです。そもそもこのアーキテクチャにはサーバーが必要なかったのですから。
仮想化UI、傍受の問題、オンデバイスMLの制約と格闘しているなら:それこそ私たちの仕事です。