1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hermes AgentでInstagramとThreadsの自動投稿システムを作った話

1
Posted at

はじめに

猫のいる宿を紹介するサイト「ねこ宿研究所」では、公開した記事をInstagramとThreadsでも紹介しています。

手作業で投稿を続けるのは難しいため、VPS上で動かしているHermes Agentの運用環境に、次の処理を行う自動投稿システムを作りました。

  • 公開済み記事から投稿候補を選ぶ
  • InstagramとThreadsで異なる投稿文を組み立てる
  • Meta APIで投稿する
  • 成功・失敗をDiscordへ通知する
  • cronから決まった時刻に実行する
  • 失敗時は二重投稿を避けながら最大3回まで再試行する

実際に運用を始めると、再試行機能を追加した際にシェルスクリプトの実行権限が消え、cronが全停止する事故も起きました。本記事では、最終的な構成だけでなく、なぜその設計にしたか、障害から何を学んだかもまとめます。

なお、この記事は2026年8月時点で実際に構築・運用した内容です。Meta APIの仕様や必要な権限は変更される可能性があります。

Hermes AgentにAPIトークンを直接扱わせない

Hermes Agentは、Discordから指示を受け、スキルやターミナルを使って作業できるAIエージェントです。

最初は「Hermes Agentに記事を選ばせ、そのままMeta APIを呼ばせればよい」と考えました。しかし、外部投稿を自由なシェル操作へ任せると、次のリスクがあります。

  • 同じ投稿を二重送信する
  • 誤ったアカウントへ投稿する
  • タイムアウト時に、投稿済みか分からないまま再実行する
  • Discordの曖昧な指示を投稿承認として扱う
  • ログや会話へアクセストークンを出してしまう

そこで、Hermes AgentとMeta APIの間に、用途を限定したPython製の投稿ワーカーを置きました。

Discord
   ↓ 指示・完了通知
Hermes Agent
   ↓ 制限されたコマンドだけ実行
投稿候補選定スクリプト
   ↓ draft → approve → publish
投稿ワーカー
   ├─ Instagram API
   └─ Threads API

AIエージェントは運用や改修の窓口ですが、定刻投稿そのものは決定的なPythonスクリプトとcronで動かします。これにより、毎回LLMを呼ばず、モデルの利用枠や回答の揺らぎにも依存しない構成になりました。

コンテナとデータの分離

Hermes AgentはWordPressとは別のDocker Composeプロジェクトとして動かしています。WordPressのコンテナやDockerソケットはマウントせず、Hermes用のデータディレクトリだけを渡します。

services:
  hermes:
    image: nousresearch/hermes-agent:<固定したバージョン>
    restart: unless-stopped
    command: ["gateway", "run"]
    volumes:
      - /home/example/.hermes:/opt/data
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL

InstagramとThreadsのトークンは、リポジトリ外のファイルとして保存します。

~/.hermes/credentials/instagram-access-token
~/.hermes/credentials/threads-access-token

権限は600とし、投稿ワーカーはファイルを読むだけです。トークンをコマンド引数、キュー、監査ログへ入れないようにしました。

投稿ワーカーの状態管理

投稿ワーカーは、1回の投稿をJSONキューとして保存します。主な状態は次のとおりです。

awaiting_approval
        ↓
     approved
        ↓
    publishing
        ↓
     published

失敗時: failed / needs_review

本文、記事URL、画像URL、記事更新日、記事MarkdownのSHA-256を承認対象に含めます。承認後に記事や本文が変わった場合、ハッシュが一致しないため投稿を止めます。

def approval_hash(record: dict) -> str:
    payload = json.dumps(
        approval_payload(record),
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    )
    return hashlib.sha256(payload.encode()).hexdigest()

自動運転へ移行する前には、DiscordからキューIDを指定して承認する試験期間を設けました。InstagramとThreadsの両方で実投稿を確認してから、自動運転専用の承認主体を追加しています。

投稿候補は公開済みMarkdownから選ぶ

ねこ宿研究所では、WordPress記事の正本をMarkdownで管理しています。自動投稿では、フロントマターが次の条件を満たす記事だけを候補にします。

  • status: publish
  • slugが記事URLと一致する
  • titledescriptionfact_checkedがある
  • 同じプラットフォームで直近60日に使っていない
  • 公開URLがHTTP 200を返す

Instagramでは、さらにサイトのWordPressメディアにあるJPEG画像を必要条件にしました。

image_pattern = re.compile(
    r"!\[([^]]+)\]\((https://www\.example\.com/"
    r"wp-content/uploads/[^)]+\.(?:jpg|jpeg))\)",
    re.IGNORECASE,
)

外部サイトや予約サイトの画像は取得しません。記事内画像がWebPしかない場合は、その記事をInstagram候補から外すか、WordPress側にJPEG派生版を用意します。

また、Markdown上では公開扱いでも、WordPress側のURLがまだ存在しないケースがありました。候補決定前に記事URLと画像URLへHEADリクエストを送り、到達不能な候補はスキップします。

def first_reachable(platform: str, candidates: list[dict]) -> dict:
    for item in candidates:
        if not public_url_ok(item["url"]):
            continue
        if platform == "instagram" and not public_url_ok(
            item["image_url"], "image/jpeg"
        ):
            continue
        return item
    raise AutomationError("No unused article with reachable public URLs")

InstagramとThreadsの文章を分ける

同じ記事を紹介する場合でも、両プラットフォームへ同一文面を同時投稿しないようにしました。

Threadsは記事URLを本文へ含め、時間帯ごとに役割を分けています。

08:00  ねこ宿小ネタ
11:30  今日のねこ宿
15:00  猫・旅行ネタ
19:00  新着記事・宿紹介
22:00  軽い雑談・質問

Instagramはサイト内JPEG画像と代替テキストを付け、1日2回投稿します。

11:30  ねこ宿紹介
20:00  写真・記事・看板猫紹介

宿紹介では、猫に必ず会えるような表現を避けるため、次の趣旨を必ず入れます。

猫との時間は、猫の意思やその日の体調を尊重してお楽しみください。

禁止表現や文字数は投稿ワーカーでも検査します。プロンプトだけで守るのではなく、コード側でも止めるのがポイントです。

Meta APIでの投稿

Instagramは、画像コンテナを作成し、処理完了を確認してから公開します。

アカウント確認
  ↓
メディアコンテナ作成
  ↓
status_codeがFINISHEDになるまで確認
  ↓
media_publish
  ↓
投稿IDとpermalinkを取得

Threadsも、テキストコンテナを作成してから公開します。どちらも投稿前にAPIが返すユーザー名を検査し、想定したアカウントでなければ停止します。

最も注意したのはネットワークタイムアウトです。公開APIの呼び出し後に通信が切れると、「投稿に失敗した」のか「投稿は成功したが応答を受け取れなかった」のか判断できません。

この場合は自動再投稿せず、キューをneeds_reviewにして対象プラットフォームを一時停止します。

except TimeoutError:
    record["status"] = "needs_review"
    state["paused"][record["platform"]] = True
    save_record(record)

Discordへ完了通知する

投稿成功後は、Discord Bot APIで非公開の運用チャンネルへ通知します。

✅ threadsへ自動投稿しました(th-travel)。
記事: 記事タイトル
キュー: TH-...
https://www.threads.com/...

Discordのメッセージには、キューIDをもとに作った数値nonceを付けています。通知処理を再実行した場合でも、同じ完了通知が重複しにくくするためです。

重要なのは、投稿成功後のDiscord通知失敗を「投稿失敗」と同一視しないことです。キューがすでにpublishedなら、通知の再試行はしても、SNS投稿は再実行しません。

cron失敗時の再試行をどう設計したか

運用開始後、「cronが失敗したら原因を確認して再投稿し、3回失敗したら諦める」機能を追加しました。

単純にコマンド全体を3回実行するのは危険です。1回目で投稿済みなのに応答だけ失われた場合、同じ内容を3回投稿する可能性があります。

そこで、各試行の前後でキューディレクトリを比較します。

新しいキューがない
  → 投稿処理へ入る前の失敗。最大3回まで再試行可能

新しいキューがpublished
  → SNS投稿は成功。再投稿しない

新しいキューがpublishing / failed / needs_review
  → Metaへ到達した可能性がある。停止して人が確認

プラットフォームがpaused
  → 再試行しない

ラッパーの骨格は次のようになりました。

for (( attempt=1; attempt<=3; attempt++ )); do
    before=$(snapshot_queue)

    if python3 social_automation.py "$platform" "$slot"; then
        exit 0
    fi

    decision=$(retry_decision "$before")
    case "$decision" in
        published:*) exit 0 ;;
        retry:*)     sleep 30 ;;
        *)           exit 1 ;;
    esac
done

「失敗したら再試行」ではなく、「外部へ副作用が発生していないと確認できる場合だけ再試行」と考える必要がありました。

実際に起きた障害:再試行機能を追加したらcronが全停止

再試行機能の追加はHermes AgentへDiscordから依頼しました。ロジック自体は安全側に作られていましたが、更新後のシェルスクリプトが600になり、実行権限が消えていました。

cronログには次のエラーが残っていました。

flock: failed to execute run_social_automation.sh: Permission denied

原因は単純ですが、Pythonの構文検査だけでは検出できません。修正後は、配備処理とスキルの運用手順へ次の確認を追加しました。

chmod 700 run_social_automation.sh
bash -n run_social_automation.sh
stat -c "%a %n" run_social_automation.sh

さらに、投稿を伴わない/bin/falseを自動処理の代わりに指定し、正確に3回で終了するテストも行いました。

status=1 attempts=3 gave_up=1

最後に実際のThreads投稿を1件実行し、キューがpublishedになったこと、Discord通知が届いたこと、連続失敗数が0へ戻ったことまで確認しました。

運用して分かったこと

1. AIエージェントと副作用のある処理を分ける

Hermes Agentは、調査、運用指示、スキル改善の窓口として便利です。一方、SNS投稿のように取り消しにくい処理は、引数と許可範囲を狭くしたワーカーへ閉じ込めた方が安全でした。

2. 再試行回数より「再試行してよい状態」の定義が重要

最大3回という数字だけでは二重投稿を防げません。外部APIへ到達する前か、到達した可能性があるかを、永続化した状態から判断する必要があります。

3. 投稿後通知は投稿処理と別の副作用

Discord通知が失敗しても、SNS投稿まで巻き戻ったわけではありません。工程ごとに成功状態を分けることで、安全に復旧できます。

4. AIによる改修でもUnixの基本確認は必要

コード差分、構文、テストに加えて、実行権限、cronサービス、実行ユーザー、ログの保存先まで確認する必要があります。今回の障害はchmodstatで防げるものでした。

5. 完全自動化でも停止手段を残す

Instagram、Threads、全体を個別に停止できるフラグを用意しました。曖昧なAPI結果や連続失敗時は自動停止し、再開だけは明示的な運用指示を必要とします。

まとめ

Hermes Agentを使って、記事選定、Instagram・Threads投稿、Discord通知、cron運転までを一つの運用フローにまとめました。

実装で特に重要だったのは、AIに自由に投稿させることではなく、次の境界をコードで作ることでした。

  • 投稿できるアカウント、URL、画像形式を限定する
  • 投稿内容と記事ソースをハッシュで固定する
  • 状態を永続化して二重投稿を防ぐ
  • 結果が曖昧なら再試行せず停止する
  • 外部副作用の前だけ最大3回再試行する
  • 投稿結果をDiscordへ通知する
  • スクリプトの実行権限まで配備後に検査する

AIエージェントによる自動化は、自由度を高くするほど便利になるとは限りません。外部へ書き込む部分を小さく、決定的に、止めやすくすることで、ようやく日常運用へ載せられると感じました。

本システムを運用している「ねこ宿研究所」は、看板猫のいる宿を調査・紹介するサイトです。本記事の構成は、そこで実際に公開している記事とSNS運用を対象に検証しました。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?