(2025年8月17日)画像に余計な空白が入らないようにするため一部仕様変更.
(2025年8月14日).pahooEnv導入
(2025年8月2日)og:imageがないページに対応
(2025年1月25日)トークンを保持するよう改良.「新規セッション」チェック追加.
(2024年12月6日)画像のドラッグ&ドロップ,コピー&ペーストに対応した.OGPかどうかに関わらず画像が指定サイズに収めるよう拡大・縮小する仕様に変更した.引用時にも画像やOGP情報を付けられるようにした.API仕様変更対応
サンプル・プログラムの実行例
目次
- サンプル・プログラムの実行例
- サンプル・プログラム
- 準備:PHP の https対応
- 準備:pahooInputData 関数群
- 解説:pahooBlueskyAPIクラス
- 解説:API認証とセッション
- 解説:新規セッション開始
- 解説:セッションをリフレッシュ
- 解説:セッション終了
- 解説:投稿用URLやハッシュタグ情報を取得
- 解説:画像データの扱い
- 解説:投稿メッセージから画像URLを抽出
- 解説:画像をアップロード
- 解説:画像を指定幅・高さに収まるように拡大・縮小する
- 解説:透明背景を白色で塗りつぶす
- 解説:OGP情報を取得
- 解説:ユーザーのDIDを取得する
- 解説:ルートIDと親IDを取得する
- 解説:メッセージ投稿
- 解説:メイン・プログラム
- 参考サイト
サンプル・プログラム
| postBluesky.php | サンプル・プログラム本体 |
| .pahooEnv | クラウドサービスを利用するためのアカウント情報などを記入する .env ファイル。 使い方は「各種クラウド連携サービス(WebAPI)の登録方法」を参照。include_path が通ったディレクトリに配置すること。 |
| pahooInputData.php | データ入力に関わる関数群。 使い方は「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。 |
| pahooBlueskyAPI.php | Bluesky APIに関わるクラス pahooBlueskyAPI。 使い方は「PHPでPHPでBlueskyに投稿する」などを参照。include_path が通ったディレクトリに配置すること。 |
| pahooScraping.php | スクレイピング処理に関わるクラス pahooScraping。 スクレイピング処理に関わるクラスの使い方は「PHPでDOMDocumentを使ってスクレイピング」を参照。include_path が通ったディレクトリに配置すること。 |
| バージョン | 更新日 | 内容 |
|---|---|---|
| 1.6.0 | 2025/08/14 | .pahooEnv導入 |
| 1.5.0 | 2025/01/25 | トークンを保持,「新規セッション」チェック追加 |
| 1.4.1 | 2024/12/08 | 文字入力時の空白トリムなど追加 |
| 1.4.0 | 2024/12/06 | 画像のドラッグ&ドロップ,コピー&ペーストに対応 |
| 1.3.0 | 2024/11/10 | 入力メッセージの残文字数とプログレスバー表示 |
| バージョン | 更新日 | 内容 |
|---|---|---|
| 2.7.1 | 2025/11/22 | PHP8.5対応:curl_close,imagedestroyを実行しないようにした |
| 2.7.0 | 2025/08/17 | reductImage,uploadBlob仕様変更←画像に余計な空白が入らないようにするため |
| 2.6.0 | 2025/08/14 | .pahooEnv 導入 |
| 2.5.1 | 2025/08/10 | uploadBlob() -- bug-fix |
| 2.5.0 | 2025/08/02 | getOGPInformation() -- og:imageがないページに対応 |
| バージョン | 更新日 | 内容 |
|---|---|---|
| 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 | 初版 |
| バージョン | 更新日 | 内容 |
|---|---|---|
| 2.0.1 | 2025/08/11 | getParam() bug-fix |
| 2.0.0 | 2025/08/11 | pahooLoadEnv() 追加 |
| 1.9.0 | 2025/07/26 | getParam() 引数に$trim追加 |
| 1.8.1 | 2025/03/15 | validRegexPattern() debug |
| 1.8.0 | 2024/11/12 | validRegexPattern() 追加 |
準備:PHP の https対応
Windowsでは、"php.ini" の下記の行を有効化する。
extension=php_openssl.dllLinuxでは --with-openssl=/usr オプションを付けて再ビルドする。→OpenSSLインストール手順
これで準備は完了だ。
準備:pahooInputData 関数群
また、各種クラウドサービスに登録したときに取得するアカウント情報、アプリケーションパスワードなどを登録した .pahooEnv ファイルから読み込む関数 pahooLoadEnv を備えている。こちらについては、「各種クラウド連携サービス(WebAPI)の登録方法」をご覧いただきたい。
解説:pahooBlueskyAPIクラス
Bluesky APIを利用するメソッドはクラス "pahooBlueskyAPI.php" に分離している。また、このクラスからクラス "pahooScraping.php" を呼び出すので、2つのクラス・ファイルを include_path の通ったディレクトリに配置すること。他のプログラムでも pahooBlueskyAPIクラス を利用するが、常に最新のクラス・ファイルを1つ配置すればよい。
基本的に、POSTプロトコルでデータを渡し、JSON形式で応答が戻ってくるAPIであるが、Bluesky は分散型SNSと呼ばれるように、PDS(Personal Data Server)が複数存在し、API も PDSの中に入っている。これを AT Protocol と呼び、PDSが稼動しているドメインを PDSドメインと呼ぶ。
PDSドメインは、ユーザーによって変わる可能性がある。たとえばハンドル名 hoge.bsky.social であれば、bsky.social が PDSドメインである。
BlueskyAPI は、機能ごとにエンドポイントが用意されており、API呼び出しURLは htttps://{PDSドメイン}/xrpc/{エンドポント} となる。
API に対する操作は、ユーザー定義クラス pahooBlueskyAPI にカプセル化した。
左図に、今回利用する BlueskyAPI と、それを呼び出すメソッドを整理した。
これ以外にも、APIは呼び出さないが、メッセージ中からURLを取り出すメソッド getURLs や、画像URL(画像など)を取り出すメソッド extractMediaURL などが利用できる。
今回の目的であるメッセージ投稿については、後述 post メソッドに一元化したが、リンクURLが画像URLが含まれていたり、返信や引用をするときには、post から別のメソッドを呼び出して投稿に必要な追加情報を取得する形にした。
PHPのクラスについては「PHPでクラスを使ってテキストの読みやすさを調べる」を参照されたい。
解説:API認証とセッション
基本的なAPIは、冒頭で紹介したアプリパスワードが必要になる。
プロファイル情報取得、スレッド情報取得、メッセージ投稿などでは、アプリパスワードの代わりにトークン(accessJWT)が必要になる。
accessJWT は英数字からなる文字列だが、その中に有効期間が埋め込まれており、その有効期間中(1~2時間)は APIとの間にセッションが張られているとイメージしてもらえばよい。
これまでは BlueskyAPI を呼び出す都度、accessJwt を新規生成していたが、新規生成 API の呼び出し回数制限が厳しくなってきていることから、トークンをサーバに保存し、有効期限内であれば再利用するように改良した。また、有効期限切れの場合も、セッションのリフレッシュAPIを利用し、トークンを新規生成せず有効期限を延長するようにした。リフレッシュトークン refreshJwt は寿命は数十日と長い。
ファイルには、アクセストークン accessJWT とリフレッシュトークン refreshJWT の2つを格納するが、いずれもアプリパスワードと同じように秘匿性を保つことに留意されたい。
トークン取得の流れを左図に整理する。
pahooBlueskyAPI.php
311: /**
312: * 有効なアクセストークン(accessJwt)を取得する
313: * @param なし
314: * @return bool TRUE:成功/FALSE:失敗
315: */
316: function getValidToken() {
317: // プロパティにトークンが無い
318: if (($this->accessJwt == '') || ($this->refreshJwt !== '')) {
319: // トークンを保存したファイルがない
320: if (! is_file(self::FILENAME_TOKEN)) {
321: return $this->createSession();
322: }
323:
324: // 保存ファイルからトークンを読み込む
325: $contents = @file_get_contents(self::FILENAME_TOKEN);
326: if ($contents == FALSE) {
327: return $this->createSession();
328: }
329: $arr = preg_split('/\n/msiu', $contents);
330: if (count($arr) < 2) {
331: return $this->createSession();
332: }
333: if (($arr[0] == '') || ($arr[1] == '')) {
334: return $this->createSession();
335: }
336: $this->accessJwt = $arr[0];
337: $this->refreshJwt = $arr[1];
338: }
339:
340: // accessJwt の有効期限を検査する
341: $arr = preg_split('/\./iu', $this->accessJwt);
342: if (count($arr) < 3) {
343: return $this->createSession();
344: }
345: $decodedPayload = base64_decode($arr[1]);
346: $decoded = json_decode($decodedPayload, TRUE);
347: $exp = isset($decoded['exp']) ? $decoded['exp'] : 0;
348:
349: // 期限切れならリフレッシュする
350: if (time() > ((int)$exp - 100)) { // 余裕をみて100秒前
351: $res = $this->refreshSession();
352: if (! $res) {
353: return $this->createSession();
354: }
355: }
356:
357: return TRUE;
358: }
アクセストークン accessJwt は英数字からなる文字列だが、ドット [.;blue] で3つのブロックに区切られており、左から2番目のブロックには有効期限が Base64形式でエンコードされている。この値を現在時刻 time と比較し、有効期限が過ぎていれば後述するメソッド refreshSession を使ってアクセストークンの有効期間を延長(リフレッショ)する。
解説:新規セッション開始
ここで取得したアクセストークンは再利用するため、定数 FILENAME_TOKEN で示すファイルの1行目にアクセストークン accessJwt を、2行目にリフレッシュトークン refreshJwt を保存する。
| URL |
|---|
| https://{PDSドメイン}/xrpc/com.atproto.server.createSession |
pahooBlueskyAPI.php
15: // スクレイピング処理に関わるクラス:include_pathが通ったディレクトリに配置
16: require_once('pahooScraping.php');
17:
18: // Bluesky API クラス =======================================================
19: class pahooBlueskyAPI {
20: public $pds; // PDSドメイン
21: public $webapi ; // 直前に呼び出したWebAPI URL
22: public $errmsg; // エラーメッセージ
23: public $accessJwt; // accessJwt
24: public $refreshJwt; // refreshJwt
25:
26: const INTERNAL_ENCODING = 'UTF-8'; // 内部エンコーディング
27: const MAX_MESSAGE_LEN = 300; // 投稿可能なメッセージ文字数
28: const URL_LEN = 23; // メッセージ中のURL文字数(相当)
29: const MAX_IMAGE_WIDTH = 1200; // 投稿可能な最大画像幅(ピクセル)
30: const MAX_IMAGE_HEIGHT = 675; // 投稿可能な最大画像高さ(ピクセル)
31: // これより大きいときは自動縮小する
32: // トークンを保存するファイル名
33: // 秘匿性を保つことができ、かつ、PHPプログラムから読み書き可能であること
34: const FILENAME_TOKEN = './.token';
35:
36: // -- 以下のデータは .env ファイルに記述可能
37: // Bluesky API アプリパスワード
38: // https://bsky.app/
39: public $BLUESKY_HANDLE = ''; // ハンドル名
40: public $BLUESKY_PASSWORD = ''; // アプリケーション・パスワード
上述の手順で取得したアプリケーション・パスワードをプロパティ変数 $BLUESKY_PASSWORD に、あなたのハンドル名を $BLUESKY_HANDLE に代入する。
投稿可能な最大文字数は定数 MAX_MESSAGE_LEN として用意した。現在の仕様では300文字だ。
トークンを保存するファイル名を定数 FILENAME_TOKEN に用意する。前述したように、秘匿性が保つことができ、かつ PHPプログラムから読み書き可能なディレクトリを指定すること。
pahooBlueskyAPI.php
42: /**
43: * コンストラクタ
44: * もしAPIエラーが出る場合には,新規セッションにしてみる.
45: * @param string $pds PDSドメイン
46: * @param bool $newSession 新規セッションにするかどうか(TRUE:新規,デフォルトはFALSE)
47: * @return なし
48: */
49: function __construct($pds, $newSession=FALSE) {
50: if (isset($_ENV['PAHOO_BLUESKY_HANDLE'])) {
51: $this->BLUESKY_HANDLE = $_ENV['PAHOO_BLUESKY_HANDLE'];
52: }
53: if (isset($_ENV['PAHOO_BLUESKY_PASSWORD'])) {
54: $this->BLUESKY_PASSWORD = $_ENV['PAHOO_BLUESKY_PASSWORD'];
55: }
56:
57: // プロパティを初期化する.
58: $this->pds = $pds;
59: $this->webapi = '';
60: $this->errmsg = '';
61: $this->accessJwt = '';
62: $this->refreshJwt = '';
63:
64: // 新規セッションを開始する.
65: if ($newSession) {
66: $this->createSession();
67: }
68: }
2番目の引数 $newSession を TRUE にすると、インスタンス生成時に必ず createSession メソッドを呼び出し、新規セッションを強制する。API呼び出しに失敗したり、セッションを保存したファイルが破損した場合に備えて用意したオプションである。省略時には FALSE(セッションを維持する)である。
pahooBlueskyAPI.php
360: /**
361: * 新規セッションを開始する.
362: * @param なし
363: * @return bool TRUE:成功/FALSE:失敗
364: */
365: function createSession() {
366: // エラーメッセージ・クリア
367: $this->clearerror();
368:
369: // リクエストURL
370: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.createSession';
371: $this->webapi = $requestURL;
372: $ch = curl_init($requestURL);
373: // cURLを使ったリクエスト
374: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
375: curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
376: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
377: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
378: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
379: curl_setopt($ch, CURLOPT_POST, TRUE);
380: curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
381: 'identifier' => $this->BLUESKY_HANDLE,
382: 'password' => $this->BLUESKY_PASSWORD,
383: ]));
384:
385: // レスポンス処理
386: $response = curl_exec($ch);
387: // var_dump('*createSession');
388: // var_dump($response);
389: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
390: if ($httpStatusCode != 200) {
391: $this->seterror('セッションを開始できません');
392: return FALSE;
393: }
394: if (PHP_VERSION_ID < 80500) {
395: curl_close($ch);
396: }
397: $items = json_decode($response, TRUE);
398:
399: // エラーチェックとリターン
400: if (isset($items['accessJwt']) && isset($items['refreshJwt'])) {
401: $this->accessJwt = (string)$items['accessJwt'];
402: $this->refreshJwt = (string)$items['refreshJwt'];
403: // トークンをファイルに保存する
404: $contents = $this->accessJwt . "\n" . $this->refreshJwt;
405: file_put_contents(self::FILENAME_TOKEN, $contents);
406: return TRUE;
407: } else if (isset($items['error'])) {
408: $this->seterror($items['message']);
409: return FALSE;
410: } else {
411: $this->seterror('セッションを開始できません');
412: return FALSE;
413: }
414: }
メソッドの中身は、上述のAPI仕様の通りに作った。
POSTプロトコルとして、これまでのクラウドサービス利用でも使ってきた cURL関数を利用する。
解説:セッションをリフレッシュ
ここで取得したアクセストークンは再利用するため、定数 FILENAME_TOKEN で示すファイルの1行目にアクセストークン accessJwt を、2行目にリフレッシュトークン refreshJwt を保存する。
| URL |
|---|
| https://{PDSドメイン}/xrpc/com.atproto.server.refreshSession |
pahooBlueskyAPI.php
416: /**
417: * セッションをリフレッシュする.
418: * @param なし
419: * @return bool TRUE:成功/FALSE:失敗
420: */
421: function refreshSession() {
422: // エラーメッセージ・クリア
423: $this->clearerror();
424:
425: // リクエストURL
426: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.refreshSession';
427: $this->webapi = $requestURL;
428: $ch = curl_init($requestURL);
429: // cURLを使ったリクエスト
430: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
431: curl_setopt($ch, CURLOPT_HTTPHEADER, [
432: 'Authorization: Bearer ' . $this->refreshJwt,
433: 'Content-Type: application/json',
434: ]);
435: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
436: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
437: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
438: curl_setopt($ch, CURLOPT_POST, TRUE);
439:
440: // レスポンス処理
441: $response = curl_exec($ch);
442: // var_dump('*refreshSession');
443: // var_dump('refreshJwt = ' . $this->refreshJwt);
444: // var_dump($response);
445: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
446: if ($httpStatusCode != 200) {
447: $this->seterror('セッションをリフレッショできません');
448: return FALSE;
449: }
450: if (PHP_VERSION_ID < 80500) {
451: curl_close($ch);
452: }
453: $items = json_decode($response, TRUE);
454:
455: // エラーチェックとリターン
456: if (isset($items['accessJwt']) && isset($items['refreshJwt'])) {
457: $this->accessJwt = (string)$items['accessJwt'];
458: $this->refreshJwt = (string)$items['refreshJwt'];
459: // トークンをファイルに保存する
460: $contents = $this->accessJwt . "\n" . $this->refreshJwt;
461: file_put_contents(self::FILENAME_TOKEN, $contents);
462: return TRUE;
463: } else if (isset($items['error'])) {
464: $this->seterror($items['message']);
465: return FALSE;
466: } else {
467: $this->seterror('セッションをリフレッシュできません');
468: return FALSE;
469: }
470: }
解説:セッション終了
| URL |
|---|
| https://{PDSドメイン}/xrpc/com.atproto.server.deleteSession |
アクセストークン accessJwt の盗用を避ける意味で、セッションを開始したら、かならずセッション終了するようにしよう。
APIの戻り値は、httpステータスが200であれば成功、それ以外であればエラー情報が戻る。
pahooBlueskyAPI.php
472: /**
473: * セッション終了する.
474: * @param なし
475: * @return bool TRUE:成功/FALSE:失敗
476: */
477: function deleteSession() {
478: // リクエストURL
479: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.deleteSession';
480: $this->webapi = $requestURL;
481: $ch = curl_init($requestURL);
482: // cURLを使ったリクエスト
483: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
484: curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $this->accessJwt]);
485: curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
486: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
487: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
488: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
489:
490: // レスポンス処理
491: $response = curl_exec($ch);
492: if (curl_errno($ch)) {
493: $this->seterror('セッション終了できません' . curl_error($ch));
494: return FALSE;
495: }
496: if (PHP_VERSION_ID < 80500) {
497: curl_close($ch);
498: }
499: $this->accessJwt = '';
500: $this->refreshJwt = '';
501:
502: return TRUE;
503: }
解説:投稿用URLやハッシュタグ情報を取得
メッセージからURLやハッシュタグの位置情報を取りだすメソッドが getRichTextPositions である。複数のURLやハッシュタグに対応している。
pahooBlueskyAPI.php
178: /**
179: * 指定したテキスト中のURLやハッシュタグの位置情報を取得する.
180: * @param string $text テキスト
181: * @return Array 位置情報
182: */
183: function getRichTextPositions($text) {
184: $urlData = array();
185:
186: // URL
187: $regexURL = '/(https?:\/\/[^\s]+)/ui';
188: preg_match_all($regexURL, $text, $matches, PREG_OFFSET_CAPTURE);
189: foreach ($matches[0] as $match) {
190: $url = $match[0];
191: $start = $match[1];
192: $end = $start + strlen($url);
193:
194: $urlData[] = array(
195: 'type' => 'link',
196: 'start' => $start,
197: 'end' => $end,
198: 'url' => $url,
199: );
200: }
201:
202: // ハッシュタグ
203: $regexHashTag = '/(#[\p{L}\p{N}_\-.]+)/ui';
204: preg_match_all($regexHashTag, $text, $matches, PREG_OFFSET_CAPTURE);
205: foreach ($matches[0] as $match) {
206: $hashtag = $match[0];
207: $start = $match[1];
208: $end = $start + strlen($hashtag);
209:
210: $urlData[] = array(
211: 'type' => 'tag',
212: 'start' => $start,
213: 'end' => $end,
214: 'tag' => $hashtag,
215: );
216: }
217: return $urlData;
218: }
pahooBlueskyAPI.php
220: /**
221: * 投稿用URLやハッシュタグ情報を取得する.
222: * @param string $text テキスト
223: * @return Array 投稿用facets情報
224: */
225: function parseRichText($text) {
226: $positions = $this->getRichTextPositions($text);
227: $results = $facets = array();
228: if (! empty($positions)) {
229: foreach ($positions as $position) {
230: // URL
231: if ($position['type'] == 'link') {
232: $facets[] = [
233: 'index' => [
234: 'byteStart' => $position['start'],
235: 'byteEnd' => $position['end'],
236: ],
237: 'features' => [
238: [
239: '$type' => 'app.bsky.richtext.facet#link',
240: 'uri' => $position['url'],
241: ],
242: ],
243: ];
244: // ハッシュタグ
245: } else if ($position['type'] == 'tag') {
246: $facets[] = [
247: 'index' => [
248: 'byteStart' => $position['start'],
249: 'byteEnd' => $position['end'],
250: ],
251: 'features' => [
252: [
253: '$type' => 'app.bsky.richtext.facet#tag',
254: 'tag' => ltrim($position['tag'], '#'),
255: ],
256: ],
257: ];
258: }
259: }
260: $results = [
261: 'facets' => $facets,
262: ];
263: }
264:
265: return $results;
266: }
このように、クライアント側で用意するデータ構造によって、URLとハッシュタグを同列のハイパーリンクとして扱う Bluesky の設計には感心させられた。ハッシュタグの方はリンク先情報を渡さないが、実際には Bluesky の検索URLにハイパーリンクする。将来的に検索機能も分散方式になった場合でも対応が容易であり、じつに拡張性のある設計だ。
解説:画像データの扱い
- メッセージ中に画像URLを記述する。
- 画像ファイルをコピー&ペーストする。
- 画像ファイルをドラッグ&ドロップする。
1.の手順はサーバ側で、後述するPHPのユーザー定義メソッド extractMediaURL を使って行う。
2.と3.の手順はクライアント側で、JavaScriptを使って行う。2.の手順については「JavaScriptでクリップボードの画像取得+リサイズ」を、3.の手順については「解説:ファイルのドロップ――PHPで撮影場所をマッピング」をご覧いただきたい。
1~3の手順で取得した画像データは、後述するユーザー定義メソッド uploadBlob を使って Bluesky API によりアップロードする。
解説:投稿メッセージから画像URLを抽出
pahooBlueskyAPI.php
268: /**
269: * 指定したテキストから画像URLを抜き出して配列に格納する.
270: * テキストはUTF-8で指定すること.
271: * 画像拡張子$extに複数の拡張子を指定できる.省略時は 'jpg|png|webp|bmp'
272: * @param string $str テキスト
273: * @param array $urls 画像URLを格納する配列
274: * @param string $ext 画像拡張子;省略時 jpg|bng|webp|bmp
275: * @return string 画像URLを除いたテキスト
276: */
277: function extractMediaURL($str, &$urls, $ext='jpg|png|webp|bmp|mp4|mp3') {
278: // http記法
279: $pat1 = '/https?\:\/\/[\-_\.\!\~\*\'\(\)a-zA-Z0-9\;\/\?\:\@\&\=\+\$\,\%\#]+(' . $ext . ')/i';
280: // file記法
281: $pat2 = '/file\:\/\/\/((.*?)(' . $ext . '))/i';
282:
283: // 画像URLを抜き出す.
284: if (preg_match_all($pat1, $str, $arr) > 0) {
285: foreach ($arr[0] as $url) {
286: $urls[] = $url;
287: }
288: // テキストから画像URLを消去する.
289: $str = str_replace($urls, '', $str);
290: }
291:
292: // ローカル画像を抜き出す.
293: if (preg_match_all($pat2, $str, $arr) > 0) {
294: $fnames = array();
295: foreach ($arr[1] as $key=>$fname) {
296: // 画像ファイルかどうかを判定する.
297: if (exif_imagetype($fname) != FALSE) {
298: $urls[] = $fname;
299: $fnames[] = $arr[0][$key];
300: }
301: }
302: // テキストからローカル画像を消去する.
303: $str = str_replace($fnames, '', $str);
304: }
305: // 余分な空白を削除する.
306: $str = trim($str);
307:
308: return $str;
309: }
引数 $str に、画像URLやローカルファイル名を含んだメッセージ(文字列)を、引数 $urls に抽出した画像URLやローカルファイル名を配列として格納する。画像の拡張子は引数 $ext に指定する。区切り文字はパイプ #x7C; である。
インターネット上にある画像URLは "http:// または "https://" ではじまるURLである。
ローカルファイル名は "file:///" ではじまるファイル名である。ローカルファイル名の場合、引数 $urls には "file:///" を除いたローカルファイル名を格納する。PHPで処理しているので、このローカルファイル名はサーバにおけるファイル名であることに留意されたい。
戻り値は、引数 $str から $urls に格納した画像URLやローカルファイル名を除いた残りのテキスト文字列である。
解説:画像をアップロード
拡大・縮小した後の幅と高さを引数 $width, $height に代入して戻すようにした。
| URL |
|---|
| https://{PDSドメイン}/xrpc/com.atproto.repo.uploadBlob |
pahooBlueskyAPI.php
732: /**
733: * 画像をアップロードする.
734: * 画像ファイルなどを投稿するときに事前に呼び出し,blobデータを投稿する.
735: * @param string $filename 画像ファイル名
736: * @param int $width アップロードした画像の幅を格納(ピクセル)
737: * @param int $height アップロードした画像の高さを格納(ピクセル)
738: * @param int $maxWidth アップロードする画像の最大幅(ピクセル)
739: * @param int $maxHeight アップロードする画像の最大高(ピクセル)
740: * @param bool $flagFixedSize TRUE:画像を最大幅・高に加工する(背景白色)
741: * @return string Blusky PDSのURL/FALSE:アップロード失敗
742: */
743: function uploadBlob($filename, &$width, &$height, $maxWidth=self::MAX_IMAGE_WIDTH, $maxHeight=self::MAX_IMAGE_HEIGHT, $flagFixedSize=FALSE) {
744: $mimeType = '';
745: $fileSize = 0;
746:
747: // エラーメッセージ・クリア
748: $this->clearerror();
749:
750: // Refererを生成する
751: $parsedUrl = parse_url($filename);
752: if (isset($parsedUrl['host'])) {
753: $referer = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . '/';
754: } else if (isset($parsedUrl['scheme'])) {
755: $referer = $parsedUrl['scheme'] . ':///';
756: } else {
757: $referer = '';
758: }
759:
760: // 対象URLの内容を読み込む
761: // リダイレクト先からも読み込めるようにする
762: $contents = '';
763: $options = [
764: 'http' => [
765: 'method' => 'GET',
766: 'follow_location' => 1, // リダイレクトを追跡する
767: 'max_redirects' => 10, // 最大リダイレクト回数
768: 'header' =>
769: "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)\r\n" .
770: "Referer: {$referer}\r\n"
771: ]
772: ];
773: $context = stream_context_create($options);
774:
775: // 画像を読み込む
776: $imageData = file_get_contents($filename, FALSE, $context);
777: if ($imageData === FALSE) {
778: $this->seterror($filename . ' の読み込みに失敗しました');
779: return FALSE;
780: }
781: // MIMEタイプを判定する
782: $finfo = new finfo(FILEINFO_MIME_TYPE);
783: $mimeType = (string)$finfo->buffer($imageData);
784: $finfo = NULL;
785:
786: // 必要に応じて画像データを縮小する
787: $imageData = $this->reductImage($imageData, $width, $height, $mimeType, $maxWidth, $maxHeight, $flagFixedSize);
788:
789: // 透明背景を白色で塗りつぶす(投稿したときに黒背景になってしまうため)
790: $imageData = $this->convertTransparentToWhite($imageData, $mimeType);
791:
792: // トークンを取得する.
793: $this->getValidToken();
794: // var_dump($this->accessJwt);
795:
796: // リクエストURL
797: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.uploadBlob';
798: $this->webapi = $requestURL;
799: // cURLを使ったリクエスト
800: $ch = curl_init($requestURL);
801: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
802: curl_setopt($ch, CURLOPT_HTTPHEADER, [
803: 'Authorization: Bearer ' . $this->accessJwt,
804: 'Accept: application/json',
805: 'Content-Type: ' . $mimeType,
806: ]);
807: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
808: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
809: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
810: curl_setopt($ch, CURLOPT_POST, TRUE);
811: curl_setopt($ch, CURLOPT_POSTFIELDS, $imageData);
812:
813: // レスポンス処理
814: $response = curl_exec($ch);
815: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
816: if (PHP_VERSION_ID < 80500) {
817: curl_close($ch);
818: }
819: $items = json_decode($response, TRUE);
820: if ($httpStatusCode != 200) {
821: $errmsg = '画像をアップロードできません(http code:' . $httpStatusCode . ')';
822: if (isset($items['message'])) {
823: $errmsg .= ';' . $items['message'];
824: }
825: $this->seterror($errmsg);
826: return FALSE;
827: }
828:
829: // エラーチェックとリターン
830: if (isset($items['blob'])) {
831: return $items['blob'];
832: } else if (isset($items['error'])) {
833: $this->seterror($items['message']);
834: return FALSE;
835: } else {
836: $this->seterror('画像をアップロードできません');
837: return FALSE;
838: }
839: }
ここで、画像の最大幅を超えた場合は後述する reductImageを呼び出し、自動的に最大幅・最大高に縮小する。なお、引数の画像の最大幅は省略可能で、省略時には冒頭の定数に定義する MAX_IMAGE_WIDTH を代入する。また、拡大・縮小した後の幅と高さを引数 $width, $height に代入して戻すようにした。
メソッドの中身は、上述のAPI仕様の通りに作った。
画像ファイルは、組み込み関数 file_get_contents を使って変数 $imageData に格納する。画像のMIMEタイプを判定するのに、finfoクラスを利用した。
解説:画像を指定幅・高さに収まるように拡大・縮小する
また、Bluesky はTwitter(現・X)と異なり、OGP情報の画像は常に横長の画像として表示する。そこで、OGP情報として縦長の画像を登録するときは、全体が収まるよう縮小した上で、余白(背景)を白色にするフラグ $flagFixedSize を用意した。
pahooBlueskyAPI.php
587: /**
588: * 画像データを指定幅・高さに収まるように拡大・縮小する
589: * @param string $imageData 画像データ(画像ファイルから読み込んだバイナリ)
590: * @param int $width 拡大・縮小後の画像の幅を格納(ピクセル)
591: * @param int $height 拡大・縮小後の画像の高さを格納(ピクセル)
592: * @param string $mimeType 縮小後の画像のMIMEタイプ
593: * @param int $maxWidth 画像データの最大幅(ピクセル)
594: * @param int $maxHeight 画像データの最大高(ピクセル)
595: * @param bool $flagFixedSize TRUE:画像を最大幅・高に加工する(背景白色)
596: * @return string 縮小後の画像データ/FALSE 対応していない画像フォーマット
597: */
598: function reductImage($imageData, &$width, &$height,
599: $mimeType='image/jpeg',
600: $maxWidth=self::MAX_IMAGE_WIDTH, $maxHeight=self::MAX_IMAGE_HEIGHT,
601: $flagFixedSize=FALSE) {
602:
603: // 拡大・縮小倍率
604: $scale = 1.0;
605:
606: // 画像フォーマットを取得する
607: if (preg_match('/\/([a-z]+)/i', $mimeType, $arr) > 0) {
608: $imageFormat = $arr[1];
609: } else {
610: $imageFormat = 'jpeg';
611: }
612:
613: // GD画像データに変換する
614: $imageSource = imagecreatefromstring($imageData);
615: if (! $imageSource) {
616: $this->seterror('画像データを縮小できません');
617: return FALSE;
618: }
619:
620: // 画像のアスペクト比が4:3より横長だったら,16:9にトリミングする
621: // $imageSource = $this->cropTo16by9($imageSource);
622:
623: // 元の画像の幅・高さを取得
624: $originalWidth = imagesx($imageSource);
625: $originalHeight = imagesy($imageSource);
626:
627: // リサイズ倍率を計算する
628: $scaleW = $maxWidth / $originalWidth;
629: $scaleH = $maxHeight / $originalHeight;
630: $scale = min($scaleW, $scaleH);
631: $newWidth = (int)($originalWidth * $scale);
632: $newHeight = (int)($originalHeight * $scale);
633:
634: // リサイズ後の画像オブジェクトを用意
635: $imageResize = imagecreatetruecolor($newWidth, $newHeight);
636: // 透明色の処理(PNGやGIFの場合)
637: imagealphablending($imageResize, FALSE);
638: imagesavealpha($imageResize, TRUE);
639: $transparent = imagecolorallocatealpha($imageResize, 255, 255, 255, 127);
640: imagefilledrectangle($imageResize, 0, 0, $newWidth, $newHeight, $transparent);
641: // 画像リサイズ実行
642: imagecopyresampled($imageResize, $imageSource, 0, 0, 0, 0, $newWidth, $newHeight, $originalWidth, $originalHeight);
643:
644: // 画像を最大幅・高に加工する(背景白色)
645: if ($flagFixedSize) {
646: // 白色背景を作成する
647: $imageBackground = imagecreatetruecolor($maxWidth, $maxHeight);
648: $white = imagecolorallocate($imageBackground, 255, 255, 255);
649: imagefill($imageBackground, 0, 0, $white);
650: // 画像を背景の中央に配置するための座標計算
651: $dstX = (int)(($maxWidth - $newWidth) / 2);
652: $dstY = (int)(($maxHeight - $newHeight) / 2);
653: // $imageResize を白色背景にコピー
654: imagecopy($imageBackground, $imageResize, $dstX, $dstY, 0, 0, $newWidth, $newHeight);
655: // リサイズした画像をバイナリ形式に変換する
656: $imageData = $this->image2binary($imageBackground, $imageFormat);
657: // メモリ解放
658: if (PHP_VERSION_ID < 80500) {
659: imagedestroy($imageBackground);
660: }
661:
662: // 画像縮小のみの場合
663: } else {
664: // リサイズした画像をバイナリ形式に変換する
665: $imageData = $this->image2binary($imageResize, $imageFormat);
666: }
667: // メモリ解放
668: if (PHP_VERSION_ID < 80500) {
669: imagedestroy($imageResize);
670: }
671:
672: // 縮小後の画像の幅・高さ
673: $width = $newWidth;
674: $height = $newHeight;
675:
676: return $imageData;
677: }
まず、引数として渡された $mimeType から画像形式を取り出して変数 $imageFormat に代入する。これは拡大・縮小後の画像形式を保つための処理だ。
画像の拡大・縮小には GD関数群を利用する。
その前に、 imagecreatefromstring 関数を使い、引数で渡された画像データ(バイナリデータ)をGD画像データに変換する。次に、 imagesx 関数を使い、画像の幅と高さを取得する。
最大画像幅・高と比較して、縮小率を計算し変数 $scale に代入する。
サイズ後の画像オブジェクト $imageResize を用意し、 imagealphablending 関数、 imagesavealphag 関数、 imagecolorallocatealpha 関数、 imagefilledrectangle 関数を使って透明色の処理を行ったら、 imagecopyresampled 関数を使ってリサイズを実行する。
最後に、GD画像データを画像データ(バイナリデータ)に変換するには、後述する image2binaryメソッドを適用し、メモリを解放する。
$imageFormat が TRUE のときは、 imagecreatetruecolor 関数、 imagecolorallocate 関数、 imagefill 関数を使って新しい白一色の画像を生成する。元画像を白色画像の中央に配置するための座標計算をしたら、 imagecopy 関数を使って2つの画像を合成する。
最後に、拡大・縮小後の画像の幅と高さを引数 $width, $height に代入して戻す。
pahooBlueskyAPI.php
505: /**
506: * GD画像データをバイナリデータに変換する.
507: * @param string $image GD画像データ
508: * @param string $imageFormat 変換する画像フォーマット(jpeg, png, gif)
509: * @return string バイナリデータ/FALSE 対応していない画像フォーマット
510: */
511: function image2binary($image, $imageFormat='jpeg') {
512: // 出力バッファリングを開始
513: ob_start();
514:
515: // 画像フォーマットに応じて変換関数を選択
516: switch ($imageFormat) {
517: case 'jpeg':
518: imagejpeg($image, NULL, 75);
519: break;
520: case 'png':
521: imagepng($image, NULL, 5);
522: break;
523: case 'gif':
524: imagegif($image);
525: break;
526: case 'webp':
527: imagewebp($image, NULL, 75);
528: break;
529: case 'bmp':
530: imagebmp($image);
531: break;
532: case 'avif':
533: imageavif($image, NULL, 50);
534: break;
535: default:
536: return FALSE;
537: }
538:
539: // バッファ内容を取得する
540: $binaryData = ob_get_clean();
541:
542: return $binaryData;
543: }
GD関数群にある imagejpeg 関数などを使い、画面に出力される画像データ(バイナリ)を、 ob_start 関数を使って横取りすることで変数に格納する。
画像フォーマットに応じて変換するGD関数を選択し、最後に ob_get_clean 関数を使って変数に格納する。
解説:透明背景を白色で塗りつぶす
pahooBlueskyAPI.php
679: /**
680: * 透明背景を白色で塗りつぶす
681: * Blueskyに投稿したときに黒背景になってしまうため
682: * @param string $imageData 画像データ(画像ファイルから読み込んだバイナリ)
683: * @return string 変換後の画像データ/FALSE 対応していない画像フォーマット
684: */
685: function convertTransparentToWhite($imageData, $mimeType='image/png') {
686: // 画像フォーマットを取得する
687: if (preg_match('/\/([a-z]+)/i', $mimeType, $arr) > 0) {
688: $imageFormat = $arr[1];
689: } else {
690: $imageFormat = 'png';
691: }
692:
693: // アルファチャネルをサポートしていない画像フォーマットはそのままリターン
694: if (! preg_match('/png|webp|tiff|psd|exr|ico/i', $imageFormat)) {
695: return $imageData;
696: }
697:
698: // GD画像データに変換する
699: $imageSource = imagecreatefromstring($imageData);
700: if (! $imageSource) {
701: $this->seterror('画像データを読み込めません');
702: return FALSE;
703: }
704:
705: // 画像の幅・高さを取得
706: $width = imagesx($imageSource);
707: $height = imagesy($imageSource);
708:
709: // 背景画像を作成する
710: $imageResult = imagecreatetruecolor($width, $height);
711:
712: // 白色を作成して塗りつぶす
713: $white = imagecolorallocate($imageResult, 255, 255, 255);
714: imagefill($imageResult, 0, 0, $white);
715:
716: // 元の画像を新しい画像にコピーする
717: imagecopy($imageResult, $imageSource, 0, 0, 0, 0, $width, $height);
718:
719: // アルファブレンドを無効化して保存する
720: imagesavealpha($imageResult, FALSE);
721:
722: // リサイズした画像をバイナリ形式に変換する
723: $imageData = $this->image2binary($imageResult, $imageFormat);
724: // メモリ解放
725: if (PHP_VERSION_ID < 80500) {
726: imagedestroy($imageResult);
727: }
728:
729: return $imageData;
730: }
解説: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
841: /**
842: * OGP情報を取得する.
843: * @param string $url 対象コンテンツ
844: * @return array OGP情報(embed形式)/NULL:OGP情報はない
845: */
846: function getOGPInformation($url) {
847: $contents = '';
848: $height = $width = 0;
849:
850: // リダイレクト先からも読み込めるようにする
851: $options = [
852: 'http' => [
853: 'method' => 'GET',
854: 'follow_location' => 1, // リダイレクトを追跡する
855: 'max_redirects' => 10, // 最大リダイレクト回数
856: '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"
857: ]
858: ];
859: $context = stream_context_create($options);
860: if (($infp = @fopen($url, 'r', FALSE, $context)) == FALSE) return NULL;
861: while (! feof($infp)) {
862: $contents .= fread($infp, 5000);
863: }
864: fclose($infp);
865:
866: // 文字化け対策:読み込んだコンテンツをUTF-8に変換
867: $contents = mb_convert_encoding($contents, self::INTERNAL_ENCODING, 'auto');
868:
869: // コンテンツからOGP情報を抽出する
870: $pcr = new pahooScraping($contents);
871: $oggImage = $pcr->getValueFistrXPath('//meta[@property="og:image"]', 'content');
872: if ($oggImage !== '') {
873: preg_match('/^[^\?]+/i', $oggImage, $arr);
874: $oggImage = $arr[0];
875: }
876: $oggDescription = $pcr->getValueFistrXPath('//meta[@property="og:description"]', 'content');
877: $oggTitle = $pcr->getValueFistrXPath('//meta[@property="og:title"]', 'content');
878: $pcr = NULL;
879:
880: // OGP情報がない
881: if (($oggDescription === '') || ($oggTitle === '')) {
882: return NULL;
883: }
884:
885: // embedに成形する
886: $mimeType = '';
887: $fileSize = 0;
888: // 画像がある場合
889: if ($oggImage !== '') {
890: $image = $this->uploadBlob($oggImage, $width, $height, self::MAX_IMAGE_WIDTH, self::MAX_IMAGE_HEIGHT, TRUE);
891: if ($image == FALSE) return NULL;
892: } else {
893: $image = '';
894: }
895: $embed = [
896: 'embed' => [
897: '$type' => 'app.bsky.embed.external',
898: 'external' => [
899: 'uri' => $url,
900: 'title' => $oggTitle,
901: 'description' => $oggDescription,
902: ]
903: ]
904: ];
905: // 画像がある場合
906: if ($image !== '') {
907: $embed['embed']['external']['thumb'] = $image;
908: }
909:
910: return $embed;
911: }
Twitter(現・X) APIは、メッセージにURLを記載するだけで、Twitterボットが OGP情報 を探して非同期にアップロードするが、Bluesky API ではクライアント側でアップロードしてやる必要がある。Bluesky は分散型SNSであるため、ボットに複雑な作業をさせないよう、クライアント側で処理する仕様になっているものと思われる。そのおかげで、後述するように、引用投稿にOGP情報や画像を含めるという、Twitter(現・X) で実装されていない投稿を可能にしている。
解説:ユーザーのDIDを取得する
ユーザーの DID は、ユーザー・プロファイル情報を取得するエンドポイントは app.bsky.actor.getProfile だ。
| URL (public) |
|---|
| https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={ユーザー名} |
| URL (認証必要) |
| https://{PDSドメイン}/xrpc/app.bsky.actor.getProfile?actor={ユーザー名} |
目的とするユーザーDIDは、上述のレスポンスの 1項目に過ぎないので、まず、ユーザー名を与えてエンドポイント app.bsky.actor.getProfile を呼び出すメソッド getProfile を作成し、得られたレスポンスからユーザーDIDだけを返すメソッド getDID の2つを用意した。
pahooBlueskyAPI.php
913: /**
914: * ユーザー・プロファイル情報を取得する
915: * @param string $name ユーザーのアカウント名
916: * @return array ユーザー・プロファイル情報 / FALSE:取得失敗
917: */
918: function getProfile($name) {
919: // トークンを取得する.
920: $this->getValidToken();
921:
922: // リクエストURL (public)
923: $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile';
924: // リクエストURL (認証必要)
925: // $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.actor.getProfile';
926: $this->webapi = $requestURL;
927:
928: // cURLを使ったリクエスト
929: $ch = curl_init();
930: curl_setopt($ch, CURLOPT_URL, $requestURL . '?actor=' . $name);
931: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
932: curl_setopt($ch, CURLOPT_HTTPHEADER, [
933: 'Content-Type: application/json',
934: 'Authorization: Bearer ' . $this->accessJwt,
935: ]);
936: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
937: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
938: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
939:
940: // レスポンス処理
941: $response = curl_exec($ch);
942: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
943: if (PHP_VERSION_ID < 80500) {
944: curl_close($ch);
945: }
946: $items = json_decode($response, TRUE);
947: if ($httpStatusCode != 200) {
948: $errmsg = 'ユーザー・プロファイル情報をを取得できません(http code:' . $httpStatusCode . ')';
949: if (isset($items['message'])) {
950: $errmsg .= ';' . $items['message'];
951: }
952: $this->seterror($errmsg);
953: return FALSE;
954: }
955:
956: return $items;
957: }
pahooBlueskyAPI.php
959: /**
960: * ユーザーのDIDを取得する
961: * @param string $name ユーザーのアカウント名
962: * @return string ユーザーのDID / FALSE:取得失敗
963: */
964: function getDID($name) {
965: $userProfiles = $this->getProfile($name);
966:
967: if ($userProfiles == FALSE) {
968: return FALSE;
969: } else if (! isset($userProfiles['did'])) {
970: $this->seterror('ユーザーのDIDを取得できません)');
971: return FALSE;
972: } else {
973: return $userProfiles['did'];
974: }
975: }
解説:ルートIDと親IDを取得する
| URL (public) |
|---|
| https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri={atURI} |
| URL (認証必要) |
| https://{PDSドメイン}/xrpc/app.bsky.feed.getPostThread?uri={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 を使って取得する。
一方、スレッドになっている場合は、下図のようなレスポンスが返る。こちらも抜粋になる。
そこで、返信/引用元メッセージのURLを与え、ここから atURI を生成し、エンドポイント app.bsky.feed.getPostThread を呼び出すメソッド getPostThread を作成し、得られたルートID と親IDの2つを返すメソッド getRootParentID の2つを用意した。
pahooBlueskyAPI.php
977: /**
978: * メッセージURLからスレッド情報を取得する
979: * @param string $url メッセージURL
980: * @return array スレッド情報 / FALSE:取得失敗
981: */
982: function getPostThread($url) {
983: // ユーザー名、投稿IDを取得する
984: if (preg_match('/\/profile\/([^\/]+)\/post\/([0-9a-zA-Z]+)/ui', $url, $arr) == 0) {
985: $this->seterror($url . 'は投稿URLではありません');
986: return FALSE;
987: }
988: if (count($arr) < 3) {
989: $this->seterror($url . '投稿URLではありません');
990: return FALSE;
991: }
992: $userName = $arr[1];
993: $postID = $arr[2];
994:
995: // ユーザーDIDを取得する
996: $userDID = $this->getDID($userName);
997: if ($userDID == FALSE) {
998: $this->seterror($url . 'はユーザーDIDを取得できません');
999: return FALSE;
1000: }
1001:
1002: // トークンを取得する.
1003: $this->getValidToken();
1004:
1005: // AT-URIを生成する
1006: $atURI = 'at://' . $userDID . '/app.bsky.feed.post/' . $postID;
1007:
1008: // リクエストURL (public)
1009: // $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread';
1010: // リクエストURL (認証必要)
1011: $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.feed.getPostThread';
1012: $ch = curl_init($requestURL . '?uri=' . urlencode($atURI));
1013: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
1014: curl_setopt($ch, CURLOPT_HTTPHEADER, [
1015: 'Content-Type: application/json',
1016: 'Authorization: Bearer ' . $this->accessJwt,
1017: ]);
1018: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
1019: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
1020: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
1021:
1022: // レスポンス処理
1023: $response = curl_exec($ch);
1024: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
1025: if (PHP_VERSION_ID < 80500) {
1026: curl_close($ch);
1027: }
1028: $items = json_decode($response, TRUE);
1029: if ($httpStatusCode != 200) {
1030: $errmsg = 'ルートID,親IDを取得できません(http code:' . $httpStatusCode . ')';
1031: if (isset($items['message'])) {
1032: $errmsg .= ';' . $items['message'];
1033: }
1034: $this->seterror($errmsg);
1035: return FALSE;
1036: }
1037:
1038: return $items;
1039: }
pahooBlueskyAPI.php
1041: /**
1042: * メッセージURLからルートIDと親IDを取得する
1043: * @param string $url メッセージURL
1044: * @return array(ルートID, 親の投稿ID) / FALSE:取得失敗
1045: */
1046: function getRootParentID($url) {
1047: // スレッド情報を取得する
1048: $items = $this->getPostThread($url);
1049: if ($items == FALSE) return FALSE;
1050:
1051: // ルートIDを取得する
1052: // スレッドがあればrootを取得する
1053: if (isset($items['thread']['post']['record']['reply']['root'])) {
1054: $rootID = $items['thread']['post']['record']['reply']['root'];
1055: // スレッドがなければ投稿IDを取得する
1056: } else if (isset($items['thread']['post']['cid'])) {
1057: $rootID = array(
1058: 'cid' => $items['thread']['post']['cid'],
1059: 'uri' => $items['thread']['post']['uri']
1060: );
1061: } else {
1062: $this->seterror('ルートIDを取得できません');
1063: return FALSE;
1064: }
1065: // 親IDを取得する(常に投稿ID)
1066: if (isset($items['thread']['post']['cid'])) {
1067: $parentID = array(
1068: 'cid' => $items['thread']['post']['cid'],
1069: 'uri' => $items['thread']['post']['uri']
1070: );
1071: } else {
1072: $this->seterror('親IDを取得できません');
1073: return FALSE;
1074: }
1075:
1076: return array($rootID, $parentID);
1077: }
解説:メッセージ投稿
| URL |
|---|
| https://{PDSドメイン}/xrpc/com.atproto.repo.createRecord |
pahooBlueskyAPI.php
1079: /**
1080: * メッセージを投稿する.
1081: * リンクが含まれている場合は自動的にハイパーリンクに変換する.
1082: * 画像へのリンクが含まれている場合は自動的にアップロードする(4個まで).
1083: * @param string $message 投稿メッセージ(UTF-8限定)
1084: * @param bool $flagCard FALSE:カード形式で投稿しない(省略時)
1085: * TRUE:OOGP情報がある最初のリンクをカード形式で投稿する
1086: * @param string $replyURL NULL:返信しない(省略時)/返信する投稿URL
1087: * @param string $quoteURL NULL:引用しない(省略時)/引用する投稿URL
1088: * @param array $media NULL:使用しない(省略時)/画像データ配列
1089: * @return string メッセージURL/FALSE:失敗
1090: */
1091: function post($message, $flagCard=FALSE, $replyURL=NULL, $quoteURL=NULL, $media=NULL) {
1092: // エラーメッセージ・クリア
1093: $this->clearerror();
1094:
1095: // 初期化
1096: $embed = NULL;
1097: $images = array();
1098: $urls = array();
1099: $reply = array();
1100: $height = $width = 0;
1101:
1102: // 返信の場合
1103: if ($replyURL != NULL) {
1104: $res = $this->getRootParentID($replyURL);
1105: if (! $res) {
1106: return FALSE;
1107: }
1108: $rootID = $res[0];
1109: $parentID = $res[1];
1110: $reply = [
1111: 'reply' => [
1112: 'root' => $rootID,
1113: 'parent' => $parentID,
1114: ]
1115: ];
1116: }
1117:
1118: // メッセージ中から画像へのリンクを抽出する
1119: $message = $this->extractMediaURL($message, $urls);
1120:
1121: // 画像投稿を行う
1122: if ($media != NULL) {
1123: $cnt = 1;
1124: foreach ($media as $data) {
1125: $tmpname = $this->saveTempFile($data);
1126: $image = $this->uploadBlob($tmpname, $width, $height, self::MAX_IMAGE_WIDTH, self::MAX_IMAGE_HEIGHT, FALSE);
1127: unlink($tmpname);
1128: $images = array_merge($images, [['alt' => '', 'image' => $image]]);
1129: $cnt++;
1130: if ($cnt > 4) break;
1131: }
1132: $embed = [
1133: 'embed' => [
1134: '$type' => 'app.bsky.embed.images',
1135: 'images' => $images,
1136: 'aspectRatio' => [
1137: 'width' => $width,
1138: 'height' => $height
1139: ]
1140: ]
1141: ];
1142: // メッセージ中に画像URL等が含まれている場合
1143: } else if (($embed == NULL) && (count($urls) > 0)) {
1144: $cnt = 1;
1145: foreach ($urls as $filename) {
1146: // 画像アップロード(必要に応じてリサイズ)
1147: $image = $this->uploadBlob($filename, $width, $height, self::MAX_IMAGE_WIDTH, self::MAX_IMAGE_HEIGHT, FALSE);
1148: $images = array_merge($images, [['alt' => '', 'image' => $image,
1149: 'aspectRatio' => [ 'width' => $width, 'height' => $height ]]]);
1150: $cnt++;
1151: if ($cnt > 4) break;
1152: }
1153: $embed = [
1154: 'embed' => [
1155: '$type' => 'app.bsky.embed.images',
1156: 'images' => $images,
1157: 'aspectRatio' => [
1158: 'width' => $width,
1159: 'height' => $height
1160: ]
1161: ]
1162: ];
1163: // OGP情報を取得する
1164: } else if ($flagCard) {
1165: if (preg_match_all('/https?\:\/\/[^\s]+/', $message, $arr) > 0) {
1166: foreach ($arr[0] as $url) {
1167: $embed = $this->getOGPInformation($url);
1168: if ($embed != NULL) {
1169: break;
1170: }
1171: }
1172: }
1173: }
1174:
1175: // 引用処理
1176: if ($quoteURL != NULL) {
1177: $res = $this->getRootParentID($quoteURL);
1178: if (! $res) {
1179: return FALSE;
1180: }
1181: $parentID = $res[1];
1182: // 画像やOGP情報がある場合
1183: if ($embed !== NULL) {
1184: $embed = [
1185: 'embed' => [
1186: '$type' => 'app.bsky.embed.recordWithMedia',
1187: 'media' => $embed['embed'],
1188: 'record' => [
1189: '$type' => 'app.bsky.embed.record',
1190: 'record' => $parentID,
1191: ],
1192: ]
1193: ];
1194: } else {
1195: $embed = [
1196: 'embed' => [
1197: '$type' => 'app.bsky.embed.record',
1198: 'record' => $parentID,
1199: ]
1200: ];
1201: }
1202: }
1203:
1204: // URLやハッシュ情報の取得
1205: $facets = $this->parseRichText($message);
1206:
1207: // POSTデータ配列を作成する
1208: $records = [
1209: '$type' => 'app.bsky.feed.post',
1210: 'text' => $message,
1211: 'createdAt' => (new DateTime())->format('c'),
1212: ];
1213: if ($replyURL == NULL) {
1214: if ($embed == NULL) {
1215: $records = array_merge($records, $facets);
1216: } else {
1217: $records = array_merge($records, $facets, $embed);
1218: }
1219: } else {
1220: if ($embed == NULL) {
1221: $records = array_merge($records, $facets, $reply);
1222: } else {
1223: $records = array_merge($records, $facets, $reply, $embed);
1224: }
1225: }
1226:
1227: // トークンを取得する.
1228: $this->getValidToken();
1229: // var_dump($this->accessJwt);
1230:
1231: // リクエストURL
1232: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.createRecord';
1233: $this->webapi = $requestURL;
1234: // cURLを使ったリクエスト
1235: $ch = curl_init($requestURL);
1236: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
1237: curl_setopt($ch, CURLOPT_HTTPHEADER, [
1238: 'Content-Type: application/json',
1239: 'Authorization: Bearer ' . $this->accessJwt,
1240: ]);
1241: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
1242: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
1243: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
1244: curl_setopt($ch, CURLOPT_POST, TRUE);
1245: curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
1246: 'repo' => $this->BLUESKY_HANDLE,
1247: 'collection' => 'app.bsky.feed.post',
1248: 'record' => $records,
1249: ]));
1250:
1251: // レスポンス処理
1252: $response = curl_exec($ch);
1253: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
1254: if (PHP_VERSION_ID < 80500) {
1255: curl_close($ch);
1256: }
1257: $items = json_decode($response, TRUE);
1258: if ($httpStatusCode != 200) {
1259: $errmsg = '投稿できません(http code:' . $httpStatusCode . ')';
1260: if (isset($items['message'])) {
1261: $errmsg .= ';' . $items['message'];
1262: }
1263: $this->seterror($errmsg);
1264: return FALSE;
1265: }
1266:
1267: // エラーチェックとリターン
1268: if (isset($items['uri'])) {
1269: if (preg_match('/\/([0-9a-zA-Z]+)$/ui', $items['uri'], $arr) > 0) {
1270: $url = 'https://bsky.app/profile/' . $this->BLUESKY_HANDLE . '/post/' . $arr[1];
1271: } else {
1272: $url = '';
1273: }
1274: return $url;
1275: } else if (isset($items['error'])) {
1276: $this->seterror($items['message']);
1277: return FALSE;
1278: } else {
1279: $this->seterror('投稿できません(応答不正)');
1280: return FALSE;
1281: }
1282: }
解説:メイン・プログラム
postBluesky.php
postBluesky.php
673: // メイン・プログラム =======================================================
674: // パラメータを取得する.
675: $msg = trim((string)getParam('msg', TRUE, DEF_MESSAGE));
676: $replyURL = trim((string)getParam('replyURL', FALSE, NULL));
677: if ($replyURL == '') $replyURL = '';
678: $quoteURL = trim((string)getParam('quoteURL', FALSE, NULL));
679: if ($quoteURL == '') $quoteURL = '';
680: $reply = isButton('reply') ? TRUE : FALSE;
681: $outmsg = '';
682: $createSessionFlag = isButton('newsession') ? TRUE : FALSE;
683:
684: // インスタンスを生成する.
685: $pbs = new pahooBlueskyAPI('bsky.social', $createSessionFlag);
686:
687: // 投稿
688: if (isButton('exec')) {
689: // XSS対策
690: $msg = htmlspecialchars($msg);
691:
692: // 画像データがあればメッセージに追加
693: $saveFileNames = array();
694: saveImage($saveFileNames);
695: foreach ($saveFileNames as $fname) {
696: $imageURI = 'file:///' . preg_replace('/\\\/ui', '/', $fname);
697: $msg .= ' ' . $imageURI;
698: }
699:
700: // 投稿
701: if ($res) {
702: $res = $pbs->post(htmlspecialchars_decode($msg), TRUE, $replyURL, $quoteURL);
703: }
704: // エラー処理
705: if ($res == FALSE) {
706: $outmsg = '<p style="color:red;">エラー:' . $pbs->geterror() . '</p>';
707: } else {
708: $outmsg = '<p style="color:blue;">投稿成功:<a href="' . $res . '">' . $res . '</a></p>';
709: // 返信URLに代入する.
710: if ($reply) {
711: $replyURL = $res;
712: }
713: // エラー処理
714: if ($res == FALSE) {
715: $outmsg .= '<p style="color:red;">エラー:' . $pbs->geterror() . '</p>';
716: }
717: }
718:
719: // 画像ファイルを削除
720: deleteImage($saveFileNames);
721:
722: // クリア
723: } else if (isButton('clear')) {
724: $msg = $replyURL = $quoteURL = '';
725: $reply = FALSE;
726: }
727:
728: // 表示HTMLを作成する.
729: $HtmlBody = makeCommonBody($msg, $replyURL, $quoteURL, $reply, $createSessionFlag, $outmsg, $pbs->webapi);
730:
731: // 画面に表示する.
732: echo $HtmlHeader;
733: echo $HtmlBody;
734: echo $HtmlFooter;
735:
736: // インスタンスを解放する.
737: $pbs = NULL;
textareaに入力されたメッセージを取りだし、 htmlspecialchars 関数でXSS対策を行った後、セッション開始、メッセージ投稿、応答メッセージからエラー処理を行う。このとき正常応答が帰ってきたら、メッセージのURLを表示するようにする。最後にセッション終了する。
また、このプログラムはコマンドライン・パラメータとして、msg(投稿メッセージ)、replyURL(返信元URL)、quoteURL(引用元URL)を指定して呼び出すことができるので、JavaScriptと組み合わせてコンテンツ中に Bluesky への投稿処理を組み込むことができるだろう。
参考サイト
- Bluesky 公式リファレンス
- Bluesky API - 各種クラウド連携サービス(WebAPI)の登録方法
- PHPでDOMDocumentを使ってスクレイピング:ぱふぅ家のホームページ
- PHPでTwitter(現・X)に投稿(ツイート)する:ぱふぅ家のホームページ

API操作はクラスファイルに分離し、他のプログラムから利用しやすいようにした。当サイト以外が配布しているプログラムやライブラリは不要だ。