WordPressからXヘ半自動投稿する仕組みをAIでブラウザ拡張機能として作ってみた

スポンサードサーチ

WordPressで記事を公開したら、そのままSNSにもアップしたい、というのは、多くのブログ運営者やメディア運営者にとって、ごく自然なニーズだと思います。

SNSの中でも、Facebookに対する投稿は、JetPackなどのアドオンを使えば連携はできます。

しかし、Xは2023年3月に無料APIが事実上廃止されたため、そこまで出来ていた「無料で安定した自動投稿」はできなくなりました。

そこで、プラグインでの自動投稿は辞め、WordPressでブログを更新したら、Xに手動でアップする、という事を行っていたのですが、これがどうにも面倒くさい。

Xに投稿する手順としては、以下の5ステップになります。

  1. 投稿をポストするXを開く。
  2. ブログの記事タイトルをコピーしてXに貼り付ける。
  3. URLをコピーしてXに貼り付ける。
  4. Xにハッシュタグを入れる。
  5. 「ポスト」ボタンを押す。

ただ、実運用的にブログやFacebookへの投稿は自動投稿でいいのですが、Xへポストする際には、最適なハッシュタグを入れる点でも完全に自動で投稿する事自体が望ましくありません。

そのため、Xについては完全自動投稿ではなく、ブラウザ拡張を使った半自動投稿、という形での対応をすることにしました。

本記事では、作成の経緯、AIを使った要求定義から、最終的に採用した方式、要件定義、実装、セキュリティ上の改善ポイントまでをまとめました。

また、プログラミング自体はAIで行っているのですが、その前にAIと対話しながら要求定義と要件定義を行っていますので、AIを使って自社の課題を解決する際の参考にしていただければ幸いです。

XのAPIは従量課金でかなり安くなっている

XのAPIは、2026年2月7日から、新しい従量課金モデルが正式にローンチされ、使った分だけ支払う形になったため、それまでの月額200ドルよりは大幅に安く利用はできるようになっています。

X(旧Twitter)が開発者向けAPIの価格体系として新しい従量課金制を発表しました。これまでは月額200ドル(約3万1000円)または5000ドル(約78万円)の固定料金を支払う必要がありましたが、今後は事前購入したクレジットがAPIリクエストに応じて消費されるようになります。

Gigazine:X(旧Twitter)が開発者向けAPIの新しい従量課金モデルを発表、月額200ドルまたは5000ドルの固定料金制から正式に移行

料金的には、最初に最低デポジットの $5 だけクレジットを購入すれば、実際のコストは 1投稿 $0.01、という感じですので、30日間毎日投稿しても$0.3(ドル円160円で計算すると48円)と、それまでの月額200ドルよりも大幅に値下げとなりました。

そのため、以前よりAPIを有償で利用することについてのハードルは下がっていますが、まずはAPIを使わない方法を検討してみました。

AIで実現方法を検討

WordPressの投稿を、楽にXのポストに反映する方法をAIで検討してみました。

AIの回答 1

AIに質問をしたところ、以下の回答が得られました。

無料寄りで、将来も直しやすい構成としてはこれです。

  • WordPressに WP Webhooks を入れる
  • 記事公開時に title, url, excerpt を送信
  • 受け先を Cloudflare Workers にする
  • Workers で
    「新着記事を公開しました\n{title}\n{url}」
    の形に整形
  • Xへ投稿

これが一番きれいです。XのAPI条件が厳しいときだけ、投稿部分をPlaywrightに差し替えます。

この実装方法だと、Xへ投稿する際にAPIを利用する必要があるため、「APIを使わない」という与件を満たしていません。

AIの回答 2

Google Apps Script(GAS)という、Googleが提供するJavaScriptベースのクラウド型自動化プラットフォームを使うことも検討しました。

GASでできる2つの作り方

方式1: WordPressからWebhook送信

向いているケース:

  • 記事公開のたびにすぐ投稿したい

流れ:

  • GASをWebアプリとして公開
  • doPost(e) でWordPressからJSON受信
  • 受けた title, url, excerpt を整形
  • X APIへ送信

方式2: GASがRSSを定期巡回

向いているケース:

  • WordPress側の改修を減らしたい
  • シンプルに始めたい

流れ:

  • GASの時間主導トリガーで5分ごと/15分ごとに実行
  • WordPressのRSSまたはREST APIを確認
  • 最後に投稿した記事IDと比較
  • 新着だけXへ投稿

こちらも、GASからXにポストするところでAPIを使うため、「APIを使わない」という与件を満たしていません。

AIの回答 3

Claude Desktopの利用も検討しました。

結論

  • Claude Desktopを使うと開発はかなり簡単になる
  • ただし Claude Desktop単体で安定した自動投稿基盤にするのはおすすめしない
  • 実運用は GASを本体 にして、Claude Desktop は コード生成・修正・改善役 にするのが一番現実的です

Claude Desktop は日常の対話・ローカル作業支援には強いですが、常時監視・本番自動化の実行基盤そのものではないため、自動投稿基盤として使うのには望ましくなく、GASを使うのであればAPIの利用が前提となるため、「APIを使わない」という与件を満たしていません。

要求定義を見直す

そこで、再度、今の私のWordPressの投稿をXへポストする際の使い方について、何が必要で、何が不要なのかを改めて確認しました。

すると、WordPressの投稿をそのままXにそのままコピーして投稿するのではなく、件名とURLをコピーで貼り付けた後、ハッシュタグを手動で入れてポストする、ということを行っていますので、「投稿の完全自動化」は私が実現したいことではありませんでした。

そのため、要求定義としては、「WordPressの投稿をXへ自動でポストすることで楽をしたい」ではなく「WordPressの投稿を、編集して、ハッシュタグを付与して、Xにポストする作業を楽にしたい」ということに見直しました。

そうすると、システムで実現したいプロセスも以下のように変わってきます。

  • 記事が公開されたことは自動で拾う
  • 投稿文の下準備も自動でやる
  • でも最後の確認と投稿は人がやる

これだと、以下のメリットがあります。

  • X APIに依存しなくてよい
  • 毎回ゼロから文章を作らなくてよい
  • ハッシュタグを好きに追加できる
  • 投稿前に内容を確認できる

実際の私の運用では、完全自動よりもこの方が便利であり、記事ごとに少し言い回しを変えたい場合や、ハッシュタグを調整したい場合にも、半自動の方が便利です。

AIの回答 4

そこで、「ブラウザのアドオンでXへ投稿する半自動のアプリを作れませんか?」という質問を投げました。

できます。
半自動版として、今回はこの仕様で作ります。

  • WordPressで記事を公開すると「投稿待ちキュー」に追加
  • Chrome拡張が数分おきにそのキューを確認
  • 新着があれば拡張アイコンに件数表示
  • ユーザーが拡張のボタンを押すと Xの投稿画面を開いて文面を自動入力
  • 最後の「ポスト」クリックだけ手動
  • 投稿後にもう一度ボタンを押して 投稿済みにする

この構成は、WordPress側はREST APIでデータを出せて、Chrome拡張側は content_scripts でページDOMを操作し、chrome.storage で状態保存、chrome.alarms で定期確認できます。

これで要求定義に対する実現方法が決まりました。

WordPressの新着記事の投稿支援するブラウザ拡張

要求定義の実現方法としては、WordPressの新着記事の投稿を支援するブラウザ拡張を作る、となりました。

ブラウザ拡張機能(アドオン)とは、ChromeやEdge、Firefoxなどのブラウザに機能を追加し、カスタマイズできる小さなプログラムです。ブラウザの上部に追加され、画面キャプチャや翻訳、メモ機能など、ブラウジングをより便利にツールが一般的に使われています。

このブラウザ拡張なら、WordPress側から取得した情報をポップアップに表示し、Xの投稿画面を開いたうえで、投稿文のコピーや操作補助を行えます。

また、APIを使わないため、X側の課金は不要であり、しかも、完全自動ではなく半自動にすれば、運用上の事故も減らせます。

WordPressは、「投稿待ちデータを返す側」、ブラウザ拡張は「そのデータを表示し、投稿操作を支援する側」であり、以下のように動作します。

1. WordPressで記事を公開する

まずはいつも通り、WordPressで記事を公開します。ここは普段のブログ更新と同じです。

2. WordPressが「X投稿待ち」として記事を記録する

記事が公開されると、WordPress側でその記事を「まだXに投稿していない記事」として記録します。この段階では、まだXには投稿されません。

3. ブラウザ拡張が投稿待ちの記事を確認する

作成したChromeのブラウザ拡張機能が、一定時間ごとにWordPressサイトを見に行って、投稿待ちの記事があるかを確認します。現在のブラウザ拡張機能は複数サイト対応になっていて、デフォルトで2サイト分の設定を持ちつつ、設定画面から追加や変更ができるようになっています。

4. 拡張の画面に「投稿待ち記事」が並ぶ

投稿待ちの記事が見つかると、拡張のポップアップ画面に以下の情報が一覧で表示されます。

  • どのサイトの記事か
  • 記事タイトル
  • URL
  • Xに貼り付ける本文
  • 投稿のタグから自動生成されたハッシュタグ候補

ブラウザ拡張機能のポップアップには、「投稿待ち」と「設定」の2つのタブがあり、運用中にそのままサイト設定も変更できるようになっています。

5. WordPressのタグからハッシュタグ候補を作る

今回作成したブラウザ拡張機能では、WordPressの投稿タグをそのまま生かしてハッシュタグ候補に変換し、チェックボックスで選べるようにしています。

このハッシュタグは、以下のルールを入れる事で、投稿向きのハッシュタグだけを候補にするようにしています。

  • スペースは削除する
  • _-# は取り除く
  • バージョン番号のようなタグは除外する
  • 長すぎる全角タグは除外する

実装上も toHashtag() という関数でタグの整形ルールをまとめています。

このおかげで、毎回ゼロからハッシュタグを考える必要もありません。

しかも、自動で全部入れるのではなく、必要なタグだけを選んで使えますし、最後に自分で追加をすることもできますので、運用上かなり扱いやすくなっています。

6. サイトごとに設定したXアカウントを開く

複数サイトを運用していると、投稿先のXアカウントもサイトごとに分かれていることがあります。

そこで、設定画面で各サイトごとに Xアカウント を設定できるようにしました。

記事一覧には、その記事がどのXアカウント向けなのかも表示され、ボタンを押すと、設定されたXアカウントのページを開くようにしています。また、設定値 xUsername はサイトごとの設定として保存され、Xを開く処理でも使われています。

つまり、「サイトAの記事なのに、間違えてサイトBのアカウントで投稿する」という事故を減らしやすくなりました。

7. ボタンを押すと、本文がコピーされてXが開く

一覧の中から対象記事のボタンを押すと、以下の情報がテキストとしてクリップボードにコピーされます。

  • 記事タイトルとURLを含む本文
  • チェックしたハッシュタグ

その後、対象のXアカウントのページが別ウィンドウで開きます。ポップアップ側では、チェックしたハッシュタグを本文の末尾に連結してコピーする仕様になっています。

8. Xで貼り付けて、必要なら少し直して投稿する

ブラウザでXアカウントのページが開いたら、自分で投稿内容を貼り付けをしますが、この時注意すべきなのは投稿ユーザーの確認です。

複数のXアカウントを運用している場合、ログインも複数されていることが一般的です。

そのため、現在、選択しているXアカウントが投稿しようとしているXアカウントと一致しているかを確認してからポストする必要があります。

また、必要ならポストをする前に、以下の調整が必要です。

  • ハッシュタグを減らす
  • 一言コメントを足す
  • 改行を整える

調整が終わったら、最後に「ポスト」ボタンを押します。

9. 投稿後に「投稿済み」にする

投稿が終わったら、ブラウザ拡張機能で「投稿済みにする」を押します。

これで、その記事は今後の投稿待ち一覧から外れます。投稿済み処理の時だけ、保存済みのサイト設定から該当サイトのシークレットを取り出してWordPressへ通知するようになっています。

実装中にぶつかった課題

この仕組みは、最初からスムーズに完成したわけではありません。

Xの投稿欄に直接入れようとして失敗した

最初は、ブラウザ拡張機能から、Xの投稿欄へそのまま文字を入れる方式を試しました。見た目上は文字が入っているように見えるのですが、実際にはX側が正式な入力として認識しておらず以下の不具合が発生しました。

  • ポスト内容を編集できない
  • ポストボタンが押せない

そのため、現在の最終版では「自動入力」ではなく、クリップボードにコピーして、人が貼り付ける方式に切り替えています。これにより、投稿前の編集もしやすくなり、安定性も上がりました。

複数サイト対応でポップアップと裏側のズレが出た

1サイト前提の処理から複数サイト前提へ機能を広げていく中で、一覧表示はできているのに、ボタンを押したときの処理が合わないこともありました。

これは、ポップアップ側とバックグラウンド側を両方とも複数サイト仕様へ揃えることで解消しました。今の実装では、設定画面からサイトを複数追加でき、保存後にそのまま一覧へ反映されるようになっています。

WordPressのレスポンスに余計な文字が混ざることがあった

WordPressから返ってくるデータに、先頭のBOMのような余計な文字が混ざり、JSONとして読めなくなることがありました。そこで、拡張側の読み込み処理で不要な文字を除去するようにして、安定させました。

レスポンス取得部分では text.replace(/^\uFEFF/, "").trim() を入れて対策しています。

セキュリティ脆弱性への対応

今回のブラウザ拡張については、セキュリティ面も途中で見直し、サイトごとのシークレット情報を記事データに直接含めないようにしています。

投稿待ち一覧に表示するアイテムは、サイト名やURLや記事情報だけを持ち、シークレットは含めません。実際に fetchNextItem() では siteSecret を返さないようにしてあります。

そのうえで、「投稿済みにする」処理のときだけ、保存済みのサイト設定から該当サイトのシークレットを取り出して使う形にしました。つまり、必要なタイミングだけシークレットを使う構成です。

設定画面でも、シークレットキーはパスワード入力欄にしてあり、平文のまま見えないようにしています。Xアカウントもサイトごとに設定できるようにしつつ、必要最小限の情報だけを持たせる形です。

  • シークレットは wp-config.php で定義する
  • PHPソースに直書きしない
  • Chrome拡張のソースには含めない
  • hash_equals() を使う

といった方針を行っています。

企業向けの厳格な認証基盤ほどではありませんが、少なくとも「試作版のまま」よりはかなり安全寄りに整えられました。

開発したソースコードと設定方法

ここからは、実際に今回作成したブラウザ拡張と、WordPress側の設定内容について実際のコードも公開します。

「どういう仕組みなのか」は分かったけれど、実際には何を作ったのか知りたい、という方向けのパートです。

今回の仕組みは、大きく分けると次の2つで構成されています。

  • Chrome拡張側
  • WordPress側

役割としてはとてもシンプルで、WordPress側は「投稿待ちの記事情報を返す側」、Chrome拡張側は「その情報を取得して、X投稿を支援する側」です。READMEでも、拡張本体とWordPress側ファイルを分けた構成として整理しています。

Chrome拡張側の構成

Chrome拡張側は、主に次のファイルでできています。

  • manifest.json
  • background.js
  • popup.html
  • popup.js

manifest.json では、拡張の基本情報と権限を定義しています。

{
  "manifest_version": 3,
  "name": "WP to X Helper",
  "version": "1.0.0",
  "description": "WordPressの新着記事をX投稿画面へ半自動で流し込む拡張",
  "permissions": [
    "storage",
    "alarms",
    "tabs"
  ],
  "host_permissions": [
    "https://site1.com/*",
    "https://site2.com/*",
    "https://x.com/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html"
  }
}

設定する際には、host_permissionsには、自分のWordPressサイトの情報を入れてください。

今回の拡張では、必要な権限を storagealarmstabs に絞り、アクセス先も対象のWordPressサイトと x.com のみに限定しています。つまり、不要に広い権限を取らないようにしています。

background.js は、拡張の裏側で動く本体です。

const DEFAULT_SITES = [
  { name: "サイト1",  siteBase: "https://site1.com/", secret: "", xUsername: "" },
  { name: "サイト2",  siteBase: "https://site2.com/", secret: "", xUsername: "" }
];

const CONFIG = {
  pollAlarmName:  "checkWpQueueMulti",
  pollMinutes:    5,
  sitesConfigKey: "sitesConfig"
};

async function getSitesConfig() {
  const result = await chrome.storage.local.get(CONFIG.sitesConfigKey);
  return result[CONFIG.sitesConfigKey] || DEFAULT_SITES;
}

async function saveSitesConfig(sites) {
  await chrome.storage.local.set({ [CONFIG.sitesConfigKey]: sites });
}

chrome.runtime.onInstalled.addListener(async () => {
  const existing = await chrome.storage.local.get(CONFIG.sitesConfigKey);
  if (!existing[CONFIG.sitesConfigKey]) {
    await saveSitesConfig(DEFAULT_SITES);
  }
  await ensureAlarm();
  await refreshAllSites();
});

chrome.runtime.onStartup.addListener(async () => {
  await ensureAlarm();
  await refreshAllSites();
});

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === CONFIG.pollAlarmName) {
    await refreshAllSites();
  }
});

async function ensureAlarm() {
  const alarm = await chrome.alarms.get(CONFIG.pollAlarmName);
  if (!alarm) {
    await chrome.alarms.create(CONFIG.pollAlarmName, {
      periodInMinutes: CONFIG.pollMinutes
    });
  }
}

async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 15000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const res = await fetch(url, {
      ...options,
      signal: controller.signal
    });

    let text = await res.text();
    text = text.replace(/^\uFEFF/, "").trim();

    let data = null;
    try {
      data = text ? JSON.parse(text) : null;
    } catch (e) {
      throw new Error(`invalid_json: ${text.slice(0, 200)}`);
    }

    if (!res.ok) {
      const message = data?.message || data?.error || `http_${res.status}`;
      throw new Error(message);
    }

    return data;
  } finally {
    clearTimeout(timer);
  }
}

async function fetchNextItem(site) {
  const base = site.siteBase.replace(/\/$/, "");
  const url = `${base}/wp-json/x-helper/v1/next`;

  const data = await fetchJsonWithTimeout(url, {
    method: "GET",
    headers: {
      "X-WP-X-Secret": site.secret
    }
  });

  if (!data?.ok) {
    throw new Error(`${site.name}: ${data?.error || "fetch failed"}`);
  }

  if (!data.item) {
    return null;
  }

  // siteSecret はここに含めない (V-004)
  return {
    ...data.item,
    siteName:   site.name,
    siteBase:   site.siteBase,
    xUsername:  site.xUsername || ""
  };
}

async function refreshAllSites() {
  const sites = await getSitesConfig();
  const configured = sites.filter(s => s.secret);

  const items  = [];
  const errors = [];

  for (const site of configured) {
    try {
      const item = await fetchNextItem(site);
      if (item) items.push(item);
    } catch (e) {
      errors.push(`${site.name}: ${String(e.message || e)}`);
    }
  }

  await chrome.storage.local.set({
    currentItems:    items,
    lastErrors:      errors,
    lastRefreshedAt: new Date().toISOString()
  });

  if (items.length > 0) {
    await chrome.action.setBadgeText({ text: String(items.length) });
  } else if (errors.length > 0) {
    await chrome.action.setBadgeText({ text: "!" });
  } else {
    await chrome.action.setBadgeText({ text: "" });
  }
}

async function markPosted(siteBase, siteSecret, postId) {
  const base = siteBase.replace(/\/$/, "");
  const url = `${base}/wp-json/x-helper/v1/mark-posted`;

  const data = await fetchJsonWithTimeout(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-WP-X-Secret": siteSecret
    },
    body: JSON.stringify({ post_id: postId })
  });

  return data;
}

async function getStoredItems() {
  const result = await chrome.storage.local.get([
    "currentItems",
    "lastErrors",
    "lastRefreshedAt"
  ]);

  return {
    items:           result.currentItems    || [],
    errors:          result.lastErrors      || [],
    lastRefreshedAt: result.lastRefreshedAt || null
  };
}

chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  (async () => {
    try {
      if (message.type === "GET_ALL_ITEMS") {
        const result = await getStoredItems();
        sendResponse({
          ok:             true,
          items:          result.items,
          errors:         result.errors,
          lastRefreshedAt: result.lastRefreshedAt
        });
        return;
      }

      if (message.type === "REFRESH_ALL_ITEMS") {
        await refreshAllSites();
        const result = await getStoredItems();
        sendResponse({
          ok:             true,
          items:          result.items,
          errors:         result.errors,
          lastRefreshedAt: result.lastRefreshedAt
        });
        return;
      }

      if (message.type === "OPEN_X_COMPOSER") {
        const username = message.xUsername ? message.xUsername.replace(/^@/, "") : "";
        const url = username ? `https://x.com/${username}` : "https://x.com/home";
        await chrome.tabs.create({ url, active: true });
        sendResponse({ ok: true });
        return;
      }

      if (message.type === "MARK_POSTED") {
        const item = message.item;
        if (!item) {
          sendResponse({ ok: false, error: "no_item" });
          return;
        }

        // シークレットはストレージのサイト設定から取得 (V-004)
        const sites = await getSitesConfig();
        const site  = sites.find(s => s.siteBase === item.siteBase);
        if (!site || !site.secret) {
          sendResponse({ ok: false, error: "site_not_configured" });
          return;
        }

        const markRes = await markPosted(item.siteBase, site.secret, item.post_id);
        await refreshAllSites();
        sendResponse({ ok: !!markRes?.ok, ...markRes });
        return;
      }

      if (message.type === "GET_SITES_CONFIG") {
        const sites = await getSitesConfig();
        sendResponse({ ok: true, sites });
        return;
      }

      if (message.type === "SAVE_SITES_CONFIG") {
        if (!Array.isArray(message.sites)) {
          sendResponse({ ok: false, error: "invalid_sites" });
          return;
        }
        await saveSitesConfig(message.sites);
        await refreshAllSites();
        sendResponse({ ok: true });
        return;
      }

      sendResponse({ ok: false, error: "unknown_message" });
    } catch (e) {
      sendResponse({ ok: false, error: String(e.message || e) });
    }
  })();

  return true;
});

このJavaScriptは、以下の動きを担っています。

  • 登録済みサイトの設定を保存・取得する
  • 定期的に各WordPressサイトを確認する
  • 投稿待ちの記事を取得する
  • 投稿済み処理を行う
  • 設定したXアカウントのページを開く

特に、複数サイトの設定を sitesConfig として保持し、各サイトごとに siteBasesecretxUsername を管理するようにしています。

設定する際には、DEFAULT_SITESnamesiteBaseは、自分のWordPressサイトの情報を入れてください。

popup.html は、拡張アイコンを押したときに出てくる画面の見た目です。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>WP to X Helper</title>
  <style>
    body {
      font-family: sans-serif;
      width: 340px;
      padding: 12px;
      line-height: 1.5;
    }
    .tabs {
      display: flex;
      gap: 6px;
      margin-bottom: 12px;
    }
    .tabBtn {
      flex: 1;
      width: auto;
      margin-top: 0;
      padding: 6px;
      border: 1px solid #ddd;
      border-radius: 6px;
      background: #f3f4f6;
      cursor: pointer;
      font-size: 13px;
    }
    .tabBtn.active {
      background: #dbeafe;
      border-color: #93c5fd;
      font-weight: bold;
    }
    .panel { display: none; }
    .panel.active { display: block; }
    .box {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 10px;
      margin-bottom: 10px;
    }
    .title {
      font-weight: bold;
      margin-bottom: 6px;
    }
    .muted {
      color: #666;
      font-size: 12px;
    }
    button {
      width: 100%;
      margin-top: 8px;
      padding: 10px;
      border: none;
      border-radius: 8px;
      cursor: pointer;
    }
    #refreshBtn { background: #f3f4f6; }
    #toastMsg {
      font-size: 12px;
      color: #16a34a;
      background: #f0fdf4;
      border: 1px solid #bbf7d0;
      border-radius: 6px;
      padding: 6px 10px;
      margin-top: 8px;
      display: none;
    }
    pre {
      white-space: pre-wrap;
      word-break: break-word;
      background: #fafafa;
      padding: 8px;
      border-radius: 6px;
      font-size: 12px;
    }
    .site-config {
      border: 1px solid #e5e7eb;
      border-radius: 6px;
      padding: 8px;
      margin-bottom: 8px;
    }
    .site-config label {
      display: block;
      font-size: 12px;
      color: #555;
      margin-top: 6px;
    }
    .site-config input {
      width: 100%;
      box-sizing: border-box;
      padding: 5px 7px;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 13px;
      margin-top: 2px;
    }
    #saveSettingsBtn { background: #dcfce7; margin-top: 10px; }
    #addSiteBtn      { background: #f3f4f6; margin-top: 4px; font-size: 13px; padding: 7px; }
    .removeSiteBtn   { background: #fee2e2; margin-top: 6px; font-size: 12px; padding: 5px; width: auto; }
    #settingsMsg     { font-size: 12px; margin-top: 6px; color: #16a34a; min-height: 18px; }
    .hashtags        { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
    .ht-label        { font-size: 12px; background: #f0f9ff; border: 1px solid #bae6fd;
                       border-radius: 4px; padding: 2px 6px; cursor: pointer; user-select: none; }
    .ht-label input  { margin-right: 3px; }
  </style>
</head>
<body>
  <div class="tabs">
    <button class="tabBtn active" id="tabQueue">投稿待ち</button>
    <button class="tabBtn" id="tabSettings">設定</button>
  </div>

  <!-- 投稿待ちパネル -->
  <div id="panelQueue" class="panel active">
    <div class="box">
      <div class="title">現在の投稿待ち</div>
      <div id="status">読み込み中...</div>
    </div>
    <button id="refreshBtn">最新状態を確認</button>
    <div id="toastMsg"></div>
  </div>

  <!-- 設定パネル -->
  <div id="panelSettings" class="panel">
    <div class="box">
      <div class="title">サイト設定</div>
      <div class="muted" style="margin-bottom:8px;">各サイトのシークレットキーを入力してください。</div>
      <div id="sitesForm"></div>
      <button id="addSiteBtn">+ サイトを追加</button>
    </div>
    <button id="saveSettingsBtn">設定を保存</button>
    <div id="settingsMsg"></div>
  </div>

  <script src="popup.js"></script>
</body>
</html>

今回の最終版では、「投稿待ち」と「設定」の2タブ構成になっています。投稿待ち一覧を確認するだけでなく、その場でサイト設定も変更できるようにしたことで、実運用しやすくなりました。

popup.js は、そのポップアップ画面の動作部分です。

const statusEl     = document.getElementById("status");
const refreshBtn   = document.getElementById("refreshBtn");
const tabQueue     = document.getElementById("tabQueue");
const tabSettings  = document.getElementById("tabSettings");
const panelQueue   = document.getElementById("panelQueue");
const panelSettings = document.getElementById("panelSettings");
const sitesForm    = document.getElementById("sitesForm");
const saveSettingsBtn = document.getElementById("saveSettingsBtn");
const addSiteBtn   = document.getElementById("addSiteBtn");
const settingsMsg  = document.getElementById("settingsMsg");
const toastMsg     = document.getElementById("toastMsg");

function showToast(msg, durationMs = 3000) {
  toastMsg.textContent = msg;
  toastMsg.style.display = "block";
  setTimeout(() => { toastMsg.style.display = "none"; }, durationMs);
}

// ---------- タブ切り替え ----------

tabQueue.addEventListener("click", () => {
  tabQueue.classList.add("active");
  tabSettings.classList.remove("active");
  panelQueue.classList.add("active");
  panelSettings.classList.remove("active");
});

tabSettings.addEventListener("click", () => {
  tabSettings.classList.add("active");
  tabQueue.classList.remove("active");
  panelSettings.classList.add("active");
  panelQueue.classList.remove("active");
  loadSettings();
});

// ---------- ハッシュタグ変換 ----------

function toHashtag(tagName) {
  // スペース(全角半角)・_・-・# を除去
  let s = String(tagName).replace(/[\s\u3000_\-#]+/g, "");
  // x.y.z 形式のバージョン情報は非表示
  if (/^\d+(\.\d+)+$/.test(s)) return null;
  // 全角文字(U+2E80以上)が15文字以上は非表示
  const fwCount = [...s].filter(c => c.codePointAt(0) >= 0x2E80).length;
  if (fwCount >= 15) return null;
  if (!s) return null;
  return "#" + s;
}

// ---------- ユーティリティ ----------

function escapeHtml(str) {
  return String(str)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

function sendMessage(message) {
  return new Promise((resolve) => {
    chrome.runtime.sendMessage(message, resolve);
  });
}

// ---------- 投稿待ちパネル ----------

function render(items, errors, lastRefreshedAt) {
  if ((!items || items.length === 0) && (!errors || errors.length === 0)) {
    statusEl.innerHTML = `<div class="muted">現在、投稿待ちはありません。</div>`;
    bindItemButtons([]);
    return;
  }

  let html = "";

  if (lastRefreshedAt) {
    html += `<div class="muted" style="margin-bottom:8px;">最終更新: ${escapeHtml(lastRefreshedAt)}</div>`;
  }

  if (items && items.length > 0) {
    items.forEach((item, index) => {
      const hashtags = (item.tags || []).map(toHashtag).filter(Boolean);
      const hashtagsHtml = hashtags.length > 0
        ? `<div class="hashtags" data-index="${index}">` +
          hashtags.map(ht =>
            `<label class="ht-label"><input type="checkbox" class="ht-cb" value="${escapeHtml(ht)}" checked>${escapeHtml(ht)}</label>`
          ).join("") +
          `</div>`
        : "";

      const xBadge = item.xUsername
        ? `<span class="muted"> → @${escapeHtml(item.xUsername.replace(/^@/, ""))}</span>`
        : "";

      html += `
        <div class="item">
          <div class="site">${escapeHtml(item.siteName)}${xBadge}</div>
          <div><strong>${escapeHtml(item.title)}</strong></div>
          <div class="muted">Post ID: ${escapeHtml(String(item.post_id))}</div>
          <div class="muted">${escapeHtml(item.url)}</div>
          <pre>${escapeHtml(item.text || "")}</pre>
          ${hashtagsHtml}
          <button class="actionBtn openBtn" data-index="${index}">Xを開いて文面をコピーする</button>
          <button class="actionBtn markBtn" data-index="${index}">投稿済みにする</button>
        </div>
      `;
    });
  }

  if (errors && errors.length > 0) {
    html += `<div class="error">一部サイトで取得エラーがあります。<br>${errors.map(escapeHtml).join("<br>")}</div>`;
  }

  statusEl.innerHTML = html;
  bindItemButtons(items);
}

function bindItemButtons(items = []) {
  document.querySelectorAll(".openBtn").forEach((btn) => {
    btn.addEventListener("click", async () => {
      const index = Number(btn.dataset.index);
      const item  = items[index];

      if (!item) {
        alert("投稿待ちはありません。");
        return;
      }

      const hashtagsEl = document.querySelector(`.hashtags[data-index="${index}"]`);
      const selectedTags = hashtagsEl
        ? Array.from(hashtagsEl.querySelectorAll(".ht-cb:checked")).map(cb => cb.value)
        : [];
      const textToCopy = selectedTags.length > 0
        ? `${item.text || ""}\n${selectedTags.join(" ")}`
        : (item.text || "");

      try {
        await navigator.clipboard.writeText(textToCopy);
      } catch {
        showToast("クリップボードへのコピーに失敗しました。");
        return;
      }

      const res = await sendMessage({ type: "OPEN_X_COMPOSER", xUsername: item.xUsername || "" });
      if (!res?.ok) {
        showToast("X投稿画面を開けませんでした。");
        return;
      }

      showToast("クリップボードにコピーしました。Xで Ctrl+V で貼り付けてください。");
    });
  });

  document.querySelectorAll(".markBtn").forEach((btn) => {
    btn.addEventListener("click", async () => {
      const index = Number(btn.dataset.index);
      const item  = items[index];

      if (!item) {
        alert("対象記事が見つかりません。");
        return;
      }

      const ok = confirm(`「${item.title}」を投稿済みにします。手動でX投稿後に押してください。`);
      if (!ok) return;

      const res = await sendMessage({ type: "MARK_POSTED", item });
      if (res?.ok) {
        showToast("投稿済みに更新しました。");
        await loadItems();
      } else {
        showToast(`更新に失敗しました: ${res?.error || "unknown error"}`);
      }
    });
  });
}

async function loadItems() {
  const res = await sendMessage({ type: "GET_ALL_ITEMS" });
  if (res?.ok) {
    render(res.items || [], res.errors || [], res.lastRefreshedAt || null);
  } else {
    statusEl.textContent = "取得に失敗しました。";
  }
}

refreshBtn.addEventListener("click", async () => {
  statusEl.textContent = "更新中...";
  const res = await sendMessage({ type: "REFRESH_ALL_ITEMS" });
  if (res?.ok) {
    render(res.items || [], res.errors || [], res.lastRefreshedAt || null);
  } else {
    statusEl.textContent = "更新に失敗しました。";
  }
});

// ---------- 設定パネル ----------

function buildSiteRow(site = { name: "", siteBase: "", secret: "", xUsername: "" }) {
  const div = document.createElement("div");
  div.className = "site-config";
  div.innerHTML = `
    <label>サイト名
      <input type="text" class="cfg-name" value="${escapeHtml(site.name)}" placeholder="例: site1.com" />
    </label>
    <label>サイトURL
      <input type="url" class="cfg-base" value="${escapeHtml(site.siteBase)}" placeholder="https://example.com/" />
    </label>
    <label>シークレットキー
      <input type="password" class="cfg-secret" value="${escapeHtml(site.secret)}" placeholder="wp_x_queue_secret() の値" autocomplete="off" />
    </label>
    <label>X アカウント
      <input type="text" class="cfg-xusername" value="${escapeHtml(site.xUsername || "")}" placeholder="@username(@ は省略可)" />
    </label>
    <button class="removeSiteBtn">削除</button>
  `;
  div.querySelector(".removeSiteBtn").addEventListener("click", () => div.remove());
  return div;
}

async function loadSettings() {
  sitesForm.innerHTML = "";
  settingsMsg.textContent = "";
  const res = await sendMessage({ type: "GET_SITES_CONFIG" });
  const sites = res?.sites || [];
  sites.forEach(site => sitesForm.appendChild(buildSiteRow(site)));
}

addSiteBtn.addEventListener("click", () => {
  sitesForm.appendChild(buildSiteRow());
});

saveSettingsBtn.addEventListener("click", async () => {
  const rows  = sitesForm.querySelectorAll(".site-config");
  const sites = Array.from(rows).map(row => ({
    name:      row.querySelector(".cfg-name").value.trim(),
    siteBase:  row.querySelector(".cfg-base").value.trim(),
    secret:    row.querySelector(".cfg-secret").value.trim(),
    xUsername: row.querySelector(".cfg-xusername").value.trim()
  })).filter(s => s.name && s.siteBase);

  const res = await sendMessage({ type: "SAVE_SITES_CONFIG", sites });
  settingsMsg.textContent = res?.ok ? "保存しました。" : `保存失敗: ${res?.error || "unknown"}`;
});

// ---------- 起動 ----------

loadItems();

このJavaScriptは、以下の動きを担っています。

  • 投稿待ち記事の一覧表示
  • WordPressタグからハッシュタグ候補への変換
  • チェックしたハッシュタグ込みで本文をコピー
  • Xの対象アカウントを開く
  • 投稿済み処理
  • サイト設定の保存

タグをそのまま使うのではなく、ハッシュタグとして使いやすい形に整形する toHashtag() のような処理もここに入っています。

WordPress側の構成

WordPress側は、テーマや環境に応じて次のどちらかに導入する形を想定しています。

  • Snow Monkey 用の my-snow-monkey.php
  • 一般テーマ用の functions.php 追記

Snow Monkey用のmy-snow-monkey.phpに追加するコード

/**
 * WP to X helper
 * 半自動投稿キュー for Snow Monkey / My Snow Monkey
 */

if (!defined('ABSPATH')) {
	exit;
}

function wp_x_queue_secret() {
	return defined('WP_X_HELPER_SECRET') ? WP_X_HELPER_SECRET : '';
}

function wp_x_queue_target_post_types() {
	return array('post');
}

function wp_x_queue_option_name() {
	return 'wp_x_post_queue';
}

/**
 * 認証ヘッダー名
 */
function wp_x_queue_header_name() {
	return 'X-WP-X-Secret';
}

/**
 * リクエスト認証
 */
function wp_x_rest_permission_check(WP_REST_Request $request) {
	$secret = $request->get_header(wp_x_queue_header_name());

	if (!$secret || !hash_equals(wp_x_queue_secret(), $secret)) {
		return new WP_Error(
			'wp_x_unauthorized',
			'unauthorized',
			array('status' => 401)
		);
	}

	return true;
}

/**
 * 記事公開時にキューへ追加
 */
add_action('transition_post_status', 'wp_x_queue_on_publish', 10, 3);
function wp_x_queue_on_publish($new_status, $old_status, $post) {
	if (empty($post) || !is_object($post)) {
		return;
	}

	if ($new_status !== 'publish' || $old_status === 'publish') {
		return;
	}

	if (wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) {
		return;
	}

	if (!in_array($post->post_type, wp_x_queue_target_post_types(), true)) {
		return;
	}

	$queue = get_option(wp_x_queue_option_name(), array());

	foreach ($queue as $q) {
		if (isset($q['post_id']) && (int) $q['post_id'] === (int) $post->ID && ($q['status'] ?? '') === 'pending') {
			return;
		}
	}

	$excerpt = get_the_excerpt($post->ID);
	if (empty($excerpt)) {
		$raw_content = get_post_field('post_content', $post->ID);
		$excerpt = wp_trim_words(wp_strip_all_tags($raw_content), 40, '…');
	}

	$item = array(
		'post_id'    => (int) $post->ID,
		'post_type'  => (string) $post->post_type,
		'title'      => html_entity_decode(get_the_title($post->ID), ENT_QUOTES, 'UTF-8'),
		'url'        => get_permalink($post->ID),
		'excerpt'    => html_entity_decode(wp_strip_all_tags($excerpt), ENT_QUOTES, 'UTF-8'),
		'tags'       => wp_get_post_tags($post->ID, ['fields' => 'names']),
		'status'     => 'pending',
		'created_at' => current_time('mysql'),
	);

	array_unshift($queue, $item);

	// posted 済みアイテムは最新30件のみ保持
	$posted  = array_filter($queue, fn($q) => ($q['status'] ?? '') === 'posted');
	$pending = array_filter($queue, fn($q) => ($q['status'] ?? '') !== 'posted');
	$posted  = array_slice(array_values($posted), 0, 30);
	$queue   = array_merge(array_values($pending), $posted);

	update_option(wp_x_queue_option_name(), $queue, false);
}

/**
 * 投稿文生成
 */
function wp_x_build_text($item) {
	$title = isset($item['title']) ? trim((string) $item['title']) : '';
	$url   = isset($item['url']) ? trim((string) $item['url']) : '';

	$text = $title . "\n" . $url;

	if (mb_strlen($text) > 260) {
		$max_title_len = max(20, 250 - mb_strlen($url));
		$short_title = mb_substr($title, 0, $max_title_len) . '…';
		$text = $short_title . "\n" . $url;
	}

	return $text;
}

/**
 * REST API登録
 */
add_action('rest_api_init', function () {
	register_rest_route('x-helper/v1', '/next', array(
		'methods'             => 'GET',
		'callback'            => 'wp_x_rest_get_next_item',
		'permission_callback' => 'wp_x_rest_permission_check',
	));

	register_rest_route('x-helper/v1', '/mark-posted', array(
		'methods'             => 'POST',
		'callback'            => 'wp_x_rest_mark_posted',
		'permission_callback' => 'wp_x_rest_permission_check',
	));

});

function wp_x_rest_get_next_item(WP_REST_Request $request) {
	$queue = get_option(wp_x_queue_option_name(), array());

	foreach ($queue as $item) {
		if (($item['status'] ?? '') === 'pending') {
			$item['text'] = wp_x_build_text($item);

			return new WP_REST_Response(array(
				'ok'   => true,
				'item' => $item,
			), 200);
		}
	}

	return new WP_REST_Response(array(
		'ok'   => true,
		'item' => null,
	), 200);
}

function wp_x_rest_mark_posted(WP_REST_Request $request) {
	$params  = $request->get_json_params();
	$post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0;

	if (!$post_id) {
		return new WP_REST_Response(array(
			'ok'    => false,
			'error' => 'missing_post_id',
		), 400);
	}

	$queue = get_option(wp_x_queue_option_name(), array());

	foreach ($queue as &$item) {
		if (isset($item['post_id']) && (int) $item['post_id'] === $post_id) {
			$item['status']    = 'posted';
			$item['posted_at'] = current_time('mysql');

			update_option(wp_x_queue_option_name(), $queue, false);

			return new WP_REST_Response(array(
				'ok'      => true,
				'post_id' => $post_id,
			), 200);
		}
	}

	return new WP_REST_Response(array(
		'ok'    => false,
		'error' => 'not_found',
	), 404);
}

一般テーマ用の functions.php に追記するコード

/**
 * WP to X helper
 * 半自動投稿キュー for general WordPress
 */

if (!defined('ABSPATH')) {
	exit;
}

/**
 * 共有シークレット
 * Chrome拡張側と同じ値にしてください
 */
function wp_x_queue_secret() {
	return defined('WP_X_HELPER_SECRET') ? WP_X_HELPER_SECRET : '';
}

/**
 * 対象の投稿タイプ
 * 必要なら array('post', 'news', 'column') のように増やせます
 */
function wp_x_queue_target_post_types() {
	return array('post');
}

/**
 * オプション名
 */
function wp_x_queue_option_name() {
	return 'wp_x_post_queue';
}

/**
 * 認証ヘッダー名
 */
function wp_x_queue_header_name() {
	return 'X-WP-X-Secret';
}

/**
 * REST API認証
 */
function wp_x_rest_permission_check(WP_REST_Request $request) {
	$secret = $request->get_header(wp_x_queue_header_name());

	if (!$secret || !hash_equals(wp_x_queue_secret(), $secret)) {
		return new WP_Error(
			'wp_x_unauthorized',
			'unauthorized',
			array('status' => 401)
		);
	}

	return true;
}

/**
 * 記事公開時にキューへ追加
 */
add_action('transition_post_status', 'wp_x_queue_on_publish', 10, 3);
function wp_x_queue_on_publish($new_status, $old_status, $post) {
	if (empty($post) || !is_object($post)) {
		return;
	}

	// 初回公開時のみ
	if ($new_status !== 'publish' || $old_status === 'publish') {
		return;
	}

	// 自動保存・リビジョン除外
	if (wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) {
		return;
	}

	// 投稿タイプ制限
	if (!in_array($post->post_type, wp_x_queue_target_post_types(), true)) {
		return;
	}

	$queue = get_option(wp_x_queue_option_name(), array());

	// pending 状態で同じ投稿が既に登録済みなら追加しない
	foreach ($queue as $q) {
		if (isset($q['post_id']) && (int) $q['post_id'] === (int) $post->ID && ($q['status'] ?? '') === 'pending') {
			return;
		}
	}

	$excerpt = get_the_excerpt($post->ID);
	if (empty($excerpt)) {
		$raw_content = get_post_field('post_content', $post->ID);
		$excerpt = wp_trim_words(wp_strip_all_tags($raw_content), 40, '…');
	}

	$item = array(
		'post_id'    => (int) $post->ID,
		'post_type'  => (string) $post->post_type,
		'title'      => html_entity_decode(get_the_title($post->ID), ENT_QUOTES, 'UTF-8'),
		'url'        => get_permalink($post->ID),
		'excerpt'    => html_entity_decode(wp_strip_all_tags($excerpt), ENT_QUOTES, 'UTF-8'),
		'tags'       => wp_get_post_tags($post->ID, ['fields' => 'names']),
		'status'     => 'pending',
		'created_at' => current_time('mysql'),
	);

	array_unshift($queue, $item);

	// posted 済みアイテムは最新30件のみ保持
	$posted  = array_filter($queue, fn($q) => ($q['status'] ?? '') === 'posted');
	$pending = array_filter($queue, fn($q) => ($q['status'] ?? '') !== 'posted');
	$posted  = array_slice(array_values($posted), 0, 30);
	$queue   = array_merge(array_values($pending), $posted);

	update_option(wp_x_queue_option_name(), $queue, false);
}

/**
 * 投稿文生成
 */
function wp_x_build_text($item) {
	$title = isset($item['title']) ? trim((string) $item['title']) : '';
	$url   = isset($item['url']) ? trim((string) $item['url']) : '';

	$text = $title . "\n" . $url;

	if (mb_strlen($text) > 260) {
		$max_title_len = max(20, 250 - mb_strlen($url));
		$short_title = mb_substr($title, 0, $max_title_len) . '…';
		$text = $short_title . "\n" . $url;
	}

	return $text;
}

/**
 * REST API登録
 */
add_action('rest_api_init', function () {
	register_rest_route('x-helper/v1', '/next', array(
		'methods'             => 'GET',
		'callback'            => 'wp_x_rest_get_next_item',
		'permission_callback' => 'wp_x_rest_permission_check',
	));

	register_rest_route('x-helper/v1', '/mark-posted', array(
		'methods'             => 'POST',
		'callback'            => 'wp_x_rest_mark_posted',
		'permission_callback' => 'wp_x_rest_permission_check',
	));

});

/**
 * 最新の pending を1件返す
 */
function wp_x_rest_get_next_item(WP_REST_Request $request) {
	$queue = get_option(wp_x_queue_option_name(), array());

	foreach ($queue as $item) {
		if (($item['status'] ?? '') === 'pending') {
			$item['text'] = wp_x_build_text($item);

			return new WP_REST_Response(array(
				'ok'   => true,
				'item' => $item,
			), 200);
		}
	}

	return new WP_REST_Response(array(
		'ok'   => true,
		'item' => null,
	), 200);
}

/**
 * 投稿済みに変更
 */
function wp_x_rest_mark_posted(WP_REST_Request $request) {
	$params  = $request->get_json_params();
	$post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0;

	if (!$post_id) {
		return new WP_REST_Response(array(
			'ok'    => false,
			'error' => 'missing_post_id',
		), 400);
	}

	$queue = get_option(wp_x_queue_option_name(), array());

	foreach ($queue as &$item) {
		if (isset($item['post_id']) && (int) $item['post_id'] === $post_id) {
			$item['status']    = 'posted';
			$item['posted_at'] = current_time('mysql');

			update_option(wp_x_queue_option_name(), $queue, false);

			return new WP_REST_Response(array(
				'ok'      => true,
				'post_id' => $post_id,
			), 200);
		}
	}

	return new WP_REST_Response(array(
		'ok'    => false,
		'error' => 'not_found',
	), 404);
}

WordPress側の役割は、主に次の2つです。

  • 新しく公開された記事を「投稿待ちキュー」に追加する
  • Chrome拡張からのリクエストに対して、投稿待ち記事を返す

また、投稿済みになった記事については、mark-posted のAPI経由で状態を更新できるようにしています。

各WordPressのサイトととの通信にあたっては、wp-config.phpにある

/* 編集が必要なのはここまでです ! WordPress でブログをお楽しみください。 */

の前に、

/* x-helper シークレットキー */
define('WP_X_HELPER_SECRET', 'Weサイト毎に作成したシークレットキー');

を入れてください。

このシークレットキーは、推測されにくいランダムな文字列(例:パスワードジェネレーターで生成した32文字以上)を使うと安全です。

ブラウザ拡張機能の設定方法

実際の設定方法は以下の通り。

1. WordPress側の準備

まず、各WordPressサイトに必要なコードを設置します。

Snow Monkeyを使っている場合は my-snow-monkey.php、一般的なWordPressテーマの場合は functions.php に追記する形です。どちらの場合も、WordPressで記事が新規公開されたときに、その記事を「X投稿待ち」として記録できるようにします。

さらに、各サイトの wp-config.php に、シークレットキーを定義します。READMEでも、require_once(ABSPATH . 'wp-settings.php') より前に WP_X_HELPER_SECRET を定義するよう案内しています。

このシークレットキーは、拡張側とWordPress側で共通して使う認証用の文字列です。推測されにくい長いランダム文字列にしておくのが前提です。

2. Chrome拡張を読み込む

次に、Chromeで拡張を読み込みます。

手順としては、以下の通り。

  1. Chromeで chrome://extensions を開く
  2. デベロッパーモードをオンにする
  3. 「パッケージ化されていない拡張機能を読み込む」を選ぶ
  4. ブラウザ拡張機能のファイルの入ったフォルダを指定する

3. 拡張の設定画面でサイト情報を登録する

ブラウザ拡張機能を読み込んだら、ブラウザの上部に表示される「W」マークをクリックしてポップアップの「設定」タブを開き、各サイトを登録します。

設定する内容は次の4つです。

  • サイト名
  • サイトURL
  • シークレットキー
  • Xアカウント

ポップアップの設定画面にも、これらを入力するフォームがあります。Xアカウントは @ ありでもなしでも入力できるようにしています。

たとえば、以下のように登録します。

  • サイト名:サイト1
  • サイトURL:https://site1.com/
  • シークレットキー:wp-config.php で設定したシークレットキーの値
  • Xアカウント:@your_account

これを複数サイト分登録しておけば、拡張が定期的にそれぞれのサイトを見に行き、投稿待ち記事をまとめて表示してくれます。初期値として2サイト分のデフォルト設定も用意されています。

実際の使い方

設定が終わったら、使い方はとてもシンプルです。

まず、WordPressで記事を新規公開します。すると、その記事が投稿待ちとしてキューに入ります。

次に、ブラウザ拡張機能のアイコンを押して、「投稿待ち」タブを開きます。ここに、まだXへ投稿していない記事が一覧表示されます。

ポップアップでは、記事タイトル、URL、本文プレビュー、ハッシュタグ候補が表示されるようになっています。

その中から投稿したい記事を選び、必要なハッシュタグにチェックを入れたうえで、「Xを開いて文面をコピーする」を押します。すると、記事タイトルとURL、選択したハッシュタグをまとめた本文がクリップボードにコピーされ、指定したXアカウントのページが開きます。Xを開く先は、設定した xUsername に応じて変わるようになっています。

あとはX側で貼り付けて、必要なら少し文面を整え、「ポスト」ボタンを押します。投稿が終わったら、拡張に戻って「投稿済みにする」を押せば完了です。

ハッシュタグ生成のルール

今回の仕組みの中で、運用面でかなり便利だったのが、WordPressのタグをそのままハッシュタグ候補にできるようにした点です。

ただし、タグをそのまま使うと、X向きではないものも混ざります。そのため、拡張側でいくつか整形ルールを入れています。

  • スペースは削除する
  • _-# は除去する
  • 1.2.3 のようなバージョン番号は除外する
  • 長すぎる全角タグは除外する
  • 空文字になったものは使わない

こうして、実際にXのハッシュタグとして使いやすい候補だけを表示するようにしています。実装上も toHashtag() の中でそのルールをまとめています。

このおかげで、完全自動ではないものの、毎回ハッシュタグを一から考える負担はかなり減りました。しかも、候補の中から必要なものだけ選べるので、記事に合わせた調整もできます。

この構成にしてよかったこと

実際にここまで作ってみて感じたのは、「全部自動にしない」ことが、逆にちょうどよかったということです。

  • WordPressで記事が公開されたことを拾うところまでは自動。
  • 本文やタグ候補を用意するところまでも自動。
  • でも、最後の確認と投稿だけは人がやる。

この分け方にしたことで、

  • 毎回の単純作業は減る
  • ハッシュタグの自由度は残る
  • 複数サイトをまとめて扱える
  • 誤投稿のリスクも下げやすい

という、かなり実運用向きの形になりました。

ブラウザ拡張機能で作成した半自動投稿機能を使うメリット

実際に作ってみて感じたメリットは、次の通りです。

まず、Xへの投稿作業がかなり楽になることです。タイトル、URL、ハッシュタグ候補が最初から揃っているので、毎回コピー&ペーストを繰り返す必要がありません。

次に、完全自動ではないからこそ自由度が高いことです。

記事によってハッシュタグを変えたい、文末を少し変えたい、サイトごとに違うアカウントで投稿したい、といった運用には、半自動の方が合っています。

さらに、複数サイトをまとめて見られるのも便利です。どのサイトで、どの記事が、まだXへ流れていないかをひとつの拡張で確認できます。

それでもAPIを使った完全自動が最適な場合もある

今回の仕組みは、半自動であることに価値があります。

ただし、もちろんすべてのケースでこれが最適というわけではありません。

たとえば、以下のようなケースにおいては、XのAPIを正式に使った自動投稿の方が向いています。

  • 24時間完全自動で回したい
  • 投稿量が多い
  • 承認フローや監査ログが必要
  • 人手をできるだけ介在させたくない


実際、今のX APIは従量課金になったことで、以前より導入しやすくなっています。

小さいシステムでも要求定義と要件定義は必要

今回、ブラウザ拡張機能を作成するにあたって、要求定義、要件定義を行っています。

当初、要求定義として想定したのが、「WordPressの投稿をXへ自動でポストすることで楽をしたい」でスタートしましたが、最終的には「WordPressの投稿を、編集して、ハッシュタグを付与して、Xにポストする作業を楽にしたい」に見直しをしました。

また、要件定義としては、「WordPressの投稿の件名、URL、タグ情報を1回で取得してXでポスト」できる、というところからスタートして、そこから詳細な要件定義を行っていきました。

この要件定義を行う段階では、以下の検討を行っています。

  • AIで対応するのか
  • システムを新たに作るのか
  • リリースされている既存アドオンやシステム、サービスを導入するのか
  • 手動で対応するのか

その中で、私の運用においては、WordPressの新着記事の投稿を支援するブラウザ拡張が最適、という要件に絞り込みを行いましたが、企業や別の方の状況においては、別の要件になる場合もあります。

AIによってプログラミングが簡単にできるようになったことで、システムを構築するのは確かに簡単になってはいます。

しかし、既存のシステムが存在していて、それを使うことで最短・低コスト・簡単に実現できるのであれば、AIで作ってしまうのは「車輪の再発明」であり、時間の無駄である場合もあります。

そのため、このような小さい課題を解決する場合においても、要求定義と要件定義は必要であり、それに基づいて実現手段を考える事が重要です。

ご注意

公開しているコードは、脆弱性などにも配慮していますが、ご利用にあたっては、以下の点にご留意ください。

最終的な安全確認は自己責任でお願いします。

本記事で紹介しているコードは、あくまでサンプルおよび実装例です。

実際の利用環境やサーバー構成、WordPressの設定、導入しているプラグインとの組み合わせによって、挙動やリスクは変わる可能性があります。

そのため、実行前には必ずコードの内容を理解したうえで、必要に応じてご自身の責任でセキュリティチェックを行ってください。

たとえば、静的解析、動的解析、依存ライブラリの確認、権限設定の見直しなどを行うことをおすすめします。

AI時代だからこそ、戦略は人と一緒に考えることが、最初の一歩です。

開発やコンテンツ生成はAIが担える時代になりました。しかし、何を作るか・どこを目指すかという問いに答えるのは、依然として人の仕事です。

DX推進や新規事業の立ち上げで壁にぶつかる企業の多くは、ソリューションの導入や社内人材への丸投げに終始し、課題の本質が言語化されないまま進んでしまっています。

経営とITの両方を理解した人間が、経営者と並走しながら要求定義・要件定義の段階から一緒に考える。AIはこのプロセスを補助できますが、主役にはなれません。

まだ課題が言語化できていない段階からでも、遠慮なくご相談ください。一緒に考えます。

AIが生成できないのは「実績と信頼」

ECサイトやマーケットプレイスサイトはCS-Cart国際版(公式)という選択肢

AIはコードを書けます。しかし、長年の実運用で磨かれたロジックや、世界中の事業者が検証したセキュリティを、プロンプト一つで再現することはできません。

CS-Cart国際版(公式)は、自社EC・越境EC・BtoB EC・マーケットプレイスに対応した豊富な実績ある機能をパッケージとして提供しています。

構築コストを抑えながら、堅牢なECサイトを立ち上げることができます。

スポンサードサーチ