PHPでBlueskyに投稿する

(1/1)
Blueskyロゴ
PHPで Bluesky にメッセージや画像を投稿するプログラムを作る。ネット上のコンテンツが対応していればカード形式で投稿することもできる。返信や引用も投稿できるようにした。
API操作はクラスファイルに分離し、他のプログラムから利用しやすいようにした。当サイト以外が配布しているプログラムやライブラリは不要だ。
(2024年10月21日)返信,引用ができるよう機能追加
(2024年10月18日)必要に応じて画像の縦方向も縮小するようにした
(2024年10月13日)必要に応じて画像を縮小するようにした

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

PHPでBlueskyに投稿する

目次

サンプル・プログラム

圧縮ファイルの内容
postBluesky.phpサンプル・プログラム本体
pahooBlueskyAPI.phpBluesky APIに関わるクラス pahooBlueskyAPI。
使い方は「PHPでPHPでBlueskyに投稿する」などを参照。include_path が通ったディレクトリに配置すること。
pahooScraping.phpRSS処理に関わるクラス pahooScraping。
スクレイピング処理に関わるクラスの使い方は「PHPでDOMDocumentを使ってスクレイピング」を参照。include_path が通ったディレクトリに配置すること。
pahooInputData.phpデータ入力に関わる関数群。
使い方は「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。
postBluesky.php 更新履歴
バージョン 更新日 内容
1.1.0 2024/10/20 返信,引用ができるよう機能追加
1.0.0 2024/10/01 初版
pahooBlueskyAPI.php 更新履歴
バージョン 更新日 内容
1.3.1 2024/10/20 post() -- 返信,引用の引数仕様変更
1.3.0 2024/10/20 getProfile, getDID, getRootParentID追加
1.2.2 2024/10/20 deleteSession() -- debug
1.2.1 2024/10/18 必要に応じて画像の縦方向も縮小するようにした
1.2.0 2024/10/13 必要に応じて画像を縮小するようにした
pahooScraping.php 更新履歴
バージョン 更新日 内容
1.0.1 2023/09/29 __construct() -- bug-fix
1.0.0 2023/09/18 初版
pahooInputData.php 更新履歴
バージョン 更新日 内容
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() メッセージ修正
1.4.1 2023/09/30 コメントの訂正

準備:pahooBlueskyAPI クラス

pahooBlueskyAPI.php

  15: class pahooBlueskyAPI {
  16:     var $pds;           // PDSドメイン
  17:     var $webapi;        // 直前に呼び出したWebAPI URL
  18:     var $errmsg;        // エラーメッセージ
  19:     var $accessJwt;     // accessJwt
  20: 
  21:     const MAX_MESSAGE_LEN = 300;    // 投稿可能なメッセージ文字数
  22:     const URL_LEN = 30;             // メッセージ中のURL文字数(相当)
  23:     const MAX_IMAGE_WIDTH  = 1200;  // 投稿可能な最大画像幅(ピクセル)
  24:     const MAX_IMAGE_HEIGHT = 900;   // 投稿可能な最大画像高さ(ピクセル)
  25:                                     // これより大きいときは自動縮小する
  26: 
  27:     // Bluesky API アプリパスワード

BlueskyAPI を利用するために、クラスファイル "pahooBlueskyAPI.php" を使用する。組み込み関数  require_once  を使って読めるディレクトリに配置する。ディレクトリは、設定ファイル php.ini に記述されているオプション設定 include_path に設定しておく。

事前にアプリパスワードを取得する必要があり、その方法は「Bluesky API - アプリパスワードの取得方法」を参照されたい。入手したアプリパスワードを変数 $BLUESKY_PASSWORD に、あなたのハンドル名(???.bsky.social など)を変数 $BLUESKY_HANDLE に代入しておくこと。

準備: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 MAX_MESSAGE_LEN = 300;    // 投稿可能なメッセージ文字数
  22:     const URL_LEN = 30;             // メッセージ中のURL文字数(相当)
  23:     const MAX_IMAGE_WIDTH  = 1200;  // 投稿可能な最大画像幅(ピクセル)
  24:     const MAX_IMAGE_HEIGHT = 900;   // 投稿可能な最大画像高さ(ピクセル)
  25:                                     // これより大きいときは自動縮小する
  26: 
  27:     // Bluesky API アプリパスワード
  28:     // https://bsky.app/
  29:     var $BLUESKY_HANDLE   = '***************';      // ハンドル名
  30:     var $BLUESKY_PASSWORD = '***************';      // アプリケーション・パスワード

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

pahooBlueskyAPI.php

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

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

pahooBlueskyAPI.php

 249: /**
 250:  * セッション開始する.
 251:  * @param   なし
 252:  * @return  bool TRUE:成功/FALSE:失敗
 253: */
 254: function createSession() {
 255:     //エラーメッセージ・クリア
 256:     $this->clearerror();
 257: 
 258:     // リクエストURL
 259:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.createSession';
 260:     $this->webapi = $requestURL;
 261:     $ch = curl_init($requestURL);
 262:     // cURLを使ったリクエスト
 263:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 264:     curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
 265:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 266:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキップ
 267:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 268:     curl_setopt($ch, CURLOPT_POST, TRUE);
 269:     curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
 270:         'identifier' => $this->BLUESKY_HANDLE,
 271:         'password'   => $this->BLUESKY_PASSWORD,
 272:     ]));
 273: 
 274:     // レスポンス処理
 275:     $response = curl_exec($ch);
 276:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 277:     if ($httpStatusCode !200) {
 278:         $this->seterror('セッション開始できません');
 279:         return FALSE;
 280:     }
 281:     curl_close($ch);
 282:     $items = json_decode($response, TRUE);
 283: 
 284:     // エラーチェックとリターン
 285:     if (isset($items['accessJwt'])) {
 286:         $this->accessJwt = (string)$items['accessJwt'];
 287:         return TRUE;
 288:     } else if (isset($items['error'])) {
 289:         $this->seterror($items['message']);
 290:         return FALSE;
 291:     } else {
 292:         $this->seterror('セッション開始できません');
 293:         return FALSE;
 294:     }
 295: }

セッション開始メソッド 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

 297: /**
 298:  * セッション終了する.
 299:  * @param   なし
 300:  * @return  bool TRUE:成功/FALSE:失敗
 301: */
 302: function deleteSession() {
 303:     // リクエストURL
 304:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.deleteSession';
 305:     $this->webapi = $requestURL;
 306:     $ch = curl_init($requestURL);
 307:     // cURLを使ったリクエスト
 308:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 309:     curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $this->accessJwt]);
 310:     curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
 311:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 312:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
 313:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 314: 
 315:     // レスポンス処理
 316:     $response = curl_exec($ch);
 317:     if (curl_errno($ch)) {
 318:         $this->seterror('セッション終了できません' . curl_error($ch));
 319:         return FALSE;
 320:     }
 321:     curl_close($ch);
 322:     $this->accessJwt = '';
 323:     return TRUE;
 324: }

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

解説:投稿用URL情報を取得

pahooBlueskyAPI.php

 177: /**
 178:  * 投稿用URL情報を取得する.
 179:  * @param   string $text テキスト
 180:  * @return  Array 投稿用URL情報
 181: */
 182: function parseURLs($text) {
 183:     $urls = $this->getURLs($text);
 184:     $links = array();
 185:     if (! empty($urls)) {
 186:         foreach ($urls as $url) {
 187:             $a = [
 188:                 'index' => [
 189:                     'byteStart' => $url['start'],
 190:                     'byteEnd'   => $url['end'],
 191:                 ],
 192:                 'features' => [
 193:                     [
 194:                         '$type' => 'app.bsky.richtext.facet#link',
 195:                         'uri'   => $url['url'], 
 196:                     ],
 197:                 ],
 198:             ];
 199:             $links[] = $a;
 200:         }
 201:         $links = [
 202:             'facets' => $links,
 203:         ];
 204:     }
 205:     return $links;
 206: }

投稿する際、メッセージ文字列中に含まれるURLは、そのままではハイパーリンクに変換しない。ハイパーリンクのための情報を用意し、後述するエンドポイントに送信する必要がある。
メッセージ文字列中からURLを取りだし、必要な情報を用意するメソッドが parseURLs である。複数のURLにも対応している。

メッセージ中のURLの開始位置と終了位置、リンク先URL等を配列に格納して返す。後述する通り、エンドポイントに送信する際にJSONデータに追加する。

解説:メディアをアップロード

メッセージ中に画像などのメディアデータが含まれる場合、事前にメディアファイルを 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

 415: /**
 416:  * メディアをアップロードする.
 417:  * 画像ファイルなどを投稿するときに事前に呼び出し,blobデータを投稿する.
 418:  * @param   string $message  投稿メッセージ(UTF-8限定)
 419:  * @param   int    $maxWidth アップロードする画像の最大幅(ピクセル)
 420:  * @return  string Blusky PDSのURL/FALSE:アップロード失敗
 421: */
 422: function uploadBlob($filename, $maxWidth=self::MAX_IMAGE_WIDTH) {
 423:     $mimeType = '';
 424:     $fileSize = 0;
 425: 
 426:     // エラーメッセージ・クリア
 427:     $this->clearerror();
 428: 
 429:     // メディアを読み込む
 430:     $imageData = file_get_contents($filename);
 431:     if ($imageData === FALSE) {
 432:         $this->seterror($filename . ' の読み込みに失敗しました');
 433:         return FALSE;
 434:     }
 435:     // MIMEタイプを判定する
 436:     $finfo = new finfo(FILEINFO_MIME_TYPE);
 437:     $mimeType = (string)$finfo->buffer($imageData);
 438:     $finfo = NULL;
 439: 
 440:     // 必要に応じて画像データを縮小する
 441:     $imageData = $this->reductImage($imageData, $mimeType, $maxWidth);
 442: 
 443:     // リクエストURL
 444:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.uploadBlob';
 445:     $this->webapi = $requestURL;
 446:     // cURLを使ったリクエスト
 447:     $ch = curl_init($requestURL);
 448:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 449:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 450:         'Authorization: Bearer ' . $this->accessJwt,
 451:         'Accept: application/json',
 452:         'Content-Type: ' . $mimeType,
 453:     ]);
 454:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 455:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキップ
 456:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 457:     curl_setopt($ch, CURLOPT_POST, TRUE);
 458:     curl_setopt($ch, CURLOPT_BINARYTRANSFER, TRUE);
 459:     curl_setopt($ch, CURLOPT_POSTFIELDS, $imageData);
 460: 
 461:     // レスポンス処理
 462:     $response = curl_exec($ch);
 463:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 464:     if ($httpStatusCode !200) {
 465:         $this->seterror('メディアをアップロードできません');
 466:         return FALSE;
 467:     }
 468:     curl_close($ch);
 469:     $items = json_decode($response, TRUE);
 470: 
 471:     // エラーチェックとリターン
 472:     if (isset($items['blob'])) {
 473:         return $items['blob'];
 474:     } else if (isset($items['error'])) {
 475:         $this->seterror($items['message']);
 476:         return FALSE;
 477:     } else {
 478:         $this->seterror('メディアをアップロードできません');
 479:         return FALSE;
 480:     }
 481: }

メディアをアップロードするメソッド 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 ピクセルを超えたら、縮小するようなメソッドを追加することにした。

pahooBlueskyAPI.php

 366: /**
 367:  * 指定幅より大きい場合,指定幅に収まるよう画像データを縮小する.
 368:  * @param   string $imageData 画像データ(画像ファイルから読み込んだバイナリ)
 369:  * @param   string $mimeType  縮小後の画像のMIMEタイプ
 370:  * @param   int    $maxWidth  画像データの最大幅(ピクセル)
 371:  * @param   int    $maxHeight 画像データの最大高さ(ピクセル)
 372:  * @return  string 縮小後の画像データ/FALSE 対応していない画像フォーマット
 373: */
 374: function reductImage($imageData, $mimeType='image/jpeg', $maxWidth=self::MAX_IMAGE_WIDTH, $maxHeight=self::MAX_IMAGE_HEIGHT) {
 375:     // GD画像データに変換する
 376:     $imageSource = imagecreatefromstring($imageData);
 377:     if (! $imageSource) {
 378:         $this->seterror('画像データを縮小できません');
 379:         return FALSE;
 380:     }
 381: 
 382:     // 元の画像の幅・高さを取得
 383:     $width  = imagesx($imageSource);
 384:     $height = imagesy($imageSource);
 385:     if (($width > $maxWidth|| ($height > $maxHeight)) {
 386:         if ($width > $maxWidth) {
 387:             $newWidth = $maxWidth;
 388:             $newHeight = (int)($height * $maxWidth / $width);
 389:         } else if ($height > $maxHeight) {
 390:             $newWidth = $maxWidth;
 391:             $newHeight = (int)($height * $maxWidth / $width);
 392:         }
 393:         $imageResize = imagecreatetruecolor($newWidth, $newHeight);
 394:         // 透明色の処理(PNGやGIFの場合)
 395:         imagealphablending($imageResize, FALSE);
 396:         imagesavealpha($imageResize, TRUE);
 397:         $transparent = imagecolorallocatealpha($imageResize, 255, 255, 255, 127);
 398:         imagefilledrectangle($imageResize, 0, 0, $newWidth, $newHeight, $transparent);
 399:         // 画像をリサイズする
 400:         imagecopyresampled($imageResize, $imageSource, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
 401:         // リサイズした画像をバイナリ形式で取得
 402:         if (preg_match('/\/([a-z]+)/i', $mimeType, $arr> 0) {
 403:             $imageFormat = $arr[1];
 404:         } else {
 405:             $imageFormat = 'jpeg';
 406:         }
 407:         $imageData = $this->image2binary($imageResize, $imageFormat);
 408:         // メモリ解放
 409:         imagedestroy($imageResize);
 410:     }
 411: 
 412:     return $imageData;
 413: }

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

縮小するには GD関数群を利用する。
まず、 imagecreatefromstring 関数を使い、引数で渡された画像データ(バイナリデータ)をGD画像データに変換する。次に、 imagesx 関数を使い、画像幅が引数で指定した最大画像幅を超えたら、続く縮小処理に入る。超えていなければ、引数で渡された画像データ(バイナリデータ)をそのまま返す。

縮小処理では、まず、縮小後の幅と高さを計算し、 imagecreatetruecolor 関数を使い、新しいGD画像データを用意する。PNGやGIFの場合の透明職の処理を行う。
 imagefilledrectangle 関数を使い、画像を縮小する。

最大画像高についても同様に縮小処理を行う。
縮小したGD画像データを画像データ(バイナリデータ)に変換するには、後述する image2binaryメソッドを用いる。

pahooBlueskyAPI.php

 326: /**
 327:  * GD画像データをバイナリデータに変換する.
 328:  * @param   string $image GD画像データ
 329:  * @param   string $imageFormat 変換する画像フォーマット(jpeg, png, gif)
 330:  * @return  string バイナリデータ/FALSE 対応していない画像フォーマット
 331: */
 332: function image2binary($image, $imageFormat='jpeg') {
 333:     // 出力バッファリングを開始
 334:     ob_start();
 335: 
 336:     // 画像フォーマットに応じて変換関数を選択
 337:     switch ($imageFormat) {
 338:         case 'jpeg':
 339:             imagejpeg($image, NULL, 75);
 340:             break;
 341:         case 'png':
 342:             imagepng($image, NULL, 5);
 343:             break;
 344:         case 'gif':
 345:             imagegif($image);
 346:             break;
 347:         case 'webp':
 348:             imagewebp($image, NULL, 75);
 349:             break;
 350:         case 'bmp':
 351:             imagebmp($image);
 352:             break;
 353:         case 'avif':
 354:             imageavif($image, NULL, 50);
 355:             break;
 356:         default:
 357:             return FALSE;
 358:     }
 359: 
 360:     // バッファ内容を取得する
 361:     $binaryData = ob_get_clean();
 362: 
 363:     return $binaryData;
 364: }

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

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

解説: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

 483: /**
 484:  * OGP情報を取得する.
 485:  * @param   string $url 対象コンテンツ
 486:  * @return  array OGP情報(embed形式)/NULL:OGP情報はない
 487: */
 488: function getOGPInformation($url) {
 489:     $contents = '';
 490:     if (($infp = fopen($url, 'r')) == FALSE)    return NULL;
 491:     while (! feof($infp)) {
 492:         $contents .fread($infp, 5000);
 493:     }
 494:     fclose($infp);
 495: 
 496:     // コンテンツからOGP情報を抽出する
 497:     $pcr = new pahooScraping($contents);
 498:     $oggImage = $pcr->getValueFistrXPath('//meta[@property="og:image"]', 'content');
 499:     $oggDescription = $pcr->getValueFistrXPath('//meta[@property="og:description"]', 'content');
 500:     $oggTitle = $pcr->getValueFistrXPath('//meta[@property="og:title"]', 'content');
 501:     $pcr = NULL;
 502: 
 503:     // OGP情報がない
 504:     if (($oggImage == ''|| ($oggDescription == ''|| ($oggTitle == '')) {
 505:         return NULL;
 506:     }
 507: 
 508:     // embedに成形する
 509:     $mimeType = '';
 510:     $fileSize = 0;
 511:     $image = $this->uploadBlob($oggImage, self::MAX_IMAGE_WIDTH);
 512:     if ($image == FALSE)    return NULL;
 513:     $embed = [
 514:         'embed' => [
 515:             '$type'  => 'app.bsky.embed.external',
 516:             'external' => [
 517:                 'uri'           => $url,
 518:                 'thumb'         => $image,
 519:                 'title'         => $oggTitle,
 520:                 'description'   => $oggDescription,
 521:             ]
 522:         ]
 523:     ];
 524:     return $embed;
 525: }

ユーザー定義メソッド getOGPInformation は、コンテンツURLを引数に、上述の OGP情報 をスクレイピングによって取り出す。ユーザー定義クラス pahooScraping の使い方については、「PHPでDOMDocumentを使ってスクレイピング」などをご覧いただきたい。

解説:ユーザーの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

 527: /**
 528:  * ユーザー・プロファイル情報を取得する
 529:  * @param   string $name ユーザーのアカウント名
 530:  * @return  array ユーザー・プロファイル情報 / FALSE:取得失敗
 531: */
 532: function getProfile($name) {
 533:     // リクエストURL (public)
 534:     $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile';
 535:     // リクエストURL (認証必要)
 536: //  $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.actor.getProfile';
 537:     $this->webapi = $requestURL;
 538: 
 539:     // cURLを使ったリクエスト
 540:     $ch = curl_init();
 541:     curl_setopt($ch, CURLOPT_URL, $requestURL . '?actor=' . $name); 
 542:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 543:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 544:         'Content-Type: application/json',
 545:         'Authorization: Bearer ' . $this->accessJwt,
 546:     ]);
 547:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 548:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキッ
 549:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 550: 
 551:     // レスポンス処理
 552:     $response = curl_exec($ch);
 553:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 554:     if ($httpStatusCode !200) {
 555:         $this->seterror('ユーザー・プロファイル情報を取得できません(httpステータス異常)');
 556:         return FALSE;
 557:     }
 558:     curl_close($ch);
 559:     $items = json_decode($response, TRUE);
 560: 
 561:     return $items;
 562: }

pahooBlueskyAPI.php

 564: /**
 565:  * ユーザーのDIDを取得する
 566:  * @param   string $name ユーザーのアカウント名
 567:  * @return  string ユーザーのDID / FALSE:取得失敗
 568: */
 569: function getDID($name) {
 570:     $userProfiles = $this->getProfile($name);
 571: 
 572:     if ($userProfiles == FALSE) {
 573:         return FALSE;
 574:     } else if (! isset($userProfiles['did'])) {
 575:         $this->seterror('ユーザーのDIDを取得できません)');
 576:         return FALSE;
 577:     } else {
 578:         return $userProfiles['did'];
 579:     }
 580: }

解説:ルート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形式) replies $type "app.bsky.feed.defs#threadViewPost" post uri メッセージURI cid メッセージCID author did DID handle ユーザー名 displayName 表示名 avatar アバター画像URL labes createdAt 登録日時 record $type "app.bsky.feed.post" text メッセージ(UTF-8) createdAt 投稿日時(ISO 8601形式) reply parent uri 親のURI cid 親のCID root uri ルートのURI cid ルートのCID
スレッドになっている場合は、上図のようなレスポンスが返る。親ID は thread->post->uri, thread->post->cid の2つ組みであることに変わりないが、ルートID はスレッドのルート、すなわち thread->replies->0->post->record->reply->root->uri, thread->replies->0->post->record->reply->root->cid の2つ組みとなる。

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

pahooBlueskyAPI.php

 583: /**
 584:  * メッセージURLからスレッド情報を取得する
 585:  * @param   string $url メッセージURL
 586:  * @return  array スレッド情報情報 / FALSE:取得失敗
 587: */
 588: function getPostThread($url) {
 589:     // ユーザー名、投稿IDを取得する
 590:     if (preg_match('/\/profile\/([^\/]+)\/post\/([0-9a-zA-Z]+)/ui', $url, $arr) == 0) {
 591:         $this->seterror($url . 'は投稿URLではありません');
 592:         return FALSE;
 593:     }
 594:     if (count($arr< 3) {
 595:         $this->seterror($url . '投稿URLではありません');
 596:         return FALSE;
 597:     }
 598:     $userName = $arr[1];
 599:     $postID   = $arr[2];
 600: 
 601:     // ユーザーDIDを取得する
 602:     $userDID = $this->getDID($userName);
 603:     if ($userDID == FALSE) {
 604:         return FALSE;
 605:     }
 606: 
 607:     // AT-URIを生成する
 608:     $atURI = 'at://' . $userDID . '/app.bsky.feed.post/' . $postID;
 609: 
 610:     // リクエストURL (public)
 611:     $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread';
 612:     // リクエストURL (認証必要)
 613: //  $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.feed.getPostThread';
 614:     $ch = curl_init($requestURL . '?uri=' . urlencode($atURI));
 615:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 616:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 617:         'Content-Type: application/json',
 618:         'Authorization: Bearer ' . $this->accessJwt,
 619:     ]);
 620:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 621:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキッ
 622:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 623: 
 624:     // レスポンス処理
 625:     $response = curl_exec($ch);
 626:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 627: 
 628:     if ($httpStatusCode !200) {
 629:         $this->seterror('ルートID,親IDを取得できません(httpステータス異常)');
 630:         return FALSE;
 631:     }
 632:     curl_close($ch);
 633:     $items = json_decode($response, TRUE);
 634: 
 635:     return $items;
 636: }

pahooBlueskyAPI.php

 638: /**
 639:  * メッセージURLからルートIDと親IDを取得する
 640:  * @param   string $url メッセージURL
 641:  * @return  array(ルートID, 親の投稿ID) / FALSE:取得失敗
 642: */
 643: function getRootParentID($url) {
 644:     // スレッド情報を取得する
 645:     $items = $this->getPostThread($url);
 646:     if ($items == FALSE)    return FALSE;
 647: //  var_dump($items);
 648: 
 649:     // ルートIDを取得する
 650:     // スレッドがあればrootを取得する
 651:     if (isset($items['thread']['replies'][0]['post']['record']['reply']['root'])) {
 652:         $rootID = $items['thread']['replies'][0]['post']['record']['reply']['root'];
 653:     // スレッドがなければ投稿IDを取得する
 654:     } else if (isset($items['thread']['post']['cid'])) {
 655:         $rootID = array(
 656:             'cid' => $items['thread']['post']['cid'],
 657:             'uri' => $items['thread']['post']['uri']
 658:         );
 659:     } else {
 660:         $this->seterror('ルートIDを取得できません');
 661:         return FALSE;
 662:     }
 663:     // 親IDを取得する(常に投稿ID)
 664:     if (isset($items['thread']['post']['cid'])) {
 665:         $parentID = array(
 666:             'cid' => $items['thread']['post']['cid'],
 667:             'uri' => $items['thread']['post']['uri']
 668:         );
 669:     } else {
 670:         $this->seterror('親IDを取得できません');
 671:         return FALSE;
 672:     }
 673: 
 674:     return array($rootID, $parentID);
 675: }

解説:メッセージ投稿

メッセージや画像を投稿するエンドポイントは 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
更に画像などのメディアデータを含む場合は、次の形式でリクエスト・データを渡す。
リクエスト・データ(メディアデータ有)(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 embed $type "app.bsky.embed.images" images alt '' image メディアをアップしたPDSのURL alt '' image メディアをアップしたPDSの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 embed $type "app.bsky.embed.external" external uri コンテンツURL thumb メディアをアップしたPDSのUR title コンテンツのタイトル description コンテンツの概要
返信の場合は、次のフォーマットのリクエスト・データを渡す。上図にある、リンクやメディアデータ、カード形式のタグ情報を追加することもできる。
リクエスト・データ(返信投稿) (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
投稿の流れをフロー図に示し、プログラムを掲載する。
pahooBlueskyAPI::post

pahooBlueskyAPI.php

 677: /**
 678:  * メッセージを投稿する.
 679:  * リンクが含まれている場合は自動的にハイパーリンクに変換する.
 680:  * メディアへのリンクが含まれている場合は自動的にアップロードする(4個まで).
 681:  * @param   string $message 投稿メッセージ(UTF-8限定)
 682:  * @param   bool $flagCard  FALSE:カード形式で投稿しない(省略時)
 683:  *              TRUE:OOGP情報がある最初のリンクをカード形式で投稿する
 684:  * @param   string $replyURL  NULL:返信しない(省略時)/返信する投稿URL
 685:  * @param   string $quoteURL  NULL:引用しない(省略時)/引用する投稿URL
 686:  * @param   array  $media     NULL:使用しない(省略時)/メディアデータ配列
 687:  * @return  string メッセージURL/FALSE:失敗
 688: */
 689: function post($message, $flagCard=FALSE, $replyURL=NULL, $quoteURL=NULL, $media=NULL) {
 690:     // エラーメッセージ・クリア
 691:     $this->clearerror();
 692: 
 693:     // 初期化
 694:     $embed  = NULL;
 695:     $images = array();
 696:     $urls   = array();
 697:     $reply  = array();
 698: 
 699:     // 返信の場合
 700:     if ($replyURL !NULL) {
 701:         $res = $this->getRootParentID($replyURL);
 702:         if (! $res) {
 703:             return FALSE;
 704:         }
 705:         $rootID   = $res[0];
 706:         $parentID = $res[1];
 707:         $reply = [
 708:             'reply' => [
 709:                 'root'   => $rootID,
 710:                 'parent' => $parentID,
 711:             ]
 712:         ];
 713:     }
 714: 
 715:     // 引用処理
 716:     if ($quoteURL !NULL) {
 717:         $res = $this->getRootParentID($quoteURL);
 718:         if (! $res) {
 719:             return FALSE;
 720:         }
 721:         $parentID = $res[1];
 722:         $embed = [
 723:             'embed' => [
 724:                 '$type' => 'app.bsky.embed.record',
 725:                 'record' => $parentID,
 726:             ]
 727:         ];
 728: 
 729:     // 引用以外の処理
 730:     } else {
 731:         // メディア・ファイルの処理
 732:         // メッセージ中からメディアへのリンクを抽出する
 733:         $message = $this->extractMediaURL($message, $urls);
 734: 
 735:         // OGP情報を取得する
 736:         if ($flagCard) {
 737:             if (preg_match_all('/https?\:\/\/[^\s]+/', $message, $arr> 0) {
 738:                 foreach ($arr[0as $url) {
 739:                     $embed = $this->getOGPInformation($url);
 740:                     if ($embed !NULL) {
 741:                         break;
 742:                     }
 743:                 }
 744:             }
 745:         }
 746: 
 747:         // 画像投稿を行う(OGP情報がない場合を含む)
 748:         if ($media !NULL) {
 749:             $cnt = 1;
 750:             foreach ($media as $data) {
 751:                 $tmpname = $this->saveTempFile($data);
 752:                 $image = $this->uploadBlob($tmpname, self::MAX_IMAGE_WIDTH);
 753:                 unlink($tmpname);
 754:                 $images = array_merge($images, [['alt' => '', 'image' => $image]]);
 755:                 $cnt++;
 756:                 if ($cnt > 4)   break;
 757:             }
 758:             $embed = [
 759:                 'embed' => [
 760:                     '$type'  => 'app.bsky.embed.images',
 761:                     'images' => $images,
 762:                 ]
 763:             ];
 764:         } else if (($embed == NULL&& (count($urls> 0)) {
 765:             $cnt = 1;
 766:             foreach ($urls as $filename) {
 767:                 $image = $this->uploadBlob($filename, self::MAX_IMAGE_WIDTH);
 768:                 $images = array_merge($images, [['alt' => '', 'image' => $image]]);
 769:                 $cnt++;
 770:                 if ($cnt > 4)   break;
 771:             }
 772:             $embed = [
 773:                 'embed' => [
 774:                     '$type'  => 'app.bsky.embed.images',
 775:                     'images' => $images,
 776:                 ]
 777:             ];
 778:         }
 779:     }
 780: 
 781:     // リンク情報の取得
 782:     $links = $this->parseURLs($message);
 783: 
 784:     // POSTデータ配列を作成する
 785:     $records = [
 786:         '$type'     => 'app.bsky.feed.post',
 787:         'text'      => $message,
 788:         'createdAt' => (new DateTime())->format('c'),
 789:     ];
 790:     if ($replyURL == NULL) {
 791:         if ($embed == NULL) {
 792:             $records = array_merge($records, $links);
 793:         } else {
 794:             $records = array_merge($records, $links, $embed);
 795:         }
 796:     } else {
 797:         if ($embed == NULL) {
 798:             $records = array_merge($records, $links, $reply);
 799:         } else {
 800:             $records = array_merge($records, $links, $reply, $embed);
 801:         }
 802:     }
 803: //  var_dump($records);
 804: 
 805:     // リクエストURL
 806:     $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.createRecord';
 807:     $this->webapi = $requestURL;
 808:     // cURLを使ったリクエスト
 809:     $ch = curl_init($requestURL);
 810:     curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
 811:     curl_setopt($ch, CURLOPT_HTTPHEADER, [
 812:         'Content-Type: application/json',
 813:         'Authorization: Bearer ' . $this->accessJwt,
 814:     ]);
 815:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 816:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキップ
 817:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); //  〃
 818:     curl_setopt($ch, CURLOPT_POST, TRUE);
 819:     curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
 820:         'repo'          => $this->BLUESKY_HANDLE,
 821:         'collection'    => 'app.bsky.feed.post',
 822:         'record'        => $records,
 823:     ]));
 824: 
 825:     // レスポンス処理
 826:     $response = curl_exec($ch);
 827:     $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
 828:     if ($httpStatusCode !200) {
 829:         $this->seterror('投稿できません(httpステータス異常)');
 830:         return FALSE;
 831:     }
 832:     curl_close($ch);
 833:     $items = json_decode($response, TRUE);
 834: //  var_dump($items);
 835: 
 836:     // エラーチェックとリターン
 837:     if (isset($items['uri'])) {
 838:         if (preg_match('/\/([0-9a-zA-Z]+)$/ui', $items['uri'], $arr> 0) {
 839:             $url = 'https://bsky.app/profile/' . $this->BLUESKY_HANDLE . '/post/' . $arr[1];
 840:         } else {
 841:             $url = '';
 842:         }
 843:         return $url;
 844:     } else if (isset($items['error'])) {
 845:         $this->seterror($items['message']);
 846:         return FALSE;
 847:     } else {
 848:         $this->seterror('投稿できません(応答不正)');
 849:         return FALSE;
 850:     }
 851: }

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

postBluesky.php

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

postBluesky.php

 195: // メイン・プログラム =======================================================
 196: //オブジェクトを生成する.
 197: $pbs = new pahooBlueskyAPI('bsky.social');
 198: 
 199: //パラメータを取得する.
 200: $msg = getParam('msg', TRUE, DEF_MESSAGE);
 201: $replyURL = getParam('replyURL', FALSE, NULL);
 202: if ($replyURL == '')    $replyURL = '';
 203: $quoteURL = getParam('quoteURL', FALSE, NULL);
 204: if ($quoteURL == '')    $quoteURL = '';
 205: $outmsg = '';
 206: 
 207: // 投稿
 208: if (isButton('exec')) {
 209:     //XSS対策
 210:     $msg = htmlspecialchars($msg);
 211:     //セッション開始
 212:     $res = $pbs->createSession();
 213:     //投稿
 214:     if ($res) {
 215:         $res = $pbs->post($msg, TRUE, $replyURL, $quoteURL);
 216:     }
 217:     //エラー処理
 218:     if ($res == FALSE) {
 219:         $outmsg = '<p style="color:red;">エラー:' . $pbs->geterror() . '</p>';
 220:     } else {
 221:         $outmsg = '<p style="color:blue;">投稿成功:<a href="' . $res . '">' . $res . '</a></p>';
 222:         //セッション終了
 223:         $res = $pbs->deleteSession();
 224:         //エラー処理
 225:         if ($res == FALSE) {
 226:             $outmsg .'<p style="color:red;">エラー:' . $pbs->geterror() . '</p>';
 227:         }
 228:     }
 229: 
 230: // クリア
 231: else if (isButton('clear')) {
 232:     $msg = $replyURL = $quoteURL = '';
 233: }
 234: 
 235: //表示HTMLを作成する.
 236: $HtmlBody = makeCommonBody($msg, $replyURL, $quoteURL, $outmsg, $pbs->webapi);
 237: 
 238: //画面に表示する.
 239: echo $HtmlHeader;
 240: echo $HtmlBody;
 241: echo $HtmlFooter;
 242: 
 243: //オブジェクトを解放する.
 244: $pbs = NULL;

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

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

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

参考サイト

(この項おわり)
header