Steamer Lane Studio技術備忘録WordPress

WordPress投稿時に自動連携してBlueskyへ投稿するコード

wordpress WordPress投稿時に自動連携してBlueskyへ投稿するコード 作成日: 2026年1月7日

Xが怪しい、タイムライン式を止めて大炎上によりデフォルトはタイムラインに戻したが、最近また動きがおかしい。
何よりイーロンマスクの無能さ、「なぜX=Twitterか」という根源であるタイムライン式表示を、広告収入増加のために廃止してお勧め方式にしようとするバカな思想=ユーザー離れて本末転倒になることを想定できないことから、Twitterの子というか兄弟というかUI同じであるBlueskyでの対応を準備するために自動投稿コードを作った。

define('BLUESKY_HANDLE', 'YOUR ACCOUNT.bsky.social');// 例: example.bsky.socialdefine('BLUESKY_APP_PASSWORD', 'APP PASSWORD');// App Password

// 「公開」ボックスの上にボタンを追加(クラシックエディターで確実に表示)add_action('post_submitbox_misc_actions', 'bluesky_add_repost_button_classic');function bluesky_add_repost_button_classic() {global $post;

if (!$post || $post->post_status !== 'publish') {return;}

$posted = get_post_meta($post->ID, '_bluesky_posted', true);$status = $posted ? '<span style="color:green;">✓ 自動投稿済み</span>' : '<span style="color:#999;">未投稿</span>';$button_text = $posted ? 'Blueskyに再投稿する' : 'Blueskyに今すぐ投稿する';

// nonce付きフォームで投稿(JavaScript完全不要)$nonce = wp_create_nonce('bluesky_manual_repost_' . $post->ID);

echo '<div class="misc-pub-section" style="border-top:1px solid #ddd;padding-top:15px;margin-top:15px;">';echo '<div style="font-weight:bold;margin-bottom:8px;">Bluesky投稿</div>';echo '<div style="margin-bottom:8px;">状態: ' . $status . '</div>';echo '<form method="post" style="display:inline;">';echo '<input type="hidden" name="bluesky_manual_repost" value="1" />';echo '<input type="hidden" name="post_id" value="' . $post->ID . '" />';echo '<input type="hidden" name="bluesky_nonce" value="' . $nonce . '" />';echo '<input type="submit" class="button button-secondary" value="' . esc_attr($button_text) . '"onclick="this.value=\'投稿中...\'; this.form.submit(); this.disabled=true;" />';echo '</form>';echo '<small style="display:block;margin-top:8px;color:#666;">投稿内容:タイトル + アイキャッチ画像(あれば) + URL</small>';echo '</div>';

// フォーム送信処理if (isset($_POST['bluesky_manual_repost']) && $_POST['bluesky_manual_repost'] == '1') {$pid = intval($_POST['post_id']);check_admin_referer('bluesky_manual_repost_' . $pid, 'bluesky_nonce');

if (current_user_can('edit_post', $pid)) {$result = bluesky_post_article($pid, false);if ($result) {echo '<div id="message" class="updated notice is-dismissible"><p>Blueskyに投稿しました!</p></div>';} else {echo '<div id="message" class="error notice is-dismissible"><p>Bluesky投稿に失敗しました(ログを確認してください)</p></div>';}}}}

// 自動投稿(公開時)add_action('publish_post', 'post_to_bluesky_on_publish', 10, 2);function post_to_bluesky_on_publish($post_id, $post) {if ($post->post_status !== 'publish') return;if (get_post_meta($post_id, '_bluesky_posted', true)) return;

bluesky_post_article($post_id, true);}

// 共通投稿関数(タイトル + URL + アイキャッチ)function bluesky_post_article($post_id, $auto = false) {$title = get_the_title($post_id);$permalink = get_permalink($post_id);$text = $title . "\n" . $permalink;

if (mb_strlen($text) > 300) {error_log("Bluesky: 文字数超過 ({$post_id})");return false;}

$session = bluesky_create_session();if (!$session || empty($session['accessJwt']) || empty($session['did'])) {error_log('Bluesky: セッション作成失敗');return false;}

$access_jwt = $session['accessJwt'];$did = $session['did'];

$embed = null;if (has_post_thumbnail($post_id)) {$image_url = wp_get_attachment_url(get_post_thumbnail_id($post_id));if ($image_url) {$blob = bluesky_upload_blob($access_jwt, $image_url);if ($blob) {$embed = ['$type' => 'app.bsky.embed.images','images' => [['alt' => $title,'image' => $blob]]];}}}

$record = ['$type' => 'app.bsky.feed.post','text' => $text,'createdAt' => gmdate('c'),];if ($embed) $record['embed'] = $embed;

$post_data = ['repo' => $did,'collection' => 'app.bsky.feed.post','record' => $record,];

$result = bluesky_request('POST', 'com.atproto.repo.createRecord', $post_data, $access_jwt);

if ($result) {if ($auto) {update_post_meta($post_id, '_bluesky_posted', true);}return true;}error_log('Bluesky: 投稿失敗 (' . $post_id . ')');return false;}

// ヘルパー関数3つ(変更なし)function bluesky_create_session() {$data = ['identifier' => BLUESKY_HANDLE,'password' => BLUESKY_APP_PASSWORD,];

$response = wp_remote_post('https://bsky.social/xrpc/com.atproto.server.createSession', ['headers' => ['Content-Type' => 'application/json'],'body' => wp_json_encode($data),'timeout' => 30,]);

if (is_wp_error($response)) return false;$body = wp_remote_retrieve_body($response);return json_decode($body, true) ?: false;}

function bluesky_upload_blob($access_jwt, $image_url) {$image_data = wp_remote_get($image_url);if (is_wp_error($image_data)) return false;

$body = wp_remote_retrieve_body($image_data);$mime = wp_remote_retrieve_header($image_data, 'content-type') ?: 'image/jpeg';

$response = wp_remote_post('https://bsky.social/xrpc/com.atproto.repo.uploadBlob', ['headers' => ['Content-Type' => $mime,'Authorization' => 'Bearer ' . $access_jwt,],'body' => $body,'timeout' => 30,]);

if (is_wp_error($response)) return false;$json = json_decode(wp_remote_retrieve_body($response), true);return $json['blob'] ?? false;}

function bluesky_request($method, $endpoint, $data = [], $access_jwt = '') {$url = 'https://bsky.social/xrpc/' . $endpoint;

$args = ['method' => $method,'headers' => ['Content-Type' => 'application/json','Authorization' => 'Bearer ' . $access_jwt,],'body' => wp_json_encode($data),'timeout' => 30,];

$response = wp_remote_request($url, $args);if (is_wp_error($response)) return false;

if (wp_remote_retrieve_response_code($response) !== 200) return false;

return json_decode(wp_remote_retrieve_body($response), true);}

BlueskyはOGP非対応のようで、Twitter(アンチイーロン・Xとは呼ばない)の様に投げればOGP読んで自動ってことはないので、アイキャッチを自動投稿するようにしている。
Twitterの様にAPIややこしくないので、ぱっと作って
define(‘BLUESKY_HANDLE’, ‘YOUR ACCOUNT.bsky.social’);// 例: example.bsky.social
define(‘BLUESKY_APP_PASSWORD’, ‘APP PASSWORD’);// App Password
この2か所埋めればOK。

PHPをCronで叩く=WP以外での自動投稿版もあるので、それはいずれ投稿します。