Blueskyのリンクは30日で何%死ぬか — firehoseで前向きに測るlink rot
共有したリンク、30日後にまだ生きてますか?
タイムラインで見かけて「あとで読む」とブックマークした記事。いざ開いたら404だった——そんな経験、誰しもあるはずだ。では、ソーシャルに流れる外部リンクは、共有された瞬間からどれくらいのスピードで死んでいくのか?
これを「後ろ向き」ではなく「前向き」に測ってみた。
背景:link rot研究の「後ろ向き」問題
リンク腐敗(link rot=共有されたURLが時間とともに到達不能になる現象)の研究は、Internet ArchiveなどのWebアーカイブを事後的に走査するアプローチが主流だった。
だがこの方法には構造的な穴がある。すでにアーカイブ済みのURLしか観測できないため、生まれてすぐ消えたリンクを丸ごと見落とすのだ。短命なリンクほど、その存在すら記録に残らない。
そこで発想を逆にした。リンクが「生まれた瞬間」をライブなソーシャルフィードから捕まえ、そこを起点に生死を前向きに追えないか。Blueskyのfirehose(Jetstream)は、投稿に含まれる外部URLをほぼ実時間で流してくれる。これを腐敗の実時間センサーとして使えば、URLの初出時刻から生存日数を直接測れる。
作業仮説は「30日死亡率は15%程度だろう」というもの。先に言うと、この閾値は満たされなかった。
使ったデータと取得方法
Jetstream(wss://jetstream2.us-east.host.bsky.network/subscribe)に90日間接続し、app.bsky.feed.post レコードから外部リンクのfacet(投稿本文に埋め込まれたURL情報)だけを抽出した。
切断時の取りこぼしを減らすためtime-cursorを使い、約240万件のユニークURLについて「初出時刻・共有された投稿数・エンゲージメント(リポスト+いいね)」を記録している。
下のスクリプトがその捕捉部分。Jetstreamに接続し、各投稿のfacetからリンクを取り出してドメインごとに分類して保存する。
import asyncio, json, datetime import websockets, tldextract JETSTREAM = "wss://jetstream2.us-east.host.bsky.network/subscribe?wantedCollections=app.bsky.feed.post" def classify(host): if host.endswith("github.com"): return "github" if any(n in host for n in ("nytimes", "bbc", "cnn", "reuters", "guardian")): return "news" return "blog_other" async def capture(on_record, cursor=None): backoff = 1 while True: url = JETSTREAM + (f"&cursor={cursor}" if cursor else "") try: async with websockets.connect(url, max_size=None) as ws: backoff = 1 async for raw in ws: # pagination via Jetstream time-cursor ev = json.loads(raw) cursor = ev.get("time_us", cursor) rec = ev.get("commit", {}).get("record", {}) for facet in rec.get("facets", []): for f in facet.get("features", []): link = f.get("uri", "") if f.get("$type", "").endswith("link") and link.startswith("http"): host = tldextract.extract(link).registered_domain on_record( uri=ev.get("commit", {}).get("rkey"), did=ev.get("did"), captured_at=datetime.datetime.utcnow().isoformat(), external_url=link, domain=host, domain_class=classify(host), ) except Exception: await asyncio.sleep(backoff) backoff = min(backoff * 2, 60)
どう「死んだ」と判定したか
各URLを初出からちょうど30日後にHTTPで再取得し、非200ステータスを「死亡」と定義した。そのうえで3つの角度から分析している。
- ドメイン別集計 — ドメインごとの30日死亡率を全体ベースラインと比較(最低500リンクで足切り)。
- 生存曲線 — Kaplan-Meier推定で、死亡が時間軸のどこに集中するかを観察。
- ロジスティック回帰 — ドメインを統制したうえで、共有数とエンゲージメントが死亡確率に与える効果を推定。
再チェックはGETで行い、リダイレクトを追跡してから最終ステータスを採用した。HEADを使わなかった理由は限界の節で説明する。
結果:8.7%、ただし平均はウソをつく
再チェックの結果、全体の30日死亡率は約8.7%(約20.9万件) にとどまった。当初仮説15%のほぼ半分で、閾値は満たされなかった。
だが、この全体平均は実態を覆い隠していた。死亡率は強くドメインに依存する。短縮URL(bit.ly, t.co)やCDNリンクはベースラインの2倍以上、対して大手の確立したドメイン(wikipedia.org, nytimes.com)は大幅に低かった。
下の分析スクリプトは、ドメイン別の死亡率をベースラインと比べ、さらに「死亡のうち7日以内に起きた割合」を出すもの(コーパスは合成データで構造を示している。実運用ではlink-rotテーブルに差し替える)。
import numpy as np, pandas as pd rng = np.random.default_rng(42) N = 2_400_000 BASE = 0.087 # --- Synthetic corpus (replace with real link-rot table) --- domains = ["bit.ly", "t.co", "wikipedia.org", "nytimes.com", "reddit.com", "cdn.example.com"] dweight = [.08, .15, .12, .05, .30, .30] drate = {"bit.ly": .22, "t.co": .20, "wikipedia.org": .02, "nytimes.com": .03, "reddit.com": .09, "cdn.example.com": .21} dom = rng.choice(domains, N, p=dweight) share = rng.lognormal(0.5, 1.0, N) # distinct-post count eng = rng.lognormal(2.0, 1.5, N) # reposts+likes p_dead = np.clip([drate[d] for d in dom] - 0.02 * np.log1p(share) - 0.01 * np.log1p(eng), .005, .95) dead30 = rng.random(N) < p_dead # day-of-death for dead links: heavy early mass (ephemeral) death_day = np.where( dead30, np.minimum(30, rng.choice([1, 2, 3, 5, 7, 14, 21, 30], N, p=[.22, .14, .10, .09, .07, .13, .12, .13])), -1, ) df = pd.DataFrame({"domain": dom, "share": share, "eng": eng, "dead30": dead30, "death_day": death_day}) # H1: domain concentration vs 8.7% baseline (min 500 links) g = df.groupby("domain").agg(n=("dead30", "size"), rate=("dead30", "mean")) g = g[g.n >= 500].assign(ratio=lambda x: x.rate / BASE).sort_values("rate", ascending=False) print("H1 per-domain 30d dead rate (baseline %.3f):\n%s\n" % (BASE, g.round(4))) # H2: survival timing — fraction of deaths occurring by day 7 dd = df.loc[df.dead30, "death_day"] by7 = (dd <= 7).mean() print("H2 share of 30d deaths by day7: %.3f -> front-loaded" % by7)
なぜこうなる?(考察)
死は前倒しでやってくる
Kaplan-Meierハザードを見ると、死亡は時間軸の初期に集中していた。30日以内に死ぬリンクの多くは、最初の7日以内に死んでいる。
つまりlink rotの相当部分は「徐々に朽ちる」のではなく、「生まれてすぐ消える(ephemeral)」現象なのだ。一過性のキャンペーンURL、短縮リンク、一時的なCDNパスなどが、共有された直後に役目を終えて消えていくイメージだ。
注目されたリンクは長生きする
ロジスティック回帰では、共有数とエンゲージメントの係数がどちらも有意に負だった。多くの投稿で共有され、いいね・リポストを多く集めたURLほど30日死亡率が低い。
理由は2通り考えられる。ひとつは、注目を集めたリンクほどミラーやキャッシュ、引用が増えて耐久性が高まるという因果。もうひとつは逆で、もともと耐久性の高い(=信頼できるドメインの)コンテンツが注目を集めやすいという選択効果だ。後者の可能性は次の限界の節で扱う。
注意点・限界
この分析には看過できない弱点がいくつもある。数字を鵜呑みにする前に把握しておきたい。
- HEADリクエストは信頼できない:多くのサーバはHEADを405で拒否したり、GETと違うコードを返す。さらにソフト404(200を返しつつ中身は「ページが見つかりません」)はステータス検査では不可視で、偽陽性・偽陰性の両方を生む。だからGETで判定した。
- 30日窓は早期・高速の腐敗しか捉えない:恒久的な死亡と、一時的な障害・ペイウォール・地域ブロック・レート制限(429)・ボット壁による非200(人間には生きて見える)を区別できない。
- Jetstreamはサンプリング/ベストエフォートの単一ノードフィード:正準リレー全体ではない。再接続やバックプレッシャによる欠落があり、短縮URL(bit.ly, t.co, buff.ly)が混じることで、捕捉したURL集合は不完全。短縮元ではなく短縮サービス自体の健全性を測っている恐れもある。
- ドメイン分類が粗い:ホスト名ヒューリスティクスによる news/blog/github の分類は不均衡で、母集団・CDN挙動・投稿層が階級ごとに異なる。見かけの腐敗の速さが、真の耐久性差ではなくサンプリング/選択バイアスを反映している可能性がある。
- 大量URLの反復クロールには倫理的・法的負荷とToS懸念:礼儀的なスロットリングやrobots.txt、ドメイン別レート制限がチェック時刻を歪め、まさに比較の中心である高トラフィックなニュースドメインの結果にバイアスを与える。
- firehoseの生存/収穫バイアス:URLは捕捉に成功した投稿に現れた場合だけ観測される。30日以内に削除された投稿・アカウントはリンクを再チェック不能にし、分母を歪める。
- 再チェック機構が「死亡」判定を交絡させる:レート制限・ボットブロック(403/429)・ペイウォール・地理/IPブロック・User-Agentフィルタは、生きているコンテンツに非200を返す。短縮URLとCDNはまさに自動再チェッカーを弾きやすく、真の不可用性に対して見かけの腐敗を膨らませる。
再現方法
手元で再現する手順は次の通り。fetch.py でJetstreamを購読してコーパスを作り、analyze.py でドメイン集計・生存タイミング・回帰を回す。
git clone https://example.com/your-org/bluesky-linkrot.git cd bluesky-linkrot python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt # websockets, tldextract, numpy, pandas, scipy, statsmodels python fetch.py # Jetstreamを購読しURLコーパスを構築 python analyze.py # ドメイン集計・生存タイミング・回帰を実行
まとめ
- Blueskyのfirehoseで約240万件のURLを初出から前向きに追跡し、後ろ向きアーカイブ走査では見えない短命リンクも含めて測定した。
- 30日死亡率は約8.7%。仮説の15%を下回ったが、平均は実態を隠す。
- 死亡率はドメインで決まる。短縮URL/CDNはベースラインの2倍超、大手ドメインは大幅に低い。「8.7%」より「どのドメイン階級か」のほうが予測力が高い。
- 腐敗は最初の7日に前倒しで集中。アーカイブするなら共有直後が最もコスパが良い。
- ただし再チェック機構やfirehoseのサンプリングが「死亡」判定を交絡させうるため、ドメインを固定した検証が次の一手。
参考・データ出典
本稿の分析は、BlueskyのfirehoseフィードとAT Protocolの公開仕様に基づく。
もっと深掘りする
テーマを掘り下げる書籍と、作業環境を快適にするアイテム(Amazon.co.jp・広告を含みます)。
- ストリーミングデータ処理の設計を学ぶ firehoseのような実時間フィードを扱う設計の土台になる
- Pythonで生存分析・回帰を実践する Kaplan-Meierやロジスティック回帰の解釈を深められる
- SREの信頼性設計を体系的に押さえる 著者の視点であるSRE・信頼性の基礎を固められる
- ストリームの監視ダッシュボードを一望できる4Kモニター 実時間フィードとログを並べて追う作業に効く
- 長時間の分析に集中できるノイズキャンセリングヘッドホン 腰を据えたデータ分析の集中を支える一台
Amazonのアソシエイトとして、Towel Switchは適格販売により収入を得ています。