(2024年10月18日)必要に応じて画像の縦方向も縮小するようにした
(2024年10月13日)必要に応じて画像を縮小するようにした
サンプル・プログラムの実行例
目次
サンプル・プログラム
postBluesky.php | サンプル・プログラム本体 |
pahooBlueskyAPI.php | Bluesky APIに関わるクラス pahooBlueskyAPI。 使い方は「PHPでPHPでBlueskyに投稿する」などを参照。include_path が通ったディレクトリに配置すること。 |
pahooScraping.php | RSS処理に関わるクラス pahooScraping。 スクレイピング処理に関わるクラスの使い方は「PHPでDOMDocumentを使ってスクレイピング」を参照。include_path が通ったディレクトリに配置すること。 |
pahooInputData.php | データ入力に関わる関数群。 使い方は「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。 |
バージョン | 更新日 | 内容 |
---|---|---|
1.1.0 | 2024/10/20 | 返信,引用ができるよう機能追加 |
1.0.0 | 2024/10/01 | 初版 |
バージョン | 更新日 | 内容 |
---|---|---|
1.3.1 | 2024/10/20 | post() -- 返信,引用の引数仕様変更 |
1.3.0 | 2024/10/20 | getProfile, getDID, getRootParentID追加 |
1.2.2 | 2024/10/20 | deleteSession() -- debug |
1.2.1 | 2024/10/18 | 必要に応じて画像の縦方向も縮小するようにした |
1.2.0 | 2024/10/13 | 必要に応じて画像を縮小するようにした |
バージョン | 更新日 | 内容 |
---|---|---|
1.0.1 | 2023/09/29 | __construct() -- bug-fix |
1.0.0 | 2023/09/18 | 初版 |
バージョン | 更新日 | 内容 |
---|---|---|
1.7.0 | 2024/10/09 | validURL() validEmail() 追加 |
1.6.0 | 2024/10/07 | isButton() -- buttonタグに対応 |
1.5.0 | 2024/01/28 | exitIfExceedVersion() 追加 |
1.4.2 | 2024/01/28 | exitIfLessVersion() メッセージ修正 |
1.4.1 | 2023/09/30 | コメントの訂正 |
準備:pahooBlueskyAPI クラス
pahooBlueskyAPI.php
15: class pahooBlueskyAPI {
16: var $pds; // PDSドメイン
17: var $webapi; // 直前に呼び出したWebAPI URL
18: var $errmsg; // エラーメッセージ
19: var $accessJwt; // accessJwt
20:
21: const MAX_MESSAGE_LEN = 300; // 投稿可能なメッセージ文字数
22: const URL_LEN = 30; // メッセージ中のURL文字数(相当)
23: const MAX_IMAGE_WIDTH = 1200; // 投稿可能な最大画像幅(ピクセル)
24: const MAX_IMAGE_HEIGHT = 900; // 投稿可能な最大画像高さ(ピクセル)
25: // これより大きいときは自動縮小する
26:
27: // Bluesky API アプリパスワード
事前にアプリパスワードを取得する必要があり、その方法は「Bluesky API - アプリパスワードの取得方法」を参照されたい。入手したアプリパスワードを変数 $BLUESKY_PASSWORD に、あなたのハンドル名(???.bsky.social など)を変数 $BLUESKY_HANDLE に代入しておくこと。
準備:PHP の https対応
Windowsでは、"php.ini" の下記の行を有効化する。
extension=php_openssl.dllLinuxでは --with-openssl=/usr オプションを付けて再ビルドする。→OpenSSLインストール手順
これで準備は完了だ。
解説:pahooBlueskyAPIクラス
基本的に、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 から別のメソッドを呼び出して投稿に必要な追加情報を取得する形にした。
解説:セッション開始
URL |
---|
https://{PDSドメイン}/xrpc/com.atproto.server.createSession |
アクセストークン refreshJwt は、アクセストークンの再発行や、セッションの終了・破棄に用いることができ、寿命は数十日と長い。リフレッシュトークン refreshJwt をストレージに保存しておき、次回はアクセストークンを再発行するというのが BlueskyAPI の望ましい運用方法と思われるが、リフレッシュトークン refreshJwt だけでアクセストークン accessJwt を再発行できてしまうので、流出するとたいへん危険である。
今回つくるプログラムは、単発でメッセージや画像を投稿するものなので、リフレッシュトークン refreshJwt は使わず、プログラム起動時にアクセストークン accessJwt を取得するようにする。
pahooBlueskyAPI.php
11: //スクレイピング処理に関わるクラス:include_pathが通ったディレクトリに配置
12: require_once('pahooScraping.php');
13:
14: // Bluesky API クラス =======================================================
15: class pahooBlueskyAPI {
16: var $pds; // PDSドメイン
17: var $webapi; // 直前に呼び出したWebAPI URL
18: var $errmsg; // エラーメッセージ
19: var $accessJwt; // accessJwt
20:
21: const MAX_MESSAGE_LEN = 300; // 投稿可能なメッセージ文字数
22: const URL_LEN = 30; // メッセージ中のURL文字数(相当)
23: const MAX_IMAGE_WIDTH = 1200; // 投稿可能な最大画像幅(ピクセル)
24: const MAX_IMAGE_HEIGHT = 900; // 投稿可能な最大画像高さ(ピクセル)
25: // これより大きいときは自動縮小する
26:
27: // Bluesky API アプリパスワード
28: // https://bsky.app/
29: var $BLUESKY_HANDLE = '***************'; // ハンドル名
30: var $BLUESKY_PASSWORD = '***************'; // アプリケーション・パスワード
上述の手順で取得したアプリケーション・パスワードをプロパティ変数 $BLUESKY_PASSWORD に、あなたのハンドル名を $BLUESKY_HANDLE に代入する。
投稿可能な最大文字数は定数 MAX_MESSAGE_LEN として用意した。現在の仕様では300文字だ。
pahooBlueskyAPI.php
32: /**
33: * コンストラクタ
34: * @param string $pds PDSドメイン
35: * @return なし
36: */
37: function __construct($pds) {
38: $this->pds = $pds;
39: $this->webapi = '';
40: $this->errmsg = '';
41: $this->accessJwt = '';
42: }
pahooBlueskyAPI.php
249: /**
250: * セッション開始する.
251: * @param なし
252: * @return bool TRUE:成功/FALSE:失敗
253: */
254: function createSession() {
255: //エラーメッセージ・クリア
256: $this->clearerror();
257:
258: // リクエストURL
259: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.createSession';
260: $this->webapi = $requestURL;
261: $ch = curl_init($requestURL);
262: // cURLを使ったリクエスト
263: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
264: curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
265: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
266: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキップ
267: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
268: curl_setopt($ch, CURLOPT_POST, TRUE);
269: curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
270: 'identifier' => $this->BLUESKY_HANDLE,
271: 'password' => $this->BLUESKY_PASSWORD,
272: ]));
273:
274: // レスポンス処理
275: $response = curl_exec($ch);
276: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
277: if ($httpStatusCode != 200) {
278: $this->seterror('セッション開始できません');
279: return FALSE;
280: }
281: curl_close($ch);
282: $items = json_decode($response, TRUE);
283:
284: // エラーチェックとリターン
285: if (isset($items['accessJwt'])) {
286: $this->accessJwt = (string)$items['accessJwt'];
287: return TRUE;
288: } else if (isset($items['error'])) {
289: $this->seterror($items['message']);
290: return FALSE;
291: } else {
292: $this->seterror('セッション開始できません');
293: return FALSE;
294: }
295: }
メソッドの中身は、上述のAPI仕様の通りに作った。
POSTプロトコルとして、これまでのクラウドサービス利用でも使ってきた cURL関数を利用する。
解説:セッション終了
URL |
---|
https://{PDSドメイン}/xrpc/com.atproto.server.deleteSession |
アクセストークン accessJwt の盗用を避ける意味で、セッションを開始したら、かならずセッション終了するようにしよう。
APIの戻り値は、httpステータスが200であれば成功、それ以外であればエラー情報が戻る。
pahooBlueskyAPI.php
297: /**
298: * セッション終了する.
299: * @param なし
300: * @return bool TRUE:成功/FALSE:失敗
301: */
302: function deleteSession() {
303: // リクエストURL
304: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.deleteSession';
305: $this->webapi = $requestURL;
306: $ch = curl_init($requestURL);
307: // cURLを使ったリクエスト
308: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
309: curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $this->accessJwt]);
310: curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
311: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
312: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // サーバ証明書検証をスキップ
313: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
314:
315: // レスポンス処理
316: $response = curl_exec($ch);
317: if (curl_errno($ch)) {
318: $this->seterror('セッション終了できません' . curl_error($ch));
319: return FALSE;
320: }
321: curl_close($ch);
322: $this->accessJwt = '';
323: return TRUE;
324: }
解説:投稿用URL情報を取得
pahooBlueskyAPI.php
177: /**
178: * 投稿用URL情報を取得する.
179: * @param string $text テキスト
180: * @return Array 投稿用URL情報
181: */
182: function parseURLs($text) {
183: $urls = $this->getURLs($text);
184: $links = array();
185: if (! empty($urls)) {
186: foreach ($urls as $url) {
187: $a = [
188: 'index' => [
189: 'byteStart' => $url['start'],
190: 'byteEnd' => $url['end'],
191: ],
192: 'features' => [
193: [
194: '$type' => 'app.bsky.richtext.facet#link',
195: 'uri' => $url['url'],
196: ],
197: ],
198: ];
199: $links[] = $a;
200: }
201: $links = [
202: 'facets' => $links,
203: ];
204: }
205: return $links;
206: }
メッセージ文字列中からURLを取りだし、必要な情報を用意するメソッドが parseURLs である。複数のURLにも対応している。
メッセージ中のURLの開始位置と終了位置、リンク先URL等を配列に格納して返す。後述する通り、エンドポイントに送信する際にJSONデータに追加する。
解説:メディアをアップロード
URL |
---|
https://{PDSドメイン}/xrpc/com.atproto.repo.uploadBlob |
pahooBlueskyAPI.php
415: /**
416: * メディアをアップロードする.
417: * 画像ファイルなどを投稿するときに事前に呼び出し,blobデータを投稿する.
418: * @param string $message 投稿メッセージ(UTF-8限定)
419: * @param int $maxWidth アップロードする画像の最大幅(ピクセル)
420: * @return string Blusky PDSのURL/FALSE:アップロード失敗
421: */
422: function uploadBlob($filename, $maxWidth=self::MAX_IMAGE_WIDTH) {
423: $mimeType = '';
424: $fileSize = 0;
425:
426: // エラーメッセージ・クリア
427: $this->clearerror();
428:
429: // メディアを読み込む
430: $imageData = file_get_contents($filename);
431: if ($imageData === FALSE) {
432: $this->seterror($filename . ' の読み込みに失敗しました');
433: return FALSE;
434: }
435: // MIMEタイプを判定する
436: $finfo = new finfo(FILEINFO_MIME_TYPE);
437: $mimeType = (string)$finfo->buffer($imageData);
438: $finfo = NULL;
439:
440: // 必要に応じて画像データを縮小する
441: $imageData = $this->reductImage($imageData, $mimeType, $maxWidth);
442:
443: // リクエストURL
444: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.uploadBlob';
445: $this->webapi = $requestURL;
446: // cURLを使ったリクエスト
447: $ch = curl_init($requestURL);
448: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
449: curl_setopt($ch, CURLOPT_HTTPHEADER, [
450: 'Authorization: Bearer ' . $this->accessJwt,
451: 'Accept: application/json',
452: 'Content-Type: ' . $mimeType,
453: ]);
454: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
455: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキップ
456: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
457: curl_setopt($ch, CURLOPT_POST, TRUE);
458: curl_setopt($ch, CURLOPT_BINARYTRANSFER, TRUE);
459: curl_setopt($ch, CURLOPT_POSTFIELDS, $imageData);
460:
461: // レスポンス処理
462: $response = curl_exec($ch);
463: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
464: if ($httpStatusCode != 200) {
465: $this->seterror('メディアをアップロードできません');
466: return FALSE;
467: }
468: curl_close($ch);
469: $items = json_decode($response, TRUE);
470:
471: // エラーチェックとリターン
472: if (isset($items['blob'])) {
473: return $items['blob'];
474: } else if (isset($items['error'])) {
475: $this->seterror($items['message']);
476: return FALSE;
477: } else {
478: $this->seterror('メディアをアップロードできません');
479: return FALSE;
480: }
481: }
ここで、画像の最大幅を超えた場合は後述する reductImageを呼び出し、自動的に最大幅・最大高に縮小する。なお、引数の画像の最大幅は省略可能で、省略時には冒頭の定数に定義する MAX_IMAGE_WIDTH を代入する。
メソッドの中身は、上述のAPI仕様の通りに作った。
メディアファイルは、組み込み関数 file_get_contents を使って変数 $imageData に格納する。メディアのMIMEタイプを判定するのに、finfoクラスを利用した。
解説:必要に応じて画像を縮小する
pahooBlueskyAPI.php
366: /**
367: * 指定幅より大きい場合,指定幅に収まるよう画像データを縮小する.
368: * @param string $imageData 画像データ(画像ファイルから読み込んだバイナリ)
369: * @param string $mimeType 縮小後の画像のMIMEタイプ
370: * @param int $maxWidth 画像データの最大幅(ピクセル)
371: * @param int $maxHeight 画像データの最大高さ(ピクセル)
372: * @return string 縮小後の画像データ/FALSE 対応していない画像フォーマット
373: */
374: function reductImage($imageData, $mimeType='image/jpeg', $maxWidth=self::MAX_IMAGE_WIDTH, $maxHeight=self::MAX_IMAGE_HEIGHT) {
375: // GD画像データに変換する
376: $imageSource = imagecreatefromstring($imageData);
377: if (! $imageSource) {
378: $this->seterror('画像データを縮小できません');
379: return FALSE;
380: }
381:
382: // 元の画像の幅・高さを取得
383: $width = imagesx($imageSource);
384: $height = imagesy($imageSource);
385: if (($width > $maxWidth) || ($height > $maxHeight)) {
386: if ($width > $maxWidth) {
387: $newWidth = $maxWidth;
388: $newHeight = (int)($height * $maxWidth / $width);
389: } else if ($height > $maxHeight) {
390: $newWidth = $maxWidth;
391: $newHeight = (int)($height * $maxWidth / $width);
392: }
393: $imageResize = imagecreatetruecolor($newWidth, $newHeight);
394: // 透明色の処理(PNGやGIFの場合)
395: imagealphablending($imageResize, FALSE);
396: imagesavealpha($imageResize, TRUE);
397: $transparent = imagecolorallocatealpha($imageResize, 255, 255, 255, 127);
398: imagefilledrectangle($imageResize, 0, 0, $newWidth, $newHeight, $transparent);
399: // 画像をリサイズする
400: imagecopyresampled($imageResize, $imageSource, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
401: // リサイズした画像をバイナリ形式で取得
402: if (preg_match('/\/([a-z]+)/i', $mimeType, $arr) > 0) {
403: $imageFormat = $arr[1];
404: } else {
405: $imageFormat = 'jpeg';
406: }
407: $imageData = $this->image2binary($imageResize, $imageFormat);
408: // メモリ解放
409: imagedestroy($imageResize);
410: }
411:
412: return $imageData;
413: }
縮小するには GD関数群を利用する。
まず、 imagecreatefromstring 関数を使い、引数で渡された画像データ(バイナリデータ)をGD画像データに変換する。次に、 imagesx 関数を使い、画像幅が引数で指定した最大画像幅を超えたら、続く縮小処理に入る。超えていなければ、引数で渡された画像データ(バイナリデータ)をそのまま返す。
縮小処理では、まず、縮小後の幅と高さを計算し、 imagecreatetruecolor 関数を使い、新しいGD画像データを用意する。PNGやGIFの場合の透明職の処理を行う。
imagefilledrectangle 関数を使い、画像を縮小する。
最大画像高についても同様に縮小処理を行う。
縮小したGD画像データを画像データ(バイナリデータ)に変換するには、後述する image2binaryメソッドを用いる。
pahooBlueskyAPI.php
326: /**
327: * GD画像データをバイナリデータに変換する.
328: * @param string $image GD画像データ
329: * @param string $imageFormat 変換する画像フォーマット(jpeg, png, gif)
330: * @return string バイナリデータ/FALSE 対応していない画像フォーマット
331: */
332: function image2binary($image, $imageFormat='jpeg') {
333: // 出力バッファリングを開始
334: ob_start();
335:
336: // 画像フォーマットに応じて変換関数を選択
337: switch ($imageFormat) {
338: case 'jpeg':
339: imagejpeg($image, NULL, 75);
340: break;
341: case 'png':
342: imagepng($image, NULL, 5);
343: break;
344: case 'gif':
345: imagegif($image);
346: break;
347: case 'webp':
348: imagewebp($image, NULL, 75);
349: break;
350: case 'bmp':
351: imagebmp($image);
352: break;
353: case 'avif':
354: imageavif($image, NULL, 50);
355: break;
356: default:
357: return FALSE;
358: }
359:
360: // バッファ内容を取得する
361: $binaryData = ob_get_clean();
362:
363: return $binaryData;
364: }
GD関数群にある imagejpeg 関数などを使い、画面に出力される画像データ(バイナリ)を、 ob_start 関数を使って横取りすることで変数に格納する。
画像フォーマットに応じて変換するGD関数を選択し、最後に ob_get_clean 関数を使って変数に格納する。
解説:OGP情報を取得
OGP情報 とは、HTMLコンテンツのheadタグの中に含まれている次のタグを指す。
<head prefix="og: https://ogp.me/ns#">
<meta property="og:url" content="{コンテンツURL}">
<meta property="og:type" content="article">
<meta property="og:title" content="{コンテンツ・タイトル}">
<meta property="og:description" content="{コンテンツの概要}">
<meta property="og:site_name" content="{サイト名}">
<meta property="og:image" content="{代表画像URL}">
pahooBlueskyAPI.php
483: /**
484: * OGP情報を取得する.
485: * @param string $url 対象コンテンツ
486: * @return array OGP情報(embed形式)/NULL:OGP情報はない
487: */
488: function getOGPInformation($url) {
489: $contents = '';
490: if (($infp = fopen($url, 'r')) == FALSE) return NULL;
491: while (! feof($infp)) {
492: $contents .= fread($infp, 5000);
493: }
494: fclose($infp);
495:
496: // コンテンツからOGP情報を抽出する
497: $pcr = new pahooScraping($contents);
498: $oggImage = $pcr->getValueFistrXPath('//meta[@property="og:image"]', 'content');
499: $oggDescription = $pcr->getValueFistrXPath('//meta[@property="og:description"]', 'content');
500: $oggTitle = $pcr->getValueFistrXPath('//meta[@property="og:title"]', 'content');
501: $pcr = NULL;
502:
503: // OGP情報がない
504: if (($oggImage == '') || ($oggDescription == '') || ($oggTitle == '')) {
505: return NULL;
506: }
507:
508: // embedに成形する
509: $mimeType = '';
510: $fileSize = 0;
511: $image = $this->uploadBlob($oggImage, self::MAX_IMAGE_WIDTH);
512: if ($image == FALSE) return NULL;
513: $embed = [
514: 'embed' => [
515: '$type' => 'app.bsky.embed.external',
516: 'external' => [
517: 'uri' => $url,
518: 'thumb' => $image,
519: 'title' => $oggTitle,
520: 'description' => $oggDescription,
521: ]
522: ]
523: ];
524: return $embed;
525: }
解説:ユーザーの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
527: /**
528: * ユーザー・プロファイル情報を取得する
529: * @param string $name ユーザーのアカウント名
530: * @return array ユーザー・プロファイル情報 / FALSE:取得失敗
531: */
532: function getProfile($name) {
533: // リクエストURL (public)
534: $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile';
535: // リクエストURL (認証必要)
536: // $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.actor.getProfile';
537: $this->webapi = $requestURL;
538:
539: // cURLを使ったリクエスト
540: $ch = curl_init();
541: curl_setopt($ch, CURLOPT_URL, $requestURL . '?actor=' . $name);
542: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
543: curl_setopt($ch, CURLOPT_HTTPHEADER, [
544: 'Content-Type: application/json',
545: 'Authorization: Bearer ' . $this->accessJwt,
546: ]);
547: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
548: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキッ
549: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
550:
551: // レスポンス処理
552: $response = curl_exec($ch);
553: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
554: if ($httpStatusCode != 200) {
555: $this->seterror('ユーザー・プロファイル情報を取得できません(httpステータス異常)');
556: return FALSE;
557: }
558: curl_close($ch);
559: $items = json_decode($response, TRUE);
560:
561: return $items;
562: }
pahooBlueskyAPI.php
564: /**
565: * ユーザーのDIDを取得する
566: * @param string $name ユーザーのアカウント名
567: * @return string ユーザーのDID / FALSE:取得失敗
568: */
569: function getDID($name) {
570: $userProfiles = $this->getProfile($name);
571:
572: if ($userProfiles == FALSE) {
573: return FALSE;
574: } else if (! isset($userProfiles['did'])) {
575: $this->seterror('ユーザーのDIDを取得できません)');
576: return FALSE;
577: } else {
578: return $userProfiles['did'];
579: }
580: }
解説:ルートIDと親IDを取得する
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
583: /**
584: * メッセージURLからスレッド情報を取得する
585: * @param string $url メッセージURL
586: * @return array スレッド情報情報 / FALSE:取得失敗
587: */
588: function getPostThread($url) {
589: // ユーザー名、投稿IDを取得する
590: if (preg_match('/\/profile\/([^\/]+)\/post\/([0-9a-zA-Z]+)/ui', $url, $arr) == 0) {
591: $this->seterror($url . 'は投稿URLではありません');
592: return FALSE;
593: }
594: if (count($arr) < 3) {
595: $this->seterror($url . '投稿URLではありません');
596: return FALSE;
597: }
598: $userName = $arr[1];
599: $postID = $arr[2];
600:
601: // ユーザーDIDを取得する
602: $userDID = $this->getDID($userName);
603: if ($userDID == FALSE) {
604: return FALSE;
605: }
606:
607: // AT-URIを生成する
608: $atURI = 'at://' . $userDID . '/app.bsky.feed.post/' . $postID;
609:
610: // リクエストURL (public)
611: $requestURL = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread';
612: // リクエストURL (認証必要)
613: // $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.feed.getPostThread';
614: $ch = curl_init($requestURL . '?uri=' . urlencode($atURI));
615: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
616: curl_setopt($ch, CURLOPT_HTTPHEADER, [
617: 'Content-Type: application/json',
618: 'Authorization: Bearer ' . $this->accessJwt,
619: ]);
620: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
621: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキッ
622: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
623:
624: // レスポンス処理
625: $response = curl_exec($ch);
626: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
627:
628: if ($httpStatusCode != 200) {
629: $this->seterror('ルートID,親IDを取得できません(httpステータス異常)');
630: return FALSE;
631: }
632: curl_close($ch);
633: $items = json_decode($response, TRUE);
634:
635: return $items;
636: }
pahooBlueskyAPI.php
638: /**
639: * メッセージURLからルートIDと親IDを取得する
640: * @param string $url メッセージURL
641: * @return array(ルートID, 親の投稿ID) / FALSE:取得失敗
642: */
643: function getRootParentID($url) {
644: // スレッド情報を取得する
645: $items = $this->getPostThread($url);
646: if ($items == FALSE) return FALSE;
647: // var_dump($items);
648:
649: // ルートIDを取得する
650: // スレッドがあればrootを取得する
651: if (isset($items['thread']['replies'][0]['post']['record']['reply']['root'])) {
652: $rootID = $items['thread']['replies'][0]['post']['record']['reply']['root'];
653: // スレッドがなければ投稿IDを取得する
654: } else if (isset($items['thread']['post']['cid'])) {
655: $rootID = array(
656: 'cid' => $items['thread']['post']['cid'],
657: 'uri' => $items['thread']['post']['uri']
658: );
659: } else {
660: $this->seterror('ルートIDを取得できません');
661: return FALSE;
662: }
663: // 親IDを取得する(常に投稿ID)
664: if (isset($items['thread']['post']['cid'])) {
665: $parentID = array(
666: 'cid' => $items['thread']['post']['cid'],
667: 'uri' => $items['thread']['post']['uri']
668: );
669: } else {
670: $this->seterror('親IDを取得できません');
671: return FALSE;
672: }
673:
674: return array($rootID, $parentID);
675: }
解説:メッセージ投稿
URL |
---|
https://{PDSドメイン}/xrpc/com.atproto.repo.createRecord |
pahooBlueskyAPI.php
677: /**
678: * メッセージを投稿する.
679: * リンクが含まれている場合は自動的にハイパーリンクに変換する.
680: * メディアへのリンクが含まれている場合は自動的にアップロードする(4個まで).
681: * @param string $message 投稿メッセージ(UTF-8限定)
682: * @param bool $flagCard FALSE:カード形式で投稿しない(省略時)
683: * TRUE:OOGP情報がある最初のリンクをカード形式で投稿する
684: * @param string $replyURL NULL:返信しない(省略時)/返信する投稿URL
685: * @param string $quoteURL NULL:引用しない(省略時)/引用する投稿URL
686: * @param array $media NULL:使用しない(省略時)/メディアデータ配列
687: * @return string メッセージURL/FALSE:失敗
688: */
689: function post($message, $flagCard=FALSE, $replyURL=NULL, $quoteURL=NULL, $media=NULL) {
690: // エラーメッセージ・クリア
691: $this->clearerror();
692:
693: // 初期化
694: $embed = NULL;
695: $images = array();
696: $urls = array();
697: $reply = array();
698:
699: // 返信の場合
700: if ($replyURL != NULL) {
701: $res = $this->getRootParentID($replyURL);
702: if (! $res) {
703: return FALSE;
704: }
705: $rootID = $res[0];
706: $parentID = $res[1];
707: $reply = [
708: 'reply' => [
709: 'root' => $rootID,
710: 'parent' => $parentID,
711: ]
712: ];
713: }
714:
715: // 引用処理
716: if ($quoteURL != NULL) {
717: $res = $this->getRootParentID($quoteURL);
718: if (! $res) {
719: return FALSE;
720: }
721: $parentID = $res[1];
722: $embed = [
723: 'embed' => [
724: '$type' => 'app.bsky.embed.record',
725: 'record' => $parentID,
726: ]
727: ];
728:
729: // 引用以外の処理
730: } else {
731: // メディア・ファイルの処理
732: // メッセージ中からメディアへのリンクを抽出する
733: $message = $this->extractMediaURL($message, $urls);
734:
735: // OGP情報を取得する
736: if ($flagCard) {
737: if (preg_match_all('/https?\:\/\/[^\s]+/', $message, $arr) > 0) {
738: foreach ($arr[0] as $url) {
739: $embed = $this->getOGPInformation($url);
740: if ($embed != NULL) {
741: break;
742: }
743: }
744: }
745: }
746:
747: // 画像投稿を行う(OGP情報がない場合を含む)
748: if ($media != NULL) {
749: $cnt = 1;
750: foreach ($media as $data) {
751: $tmpname = $this->saveTempFile($data);
752: $image = $this->uploadBlob($tmpname, self::MAX_IMAGE_WIDTH);
753: unlink($tmpname);
754: $images = array_merge($images, [['alt' => '', 'image' => $image]]);
755: $cnt++;
756: if ($cnt > 4) break;
757: }
758: $embed = [
759: 'embed' => [
760: '$type' => 'app.bsky.embed.images',
761: 'images' => $images,
762: ]
763: ];
764: } else if (($embed == NULL) && (count($urls) > 0)) {
765: $cnt = 1;
766: foreach ($urls as $filename) {
767: $image = $this->uploadBlob($filename, self::MAX_IMAGE_WIDTH);
768: $images = array_merge($images, [['alt' => '', 'image' => $image]]);
769: $cnt++;
770: if ($cnt > 4) break;
771: }
772: $embed = [
773: 'embed' => [
774: '$type' => 'app.bsky.embed.images',
775: 'images' => $images,
776: ]
777: ];
778: }
779: }
780:
781: // リンク情報の取得
782: $links = $this->parseURLs($message);
783:
784: // POSTデータ配列を作成する
785: $records = [
786: '$type' => 'app.bsky.feed.post',
787: 'text' => $message,
788: 'createdAt' => (new DateTime())->format('c'),
789: ];
790: if ($replyURL == NULL) {
791: if ($embed == NULL) {
792: $records = array_merge($records, $links);
793: } else {
794: $records = array_merge($records, $links, $embed);
795: }
796: } else {
797: if ($embed == NULL) {
798: $records = array_merge($records, $links, $reply);
799: } else {
800: $records = array_merge($records, $links, $reply, $embed);
801: }
802: }
803: // var_dump($records);
804:
805: // リクエストURL
806: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.repo.createRecord';
807: $this->webapi = $requestURL;
808: // cURLを使ったリクエスト
809: $ch = curl_init($requestURL);
810: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
811: curl_setopt($ch, CURLOPT_HTTPHEADER, [
812: 'Content-Type: application/json',
813: 'Authorization: Bearer ' . $this->accessJwt,
814: ]);
815: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
816: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); //サーバ証明書検証をスキップ
817: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // 〃
818: curl_setopt($ch, CURLOPT_POST, TRUE);
819: curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
820: 'repo' => $this->BLUESKY_HANDLE,
821: 'collection' => 'app.bsky.feed.post',
822: 'record' => $records,
823: ]));
824:
825: // レスポンス処理
826: $response = curl_exec($ch);
827: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
828: if ($httpStatusCode != 200) {
829: $this->seterror('投稿できません(httpステータス異常)');
830: return FALSE;
831: }
832: curl_close($ch);
833: $items = json_decode($response, TRUE);
834: // var_dump($items);
835:
836: // エラーチェックとリターン
837: if (isset($items['uri'])) {
838: if (preg_match('/\/([0-9a-zA-Z]+)$/ui', $items['uri'], $arr) > 0) {
839: $url = 'https://bsky.app/profile/' . $this->BLUESKY_HANDLE . '/post/' . $arr[1];
840: } else {
841: $url = '';
842: }
843: return $url;
844: } else if (isset($items['error'])) {
845: $this->seterror($items['message']);
846: return FALSE;
847: } else {
848: $this->seterror('投稿できません(応答不正)');
849: return FALSE;
850: }
851: }
解説:メイン・プログラム
postBluesky.php
34: // データ入力に関わる関数群:include_pathに配置すること
35: require_once('pahooInputData.php');
36:
37: // PHPバージョン・チェック
38: exitIfLessVersion(MINUMUM_VERSION);
39:
40: // リファラチェック+リリースフラグの設定
41: if (isset($_SERVER['HTTP_HOST']) && ($_SERVER['HTTP_HOST'] == 'localhost')) {
42: define('FLAG_RELEASE', FALSE);
43: define('REFER_ON', '');
44: ini_set('display_errors', 1);
45: ini_set('error_reporting', E_ALL);
46: } else {
47: // リリース・フラグ(公開時にはTRUEにすること)
48: define('FLAG_RELEASE', TRUE);
49: // リファラ・チェック(直リン防止用;空文字ならチェックしない)
50: if (! isCommandLine()) {
51: define('REFER_ON', 'www.pahoo.org');
52: } else {
53: define('REFER_ON', '');
54: }
55: }
56:
57: // 表示幅(ピクセル)
58: define('WIDTH', 600);
59:
60: //投稿メッセージ(初期値)
61: define('DEF_MESSAGE', 'PHPでBlueskyにメッセージや画像を投稿するプログラムを作成。カード形式の投稿や返信、引用投稿も可能。API操作はクラスファイルに分離し、他プログラムからも利用可能。他サイト配布以外のプログラムやライブラリは不要。 https://www.pahoo.org/e-soul/webtech/php06/php06-30-01.shtm');
62:
63: // BlueskyAPIクラス:include_pathが通ったディレクトリに配置
postBluesky.php
195: // メイン・プログラム =======================================================
196: //オブジェクトを生成する.
197: $pbs = new pahooBlueskyAPI('bsky.social');
198:
199: //パラメータを取得する.
200: $msg = getParam('msg', TRUE, DEF_MESSAGE);
201: $replyURL = getParam('replyURL', FALSE, NULL);
202: if ($replyURL == '') $replyURL = '';
203: $quoteURL = getParam('quoteURL', FALSE, NULL);
204: if ($quoteURL == '') $quoteURL = '';
205: $outmsg = '';
206:
207: // 投稿
208: if (isButton('exec')) {
209: //XSS対策
210: $msg = htmlspecialchars($msg);
211: //セッション開始
212: $res = $pbs->createSession();
213: //投稿
214: if ($res) {
215: $res = $pbs->post($msg, TRUE, $replyURL, $quoteURL);
216: }
217: //エラー処理
218: if ($res == FALSE) {
219: $outmsg = '<p style="color:red;">エラー:' . $pbs->geterror() . '</p>';
220: } else {
221: $outmsg = '<p style="color:blue;">投稿成功:<a href="' . $res . '">' . $res . '</a></p>';
222: //セッション終了
223: $res = $pbs->deleteSession();
224: //エラー処理
225: if ($res == FALSE) {
226: $outmsg .= '<p style="color:red;">エラー:' . $pbs->geterror() . '</p>';
227: }
228: }
229:
230: // クリア
231: } else if (isButton('clear')) {
232: $msg = $replyURL = $quoteURL = '';
233: }
234:
235: //表示HTMLを作成する.
236: $HtmlBody = makeCommonBody($msg, $replyURL, $quoteURL, $outmsg, $pbs->webapi);
237:
238: //画面に表示する.
239: echo $HtmlHeader;
240: echo $HtmlBody;
241: echo $HtmlFooter;
242:
243: //オブジェクトを解放する.
244: $pbs = NULL;
textareaに入力されたメッセージを取りだし、 htmlspecialchars 関数でXSS対策を行った後、セッション開始、メッセージ投稿、応答メッセージからエラー処理を行う。このとき正常応答が帰ってきたら、メッセージのURLを表示するようにする。最後にセッション終了する。
また、このプログラムはコマンドライン・パラメータとして、msg(投稿メッセージ)、replyURL(返信元URL)、quoteURL(引用元URL)を指定して呼び出すことができるので、JavaScriptと組み合わせてコンテンツ中に Bluesky への投稿処理を組み込むことができるだろう。
参考サイト
- Bluesky 公式リファレンス
- PHPでDOMDocumentを使ってスクレイピング:ぱふぅ家のホームページ
- PHPでTwitter(現・X)に投稿(ツイート)する:ぱふぅ家のホームページ
API操作はクラスファイルに分離し、他のプログラムから利用しやすいようにした。当サイト以外が配布しているプログラムやライブラリは不要だ。