Vibe coding実践例: RSSからX投稿コメント自動生成の実装

AIテキスト

前回までの一連の取り組みで、RSSフィードからX投稿のコメント生成をLLMで自動化するコードをVibe codingで作成しました。

今回は、これまでのコードをまとめ、RSSフィード取得からX投稿コメント生成部分までを掲載します。(X投稿部分は次回以降で解説予定)

調整したコード全体

下記は、RSSフィードから記事を収集し、本文を取得、要約生成、X投稿コメント案生成までを行うコードです。 Vibe codingでのドキュメントに基づいたコード分割と再構築を経て、動作する状態まで調整を進めました。

コードを見ると違和感があります。例えば、例外の「握りつぶし」を避けるよう何度か指示しましたが残っています。 色々手を入れたくなりますが、結果が得られることを重視し、速さと快適さを享受するのがVibe codingの醍醐味でしょう。

なお掲載のため、動作が確認できた後、最終的にデバッグログや過剰な関数定義など、動作に影響のない冗長な部分は手動でカットしています。 今回は手動で行いましたが、指示と確認に時間をかければ、AIへの指示のみでカットも可能です。

import json
import requests
import feedparser
from pathlib import Path
from datetime import datetime
import re
from html import unescape

# =====================
# 設定
# =====================
RSS_URL = "https://www.techno-edge.net/rss20/index.rdf"
ARTICLES_JSON = "articles.json"
REVIEW_MD = "review.md"

OLLAMA_URL = "http://192.168.11.63:11434/api/generate"
OLLAMA_MODEL = "gemma3:12b"

# =====================
# 共通ユーティリティ
# =====================

def check_ollama_health() -> None:
    tags_url = OLLAMA_URL.replace("/api/generate", "/api/tags")
    r = requests.get(tags_url, timeout=5)
    r.raise_for_status()


def load_articles() -> dict:
    if not Path(ARTICLES_JSON).exists():
        return {}
    return json.loads(Path(ARTICLES_JSON).read_text(encoding="utf-8"))


def save_articles(articles: dict) -> None:
    Path(ARTICLES_JSON).write_text(
        json.dumps(articles, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

# =====================
# LLM 関連(添付コード準拠)
# =====================

PERSONA_PROMPT = """あなたはエンジニアです。以下のニュースについて、X(旧Twitter)用の日本語コメントを1つ作成してください。

# ニュース
__NEWS_SUMMARY__

# あなたの特性
- 専門知識: エンジニア(技術的視点)
- 感情: 冷静(emotion: 0.0)
- 口調: 落ち着いた観察的スタイル(tone: calm)
- 立場: やや批判的(stance: slightly_critical)
- 確信度: 中程度(confidence: 0.5)
- リスク許容度: 低め(risk_tolerance: 0.2)

# 出力ルール(厳守)
1. 日本語のみで出力(英語は使用禁止)
2. 文字数: **60〜120字**
3. 断定を避け、「可能性がある」「懸念される」「示唆される」など推測表現を使う
4. 個人攻撃は禁止
5. 1つの話題に絞る(複数の論点を混ぜない)
6. 絵文字、ハッシュタグは使用禁止
7. 説明や前置きは不要、ニュースの要点とコメントを含む短文のみを出力

出力:"""

SUMMARY_PROMPT = """以下のテキストを日本語で簡潔に要約してください。

# テキスト
__TEXT__

# ルール
- 日本語のみ
- 200〜400字程度
- 箇条書きは禁止
- 前置きや結論ラベルは禁止

要約:"""

def generate_summary(text: str) -> str:
    if not text:
        return ""
    payload = {
        "model": OLLAMA_MODEL,
        "prompt": SUMMARY_PROMPT.replace("__TEXT__", text[:3000]),
        "stream": False
    }
    try:
        r = requests.post(OLLAMA_URL, json=payload, timeout=180)
        r.raise_for_status()
        data = r.json()
        return (data.get("response") or "").strip()
    except Exception:
        return ""


def generate_comment_from_summary(summary: str) -> str:
    if not summary:
        return ""
    payload = {
        "model": OLLAMA_MODEL,
        "prompt": PERSONA_PROMPT.replace("__NEWS_SUMMARY__", summary),
        "stream": False
    }
    try:
        r = requests.post(OLLAMA_URL, json=payload, timeout=180)
        r.raise_for_status()
        data = r.json()
        return (data.get("response") or "").strip()
    except Exception:
        return ""

# =====================
# RSS / 記事処理
# =====================

def collect_rss_entries() -> list:
    feed = feedparser.parse(RSS_URL)
    entries = []

    for e in feed.entries:
        url = e.get("link")
        title = e.get("title")
        if not url or not title:
            continue
        entries.append({
            "title": title,
            "url": url,
            "published": e.get("published", "")
        })

    return entries


def fetch_article_body(url: str) -> str:
    try:
        r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
        r.raise_for_status()
        html = r.text

        # script/style を除去
        html = re.sub(r"<script[\s\S]*?</script>", " ", html, flags=re.IGNORECASE)
        html = re.sub(r"<style[\s\S]*?</style>", " ", html, flags=re.IGNORECASE)

        # タグ除去
        text = re.sub(r"<[^>]+>", " ", html)
        text = unescape(text)

        # 空白正規化
        text = re.sub(r"\s+", " ", text).strip()

        # 長すぎる場合は上限
        return text[:8000]
    except Exception:
        return ""


def update_articles(entries: list, articles: dict) -> None:
    for e in entries:
        if e["url"] in articles:
            continue

        articles[e["url"]] = {
            "title": e["title"],
            "url": e["url"],
            "published": e["published"],
            "status": "new",
            "summary": None,
            "comment": None,
            "created_at": datetime.utcnow().isoformat()
        }


def process_articles(articles: dict) -> None:
    for a in articles.values():
        if a.get("status") != "new":
            continue

        body_text = fetch_article_body(a["url"])
        a["body"] = body_text
        if not body_text:
            a["status"] = "error"
            continue

        summary = generate_summary(body_text)
        a["summary"] = summary
        if not summary:
            a["status"] = "error"
            continue

        comment_body = generate_comment_from_summary(summary)
        if not comment_body:
            a["status"] = "error"
            continue

        # Xコメント案
        comment = "\n".join([
            comment_body,
            a["url"],
        ])
        a["comment"] = comment

        # 長さ制限チェック
        if len(comment) > 280:
            a["status"] = "error"
            continue

        a["status"] = "ready"

# =====================
# Markdown 出力
# =====================

def export_review_md(articles: dict) -> None:
    lines = ["# レビュー待ち記事\n"]

    for a in articles.values():
        if a.get("status") != "ready":
            continue

        lines.extend([
            f"## {a['title']}",
            f"- URL: {a['url']}",
            "",
            "### 要約",
            a["summary"] if a.get("summary") else "",
            "",
            "### 本文(抽出テキスト)",
            a["body"] if a.get("body") else "",
            "",
            "### Xコメント案",
            a.get("comment", ""),
            "",
            "- [ ] 投稿する",
            "- [ ] スキップ",
            "",
            "---",
            ""
        ])

    Path(REVIEW_MD).write_text("\n".join(lines), encoding="utf-8")

# =====================
# main
# =====================

def main():
    check_ollama_health()

    articles = load_articles()
    entries = collect_rss_entries()

    update_articles(entries, articles)
    process_articles(articles)

    save_articles(articles)
    export_review_md(articles)

    print("[DONE] collect_and_prepare 完了")

if __name__ == "__main__":
    main()

実行方法

必要なライブラリを導入し、上のコードを collect_and_prepare.py として保存して実行してください。

必要ライブラリ

事前に次のパッケージをインストールします。
RSS読み込みにfeedparser、OllamaのAPI呼び出しにrequestsを利用しています。

pip install feedparser requests

実行

Ollamaが起動している状態でスクリプトを実行します。

python collect_and_prepare.py

実行するとカレントディレクトリに articles.jsonreview.md が生成されます。articles.json は記事情報と処理状態を保持し、review.md にはX投稿案が一覧化されます。review.md を開いて投稿する記事にチェックを入れて保存すると、後続の投稿スクリプトでチェック済みの記事を読み込んで投稿できます。

所感と結論

今回、改めてVibe codingを試した感想を整理します。

  • ファイル数の目安: VS CodeとChatGPTアプリ(Mac)の組み合わせでは、同時に扱うファイルは体感で5個前後が快適です。
  • 1ファイルの行数: 1ファイルは200行程度に抑えると、AIがコード全体を把握しやすく作業漏れが減ります。
  • Markdown生成の注意: JSONに比べてMarkdownの読み書きは文脈依存が強く、細かい指示が必要です。
  • 文字列・コメント類の扱い: ヒアドキュメントや深いインデントはAIが誤生成しやすいです。
  • 既存コードの再利用: AIはコピペが苦手で行間を補完しがちなので、既存コードの正確な再利用は難しい場合があります。
  • デバッグ傾向: 問題切り分けが苦手なためログやテストを多用し、原因追跡に時間がかかることがあります。

総じて、実作業は半日程度でプロトタイプが作れました。Vibe codingの利点は「速さ」と「快適さ」にあり、適切な期待値で使うのが有効です。

まとめ

ChatGPT+VS Codeで「コードを読まず」に進めるVibe codingの開発スタイルは、この規模であれば十分にその良さが味わえると感じます。VS Codeでなくとも、一般のエディタとターミナル等のCLI環境でも同等の効果が得られるでしょう。

実際には、有料にはなりますがVS CODE + GitHub Copilot やCurosrなど、プロジェクト構造を理解する補助ツールと組み合わせると、より大きな作業にも対応できます。

次回は後半、Xへの投稿部分を詳述します。

タイトルとURLをコピーしました