
(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 の https対応
- 解説:pahooBlueskyAPIクラス
- 解説:API認証とセッション
- 解説:新規セッション開始
- 解説:セッションをリフレッシュ
- 解説:セッション終了
- 解説:投稿用URLやハッシュタグ情報を取得
- 解説:画像データの扱い
- 解説:投稿メッセージから画像URLを抽出
- 解説:画像をアップロード
- 解説:画像を指定幅・高さに収まるように拡大・縮小する
- 解説:透明背景を白色で塗りつぶす
- 解説:OGP情報を取得
- 解説:ユーザーのDIDを取得する
- 解説:ルートIDと親IDを取得する
- 解説:メッセージ投稿
- 解説:メイン・プログラム
- 参考サイト
サンプル・プログラム
postBluesky.php | サンプル・プログラム本体 |
pahooBlueskyAPI.php | Bluesky APIに関わるクラス pahooBlueskyAPI。 使い方は「PHPでPHPでBlueskyに投稿する」などを参照。include_path が通ったディレクトリに配置すること。 |
pahooScraping.php | スクレイピング処理に関わるクラス pahooScraping。 スクレイピング処理に関わるクラスの使い方は「PHPでDOMDocumentを使ってスクレイピング」を参照。include_path が通ったディレクトリに配置すること。 |
pahooInputData.php | データ入力に関わる関数群。 使い方は「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。 |
バージョン | 更新日 | 内容 |
---|---|---|
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代入機能を追加 |
バージョン | 更新日 | 内容 |
---|---|---|
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かどうかに関わらず画像が指定サイズに収めるよう拡大・縮小する仕様に変更 |
バージョン | 更新日 | 内容 |
---|---|---|
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 | 初版 |
バージョン | 更新日 | 内容 |
---|---|---|
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対応


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

これで準備は完了だ。
解説:pahooBlueskyAPIクラス

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.social が PDSドメインである。
BlueskyAPI は、機能ごとにエンドポイントが用意されており、API呼び出しURLは htttps://{PDSドメイン}/xrpc/{エンドポント} となる。

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

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

基本的な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: }
アクセストークン accessJwt は英数字からなる文字列だが、ドット [.;blue] で3つのブロックに区切られており、左から2番目のブロックには有効期限が Base64形式でエンコードされている。この値を現在時刻 time と比較し、有効期限が過ぎていれば後述するメソッド refreshSession を使ってアクセストークンの有効期間を延長(リフレッショ)する。
解説:新規セッション開始
ここで取得したアクセストークンは再利用するため、定数 FILENAME_TOKEN で示すファイルの1行目にアクセストークン accessJwt を、2行目にリフレッシュトークン refreshJwt を保存する。
URL |
---|
https://{PDSドメイン}/xrpc/com.atproto.server.createSession |
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 = '***************'; // アプリケーション・パスワード
上述の手順で取得したアプリケーション・パスワードをプロパティ変数 $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: }
2番目の引数 $newSession を TRUE にすると、インスタンス生成時に必ず 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: }

メソッドの中身は、上述のAPI仕様の通りに作った。
POSTプロトコルとして、これまでのクラウドサービス利用でも使ってきた cURL関数を利用する。
解説:セッションをリフレッシュ
ここで取得したアクセストークンは再利用するため、定数 FILENAME_TOKEN で示すファイルの1行目にアクセストークン accessJwt を、2行目にリフレッシュトークン refreshJwt を保存する。
URL |
---|
https://{PDSドメイン}/xrpc/com.atproto.server.refreshSession |
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: }
解説:セッション終了
URL |
---|
https://{PDSドメイン}/xrpc/com.atproto.server.deleteSession |
アクセストークン 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: }
解説:投稿用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[0] as $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[0] as $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: }
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: }
このように、クライアント側で用意するデータ構造によって、URLとハッシュタグを同列のハイパーリンクとして扱う Bluesky の設計には感心させられた。ハッシュタグの方はリンク先情報を渡さないが、実際には Bluesky の検索URLにハイパーリンクする。将来的に検索機能も分散方式になった場合でも対応が容易であり、じつに拡張性のある設計だ。
解説:画像データの扱い
- メッセージ中に画像URLを記述する。
- 画像ファイルをコピー&ペーストする。
- 画像ファイルをドラッグ&ドロップする。

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[0] as $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[1] as $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: }

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

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

戻り値は、引数 $str から $urls に格納した画像URLやローカルファイル名を除いた残りのテキスト文字列である。
解説:画像をアップロード
URL |
---|
https://{PDSドメイン}/xrpc/com.atproto.repo.uploadBlob |
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: }
ここで、画像の最大幅を超えた場合は後述する reductImageを呼び出し、自動的に最大幅・最大高に縮小する。なお、引数の画像の最大幅は省略可能で、省略時には冒頭の定数に定義する MAX_IMAGE_WIDTH を代入する。

メソッドの中身は、上述のAPI仕様の通りに作った。
画像ファイルは、組み込み関数 file_get_contents を使って変数 $imageData に格納する。画像のMIMEタイプを判定するのに、finfoクラスを利用した。
解説:画像を指定幅・高さに収まるように拡大・縮小する
また、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: }

まず、引数として渡された $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: }

GD関数群にある imagejpeg 関数などを使い、画面に出力される画像データ(バイナリ)を、 ob_start 関数を使って横取りすることで変数に格納する。
画像フォーマットに応じて変換するGD関数を選択し、最後に ob_get_clean 関数を使って変数に格納する。
解説:透明背景を白色で塗りつぶす
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: }
解説: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: }
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
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を取得する
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
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: }
解説:メッセージ投稿
URL |
---|
https://{PDSドメイン}/xrpc/com.atproto.repo.createRecord |

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[0] as $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;

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

また、このプログラムはコマンドライン・パラメータとして、msg(投稿メッセージ)、replyURL(返信元URL)、quoteURL(引用元URL)を指定して呼び出すことができるので、JavaScriptと組み合わせてコンテンツ中に Bluesky への投稿処理を組み込むことができるだろう。
参考サイト
- Bluesky 公式リファレンス
- Bluesky API - 各種クラウド連携サービス(WebAPI)の登録方法
- PHPでDOMDocumentを使ってスクレイピング:ぱふぅ家のホームページ
- PHPでTwitter(現・X)に投稿(ツイート)する:ぱふぅ家のホームページ
API操作はクラスファイルに分離し、他のプログラムから利用しやすいようにした。当サイト以外が配布しているプログラムやライブラリは不要だ。