PHPでBlueskyに投稿する

(1/1)
Blueskyロゴ
PHPで Bluesky にメッセージや画像を投稿するプログラムを作る。ネット上のコンテンツが対応していればカード形式で(OGP情報を)投稿することもできる。返信や引用も投稿できるようにした。
API操作はクラスファイルに分離し、他のプログラムから利用しやすいようにした。当サイト以外が配布しているプログラムやライブラリは不要だ。
(2025年1月25日)トークンを保持するよう改良.「新規セッション」チェック追加.
(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.5.0 2025/01/25 トークンを保持,「新規セッション」チェック追加
1.4.1 2024/12/08 文字入力時の空白トリムなど追加
1.4.0 2024/12/06 画像のドラッグ&ドロップ,コピー&ペーストに対応
1.3.0 2024/11/10 入力メッセージの残文字数とプログレスバー表示
1.2.0 2024/11/01 返信URL代入機能を追加
pahooBlueskyAPI.php 更新履歴
バージョン 更新日 内容
2.0.0 2025/01/24 トークンを保持するよう改良
1.9.0 2025/01/16 getEmbedPosts() 追加
1.8.1 2024/12/11 getOGPInformation()--リダイレクト対応
1.8.0 2024/12/06 post()--引用時にも画像やOGP情報を付けられるように
1.7.0 2024/12/06 reductImage()--OGPかどうかに関わらず画像が指定サイズに収めるよう拡大・縮小する仕様に変更
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については、公式リファレンスが詳しい。APIを利用するには、事前に、あなたのアカウントから利用登録を行い、アプリパスワードを取得する必要がある。その手順は「Bluesky API - 各種クラウド連携サービス(WebAPI)の登録方法」をご覧いただきたい。
Bluesky APIを利用するメソッドはクラス "pahooBlueskyAPI.php" に分離している。また、このクラスからクラス "pahooScraping.php" を呼び出すので、2つのクラス・ファイルを include_path の通ったディレクトリに配置すること。

基本的に、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 から別のメソッドを呼び出して投稿に必要な追加情報を取得する形にした。

解説:API認証とセッション

トークン取得の流れ - pahooBlueskyAPIクラス
BlueskyAPI のうち認証が必要なものが複数ある。
基本的なAPIは、冒頭で紹介したアプリパスワードが必要になる。

プロファイル情報取得、スレッド情報取得、メッセージ投稿などでは、アプリパスワードの代わりにトークン(accessJWT)が必要になる。
accessJWT は英数字からなる文字列だが、その中に有効期間が埋め込まれており、その有効期間中(1~2時間)は APIとの間にセッションが張られているとイメージしてもらえばよい。

これまでは BlueskyAPI を呼び出す都度、accessJwt を新規生成していたが、新規生成 API の呼び出し回数制限が厳しくなってきていることから、トークンをサーバに保存し、有効期限内であれば再利用するように改良した。また、有効期限切れの場合も、セッションのリフレッシュAPIを利用し、トークンを新規生成せず有効期限を延長するようにした。リフレッシュトークン refreshJwt は寿命は数十日と長い。
ファイルには、アクセストークン accessJWT とリフレッシュトークン refreshJWT の2つを格納するが、いずれもアプリパスワードと同じように秘匿性を保つことに留意されたい。

トークン取得の流れを左図に整理する。

pahooBlueskyAPI.php

 296: /**
 297:  * 有効なアクセストークン(accessJwt)を取得する
 298:  * @param   なし
 299:  * @return  bool TRUE:成功/FALSE:失敗
 300: */
 301: function getValidToken() {
 302:     // プロパティにトークンが無い
 303:     if (($this->accessJwt == ''|| ($this->refreshJwt !== '')) {
 304:         // トークンを保存したファイルがない
 305:         if (! is_file(self::FILENAME_TOKEN)) {
 306:             return $this->createSession();
 307:         }
 308: 
 309:         // 保存ファイルからトークンを読み込む
 310:         $contents = @file_get_contents(self::FILENAME_TOKEN);
 311:         if ($contents == FALSE) {
 312:             return $this->createSession();
 313:         }
 314:         $arr = preg_split('/\n/msiu', $contents);
 315:         if (count($arr< 2) {
 316:             return $this->createSession();
 317:         }
 318:         if (($arr[0] == ''|| ($arr[1] == '')) {
 319:             return $this->createSession();
 320:         }
 321:         $this->accessJwt  = $arr[0];
 322:         $this->refreshJwt = $arr[1];
 323:     }
 324: 
 325:     // accessJwt の有効期限を検査する
 326:     $arr = preg_split('/\./iu', $this->accessJwt);
 327:     if (count($arr< 3) {
 328:         return $this->createSession();
 329:     }
 330:     $decodedPayload = base64_decode($arr[1]);
 331:     $decoded = json_decode($decodedPayload, TRUE);
 332:     $exp = isset($decoded['exp']) ? $decoded['exp': 0;
 333: 
 334:     // 期限切れならリフレッシュする
 335:     if (time() > ((int)$exp - 100)) {       // 余裕をみて100秒前
 336:         $res = $this->refreshSession();
 337:         if (! $res) {
 338:             return $this->createSession();
 339:         }
 340:     }
 341: 
 342:     return TRUE;
 343: }

上図の流れをプログラムにしたものがユーザー定義メソッド getValidToken である。
アクセストークン accessJwt は英数字からなる文字列だが、ドット [.;blue] で3つのブロックに区切られており、左から2番目のブロックには有効期限が Base64形式でエンコードされている。この値を現在時刻  time  と比較し、有効期限が過ぎていれば後述するメソッド refreshSession を使ってアクセストークンの有効期間を延長(リフレッショ)する。

解説:新規セッション開始

新規にアクセストークン accessJwt を取得する。使用するエンドポイントは com.atproto.server.createSession だ。
ここで取得したアクセストークンは再利用するため、定数 FILENAME_TOKEN で示すファイルの1行目にアクセストークン accessJwt を、2行目にリフレッシュトークン refreshJwt を保存する。
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

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:     var $refreshJwt;    // refreshJwt
  21: 
  22:     const INTERNAL_ENCODING = 'UTF-8';  // 内部エンコーディング
  23:     const MAX_MESSAGE_LEN = 300;        // 投稿可能なメッセージ文字数
  24:     const URL_LEN = 23;                 // メッセージ中のURL文字数(相当)
  25:     const MAX_IMAGE_WIDTH  = 1200;      // 投稿可能な最大画像幅(ピクセル)
  26:     const MAX_IMAGE_HEIGHT = 630;       // 投稿可能な最大画像高さ(ピクセル)
  27:                                         // これより大きいときは自動縮小する
  28:     // トークンを保存するファイル名
  29:     // 秘匿性を保つことができ、かつ、PHPプログラムから読み書き可能であること
  30:     const FILENAME_TOKEN = './.token';
  31: 
  32:     // Bluesky API アプリパスワード
  33:     // https://bsky.app/
  34:     var $BLUESKY_HANDLE   = '***************';      // ハンドル名
  35:     var $BLUESKY_PASSWORD = '***************';      // アプリケーション・パスワード

BlueskyAPI を利用するユーザー定義クラス pahooBlueskyAPIをつくる。
上述の手順で取得したアプリケーション・パスワードをプロパティ変数 $BLUESKY_PASSWORD に、あなたのハンドル名を $BLUESKY_HANDLE に代入する。
投稿可能な最大文字数は定数 MAX_MESSAGE_LEN として用意した。現在の仕様では300文字だ。
トークンを保存するファイル名を定数 FILENAME_TOKEN に用意する。前述したように、秘匿性が保つことができ、かつ PHPプログラムから読み書き可能なディレクトリを指定すること。

pahooBlueskyAPI.php

  35: /**
  36:  * コンストラクタ
  37:  * もしAPIエラーが出る場合には,新規セッションにしてみる.
  38:  * @param   string $pds PDSドメイン
  39:  * @param   bool $newSession 新規セッションにするかどうか(TRUE:新規,デフォルトはFALSE)
  40:  * @return  なし
  41: */
  42: function __construct($pds, $newSession=FALSE) {
  43:     $this->pds        = $pds;
  44:     $this->webapi     = '';
  45:     $this->errmsg     = '';
  46:     $this->accessJwt  = '';
  47:     $this->refreshJwt = '';
  48: 
  49:     // 新規セッションを開始する.
  50:     if ($newSession) {
  51:         $this->createSession();
  52:     }
  53: }

コンストラクタの引数は PDSドメインで、変数 $pds に保管し、API呼び出し時に参照できるようにした。
2番目の引数 $newSessionTRUE にすると、インスタンス生成時に必ず createSession メソッドを呼び出し、新規セッションを強制する。API呼び出しに失敗したり、セッションを保存したファイルが破損した場合に備えて用意したオプションである。省略時には FALSE(セッションを維持する)である。

pahooBlueskyAPI.php

 345: /**
 346:  * 新規セッションを開始する.
 347:  * @param   なし
 348:  * @return  bool TRUE:成功/FALSE:失敗
 349: */
 350: function createSession() {
 351:     // エラーメッセージ・クリア
 352:     $this->clearerror();
 353: 
 354:     // リクエストURL
 355:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.createSession';
 356:     $this->webapi = $requestURL;
 357:     $ch = curl_init($requestURL);
 358:     // cURLを使ったリクエスト
 359:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 360:     curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
 361:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 362:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
 363:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 364:     curl_setopt($ch, CURLOPT_POST, TRUE);
 365:     curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
 366:         'identifier' => $this->BLUESKY_HANDLE,
 367:         'password'   => $this->BLUESKY_PASSWORD,
 368:     ]));
 369: 
 370:     // レスポンス処理
 371:     $response = curl_exec($ch);
 372:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 373:     if ($httpStatusCode !200) {
 374:         $this->seterror('セッションを開始できません');
 375:         return FALSE;
 376:     }
 377:     curl_close($ch);
 378:     $items = json_decode($response, TRUE);
 379: 
 380:     // エラーチェックとリターン
 381:     if (isset($items['accessJwt']) && isset($items['refreshJwt'])) {
 382:         $this->accessJwt = (string)$items['accessJwt'];
 383:         $this->refreshJwt = (string)$items['refreshJwt'];
 384:         // トークンをファイルに保存する
 385:         $contents = $this->accessJwt . "\n" . $this->refreshJwt;
 386:         file_put_contents(self::FILENAME_TOKEN, $contents);
 387:         return TRUE;
 388:     } else if (isset($items['error'])) {
 389:         $this->seterror($items['message']);
 390:         return FALSE;
 391:     } else {
 392:         $this->seterror('セッションを開始できません');
 393:         return FALSE;
 394:     }
 395: }

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

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

解説:セッションをリフレッシュ

取得済みのアクセストークン accessJwt の有効期限を延長する。使用するエンドポイントは com.atproto.server.refreshSession だ。
ここで取得したアクセストークンは再利用するため、定数 FILENAME_TOKEN で示すファイルの1行目にアクセストークン accessJwt を、2行目にリフレッシュトークン refreshJwt を保存する。
com.atproto.server.createSession
URL
https://{PDSドメイン}/xrpc/com.atproto.server.refreshSession
リクエスト・データ(http) header Authorization "Bearer" + リフレッシュトークン Content-Type "application/json"
レスポンス・データ(json) accessJwt アクセストークン refreshJwt リフレッシュトークン handle ハンドル名 did did

pahooBlueskyAPI.php

 397: /**
 398:  * セッションをリフレッシュする.
 399:  * @param   なし
 400:  * @return  bool TRUE:成功/FALSE:失敗
 401: */
 402: function refreshSession() {
 403:     // エラーメッセージ・クリア
 404:     $this->clearerror();
 405: 
 406:     // リクエストURL
 407:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.refreshSession';
 408:     $this->webapi = $requestURL;
 409:     $ch = curl_init($requestURL);
 410:     // cURLを使ったリクエスト
 411:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 412:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 413:         'Authorization: Bearer ' . $this->refreshJwt,
 414:         'Content-Type: application/json',
 415:     ]);
 416:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 417:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
 418:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 419:     curl_setopt($ch, CURLOPT_POST, TRUE); 
 420: 
 421:     // レスポンス処理
 422:     $response = curl_exec($ch);
 423:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 424:     if ($httpStatusCode !200) {
 425:         $this->seterror('セッションをリフレッショできません');
 426:         return FALSE;
 427:     }
 428:     curl_close($ch);
 429:     $items = json_decode($response, TRUE);
 430: 
 431:     // エラーチェックとリターン
 432:     if (isset($items['accessJwt']) && isset($items['refreshJwt'])) {
 433:         $this->accessJwt = (string)$items['accessJwt'];
 434:         $this->refreshJwt = (string)$items['refreshJwt'];
 435:         // トークンをファイルに保存する
 436:         $contents = $this->accessJwt . "\n" . $this->refreshJwt;
 437:         file_put_contents(self::FILENAME_TOKEN, $contents);
 438:         return TRUE;
 439:     } else if (isset($items['error'])) {
 440:         $this->seterror($items['message']);
 441:         return FALSE;
 442:     } else {
 443:         $this->seterror('セッションをリフレッシュできません');
 444:         return FALSE;
 445:     }
 446: }

セッションをリフレッシュするメソッド refreshSession は、引数はなく、リフレッシュに成功したかどうかを戻り値にする。

解説:セッション終了

アクセストークン 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

 448: /**
 449:  * セッション終了する.
 450:  * @param   なし
 451:  * @return  bool TRUE:成功/FALSE:失敗
 452: */
 453: function deleteSession() {
 454:     // リクエストURL
 455:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.deleteSession';
 456:     $this->webapi = $requestURL;
 457:     $ch = curl_init($requestURL);
 458:     // cURLを使ったリクエスト
 459:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 460:     curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $this->accessJwt]);
 461:     curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
 462:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 463:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
 464:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 465: 
 466:     // レスポンス処理
 467:     $response = curl_exec($ch);
 468:     if (curl_errno($ch)) {
 469:         $this->seterror('セッション終了できません' . curl_error($ch));
 470:         return FALSE;
 471:     }
 472:     curl_close($ch);
 473:     $this->accessJwt = '';
 474:     $this->refreshJwt = '';
 475:     return TRUE;
 476: }

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

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

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

pahooBlueskyAPI.php

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

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

pahooBlueskyAPI.php

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

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

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

投稿メッセージ中に、画像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

 647: /**
 648:  * 画像をアップロードする.
 649:  * 画像ファイルなどを投稿するときに事前に呼び出し,blobデータを投稿する.
 650:  * @param   string $message   投稿メッセージ(UTF-8限定)
 651:  * @param   int    $maxWidth  アップロードする画像の最大幅(ピクセル)
 652:  * @param   int    $maxHeight アップロードする画像の最大高(ピクセル)
 653:  * @param   bool   $flagFixedSize TRUE:画像を最大幅・高に加工する(背景白色)
 654:  * @return  string Blusky PDSのURL/FALSE:アップロード失敗
 655: */
 656: function uploadBlob($filename, $maxWidth=self::MAX_IMAGE_WIDTH,
 657:     $maxHeight=self::MAX_IMAGE_HEIGHT, $flagFixedSize=FALSE) {
 658: 
 659:     $mimeType = '';
 660:     $fileSize = 0;
 661: 
 662:     // エラーメッセージ・クリア
 663:     $this->clearerror();
 664: 
 665:     // 画像を読み込む
 666:     $imageData = file_get_contents($filename);
 667:     if ($imageData === FALSE) {
 668:         $this->seterror($filename . ' の読み込みに失敗しました');
 669:         return FALSE;
 670:     }
 671:     // MIMEタイプを判定する
 672:     $finfo = new finfo(FILEINFO_MIME_TYPE);
 673:     $mimeType = (string)$finfo->buffer($imageData);
 674:     $finfo = NULL;
 675: 
 676:     // 必要に応じて画像データを縮小する
 677:     $imageData = $this->reductImage($imageData, $mimeType, $maxWidth, $maxHeight, $flagFixedSize);
 678: 
 679:     // 透明背景を白色で塗りつぶす(投稿したときに黒背景になってしまうため)
 680:     $imageData = $this->convertTransparentToWhite($imageData, $mimeType);
 681: 
 682:     // トークンを取得する.
 683:     $this->getValidToken();
 684: 
 685:     // リクエストURL
 686:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.uploadBlob';
 687:     $this->webapi = $requestURL;
 688:     // cURLを使ったリクエスト
 689:     $ch = curl_init($requestURL);
 690:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 691:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 692:         'Authorization: Bearer ' . $this->accessJwt,
 693:         'Accept: application/json',
 694:         'Content-Type: ' . $mimeType,
 695:     ]);
 696:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 697:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
 698:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 699:     curl_setopt($ch, CURLOPT_POST, TRUE);
 700:     curl_setopt($ch, CURLOPT_POSTFIELDS, $imageData);
 701: 
 702:     // レスポンス処理
 703:     $response = curl_exec($ch);
 704:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 705:     if ($httpStatusCode !200) {
 706:         $this->seterror('画像をアップロードできません');
 707:         return FALSE;
 708:     }
 709:     curl_close($ch);
 710:     $items = json_decode($response, TRUE);
 711: 
 712:     // エラーチェックとリターン
 713:     if (isset($items['blob'])) {
 714:         return $items['blob'];
 715:     } else if (isset($items['error'])) {
 716:         $this->seterror($items['message']);
 717:         return FALSE;
 718:     } else {
 719:         $this->seterror('画像をアップロードできません');
 720:         return FALSE;
 721:     }
 722: }

画像をアップロードするメソッド 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

 518: /**
 519:  * 画像データを指定幅・高さに収まるように拡大・縮小する
 520:  * @param   string $imageData 画像データ(画像ファイルから読み込んだバイナリ)
 521:  * @param   string $mimeType  縮小後の画像のMIMEタイプ
 522:  * @param   int    $maxWidth  画像データの最大幅(ピクセル)
 523:  * @param   int    $maxHeight 画像データの最大高(ピクセル)
 524:  * @param   bool   $flagFixedSize TRUE:画像を最大幅・高に加工する(背景白色)
 525:  * @return  string 縮小後の画像データ/FALSE 対応していない画像フォーマット
 526: */
 527: function reductImage($imageData, $mimeType='image/jpeg',
 528:     $maxWidth=self::MAX_IMAGE_WIDTH, $maxHeight=self::MAX_IMAGE_HEIGHT,
 529:     $flagFixedSize=FALSE) {
 530: 
 531:     // 拡大・縮小倍率
 532:     $scale = 1.0;
 533: 
 534:     // 画像フォーマットを取得する
 535:     if (preg_match('/\/([a-z]+)/i', $mimeType, $arr> 0) {
 536:         $imageFormat = $arr[1];
 537:     } else {
 538:         $imageFormat = 'jpeg';
 539:     }
 540: 
 541:     // GD画像データに変換する
 542:     $imageSource = imagecreatefromstring($imageData);
 543:     if (! $imageSource) {
 544:         $this->seterror('画像データを縮小できません');
 545:         return FALSE;
 546:     }
 547: 
 548:     // 元の画像の幅・高さを取得
 549:     $originalWidth  = imagesx($imageSource);
 550:     $originalHeight = imagesy($imageSource);
 551: 
 552:     // リサイズ倍率を計算する
 553:     $scaleW = $maxWidth / $originalWidth;
 554:     $scaleH = $maxHeight / $originalHeight;
 555:     $scale = min($scaleW, $scaleH);
 556:     $newWidth  = (int)($originalWidth  * $scale);
 557:     $newHeight = (int)($originalHeight * $scale);
 558: 
 559:     // リサイズ後の画像オブジェクトを用意
 560:     $imageResize = imagecreatetruecolor($newWidth, $newHeight);
 561:     // 透明色の処理(PNGやGIFの場合)
 562:     imagealphablending($imageResize, FALSE);
 563:     imagesavealpha($imageResize, TRUE);
 564:     $transparent = imagecolorallocatealpha($imageResize, 255, 255, 255, 127);
 565:     imagefilledrectangle($imageResize, 0, 0, $newWidth, $newHeight, $transparent);
 566:     // 画像リサイズ実行
 567:     imagecopyresampled($imageResize, $imageSource, 0, 0, 0, 0, $newWidth, $newHeight, $originalWidth, $originalHeight);
 568: 
 569:     // 画像を最大幅・高に加工する(背景白色)
 570:     if ($flagFixedSize) {
 571:         // 白色背景を作成する
 572:         $imageBackground = imagecreatetruecolor($maxWidth, $maxHeight);
 573:         $white = imagecolorallocate($imageBackground, 255, 255, 255);
 574:         imagefill($imageBackground, 0, 0, $white);
 575:         // 画像を背景の中央に配置するための座標計算
 576:         $dstX = (int)(($maxWidth -  $newWidth) / 2);
 577:         $dstY = (int)(($maxHeight - $newHeight) / 2);
 578:         // $imageResize を白色背景にコピー
 579:         imagecopy($imageBackground, $imageResize, $dstX, $dstY, 0, 0, $newWidth, $newHeight);
 580:         // リサイズした画像をバイナリ形式に変換する
 581:         $imageData = $this->image2binary($imageBackground, $imageFormat);
 582:         // メモリ解放
 583:         imagedestroy($imageBackground);
 584: 
 585:     // 画像縮小のみの場合
 586:     } else {
 587:         // リサイズした画像をバイナリ形式に変換する
 588:         $imageData = $this->image2binary($imageResize, $imageFormat);
 589:     }
 590:     // メモリ解放
 591:     imagedestroy($imageResize);
 592: 
 593:     return $imageData;
 594: }

ユーザー定義メソッド 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

 478: /**
 479:  * GD画像データをバイナリデータに変換する.
 480:  * @param   string $image GD画像データ
 481:  * @param   string $imageFormat 変換する画像フォーマット(jpeg, png, gif)
 482:  * @return  string バイナリデータ/FALSE 対応していない画像フォーマット
 483: */
 484: function image2binary($image, $imageFormat='jpeg') {
 485:     // 出力バッファリングを開始
 486:     ob_start();
 487: 
 488:     // 画像フォーマットに応じて変換関数を選択
 489:     switch ($imageFormat) {
 490:         case 'jpeg':
 491:             imagejpeg($image, NULL, 75);
 492:             break;
 493:         case 'png':
 494:             imagepng($image, NULL, 5);
 495:             break;
 496:         case 'gif':
 497:             imagegif($image);
 498:             break;
 499:         case 'webp':
 500:             imagewebp($image, NULL, 75);
 501:             break;
 502:         case 'bmp':
 503:             imagebmp($image);
 504:             break;
 505:         case 'avif':
 506:             imageavif($image, NULL, 50);
 507:             break;
 508:         default:
 509:             return FALSE;
 510:     }
 511: 
 512:     // バッファ内容を取得する
 513:     $binaryData = ob_get_clean();
 514: 
 515:     return $binaryData;
 516: }

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

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

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

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

pahooBlueskyAPI.php

 596: /**
 597:  * 透明背景を白色で塗りつぶす
 598:  * Blueskyに投稿したときに黒背景になってしまうため
 599:  * @param   string $imageData 画像データ(画像ファイルから読み込んだバイナリ)
 600:  * @return  string 変換後の画像データ/FALSE 対応していない画像フォーマット
 601: */
 602: function convertTransparentToWhite($imageData, $mimeType='image/png') {
 603:     // 画像フォーマットを取得する
 604:     if (preg_match('/\/([a-z]+)/i', $mimeType, $arr> 0) {
 605:         $imageFormat = $arr[1];
 606:     } else {
 607:         $imageFormat = 'png';
 608:     }
 609: 
 610:     // アルファチャネルをサポートしていない画像フォーマットはそのままリターン
 611:     if (! preg_match('/png|webp|tiff|psd|exr|ico/i', $imageFormat)) {
 612:         return $imageData;
 613:     }
 614: 
 615:     // GD画像データに変換する
 616:     $imageSource = imagecreatefromstring($imageData);
 617:     if (! $imageSource) {
 618:         $this->seterror('画像データを読み込めません');
 619:         return FALSE;
 620:     }
 621: 
 622:     // 画像の幅・高さを取得
 623:     $width  = imagesx($imageSource);
 624:     $height = imagesy($imageSource);
 625: 
 626:     // 背景画像を作成する
 627:     $imageResult = imagecreatetruecolor($width, $height);
 628: 
 629:     // 白色を作成して塗りつぶす
 630:     $white = imagecolorallocate($imageResult, 255, 255, 255);
 631:     imagefill($imageResult, 0, 0, $white);
 632: 
 633:     // 元の画像を新しい画像にコピーする
 634:     imagecopy($imageResult, $imageSource, 0, 0, 0, 0, $width, $height);
 635: 
 636:     // アルファブレンドを無効化して保存する
 637:     imagesavealpha($imageResult, FALSE);
 638: 
 639:     // リサイズした画像をバイナリ形式に変換する
 640:     $imageData = $this->image2binary($imageResult, $imageFormat);
 641:     // メモリ解放
 642:     imagedestroy($imageResult);
 643: 
 644:     return $imageData;
 645: }

プログラムの流れは、上述の 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

 724: /**
 725:  * OGP情報を取得する.
 726:  * @param   string $url 対象コンテンツ
 727:  * @return  array OGP情報(embed形式)/NULL:OGP情報はない
 728: */
 729: function getOGPInformation($url) {
 730:     $contents = '';
 731:     // リダイレクト先からも読み込めるようにする
 732:     $options = [
 733:         'http' => [
 734:             'method' => 'GET',
 735:             'follow_location' => 1,     // リダイレクトを追跡する
 736:             'max_redirects' => 10,      // 最大リダイレクト回数
 737:             'header' => "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36\r\n"
 738:         ]
 739:     ];
 740:     $context = stream_context_create($options);
 741:     if (($infp = @fopen($url, 'r', FALSE, $context)) == FALSE)  return NULL;
 742:     while (! feof($infp)) {
 743:         $contents .fread($infp, 5000);
 744:     }
 745:     fclose($infp);
 746: 
 747:     // 文字化け対策:読み込んだコンテンツをUTF-8に変換
 748:     $contents = mb_convert_encoding($contents, self::INTERNAL_ENCODING, 'auto');
 749: 
 750:     // コンテンツからOGP情報を抽出する
 751:     $pcr = new pahooScraping($contents);
 752:     $oggImage = $pcr->getValueFistrXPath('//meta[@property="og:image"]', 'content');
 753:     preg_match('/^[^\?]+/i', $oggImage, $arr);
 754:     $oggImage = $arr[0];
 755:     $oggDescription = $pcr->getValueFistrXPath('//meta[@property="og:description"]', 'content');
 756:     $oggTitle = $pcr->getValueFistrXPath('//meta[@property="og:title"]', 'content');
 757:     $pcr = NULL;
 758: 
 759:     // OGP情報がない
 760:     if (($oggImage == ''|| ($oggDescription == ''|| ($oggTitle == '')) {
 761:         return NULL;
 762:     }
 763: 
 764:     // embedに成形する
 765:     $mimeType = '';
 766:     $fileSize = 0;
 767:     $image = $this->uploadBlob($oggImage, self::MAX_IMAGE_WIDTH, self::MAX_IMAGE_HEIGHT, TRUE);
 768:     if ($image == FALSE)    return NULL;
 769:     $embed = [
 770:         'embed' => [
 771:             '$type'  => 'app.bsky.embed.external',
 772:             'external' => [
 773:                 'uri'           => $url,
 774:                 'thumb'         => $image,
 775:                 'title'         => $oggTitle,
 776:                 'description'   => $oggDescription,
 777:             ]
 778:         ]
 779:     ];
 780:     return $embed;
 781: }

ユーザー定義メソッド 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

 783: /**
 784:  * ユーザー・プロファイル情報を取得する
 785:  * @param   string $name ユーザーのアカウント名
 786:  * @return  array ユーザー・プロファイル情報 / FALSE:取得失敗
 787: */
 788: function getProfile($name) {
 789:     // トークンを取得する.
 790:     $this->getValidToken();
 791: 
 792:     // リクエストURL (public)
 793:     $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile';
 794:     // リクエストURL (認証必要)
 795: //  $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.actor.getProfile';
 796:     $this->webapi = $requestURL;
 797: 
 798:     // cURLを使ったリクエスト
 799:     $ch = curl_init();
 800:     curl_setopt($ch, CURLOPT_URL, $requestURL . '?actor=' . $name); 
 801:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 802:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 803:         'Content-Type: application/json',
 804:         'Authorization: Bearer ' . $this->accessJwt,
 805:     ]);
 806:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 807:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
 808:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 809: 
 810:     // レスポンス処理
 811:     $response = curl_exec($ch);
 812:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 813:     if ($httpStatusCode !200) {
 814:         $this->seterror('ユーザー・プロファイル情報を取得できません(httpステータス異常)');
 815:         return FALSE;
 816:     }
 817:     curl_close($ch);
 818:     $items = json_decode($response, TRUE);
 819: 
 820:     return $items;
 821: }

pahooBlueskyAPI.php

 823: /**
 824:  * ユーザーのDIDを取得する
 825:  * @param   string $name ユーザーのアカウント名
 826:  * @return  string ユーザーのDID / FALSE:取得失敗
 827: */
 828: function getDID($name) {
 829:     $userProfiles = $this->getProfile($name);
 830: 
 831:     if ($userProfiles == FALSE) {
 832:         return FALSE;
 833:     } else if (! isset($userProfiles['did'])) {
 834:         $this->seterror('ユーザーのDIDを取得できません)');
 835:         return FALSE;
 836:     } else {
 837:         return $userProfiles['did'];
 838:     }
 839: }

解説:ルート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

 841: /**
 842:  * メッセージURLからスレッド情報を取得する
 843:  * @param   string $url メッセージURL
 844:  * @return  array スレッド情報 / FALSE:取得失敗
 845: */
 846: function getPostThread($url) {
 847:     // ユーザー名、投稿IDを取得する
 848:     if (preg_match('/\/profile\/([^\/]+)\/post\/([0-9a-zA-Z]+)/ui', $url, $arr) == 0) {
 849:         $this->seterror($url . 'は投稿URLではありません');
 850:         return FALSE;
 851:     }
 852:     if (count($arr< 3) {
 853:         $this->seterror($url . '投稿URLではありません');
 854:         return FALSE;
 855:     }
 856:     $userName = $arr[1];
 857:     $postID   = $arr[2];
 858: 
 859:     // ユーザーDIDを取得する
 860:     $userDID = $this->getDID($userName);
 861:     if ($userDID == FALSE) {
 862:         $this->seterror($url . 'はユーザーDIDを取得できません');
 863:         return FALSE;
 864:     }
 865: 
 866:     // トークンを取得する.
 867:     $this->getValidToken();
 868: 
 869:     // AT-URIを生成する
 870:     $atURI = 'at://' . $userDID . '/app.bsky.feed.post/' . $postID;
 871: 
 872:     // リクエストURL (public)
 873:     $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread';
 874:     // リクエストURL (認証必要)
 875: //  $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.feed.getPostThread';
 876:     $ch = curl_init($requestURL . '?uri=' . urlencode($atURI));
 877:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 878:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 879:         'Content-Type: application/json',
 880:         'Authorization: Bearer ' . $this->accessJwt,
 881:     ]);
 882:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 883:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
 884:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 885: 
 886:     // レスポンス処理
 887:     $response = curl_exec($ch);
 888:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 889: 
 890:     if ($httpStatusCode !200) {
 891:         $this->seterror('ルートID,親IDを取得できません(httpステータス異常)');
 892:         return FALSE;
 893:     }
 894:     curl_close($ch);
 895:     $items = json_decode($response, TRUE);
 896: 
 897:     return $items;
 898: }

pahooBlueskyAPI.php

 900: /**
 901:  * メッセージURLからルートIDと親IDを取得する
 902:  * @param   string $url メッセージURL
 903:  * @return  array(ルートID, 親の投稿ID) / FALSE:取得失敗
 904: */
 905: function getRootParentID($url) {
 906:     // スレッド情報を取得する
 907:     $items = $this->getPostThread($url);
 908:     if ($items == FALSE)    return FALSE;
 909: 
 910:     // ルートIDを取得する
 911:     // スレッドがあればrootを取得する
 912:     if (isset($items['thread']['post']['record']['reply']['root'])) {
 913:         $rootID = $items['thread']['post']['record']['reply']['root'];
 914:     // スレッドがなければ投稿IDを取得する
 915:     } else if (isset($items['thread']['post']['cid'])) {
 916:         $rootID = array(
 917:             'cid' => $items['thread']['post']['cid'],
 918:             'uri' => $items['thread']['post']['uri']
 919:         );
 920:     } else {
 921:         $this->seterror('ルートIDを取得できません');
 922:         return FALSE;
 923:     }
 924:     // 親IDを取得する(常に投稿ID)
 925:     if (isset($items['thread']['post']['cid'])) {
 926:         $parentID = array(
 927:             'cid' => $items['thread']['post']['cid'],
 928:             'uri' => $items['thread']['post']['uri']
 929:         );
 930:     } else {
 931:         $this->seterror('親IDを取得できません');
 932:         return FALSE;
 933:     }
 934: 
 935:     return array($rootID, $parentID);
 936: }

解説:メッセージ投稿

メッセージや画像を投稿するエンドポイントは 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

 938: /**
 939:  * メッセージを投稿する.
 940:  * リンクが含まれている場合は自動的にハイパーリンクに変換する.
 941:  * 画像へのリンクが含まれている場合は自動的にアップロードする(4個まで).
 942:  * @param   string $message 投稿メッセージ(UTF-8限定)
 943:  * @param   bool $flagCard  FALSE:カード形式で投稿しない(省略時)
 944:  *              TRUE:OOGP情報がある最初のリンクをカード形式で投稿する
 945:  * @param   string $replyURL  NULL:返信しない(省略時)/返信する投稿URL
 946:  * @param   string $quoteURL  NULL:引用しない(省略時)/引用する投稿URL
 947:  * @param   array  $media     NULL:使用しない(省略時)/画像データ配列
 948:  * @return  string メッセージURL/FALSE:失敗
 949: */
 950: function post($message, $flagCard=FALSE, $replyURL=NULL, $quoteURL=NULL, $media=NULL) {
 951:     // エラーメッセージ・クリア
 952:     $this->clearerror();
 953: 
 954:     // 初期化
 955:     $embed  = NULL;
 956:     $images = array();
 957:     $urls   = array();
 958:     $reply  = array();
 959: 
 960:     // 返信の場合
 961:     if ($replyURL !NULL) {
 962:         $res = $this->getRootParentID($replyURL);
 963:         if (! $res) {
 964:             return FALSE;
 965:         }
 966:         $rootID   = $res[0];
 967:         $parentID = $res[1];
 968:         $reply = [
 969:             'reply' => [
 970:                 'root'   => $rootID,
 971:                 'parent' => $parentID,
 972:             ]
 973:         ];
 974:     }
 975: 
 976:     // メッセージ中から画像へのリンクを抽出する
 977:     $message = $this->extractMediaURL($message, $urls);
 978: 
 979:     // 画像投稿を行う
 980:     if ($media !NULL) {
 981:         $cnt = 1;
 982:         foreach ($media as $data) {
 983:             $tmpname = $this->saveTempFile($data);
 984:             $image = $this->uploadBlob($tmpname, self::MAX_IMAGE_WIDTH, self::MAX_IMAGE_HEIGHT, FALSE);
 985:             unlink($tmpname);
 986:             $images = array_merge($images, [['alt' => '', 'image' => $image]]);
 987:             $cnt++;
 988:             if ($cnt > 4)   break;
 989:         }
 990:         $embed = [
 991:             'embed' => [
 992:                 '$type'  => 'app.bsky.embed.images',
 993:                 'images' => $images,
 994:             ]
 995:         ];
 996:     // メッセージ中に画像URL等が含まれている場合
 997:     } else if (($embed == NULL&& (count($urls> 0)) {
 998:         $cnt = 1;
 999:         foreach ($urls as $filename) {
1000:             // 画像アップロード(必要に応じてリサイズ)
1001:             $image = $this->uploadBlob($filename, self::MAX_IMAGE_WIDTH, self::MAX_IMAGE_HEIGHT, FALSE);
1002:             $images = array_merge($images, [['alt' => '', 'image' => $image]]);
1003:             $cnt++;
1004:             if ($cnt > 4)   break;
1005:         }
1006:         $embed = [
1007:             'embed' => [
1008:                 '$type'  => 'app.bsky.embed.images',
1009:                 'images' => $images,
1010:             ]
1011:         ];
1012:     // OGP情報を取得する
1013:     } else if ($flagCard) {
1014:         if (preg_match_all('/https?\:\/\/[^\s]+/', $message, $arr> 0) {
1015:             foreach ($arr[0as $url) {
1016:                 $embed = $this->getOGPInformation($url);
1017:                 if ($embed !NULL) {
1018:                     break;
1019:                 }
1020:             }
1021:         }
1022:     }
1023: 
1024:     // 引用処理
1025:     if ($quoteURL !NULL) {
1026:         $res = $this->getRootParentID($quoteURL);
1027:         if (! $res) {
1028:             return FALSE;
1029:         }
1030:         $parentID = $res[1];
1031:         // 画像やOGP情報がある場合
1032:         if ($embed !NULL) {
1033:             $embed = [
1034:                 'embed' => [
1035:                     '$type' => 'app.bsky.embed.recordWithMedia',
1036:                     'media' => $embed['embed'],
1037:                     'record' => [
1038:                         '$type' => 'app.bsky.embed.record',
1039:                         'record' => $parentID,
1040:                     ],
1041:                 ]
1042:             ];
1043:         } else {
1044:             $embed = [
1045:                 'embed' => [
1046:                     '$type' => 'app.bsky.embed.record',
1047:                     'record' => $parentID,
1048:                 ]
1049:             ];
1050:         }
1051:     }
1052: 
1053:     // URLやハッシュ情報の取得
1054:     $facets = $this->parseRichText($message);
1055: 
1056:     // POSTデータ配列を作成する
1057:     $records = [
1058:         '$type'     => 'app.bsky.feed.post',
1059:         'text'      => $message,
1060:         'createdAt' => (new DateTime())->format('c'),
1061:     ];
1062:     if ($replyURL == NULL) {
1063:         if ($embed == NULL) {
1064:             $records = array_merge($records, $facets);
1065:         } else {
1066:             $records = array_merge($records, $facets, $embed);
1067:         }
1068:     } else {
1069:         if ($embed == NULL) {
1070:             $records = array_merge($records, $facets, $reply);
1071:         } else {
1072:             $records = array_merge($records, $facets, $reply, $embed);
1073:         }
1074:     }
1075: 
1076:     // トークンを取得する.
1077:     $this->getValidToken();
1078: 
1079:     // リクエストURL
1080:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.createRecord';
1081:     $this->webapi = $requestURL;
1082:     // cURLを使ったリクエスト
1083:     $ch = curl_init($requestURL);
1084:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
1085:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
1086:         'Content-Type: application/json',
1087:         'Authorization: Bearer ' . $this->accessJwt,
1088:     ]);
1089:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
1090:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
1091:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
1092:     curl_setopt($ch, CURLOPT_POST, TRUE);
1093:     curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
1094:         'repo'          => $this->BLUESKY_HANDLE,
1095:         'collection'    => 'app.bsky.feed.post',
1096:         'record'        => $records,
1097:     ]));
1098: 
1099:     // レスポンス処理
1100:     $response = curl_exec($ch);
1101:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
1102:     if ($httpStatusCode !200) {
1103:         $this->seterror('投稿できません(httpステータス異常)');
1104:         return FALSE;
1105:     }
1106:     curl_close($ch);
1107:     $items = json_decode($response, TRUE);
1108: 
1109:     // エラーチェックとリターン
1110:     if (isset($items['uri'])) {
1111:         if (preg_match('/\/([0-9a-zA-Z]+)$/ui', $items['uri'], $arr> 0) {
1112:             $url = 'https://bsky.app/profile/' . $this->BLUESKY_HANDLE . '/post/' . $arr[1];
1113:         } else {
1114:             $url = '';
1115:         }
1116:         return $url;
1117:     } else if (isset($items['error'])) {
1118:         $this->seterror($items['message']);
1119:         return FALSE;
1120:     } else {
1121:         $this->seterror('投稿できません(応答不正)');
1122:         return FALSE;
1123:     }
1124: }

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

postBluesky.php

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

postBluesky.php

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

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

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

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

参考サイト

(この項おわり)
header