X(Twitter)のリンクを、開く前に正体判定するChrome拡張。
タイムラインやリプ欄のリンクを背景で調べて、アダルト誘導・連投スパム・有料記事・広告過多などを 判定します。危険度の高いものは画像を色で覆って知らせるので、うっかり開くことがありません。 引用ポストの引用元も判定します。
┌──────────────────────────┐
│ ぜひ見てください │
│ ┌────────────────────┐ │
│ │ │ │
│ │ 🔞 R18 │ │ ← マゼンタで覆われる
│ │ 🔗 短縮 │ │
│ │ │ │
│ └────────────────────┘ │
└──────────────────────────┘
色はカテゴリごとに変えてあり、何で覆われているかが色でも分かります。
| バッジ | 色 | 判定方法 |
|---|---|---|
| 🔞 R18 | マゼンタ | 既知ドメイン45件 / URL内の明示語 / RTAラベル・年齢確認ゲート(サイト自身の申告) |
| 🚨 連投 | 深い赤 | リプ欄で同じ登録可能ドメインが3件以上のポストに出現 |
| 🎣 招待誘導 | 焦茶 | 中継カードの行き先が招待リンクと確認できた場合、実際の行き先を表示 |
| オレンジ | 自分で登録したドメイン(ポップアップから追加) | |
| 🔒 有料 | 紺 | 構造シグナル(JSON-LD/meta)→ 媒体別CTA → 汎用CTA の3層 |
| 📦 DL | 茶 | .apk .exe .dmg .msi 等への直リンク |
知っておくと良いが危険ではないので、画像は隠しません。
| バッジ | 色 | 判定方法 |
|---|---|---|
| 📊 広告多 | 青緑 | 広告"枠"の数を数えて10個以上(社数では測れない。後述) |
| 🔑 登録 | スレート | 無料だが登録/ログインが要るウォール(有料判定が取れたら出さない) |
| 🏭 まとめ | 紫 | 既知ドメインのみ。ヒューリスティックは使わない(後述) |
| 📣 広告 | グレー | プレスリリース配信 / 広告ネットワーク経由 / utm_medium=paid 等 |
| 💰 アフィ | 黄土 | ASP約15社。Amazonはホスト+?tag=の両方を要求 |
| 🎁 招待 | 緑 | 招待コードのクエリ/招待用パス/TikTok Lite 等(後述) |
| 🔗 短縮 | 青 | 既知の短縮サービス35件+アプリ計測リンク(t.co は全リンクに付くので除外) |
両方付いた場合は、覆いの中に小さいバッジを並べます(🔞R18 で覆ったうえで 🔗短縮 を小表示)。 覆いは中身がうっすら見える濃さです(塗り潰すと何のリンクか分からなくなるため)。
判定ルールの全文(正規表現・ドメイン全リスト・実測値・採用してはいけない語)は docs/DETECTION.md にまとめてあります。 このREADMEには各カテゴリの判定根拠を要約して載せています。
開発版を手動で読み込む場合は、以下の手順を使ってください。
- このリポジトリをダウンロード(またはclone)
- Chromeで
chrome://extensionsを開く - 右上「デベロッパーモード」をON
- 「パッケージ化されていない拡張機能を読み込む」→ このフォルダを選択
- x.com を開いてスクロール
更新したときは
chrome://extensionsで🔄を押し、Xのタブも再読み込みしてください (content scriptが古いままだと反映されません)。
インストール直後から全機能が有効です。設定は不要ですが、拡張機能アイコンから カテゴリごとのON/OFF・自分で登録したドメインの管理・動作テストができます。
<all_urls>(すべてのサイトの読み取り)を要求します。Xに表示されたリンクの中身を
背景で取得して判定するためです。有料記事の判定・広告枠の計測・登録ウォールの検出・
短縮URLの行き先の解決は、いずれもリンク先のHTMLを見ないとできません。
- 取得するのは画面に出たリンク先だけです。閲覧履歴や他のタブには一切アクセスしません。
- 取得はCookieを送らない匿名リクエスト(
credentials: "omit")です。 - URL・投稿テキスト・判定結果を開発者のサーバーへ送信しません。判定はブラウザ内で行います。
- 結果は6時間だけローカルにキャッシュします。
- 気になる場合は、ポップアップの「リンク先を取得して判定する」をOFFにできます (一般サイトの内容取得・リンク集一覧取得・LinkMistyのクリック先確認を停止します。短縮URLの転送確認と登録済み有料媒体の判定はOFFでも通信します)。
詳細は docs/PRIVACY.md を参照してください。
このプロジェクトの判定は、次の3原則で作っています。
1. 断定しない。 ラベルは事実ベースの中立表現にとどめます(「アダルト」「広告枠12個」)。 サイトの品質や善悪の判断はしません。誤ラベルは実害があるためです。
2. 誤検出より取りこぼしを選ぶ。 判定できないものはバッジを出しません(unknown)。「たぶん怪しい」で警告を出すと、 すぐに信用されなくなって全部無視されるようになります。
3. 閾値は実測で決める。 「なんとなくこのくらい」ではなく、実際のサイトを取得して数えた値から決めます。
GAM/AdSenseを使うサイトは初期HTMLにローダーが1〜2本あるだけで、広告枠は実行時に生成されます。 実測でも gigazineは広告ネットワーク2社しか出ないのに、広告枠は12個ありました。 そのため 広告"枠"の数を主指標にしています。
| サイト | 広告枠 | 判定 |
|---|---|---|
| alfalfalfa | 29 | ★ 広告過多 |
| gigazine(記事) | 12 | ★ 広告過多 |
| yurukuyaru | 9 | — |
| 毎日新聞(記事) | 8 | — |
| nlab / togetter / 朝日(記事) | 6 / 4 / 2 | — |
| zenn / Yahoo!ニュース / 4gamer | 0 | — |
普通の新聞記事(8枠)を超える 10 を境界にしました。yurukuyaru(9)は取りこぼしますが、 原則2に従って誤検出しない側に倒してあります。
content.js … 画面内に入ったポストをSWへ送る(IntersectionObserver)+本体/引用元の分割+描画
background.js … 判定の統合・fetchキュー(並列8)・6hキャッシュ・2段階レスポンス
classifier.js … URLだけで判定(アフィ/短縮/PR/まとめ/アダルト/DL)
pagesignals.js… HTMLから判定(広告枠数/アダルト自己申告/登録ウォール)
paywall.js … HTMLから有料判定(3層)
rules.js … 全ルール+カテゴリ定義の一元管理
popup.js/html … 設定UI(CATEGORIESから自動生成)+動作テスト
上ほど信頼度が高く、未登録ドメインでも 1 と 3 だけで判定できます。
- 構造シグナル …
isAccessibleForFree:false/article:content_tier=locked/hasPart.cssSelector— 媒体非依存・確定的 - 媒体別CTA …
PAYWALL_SITES[domain].paidRe— 実記事で確認済みのもの - 汎用CTA …
GENERIC_PAYWALL_RE— 本文を塞ぐ固有フレーズのみ(日英)
ルール登録済みの媒体: 朝日 / 読売 / 毎日 / 産経 / 日経 / 東京新聞 / 東洋経済オンライン /
ダイヤモンド・オンライン / 現代ビジネス / JBpress
(朝日・読売・日経・東洋経済・ダイヤモンドは実記事で確認済み confirmed:true)
日本の大手紙は JSON-LD isAccessibleForFree をほぼ吐かないため、有料CTA文字列の正規表現が
主判定になります。CSSクラスはハッシュ化されるのでDOMセレクタは使えません。
「残り<b>N</b>文字」のようにタグを跨ぐケースがあるため、生HTMLとタグ除去後テキストの
両方で照合します。
バズ投稿のリプ欄に湧く誘導リンクの主要な手口は「同じ行き先を別々のアカウントで大量投稿する」こと。 個々のURLは無害に見えても、同じドメインが複数のポストに出ること自体が事実として異常なので、 文面や言語に依存せず検出できます(文言を変えてくるスパムにも効く)。
- 会話ページ(
/status/)限定。TLで人気記事が何度も流れるのは正常なので対象外。 - カウントは登録可能ドメイン単位(
a1.spam.topとa2.spam.topを同一視)。 ホスト完全一致だとサブドメインを回すだけで回避されるため。 - 行き先が確定したものだけ。
t.coのままのリンクは数えません(全部が同一ホスト扱いになるため)。 SAFE_HOSTSは対象外。大手ドメインは何度貼られても異常ではありません。- 引用元の中のリンクは数えません。 引用タブ(
/status/<id>/quotes)では並ぶ全ポストが 同じ引用元を抱えているので、数えると1つの投稿がポスト数ぶん重複して必ず閾値を超えます。 検出したいのは「別々の人が同じリンクを貼っている」ことです。 - 閾値に達すると、既に描画済みの過去のポストにも遡ってバッジを付け直します。
3系統のどれかが当たれば判定します。
- 既知ドメイン(約45件)— 確実だがリストにあるものしか当たらない。
dmm.co.jp/digitalのようにパス前方一致も書けるので、同一ドメインで一般向けと アダルトが分かれるサイト(FANZAとDMM通販)を撃ち分けられます。 - URL内の明示語 —
pornhentaijavエロ無修正など。英単語は 区切り文字で挟まれている場合のみマッチさせます。そうしないとjavascriptのjavで誤爆します。 さらにSAFE_HOSTS(大手ドメイン)では評価しません。 - サイト自身の申告(要fetch)— RTAラベル、
<meta name="rating" content="adult">、 そして年齢確認ゲート(「あなたは18歳以上ですか」等)。
3 が実質いちばん強く効きます。abc-video.site のような無害に見える名前の新規ドメインは
URLからは絶対に判定できませんが、中身を見れば年齢確認でほぼ確実に分かるためです。
「サイト自身が宣言しているもの」を読むだけなので、こちらが推測する余地がなく誤検出しません。
既知ドメインリストのみで、ヒューリスティックは一切使っていません。
本文の引用比率などで推定することは技術的には可能ですが、「まとめサイト認定」は サイトの信用に関わるため誤ラベルの実害が大きいと判断しました。確実に分かっている ドメインだけを列挙し、ラベルも「まとめ/転載」という中立表現にとどめています。
URLパターンのみ。4つの条件形式を組み合わせます。
| 形式 | 意味 | 例 |
|---|---|---|
hostRe |
ホスト名の正規表現 | hb.afl.rakuten.co.jp, a.r10.to |
param |
パラメータの存在 | Amazon の ?tag= |
paramRe |
パラメータ値の正規表現 | 楽天の scid=af_... |
paramAny |
いずれかの key / key=value | affiliate_id, utm_medium=affiliate |
Amazonは hostRe と param の両方を要求します。amazon.co.jp へのリンクでも
?tag= が無ければアフィリンクではないので判定しません。
TikTok Lite のようなポイント系アプリの招待リンクは、アプリ名を列挙しても追いつきません (キャンペーンごとに新しいアプリが出てくる)。そこでアプリ名ではなく招待キャンペーンの構造で 判定します。
| 方法 | 例 |
|---|---|
| 招待コードのクエリパラメータ(24種) | ?invite_code=, ?referralCode=, ?inviter_id= … |
| 招待用のパスセグメント | /invite/AB12, /referral, /invite-friend/… |
| アプリ固有ルール | lite.tiktok.com(コードがパスに埋まるため) |
誤検出を避けるための線引き:
?ref=単体は採用しない。普通のサイトが「参照元」の意味で使うため。- パスは1セグメントまるごと一致のときだけ。
/reference/apiや/2026/invitation-to-summitは判定しません。 - SAFE_HOSTS(大手ドメイン)でも判定します。アフィリエイトを
amazon.co.jpでも 判定するのと同じで、パラメータの存在は事実だからです。
AppsFlyer / Branch / Adjust 等のアプリ計測リンク(*.onelink.me, *.app.link,
*.adj.st …)は「行き先が隠れている」点が短縮URLと同じなので 🔗短縮 に入れています。
招待コード付きなら 🎁 と 🔗 が両方出ます。
LinkMisty の /c/ カードは、HTML内にある同一ページの ?lm_go=1 を確認し、
クリック時の転送先を判定します。取得ONで招待先が確認できた場合は、
リンクカードを覆い、その招待先の種類(例:「TikTok Lite 招待」)を表示します。
通常の招待リンクは小バッジのままです。取得OFFでは 🔗短縮のみ表示します。
画像がリンクそのものなのか、投稿者が別に貼った画像なのかを区別します。
| Xの要素 | 正体 | 扱い |
|---|---|---|
card.wrapper / card.layout*.media |
リンクカード。クリックで外部サイトに飛ぶ | バッジを載せ、強調対象なら覆う |
tweetPhoto / videoPlayer / previewInterstitial |
投稿者の添付メディア。アンカーは /user/status/<id>/photo/1 でXの画像ビューアに飛ぶだけ |
原則覆わない。バッジはリンクの脇に出す |
本文にリンクがあるからといって添付画像を覆うと、そのリンクとは無関係な写真を隠すことになります (実例: ニュース媒体が「本文にリンク+別途スクショを添付」しているポスト)。
例外は BAIT_COVER_KINDS(🔞アダルト / 🚨連投 /
⚠️ 描画先は結果を適用する直前に取り直します。Xは画像/カードを本文より遅れて挿入することがあり、processTweetの時点で決めたtargetを使い続けると、画像が出てきても覆えません (実機で確認済み。cover対象だと「画像を覆う」という中心機能が黙って効かなくなる)。
本体と引用元を別々に判定し、引用元だけを覆えます。Xが引用元をどう描くかには複数パターンがあり、 単一の前提に乗ると引用元が丸ごと処理対象から落ちるため、3段構えにしています。
- 描画先はリンクごとに決める —
anchor.closest(カード)。引用元のカードリンクはアンカー自身が そのカードの中にあるので、引用かどうかを判定しなくても正しい場所に出る。 - 引用元の特定は2通り試す —
div[role="link"]で見つからなければ、属性に依存しない構造から 割り出す:「作者表示(User-Name)が2つある=引用あり」→「2つ目を含み1つ目を含まない最大のブロック」。 - アンカーが無い場合を補完 — 引用元コンテナ自体がリンクなので、Xは引用元本文のURLを
<a>にせず 素のテキストで描くことがある。その場合は引用元のテキストからURLらしいトークンを拾う。
npm test依存ゼロ(Node標準のみ)。ユニットテスト+background統合テスト+MV3起動時の競合テストを npm test で実行します。
content.js の回帰テスト(ブラウザで実行)
npm run test:domhttp://localhost:8933/test/content-test.html を開くとDOM回帰テストが走ります。
content.js は MV3 の制約で ES モジュールにできず、Node から import できません。
そのため実ブラウザでDOMを組み立てて検証します。描画の検証は processTweet() を直接呼び、
遷移の検証は交差通知をスタブにして、実際のMutationObserverから判定・描画まで通します。
過去に見つかったバグがそのまま項目になっています — リプ欄1件目が抜ける /
引用タブで🚨が誤爆する / 添付画像を覆ってしまう / article ノード自身を取りこぼす /
ポスト内部の遅延追加・href差し替え / 遷移後に詳細判定が消える / 判定後のカード追加 /
カード見出しとは別のアンカーに表示ドメインがある。
背景処理ではt.co・LinkMistyの一時的な解決失敗もキャッシュせず、表示側で最大2回再試行します。
MV3の起動直後も設定・権限・旧キャッシュの更新を待ってから判定します。取得・カテゴリ・権限の変更時はディスクキャッシュも無効化します。
ハーネス側で踏んだ罠があるので、書き換えるときは注意してください。
- 実物のIntersectionObserverに依存しない。 バックグラウンドタブ(
visibilityState: "hidden")では 交差を報告しないので、テストが環境依存で通らなくなります。交差通知だけスタブにします。 content.jsは毎回クエリを付けて動的に読み込む。 静的な<script src>だと ブラウザキャッシュが効いて、わざとバグを入れても全件パスします(実際にそうなり、 テストが何も検証していない状態になりました)。- 同じ理由で
setTimeoutも使いません(バックグラウンドで1秒程度まで throttle され、 全体が数十秒かかる)。非同期の再現にはqueueMicrotaskを使い、再試行タイマーもハーネスで制御しています。
このテストは変異テストで検証済みです。引用元ガードを外すと該当2件が、
pending を1件保持に戻すと該当6件が、それぞれ落ちることを確認しています。
動作テスト(Xを開かずに確認) 拡張機能アイコン → 一番下の「動作テスト」にURLを貼って「判定」。 Xで出るのと同じ判定結果が出ます。カテゴリ別のプリセットボタンもあります。
表示テスト
test/fixture-x.html をブラウザで開くと、XのDOM構造を再現したダミーに実際の content.js と
badge.css を読ませた結果が見られます(判定結果はスタブ)。通常ポスト / 引用元がアダルト /
引用元に role="link" が無い / 引用元URLが素のテキスト / 覆わないカテゴリのみ / 画像なし
などのパターンを網羅しています。
デモ画面(スクリーンショット用)
docs/demo.html は、Xのタイムラインの見た目を再現したデモです。こちらも実際の content.js と
badge.css を読み込むので、バッジの描画は拡張機能の実物と完全に同一です(判定結果だけスタブ)。
ライト/ダーク切り替えと、UIパネルを隠すボタンがあります。
投稿内容は先頭の POSTS 配列を編集すれば差し替えられます。実在サイトを名指ししないよう、
ドメインは .example の架空のものにしてあります。
実URLでの判定確認(ルール調整用)
node test/verify-url.js "https://example.com/article"実URLをfetchして全カテゴリの判定結果と、有料CTA候補の語を出します。
npm run packmanifest.json / src/ / icons/ だけを link-radar.zip にまとめます。
テスト・ドキュメント・デモは含みません(ストアには不要なため)。
GitHubリポジトリにはそれらも含めて全て置いています。
新しいカテゴリ … rules.js にデータ → classifier.js/pagesignals.js に検出関数 →
CATEGORIES に1行 → content.js の BADGE に表示定義 → badge.css に色。
CATEGORIES に足せばポップアップの設定UIにも自動で出ます。
有料判定の精度を上げる
- 対象媒体の有料記事と無料記事のURLを1本ずつ用意
node test/verify-url.js "<有料記事URL>"→ 出力の「有料っぽい語」を確認- 有料記事だけに出る固有フレーズを
PAYWALL_SITESの該当媒体paidReに追記、confirmed:trueに node test/verify-url.js "<無料記事URL>"で 無料が free と出ることを確認
⚠️ ナビやフッタに常時出る語(「有料会員」「会員限定」単体)は使わないこと。 無料ページでも誤検出します。本文の続きを塞ぐ固有フレーズだけを採用してください。
アダルトドメイン … ADULT_HOSTS に追記。
同一ドメインで一般向けとアダルトが分かれるサイトは "dmm.co.jp/digital" のようにパス前方一致でも書けます。
利用者が足すドメイン … コード変更は不要です。ポップアップの「自分で登録したドメイン」から
setUserRules()(rules.js)で、リモート取得を入れるときも同じ口を使います。
Xの表示テキストを丸ごとURLにしてはいけない。 カードリンクのアンカーは innerText に「ホスト+見出し+説明」が改行で連なります:
gigazine.net
【朗報】新型ゲーム機が発表される
これを空白除去して https:// に繋ぐと、日本語がホスト名の一部として解釈され、URLパーサが
punycodeホスト (gigazine.xn--net-w03bf…) を生成します。かつてこれでほぼ全てのカードリンクが
誤判定されていました。urlFromDisplayText() は空白区切りトークンのうち
「先頭から末尾までがホスト(+パス)」のものだけを採用します。
t.co を最終ホストとして返してはいけない。
Xのリンクは全て t.co に包まれています。最終ホストを t.co のまま返すと、t.co は SAFE_HOSTS
なので連投判定が一度も発火しません。background.js は「fetchしてよい完全なURL(realUrl)」と
「ホスト判定に使うホスト(realHost)」を明確に分け、表示テキストのホスト(パスは省略されうるが
ホストは信頼できる)を後者に使います。test/test-background.js がこの回帰を固定しています。
リンク先の取得(有料/広告量/登録必須/短縮の行き先)を待ってから描画すると、その間ポストに バッジが1つも出ず「遅い」と感じます。そこで background は2回に分けて返します。
- URLだけで分かる分を即座に返す(アダルト既知ドメイン/アフィ/短縮/まとめ/直DL)→ すぐ描画
- 取得が終わったら
classifyUpdateを content script へ送り、上書きで描き直す
連投カウントは2段階目でのみ行います。1段階目の時点では行き先が未確定で、 短縮URLのドメインで数えてしまうためです。
判定結果は6時間 chrome.storage.local にキャッシュされます。
拡張機能のバージョンが変わると自動で全部捨てます(ルールを直したのに古い誤検出が
残り続けるのを防ぐため)。ユーザー登録ドメインを変更したときも同様に捨てます。
手動で消したいときはポップアップの「判定キャッシュを消す」から。
storage.local の既定クォータは約10MBで、TTL切れの項目は放っておいても消えません。
何もしないと数週間で埋まり、埋まると set が例外になってディスクキャッシュが黙って効かなくなります。
そこで pruneCache() が期限切れを捨て、それでも CACHE_MAX_ENTRIES(3000) を超える分は
古い順に削ります。起動時と、200回書き込むごとに走ります。
(chrome.alarms で定期実行してもよいのですが、権限を1つ増やすと審査で説明する範囲が
広がるので避けています。service workerは頻繁に落ちて起動し直すため、これで十分刈られます。)
- 広告過多は初期HTMLに置かれている広告枠だけを数えます。完全にJSで動的生成される枠は 数えられず、実際より少なく出ます(誤検出は起きない方向)。
- リンクを含まないポスト(画像だけで「プロフのリンクから」と誘導するタイプ)は判定できません。
- 有料判定はサーバー側HTMLに出るマーカーだけを見ます。JSでのみ有料化する媒体は取りこぼします。
- Amazonの
tag=はリンクが省略表示されていると拾えないことがあります(amzn.to短縮は検出可)。 - 連投は同一ページ内のカウントのみ。ページを離れるとリセットされます。
- Xのレイアウト変更・各媒体のHTML変更で壊れ得ます。
- Chrome ウェブストア公開
- ルールをGitHub等からリモート取得(拡張機能を更新せずに媒体・ドメインを追加できるように)。
差し込み口は
setUserRules()が既にあるので、取得・検証・キャッシュを足す形になります。 MV3はリモートコードを禁じているのでJSONデータのみ。正規表現を配るならパターン長に上限を 設けてReDoSを防ぐこと。取得失敗時は同梱ルールで動き続けること。 導入時はdocs/PRIVACY.mdの更新が必要です(配信元にIPが見えるため)。
採用しなかったもの
- 要注意ドメインを公開ブロックリストから自動充填 … 鮮度・性質の両面で見送りました。理由は docs/DETECTION.md を参照。
<all_urls>をオプション権限にする … ホスト権限が無いと service worker の fetch が CORS で弾かれ、t.co の解決自体ができません。 Xはほぼ全リンクを t.co で包むので、既定でOFFにすると機能の大半が動かない別物になります。 審査対策は権限を削ることではなく、申請時の説明で行います。
MIT License — LICENSE を参照。