🎃

X(Twitter)とActivityPubを繋ぐ簡易Bridgeを作ってみた

2024/02/11に公開

初めに

ThreadsがActivityPubに対応するのが間近になりBlueskyが一般公開されたり等SNSの生まれ変わりが始まっているのを見かけN番煎じですが、ActivityPubの理解を深めるために制作してみました

Xは乗っ取られて取り返せなかったのでThreadsで活動しています。
良ければつながりましょう!
https://www.threads.net/@kohnoselami

概要

まず、こういったことを構想したときにぶち当たるのは公式APIによるレートリミットや様々な制限です。
良くない手法ではありますが、結構認められ始めているスクレイピングを用いて情報を抽出することでレートリミットを完全にバイパスさせたり、本来のAPIでは取得できないような情報を取得します。

ActivityPubの実装については仕様書から読み解き、独自での実装を行いました
これを行うことでActivityPubの理解を深めることが出来たので、共有させていただきます。
これを作ってもなお、分からない部分や未完成な部分があります。ご容赦ください

流れ

ActivityPubの仕様やMastodon(Misskey)の仕様に従います。

主に、以下のような順序でActivityPubはサーバーへのリクエストが飛んできます。

  1. 誰かが検索窓で「 @X@x-activitypub-bridge.deno.dev 」と検索する (又は、メンションなどが行われる)
  2. サーバー宛に、/.well-known/webfingerというパスにresouceというクエリに「 acct:@X@x-activitypub-bridge.deno.dev 」が付いた状態でリクエストが飛んでくる。WebFingerの形式でユーザーの情報を返すURLを返す。
  3. サーバー宛に、ユーザーの情報を返すURLへ「Accept: application/activity+json」のヘッダーを持ってリクエストが飛んでくる。ActivityStreamsのPersonオブジェクト(等)を返す。
  4. クライアントの画面に情報が表示される

(inbox, outboxの実装はしていません。プロフィールが表示されるだけでフォロー等を行うことが出来なくなります。)

Xへのスクレイピング

Xからのデータの取得には、主に埋め込みAPI非公開APIを利用して取得します。

  1. 埋め込みAPIではレートリミットをバイパスできますが、取得できる情報が少ないです。
  2. 非公開APIではレートリミットをバイパスすることはできませんが、取得できる情報が多いです。

埋め込みAPI

主にXの埋め込みAPIから情報を取得します。
https://publish.twitter.com/#

XはReactで出来ており、埋め込みはNext.jsで出来ています。
この時埋め込みはSSRで提供されているためHTML内のpagePropsよりAPIと同等の情報を得ることが可能です。
そして、何故埋め込みを使うのかと言うとこちらのウェブページにレートリミットは存在しません
ActivityPubの仕様上常に最新のデータを返す必要があり、また逆にXのデータは価値が高いので自分自身がスクレイピングされる恐れがあり大量アクセスに耐える仕組みにする必要があります。
そのために一部の情報は非公開APIから取得するのではなく、埋め込みAPIから取得することでX側によるレートリミットを回避することが出来ます。

https://github.com/c7e715d1b04b17683718fb1e8944cc28/X-ActivityPub-Bridge/blob/main/src/x/syndication_twitter.ts

埋め込みAPIから情報を取得するライブラリをSyndication Twitterと名付けました(そのままです。)
以上のファイルにスクレイピングの処理が含まれます。

例えば、ScreenNameからTimelineProfileを取得するプログラムは以下のような仕組みになります。

  async timelineProfileByScreenName(screenName: string) {
    const response = await this.client.get(
      `https://syndication.twitter.com/srv/timeline-profile/screen-name/${screenName}`,
    );
    const nextData = await response.text()
      .then((text) =>
        JSON.parse(
          text.split('<script id="__NEXT_DATA__" type="application/json">')[1]
            .split('</script>')[0],
        )
      )
      .then((data) => NextDataSchema.parseAsync(data));
    const timelineProfile = await TimelineProfileSchema.parseAsync(
      nextData.props.pageProps,
    );
    return timelineProfile;
  }

引数でstring型のscreenNameを受け取り埋め込みAPIへリクエストを行います。
HTML内のscriptタグからNEXT DATAを取り出しjsonへパースをし、最後にzodで型安全と整形を両立させています。
こうすることである程度の表記揺れを矯正し、型エラーを防ぐことが出来ます。

非公開API

この時に、ついによく使われている非公開APIと呼ばれている方法を用いて情報を取得します。
主に、プロフィールの誕生日やポスト数、いいねをした数、フォロワー数、フォロー数等々が取得できないため、非公開APIを用いて取得します。

https://github.com/c7e715d1b04b17683718fb1e8944cc28/X-ActivityPub-Bridge/blob/main/src/x/web_twitter.ts

非公開APIから情報を取得するライブラリをWeb Twitterと名付けました(そのままです。)
以上のファイルにスクレイピングの処理が含まれます。

例えば、ScreenNameからUserを取得するプログラムは以下のような仕組みになります。

  async getUserByScreenName(screenName: string) {
    const response = await this.client.get('graphql/NimuplG1OB7Fd2btCLdBOw/UserByScreenName', {
      searchParams: new URLSearchParams({
        variables: JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true }),
        features: JSON.stringify({
          hidden_profile_likes_enabled: true,
          hidden_profile_subscriptions_enabled: true,
          responsive_web_graphql_exclude_directive_enabled: true,
          verified_phone_label_enabled: false,
          subscriptions_verification_info_is_identity_verified_enabled: true,
          subscriptions_verification_info_verified_since_enabled: true,
          highlights_tweets_tab_ui_enabled: true,
          responsive_web_twitter_article_notes_tab_enabled: false,
          creator_subscriptions_tweet_preview_api_enabled: true,
          responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
          responsive_web_graphql_timeline_navigation_enabled: true,
        }),
        fieldToggles: JSON.stringify({ withAuxiliaryUserLabels: false }),
      }),
    });
    const user = await response.json()
      .then((json) => UserResponseSchema.parseAsync(json))
      .then(({ data: { user: { result } } }) => result);
    return user;
  }

主に上記の埋め込みAPIと同様で
引数でstring型のscreenNameを受け取り非公開APIへリクエストを行います。
その後jsonへパースをし、最後にzodで型安全と整形を両立させています。

こういったAPIへのリクエスト方法や、APIそのものの見つけ方はChromeのF12のDev Tools、Networkタブより見つけ出すことが出来ます。

求めている情報を返す通信を見つけたら、それを右クリックし「コピー」→「cURL (bash) としてコピー」を押します。
https://curlconverter.com/

上記のコンバーターに張り付けて、PythonやNode.jsコードへの変換を行うことでWebブラウザと同様のリクエストを行うことが出来るためスクレイピングを行うことが容易になります。
色々対策やヘッダーの値に揺らぎを与えることで、こういったスクレイピングを対策しているサービスが増えてきていますが、ここで詳しくは説明しません
少なからずXは簡単なので、これで行けます。

XからActivityPubの形式への変換

先ほど取得したXのデータをActivityPubの形式へ変換しWebFingerやActivityStreamsで返還します。
主に仕様書に従い、Xから取得したデータをObjectへ変換します。
https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person
https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object

Personオブジェクトを/users/0で返還するため、Personオブジェクトを見ます。
そうするとObjectオブジェクトからのExtendモデルであることが分かるため、ObjectオブジェクトのPropertiesを参照してActivityPubに流したい情報をPropertiesに加えてレスポンスを返還します。

例えば以下のような形です。

{
    "@context":[
        "https://www.w3.org/ns/activitystreams",
        "https://w3id.org/security/v1",
        {
            "manuallyApprovesFollowers":"as:manuallyApprovesFollowers",
            "toot":"http://joinmastodon.org/ns#",
            "featured":{
                "@id":"toot:featured",
                "@type":"@id"
            },
            "schema":"http://schema.org#",
            "PropertyValue":"schema:PropertyValue",
            "value":"schema:value",
            "discoverable":"toot:discoverable",
            "indexable":"toot:indexable"
        }
    ],
    "id":"https://x-activitypub-bridge.deno.dev/users/783214",
    "type":"Person",
    "following":"https://x-activitypub-bridge.deno.dev/users/783214/following",
    "followers":"https://x-activitypub-bridge.deno.dev/users/783214/followers",
    "liked":"https://x-activitypub-bridge.deno.dev/users/783214/liked",
    "inbox":"https://x-activitypub-bridge.deno.dev/users/783214/inbox",
    "outbox":"https://x-activitypub-bridge.deno.dev/users/783214/outbox",
    "preferredUsername":"X",
    "name":"X",
    "summary":"<p>what's happening?!</p>",
    "url":"https://x.com/intent/user?screen_name=X",
    "manuallyApprovesFollowers":false,
    "discoverable":true,
    "indexable":true,
    "published":"2007-02-20T14:35:54.000Z",
    "attachment":[
        {
            "type":"PropertyValue",
            "name":"Location",
            "value":"everywhere"
        },
        {
            "type":"PropertyValue",
            "name":"URL",
            "value":"<p><a href=\"https://about.x.com/\">about.x.com/</a></p>"
        },
        {
            "type":"PropertyValue",
            "name":"X ActivityPub Bridge",
            "value":"<a href=\"https://github.com/c7e715d1b04b17683718fb1e8944cc28/XActivityPubBridge\">github.com/c7e715d1b04b17683718fb1e8944cc28/XActivityPubBridge</a>"
        },
        {
            "type":"PropertyValue",
            "name":"Original",
            "value":"<a href=\"https://x.com/intent/user?screen_name=X\">@X</a>"
        }
    ],
    "icon":{
        "type":"Image",
        "url":"https://pbs.twimg.com/profile_images/1683899100922511378/5lY42eHs.jpg"
    },
    "image":{
        "type":"Image",
        "url":"https://pbs.twimg.com/profile_banners/783214/1690175171"
    },
    "location":{
        "type":"Place",
        "name":"everywhere"
    }
}

MastodonやMisskey特有のPropertiesもあるため、そういった項目を表示させたい場合には@contextへ仕様を追加してください

動作確認

Ngrokなどで公開し、Misskeyで動作確認を行いましょう
https://misskey.io/@x@x-activitypub-bridge.deno.dev

misskey.io/@nantoka@domain の形式で実際にアクセスを確認することが出来ます。

最後に

細かいActivityPubの実装については省きました
一応最低限これくらいでプロフィールの表示までを行うことが出来ますが、inbox outbox等の実装を行わなければ投稿すら表示されません
あと、何故かMastodonで表示されなくなりました

最後にすべてのコードはGithubで公開されているため、研究や学習目的のみで使用してみてください
また、すべてのコードはGNU General Public License v3.0を用いてライセンスされております。
https://github.com/c7e715d1b04b17683718fb1e8944cc28/X-ActivityPub-Bridge

そして、このコードはDeno Deployを用いてサービス運用されておりますので実際に使用してみてください
あと、何故Mastodonに表示されないかや投稿をinbox outbox無しで表示させる方法があれば教えてください

Discussion