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

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