本ブログ(WordPress)記事を SNS Blueskyに自動的に投稿
March 26, 2025 – 3:29 pmこのブログ上に新規作成したポスト記事を、X(旧Twitter)に加えて、米新興SNSのBlueskyにも自動投稿できるようにした。Bluesky apiが公開され、PHPでの投稿ができそうなのでTwitterへの投稿機能を拡張した。
このところ、イーロン・マスクがXを私物化し、横暴な態度をとっていることから、Xの利用者がBlueskyへ乗り換えるが進んでいるようだ。日経電子版(3/11 配信)の記事「米SNSのBluesky、利用者4カ月で2倍 Xから乗り換え」もよると、利用者が急速に伸び3300万人にも達したとのことだ。
このブログ記事の要約をX同様にBlueskyにも投稿できるようにすることも多少の意味があるのでは考えた。
以下、X(旧Twitter)とBlueskyへの投稿機能を実現するためのphpソース(修正ファイルのみ)をメモしておいた:
- function.php関連部
functions.phpにpostを新規に投稿するタイミングでの手続きを記載:require_once 'subfunc/action.php';function on_all_status_transitions( $new_status, $old_status, $post ) { if ( $new_status != $old_status && (string)$new_status === 'publish' ) action( $post ); } add_action( 'transition_post_status', 'on_all_status_transitions', 10, 3 );
- ディレクトリ構成とphpファイル:
(theme's subdirectory) | +--- functions.php [subfunc] ---+ | +--- action.php config.php [OAuth] ---+ | +--- OAuth.php twitteroauth.php [bluesky] ---+ | +--- bluesky.php
- action.php:
<?php require_once "vendor/autoload.php"; require_once "config.php"; require_once "bluesky/bluesky.php"; use Abraham\TwitterOAuth\TwitterOAuth; function action( $post ) { $post_id = $post->ID; $post_title = $post->post_title; $post_content = $post->post_content; $leavename = false; $post_url = get_permalink( $post_id, $leavename ); $no_str_title = mb_strlen($post_title, "UTF-8"); // $length_content = 140 - $no_str_title - 20 - 6; $length_content = 138 - $no_str_title - 20 - 6; $post_excerpt = twcm_get_description( $post ); $trunc_content = mb_truncate($post_excerpt, $length=$length_content, $etc='..'); $message = $post_title . "\r\n" . $post_url . "\r\n" . $trunc_content; send_message( $message ); send_blue( $post ); } function send_blue( $post ) { $post_id = $post->ID; $post_title = $post->post_title; $post_content = $post->post_content; $leavename = false; $post_url = get_permalink( $post_id, $leavename ); $linkStart = 0; $linkEnd = strlen($post_title); $arr_link[0] = [$post_url,$post_title,$linkStart,$linkEnd]; $post_content = remove_PreTags( $post_content ); $parts = explode('', $post_content); $post_excerpt = $parts[0]; // 正規表現でタグを検索 $pattern = '/(.*?)<\/a>/i'; $nmatch = preg_match_all($pattern, $post_excerpt, $matches); $post_excerpt = extractPlainText($post_excerpt); $post_excerpt = removeHtlmTags($post_excerpt); $no_str_title = mb_strlen($post_title, "UTF-8"); $length_content = 290 - $no_str_title; $post_excerpt = mb_truncate($post_excerpt, $length=$length_content, $etc='..'); $text = $post_title . "\n\n" . $post_excerpt; if($nmatch > 0) { for ($i=0; $i<$nmatch; $i++) { $linkStart = strpos($text, $matches[2][$i]); $linkEnd = $linkStart + strlen($matches[2][$i]); $arr_link[$i+1] = [$matches[1][$i], $matches[2][$i], $linkStart, $linkEnd]; } } $bluesky_pass = BLUESKY_APP_PASSWORD; $bluesky = new Bluesky("yamasnet.bsky.social", $bluesky_pass); $res = $bluesky->post($text, null, $link); } function send_message( $message ) { echo $image_url; $consumer_key = CONSUMER_KEY; $consumer_secret = CONSUMER_SECRET; $access_token = ACCESS_TOKEN; $access_token_secret = ACCESS_SECRET; $connection = new TwitterOAuth($consumer_key, $consumer_secret, $access_token, $access_token_secret); $connection->setApiVersion("2"); $result = $connection->post("tweets", ["text" => $message], true); } function twcm_get_description( $post ) { // global $post; if( has_excerpt() ) $desc = trim(get_the_excerpt()); else { $desc=strip_shortcodes( $post->post_content ); #avoid shortcode content //__insert_start $pos = mb_strpos($desc, '', 0, "UTF-8" ); if( $pos > 0 ) $desc = mb_substr($desc, 0, $pos, "UTF-8"); //__insert_end } $desc=strip_tags( $desc ); $desc=esc_attr($desc); $desc = trim(preg_replace("/\s+/", " ", $desc)); #to maintain a space between words in description. Since version 1.1.2 if( has_excerpt() ) $desc=twcm_sub_string($desc, 140); else $desc = mb_truncate($desc, $length = 140, $etc = '' ); return $desc; } //** // function added 2015/06/19 //** function mb_truncate($string, $length = 80, $etc = '[...]') { if ($length == 0) return ''; if (mb_strlen($string,"UTF-8") > $length) { $string = mb_substr($string, 0, $length, "UTF-8"); return $string.$etc; } else { return $string; } } function twcm_sub_string($text, $charlength=200) { $charlength++; $retext=""; if ( mb_strlen( $text ) > $charlength ) { $subex = mb_substr( $text, 0, $charlength - 5 ); $exwords = explode( ' ', $subex ); $excut = - ( mb_strlen( $exwords[ count( $exwords ) - 1 ] ) ); if ( $excut < 0 ) { $retext .= mb_substr( $subex, 0, $excut ); } else { $retext .= $subex; } $retext .= '[...]'; } else { $retext .= $text; } return $retext; } function removeHtlmTags($inputText) { // strip_tags関数を使用してHTMLタグを除去 $cleanText = strip_tags($inputText); return $cleanText; } function extractPlainText($inputText) { // 正規表現でリンク部分を取り除く $plainText = preg_replace('/]*>(.*?)<\/a>/', '$1', $inputText); return $plainText; } function remove_PreTags($inputText) { //
と
の間を含めて削除する
return preg_replace('/]*>.*?<\/pre>/is', '', $inputText); } function mb_truncate1($string, $length, $etc='..') { if ( $length == 0 ) return ''; if (mb_strlen($string) > $length ) { return mb_substr($string, 0, $length).$etc; } else { return $string; } }
- bluesky.php:
<?php class Bluesky { public $jwt; public $handle; public function __construct($handle, $password) { $this->handle = $handle; $this->jwt = $this->getJwt($handle, $password); // parent::__construct(); } private function getJwt($handle, $password) { $ch = curl_init("https://bsky.social/xrpc/com.atproto.server.createSession"); curl_setopt_array($ch, [ CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode([ "identifier" => $handle, "password" => $password, ]), ]); $response = curl_exec($ch); curl_close($ch); $responseJson = json_decode($response, true); if (isset($responseJson["accessJwt"])) { return $responseJson["accessJwt"]; } else { throw new \Exception("Failed to obtain JWT: " . $response); } } public function post_mod($text, $imagePath = null, $link = null) { $imageUri = $imagePath ? $this->uploadImage($imagePath) : null; $record = [ "\$type" => "app.bsky.feed.post", "text" => $text, //"createdAt" => Carbon::now()->format('c'), "createdAt" => (new DateTime())->format("c"), ]; if ($imageUri) { $record['embed'] = [ '$type' => 'app.bsky.embed.images', 'images' => [ [ 'image' => $imageUri, 'alt' => 'Image description' ] ] ]; } if ($link) { $n_link = count($link); $facets = []; //create facets for($i=0; $i < $n_link; $i++) { $linkStart = $link[$i][2]; $linkEnd = $link[$i][3]; $facets[] = [ 'index' => [ 'byteStart' => $linkStart, 'byteEnd' => $linkEnd ], 'features' => [ [ '$type' => 'app.bsky.richtext.facet#link', 'uri' => $link[$i][0] ] ] ]; } if(!empty($facets)) { $record['facets'] = $facets; } } $ch = curl_init("https://bsky.social/xrpc/com.atproto.repo.createRecord"); curl_setopt_array($ch, [ CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Content-Type: application/json", "Authorization: Bearer {$this->jwt}", ], CURLOPT_POSTFIELDS => json_encode([ "repo" => $this->handle, "collection" => "app.bsky.feed.post", "record" => $record, ]), ]); $response = curl_exec($ch); curl_close($ch); return json_decode($response, true); } private function uploadImage($imagePath) { $imageData = file_get_contents($imagePath); $mime = mime_content_type($imagePath); $ch = curl_init("https://bsky.social/xrpc/com.atproto.repo.uploadBlob"); curl_setopt_array($ch, [ CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Content-Type: $mime", "Authorization: Bearer {$this->jwt}", ], CURLOPT_POSTFIELDS => $imageData, ]); $response = curl_exec($ch); $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpcode == 200) { $responseJson = json_decode($response, true); return $responseJson['blob'] ?? null; } else { throw new \Exception("Failed to upload image: HTTP $httpcode - $response"); } } }