🦋

Bluesky&Twitterの同時投稿サービスを作り、公開した。

2024/02/04に公開
4

2024-02-08 追記:
本当にありがたいことにBluesky内で非常に注目を集めることになり、現在は開発に対して非常に積極的です!まだリリースして一週間もしていませんが、すでにAPI実行数(=OGP生成回数)は4000回を超えており、非常に多くのアクティブユーザ(Blueskyのテスターやアーリーアダプター)に使用していただいております。
2024-02-08時点のポストスクショ、感謝...
元Postはこちら:

本文が悲観的な状態の際に読んでくれた方にはご心配をおかけしました。
以降本文全体的に修正を入れてます。内容自体はそんなに変わりません。

...

BlueSkyをご存じでしょうか。Twitter(現X)の共同創業者の人が作った次なるSNSで、現在絶賛βテストをやっています。 ついに招待制が終了、引き続きテスト中ではあると思いますが、ひとまずリリースされました。
最近アイコンがちょうちょになってとってもかわいい。βテスト段階で運よくWaitlistから招待コードが発行され、Blueskyデビューしました。

こちらです。開発者の方や、フィードバックをくれそうな一般使用者は率先してフォローお返ししてます。お気軽にフォローしてね。しなくてもいいです。

さて、そんなBlueskyですがUIがTwitterにとても似ており、これは流行ると...勝手に思い込み、せっかくだから自分に何かできないかと考え、Bluesky&Twitterのマルチポストサービスを作りました。絶賛稼働中です。

なのですが、もっと広まると思いきや絶賛閑古鳥が鳴いてます[1]。リリースしたばかりというのもありますが、それでもまだ趣味のブログの方が注目されてます。流行らなそうだな...と思い心が折れかけてしまってるので、いったん開発の手を止めて文章を書いている次第です。

ありがたいことに皆さんに広めていただき、大変うれしく思います。本文章ではSkyshareを作った経緯、そしてこれを支える技術について、後続の開発者の皆様や、これから開発を始めたい新米デベロッパーに向けて記載していきます。

経緯

Blueskyのアイコンがちょうちょに変わったころ、私も重い腰を上げてアプリをダウンロード&登録しました。で、WaitListに登録して3週間ぐらいで招待コードが届いたので「意外と早いな」と思いました。

これが事の始まりで、こんなに簡単に登録できるのにユーザが移動しないのはなんでだろう、去年ぐらいに話題になったからみんな知ってるはずなのに...という思考から最終的に、アーティストはユーザが居るSNSに、ユーザはアーティストが居るSNSにという構造が出来上がってしまっているから人が動かないのではないか?という結論に至りました。

実際は「本当にたまたま私は招待コードを早く受け取れただけ」であり、ユーザが移動できないのはコンテンツ以外の要員がまあまああったのですが、当初の私は、招待制であること自体はユーザの行動意欲を減らす要因にはならないだろう、と思い込んでました。これは浅はか...前例があったのに何も学んでいない[2]

簡潔にまとめると、Blueskyに参加できて嬉しくなってハイになったから作りました。

動機

アーティストはユーザが居るSNSに、ユーザはアーティストが居るSNSにという構造が出来上がってしまっているから人が動かないのではないか?という結論に至りました。

これを解消するためには、やっぱりまずは両方の土俵をそろえる必要があると思いました。
今やSNSなんて使い分けるほどありますが、Twitterほど汎用的でなんでも投稿できる(諸説ありますが)SNSはないと思います。Blueskyはそれになりえると考えました。

だからクリエイターが両方にコンテンツを提供すれば土俵は同じになるはずです。広告やおすすめでめちゃくちゃになったXのタイムラインではなく、Blueskyで快適に昔のTwitterにみんな戻りたいと思っているはずだ[3]...という思考で、BlueskyとTwitterに同時投稿できたらいいのでは?と考えに至ります。

開発開始の頃(2024年1月頭ぐらい)見る限りはChromeの拡張機能として上記を実現しているツールが存在していました。

Chromeの拡張機能であるため、スマホやタブレットでの使用はできません。そしてこの課題をなんとかしているサービスはありません。[4]

目的と手段

今回開発に至ったSkyshare.ukは、ユーザに自由を与え、より快適なプラットフォームへ遷移させる選択肢を与えることを目的にしています。その対象がX→Blueskyだったというだけです。

手法は以下です。

  1. Xでクリエイティブな活動をされている人が、Blueskyへのコンテンツ供給を本ツールを用いて行うことで、BlueskyとXの両方にコンテンツを供給します。
    なぜかというと、クリエイターはオーディエンス(ROMなどの一般ユーザ・本説明では単にユーザと書きます)の数などの関係で、Xを切り捨てることが困難であるためです。
  2. クリエイターのコンテンツを受領するプラットフォームをユーザに選択する余地が生まれます。
    コンテンツ主体のプラットフォームにおいて土俵を一致させることで、XとBlueskyを競合させます。
  3. ユーザはより快適なプラットフォームを選びます。これがXかBlueskyかはユーザに委ねられます。
  4. ユーザの増加により、既存クリエイターの定着や、ユーザ増加により魅力が上がったプラットフォームへさらに新規クリエイター流入することが期待できます。

この1〜4の工程の好循環により、クリエイターとユーザの幸福を実現します!
(ついでに片方のプラットフォームが栄えます。)

TwitterAPIの課題

Twitterは去年頃から実質デベロッパーの締め出しといえるAPI料金を提示してきました。どんなに安くても一か月に$100(日本円で15000円ぐらい)は支払う必要があります。
この結果、ほとんどのサードパーティTwitterクライアントが開発を終了しちゃいました。

Twitterへの投稿をどうするか

TwitterAPIを使っての開発は(月15000円回収できるサービスか、慈善活動でもない限り)ほぼ無理という状況です。
ゆえに、Twitterへの自動投稿はあきらめざるを得ませんでした。しかし同時投稿自体が不可能ではないと考え、できるだけユーザの手を煩わせない形で、いわゆる半自動でBlueskyとTwitterへの同時投稿を実現すれば、まだ希望はあるのではないでしょうか。

この発想から、かろうじて残っているTwitter古の機能「Twitterの投稿リンク(Twitter Share Button)」を使って、半自動でのBluesky/Twitterの同時投稿を実現することを考えました。

Twitter Share Buttonについて

こういうやつです。

投稿リンク(Twitter Share Button)の例

これなら本文の再入力は抑えられますが、添付のメディアが主体であるクリエイターにとってはあまり有用ではないと感じました。

そこで、メディアについてはOGPを採用してごまかすことで対処しようと考えました。

OGPとは

聞きなれない言葉だと思います。私もWeb開発を始める前までは知りませんでした。要はURLにくっついてくるサムネイルのことです。あくまでOGPはOpen Graph Protocolなので、正確には画像にくっついてくるサムネイルは「OGPカード」などと呼ばれるらしいですが知ったこっちゃないです。

Blueskyの画像の投稿からこれを作ることができれば、完全な同時投稿とは言えなくてもかなり要件は満たせているのでは?と考えました。これが今回作成したWebサービス「Skyshare」の動機であり、機能であり、すべてであります。

...実際はこんな簡単な話じゃなかったので全然流行ってないんですけどね。
色々課題はありました[5]が、今回のサービスはリンク先は関係ない、リンク先ではなくリンクから取得可能なOGPカードの部分にのみ用事があったため、いったん目をつむりました。

開発について

Webサービスではありますが、個人として初めての開発であり信頼が薄いこと、またBlueskyのAPIは始まったばかりで、新参開発者の方は苦労されているだろうと思い、ソースコードを全て公開しています。同じものがつくれますし、公開している以上、不正だと考えられるコードは一切記載しない努力をしています。

https://github.com/nkte8/skyshare

開発者のみなさんスターをありがとうございます!開発当初はAT Protoのかかわるフロントエンドだけ公開にしようかな、とも思ったのですが、勉強される方も全部見えた方がうれしいですよね。ということで、バックエンドもリポジトリに追加しています。

ちなみに、BlueskyはAT Protocolだけにとどまらず、公式クライアントですらOSSです。
これってとんでもないことだと思います。こういう部分もBlueskyが期待できる点です。

使ったサービス

開発していて色んな選択肢を見ていると、知らないだけで優良なサービスはごろごろしてますね。今回は以下のIaaSやPaaSを使用しました。

  • Firebase
    • functions: OGPを生成したり、DBへWebページ情報を登録するために使用
    • storage: 生成したOGPを格納するために使用
  • Cloudflare
    • Pages: Webページの構築に使用
    • Workers: DBからWebページ情報を取得するために使用
  • Upstash
    • Redis: Webページ情報をDBで管理するために使用

特にUpstashには助けられました。なんとRESTでRedis(キーバリューストアのDB)をたたくことができたり、Cloudflare Worker無料枠(たった10msしか実行可能時間がない)でも問題なく動作する、無料枠が良心的だったりと、とんでもないサービスです。

サービスのマッピング

以下のような感じになります。
サイトマップ

注記すべきは以下です。

  • UpstashDBからの情報取得はCloudflare越しに実施しています。アクセストークンの保持の観点でセキュリティ的に問題があるためです。
  • FirebaseでもBlueskyから情報を得ています。クライアントに終始しないのは、クライアントサイドのスクリプト改ざん等、セキュリティ的な問題への対処のためです。

より詳しいこと

書きたいことは山ほどあるのですが、全部説明するには余暇がたりません。よければソースコードを読んでいただければと思います。

解説を書く時間はないのですが、逆引き的にいくつか難しい部分のソースコードを引っ張ってきておきます。

React createPortalによるコンポーネントの別箇所配置

こちらでcreatePortalを実施しています。
https://github.com/nkte8/skyshare/blob/main/astro/src/components/Client/index.tsx

どうしてこのような形をとったかというと、(root)/app/ページ以外でもセッションを利用したかったからです。
具体的には(URL)/posts/hogehoge/ページで、ページの削除を実施する際にセッションを取得するのですが、このためだけにクライアントをポータル化して、どのページでも展開できるようにしています。

Astro SSRモードで、データベースからページを生成する

AstroのSSRモードはサーバーサイドレンダリング、つまり動的にページを生成できます。
これはどういう話かというと、例えばDBに登録された情報を使ってページが生成できるという感じです。

以下では(root)/posts/(アクセスされたページ名)/について、DBへ問い合わせを行いレスポンスが正しければ通過する、というコードです。考え方としてはネガティブフィルタリングで、アクセスされたくないページに対して503や404レスポンスを行います。
https://github.com/nkte8/skyshare/blob/main/astro/src/pages/posts/[slug].astro

Bluesky向けの投稿のURLやメンションをきちんと実装する

@atproto/apiライブラリさえ使えばdetectFacets関数で簡単にRecordを作成できますが、今回はすべてRESTを直接たたく実装をした(理由は後述)ので、detectFacets相当の機能は自力で実装しました。

処理としては正規表現でURLやメンションの内容を確定して、app.bsky.richtext.facetの定義に従ってObject型を作成・Recordに付与しているだけです。

RegExp型でgオプションを付けてしまうとbyteStartに苦労するので、正規表現は一要素ずつ再帰関数により情報の取得・作成をしています。

/astro/src/utils/atproto_api/detectFacets.ts#L12-L48(長いためトグル)

Skyshare@1.0.9 から切り出し

/astro/src/utils/atproto_api/detectFacets.ts#L12-L48
const regexSeacrh = ({
    array,
    regex,
    encoded,
    cursor = 0
}: {
    array: Array<regexResult>,
    regex: RegExp
    encoded: string,
    cursor?: number
}) => {
    // 文字を取得するために使用
    const regexedText = regex.exec(encoded)
    // バイト数を数えるために使用
    const replacedText = regex.exec(encoded.replace(/%../g, "*"))
    if (regexedText === null || replacedText === null) {
        return
    }
    const regexedStart = replacedText.index
    const linkref = decodeURI(regexedText[1])
    const regexedEnd = regexedStart + replacedText[1].length
    const sliceEnd = regexedText.index + regexedText[1].length
    const Item = {
        encoded: linkref,
        index: {
            byteStart: cursor + regexedStart,
            byteEnd: cursor + regexedEnd
        }
    }
    array.push(Item)
    regexSeacrh({
        array,
        regex,
        encoded: encoded.slice(sliceEnd),
        cursor: cursor + regexedEnd,
    })
}

app.bsky.richtext.facetの構造は以下のようになっています。linkmentionも似たような型をしているので、もっと効率化できそうです。

https://github.com/nkte8/skyshare/blob/main/astro/src/utils/atproto_api/facets.ts

ただ、単純に複合型というわけには行きません。facetの作成には、RegExpで取得した結果を以下のようにマッピング処理をしていますが、もしapp.bsky.richtext.facetの型を一括に扱うのであれば、これらの関数も統合するべきだからです。

/astro/src/utils/atproto_api/detectFacets.ts#L50-107(長いためトグル)

Skyshare@1.0.9 から切り出し

/astro/src/utils/atproto_api/detectFacets.ts#L50-107
const createLinkFacet = ({
    encoded,
}: {
    encoded: string,
}): Array<facet.link> => {
    const Regex = /(https?:\/\/[^ ]*) ?/i
    let facet: Array<facet.link> = []
    let regexResult: Array<regexResult> = []
    regexSeacrh({
        array: regexResult,
        regex: Regex,
        encoded: encoded,
    })
    for (let link of regexResult) {
        facet.push({
            index: link.index,
            features: [
                {
                    $type: "app.bsky.richtext.facet#link",
                    uri: link.encoded
                }
            ]
        })
    }
    return facet
}

const createMentionFacet = async ({
    encoded,
}: {
    encoded: string,
}): Promise<Array<facet.mention>> => {
    const Regex = /(@[^ ]*) ?/i
    let result: Array<facet.mention> = []
    let regexResult: Array<regexResult> = []
    regexSeacrh({
        array: regexResult,
        regex: Regex,
        encoded: encoded,
    })
    for (let link of regexResult) {
        const resResolve = await resolveHandle({ handle: link.encoded.slice(1) })
        // 存在しないハンドルの場合はfacetから除外
        if (typeof resResolve?.error !== "undefined") {
            continue
        }
        result.push({
            index: link.index,
            features: [
                {
                    $type: "app.bsky.richtext.facet#mention",
                    did: resResolve.did
                }
            ]
        })
    }
    return result
}

ここの美しい実装方法はまだ思いつきません。アイデアはいつでも募集中です!

躓いた点

Reactで@atproto/apiパッケージが動かない

@atproto/apiはWebアセンブリに対応していないため[6]、残念ながらReactのWebコンポーネントにバンドルすることはできません。

クライアントが使えないならクライアントを使わなければいいじゃない、ということで、SkyshareではRESTをfetchAPIで直接たたくことで解決しました。

ソースコードでは以下の処理をRESTで使うことができます。参考にしてください。

  • createRecord.ts: ポストの作成
  • createSession.ts: アクセストークンの取得
  • deleteSession.ts: アクセストークンの即時無効化
  • getPostThread.ts: ポストの詳細情報(EmbedされてるメディアのURLなど)の取得
  • getProfile.ts: ユーザプロフィールの取得
  • getRecord.ts: ポストの情報取得(トークン不要だが情報は少ない)
  • getSession.ts: 現在のアクセストークンの情報を取得
  • refreshSession.ts: リフレッシュトークンを使ってアクセストークンを再発行可能
  • resolveHandle.ts: トークン不要、handleからdidを取得可能
  • uploadBlob.ts: メディアのアップロード、アップロード後の識別子(ref.$link)を取得

Firebaseで@upstash/redisが動かない

うまく動かせませんでした。原因は最後まで分からず...

幸い、Upstash上のRedisはRESTにより操作が可能だったため、こちらもfetchAPIを直接たたいています。やはりREST、RESTがすべてを解決する。

ちなみにRESTのたたき方自体はUpstash準拠なのですが、たたくコマンドについてはRedis側の命令に準じている仕様で面白いです。

Here are some examples:
SET foo bar -> REST_URL/set/foo/bar
SET foo bar EX 100 -> REST_URL/set/foo/bar/EX/100
GET foo -> REST_URL/get/foo
...

ソースコードでは基本的な値の登録と削除をRESTで実施しました。Cloudflare側はクライアントがきちんと動作したので、@upstash/redisを使っています。

  • redisDel.ts: 登録済みキー/バリューを削除
  • redisSet.ts: キー/バリューを有効期限付きで登録

CORS設定関連

一番苦労したかもしれません。
Firebase functionsとCloudflare Workerで設定方法が異なります。

Firebase functionsの場合

CORSはonRequest({ cors: string | boolean | Regex | undefined })として設定される必要があります。以下の例ではRegex型として定義しています。

/firebase/functions/src/vars.ts
export const domain = /skyshare\.uk$/
/firebase/functions/src/ogpGenerator/entrypoint.prod.ts
...(中略)...
    export const ogpGenerator = functions.https.onRequest({
        region: 'asia-northeast1',
        cors: cors_value,
    }, async (
        request, response
    ) => {
        if (request.method === "OPTIONS") {
            response.status(204).send()
            return
        }
...

Cloudflare Workerの場合

Cloudflare Workerについては何のひねりもなく、レスポンスのヘッダにAccess-Control-Allow-Originを指定してあげるだけですが、注意すべきはURLは/で終わってはいけません。

また、Cloudflare Workerのように正規表現で楽もできそうにないです。(詳しい方いらっしゃったらコメントお願いします。)

以下の例だとskyshare.ukのみCORS設定をしたい場合は以下のように設定します。Access-Control-Allow-Origin: https://skyshare.uk/だと誤りです(最後のスラッシュはつけてはいけない)

/workers/src/index.ts
export default {
	async fetch(request: Request, env: Env) {
		let corsHeaders = {}
		if (env.ENV === "prod") {
			corsHeaders = {
				"Access-Control-Allow-Origin": "https://skyshare.uk",
				"Access-Control-Allow-Methods": "GET,OPTIONS",
				"Access-Control-Allow-Headers": 'Content-Type',
			};
		}else {
        ...(中略)...
		}

		if (request.method === "OPTIONS") {
			return new Response(null, {
				status: 204,
				headers: corsHeaders
			});
		}

実際に使ってみて

ちゃんとBlueskyに画像は投稿されますし、Twitter用のOGPカードも生成されて、両方のSNSへの投稿は非常に簡単になりました。

...投稿自体は簡単になったのですが、体感Twitterでのインプレッションやいいねが全然されず、Blueskyの方はBlueskyの方でフォロワーが全然いないのであまり楽しくなく、なんだか中途半端になってしまいました。使ってるユーザ(私)自身の問題な部分はあるかもしれません。

この課題はツールがたくさん使われるようになっても、やはり多くのクリエイターが気にしているようでした。

今後の開発について

いったん作りたいものは作ったので個人的には満足です。本当は内部APIを守る仕組み(Access Tokenの実装など)が必要なのですが、ユーザが増えるまでは急ぐことではないと判断して正式リリースには含めませんでした。

あとはボタン表示や投稿画面をもうちょっとリッチにするといった、見た目のほそぼそとしたアップデートが主になる見込みです。

大変たくさん利用していただいており、今のところは順調に使われています。現在の時点で、APIの実行には認証の意味も兼ねてBlueskyのアクセストークンやリソースが必要な作りをしていますが、万が一に備えてAPIをより強固に守る仕組みや、ユーザフィードバックを反映してUIの改善や機能の追加を頑張っていきたいです。

感想

TwitterAPI、Media Uploadの項目がComming Soon(2~3年放置)の状態から2024 1Q~2Qと記載が変わっていたので、近いうちにメディア投稿もできるようになるのだと思います。これがTwitterの狭い無料範囲枠で使えること、狭い無料範囲枠が少しでも広がってくれたらいいな...と思いますが、期待はしないでおきましょう。

Blueskyに行こうね...文章の最初の方でも載せましたがあらためて。Skyshareのアプデ情報や、技術的に面白そうだな~みたいな興味や、こうしたいああしたいというような情報を発信しています。あと、距離感バグったみたいなリプライをフォロー/フォロー外限らず結構してます。個人制作のプロダクトだから、公式みたいなお堅さは要らないだろうという部分で、ユーザと近いコミュニケーションのがいいと思っているからです。実際その方が楽しいからね。

https://bsky.app/profile/nekono.dev

あとは今回学んだことを趣味のWebサイトの拡充などに活かしていきたいです。また今回はBlueskyのRESTについては、書き込み系ばかりで読み取りをやっていなかったので、次はbotの製作に挑戦してみたいです。

話は変わりますが、筆者の本職はWebにかすりすらしません。この記事の執筆も含めすべて趣味の延長上の活動なのですが、営業に(自社の常駐客先で求められてない技術は)金にならないと理解を示していただけないので、フルリモート正社員雇用の条件の良い転職先を探しています。

追記1: 2024-02-05

この記事、深夜しょんんぼりしながら書いたので卑下して、初版は「BlueskyとTwitterを繋ぐサービスが作りたかった」とタイトルを設定していましたが、今朝起きてよくよく考えたら「作った」ことに関しては事実なのでタイトルを「Bluesky&Twitterへ同時投稿するサービスを作った」に修正しました。役に立つかどうかは別として、これは確実に実績なので...

追記2: 2024-02-08

タイトルをBluesky&Twitterの同時投稿サービスを作り、公開した。に変更しました。そのままでも良かったんですけど、明確に更新したことが分かった方が、読む側もとっつきやすいかなと思ったためです。ここまで読んでくれてありがとうね。

追記3: 2024-04-13

目的と手段セクションを作って、本アプリがどういうことを期待しているのかを明文化しました!また、本当に遅くなりましたが、本記事へバッジを貼っていただいた皆様、本当にありがとうございます!非常に励みになります!
現在、開発はより規模を大きくしており、ZEKE320氏やSo-Asano氏の協力により、OSSとしての品質向上や、機能の実現の幅を広げています。
Blueskyが盛り上がっていってほしい、そして、クリエイターやユーザのみなさんの幸福のお手伝いができたらいいなと、思っています!

脚注
  1. 2024/02/04時点のGoogleアナリティクスおよびTwitterでのドメイン名検索結果から。ドメインはBskylinx→Skyshareとサービス名が一度変わっているため、これも含めて検索したところほとんど自身の利用履歴で悲しかったskyshare.ukでTwitterを検索してはニヨニヨしてます。テストで投稿されてる方や、もう本番運用されてる方、あとはフィードバックを寄せてくれている方がいっぱい!ありがとう! ↩︎

  2. 考えてはいました。ただ、Clubhoseは肉声を使うことや通話という体系上時間の制約があるなど、そもそもの特性上難しい点があり、招待制でなくても流行っていなかったと考えていました。 ↩︎

  3. 浅はかその2。最近は自分のおすすめが猫と文鳥でいっぱいになって、これはこれでいいな...と思うとともに、Blueskyのフィードの不完全さを嘆いています。 ↩︎

  4. SocialHubがギリギリ対応していたかと思ったのですが、去年の夏ごろにやはりTwitter連携は終了していました。 ↩︎

  5. URL付きの投稿のインプレッション(投稿自体の表示数)は、URL無しの投稿のインプレッションよりも少なく出るという検証結果があります。【検証】TwitterでURLを記載するとインプレッションが低下するのは事実?。クリック率についてはあまり高くないという理解はありましたが、まさかインプレッションにも影響があるとは思ってませんでした。 ↩︎

  6. ReactやAstroでbuildができないライブラリ全般、Webアセンブリに対応していないことが原因だと理解していますが、もし違ったらコメントで指摘してください。Web開発歴4ヶ月ちょっとしかないため、間違ったことを書いている可能性は十分にあります。 ↩︎

GitHubで編集を提案

Discussion