# Bluesky で「短期間に多人数とインタラクトしたアカウントのリスト(自動更新)」を作った
[Bluesky](https://bsky.app/) にはユーザがリストを作り,そのリストを第三者がまるごと購読・ミュート・ブロックできる機能があります.この機能を使って SPAM アカウントの影響を減らせないかと考えました.
SPAM アカウントの特徴は**短期間**に**多人数**とインタラクトすることです.一般に SPAM アカウントは手当たり次第にリプライ・引用ポストで他のアカウントに介入してユーザを悪質なウェブサービス等に誘導します.単一の成功率は非常に低いですが多数に送ることで誰かに刺さることを期待しています(参考:[One response for every 12.5m e-mails can fetch millions](https://www.newindianexpress.com/lifestyle/tech/2008/Nov/12/one-response-for-every-125m-e-mails-can-fetch-millions-6311.html))
そこで,Bluesky の API を利用して短時間に多人数とインタラクトしたアカウントのリストを作ってみました.一旦登録されたアカウントも該当するアクティビティが一定期間なければリストから自動で削除されます.
- 短時間でのリポスト相手が多いアカウント https://bsky.app/profile/tmaehara.bsky.social/lists/3klaalnbyfd2q
- 短時間での引用ポスト相手が多いアカウント https://bsky.app/profile/tmaehara.bsky.social/lists/3klaalcinmk2w
- 短時間でのリプライ相手が多いアカウント https://bsky.app/profile/tmaehara.bsky.social/lists/3klaajwru372o
- 短時間でのライク相手が多いアカウント https://bsky.app/profile/tmaehara.bsky.social/lists/3kla6hmpa5s2e
- 短時間でのフォロー相手が多いアカウント <s>`https://bsky.app/profile/tmaehara.bsky.social/lists/3klaahvob3d22`</s> https://bsky.app/profile/tmaehara-lists.bsky.social/lists/3klgyd74xyz2p
各種パラメタの匙加減は実際に対策したい SPAM アカウントを見て調整するものですが,2024-02-13 時点で Bluesky は割と平和なので対策するべき SPAM アカウントが見当たらず調整できていません.実際,現状で引っ掛かっているアカウントは SPAM っぽくないものも多いです(特にライク・リポストは常識的な利用をしているユーザが多く引っ掛かっています).そのため,現時点ではこの基準で引っかかるユーザはどんなだろう,と見てみるくらいがちょうどよい使い方じゃないかと思います.
ソースコードは https://github.com/spaghetti-source/bluesky-activity-based-list にあります.
## アルゴリズム
アルゴリズムは素朴には以下のように記述できます(定期的にリストから削除する部分は省略).
```=
prepare set[account][date] for all accounts.
for each (account1 interacts account2) in interactions: # read from firehose
insert account2 to set[account1][now]
if len(set[account1][now]) >= threshold:
insert account1 to the Bluesky list
```
考えるのは `set[account][date]` をどのようなデータ構造で実装するかです.手続きから,このデータ構造は以下の2つの操作を効率的にできることが要求されます.
1. 要素を挿入する
2. 要素数が閾値以上かを判定する
平衡二分探索木 (TreeSet in Rust) やハッシュテーブル (HashSet in Rust, set in Python) といった標準的なデータ構造も使えますが,これらは全インタラクションを覚えられるくらいパワフルなのでメモリが心配になります.そこでここでは [Bloom Filter](https://en.wikipedia.org/wiki/Bloom_filter) の特殊版である次のデータ構造を利用します.
集合を N ビットの数で表現します.要素の挿入は「その要素をハッシュで 1 .. N の整数に変換し,その桁目のビットを 1 にする」ことで実装します.コードで書くと次のようになります.
```py
set[account1][now] |= 1 << hash(account2)
```
要素数が閾値 k 以上かは「立っているビット数が k 以上」で判定します.コードで書くと次のようになります.
```py
if set[account][now].bit_count() >= k:
....
```
構成から,この手続きは要素数が k 未満のときは正しい結果を返し,要素数が k 以上のときはハッシュが衝突して立っているビット数が k 未満となりうるので確率的に間違えます.ユースケースで言い換えると,アクティビティが少ないアカウントを間違えてリストに入れることはないがアクティビティの多いアカウントを見逃すことはある,となって保守的な運用をしたいリストの実装としては望ましい側の間違え方といえます.間違える確率の正確な評価は確率論の有名問題 [classical occupancy problem](https://probabilityandstats.wordpress.com/2010/03/27/the-occupancy-problem/) に帰着でき,$\Omega(k \log k)$ だけ要素があれば十分高い確率で YES と答えることがわかります.
ちなみに,真の Bloom Filter は上のデータ構造のハッシュを複数使うように修正したものです.Bloom Filter は要素数判定の精度が落ちるかわりに要素が集合に入っているか(メンバシップクエリ)の精度が上がっています.
## 実装
上述したアルゴリズムを実装するだけです.現在の Bluesky のアカウント数は 10M 未満なので,デイリーアクティブアカウント数は 1/10 倍して 1M 未満と見積もります.1M 個の B ビットの数なんて何をやったって処理できる分量なので,実装は他の要素で決めます.ここでは簡単のため Python の [atproto](https://atproto.blue/en/latest/) ライブラリを用いることにします.
プログラムをいつでも再起動できるようにデータは外部で管理します.デイリーアクティブアカウント数が少ないことと,firehose からの流量も1クライアントで処理しきれる程度の量しかないことから,off-the-shelf のファイル型データベースの SQLite を使います.すなわちテーブルの定義を sqlalchemy で
```python=
class User(Base):
__tablename__ = "users_activity"
action = Column(String(8), nullable=False, primary_key=True) # liked, replied, reposted, quoted, followed
id = Column(String(32), nullable=False, primary_key=True) # did of the account
timestamp = Column(DateTime(timezone=False), primary_key=True) # hourly partition
value = Column(LargeBinary) # simplified bloom filter
```
とし,Firehose から該当の行動が流れてくるたびに
```python=
for h in reversed(range(hours)):
timestamp = (datetime.today() + timedelta(hours=h)).replace(
minute=0, second=0, microsecond=0
)
user = (
session.query(User)
.filter(
User.action == action,
User.id == source,
User.timestamp == timestamp,
)
.first()
)
if user:
curr_value = bit_or(user.value, value)
user.value = curr_value
session.merge(user)
else:
user = User(action=action, id=source, timestamp=timestamp, value=value)
session.add(user)
curr_count = popcount(curr_value)
if curr_count >= thresholds[action]:
# 以下,実際にリストを操作する処理
```
で更新します.2時間分書き込んでいるのは,閾値 100 に対して 00:59:59 に 99 件インタラクトして 01:00:00 に 1 件インタラクトするのがセーフになるのが気に食わなかったからです.上記の実装だと「1時間以内に閾値以上インタラクトしたら常にリスト追加,2時間以内はタイミングによる」となります.
予備実験により,リストに引っかかるブロック件数がたいして多くないことがわかったので,毎リクエストごとにブロック解除も SQLite に問い合わせをしてしまうことにします.もっと件数が増えてきて間に合わなくなったら再考します.
全体の実装としては割とシンプルです.興味のある方は冒頭のリンクを辿ってソースコードを参照してください.
---
## 思想的なもの
快適なソーシャルメディア利用には SPAM アカウントを含む悪質なアカウントの対応が不可欠です.従来のソーシャルメディア (Twitter, Facebook, etc) では,この対応は運営側のモデレーション(アカウント停止など)と,ユーザ個人のモデレーション(ブロック・ミュートなど)の2階層で行われていました.Bluesky のユーザが作成したモデレーションリストをユーザ同士で共有する機能はこの間に新しい階層を設けるものといえます.
ユーザが作るモデレーションリストは,誤った判断をしてもリスト作成者とその利用者だけが責任を負うため運営よりはずっと大胆な対応ができます.一方でユーザが作るモデレーションリストは複数の(特に不特定多数の)ユーザが利用する可能性があるため個人よりもずっと慎重な対応が求められます.特にリストに「悪質なアカウント」とか「ブロック推奨」みたいな文言をつけて公開してしまうと個人単位では問題なかった誤ブロックが誹謗中傷になりますし,昔は悪質だったけど今は健全になったアカウントを戻さないことにもリスクがあります.
これに対する最も現実的な解決は小さなコミュニティでのみ利用するという "個人利用のちょっと上" を目指すものだと思います.ただ,それでは技術屋としては面白くないので,もっと広い範囲で利用できるものを考えてみます.わたしが考える範囲だと以下の2案がすぐに思い付きます.
1. コミュニティ主導にして間違いの数を減らしつつ責任を分散する
2. 機械的に判断できる基準を自動運用して責任を利用者に押し付ける
この記事で目指したのは 2 の方向です.特に今回実装した基準「短時間でいっぱいフォローしたユーザ」などは原理的には誰でも確認できるものなのでリスト利用者に判断を任せやすいという性質があります(cf: AIベースの手法は説明可能性がない).この記事でやっているように,ソースコードを公開することで意図通り実装されているかを第三者が確認することもできます.実用上の利点として,自動登録することで突発的に現れたアカウントにも対応できますし,自動削除することで Bluesky 開始直後にいっぱいフォローしたらリストに入ってしまった,みたいなのも対応できます.
個人的にはソーシャルメディアの SPAM 対策はこの類のルールベースのリストで出現直後をカバーしつつ,そこで稼いだ時間で信頼度の高いコミュニティリストに移譲するのがよいんじゃないかなあと思っています.コミュニティ側を主導するのはめちゃくちゃ面倒そうなので全然やりたくないですが…….