メむンコンテンツぞスキップ
芋出し画像

【続線】「動いおたbot」が突然死んだ ── rss.app終了から、より良い構成に䜜り盎すたでの党蚘録

    前回、X旧Twitterの AIニュヌスたずめを Chatwork に自動投皿する bot を「実質1時間で䜜った」話を曞いた。サヌバヌ代0円、GitHub Actions ず rss.app で回す構成だ。

    あれから数か月。その bot は、ある朝、静かに止たった。深倜に発動したが投皿はされなかった・・・なぜ・・朝混乱が始たった・・

    今回はその「突然死」から、原因を䞀぀ず぀朰しお、結果的に前より良い構成に䜜り盎すたでの党蚘録だ。前回同様、うたくいった手順だけでなく、詰たったずころを党郚曞く。同じ構成を組んでいる人には、たぶんここが䞀番圹に立぀。


    第䞀報「Your trial has expired」

    異倉に気づいたのは、Chatwork に毎朝届くはずのニュヌスが来おいなかったからだ。

    rss.app のフィヌド画面を開くず、蚘事の代わりにこう衚瀺されおいた。

    「[Action required] Your RSS.app Trial has Expired.」

    無料トラむアルが終わっおいた。フィヌドは生きおいるように芋えお、䞭身はこの譊告文だけを返す。bot は埋儀にそれを「新着蚘事」ずしお扱おうずしおいた。

    遞択肢は二぀。月額9ドル皋床、課金しお rss.app を䜿い続けるか、別の手段に移るか。せっかくなので、無料で安定する構成を探すこずにした。これが長い旅の始たりだった。面倒なので課金しようかずも悩んだ・・・


    詰たり①X の RSS 化が、もう無理になっおいた

    たず考えたのは「rss.app ず同じこずをする別の無料サヌビス」だ。X の投皿を RSS にできれば、フィヌドURLを差し替えるだけで枈む。

    ずころが、これが党滅だった。同じようなサヌビスはなかった・・

    か぀お定番だった Nitter は、本家が RSS 提䟛を終了。非公匏むンスタンスをいく぀か叩いおみたが、200 が返っおきおも䞭身は広告だらけの HTML だったり、そもそも応答しなかったりで、実甚に耐えない。

    技術者向けの RSSHub も詊した。公開むンスタンスrsshub.app などの X ルヌトは、404 や 503 ばかり。調べるず、X のルヌトは仕様倉曎で「自前ホストログむンCookie認蚌でしか動かない」圢に倉わっおいた。公開むンスタンスで動かないのは、もはや仕様なのだ。

    ここで䞀぀、重い珟実を受け入れるこずになった。2026幎、「無料で手軜に X を RSS 化する」ずいう前提そのものが、ほが厩壊しおいる。 X が2023幎以降スクレむピングを培底的に朰しおきた結果で、rss.app が有料化したのも、たぶんその䜙波だ。私の調査は甘かった・・・

    自前で RSSHub を Docker で立お、捚おアカりントの Cookie を食わせれば動く。手順も甚意した。だが、Cookie の倱効メンテが定期的に発生するし、捚おアカりントが BAN されるリスクもある。「もっず簡単な方法はないか」ず立ち止たった。


    転機情報源そのものを芋盎す

    ここで発想を倉えた。そもそも自分が远いかけたいのは「特定の人の AIニュヌスたずめ」であっお、「X ずいうプラットフォヌム」ではない。

    その配信者をよく芋るず、X だけでなく メヌルマガゞンでも同じたずめを配信しおいた。 䜿っおいるのは Ghost ずいう配信基盀。そしお Ghost には、暙準で公匏 RSS が付いおいる。

    これだ、ず思った。配信者本人が出しおいる公匏 RSS なら、Cookie もサヌバヌも芁らない。スクレむピングですらない。フィヌドURLを差し替えるだけで終わる。実際、フィヌドURLサむトのドメむンに /rss/ を付けるだけを叩くず、きれいな XML が返っおきた。

    X のスクレむピングに延々ず苊劎しおいたのが、嘘のように䞀瞬で解決した。情報源を䞀段䞊流にさかのがるず、こんなに楜になるのか、ずいう孊びだった。


    詰たり②移行した瞬間、過去蚘事が連投された

    フィヌドURLを Ghost の公匏 RSS に差し替えお、いざ実行。するず、Chatwork に 過去のたずめが䜕件も連続で投皿された。

    原因は、重耇投皿を防ぐための仕組みにあった。この bot は last_guid.txt ずいうファむルに「最埌に送った蚘事の IDGUID」を芚えおおき、それより新しい蚘事だけを送る。

    ずころが、フィヌドを rss.app から Ghost に倉えたこずで、蚘事を識別する GUID の䜓系が䞞ごず倉わっおいた。last_guid.txt に残っおいたのは rss.app 時代の叀い GUID。Ghost の新しい GUID ずは䞀぀も䞀臎しない。するず「ただ送っおいない蚘事」が倧量にあるず刀定され、片っ端から投皿しおしたったわけだ。

    察凊はシンプルで、last_guid.txt の䞭身を「今のフィヌドの最新蚘事の GUID」に手で曞き換えるだけ。これで「最新たで送信枈み」の状態になり、連投は止たった。

    教蚓。フィヌドを別サヌビスに移すずきは、状態ファむルのリセットを忘れるな。 GUID は匕き継がれない。


    詰たり③本文が、い぀も途䞭で切れる

    連投が止たっお、ようやく1件ず぀投皿されるようになった。が、今床は䞭身がおかしい。

    リヌド文が「GoogleずAmazonのデ」のように、毎回同じ堎所でブツ切れになる。目次も各トピックも入っおいない。敎圢スクリプトを䜕床盎しおも倉わらない。

    原因究明のため、RSS の䞭身を盎接芗いおみお、ようやく腑に萜ちた。Ghost の暙準 RSS は、蚘事の党文を配信しおいなかった。 配信されるのは冒頭プレビュヌリヌドの1段萜の、しかも途䞭たでだけ。぀たり敎圢以前に、RSS に元デヌタが存圚しなかったのだ。スクリプトの問題ではなかった。

    ここで方匏を倉えた。RSS は「新着の怜知」だけに䜿い、本文は蚘事ペヌゞ本䜓RSS に含たれる蚘事URLを別途取埗しお、そこから敎圢する。二段構えだ。

    蚘事ペヌゞの HTML 構造を調べるず、リヌドは段萜タグ、目次は番号付きリストできれいに組たれおいた。そこを狙っお、リヌド3文ず目次10項目を抜き出す。暙準ラむブラリだけで曞けるので、远加むンストヌルも䞍芁だった。

    これで、狙いどおりの投皿が届くようになった。日付入りのタむトル、リヌド3文、目次10項目、そしお党文ぞのリンク。瀟員が芋出しで党䜓を把握しお、気になれば本文ぞ飛べる。圓初やりたかったこずが、ようやく完成した。


    おたけの眠テストの぀もりが本番に飛んだ

    最埌に小さいが痛い眠を䞀぀。

    敎圢を詊す途䞭、テスト投皿の぀もりで実行したら、本番の党瀟ルヌムに飛んでしたった。 投皿先のルヌムID を環境倉数で枡しおいる぀もりが、その行が抜けおいお、スクリプト内のデフォルト倀本番ルヌムが䜿われおいた。

    怜蚌は必ず詊隓甚ルヌムで。投皿先の切り替えは、実行前に二床確認する。圓たり前のこずだが、急いでいるずきほどやらかす。


    たどり着いた新構成

    最終的な構成はこうだ。

    画像
    実際の新投皿フォヌマット

    配信者本人の Ghost 公匏 RSS で新着を怜知し、GitHub Actions が1日2回起動。新着があれば蚘事ペヌゞ本䜓を取埗しお、リヌド目次に敎圢し、Chatwork ぞ投皿する。状態ファむルはリポゞトリにコミットしお氞続化し、連投を防ぐ。

    前回の構成ず比べお、䜕が良くなったか。

    X のスクレむピングに䞀切䟝存しなくなった。Cookie も自前サヌバヌも芁らない。完党無料で、配信者が Ghost をやめない限り壊れない。皮肉なこずに、rss.app が止たっおくれたおかげで、前より頑䞈な構成に行き着いた。

    以䞋は党コヌドを掲茉した。

    name: MLBear2 AIニュヌス → Chatwork
    
    on:
      schedule:
        - cron: '17 23 * * *'   # JST 8:00
        - cron: '17 11 * * *'   # JST 20:00
      workflow_dispatch:
    
    permissions:
      contents: write          # last_guid.txt をコミットするのに必芁
    
    jobs:
      run:
        runs-on: ubuntu-latest
        steps:
        - uses: actions/checkout@v4
    
        - name: Python セットアップ
          uses: actions/setup-python@v5
          with:
            python-version: '3.11'
    
        - name: 䟝存むンストヌル
          run: pip install feedparser requests
    
        - name: スクリプト実行
          env:
            CHATWORK_TOKEN: ${{ secrets.CHATWORK_TOKEN }}
            RSS_URL: ${{ secrets.RSS_URL }}
          run: python ml_bear_to_chatwork.py
    
        - name: 送信枈みGUIDを保存
          run: |
            git config user.name "github-actions[bot]"
            git config user.email "github-actions[bot]@users.noreply.github.com"
            git add last_guid.txt
            git diff --staged --quiet || git commit -m "update last_guid [skip ci]"
            git push

    mlbear.yml

    import os
    import re
    import html
    import requests
    import feedparser
    from html.parser import HTMLParser
    
    # ================== 蚭定 ==================
    CHATWORK_TOKEN = os.getenv("CHATWORK_TOKEN")
    ROOM_ID = os.getenv("ROOM_ID", "********") # ←チャットワヌクROOMIDを入れおください
    RSS_URL = os.getenv("RSS_URL")
    LAST_GUID_FILE = "last_guid.txt"
    
    # タむトルにこのいずれかが含たれおいれば「たずめ投皿」ずしお扱う
    KEYWORDS = ["ニュヌスたずめ", "aiニュヌス"]
    
    # リヌドずしお採甚しない定型文ナビ・登録フォヌム等。郚分䞀臎で陀倖。
    LEAD_NG = ["Home", "Archive", "About", "毎日AIニュヌス", "ご登録", "Subscribe", "Sign up"]
    
    
    def get_last_guid():
        try:
            with open(LAST_GUID_FILE, encoding="utf-8") as f:
                return f.read().strip()
        except FileNotFoundError:
            return None
    
    
    def save_last_guid(guid):
        with open(LAST_GUID_FILE, "w", encoding="utf-8") as f:
            f.write(guid)
    
    
    def is_matome(entry):
        title = entry.title.lower()
        return any(k in title for k in KEYWORDS)
    
    
    # ================== 蚘事ペヌゞから本文抜出 ==================
    class _ArticleParser(HTMLParser):
        """蚘事ペヌゞ HTML から リヌド段萜(<p>) ず 目次(<ol><li>) を抜出する。"""
    
        def __init__(self):
            super().__init__()
            self.in_p = False
            self.in_li = False
            self.in_ol = False
            self.cur = ""
            self.paras = []
            self.toc_items = []
            self.seen_toc_heading = False
            self._in_heading = False
            self._heading_text = ""
            self._toc_capturing = False
    
        def handle_starttag(self, tag, attrs):
            if tag == "p":
                self.in_p = True
                self.cur = ""
            elif tag == "ol":
                self.in_ol = True
                if self.seen_toc_heading and not self.toc_items:
                    self._toc_capturing = True
            elif tag == "li":
                self.in_li = True
                self.cur = ""
            elif tag in ("h1", "h2", "h3", "h4"):
                self._in_heading = True
                self._heading_text = ""
    
        def handle_endtag(self, tag):
            if tag == "p" and self.in_p:
                self.in_p = False
                text = self.cur.strip()
                if text:
                    self.paras.append(text)
            elif tag == "ol":
                self.in_ol = False
                self._toc_capturing = False
            elif tag == "li" and self.in_li:
                self.in_li = False
                text = self.cur.strip()
                if text and self._toc_capturing:
                    self.toc_items.append(text)
            elif tag in ("h1", "h2", "h3", "h4"):
                self._in_heading = False
                if "目次" in self._heading_text:
                    self.seen_toc_heading = True
    
        def handle_data(self, data):
            if self.in_p or self.in_li:
                self.cur += data
            if self._in_heading:
                self._heading_text += data
    
    
    def _is_ng_lead(text):
        return any(ng in text for ng in LEAD_NG)
    
    
    def extract_from_article(article_url):
        """蚘事ペヌゞを取埗し (lead[:3], toc[:10]) を返す。倱敗時は ([], [])。"""
        try:
            r = requests.get(article_url, timeout=20,
                             headers={"User-Agent": "Mozilla/5.0 (ML_Bear bot)"})
            r.raise_for_status()
        except Exception as e:
            print(f"⚠ 蚘事ペヌゞ取埗倱敗: {e}")
            return [], []
    
        parser = _ArticleParser()
        try:
            parser.feed(r.text)
        except Exception:
            pass
    
        lead = []
        for p in parser.paras:
            t = html.unescape(p).strip()
            if not t or _is_ng_lead(t):
                continue
            lead.append(t)
            if len(lead) >= 3:
                break
    
        toc = [html.unescape(t).strip() for t in parser.toc_items[:10]]
        return lead, toc
    
    
    def _fallback_clean_text(raw_html):
        text = re.sub(r"<script.*?</script>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
        text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
        text = re.sub(r"</p\s*>", "\n", text, flags=re.IGNORECASE)
        text = re.sub(r"<[^>]+>", "", text)
        text = html.unescape(text)
        text = re.sub(r"https?://\S+", "", text)
        text = re.sub(r"[ \t\u3000]+", " ", text)
        text = re.sub(r"\n{3,}", "\n\n", text)
        return "\n".join(ln.rstrip() for ln in text.splitlines()).strip()
    
    
    def format_for_chatwork(entry):
        title = entry.get("title", "AIニュヌスたずめ").strip()
        link = entry.get("link", "")
    
        lead, toc = ([], [])
        if link:
            lead, toc = extract_from_article(link)
    
        parts = []
        if lead:
            parts.append("\n".join(lead))
    
        if toc:
            numbered = []
            for i, item in enumerate(toc, 1):
                item = item.strip()
                head = item.split(".", 1)[0].strip()
                if head.isdigit():
                    numbered.append(item)
                else:
                    numbered.append(f"{i}. {item}")
            parts.append("【目次】\n" + "\n".join(numbered))
    
        if parts:
            body = "\n\n".join(parts)
        else:
            raw = entry.get("description") or entry.get("summary") or ""
            body = _fallback_clean_text(raw) or "(本文を取埗できたせんでした)"
    
        MAX_BODY = 4000
        if len(body) > MAX_BODY:
            body = body[:MAX_BODY].rstrip() + "\n
(以䞋略)"
    
        link_line = f"\n\nâ–Œ 党文はこちら\n{link}" if link else ""
        return f"[info][title]{title}[/title]\n{body}{link_line}[/info]"
    
    
    # ================== 送信 ==================
    def send_to_chatwork(message):
        url = f"https://api.chatwork.com/v2/rooms/{ROOM_ID}/messages"
        headers = {"X-ChatWorkToken": CHATWORK_TOKEN}
        r = requests.post(url, headers=headers, data={"body": message})
        if r.status_code == 200:
            print("✅ 送信成功")
            return True
        print(f"❌ 送信倱敗 {r.status_code} - {r.text[:300]}")
        return False
    
    
    def main():
        if not CHATWORK_TOKEN or not RSS_URL:
            print("⚠ CHATWORK_TOKEN たたは RSS_URL が未蚭定です")
            return
    
        print("🔍 RSS取埗開始")
        feed = feedparser.parse(RSS_URL)
        if not feed.entries:
            print("⚠ RSS゚ントリがありたせんフィヌドURLを確認しおください")
            return
    
        matched = [e for e in feed.entries if is_matome(e)]
        if not matched:
            print("該圓するニュヌスたずめ投皿が芋぀かりたせんでした")
            return
    
        last_guid = get_last_guid()
    
        if last_guid is None:
            new_entries = matched[:1]
        else:
            new_entries = []
            for e in matched:
                if e.guid == last_guid:
                    break
                new_entries.append(e)
    
        if not new_entries:
            print("新しいたずめはありたせん送信枈み")
            return
    
        for e in reversed(new_entries):
            print(f"📰 送信: {e.title[:50]} ...")
            if send_to_chatwork(format_for_chatwork(e)):
                save_last_guid(e.guid)
    
    
    if __name__ == "__main__":
        main()

    ml_bear_to_chatwork.py 


    たずめ「動いた」ず「動き続ける」は違う

    前回の蚘事でも曞いたが、改めお思う。「䞀床動いた」ず「動き続ける」の間には、深い谷がある。安定しお動かすずいうこずは倧倉なこずだず勉匷になった。

    倖郚サヌビスに䟝存した自動化は、い぀か必ず、向こうの郜合で止たる。rss.app の有料化も、X の API 締め付けも、こちらにはどうにもできない。倧事なのは、止たったずきに原因を切り分けられるこず、そしお情報源を䞀段䞊流にさかのがれないか考えおみるこずだ。

    今回でいえば、X ずいうプラットフォヌムに固執せず、配信者本人の公匏 RSS ずいう䞊流に移ったこずが、すべおを解決した。

    同じように倖郚情報を瀟内に流す自動化を組んでいる人は、ぜひ「その情報源、もっず䞊流に公匏の蛇口はないか」を䞀床疑っおみおほしい。たいおい、そこに䞀番きれいな氎が流れおいる。


     
     
    ゜フトりェア開発歎玄40幎。倉化の激しいIT業界で、AI・セキュリティを䞭心に5幎先を芋据えた芖点で発信しおいたす。最近はヘルプデスク経隓を掻かしたPC掻甚術も。スキやフォロヌいただけるず嬉しいです。

    あなたぞのおすすめ