🔙

[Data Portability]curlとDuckDBでデータ分析できるActivityPub C2S実装(GetBack)

2024/12/07に公開

Intro

curlとDuckDBでデータ分析できるGetBackというツールを作りました。
早速GlitchなどのBashやLinuxコマンドが使える環境で動かしてみましょう。
duckdbコマンドがなければGitHubからDuckDBをダウンロードしてきます。

$ curl -fsSL https://acefed.gitlab.io/getback.sh | RANGE=20 bash -s -- https://mastodon.example.com/@Alice
# Glitchの場合下記コマンドも必要
# $ echo '/bin' > .gitignore
# $ rm index.html
# $ rm -rf .cache .git
# $ refresh

こうすることでMastodonから400 Posts分JSONをGETできます。
もちろんMastodonだけではなくMisskey, Pleromaといったtype: OrderedCollectionPageが存在するActivityPub実装であれば大体いけます。

DuckDB

ところで皆さんDuckDBはご存知ですよね。

https://duckdb.org/

ご存じない人はぜひZennの人気記事を上から順番に読んでみてください。

https://zenn.dev/topics/duckdb?order=alltime

ご存知になったら次へ進んでください。

Just Do It

まずDuckDBのパスを通します。

$ export PATH=$PWD/bin:$PATH

こうすることでcontent内にある投稿内容をdata.txtに出力してくれます。

$ duckdb -noheader -list -s "SELECT item->'object'->>'content' FROM (SELECT unnest(orderedItems) AS item FROM read_json_auto('data.jsonl.gz')) WHERE item->'object'->>'content' IS NOT NULL;" > data.txt
# <p>Example</p>

他にもActivityPubに必要不可欠なIDのURLを出力したり、

$ duckdb -noheader -list -s "SELECT id FROM read_json_auto('data.jsonl.gz');" > data.txt
# https://www.example.com/users/alice/outbox?page=true
$ duckdb -noheader -list -s "SELECT item->>'id' FROM (SELECT unnest(orderedItems) AS item FROM read_json_auto('data.jsonl.gz'));" > data.txt
# https://www.example.com/notes/EXAMPLE/activity
$ duckdb -noheader -list -s "SELECT item->'object'->>'id' FROM (SELECT unnest(orderedItems) AS item FROM read_json_auto('data.jsonl.gz')) WHERE item->'object'->>'id' IS NOT NULL;" > data.txt
# https://www.example.com/notes/EXAMPLE

画像のURLを出力したりできます。

$ duckdb -noheader -list -s "SELECT attachment->>'url' FROM (SELECT unnest(json_extract(item->'object'->>'attachment', '\$[*]')) AS attachment FROM (SELECT unnest(orderedItems) AS item FROM read_json_auto('data.jsonl.gz')));" > data.txt
# https://media.example.com/files/00000000-0000-0000-0000-000000000000.png
# attachmentがarrayではなくobjectの場合(Holloなど)
$ duckdb -noheader -list -s "SELECT item->'object'->'attachment'->>'url' FROM (SELECT unnest(orderedItems) AS item FROM read_json_auto('data.jsonl.gz')) WHERE item->'object'->>'attachment' IS NOT NULL;" > data.txt
# https://media.example.com/files/00000000-0000-0000-0000-000000000000.png

出力した画像のURLをアレするコマンドです。

$ while read -r line; do curl -sLo "media/$(date +%s%N)" --create-dirs "$line"; done < data.txt
# ファイル名をハッシュ値にして同じ画像をアレしない場合
$ while read -r line; do curl -sLo media/data.bin --create-dirs "$line" && mv media/data.bin "media/$(sha256sum media/data.bin | cut -d ' ' -f1)"; done < data.txt

出力前に個数をカウントすることもできます。

$ duckdb -noheader -list -s "SELECT count(item) FROM (SELECT unnest(orderedItems) AS item FROM read_json_auto('data.jsonl.gz'));" > data.txt
127
$ duckdb -noheader -list -s "SELECT count(attachment) FROM (SELECT unnest(json_extract(item->'object'->>'attachment', '\$[*]')) AS attachment FROM (SELECT unnest(orderedItems) AS item FROM read_json_auto('data.jsonl.gz')));" > data.txt
31

ワンライナーでSQLの内容を理解できない場合は以下のスクラップを参照してください。

https://zenn.dev/tkithrta/scraps/6e766730047aed

試していませんがMacでも使えると思います。

$ curl -fsSL https://acefed.gitlab.io/getback.sh | DUCKDBOS=osx DUCKDBARCH=universal bash -s -- https://mastodon.example.com/@Apple

RANGEのデフォルトは100ですが、サーバーがエラーを返したりHTMLを返してきたりすると止まるようになっているので安心して使えます。

getback.sh

$ curl https://acefed.gitlab.io/getback.sh

こんな感じのコマンドが表示されます。

mkdir -p data
if ! command -v duckdb >/dev/null; then
  if [ ! -f bin/duckdb ]; then
    curl -LO --progress-bar "https://github.com/duckdb/duckdb/releases/latest/download/duckdb_cli-${DUCKDBOS:-linux}-${DUCKDBARCH:-amd64}.zip"
    unzip -q "duckdb_cli-${DUCKDBOS:-linux}-${DUCKDBARCH:-amd64}.zip" -d bin
    rm "duckdb_cli-${DUCKDBOS:-linux}-${DUCKDBARCH:-amd64}.zip"
  fi
  export PATH=$PWD/bin:$PATH
fi

outbox=$(curl -sL -H 'Accept: application/activity+json' "$1" | duckdb -noheader -list -s "SELECT outbox FROM read_json_auto('/dev/stdin');" 2>/dev/null || echo "$1")
next=$(curl -sL -H 'Accept: application/activity+json' "$outbox" | duckdb -noheader -list -s "SELECT first FROM read_json_auto('/dev/stdin');" 2>/dev/null || echo "$outbox")
data=data/$(date +%s%N).json
for _ in $(seq 1 "${RANGE:-100}"); do
  curl -sL -H 'Accept: application/activity+json' "$next" > "$data"
  echo >> "$data"
  next=$(duckdb -noheader -list -s "SELECT next FROM read_json_auto('/dev/stdin');" < "$data" 2>/dev/null || echo 'EOF')
  if [ "$next" = 'EOF' ]; then
    break
  fi
  data=data/$(date +%s%N).json
done

cat data/*.json > data.jsonl
gzip -fk data.jsonl
duckdb -noheader -list -s "SELECT count(item) FROM (SELECT unnest(orderedItems) AS item FROM read_json_auto('data.jsonl.gz'));" > data.txt
gzip -fk data.txt
echo "NEXT $data $next"

curlとDuckDBのほか、Bash, Coreutils, gzip, unzipといったLinuxコマンドだけを使用します。
実はGetBackは元々curlとjqで作られていて、半年ぐらい前から公開していたのですが、ほとんど宣伝しておらず、今回jqをDuckDBに置き換えたのもあって記事にしました。

Plot

GetBackでマジでデカいdata.jsonl.gzを出力したらPythonのpandasとmatplotlibでデータ分析してみましょう。

$ touch hist.py
$ pip install duckdb pandas matplotlib
hist.py
import duckdb
import pandas as pd
import matplotlib.pyplot as plt

sql = """
SELECT item->>'published' AS published
FROM (
    SELECT unnest(orderedItems) AS item
    FROM read_json_auto('data.jsonl.gz')
  )
"""
df = duckdb.query(sql).df()

plt.figure(figsize=(12.8, 9.6))
plt.hist(pd.to_datetime(df["published"]), bins=2000)
plt.savefig("result.png")
$ python hist.py

result.pngが生成されているので確認してみましょう。

result

投稿数がヒストグラムでとてもわかりやすくなりました。

FAQ

Q. これってActivityPub実装?
A. ActivityPub実装です。

ActivityPubのOverviewを見てみましょう。

https://www.w3.org/TR/activitypub/#Overview

多分皆さんが想像するActivityPub実装は

A server to server federation protocol (so decentralized websites can share information)

サーバー間のフェデレーション・プロトコル(分散化されたウェブサイトが情報を共有できるようにする)

のほうだと思うのですが、その下に

A client to server protocol (so users, including real-world users, bots, and other automated processes, can communicate with ActivityPub using their accounts on servers, from a phone or desktop or web application or whatever)

クライアント・ツー・サーバー・プロトコル(実世界のユーザー、ボット、その他の自動化されたプロセスを含むユーザーが、携帯電話やデスクトップ、ウェブアプリケーションなどから、サーバー上のアカウントを使ってActivityPubと通信できるようにする。)

とあります。まさに今回やったことですね!

ActivityPub implementations can implement just one of these things or both of them.

ActivityPubの実装は、これらのどちらか一方だけを実装することもできるし、両方を実装することもできる。

なのでFediverseに参加できるサーバー実装とは言えませんが、ActivityPubのクライアント実装であることは間違いないでしょう。

Q. これってC2S実装?
A. C2S実装です。

Overviewをさらに読み進めてみましょう。

You can POST to someone's inbox to send them a message (server-to-server / federation only... this is federation!)

誰かの受信トレイにPOSTしてメッセージを送ることができる(サーバー間/フェデレーションのみ...これはフェデレーションだ!)。

これも皆さんが想像するActivityPub実装ですね。

You can GET from your inbox to read your latest messages (client-to-server; this is like reading your social network stream)

受信トレイからGETして最新のメッセージを読むことができる(クライアントからサーバーへ。)

これは違いますね。今回はinboxではなくoutboxからGETしているので。
ちなみにOAuth 2.0を使えば実装できるみたいです。
私はほとんど見たことないですが。

You can POST to your outbox to send messages to the world (client-to-server)

送信箱にPOSTして、世界中にメッセージを送ることができる(クライアント・ツー・サーバー)。

これも違いますね。今回はPOSTではなくGETしているので。
これもOAuth 2.0を使えば実装できるみたいです。
私はほとんど見たことないですが。

You can GET from someone's outbox to see what messages they've posted (or at least the ones you're authorized to see). (client-to-server and/or server-to-server)

誰かの送信トレイからGETして、その人が投稿したメッセージ(あるいは、少なくともあなたが見ることを許可されているメッセージ)を見ることができます。(クライアントからサーバ、あるいはサーバからサーバ)

これですね! client-to-server and/or server-to-serverなのでcurlとDuckDBでActivityPubのClient to Server実装をしたとみなしていいでしょう。

Q. DoS攻撃になるのでは?
A. これが結構難しくて、

実はGetBackのアイデア自体は数年前からあって、誰もやっていないのはみんな思いつくけどDoS攻撃になるからでは? と思っていました。

はたして本当にそうなのでしょうか?
ActivityPubの5.1 Outboxを読んでみましょう。

https://www.w3.org/TR/activitypub/#outbox

Outbox は、アクターのプロファイルの Outbox プロパティを通して発見される。送信トレイはOrderedCollectionでなければならない[MUST]。
outboxストリームは、アクティビティを取得するリクエスト側の能力(つまり、outboxのコンテンツは、それを読む人のパーミッションによってフィルタリングされる)を条件として、ユーザーが公開したアクティビティを含む。ユーザーがAuthorizationなしでリクエストを送信した場合、サーバーはすべてのPublic投稿で応答する必要があります。これは、ユーザーが公開したすべての関連オブジェクトになる可能性がありますが、利用可能なアイテムの数は、サーバーを実装およびデプロイする人の裁量に任されています。

なるほど。念の為ChatGPTに聞いてみました。

GPT-4o
You:
ActivityPubの5.1 Outboxには以下の記述があります。クライアント実装に制限はありますか?

The outbox is discovered through the outbox property of an actor's profile. The outbox MUST be an OrderedCollection. 
The outbox stream contains activities the user has published, subject to the ability of the requestor to retrieve the activity (that is, the contents of the outbox are filtered by the permissions of the person reading it). If a user submits a request without Authorization the server should respond with all of the Public posts. This could potentially be all relevant objects published by the user, though the number of available items is left to the discretion of those implementing and deploying the server.
The outbox accepts HTTP POST requests, with behaviour described in Client to Server Interactions.

ChatGPT:
ActivityPubの5.1 Outboxセクションに記載されている内容から、クライアント実装に対して直接的な技術的制限は設けられていません。ただし、以下の点に留意する必要があります。

### 必須要件
- **Outboxの発見と形式**
  - Outboxは、アクターのプロフィール内の`outbox`プロパティを通じて発見される必要があります。
  - Outboxは必ず`OrderedCollection`である必要があります。

- **データのフィルタリング**
  - Outboxに含まれる活動(アクティビティ)は、リクエストを行うユーザーのアクセス権に基づいてフィルタリングされます。
  - 認証なしのリクエストには`Public`な投稿だけが返される必要があります。

- **HTTP POSTリクエスト**
  - OutboxはHTTP POSTリクエストを受け付ける必要があり、その振る舞いは「Client to Server Interactions」で定義されています。

### クライアント実装に対する制限がない理由
この仕様は、サーバー側の振る舞いを規定していますが、クライアントについては「サーバーが定義するこれらの仕様に従って通信すること」を前提にしているだけで、具体的なクライアントの設計や実装方法に制限を課していません。

例えば:
- クライアントはどのようなUI/UXで`outbox`を利用するか自由です。
- クライアントはリクエストの認証やアクセス制御の仕組みを独自に実装できます(ただしサーバーが定める仕様を満たす必要があります)。

### 注意すべき点
クライアント実装では次の点を意識する必要があります:
1. **サーバーの仕様に準拠**:
   - Outboxへのアクセスや投稿時に、ActivityPubプロトコルで定められたHTTPメソッドやリクエスト形式を遵守する必要があります。

2. **認証の処理**:
   - 認証なしの場合に`Public`投稿のみ取得できる仕様をサーバーがサポートしていることを前提にする。

3. **エラーハンドリング**:
   - サーバーがHTTPリクエストに対して返すエラーコード(例: 403 Forbidden, 404 Not Foundなど)への適切な対応。

### 総括
クライアント実装には大きな制限はありませんが、サーバー仕様との互換性を意識する必要があります。また、仕様には柔軟性を残している部分(例えば、サーバー側で返す項目の量など)があるため、相互運用性を確保するためにはテストやプロトコルに対する理解が重要です。

なるほど。
クライアント側ではなくサーバー側で認証をかけたりエラーハンドリングを行うべきで、クライアント側で取得するにあたりあまり深く考える必要はなかったみたいです。
実際GetBackはマルチスレッドで動かしているわけではないですし、dataディレクトリにJSON書き込む処理で待ち時間もあるので、サーバーに意図的に負荷をかける行為に該当しないのではないのでしょうか。

Q. 何か対策は?
A. サーバーのOrderedCollectionPageで適切なエラーを返す

ActivityPubのサーバー実装では必ずtype: OrderedCollectionのoutboxエンドポイントを実装しなければなりませんが、type: OrderedCollectionfirstから呼び出すtype: OrderedCollectionPageでは特に制限は設けられていないようです。
こういうのはどうでしょうか?

  1. OrderedCollectionPageすべてのページで403 Forbiddenを返す
  2. Bearer認証などで認証されたクライアント以外は401 Unauthorizedを返す
  3. nextのcursorが一定値超えたら403 Forbiddenか401 Unauthorizedを返す
  4. Rate Limitingを設けてアクセス数が一定値超えたら429 Too Many Requestsを返す
  5. nextのcursorが一定値超えたらlast pageにする

例えば1000 Postsしている人がいたとして、400 PostsまではActivityPub実装としてoutboxから取得できるようにして、残りの600 PostsはアーカイブされたActivity Streamsとみなす。
つまりActivityPubのoutbox以外の方法で取得できるOrderedCollectionPageとして扱う。
伝わりますかね?

  1. type: OrderedCollectionfirstをなくしてtype: OrderedCollectionPageそのものをなくす

ActivityPub#collectionsとActivity Streams 2.0#collections見返してみたけど問題ないっぽい?

  1. Publicではない控えめな公開で投稿する

Mastodonの未収載や非公開、Misskeyのフォロワーやダイレクトなど

  1. インターネットやめる(Discordにする?)

いわゆるイントラネットを使う。Discordなら誰でもサーバーを建てられる

Q. GetBackで一つのdata.jsonl.gzファイルにするメリットは?
A. Data Portability

  • シェルスクリプトの依存関係が少なく、すぐ使える
  • アカウント不要
  • type: OrderedCollectionPageのあるActivityPub実装全てで使える
  • readlineなJSONなのでjqやスクリプトと相性がいい
  • オフラインでも使える
  • 静的ファイルとしてアップロードできる
    • Apacheのようなgzipで事前圧縮できるサーバーだとさらにお得
  • JSON-LDなのでgzip圧縮率が高い
    • ハフマン符号なので
  • zlessで閲覧したり検索できる
    • JSON Linesなので閲覧はしんどいかも
  • DuckDBにインポートできる(New!)
  • Pythonでデータ分析できる(New!)

ActivityPub実装を作っていると気になってくるのがデータ転送量だったりデータベースのコネクション数だったりするのですが、RDBMSやめる(Redisにする?)、一部ページを動的にしない、圧縮してデータ転送量を減らす、データベースから生成するJSONと生成済みのJSONファイルを結合する、BANしたユーザーにdata.jsonl.gz渡して帰ってもらうなどなど、GetBackがやっている処理から問題解決のアイデアがいくつも浮かびますね。

Q. GetBackでできないことは?
A. Public以外の投稿を取得できない

でもMastodonのActor自身であればcurlでBearer認証されたリクエスト送れば全部取得できる?
いっそのことMastodonやMisskeyにGetBackの処理を組み込んでみては?

Outro

今回はcurlとDuckDBでデータ分析できるActivityPub C2S実装を作りました。
ただDuckDBやPythonを使ってまだまだやりたいことがたくさんありますし、GetBackを作ってたくさんの発見がありました。

例えばTkinterを使ってJSONビューワーを作ったり、PIL.ImageTkを使って画像ビューワーを作ったり、DuckDBそのものをActivityPub実装に組み込んだり、できることがものすごく増えました!
今後のActivityPub実装にご期待ください!

Discussion