PHPでBlueskyに投稿する

(1/1)
Blueskyロゴ
PHPで Bluesky にメッセージや画像を投稿するプログラムを作る。ネット上のコンテンツが対応していればカード形式で(OGP情報を)投稿することもできる。返信や引用も投稿できるようにした。
API操作はクラスファイルに分離し、他のプログラムから利用しやすいようにした。当サイト以外が配布しているプログラムやライブラリは不要だ。
(2024年12月6日)画像のドラッグ&ドロップ,コピー&ペーストに対応した.OGPかどうかに関わらず画像が指定サイズに収めるよう拡大・縮小する仕様に変更した.引用時にも画像やOGP情報を付けられるようにした.API仕様変更対応
(2024年11月30日)画像が指定幅より小さい時,画像が透明背景の時の処理を追加
(2024年11月24日)不具合修正
(2024年11月22日)PHP8.4対応
(2024年11月21日)ハッシュタグに対応した
(2024年11月10日)入力メッセージの残文字数とプログレスバーを表示するようにした
(2024年10月31日)文字化け対策
(2024年10月22日)ローカル画像を投稿できるよう修正.画像投稿の優先順位修正
(2024年10月21日)返信,引用ができるよう機能追加
(2024年10月18日)必要に応じて画像の縦方向も縮小するようにした
(2024年10月13日)必要に応じて画像を縮小するようにした

サンプル・プログラムの実行例

PHPでBlueskyに投稿する

目次

サンプル・プログラム

圧縮ファイルの内容
postBluesky.phpサンプル・プログラム本体
pahooBlueskyAPI.phpBluesky APIに関わるクラス pahooBlueskyAPI。
使い方は「PHPでPHPでBlueskyに投稿する」などを参照。include_path が通ったディレクトリに配置すること。
pahooScraping.phpスクレイピング処理に関わるクラス pahooScraping。
スクレイピング処理に関わるクラスの使い方は「PHPでDOMDocumentを使ってスクレイピング」を参照。include_path が通ったディレクトリに配置すること。
pahooInputData.phpデータ入力に関わる関数群。
使い方は「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。
postBluesky.php 更新履歴
バージョン 更新日 内容
1.4.0 2024/12/06 画像のドラッグ&ドロップ,コピー&ペーストに対応
1.3.0 2024/11/10 入力メッセージの残文字数とプログレスバー表示
1.2.0 2024/11/01 返信URL代入機能を追加
1.1.0 2024/10/20 返信,引用ができるよう機能追加
1.0.0 2024/10/01 初版
pahooBlueskyAPI.php 更新履歴
バージョン 更新日 内容
1.8.0 2024/12/06 post()--引用時にも画像やOGP情報を付けられるように
1.7.0 2024/12/06 reductImage()--OGPかどうかに関わらず画像が指定サイズに収めるよう拡大・縮小する仕様に変更
1.6.1 2024/12/04 getRootParentID() -- API仕様変更対応
1.6.0 2024/11/30 convertTransparentToWhite() 追加
1.5.0 2024/11/30 reductImage() -- 指定幅より小さい場合を追加;reductImage, uploadBlobの引数に$flagCard追加
pahooScraping.php 更新履歴
バージョン 更新日 内容
1.2.1 2024/10/31 __construct() 文字化け対策
1.2.0 2024/09/29 getValueFistrXPath() 属性値でない指定に対応
1.1.0 2023/10/15 getValueFistrXPath() 追加
1.0.1 2023/09/29 __construct() bug-fix
1.0.0 2023/09/18 初版
pahooInputData.php 更新履歴
バージョン 更新日 内容
1.8.0 2024/11/12 validRegexPattern() 追加
1.7.0 2024/10/09 validURL() validEmail() 追加
1.6.0 2024/10/07 isButton() -- buttonタグに対応
1.5.0 2024/01/28 exitIfExceedVersion() 追加
1.4.2 2024/01/28 exitIfLessVersion() メッセージ修正

準備:PHP の https対応

Bluesky API の呼び出しはhttps通信で行うため、PHPにOpenSSLモジュールが組み込まれている必要がある。関数  phpinfo  を使って、下図のように表示されればOK。
OpenSSL - PHP
そうでない場合は、次の手順に従ってOpenSSLを有効化し、PHPを再起動させる必要がある。

Windowsでは、"php.ini" の下記の行を有効化する。
extension=php_openssl.dll
Linuxでは --with-openssl=/usr オプションを付けて再ビルドする。→OpenSSLインストール手順

これで準備は完了だ。

解説:pahooBlueskyAPIクラス

pahooBlueskyAPIクラス
Bluesky に投稿したりプログラムで操作するAPIについては、公式リファレンスが詳しい。

基本的に、POSTプロトコルでデータを渡し、JSON形式で応答が戻ってくるAPIであるが、Bluesky は分散型SNSと呼ばれるように、PDS(Personal Data Server)が複数存在し、APIもPDSの中に入っている。これを AT Protocol と呼び、PDSが稼動しているドメインを PDSドメインと呼ぶ。
PDSドメインは、ユーザーによって変わる可能性がある。たとえばハンドル名 hoge.bsky.social であれば、bsky.socialPDSドメインである。
BlueskyAPI は、機能ごとにエンドポイントが用意されており、API呼び出しURLは htttps://{PDSドメイン}/xrpc/{エンドポント} となる。

APIに対する操作は、ユーザー定義クラス pahooBlueskyAPI にカプセル化した。
左図に、今回利用する BlueskyAPI と、それを呼び出すメソッドを整理した。
これ以外にも、APIは呼び出さないが、メッセージ中からURLを取り出すメソッド getURLs や、画像URL(画像など)を取り出すメソッド extractMediaURL などが利用できる。

今回の目的であるメッセージ投稿については、後述 post メソッドに一元化したが、リンクURLが画像URLが含まれていたり、返信や引用をするときには、post から別のメソッドを呼び出して投稿に必要な追加情報を取得する形にした。

解説:セッション開始

BlueskyAPI を利用するには、まずセッションを開き、アクセストークン accessJwt を取得する。使用するエンドポイントは com.atproto.server.createSession だ。
com.atproto.server.createSession
URL
https://{PDSドメイン}/xrpc/com.atproto.server.createSession
リクエスト・データ(http) header Content-Type "application/json" post identifier ハンドル名 password アプリケーション・パスワード
レスポンス・データ(json) accessJwt アクセストークン refreshJwt リフレッシュトークン handle ハンドル名 did did
アクセストークン accessJwt は、後述するメッセージや画像の投稿で使用する。寿命は1~2時間だ。
アクセストークン refreshJwt は、アクセストークンの再発行や、セッションの終了・破棄に用いることができ、寿命は数十日と長い。リフレッシュトークン refreshJwt をストレージに保存しておき、次回はアクセストークンを再発行するというのが BlueskyAPI の望ましい運用方法と思われるが、リフレッシュトークン refreshJwt だけでアクセストークン accessJwt を再発行できてしまうので、流出するとたいへん危険である。

今回つくるプログラムは、単発でメッセージや画像を投稿するものなので、リフレッシュトークン refreshJwt は使わず、プログラム起動時にアクセストークン accessJwt を取得するようにする。

pahooBlueskyAPI.php

  11: //スクレイピング処理に関わるクラス:include_pathが通ったディレクトリに配置
  12: require_once('pahooScraping.php');
  13: 
  14: // Bluesky API クラス =======================================================
  15: class pahooBlueskyAPI {
  16:     var $pds;           // PDSドメイン
  17:     var $webapi;        // 直前に呼び出したWebAPI URL
  18:     var $errmsg;        // エラーメッセージ
  19:     var $accessJwt;     // accessJwt
  20: 
  21:     const INTERNAL_ENCODING = 'UTF-8';  //内部エンコーディング
  22:     const MAX_MESSAGE_LEN = 300;        // 投稿可能なメッセージ文字数
  23:     const URL_LEN = 23;                 // メッセージ中のURL文字数(相当)
  24:     const MAX_IMAGE_WIDTH  = 1200;      // 投稿可能な最大画像幅(ピクセル)
  25:     const MAX_IMAGE_HEIGHT = 630;       // 投稿可能な最大画像高さ(ピクセル)
  26:                                         // これより大きいときは自動縮小する
  27: 
  28:     // Bluesky API アプリパスワード
  29:     // https://bsky.app/
  30:     var $BLUESKY_HANDLE   = '***************';      // ハンドル名
  31:     var $BLUESKY_PASSWORD = '***************';      // アプリケーション・パスワード

BlueskyAPI を利用するユーザー定義クラス pahooBlueskyAPIをつくる。
上述の手順で取得したアプリケーション・パスワードをプロパティ変数 $BLUESKY_PASSWORD に、あなたのハンドル名を $BLUESKY_HANDLE に代入する。
投稿可能な最大文字数は定数 MAX_MESSAGE_LEN として用意した。現在の仕様では300文字だ。

pahooBlueskyAPI.php

  33: /**
  34:  * コンストラクタ
  35:  * @param   string $pds PDSドメイン
  36:  * @return  なし
  37: */
  38: function __construct($pds) {
  39:     $this->pds       = $pds;
  40:     $this->webapi    = '';
  41:     $this->errmsg    = '';
  42:     $this->accessJwt = '';
  43: }

コンストラクタの引数は PDSドメインで、変数 $pds に保管し、API呼び出し時に参照できるようにした。

pahooBlueskyAPI.php

 286: /**
 287:  * セッション開始する.
 288:  * @param   なし
 289:  * @return  bool TRUE:成功/FALSE:失敗
 290: */
 291: function createSession() {
 292:     //エラーメッセージ・クリア
 293:     $this->clearerror();
 294: 
 295:     // リクエストURL
 296:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.createSession';
 297:     $this->webapi = $requestURL;
 298:     $ch = curl_init($requestURL);
 299:     // cURLを使ったリクエスト
 300:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 301:     curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
 302:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 303:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキップ
 304:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 305:     curl_setopt($ch, CURLOPT_POST, TRUE);
 306:     curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
 307:         'identifier' => $this->BLUESKY_HANDLE,
 308:         'password'   => $this->BLUESKY_PASSWORD,
 309:     ]));
 310: 
 311:     // レスポンス処理
 312:     $response = curl_exec($ch);
 313:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 314:     if ($httpStatusCode !200) {
 315:         $this->seterror('セッション開始できません');
 316:         return FALSE;
 317:     }
 318:     curl_close($ch);
 319:     $items = json_decode($response, TRUE);
 320: 
 321:     // エラーチェックとリターン
 322:     if (isset($items['accessJwt'])) {
 323:         $this->accessJwt = (string)$items['accessJwt'];
 324:         return TRUE;
 325:     } else if (isset($items['error'])) {
 326:         $this->seterror($items['message']);
 327:         return FALSE;
 328:     } else {
 329:         $this->seterror('セッション開始できません');
 330:         return FALSE;
 331:     }
 332: }

セッション開始メソッド createSession は、引数はなく、セッション開始に成功したかどうかを戻り値にする。

メソッドの中身は、上述のAPI仕様の通りに作った。
POSTプロトコルとして、これまでのクラウドサービス利用でも使ってきた cURL関数を利用する。

解説:セッション終了

セッションを終了するには、アクセストークン accessJwt をエンドポイントは com.atproto.server.deleteSession だ。
com.atproto.server.deleteSession
URL
https://{PDSドメイン}/xrpc/com.atproto.server.deleteSession
リクエスト・データ(json) header Content-Type "application/json" post identifier "Bearer {アクセストークン}" password アプリケーション・パスワード
セッション開始時に取得したアクセストークン accessJwt を使い、このセッションをクローズする。以後、このアクセストークン accessJwt は使用できなくなる。
アクセストークン accessJwt の盗用を避ける意味で、セッションを開始したら、かならずセッション終了するようにしよう。

APIの戻り値は、httpステータスが200であれば成功、それ以外であればエラー情報が戻る。

pahooBlueskyAPI.php

 334: /**
 335:  * セッション終了する.
 336:  * @param   なし
 337:  * @return  bool TRUE:成功/FALSE:失敗
 338: */
 339: function deleteSession() {
 340:     // リクエストURL
 341:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.deleteSession';
 342:     $this->webapi = $requestURL;
 343:     $ch = curl_init($requestURL);
 344:     // cURLを使ったリクエスト
 345:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 346:     curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $this->accessJwt]);
 347:     curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
 348:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 349:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
 350:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 351: 
 352:     // レスポンス処理
 353:     $response = curl_exec($ch);
 354:     if (curl_errno($ch)) {
 355:         $this->seterror('セッション終了できません' . curl_error($ch));
 356:         return FALSE;
 357:     }
 358:     curl_close($ch);
 359:     $this->accessJwt = '';
 360:     return TRUE;
 361: }

セッション終了メソッド deleteSession は、引数はなく、セッション開始に成功したかどうかを戻り値にする。

解説:投稿用URLやハッシュタグ情報を取得

Twitter API とは異なり、メッセージ文字列中に含まれるURLやハッシュタグは、そのままではハイパーリンクに変換しない。クライアント側で、ハイパーリンクにするメッセージの位置情報を取得し、それをエンドポイントに送信するデータ形式に格納する必要がある。
メッセージからURLやハッシュタグの位置情報を取りだすメソッドが getRichTextPositions である。複数のURLやハッシュタグに対応している。

pahooBlueskyAPI.php

 153: /**
 154:  * 指定したテキスト中のURLやハッシュタグの位置情報を取得する.
 155:  * @param   string $text テキスト
 156:  * @return  Array 位置情報
 157: */
 158: function getRichTextPositions($text) {
 159:     $urlData = array();
 160: 
 161:     // URL
 162:     $regexURL = '/(https?:\/\/[^\s]+)/ui';
 163:     preg_match_all($regexURL, $text, $matches, PREG_OFFSET_CAPTURE);
 164:     foreach ($matches[0as $match) {
 165:         $url = $match[0];
 166:         $start = $match[1];
 167:         $end = $start + strlen($url);
 168: 
 169:         $urlData[] = array(
 170:             'type'  => 'link',
 171:             'start' => $start,
 172:             'end'   => $end,
 173:             'url'   => $url,
 174:         );
 175:     }
 176: 
 177:     // ハッシュタグ
 178:     $regexHashTag = '/(#[\p{L}\p{N}_\-.]+)/ui';
 179:     preg_match_all($regexHashTag, $text, $matches, PREG_OFFSET_CAPTURE);
 180:     foreach ($matches[0as $match) {
 181:         $hashtag = $match[0];
 182:         $start = $match[1];
 183:         $end = $start + strlen($hashtag);
 184: 
 185:         $urlData[] = array(
 186:             'type'  => 'tag',
 187:             'start' => $start,
 188:             'end'   => $end,
 189:             'tag'   => $hashtag,
 190:         );
 191:     }
 192:     return $urlData;
 193: }

URLとハッシュタグは別々に処理するが、いずれも正規表現で位置を特定し、メッセージ中での開始位置と終了位置(メッセージ先頭からのバイト数)を格納する。

pahooBlueskyAPI.php

 195: /**
 196:  * 投稿用URLやハッシュタグ情報を取得する.
 197:  * @param   string $text テキスト
 198:  * @return  Array 投稿用facets情報
 199: */
 200: function parseRichText($text) {
 201:     $positions = $this->getRichTextPositions($text);
 202:     $results = $facets = array();
 203:     if (! empty($positions)) {
 204:         foreach ($positions as $position) {
 205:             // URL
 206:             if ($position['type'] == 'link') {
 207:                 $facets[] = [
 208:                     'index' => [
 209:                         'byteStart' => $position['start'],
 210:                         'byteEnd'   => $position['end'],
 211:                     ],
 212:                     'features' => [
 213:                         [
 214:                             '$type' => 'app.bsky.richtext.facet#link',
 215:                             'uri'   => $position['url'], 
 216:                         ],
 217:                     ],
 218:                 ];
 219:             // ハッシュタグ
 220:             } else if ($position['type'] == 'tag') {
 221:                 $facets[] = [
 222:                     'index' => [
 223:                         'byteStart' => $position['start'],
 224:                         'byteEnd'   => $position['end'],
 225:                     ],
 226:                     'features' => [
 227:                         [
 228:                             '$type' => 'app.bsky.richtext.facet#tag',
 229:                             'tag' => ltrim($position['tag'], '#'),
 230:                         ],
 231:                     ],
 232:                 ];
 233:             }
 234:         }
 235:         $results = [
 236:             'facets' => $facets,
 237:         ];
 238:     }
 239: 
 240:     return $results;
 241: }

getRichTextPositions で取得した位置情報などをエンドポイントに送信するデータ形式に加工するメソッドが .parseRichText である。送信データ形式の配列が、URLとハッシュタグとでは異なる点に留意していただきたい。
このように、クライアント側で用意するデータ構造によって、URLとハッシュタグを同列のハイパーリンクとして扱う Bluesky の設計には感心させられた。ハッシュタグの方はリンク先情報を渡さないが、実際には Bluesky の検索URLにハイパーリンクする。将来的に検索機能も分散方式になった場合でも対応が容易であり、じつに拡張性のある設計だ。

解説:画像データの扱い

画像を投稿する手順を3つ用意した。
  1. メッセージ中に画像URLを記述する。
  2. 画像ファイルをコピー&ペーストする。
  3. 画像ファイルをドラッグ&ドロップする。
複数の手順の組み合わせにも対応しているが、Bluesky の仕様で、合計で最大4ファイルまでとなる。また、後述するように、あらかじめ指定した画像の最大幅・最大高を超えている場合には自動的に縮小する。

1.の手順はサーバ側で、後述するPHPのユーザー定義メソッド extractMediaURL を使って行う。
2.と3.の手順はクライアント側で、JavaScriptを使って行う。2.の手順については「JavaScriptでクリップボードの画像取得+リサイズ」を、3.の手順については「解説:ファイルのドロップ――PHPで撮影場所をマッピング」をご覧いただきたい。
1~3の手順で取得した画像データは、後述するユーザー定義メソッド uploadBlob を使って Bluesky API によりアップロードする。

解説:投稿メッセージから画像URLを抽出

pahooBlueskyAPI.php

 243: /**
 244:  * 指定したテキストから画像URLを抜き出して配列に格納する.
 245:  * テキストはUTF-8で指定すること.
 246:  * 画像拡張子$extに複数の拡張子を指定できる.省略時は 'jpg|png|webp|bmp'
 247:  * @param   string $str  テキスト
 248:  * @param   array  $urls 画像URLを格納する配列
 249:  * @param   string $ext  画像拡張子;省略時 jpg|bng|webp|bmp
 250:  * @return  string 画像URLを除いたテキスト
 251: */
 252: function extractMediaURL($str, &$urls, $ext='jpg|png|webp|bmp|mp4|mp3') {
 253:     // http記法
 254:     $pat1 = '/https?\:\/\/[\-_\.\!\~\*\'\(\)a-zA-Z0-9\;\/\?\:\@\&\=\+\$\,\%\#]+(' . $ext . ')/i';
 255:     // file記法
 256:     $pat2 = '/file\:\/\/\/((.*?)(' . $ext . '))/i';
 257: 
 258:     // 画像URLを抜き出す.
 259:     if (preg_match_all($pat1, $str, $arr> 0) {
 260:         foreach ($arr[0as $url) {
 261:             $urls[] = $url;
 262:         }
 263:         // テキストから画像URLを消去する.
 264:         $str = str_replace($urls, '', $str);
 265:     }
 266: 
 267:     // ローカル画像を抜き出す.
 268:     if (preg_match_all($pat2, $str, $arr> 0) {
 269:         $fnames = array();
 270:         foreach ($arr[1as $key=>$fname) {
 271:             // 画像ファイルかどうかを判定する.
 272:             if (exif_imagetype($fname!FALSE) {
 273:                 $urls[] = $fname;
 274:                 $fnames[] = $arr[0][$key];
 275:             }
 276:         }
 277:         // テキストからローカル画像を消去する.
 278:         $str = str_replace($fnames, '', $str);
 279:     }
 280:     // 余分な空白を削除する.
 281:     $str = trim($str);
 282: 
 283:     return $str;
 284: }

投稿メッセージ中に、画像URLやローカルファイル名があれば、それを抽出し、後述の画像をアップロードするメソッドに渡す。

引数 $str に、画像URLやローカルファイル名を含んだメッセージ(文字列)を、引数 $urls に抽出した画像URLやローカルファイル名を配列として格納する。画像の拡張子は引数 $ext に指定する。区切り文字はパイプ #x7C; である。

インターネット上にある画像URLは "http:// または "https://" ではじまるURLである。
ローカルファイル名は "file:///" ではじまるファイル名である。ローカルファイル名の場合、引数 $urls には "file:///" を除いたローカルファイル名を格納する。PHPで処理しているので、このローカルファイル名はサーバにおけるファイル名であることに留意されたい。

戻り値は、引数 $str から $urls に格納した画像URLやローカルファイル名を除いた残りのテキスト文字列である。

解説:画像をアップロード

メッセージ中に画像などの画像データが含まれる場合、事前に画像ファイルを Bluesky にアップロードし、そのPDS URLを投稿する必要がある。画像をアップロードするエンドポイントは com.atproto.repo.uploadBlob だ。
com.atproto.repo.uploadBlob
URL
https://{PDSドメイン}/xrpc/com.atproto.repo.uploadBlob
リクエスト・データ(http) header Authorization "Bearer {アクセストークン}" Accept "application/json" Content-Type MIMEタイプ post イメージ・データ
レスポンス・データ(json) blob PDSのURL

pahooBlueskyAPI.php

 532: /**
 533:  * 画像をアップロードする.
 534:  * 画像ファイルなどを投稿するときに事前に呼び出し,blobデータを投稿する.
 535:  * @param   string $message   投稿メッセージ(UTF-8限定)
 536:  * @param   int    $maxWidth  アップロードする画像の最大幅(ピクセル)
 537:  * @param   int    $maxHeight アップロードする画像の最大高(ピクセル)
 538:  * @param   bool   $flagFixedSize TRUE:画像を最大幅・高に加工する(背景白色)
 539:  * @return  string Blusky PDSのURL/FALSE:アップロード失敗
 540: */
 541: function uploadBlob($filename, $maxWidth=self::MAX_IMAGE_WIDTH,
 542:     $maxHeight=self::MAX_IMAGE_HEIGHT, $flagFixedSize=FALSE) {
 543: 
 544:     $mimeType = '';
 545:     $fileSize = 0;
 546: 
 547:     // エラーメッセージ・クリア
 548:     $this->clearerror();
 549: 
 550:     // 画像を読み込む
 551:     $imageData = file_get_contents($filename);
 552:     if ($imageData === FALSE) {
 553:         $this->seterror($filename . ' の読み込みに失敗しました');
 554:         return FALSE;
 555:     }
 556:     // MIMEタイプを判定する
 557:     $finfo = new finfo(FILEINFO_MIME_TYPE);
 558:     $mimeType = (string)$finfo->buffer($imageData);
 559:     $finfo = NULL;
 560: 
 561:     // 必要に応じて画像データを縮小する
 562:     $imageData = $this->reductImage($imageData, $mimeType, $maxWidth, $maxHeight, $flagFixedSize);
 563: 
 564:     // 透明背景を白色で塗りつぶす(投稿したときに黒背景になってしまうため)
 565:     $imageData = $this->convertTransparentToWhite($imageData, $mimeType);
 566: 
 567:     // リクエストURL
 568:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.uploadBlob';
 569:     $this->webapi = $requestURL;
 570:     // cURLを使ったリクエスト
 571:     $ch = curl_init($requestURL);
 572:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 573:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 574:         'Authorization: Bearer ' . $this->accessJwt,
 575:         'Accept: application/json',
 576:         'Content-Type: ' . $mimeType,
 577:     ]);
 578:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 579:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキップ
 580:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 581:     curl_setopt($ch, CURLOPT_POST, TRUE);
 582: //  curl_setopt($ch, CURLOPT_BINARYTRANSFER, TRUE);
 583:     curl_setopt($ch, CURLOPT_POSTFIELDS, $imageData);
 584: 
 585:     // レスポンス処理
 586:     $response = curl_exec($ch);
 587:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 588:     if ($httpStatusCode !200) {
 589:         $this->seterror('画像をアップロードできません');
 590:         return FALSE;
 591:     }
 592:     curl_close($ch);
 593:     $items = json_decode($response, TRUE);
 594: 
 595:     // エラーチェックとリターン
 596:     if (isset($items['blob'])) {
 597:         return $items['blob'];
 598:     } else if (isset($items['error'])) {
 599:         $this->seterror($items['message']);
 600:         return FALSE;
 601:     } else {
 602:         $this->seterror('画像をアップロードできません');
 603:         return FALSE;
 604:     }
 605: }

画像をアップロードするメソッド uploadBlob は、引数は画像のファイル名と画像の最大幅で、アップロードに成功したらPDSのURLを返す。このメソッドは1回呼び出しにつき、1つの画像をアップロードする。Bluesky は1つの投稿につき最大4つまでの画像を添付できるが、複数画像を添付する場合は、uploadBlob を複数回呼び出す。
ここで、画像の最大幅を超えた場合は後述する reductImageを呼び出し、自動的に最大幅・最大高に縮小する。なお、引数の画像の最大幅は省略可能で、省略時には冒頭の定数に定義する MAX_IMAGE_WIDTH を代入する。

メソッドの中身は、上述のAPI仕様の通りに作った。
画像ファイルは、組み込み関数  file_get_contents  を使って変数 $imageData に格納する。画像のMIMEタイプを判定するのに、finfoクラスを利用した。

解説:画像を指定幅・高さに収まるように拡大・縮小する

com.atproto.repo.uploadBlob ではアップロードできる画像サイズの上限は1Mバイトのようだ。上限を超えた画像サイズが指定された場合、自動的に縮小してやりたいのだが、画像サイズから幅や高さを逆算することは難しい。そこで、画像の幅が冒頭の定数に定義する MAX_IMAGE_WIDTH ピクセルや、画像の高さが MAX_IMAGE_HEIGHT ピクセルを超えたら、縮小するようなメソッドを追加することにした。
また、Bluesky はTwitter(現・X)と異なり、OGP情報の画像は常に横長の画像として表示する。そこで、OGP情報として縦長の画像を登録するときは、全体が収まるよう縮小した上で、余白(背景)を白色にするフラグ $flagFixedSize を用意した。

pahooBlueskyAPI.php

 403: /**
 404:  * 画像データを指定幅・高さに収まるように拡大・縮小する
 405:  * @param   string $imageData 画像データ(画像ファイルから読み込んだバイナリ)
 406:  * @param   string $mimeType  縮小後の画像のMIMEタイプ
 407:  * @param   int    $maxWidth  画像データの最大幅(ピクセル)
 408:  * @param   int    $maxHeight 画像データの最大高(ピクセル)
 409:  * @param   bool   $flagFixedSize TRUE:画像を最大幅・高に加工する(背景白色)
 410:  * @return  string 縮小後の画像データ/FALSE 対応していない画像フォーマット
 411: */
 412: function reductImage($imageData, $mimeType='image/jpeg',
 413:     $maxWidth=self::MAX_IMAGE_WIDTH, $maxHeight=self::MAX_IMAGE_HEIGHT,
 414:     $flagFixedSize=FALSE) {
 415: 
 416:     // 拡大・縮小倍率
 417:     $scale = 1.0;
 418: 
 419:     // 画像フォーマットを取得する
 420:     if (preg_match('/\/([a-z]+)/i', $mimeType, $arr> 0) {
 421:         $imageFormat = $arr[1];
 422:     } else {
 423:         $imageFormat = 'jpeg';
 424:     }
 425: 
 426:     // GD画像データに変換する
 427:     $imageSource = imagecreatefromstring($imageData);
 428:     if (! $imageSource) {
 429:         $this->seterror('画像データを縮小できません');
 430:         return FALSE;
 431:     }
 432: 
 433:     // 元の画像の幅・高さを取得
 434:     $originalWidth  = imagesx($imageSource);
 435:     $originalHeight = imagesy($imageSource);
 436: 
 437:     // リサイズ倍率を計算する
 438:     $scaleW = $maxWidth / $originalWidth;
 439:     $scaleH = $maxHeight / $originalHeight;
 440:     $scale = min($scaleW, $scaleH);
 441:     $newWidth  = (int)($originalWidth  * $scale);
 442:     $newHeight = (int)($originalHeight * $scale);
 443: 
 444:     // リサイズ後の画像オブジェクトを用意
 445:     $imageResize = imagecreatetruecolor($newWidth, $newHeight);
 446:     // 透明色の処理(PNGやGIFの場合)
 447:     imagealphablending($imageResize, FALSE);
 448:     imagesavealpha($imageResize, TRUE);
 449:     $transparent = imagecolorallocatealpha($imageResize, 255, 255, 255, 127);
 450:     imagefilledrectangle($imageResize, 0, 0, $newWidth, $newHeight, $transparent);
 451:     // 画像リサイズ実行
 452:     imagecopyresampled($imageResize, $imageSource, 0, 0, 0, 0, $newWidth, $newHeight, $originalWidth, $originalHeight);
 453: 
 454:     // 画像を最大幅・高に加工する(背景白色)
 455:     if ($flagFixedSize) {
 456:         // 白色背景を作成する
 457:         $imageBackground = imagecreatetruecolor($maxWidth, $maxHeight);
 458:         $white = imagecolorallocate($imageBackground, 255, 255, 255);
 459:         imagefill($imageBackground, 0, 0, $white);
 460:         // 画像を背景の中央に配置するための座標計算
 461:         $dstX = (int)(($maxWidth -  $newWidth) / 2);
 462:         $dstY = (int)(($maxHeight - $newHeight) / 2);
 463:         // $imageResize を白色背景にコピー
 464:         imagecopy($imageBackground, $imageResize, $dstX, $dstY, 0, 0, $newWidth, $newHeight);
 465:         // リサイズした画像をバイナリ形式に変換する
 466:         $imageData = $this->image2binary($imageBackground, $imageFormat);
 467:         // メモリ解放
 468:         imagedestroy($imageBackground);
 469: 
 470:     // 画像縮小のみの場合
 471:     } else {
 472:         // リサイズした画像をバイナリ形式に変換する
 473:         $imageData = $this->image2binary($imageResize, $imageFormat);
 474:     }
 475:     // メモリ解放
 476:     imagedestroy($imageResize);
 477: 
 478:     return $imageData;
 479: }

ユーザー定義メソッド reductImage は、 file_get_contents 関数で読み込んだ画像データ(バイナリデータ)と、MIMEタイプ、最大画像幅・高を渡し、必要に応じて拡大・縮小した画像データ(バイナリデータ)を返す。

まず、引数として渡された $mimeType から画像形式を取り出して変数 $imageFormat に代入する。これは拡大・縮小後の画像形式を保つための処理だ。

画像の拡大・縮小には GD関数群を利用する。
その前に、 imagecreatefromstring 関数を使い、引数で渡された画像データ(バイナリデータ)をGD画像データに変換する。次に、 imagesx 関数を使い、画像の幅と高さを取得する。

最大画像幅・高と比較して、縮小率を計算し変数 $scale に代入する。
サイズ後の画像オブジェクト $imageResize を用意し、 imagealphablending 関数、 imagesavealphag 関数、 imagecolorallocatealpha 関数、 imagefilledrectangle 関数を使って透明色の処理を行ったら、 imagecopyresampled 関数を使ってリサイズを実行する。
最後に、GD画像データを画像データ(バイナリデータ)に変換するには、後述する image2binaryメソッドを適用し、メモリを解放する。

$imageFormat が TRUE のときは、 imagecreatetruecolor 関数、 imagecolorallocate 関数、 imagefill 関数を使って新しい白一色の画像を生成する。元画像を白色画像の中央に配置するための座標計算をしたら、 imagecopy 関数を使って2つの画像を合成する。

pahooBlueskyAPI.php

 363: /**
 364:  * GD画像データをバイナリデータに変換する.
 365:  * @param   string $image GD画像データ
 366:  * @param   string $imageFormat 変換する画像フォーマット(jpeg, png, gif)
 367:  * @return  string バイナリデータ/FALSE 対応していない画像フォーマット
 368: */
 369: function image2binary($image, $imageFormat='jpeg') {
 370:     // 出力バッファリングを開始
 371:     ob_start();
 372: 
 373:     // 画像フォーマットに応じて変換関数を選択
 374:     switch ($imageFormat) {
 375:         case 'jpeg':
 376:             imagejpeg($image, NULL, 75);
 377:             break;
 378:         case 'png':
 379:             imagepng($image, NULL, 5);
 380:             break;
 381:         case 'gif':
 382:             imagegif($image);
 383:             break;
 384:         case 'webp':
 385:             imagewebp($image, NULL, 75);
 386:             break;
 387:         case 'bmp':
 388:             imagebmp($image);
 389:             break;
 390:         case 'avif':
 391:             imageavif($image, NULL, 50);
 392:             break;
 393:         default:
 394:             return FALSE;
 395:     }
 396: 
 397:     // バッファ内容を取得する
 398:     $binaryData = ob_get_clean();
 399: 
 400:     return $binaryData;
 401: }

ユーザー定義メソッド image2binary は、引数で渡されたGD画像データと画像フォーマットにしたがい、画像データ(バイナリ)に変換したデータを返す。

GD関数群にある  imagejpeg 関数などを使い、画面に出力される画像データ(バイナリ)を、 ob_start 関数を使って横取りすることで変数に格納する。
画像フォーマットに応じて変換するGD関数を選択し、最後に  ob_get_clean 関数を使って変数に格納する。

解説:透明背景を白色で塗りつぶす

Bluesky の仕様で、透明背景の画像を投稿すると背景が黒くなってしまう。ダークモードで見ることが前提だからかもしれないが、ちょっと見にくいと感じたので透明背景を白色で塗りつぶすメソッドを用意した。

pahooBlueskyAPI.php

 481: /**
 482:  * 透明背景を白色で塗りつぶす
 483:  * Blueskyに投稿したときに黒背景になってしまうため
 484:  * @param   string $imageData 画像データ(画像ファイルから読み込んだバイナリ)
 485:  * @return  string 変換後の画像データ/FALSE 対応していない画像フォーマット
 486: */
 487: function convertTransparentToWhite($imageData, $mimeType='image/png') {
 488:     // 画像フォーマットを取得する
 489:     if (preg_match('/\/([a-z]+)/i', $mimeType, $arr> 0) {
 490:         $imageFormat = $arr[1];
 491:     } else {
 492:         $imageFormat = 'png';
 493:     }
 494: 
 495:     // アルファチャネルをサポートしていない画像フォーマットはそのままリターン
 496:     if (! preg_match('/png|webp|tiff|psd|exr|ico/i', $imageFormat)) {
 497:         return $imageData;
 498:     }
 499: 
 500:     // GD画像データに変換する
 501:     $imageSource = imagecreatefromstring($imageData);
 502:     if (! $imageSource) {
 503:         $this->seterror('画像データを読み込めません');
 504:         return FALSE;
 505:     }
 506: 
 507:     // 画像の幅・高さを取得
 508:     $width  = imagesx($imageSource);
 509:     $height = imagesy($imageSource);
 510: 
 511:     // 背景画像を作成する
 512:     $imageResult = imagecreatetruecolor($width, $height);
 513: 
 514:     // 白色を作成して塗りつぶす
 515:     $white = imagecolorallocate($imageResult, 255, 255, 255);
 516:     imagefill($imageResult, 0, 0, $white);
 517: 
 518:     // 元の画像を新しい画像にコピーする
 519:     imagecopy($imageResult, $imageSource, 0, 0, 0, 0, $width, $height);
 520: 
 521:     // アルファブレンドを無効化して保存する
 522:     imagesavealpha($imageResult, FALSE);
 523: 
 524:     // リサイズした画像をバイナリ形式に変換する
 525:     $imageData = $this->image2binary($imageResult, $imageFormat);
 526:     // メモリ解放
 527:     imagedestroy($imageResult);
 528: 
 529:     return $imageData;
 530: }

プログラムの流れは、上述の reductImageメソッドで余白を白く塗りつぶす処理とほぼ同じである。

解説:OGP情報を取得

OGP情報を取得
上図のように記事をカード形式で投稿したい場合は、コンテンツ中の OGP情報を取り出す必要がある。
OGP情報 とは、HTMLコンテンツのheadタグの中に含まれている次のタグを指す。
<head prefix="og: https://ogp.me/ns#">
<meta property="og:url" content="{コンテンツURL}">
<meta property="og:type" content="article">
<meta property="og:title" content="{コンテンツ・タイトル}">
<meta property="og:description" content="{コンテンツの概要}">
<meta property="og:site_name" content="{サイト名}">
<meta property="og:image" content="{代表画像URL}">

pahooBlueskyAPI.php

 607: /**
 608:  * OGP情報を取得する.
 609:  * @param   string $url 対象コンテンツ
 610:  * @return  array OGP情報(embed形式)/NULL:OGP情報はない
 611: */
 612: function getOGPInformation($url) {
 613:     $contents = '';
 614:     if (($infp = fopen($url, 'r')) == FALSE)    return NULL;
 615:     while (! feof($infp)) {
 616:         $contents .fread($infp, 5000);
 617:     }
 618:     fclose($infp);
 619: 
 620:     // 文字化け対策:読み込んだコンテンツをUTF-8に変換
 621:     $contents = mb_convert_encoding($contents, self::INTERNAL_ENCODING, 'auto');
 622: 
 623:     // コンテンツからOGP情報を抽出する
 624:     $pcr = new pahooScraping($contents);
 625:     $oggImage = $pcr->getValueFistrXPath('//meta[@property="og:image"]', 'content');
 626:     $oggDescription = $pcr->getValueFistrXPath('//meta[@property="og:description"]', 'content');
 627:     $oggTitle = $pcr->getValueFistrXPath('//meta[@property="og:title"]', 'content');
 628:     $pcr = NULL;
 629: 
 630:     // OGP情報がない
 631:     if (($oggImage == ''|| ($oggDescription == ''|| ($oggTitle == '')) {
 632:         return NULL;
 633:     }
 634: 
 635:     // embedに成形する
 636:     $mimeType = '';
 637:     $fileSize = 0;
 638:     $image = $this->uploadBlob($oggImage, self::MAX_IMAGE_WIDTH, self::MAX_IMAGE_HEIGHT, TRUE);
 639:     if ($image == FALSE)    return NULL;
 640:     $embed = [
 641:         'embed' => [
 642:             '$type'  => 'app.bsky.embed.external',
 643:             'external' => [
 644:                 'uri'           => $url,
 645:                 'thumb'         => $image,
 646:                 'title'         => $oggTitle,
 647:                 'description'   => $oggDescription,
 648:             ]
 649:         ]
 650:     ];
 651:     return $embed;
 652: }

ユーザー定義メソッド getOGPInformation は、コンテンツURLを引数に、上述の OGP情報 をスクレイピングによって取り出す。ユーザー定義クラス pahooScraping の使い方については、「PHPでDOMDocumentを使ってスクレイピング」などをご覧いただきたい。
Twitter(現・X) APIは、メッセージにURLを記載するだけで、Twitterボットが OGP情報 を探して非同期にアップロードするが、Bluesky API ではクライアント側でアップロードしてやる必要がある。Bluesky は分散型SNSであるため、ボットに複雑な作業をさせないよう、クライアント側で処理する仕様になっているものと思われる。そのおかげで、後述するように、引用投稿にOGP情報や画像を含めるという、Twitter(現・X) で実装されていない投稿を可能にしている。

解説:ユーザーのDIDを取得する

返信や引用を行う際、後述するように、メッセージのルートID親ID が必要になる。これを取得するために、返信元/引用元のメッセージを投稿したユーザーの DID(Detects Decentralized Identifiers)が必要になる。
ユーザーの DID は、ユーザー・プロファイル情報を取得するエンドポイントは app.bsky.actor.getProfile だ。
com.atproto.repo.uploadBlob
URL (public)
https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={ユーザー名}
URL (認証必要)
https://{PDSドメイン}/xrpc/app.bsky.actor.getProfile?actor={ユーザー名}
リクエスト・データ(http) header Content-Type "application/json" Authorization "Bearer {アクセストークン}"
レスポンス・データ(json) did DID handle ユーザー名 displayName 表示名 avatar アバター画像URL associated lists リスト feedgens フィード starterPacks スターターパック labeler chat allowIncoming all | none | following createdAt 登録日時 description 紹介文 ndexedAt 最新投稿日時 banner バナー画像URL followersCount フォロワー数 followsCount フォロー数 postsCount 投稿数 pinnedPost cid 固定投稿のCID uri 固定投稿のURI
エンドポイント app.bsky.actor.getProfile は2種類あり、publicの方は認証不要である。つまり、上述のリクエストのheaderは不要となる。

目的とするユーザーDIDは、上述のレスポンスの 1項目に過ぎないので、まず、ユーザー名を与えてエンドポイント app.bsky.actor.getProfile を呼び出すメソッド getProfile を作成し、得られたレスポンスからユーザーDIDだけを返すメソッド getDID の2つを用意した。

pahooBlueskyAPI.php

 654: /**
 655:  * ユーザー・プロファイル情報を取得する
 656:  * @param   string $name ユーザーのアカウント名
 657:  * @return  array ユーザー・プロファイル情報 / FALSE:取得失敗
 658: */
 659: function getProfile($name) {
 660:     // リクエストURL (public)
 661:     $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile';
 662:     // リクエストURL (認証必要)
 663: //  $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.actor.getProfile';
 664:     $this->webapi = $requestURL;
 665: 
 666:     // cURLを使ったリクエスト
 667:     $ch = curl_init();
 668:     curl_setopt($ch, CURLOPT_URL, $requestURL . '?actor=' . $name); 
 669:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 670:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 671:         'Content-Type: application/json',
 672:         'Authorization: Bearer ' . $this->accessJwt,
 673:     ]);
 674:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 675:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキッ
 676:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 677: 
 678:     // レスポンス処理
 679:     $response = curl_exec($ch);
 680:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 681:     if ($httpStatusCode !200) {
 682:         $this->seterror('ユーザー・プロファイル情報を取得できません(httpステータス異常)');
 683:         return FALSE;
 684:     }
 685:     curl_close($ch);
 686:     $items = json_decode($response, TRUE);
 687: 
 688:     return $items;
 689: }

pahooBlueskyAPI.php

 691: /**
 692:  * ユーザーのDIDを取得する
 693:  * @param   string $name ユーザーのアカウント名
 694:  * @return  string ユーザーのDID / FALSE:取得失敗
 695: */
 696: function getDID($name) {
 697:     $userProfiles = $this->getProfile($name);
 698: 
 699:     if ($userProfiles == FALSE) {
 700:         return FALSE;
 701:     } else if (! isset($userProfiles['did'])) {
 702:         $this->seterror('ユーザーのDIDを取得できません)');
 703:         return FALSE;
 704:     } else {
 705:         return $userProfiles['did'];
 706:     }
 707: }

解説:ルートIDと親IDを取得する

返信や引用を行う際に必要となるメッセージのルートID(返信/引用するメッセージ・スレットの先頭メッセージを示すID) や親ID(返信/引用するメッセージのID)を取得するエンドポイントは docs.bsky.app/docs/api/app-bsky-feed-get-post-thread だ。
com.atproto.repo.uploadBlob
URL (public)
https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri={atURI}
URL (認証必要)
https://{PDSドメイン}/xrpc/app.bsky.feed.getPostThread?uri={atURI}
ここで atURI という新たなキーワードが出てきた。
Bluesky は、分散型SNSであるため、1つ1つのメッセージを管理するIDを URI(Uniform Resource Identifier)で行っている。Bluesky の専用URIを atURI と呼び、次のような構造をしている。
at://{ユーザーDID}/app.bsky.feed.post/{ポストID}
ポストIDは、投稿メッセージのURLから得ることができる。
https://bsky.app/profile/{ユーザー名}/post/{ポストID}
ユーザーDID は、前述のメソッド getDID を使って取得する。
リクエスト・データ(http) header Content-Type "application/json" Authorization "Bearer {アクセストークン}"
レスポンス・データ (スレッドでない場合)(json) thread $type "app.bsky.feed.defs#threadViewPost" post uri メッセージURI cid メッセージCID author did DID handle ユーザー名 displayName 表示名 avatar アバター画像URL labeler createdAt 登録日時 record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式)
上図のレスポンスは、返信/引用対象が1つ(スレッド)になっていない場合だ。レスポンスが非常に多いので、必要な部分を抜粋して描いている。この場合は、ルートID親ID は共通で、thread->post->uri, thread->post->cid の2つ組みを指定する。
一方、スレッドになっている場合は、下図のようなレスポンスが返る。こちらも抜粋になる。
レスポンス・データ (スレッドの場合)(json) thread $type "app.bsky.feed.defs#threadViewPost" post uri メッセージURI cid メッセージCID author did DID handle ユーザー名 displayName 表示名 avatar アバター画像URL labels createdAt 登録日時 record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式) reply root uri ルートのメッセージURI cid ルートのメッセージCID parent uri 親のメッセージURI cid 親のメッセージCID
スレッドになっている場合は、上図のようなレスポンスが返る。親ID は thread->post->uri, thread->post->cid の2つ組みであることに変わりないが、ルートID はスレッドのルート、すなわち thread->post->record->reply->root->uri, thread->post->record->reply->root->cid の2つ組みとなる。

そこで、返信/引用元メッセージのURLを与え、ここから atURI を生成し、エンドポイント app.bsky.feed.getPostThread を呼び出すメソッド getPostThread を作成し、得られたルートID親IDの2つを返すメソッド getRootParentID の2つを用意した。

pahooBlueskyAPI.php

 710: /**
 711:  * メッセージURLからスレッド情報を取得する
 712:  * @param   string $url メッセージURL
 713:  * @return  array スレッド情報情報 / FALSE:取得失敗
 714: */
 715: function getPostThread($url) {
 716:     // ユーザー名、投稿IDを取得する
 717:     if (preg_match('/\/profile\/([^\/]+)\/post\/([0-9a-zA-Z]+)/ui', $url, $arr) == 0) {
 718:         $this->seterror($url . 'は投稿URLではありません');
 719:         return FALSE;
 720:     }
 721:     if (count($arr< 3) {
 722:         $this->seterror($url . '投稿URLではありません');
 723:         return FALSE;
 724:     }
 725:     $userName = $arr[1];
 726:     $postID   = $arr[2];
 727: 
 728:     // ユーザーDIDを取得する
 729:     $userDID = $this->getDID($userName);
 730:     if ($userDID == FALSE) {
 731:         return FALSE;
 732:     }
 733: 
 734:     // AT-URIを生成する
 735:     $atURI = 'at://' . $userDID . '/app.bsky.feed.post/' . $postID;
 736: 
 737:     // リクエストURL (public)
 738:     $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread';
 739:     // リクエストURL (認証必要)
 740: //  $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.feed.getPostThread';
 741:     $ch = curl_init($requestURL . '?uri=' . urlencode($atURI));
 742:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 743:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 744:         'Content-Type: application/json',
 745:         'Authorization: Bearer ' . $this->accessJwt,
 746:     ]);
 747:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 748:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキッ
 749:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 750: 
 751:     // レスポンス処理
 752:     $response = curl_exec($ch);
 753:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 754: 
 755:     if ($httpStatusCode !200) {
 756:         $this->seterror('ルートID,親IDを取得できません(httpステータス異常)');
 757:         return FALSE;
 758:     }
 759:     curl_close($ch);
 760:     $items = json_decode($response, TRUE);
 761: 
 762:     return $items;
 763: }

pahooBlueskyAPI.php

 765: /**
 766:  * メッセージURLからルートIDと親IDを取得する
 767:  * @param   string $url メッセージURL
 768:  * @return  array(ルートID, 親の投稿ID) / FALSE:取得失敗
 769: */
 770: function getRootParentID($url) {
 771:     // スレッド情報を取得する
 772:     $items = $this->getPostThread($url);
 773:     if ($items == FALSE)    return FALSE;
 774: 
 775:     // ルートIDを取得する
 776:     // スレッドがあればrootを取得する
 777:     if (isset($items['thread']['post']['record']['reply']['root'])) {
 778:         $rootID = $items['thread']['post']['record']['reply']['root'];
 779:     // スレッドがなければ投稿IDを取得する
 780:     } else if (isset($items['thread']['post']['cid'])) {
 781:         $rootID = array(
 782:             'cid' => $items['thread']['post']['cid'],
 783:             'uri' => $items['thread']['post']['uri']
 784:         );
 785:     } else {
 786:         $this->seterror('ルートIDを取得できません');
 787:         return FALSE;
 788:     }
 789:     // 親IDを取得する(常に投稿ID)
 790:     if (isset($items['thread']['post']['cid'])) {
 791:         $parentID = array(
 792:             'cid' => $items['thread']['post']['cid'],
 793:             'uri' => $items['thread']['post']['uri']
 794:         );
 795:     } else {
 796:         $this->seterror('親IDを取得できません');
 797:         return FALSE;
 798:     }
 799: 
 800:     return array($rootID, $parentID);
 801: }

解説:メッセージ投稿

メッセージや画像を投稿するエンドポイントは com.atproto.repo.createRecord だ。これ1つで、返信や引用投稿もできる。
com.atproto.repo.putRecord
URL
https://{PDSドメイン}/xrpc/com.atproto.repo.createRecord
単純にメッセージを投稿するだけであれば、次の形式でリクエスト・データを渡す。
リクエスト・データ(http) header Content-Type "application/json" Authorization "Bearer {アクセストークン}" post repo ハンドル名 collection "app.bsky.feed.post" record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式)
レスポンス・データ(json) id 投稿ID text 投稿文
URLリンク情報を含む場合は、次の形式でリクエスト・データを渡す。
リクエスト・データ(URLリンク・ハッシュタグ有) (http) header Content-Type "application/json" Authorization "Bearer {アクセストークン}" post repo ハンドル名 collection "app.bsky.feed.post" record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式) facets index byteStart リンクの開始位置 byteEnd リンクの終了位置 features $type "app.bsky.richtext.facet#link" uri URL index byteStart リンクの開始位置 byteEnd リンクの終了位置 features $type "app.bsky.richtext.facet#link" uri URL index byteStart ハッシュタグの開始位置 byteEnd ハッシュタグの終了位置 features $type "app.bsky.richtext.facet#tag" tag ハッシュタグ(#を除く)
更に画像などの画像データを含む場合は、次の形式でリクエスト・データを渡す。
リクエスト・データ(画像データ有)(http) header Content-Type "application/json" Authorization "Bearer {アクセストークン}" post repo ハンドル名 collection "app.bsky.feed.post" record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式) facets index byteStart リンクの開始位置 byteEnd リンクの終了位置 features $type "app.bsky.richtext.facet#link" uri URL index byteStart リンクの開始位置 byteEnd リンクの終了位置 features $type "app.bsky.richtext.facet#link" uri URL index byteStart ハッシュタグの開始位置 byteEnd ハッシュタグの終了位置 features $type "app.bsky.richtext.facet#tag" tag ハッシュタグ(#を除く) embed $type "app.bsky.embed.images" images alt '' image メディアをアップしたPDSのURL alt '' image メディアをアップしたPDSのURL
コンテンツをカード形式で(OGP情報を)投稿したいときは、次のフォーマットのリクエスト・データを渡す。画像データとどちらか一方を選ぶことになる。
リクエスト・データ(OGP情報) (http) header Content-Type "application/json" Authorization "Bearer {アクセストークン}" post repo ハンドル名 collection "app.bsky.feed.post" record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式) facets index byteStart リンクの開始位置 byteEnd リンクの終了位置 features $type "app.bsky.richtext.facet#link" uri URL index byteStart リンクの開始位置 byteEnd リンクの終了位置 features $type "app.bsky.richtext.facet#link" uri URL index byteStart ハッシュタグの開始位置 byteEnd ハッシュタグの終了位置 features $type "app.bsky.richtext.facet#tag" tag ハッシュタグ(#を除く) embed $type "app.bsky.embed.external" external uri コンテンツURL thumb メディアをアップしたPDSのUR title コンテンツのタイトル description コンテンツの概要
返信の場合は、次のフォーマットのリクエスト・データを渡す。上図にある、リンクや画像データ、OGP情報を追加することもできる。
リクエスト・データ(返信投稿) (http) header Content-Type "application/json" Authorization "Bearer {アクセストークン}" post repo ハンドル名 collection "app.bsky.feed.post" record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式) reply parent uri 親のURI cid 親のCID root uri ルートのURI cid ルートのCID
引用投稿の場合は、次のフォーマットのリクエスト・データを渡す。上図にある、リンクや返信のタグ情報を追加することもできる。
リクエスト・データ(引用投稿) (http) header Content-Type "application/json" Authorization "Bearer {アクセストークン}" post repo ハンドル名 collection "app.bsky.feed.post" record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式) embed $type "app.bsky.embed.record" parent uri 親のURI cid 親のCID
引用投稿にOGP情報を含むことができる。
リクエスト・データ(引用+OGP情報) (http) header Content-Type "application/json" Authorization "Bearer {アクセストークン}" post repo ハンドル名 collection "app.bsky.feed.post" record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式) embed $type "app.bsky.embed.recordWithMedia" media $type "app.bsky.embed.external" external uri コンテンツURL thumb メディアをアップしたPDSのUR title コンテンツのタイトル description コンテンツの概要 record $type "app.bsky.embed.record" parent uri 親のURI cid 親のCID facets index byteStart リンクの開始位置 byteEnd リンクの終了位置 features $type "app.bsky.richtext.facet#link" uri URL
OGP情報の代わりに画像データを含めることができ、medidノードに、前述の画像の embedノードの内容を記載すればよい。
投稿の流れをフロー図に示し、プログラムを掲載する。
pahooBlueskyAPI::post

pahooBlueskyAPI.php

 803: /**
 804:  * メッセージを投稿する.
 805:  * リンクが含まれている場合は自動的にハイパーリンクに変換する.
 806:  * 画像へのリンクが含まれている場合は自動的にアップロードする(4個まで).
 807:  * @param   string $message 投稿メッセージ(UTF-8限定)
 808:  * @param   bool $flagCard  FALSE:カード形式で投稿しない(省略時)
 809:  *              TRUE:OOGP情報がある最初のリンクをカード形式で投稿する
 810:  * @param   string $replyURL  NULL:返信しない(省略時)/返信する投稿URL
 811:  * @param   string $quoteURL  NULL:引用しない(省略時)/引用する投稿URL
 812:  * @param   array  $media     NULL:使用しない(省略時)/画像データ配列
 813:  * @return  string メッセージURL/FALSE:失敗
 814: */
 815: function post($message, $flagCard=FALSE, $replyURL=NULL, $quoteURL=NULL, $media=NULL) {
 816:     // エラーメッセージ・クリア
 817:     $this->clearerror();
 818: 
 819:     // 初期化
 820:     $embed  = NULL;
 821:     $images = array();
 822:     $urls   = array();
 823:     $reply  = array();
 824: 
 825:     // 返信の場合
 826:     if ($replyURL !NULL) {
 827:         $res = $this->getRootParentID($replyURL);
 828:         if (! $res) {
 829:             return FALSE;
 830:         }
 831:         $rootID   = $res[0];
 832:         $parentID = $res[1];
 833:         $reply = [
 834:             'reply' => [
 835:                 'root'   => $rootID,
 836:                 'parent' => $parentID,
 837:             ]
 838:         ];
 839:     }
 840: 
 841:     // メッセージ中から画像へのリンクを抽出する
 842:     $message = $this->extractMediaURL($message, $urls);
 843: 
 844:     // 画像投稿を行う
 845:     if ($media !NULL) {
 846:         $cnt = 1;
 847:         foreach ($media as $data) {
 848:             $tmpname = $this->saveTempFile($data);
 849:             $image = $this->uploadBlob($tmpname, self::MAX_IMAGE_WIDTH, self::MAX_IMAGE_HEIGHT, FALSE);
 850:             unlink($tmpname);
 851:             $images = array_merge($images, [['alt' => '', 'image' => $image]]);
 852:             $cnt++;
 853:             if ($cnt > 4)   break;
 854:         }
 855:         $embed = [
 856:             'embed' => [
 857:                 '$type'  => 'app.bsky.embed.images',
 858:                 'images' => $images,
 859:             ]
 860:         ];
 861:     // メッセージ中に画像URL等が含まれている場合
 862:     } else if (($embed == NULL&& (count($urls> 0)) {
 863:         $cnt = 1;
 864:         foreach ($urls as $filename) {
 865:             // 画像アップロード(必要に応じてリサイズ)
 866:             $image = $this->uploadBlob($filename, self::MAX_IMAGE_WIDTH, self::MAX_IMAGE_HEIGHT, FALSE);
 867:             $images = array_merge($images, [['alt' => '', 'image' => $image]]);
 868:             $cnt++;
 869:             if ($cnt > 4)   break;
 870:         }
 871:         $embed = [
 872:             'embed' => [
 873:                 '$type'  => 'app.bsky.embed.images',
 874:                 'images' => $images,
 875:             ]
 876:         ];
 877:     // OGP情報を取得する
 878:     } else if ($flagCard) {
 879:         if (preg_match_all('/https?\:\/\/[^\s]+/', $message, $arr> 0) {
 880:             foreach ($arr[0as $url) {
 881:                 $embed = $this->getOGPInformation($url);
 882:                 if ($embed !NULL) {
 883:                     break;
 884:                 }
 885:             }
 886:         }
 887:     }
 888: 
 889:     // 引用処理
 890:     if ($quoteURL !NULL) {
 891:         $res = $this->getRootParentID($quoteURL);
 892:         if (! $res) {
 893:             return FALSE;
 894:         }
 895:         $parentID = $res[1];
 896:         // 画像やOGP情報がある場合
 897:         if ($embed !NULL) {
 898:             $embed = [
 899:                 'embed' => [
 900:                     '$type' => 'app.bsky.embed.recordWithMedia',
 901:                     'media' => $embed['embed'],
 902:                     'record' => [
 903:                         '$type' => 'app.bsky.embed.record',
 904:                         'record' => $parentID,
 905:                     ],
 906:                 ]
 907:             ];
 908:         } else {
 909:             $embed = [
 910:                 'embed' => [
 911:                     '$type' => 'app.bsky.embed.record',
 912:                     'record' => $parentID,
 913:                 ]
 914:             ];
 915:         }
 916:     }
 917: 
 918:     // URLやハッシュ情報の取得
 919:     $facets = $this->parseRichText($message);
 920: 
 921:     // POSTデータ配列を作成する
 922:     $records = [
 923:         '$type'     => 'app.bsky.feed.post',
 924:         'text'      => $message,
 925:         'createdAt' => (new DateTime())->format('c'),
 926:     ];
 927:     if ($replyURL == NULL) {
 928:         if ($embed == NULL) {
 929:             $records = array_merge($records, $facets);
 930:         } else {
 931:             $records = array_merge($records, $facets, $embed);
 932:         }
 933:     } else {
 934:         if ($embed == NULL) {
 935:             $records = array_merge($records, $facets, $reply);
 936:         } else {
 937:             $records = array_merge($records, $facets, $reply, $embed);
 938:         }
 939:     }
 940: //  var_dump($records);
 941: 
 942:     // リクエストURL
 943:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.createRecord';
 944:     $this->webapi = $requestURL;
 945:     // cURLを使ったリクエスト
 946:     $ch = curl_init($requestURL);
 947:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 948:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 949:         'Content-Type: application/json',
 950:         'Authorization: Bearer ' . $this->accessJwt,
 951:     ]);
 952:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 953:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキップ
 954:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 955:     curl_setopt($ch, CURLOPT_POST, TRUE);
 956:     curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
 957:         'repo'          => $this->BLUESKY_HANDLE,
 958:         'collection'    => 'app.bsky.feed.post',
 959:         'record'        => $records,
 960:     ]));
 961: 
 962:     // レスポンス処理
 963:     $response = curl_exec($ch);
 964:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 965:     if ($httpStatusCode !200) {
 966:         $this->seterror('投稿できません(httpステータス異常)');
 967:         return FALSE;
 968:     }
 969:     curl_close($ch);
 970:     $items = json_decode($response, TRUE);
 971: //  var_dump($items);
 972: 
 973:     // エラーチェックとリターン
 974:     if (isset($items['uri'])) {
 975:         if (preg_match('/\/([0-9a-zA-Z]+)$/ui', $items['uri'], $arr> 0) {
 976:             $url = 'https://bsky.app/profile/' . $this->BLUESKY_HANDLE . '/post/' . $arr[1];
 977:         } else {
 978:             $url = '';
 979:         }
 980:         return $url;
 981:     } else if (isset($items['error'])) {
 982:         $this->seterror($items['message']);
 983:         return FALSE;
 984:     } else {
 985:         $this->seterror('投稿できません(応答不正)');
 986:         return FALSE;
 987:     }
 988: }

解説:メイン・プログラム

postBluesky.php

  35: // データ入力に関わる関数群:include_pathに配置すること
  36: require_once('pahooInputData.php');
  37: 
  38: // PHPバージョン・チェック
  39: exitIfLessVersion(MINUMUM_VERSION);
  40: 
  41: // リファラチェック+リリースフラグの設定
  42: if (isset($_SERVER['HTTP_HOST']) && ($_SERVER['HTTP_HOST'] == 'localhost')) {
  43:     define('FLAG_RELEASE', FALSE);
  44:     define('REFER_ON', '');
  45:     ini_set('display_errors', 1);
  46:     ini_set('error_reporting', E_ALL);
  47: else {
  48:     // リリース・フラグ(公開時にはTRUEにすること)
  49:     define('FLAG_RELEASE', TRUE);
  50:     // リファラ・チェック(直リン防止用;空文字ならチェックしない)
  51:     if (! isCommandLine()) {
  52:         define('REFER_ON', 'www.pahoo.org');
  53:     } else {
  54:         define('REFER_ON', '');
  55:     }
  56: }
  57: 
  58: // 表示幅(ピクセル)
  59: define('WIDTH', 600);
  60: 
  61: // 投稿可能な最大画像数
  62: define('MAX_IMAGES', 4);
  63: 
  64: // 投稿メッセージ(初期値)
  65: define('DEF_MESSAGE', 'PHPでBlueskyにメッセージや画像を投稿するプログラムを作成。カード形式の投稿や返信、引用投稿も可能。API操作はクラスファイルに分離し、他プログラムからも利用可能。他サイト配布以外のプログラムやライブラリは不要。 https://www.pahoo.org/e-soul/webtech/php06/php06-30-01.shtm');
  66: 
  67: // BlueskyAPIクラス:include_pathが通ったディレクトリに配置
  68: require_once('pahooBlueskyAPI.php');

postBluesky.php

 661: // メイン・プログラム =======================================================
 662: // オブジェクトを生成する.
 663: $pbs = new pahooBlueskyAPI('bsky.social');
 664: 
 665: // パラメータを取得する.
 666: $msg = getParam('msg', TRUE, DEF_MESSAGE);
 667: $replyURL = getParam('replyURL', FALSE, NULL);
 668: if ($replyURL == '')    $replyURL = '';
 669: $quoteURL = getParam('quoteURL', FALSE, NULL);
 670: if ($quoteURL == '')    $quoteURL = '';
 671: $reply = isButton('reply'? TRUE : FALSE;
 672: $outmsg = '';
 673: 
 674: // 投稿
 675: if (isButton('exec')) {
 676:     // XSS対策
 677:     $msg = htmlspecialchars($msg);
 678: 
 679:     // 画像データがあればメッセージに追加
 680:     $saveFileNames = array();
 681:     saveImage($saveFileNames);
 682:     foreach ($saveFileNames as $fname) {
 683:         $imageURI = 'file:///' . preg_replace('/\\\/ui', '/', $fname);
 684:         $msg .' ' . $imageURI;
 685:     }
 686: 
 687:     // セッション開始
 688:     $res = $pbs->createSession();
 689:     //投稿
 690:     if ($res) {
 691:         $res = $pbs->post($msg, TRUE, $replyURL, $quoteURL);
 692:     }
 693:     // エラー処理
 694:     if ($res == FALSE) {
 695:         $outmsg = '<p style="color:red;">エラー:' . $pbs->geterror() . '</p>';
 696:     } else {
 697:         $outmsg = '<p style="color:blue;">投稿成功:<a href="' . $res . '">' . $res . '</a></p>';
 698:         // 返信URLに代入する.
 699:         if ($reply) {
 700:             $replyURL = $res;
 701:         }
 702:         // セッション終了
 703:         $res = $pbs->deleteSession();
 704:         // エラー処理
 705:         if ($res == FALSE) {
 706:             $outmsg .'<p style="color:red;">エラー:' . $pbs->geterror() . '</p>';
 707:         }
 708:     }
 709: 
 710:     // 画像ファイルを削除
 711:     deleteImage($saveFileNames);
 712: 
 713: // クリア
 714: else if (isButton('clear')) {
 715:     $msg = $replyURL = $quoteURL = '';
 716:     $reply = FALSE;
 717: }
 718: 
 719: //表示HTMLを作成する.
 720: $HtmlBody = makeCommonBody($msg, $replyURL, $quoteURL, $reply, $outmsg, $pbs->webapi);
 721: 
 722: //画面に表示する.
 723: echo $HtmlHeader;
 724: echo $HtmlBody;
 725: echo $HtmlFooter;
 726: 
 727: //オブジェクトを解放する.
 728: $pbs = NULL;

メイン・プログラムは、冒頭でPHP7以上かどうかの判定を行い、クラス "pahooBlueskyAPI.php" を読み込む。

textareaに入力されたメッセージを取りだし、 htmlspecialchars 関数でXSS対策を行った後、セッション開始、メッセージ投稿、応答メッセージからエラー処理を行う。このとき正常応答が帰ってきたら、メッセージのURLを表示するようにする。最後にセッション終了する。

また、このプログラムはコマンドライン・パラメータとして、msg(投稿メッセージ)、replyURL(返信元URL)、quoteURL(引用元URL)を指定して呼び出すことができるので、JavaScriptと組み合わせてコンテンツ中に Bluesky への投稿処理を組み込むことができるだろう。

参考サイト

(この項おわり)
header