(2025年11月21日)PHP8.5対応:curl_close,imagedestroyを実行しないようにした.
目次
サンプル・プログラムの実行例
サンプル・プログラム
| embedBlueskyPost.php | サンプル・プログラム本体 |
| .pahooEnv | クラウドサービスを利用するためのアカウント情報などを記入する .env ファイル。 使い方は「各種クラウド連携サービス(WebAPI)の登録方法」を参照。include_path が通ったディレクトリに配置すること。 |
| pahooInputData.php | データ入力に関わる関数群。 使い方は「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。 |
| pahooBlueskyAPI.php | Bluesky APIに関わるクラス pahooBlueskyAPI。 使い方は「PHPでPHPでBlueskyに投稿する」などを参照。include_path が通ったディレクトリに配置すること。 |
| pahooScraping.php | スクレイピング処理に関わるクラス pahooScraping。 スクレイピング処理に関わるクラスの使い方は「PHPでDOMDocumentを使ってスクレイピング」を参照。include_path が通ったディレクトリに配置すること。 |
| バージョン | 更新日 | 内容 |
|---|---|---|
| 1.6.0 | 2025/08/14 | .pahooEnv導入 |
| 1.0.0 | 2025/01/18 | 初版 |
| バージョン | 更新日 | 内容 |
|---|---|---|
| 2.9.4 | 2026/09/27 | uploadBlob() -- 1Mバイトを超える画像はJPEGにして縮小(limitImageSize追加),OGPサムネイルはOGP用の大きさにする |
| 2.9.3 | 2026/09/26 | getEmbedPosts() -- 投稿へのリンクURLが at:// URI のままになっていた bug-fix |
| 2.9.2 | 2026/09/26 | トークン保存ファイル名を .htpahootoken に変更(Webから読まれないように) |
| 2.9.1 | 2026/09/26 | strcut(), atruri2postURL(), getValidToken(), getMyProfiles() bug-fix,aspectRatioを画像ごとに付ける |
| 2.9.0 | 2026/09/26 | deleteSession() -- refreshJwtでセッションを終了しトークンを消去,サーバ証明書を検証,getEmbedPosts() -- XSS対策,extractMediaURL() -- file:/// 形式を受け付けない |
| バージョン | 更新日 | 内容 |
|---|---|---|
| 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.3 | 2026/09/26 | validEmail(), validNumber(), getValidNumber(), roundFloat(), pahooLoadEnv() bug-fix |
| 2.0.2 | 2026/09/26 | pahooLoadEnv() -- str_starts_with()を使わないようにした(PHP7対応) |
| 2.0.1 | 2025/08/11 | getParam() bug-fix |
| 2.0.0 | 2025/08/11 | pahooLoadEnv() 追加 |
| 1.9.0 | 2025/07/26 | getParam() 引数に$trim追加 |
準備:PHP の https対応
Windowsでは、"php.ini" の下記の行を有効化する。
extension=php_openssl.dllLinuxでは --with-openssl=/usr オプションを付けて再ビルドする。→OpenSSLインストール手順
また、クラウド連携や相手先サイトとの https通信では、なりすましを防ぐため、相手サーバのSSL/TLS証明書を検証している。
Windows版のPHPには、証明書の検証に使うCA証明書(ルート証明書の一覧)が同梱されていないため、そのままでは https によるデータ取得に失敗する。CA証明書ファイル(cacert.pem)を入手し、"php.ini" に次の2行を追加してから、PHP(Webサーバ)を再起動してほしい。パスは cacert.pem を置いた場所に合わせること。
curl.cainfo = "C:\php\extras\ssl\cacert.pem"Linuxでは、通常はOSのCA証明書が使われるので、この設定は要らない。
openssl.cafile = "C:\php\extras\ssl\cacert.pem"
これで準備は完了だ。
準備:pahooInputData 関数群
また、各種クラウドサービスに登録したときに取得するアカウント情報、アプリケーションパスワードなどを登録した .pahooEnv ファイルから読み込む関数 pahooLoadEnv を備えている。こちらについては、「各種クラウド連携サービス(WebAPI)の登録方法」をご覧いただきたい。
.pahooEnv には「名前=値」を1行に1つずつ書く。値は "..." や '...' で囲んでもよい(囲んだ引用符は取り除かれる)。# ではじまる行はコメントで、空行や「=」のない行は読み飛ばす。
解説:pahooBlueskyAPIクラス
Bluesky APIを利用するメソッドはクラス "pahooBlueskyAPI.php" に分離している。また、このクラスからクラス "pahooScraping.php" を呼び出すので、2つのクラス・ファイルを include_path の通ったディレクトリに配置すること。
解説:セッション開始
| URL |
|---|
| https://{PDSドメイン}/xrpc/com.atproto.server.createSession |
アクセストークン refreshJwt は、アクセストークンの再発行や、セッションの終了・破棄に用いることができ、寿命は数十日と長い。リフレッシュトークン refreshJwt をストレージに保存しておき、次回はアクセストークンを再発行するというのが BlueskyAPI の望ましい運用方法と思われるが、リフレッシュトークン refreshJwt だけでアクセストークン accessJwt を再発行できてしまうので、流出するとたいへん危険である。
pahooBlueskyAPI クラスは、取得したトークンを定数 FILENAME_TOKEN で示すファイル(初期値は "./.htpahootoken")に保存し、有効期限内であれば再利用する。期限が切れたら、リフレッシュトークン refreshJwt でアクセストークンを再発行する。Apache は標準設定で、「.ht」ではじまるファイルへのWebからのアクセスを拒否するので、プログラムと同じディレクトリに置いても、トークンをWebから読まれることはない。詳しくは「解説:API認証とセッション - PHPでBlueskyに投稿する」をご覧いただきたい。
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 MAX_BLOB_SIZE = 1000000; // 投稿に添付できる画像の最大サイズ(バイト)
35: const ASPECT_WIDE = 1.91; // 画像アスペクト比(横長)
36: const ASPECT_SQUARE = 1.0; // 画像アスペクト比(正方形)
37: // これより大きいときは自動縮小する
38: // トークンを保存するファイル名
39: // 秘匿性を保つことができ、かつ、PHPプログラムから読み書き可能であること
40: // Apacheは標準設定で「.ht」ではじまるファイルへのアクセスを拒否するため,この名前にしている
41: const FILENAME_TOKEN = './.htpahootoken';
42:
43: // -- 以下のデータは .env ファイルに記述可能
44: // Bluesky API アプリパスワード
45: // https://bsky.app/
46: public $BLUESKY_HANDLE = ''; // ハンドル名
47: public $BLUESKY_PASSWORD = ''; // アプリケーション・パスワード
上述の手順で取得したアプリケーション・パスワードをプロパティ変数 $BLUESKY_PASSWORD に、あなたのハンドル名を $BLUESKY_HANDLE に代入する。
投稿可能な最大文字数は定数 MAX_MESSAGE_LEN として用意した。現在の仕様では300文字だ。
pahooBlueskyAPI.php
49: /**
50: * コンストラクタ
51: * もしAPIエラーが出る場合には,新規セッションにしてみる.
52: * @param string $pds PDSドメイン
53: * @param bool $newSession 新規セッションにするかどうか(TRUE:新規,デフォルトはFALSE)
54: * @return なし
55: */
56: function __construct($pds, $newSession=FALSE) {
57: if (isset($_ENV['PAHOO_BLUESKY_HANDLE'])) {
58: $this->BLUESKY_HANDLE = $_ENV['PAHOO_BLUESKY_HANDLE'];
59: }
60: if (isset($_ENV['PAHOO_BLUESKY_PASSWORD'])) {
61: $this->BLUESKY_PASSWORD = $_ENV['PAHOO_BLUESKY_PASSWORD'];
62: }
63:
64: // プロパティを初期化する.
65: $this->pds = $pds;
66: $this->webapi = '';
67: $this->errmsg = '';
68: $this->accessJwt = '';
69: $this->refreshJwt = '';
70:
71: // 新規セッションを開始する.
72: if ($newSession) {
73: $this->createSession();
74: }
75: }
pahooBlueskyAPI.php
384: /**
385: * 新規セッションを開始する.
386: * @param なし
387: * @return bool TRUE:成功/FALSE:失敗
388: */
389: function createSession() {
390: // エラーメッセージ・クリア
391: $this->clearerror();
392:
393: // リクエストURL
394: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.createSession';
395: $this->webapi = $requestURL;
396: $ch = curl_init($requestURL);
397: // cURLを使ったリクエスト
398: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
399: curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
400: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
401: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, TRUE); // サーバ証明書を検証する
402: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // 〃
403: curl_setopt($ch, CURLOPT_POST, TRUE);
404: curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
405: 'identifier' => $this->BLUESKY_HANDLE,
406: 'password' => $this->BLUESKY_PASSWORD,
407: ]));
408:
409: // レスポンス処理
410: $response = curl_exec($ch);
411: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
412: if ($httpStatusCode != 200) {
413: $this->seterror('セッションを開始できません');
414: return FALSE;
415: }
416: if (PHP_VERSION_ID < 80500) {
417: curl_close($ch);
418: }
419: $items = json_decode($response, TRUE);
420:
421: // エラーチェックとリターン
422: if (isset($items['accessJwt']) && isset($items['refreshJwt'])) {
423: $this->accessJwt = (string)$items['accessJwt'];
424: $this->refreshJwt = (string)$items['refreshJwt'];
425: // トークンをファイルに保存する
426: $contents = $this->accessJwt . "\n" . $this->refreshJwt;
427: file_put_contents(self::FILENAME_TOKEN, $contents);
428: return TRUE;
429: } else if (isset($items['error'])) {
430: $this->seterror($items['message']);
431: return FALSE;
432: } else {
433: $this->seterror('セッションを開始できません');
434: return FALSE;
435: }
436: }
メソッドの中身は、上述のAPI仕様の通りに作った。
POSTプロトコルとして、これまでのクラウドサービス利用でも使ってきた cURL関数を利用する。
なりすましたサーバにアプリパスワードやトークンを送らないよう、cURLではサーバ証明書を検証するよう設定している(CURLOPT_SSL_VERIFYPEER、CURLOPT_SSL_VERIFYHOST)。証明書の準備については「準備:PHP の https対応」をご覧いただきたい。
解説:セッション終了
| URL |
|---|
| https://{PDSドメイン}/xrpc/com.atproto.server.deleteSession |
トークンの盗用を避ける意味で、トークンを保存して再利用する必要がないときは、処理が終わったらセッション終了するようにしよう。
APIの戻り値は、httpステータスが200であれば成功、それ以外であればエラー情報が戻る。
pahooBlueskyAPI.php
491: /**
492: * セッション終了する.
493: * com.atproto.server.deleteSession は refreshJwt で認証する(accessJwtでは終了できない).
494: * 終了後はトークン保存ファイルの中身を空にする.
495: * @param なし
496: * @return bool TRUE:成功/FALSE:失敗
497: */
498: function deleteSession() {
499: $res = TRUE;
500:
501: // セッションが残っていれば終了する
502: if ($this->refreshJwt != '') {
503: // リクエストURL
504: $requestURL = 'https://' . $this->pds . '/xrpc/com.atproto.server.deleteSession';
505: $this->webapi = $requestURL;
506: $ch = curl_init($requestURL);
507: // cURLを使ったリクエスト
508: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
509: curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $this->refreshJwt]);
510: curl_setopt($ch, CURLOPT_POST, TRUE);
511: curl_setopt($ch, CURLOPT_POSTFIELDS, '');
512: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
513: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, TRUE); // サーバ証明書を検証する
514: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // 〃
515:
516: // レスポンス処理
517: $response = curl_exec($ch);
518: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
519: if (curl_errno($ch)) {
520: $this->seterror('セッション終了できません' . curl_error($ch));
521: $res = FALSE;
522: } else if ($httpStatusCode != 200) {
523: $this->seterror('セッション終了できません(http code:' . $httpStatusCode . ')');
524: $res = FALSE;
525: }
526: if (PHP_VERSION_ID < 80500) {
527: curl_close($ch);
528: }
529: }
530:
531: // トークンを消去する(保存ファイルは中身を空にする)
532: $this->accessJwt = '';
533: $this->refreshJwt = '';
534: if (is_file(self::FILENAME_TOKEN)) {
535: @file_put_contents(self::FILENAME_TOKEN, '');
536: }
537:
538: return $res;
539: }
APIを呼び出したあと、プロパティのトークンを消し、トークンを保存したファイルの中身も空にする。
解説:メッセージ情報を取得
| URL |
|---|
| https://{PDSドメイン}/xrpc/app.bsky.feed.getPosts?uris={atURI(複数)} |
APIの戻り値は、httpステータスが200であれば成功、それ以外であればエラー情報が戻る。
pahooBlueskyAPI.php
1392: /**
1393: * メッセージURLからメッセージ情報を取得する
1394: * @param array $urls メッセージURL(複数)
1395: * @return array メッセージ情報 / FALSE:取得失敗
1396: */
1397: function getPosts($urls) {
1398: $atURIs = [];
1399: foreach ($urls as $url) {
1400: // ユーザー名、投稿IDを取得する
1401: if (preg_match('/\/profile\/([^\/]+)\/post\/([0-9a-zA-Z]+)/ui', $url, $arr) == 0) {
1402: $this->seterror($url . 'は投稿URLではありません');
1403: return FALSE;
1404: }
1405: if (count($arr) < 3) {
1406: $this->seterror($url . '投稿URLではありません');
1407: return FALSE;
1408: }
1409: $userName = $arr[1];
1410: $postID = $arr[2];
1411:
1412: // ユーザーDIDを取得する
1413: $userDID = $this->getDID($userName);
1414: if ($userDID == FALSE) {
1415: $this->seterror($url . 'はユーザーDIDを取得できません');
1416: return FALSE;
1417: }
1418:
1419: // AT-URIを生成する
1420: $atURIs[] = 'at://' . $userDID . '/app.bsky.feed.post/' . $postID;
1421: }
1422:
1423: // トークンを取得する.
1424: $this->getValidToken();
1425:
1426: // リクエストURL (認証必要)
1427: $requestURL = 'https://' . $this->pds . '/xrpc/app.bsky.feed.getPosts';
1428: $ch = curl_init($requestURL . '?' . http_build_query(['uris' => $atURIs]));
1429: curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
1430: curl_setopt($ch, CURLOPT_HTTPHEADER, [
1431: 'Content-Type: application/json',
1432: 'Authorization: Bearer ' . $this->accessJwt,
1433: ]);
1434: curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
1435: curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, TRUE); // サーバ証明書を検証する
1436: curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // 〃
1437:
1438: // レスポンス処理
1439: $response = curl_exec($ch);
1440: $httpStatusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
1441: if (PHP_VERSION_ID < 80500) {
1442: curl_close($ch);
1443: }
1444: $items = json_decode($response, TRUE);
1445: if ($httpStatusCode != 200) {
1446: $errmsg = 'メッセージ情報を取得できません(http code:' . $httpStatusCode . ')';
1447: if (isset($items['message'])) {
1448: $errmsg .= ';' . $items['message'];
1449: }
1450: $this->seterror($errmsg);
1451: return FALSE;
1452: }
1453:
1454: return $items;
1455: }
解説:埋め込み用HTMLを取得
pahooBlueskyAPI.php
1466: /**
1467: * メッセージURLから埋め込みHTMLを取得する
1468: * @param array $url メッセージURL
1469: * @return string 埋め込みHTML / FALSE:取得失敗
1470: */
1471: function getEmbedPosts($url) {
1472: $items = $this->getPosts([$url]);
1473: if ($items == FALSE) {
1474: return FALSE;
1475: }
1476: if (! isset($items['posts'][0])) {
1477: $this->seterror($url . ' の投稿が見つかりません');
1478: return FALSE;
1479: }
1480: $post = $items['posts'][0];
1481:
1482: // 投稿日時を日本語に変換する
1483: $createdDateTime = isset($post['record']['createdAt']) ? (string)$post['record']['createdAt'] : '';
1484: if (preg_match('/([0-9]{4})\-([0-9]{2})\-([0-9]{2})T([0-9]{2})\:([0-9]{2})/', $createdDateTime, $arr) > 0) {
1485: $dt = sprintf('%04d年%d月%d日 %02d:%02d', (int)$arr[1], (int)$arr[2], (int)$arr[3], (int)$arr[4], (int)$arr[5]);
1486: } else {
1487: $dt = '';
1488: }
1489:
1490: // 投稿ページのURL:https://bsky.app/profile/{DID}/post/{投稿ID}
1491: // AT URI(at://{DID}/app.bsky.feed.post/{投稿ID})から組み立てる
1492: $rawURI = (string)($post['uri'] ?? '');
1493: if (preg_match('/^at:\/\/(did:[a-z0-9]+:[A-Za-z0-9\.\_\:\%\-]+)\/app\.bsky\.feed\.post\/([A-Za-z0-9\.\_\:\~\-]+)$/', $rawURI, $m) > 0) {
1494: $postURL = 'https://bsky.app/profile/' . $m[1] . '/post/' . $m[2] . '?ref_src=embed';
1495: // 組み立てられないときは,引数の投稿URLを使う
1496: } else if (preg_match('/^https?:\/\//i', (string)$url) > 0) {
1497: $postURL = (string)$url;
1498: } else {
1499: $postURL = '#';
1500: }
1501:
1502: // XSS対策:投稿データをエスケープしてからHTMLに埋め込む
1503: $postURL = $this->h($postURL);
1504: $uri = $this->h($post['uri'] ?? '');
1505: $cid = $this->h($post['cid'] ?? '');
1506: $text = $this->h($post['record']['text'] ?? '');
1507: $dispName = $this->h($post['author']['displayName'] ?? '');
1508: $did = $this->h($post['author']['did'] ?? '');
1509: $handle = $this->h($post['author']['handle'] ?? '');
1510:
1511: // 埋め込みHTML生成(公式の出力に合わせる)
1512: return <<< EOT
1513: <blockquote class="bluesky-embed" data-bluesky-uri="{$uri}" data-bluesky-cid="{$cid}"><p lang="">{$text}<br><br><a href="{$postURL}">[image or embed]</a></p>— {$dispName} (<a href="https://bsky.app/profile/{$did}?ref_src=embed">@{$handle}</a>) <a href="{$postURL}">{$dt}</a></blockquote><script async src="https://embed.bsky.app/static/embed.js" charset="utf-8"></script>
1514:
1515: EOT;
1516: }
投稿文、表示名、ハンドル名などは、HTMLに埋め込む前にエスケープする(XSS対策)。投稿へのリンクは、atURI から "https://bsky.app/profile/{ユーザーDID}/post/{ポストID}" の形式に組み立てる。
解説:メイン・プログラム
embedBlueskyPost.php
242: // メイン・プログラム =======================================================
243: // オブジェクトを生成する.
244: $pbs = new pahooBlueskyAPI('bsky.social');
245:
246: // パラメータを取得する.
247: $errmsg = $html = '';
248: $url = trim((string)getParam('url', FALSE, DEF_URL));
249: if ($url != '') {
250: validURL($url, $errmsg);
251: }
252:
253: // 埋め込み用HTMLを取得
254: if (isButton('exec') && ($url != '') && ($errmsg == '')) {
255: $html = $pbs->getEmbedPosts($url);
256: // 取得に失敗したらエラー・メッセージを表示する
257: if ($html === FALSE) {
258: $html = '';
259: $errmsg = $pbs->geterror();
260: }
261: }
262:
263: // 表示HTMLを作成する.
画面に表示するURLやエラー・メッセージは、 htmlspecialchars 関数でエスケープしてから出力する(XSS対策)。
なお、公開時(定数 FLAG_RELEASE が TRUE)は、エラー処理ハンドラ myErrorHandler が、警告を含むエラーで処理を止める。ただし、@演算子 で抑止した警告や、 error_reporting で除外したエラーは無視する。また、公開時はエラーの内容(ファイルのパスなど)を画面に出さないよう、display_errors を無効にしている。
参考サイト
- Bluesky 公式リファレンス
- Bluesky API - 各種クラウド連携サービス(WebAPI)の登録方法
- PHPでDOMDocumentを使ってスクレイピング:ぱふぅ家のホームページ
- PHPでBlueskyに投稿する:ぱふぅ家のホームページ
- PHPでツイートの埋め込み用HTMLを取得:ぱふぅ家のホームページ
- Extract CA Certs from Mozilla:curl

そこで今回は、PHPで メッセージ取得用の Bluesky APIを利用し、Bluesky の埋め込み用HTMLを取得するプログラムを作ってみることにする。