前回はRSSフィードからX投稿用のコメントをLLMで自動生成するプロトタイプを作成しました。
本稿では、その生成済みコメントを実際にXへ投稿する処理の実装を示します。
X投稿処理の概要
このスクリプトは、前回生成した review.md(Markdown形式)を解析し、「投稿する」にチェックされた記事だけをXへ投稿します。UIは持たず、編集はVS Codeなどのエディタで行う想定です。実行前にコメント案を目視で確認・修正します。
コードを見ずにVibe codingのみで調整し動作確認ののち、掲載に冗長なデバッグログや過度な関数化などは手作業で整理しています。AIへの指示のみでもカットは可能ですが時間がかかりすぎるため、この点は妥協しています。
import json
import re
import sys
import os
from pathlib import Path
import tweepy
# ==========
# 設定
# ==========
ARTICLES_JSON = Path("articles.json")
REVIEW_MD = Path("review.md")
# ==========
# JSON読み込み
# ==========
def load_articles_json(path: Path):
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
articles = data
print(f"[INFO] articles.json 読み込み完了: {len(articles)} 件")
return articles
# ==========
# Markdown解析
# ==========
ARTICLE_BLOCK_RE = re.compile(
r"## (?P<title>.+?)\n"
r"- URL: (?P<url>.+?)\n"
r"(?P<body>.*?)(?=\n---|\Z)",
re.S
)
def parse_review_md(path: Path):
text = path.read_text(encoding="utf-8")
blocks = []
for m in ARTICLE_BLOCK_RE.finditer(text):
start_pos = m.start()
line_no = text[:start_pos].count("\n") + 1
block = {
"title": m.group("title").strip(),
"url": m.group("url").strip(),
"body": m.group("body"),
"line": line_no,
}
blocks.append(block)
print(f"[INFO] review.md から {len(blocks)} 記事ブロックを検出")
return blocks
# ==========
# メイン処理
# ==========
def main():
articles = load_articles_json(ARTICLES_JSON)
review_blocks = parse_review_md(REVIEW_MD)
if not review_blocks:
print(f"[WARN] review.md に記事が見つかりません")
sys.exit(0)
post_count = 0
errors = []
# -------- Phase 1: validation only --------
for block in review_blocks:
url = block["url"]
title = block["title"]
body = block["body"]
post_checked = bool(re.search(r"- \[x\] 投稿する", body))
skip_checked = bool(re.search(r"- \[x\] スキップ", body))
if post_checked and skip_checked:
errors.append(f"L{block['line']} {title}: 投稿する/スキップが両方チェックされています")
continue
if not post_checked and not skip_checked:
errors.append(f"L{block['line']} {title}: 投稿判断が未確定です")
continue
if post_checked:
if url not in articles:
errors.append(f"L{block['line']} {title}: JSONに存在しないURLです")
continue
status = articles[url].get("status")
if status not in (None, "ready"):
errors.append(
f"L{block['line']} {title}: status={status} のため投稿できません"
)
continue
m = re.search(r"### Xコメント案\n(?P<text>.*?)(?=\n- \[|\Z)", body, re.S)
x_post_text = m.group("text").strip() if m else None
if not x_post_text:
errors.append(f"L{block['line']} {title}: Xコメント案が見つかりません")
continue
if errors:
print(f"[ERROR] 以下のエラーがあるため処理を中断します")
for e in errors:
print(f"[ERROR] - {e}")
sys.exit(1)
X_API_KEY = os.environ["X_API_KEY"]
X_API_SECRET = os.environ["X_API_SECRET"]
X_ACCESS_TOKEN = os.environ["X_ACCESS_TOKEN"]
X_ACCESS_TOKEN_SECRET = os.environ["X_ACCESS_TOKEN_SECRET"]
x_client = tweepy.Client(
consumer_key=X_API_KEY,
consumer_secret=X_API_SECRET,
access_token=X_ACCESS_TOKEN,
access_token_secret=X_ACCESS_TOKEN_SECRET
)
# -------- Phase 2: posting --------
for block in review_blocks:
url = block["url"]
title = block["title"]
body = block["body"]
post_checked = bool(re.search(r"- \[x\] 投稿する", body))
skip_checked = bool(re.search(r"- \[x\] スキップ", body))
if skip_checked:
continue
status = articles[url].get("status")
if status not in (None, "ready"):
print(f"[WARN] status={status} のためスキップ: {url}")
continue
m = re.search(r"### Xコメント案\n(?P<text>.*?)(?=\n- \[|\Z)", body, re.S)
post_text = m.group("text").strip() if m else None
if not post_text:
raise RuntimeError(f"Xコメント案が review.md に存在しません: {url}")
print(f"[INFO] 投稿テキスト: {post_text}")
print(f"[INFO] 文字数: {len(post_text)} 文字")
try:
resp = x_client.create_tweet(text=post_text)
tweet_id = resp.data.get("id") if resp and resp.data else None
print(f"[INFO] Xに投稿しました (v2): id={tweet_id}")
except tweepy.errors.Forbidden as e:
print(f"[ERROR] 403 Forbidden エラー")
print(f"[ERROR] エラーメッセージ: {e}")
if hasattr(e, 'response') and e.response:
print(f"[DEBUG] レスポンス詳細: {e.response.text}")
raise
except Exception as e:
print(f"[ERROR] 投稿エラー: {type(e).__name__}: {e}")
raise
# 投稿成功扱いとして status を更新
articles[url]["status"] = "posted"
post_count += 1
# -------- Phase 3: persist status --------
with ARTICLES_JSON.open("w", encoding="utf-8") as f:
json.dump(articles, f, ensure_ascii=False, indent=2)
print(f"[INFO] articles.json を更新しました (status=posted)")
print(f"[INFO] 投稿対象: {post_count} 件")
print(f"[INFO] 処理完了")
if __name__ == "__main__":
main()
UI設計に関する補足
本実装ではUIをMarkdown形式の編集ツールに依存します。前回までの想定では詳細を指示していなかったため、追加のAIとのやりとりで下記のような指示を追加しています。
- エラーがあった場合には行番号を含む詳細なログを出力し、修正を支援する
- 「スキップ」「投稿」が同時に指定された場合、またはいずれも指定されなかった場合はエラーメッセージを出す
- articles.jsonのステータス更新と、外部ツールによる並行編集の可能性を考慮し同期状態のチェックを入れる
- Markdownを1件ずつチェックして逐次処理する内容を、全体のエラーが無くなるまで投稿は行わないよう変更しMarkdownの再利用を可能にする
詳細は割愛しますが、人間の操作はミスがつきもので、ループを想定したUXを設計する必要があります。
当初のChatGPTの提案では、Streamlit/Gradio等でUIを構築する案も出ましたが、シンプルにするため推奨のMarkdown形式での運用としました。 経験上、Vibe codingではUIのモックやプロトタイプ構築は一瞬で終わりますが、UIの細かい挙動の制御やバリデーション処理、デバッグが非常に面倒になります。
これだけ手を入れるとなると、結果的に始めから何か、GUIを付けても良かったかもしれません。
実行方法
必要ライブラリのインストール
必要ライブラリをインストールします。Xへの投稿にTweepyを利用しています。
pip install tweepy
環境変数の設定
実行前にX(旧Twitter)のAPIキー等を環境変数に設定してください。
ChatGPTに相談しながら進められますが、誤りも多いので注意が必要です。
export X_API_KEY="..."
export X_API_SECRET="..."
export X_ACCESS_TOKEN="..."
export X_ACCESS_TOKEN_SECRET="..."
スクリプトの実行
articles.json と review.md を置いたディレクトリでスクリプトを実行します。設定が正しければ実際にXに投稿されますので、注意してください。
python post_to_x.py
所感と結論
本実装を通して、ChatGPT+VS CodeでのVibe codingで、簡易なツールを短時間で作る上での有効性が再確認できました。
一方で、UI設計や外部API(今回の場合はX)に関する細かな仕様対応は、人手による調整が必要で、エラー発生後の追い込みに時間を要します。AIの提案だけでは実用性が低く、人間のフォローが必要で、特に未経験で望む場合は相応の時間がかかると思います。
特に、XのAPI仕様やレート制限、エラーハンドリングは、古い仕様に基づいた説明や、ハルシネーションによる誤った説明が多かったのは気になります。 例えば単純な字数制限で投稿ができなかった場合、無料プランの制限と誤解して有料プランへの加入を進めるなど、注意が必要な挙動が見られました。
それでも、今回はRSSフィードからX投稿を自動化するツールについて、前半(生成)と後半(投稿)を合わせて概ね1日程度の作業で、実用的に動く状態まで到達しました。
繰り返しの運用に向けて追加の調整は残りますが、Vibe codingの効率性は示されました。
まとめ
Vibe codingでのX投稿コメント自動生成・投稿ツールの実装を通じて、ChatGPT+VS Codeの組み合わせで、短時間で実用的なツールを構築可能と再確認できました。
問題も多く、特に運用や保守を考えると、稼働開始後のフォローアップには通常のプロジェクト推進よりもむしろ時間を要すると思われます。
それでも、稼働開始が大幅に早められることはメリットが大きく、単純にプログラミングの喜びを享受できる良さがあります。
特にプログラミング・コーディングの入口に立つ方には、お勧めしたい手法と言えます。



