
Lets Go Rss
- 13 installs
- 97 repo stars
- Updated April 24, 2026
- albedo-tabai/lets-go-rss
Aggregates RSS updates from YouTube, Vimeo, Behance, Twitter/X, Bilibili, Weibo, Douyin, and Xiaohongshu with incremental dedup and AI classification.
About
A lightweight cross-platform RSS subscription manager that pulls content updates from many social and video platforms, deduplicates incrementally, and classifies items with AI. Developers use it to run a scheduled feed digest and push aggregated updates to a bot.
- Add, update, list, and digest subscriptions via a single Python CLI
- Playwright-backed Chromium login flow bypasses anti-scraping for Twitter and Xiaohongshu
Lets Go Rss by the numbers
- 13 all-time installs (skills.sh)
- Ranked #1,436 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/albedo-tabai/lets-go-rss --skill lets-go-rssAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 13 |
|---|---|
| repo stars | ★ 97 |
| Last updated | April 24, 2026 |
| Repository | albedo-tabai/lets-go-rss ↗ |
What it does
Aggregates RSS updates from YouTube, Vimeo, Behance, Twitter/X, Bilibili, Weibo, Douyin, and Xiaohongshu with incremental dedup and AI classification.
Files
Let's Go RSS
全平台 RSS 内容聚合工具,支持增量更新、去重、AI 分类。
快速使用
添加订阅
python3 scripts/lets_go_rss.py --add "https://www.youtube.com/@MatthewEncina"
python3 scripts/lets_go_rss.py --add "https://vimeo.com/xkstudio"
python3 scripts/lets_go_rss.py --add "https://www.behance.net/yokohara6e48"更新全部(耗时操作,建议用 crontab 后台跑)
python3 scripts/lets_go_rss.py --update --digest --skip-setup读取缓存报告(Bot 推送用,瞬间返回)
python3 scripts/lets_go_rss.py --status查看订阅
python3 scripts/lets_go_rss.py --list
python3 scripts/lets_go_rss.py --stats查看全量概览(所有订阅最新一条)
python3 scripts/lets_go_rss.py --overview健康体检 & 自动修复
python3 scripts/lets_go_rss.py --doctor # 只打印状态
python3 scripts/lets_go_rss.py --auto-fix # 自动重启挂掉的 RSSHub、清僵尸锁Playwright tier(绕过平台反爬)
RSS_PLAYWRIGHT_PLATFORMS 控制哪些平台走 skill 自管的 Chromium(Playwright)—— 用真浏览器访问,继承"正常用户"反爬声誉。
默认已为 Bilibili 开启(B 站无需登录,直接工作)。Twitter / 小红书平台强制要求登录态。
Agent 应主动编排的一次性登录流程
当 --doctor 或摘要报告显示 Twitter/XHS 源因 auth 失败时,agent 应主动提议用户让你帮他完成登录,而不是让用户自己去跑命令。标准套路:
1. AskUserQuestion 问用户:要不要现在登录 Twitter / 小红书(多选) 2. 对用户勾选的每个平台,运行:
python3 scripts/lets_go_rss.py --login <twitter|xiaohongshu>这一条命令会依次:① 打开可见 Chromium 到登录页 → ② 等用户关窗 → ③ 自动拉一条该平台已有订阅验证登录成功 → ④ 自动把该平台写进 .env 的 RSS_PLAYWRIGHT_PLATFORMS 3. 最后跑一次 --doctor 或一轮冒烟,确认失败数下降
用户看到的步骤极简:只需要在弹出的 Chromium 里登录自己的账号,登完关窗,其他全部由 agent 搞定。
Bot 推送最佳实践
问题:--update 需要 30-60 秒抓取全部订阅,Bot 定时任务可能超时。
方案:抓取和推送解耦——crontab 提前跑更新,Bot 只读缓存文件。
稳定命令(推荐)
# 后台更新(内置超时参数 + 并发防重入锁)
./scripts/run_update_cron.sh
# Bot 推送只读缓存
./scripts/run_status_push.sh# crontab -e
# 每 2 小时的 55 分更新(提前 5 分钟准备好数据)
55 */2 * * * cd /path/to/lets-go-rss && ./scripts/run_update_cron.sh >> /tmp/rss_cron.log 2>&1
# Bot 在整点读缓存推送(瞬间完成)
0 */2 * * * cd /path/to/lets-go-rss && ./scripts/run_status_push.shBot 只需调用 --status,该命令直接读取 assets/latest_update.md 并输出内容,无需网络请求、无需等待。
平台支持
| 平台 | 主策略 | 备注 |
|---|---|---|
| Vimeo | Native RSS | ✅ |
| Behance | Native RSS | ✅ |
| YouTube | yt-dlp / Atom feed fallback | ✅ |
| 微博 | RSSHub → Chrome session fallback | ⚠️ 需本机 Chrome 开启 remote debugging |
| 抖音 | RSSHub | ⚠️ 当前不稳定 |
| B站 | RSSHub | ⚠️ 需配置 |
| 小红书 | Chrome session primary | ⚠️ 需本机 Chrome 已登录且开启 remote debugging |
| Twitter/X | Syndication API | ✅ |
| 知识星球 | pub-api (公开) | ✅ |
安装依赖
# 基础(YouTube + Vimeo + Behance + Twitter syndication + Zsxq pub-api)
pip install httpx yt-dlp anthropic
# 配置环境变量:把 ANTHROPIC_API_KEY 写进 .env(已 git-ignore)
cp .env.example .env && $EDITOR .env
# 中国平台(B站/微博/抖音/小红书 RSSHub 路由)
# 方式 A(推荐):skill 自管的 RSSHub,首次 start 自动 npm install
python3 scripts/rsshub_manager.py start
# 方式 B:若已经跑了 ready-cowork 的 :1200 RSSHub,skill 会自动作为 fallback
# 方式 C:自己起 Docker rsshub 并通过 RSSHUB_BASE_URL_FALLBACK 指过去
# 可选:启用真实 Chrome 会话抓取(微博 / 小红书推荐)
# 1) 打开 Chrome
# 2) 访问 chrome://inspect/#remote-debugging
# 3) 勾选 Allow remote debugging for this browser instanceRSSHub 自管与升级
首次 rsshub_manager.py start 会: 1. 在 <skill>/node_modules/rsshub 下 npm install rsshub@latest(~50MB,首次 1-2 分钟) 2. 如未装,自动下载 puppeteer 要求的 Chrome for Testing(约 200MB 到 ~/.cache/puppeteer) 3. 启动 scripts/rsshub_worker.mjs 监听 :1201,写 pidfile 到 assets/.rsshub.pid
后续 run_update_cron.sh 在每次 cron 前都会调 start-if-needed(最多等 10 秒),保证 :1201 健在。每周一早晨 06:xx 额外调 update 拉最新 RSSHub(后台跑,不阻塞该轮 cron)。
# 常用运维
python3 scripts/rsshub_manager.py status
python3 scripts/rsshub_manager.py restart
python3 scripts/rsshub_manager.py update # npm install + restart
python3 scripts/rsshub_manager.py logs # tail worker logBot 汇报规范(⚠️ 必须严格遵守)
当 Bot 需要推送 RSS 更新时,只需执行一个命令,然后原样转发输出。
完整流程(仅 2 步)
步骤 1: 运行命令
python3 scripts/lets_go_rss.py --status
步骤 2: 把命令输出原封不动地作为你的回复发送就这么简单。不需要任何额外处理。
输出格式说明
--status 命令会输出类似以下格式的纯文本(自动生成,不需要 Bot 构造):
📡 RSS 增量更新 | 2026-02-21 18:23 | 3 个账号有新内容
🆕 📺 影视飓风 02-18 03:00
[【4K限免】你的新设备能顶住吗?](https://t.bilibili.com/1170572725010300960)
🆕 🐦 歸藏(guizang.ai) 02-14 17:15
[Tweet by @op7418](https://x.com/op7418/status/2022721414462374031)仅显示有新内容的账号,无更新时显示"暂无新更新"。若需查看所有订阅的全量状态,使用 --overview。
❌ 禁止行为
- ❌ 不得重新排版:不可以按平台分组、加表格、加标题
#层级 - ❌ 不得分多条消息:所有内容必须在一条消息内发送
- ❌ 不得删除/修改链接:标题中的链接不可去掉或替换
- ❌ 不得添加前言后语:不要加"以下是 RSS 更新"等多余文字
- ❌ 不得执行 --update:推送时只读缓存,不做抓取
⏸️ 暂无更新时的处理
当 --status 输出中显示"暂无新更新"或类似表述时,只需回复一句话:
RSS 暂无新更新 ✅不需要列出各账号的最新内容,直接说暂无更新即可。
---
输出文件
| 文件 | 说明 |
|---|---|
assets/latest_update.md | 增量更新报告(--status 读取,仅含变动账号) |
assets/full_overview.md | 全量概览(--overview 读取,含所有账号) |
assets/feed.xml | 标准 RSS 2.0 XML |
assets/summary.md | 统计摘要 |
assets/subscriptions.opml | OPML 订阅导出 |
# Copy this file to `.env` (which is git-ignored) and fill in values you want
# to override. The cron wrapper (scripts/run_update_cron.sh) `source`s this
# automatically before running Python, so every var below is available to
# classifier.py / scrapers.py / rss_engine.py at runtime.
# --- Classifier ------------------------------------------------------------
# Providing ANTHROPIC_API_KEY switches the classifier to the LLM path (Claude
# Haiku 4.5 by default). Without it, the keyword-based SimpleClassifier is
# used — still fine for most content, but less accurate.
# ANTHROPIC_API_KEY=sk-ant-...
# Override the classifier model (default: claude-haiku-4-5-20251001)
# RSS_CLASSIFIER_MODEL=claude-haiku-4-5-20251001
# --- RSSHub ----------------------------------------------------------------
# The skill prefers :1201 (its own managed instance, see rsshub_manager.py)
# and falls back to :1200 (ready-cowork's embedded worker, if that app is
# running). Override either if your topology differs.
# RSSHUB_BASE_URL_PRIMARY=http://127.0.0.1:1201
# RSSHUB_BASE_URL_FALLBACK=http://127.0.0.1:1200
# Port used by rsshub_manager.py for the managed instance (default 1201)
# RSSHUB_MANAGED_PORT=1201
# Explicit paths if `node`/`npm` are not on PATH for cron
# RSSHUB_NODE_BIN=/opt/homebrew/bin/node
# RSSHUB_NPM_BIN=/opt/homebrew/bin/npm
# --- HTTP timeouts & retries (seconds) -------------------------------------
# RSS_HTTP_TIMEOUT=10
# RSS_HTTP_RETRIES=2
# RSS_HTTP_BACKOFF=0.8
# RSS_XHS_TIMEOUT=15
# RSS_YTDLP_TIMEOUT=45
# --- UX --------------------------------------------------------------------
# RSS_NOTIFY_MACOS=0 to disable macOS notifications (e.g. XHS cookie expiry)
# RSS_NOTIFY_MACOS=1
# Parallel fetch workers (default 5)
# RSS_MAX_WORKERS=5
# --- Playwright tier (skill-managed Chromium) ------------------------------
# Fetches Bilibili / XHS / Twitter via our own headless Chromium, bypassing
# the anti-scraping that RSSHub routes hit. Playwright + its Chromium are
# already pip-installable (zero user action for Bilibili, which is public).
# Twitter / XHS additionally require a one-time sign-in (cookies persist):
# python scripts/lets_go_rss.py --login twitter
# python scripts/lets_go_rss.py --login xiaohongshu
#
# Comma-separated whitelist. Empty = disabled.
# RSS_PLAYWRIGHT_PLATFORMS=bilibili,xiaohongshu,twitter
# RSS_PLAYWRIGHT_PROFILE=~/.lets-go-rss/browser-profile
# Runtime generated files
assets/rss_database.db
assets/rss_database.db-shm
assets/rss_database.db-wal
assets/*.xml
assets/*.md
assets/subscriptions.opml
assets/.update.lock
*.xml
*.opml
latest_update.md
summary.md
# Python
__pycache__/
*.pyc
*.pyo
# Node (skill-owned RSSHub install)
node_modules/
package-lock.json
package.json
# RSSHub worker runtime state
assets/.rsshub.pid
assets/.rsshub.log
assets/.last_run_health.json
# OpenClaw push runtime logs
logs/
# Environment overrides
.env
# OS
.DS_Store
assets/last_digest.json
Let's Go RSS 🛰️ 全平台 RSS 订阅管理器
AI-Powered Universal RSS Subscription Manager | AI 驱动的全平台 RSS 订阅管理器
A lightweight RSS aggregator designed to work as a Claude Skill inside AI-powered IDEs and agents. Add subscriptions from 7 platforms(YouTube, Vimeo, Behance, Bilibili, Weibo微博, Douyin抖音, Xiaohongshu小红书), auto-update with deduplication, and get digest reports — all through simple CLI commands that your AI assistant can run for you.
一个轻量级 RSS 聚合工具,设计为 Claude Skill 在 AI IDE 和 Agent 中运行。支持 7 个平台(YouTube, Vimeo, Behance, Bilibili, Weibo微博, Douyin抖音, Xiaohongshu小红书)的订阅管理、自动更新去重、智能摘要推送——通过简单的命令行指令,让你的 AI 助手自动完成。
---
💡 Why This Project? | 为什么做这个?
The Problem | 问题
We are trapped inside algorithms. The average internet user spends 2 hours 23 minutes per day on social media (DataReportal 2024), of which 80-90% is passive zombie scrolling. Research from UPenn shows that only 30 minutes of daily social media use is genuinely beneficial — every second beyond that has diminishing or even negative returns.
我们被困在算法里。全球网民平均每天花 2 小时 23 分钟刷社交媒体(DataReportal 2024),其中 80-90% 是无意识被动浏览。宾夕法尼亚大学研究表明,每天社交媒体使用超过 30 分钟后的每一秒,边际效益都在递减甚至变为负值。
The Science | 科学依据
- Dunbar's Number (150) — Oxford anthropologist Robin Dunbar proved that humans can maintain at most ~150 stable relationships. Indiana University's analysis of Twitter confirmed: even users following thousands of accounts only actively interact with 100-200 people. Following more than 150 accounts means you're consuming data streams, not maintaining relationships.
- Miller's Law (7±2) — Cognitive psychology tells us our working memory holds ~7 items. The brain can deeply process only 5-9 quality sources per day. Hundreds of subscriptions trigger decision fatigue, forcing your brain into shallow scanning mode.
- 邓巴数(150)—— 牛津大学人类学家邓巴证明,人类最多维持约 150 段稳定社交关系。印第安纳大学对 Twitter 的大数据分析确认:即使关注数千人,活跃互动圈依然卡在 100-200 人。超过 150 个关注,你消费的是数据流,而非社交关系。
- 米勒定律(7±2)—— 认知心理学表明,短时记忆容量约 7 个单位。大脑每天能深度消化的高质量信源通常不超过 5-9 个。关注几百个账号的结果是"决策疲劳",大脑放弃深度处理,转为浅层扫描。
The Coming Storm | 即将到来的风暴
With AIGC's marginal cost approaching zero, the internet is heading toward a reality where 90%+ of content is AI-generated. The "Dead Internet Theory" is becoming fact. Social platforms are splitting from "Social Media" into "Recommendation Media" — AI feeds content, humans secrete dopamine. Real human connection is retreating into private, verified circles (Dark Forest socialization).
随着 AIGC 边际成本趋零,互联网正走向 90% 以上内容由 AI 生成的现实。"死互联网理论"正在成真。社交平台正从"社交媒体"裂变为"推荐媒体"——AI 负责投喂,人类负责分泌多巴胺。真人社交正撤退至私密的、经过验证的小圈子(黑暗森林化)。
The Solution | 解决方案
Take back control. Stop handing your attention to "guess what you like" algorithms. Build your own information moat:
夺回控制权。 别再把注意力交给"猜你喜欢"。建立你自己的信息护城河:
🎯 Curate ≤150 accounts → 📡 Let RSS pull updates → 🤖 Let AI filter noise → ☕ Reclaim your 2 hours
>
🎯 精选 ≤150 个关注 → 📡 让 RSS 拉取更新 → 🤖 让 AI 过滤噪音 → ☕ 夺回你的 2 小时
From FOMO to JOMO — embrace the joy of missing out. 99% of information is noise. Your attention is the last scarce resource in the age of AI.
从 FOMO 到 JOMO —— 拥抱「错过的快乐」。99% 的信息都是噪音。在 AI 时代,你的注意力是最后的稀缺资源。
<details> <summary>📚 References | 参考文献</summary>
1. Dunbar, R. I. M. (1992). Neocortex size as a constraint on group size in primates. Journal of Human Evolution, 22(6), 469–493. 2. Gonçalves, B., Perra, N., & Vespignani, A. (2011). Modeling Users' Activity on Twitter Networks: Validation of Dunbar's Number. PLoS ONE, 6(8), e22656. (Indiana University) 3. Miller, G. A. (1956). The Magical Number Seven, Plus or Minus Two. Psychological Review, 63(2), 81–97. 4. Hunt, M. G., Marx, R., Lipson, C., & Young, J. (2018). No More FOMO: Limiting Social Media Decreases Loneliness and Depression. Journal of Social and Clinical Psychology, 37(10), 751–768. (UPenn) 5. Kemp, S. (2024). Digital 2024: Global Overview Report. DataReportal / We Are Social / Meltwater. 6. GWI (2024). Social Media Trends Report. GlobalWebIndex.
</details>
---
🤖 Designed for AI IDEs | 为 AI IDE 设计
This Skill is built to be used with AI-powered coding environments:
本 Skill 设计为配合以下 AI 编程环境使用:
- [Claude Code](https://claude.ai/code) — Anthropic's AI coding agent (recommended)
- [Cursor](https://cursor.sh) — AI-first code editor
- [Windsurf](https://codeium.com/windsurf) — AI-powered IDE by Codeium
- [OpenClaw](https://github.com/nicepkg/openclaw) — Open-source Claude Code alternative
Just share this repo's URL with your AI assistant, and it will read SKILL.md to understand how to manage your RSS subscriptions automatically.
只需将本仓库 URL 分享给你的 AI 助手,它会读取 SKILL.md 并自动帮你管理 RSS 订阅。
---
✨ Features | 功能特性
| Feature | 功能 | Description |
|---|---|---|
| 📡 7-Platform Support | 7 平台支持 | YouTube, Vimeo, Behance, Bilibili, Weibo, Douyin, Xiaohongshu |
| 🔄 Incremental Updates | 增量更新 | SQLite-based dedup, only fetches new content |
| 📋 Digest Mode | 摘要模式 | --digest shows latest 1 item per account |
| 🤖 AI Classification | AI 分类 | Optional Claude-powered topic categorization |
| 📰 Standard Output | 标准输出 | RSS 2.0 XML + Markdown reports |
| ⏰ Schedulable | 可定时 | Works with crontab for automated updates |
---
🚀 Quick Start | 快速开始
Install | 安装
# Core dependencies | 核心依赖
pip install httpx yt-dlpBasic Usage | 基本使用
# Add subscriptions | 添加订阅
python3 scripts/lets_go_rss.py --add "https://www.youtube.com/@MatthewEncina"
python3 scripts/lets_go_rss.py --add "https://vimeo.com/xkstudio"
python3 scripts/lets_go_rss.py --add "https://www.behance.net/yokohara6e48"
# Update all | 更新全部
python3 scripts/lets_go_rss.py --update --no-llm
# Digest mode (1 item per account) | 摘要模式(每账号 1 条)
python3 scripts/lets_go_rss.py --update --no-llm --digest --skip-setup
# Read cached report (bot push) | 读取缓存报告(Bot 推送)
python3 scripts/lets_go_rss.py --status
# List subscriptions | 查看订阅
python3 scripts/lets_go_rss.py --list---
🏗️ Architecture | 架构
┌──────────────────────────────────────────────────┐
│ Tier 1: Native RSS (zero dependency) │
│ Vimeo / Behance → httpx reads RSS directly │
├──────────────────────────────────────────────────┤
│ Tier 1b: yt-dlp (pip install) │
│ YouTube → yt-dlp extracts metadata │
├──────────────────────────────────────────────────┤
│ Tier 2: RSSHub Proxy (optional Docker) │
│ Weibo / Douyin / Bilibili / XHS → local RSSHub │
└──────────────────────────────────────────────────┘📊 Platform Support | 平台支持
| Platform | Method | Dependency | Ready? |
|---|---|---|---|
| YouTube | yt-dlp | pip install yt-dlp | ✅ |
| Vimeo | Native RSS | httpx | ✅ |
| Behance | Native RSS | httpx | ✅ |
| Weibo 微博 | RSSHub | Docker | ⚠️ |
| Douyin 抖音 | RSSHub | Docker | ⚠️ |
| Bilibili B站 | RSSHub | Docker | ⚠️ |
| Xiaohongshu 小红书 | RSSHub | Docker | ⚠️ |
---
🇨🇳 Chinese Platforms Setup | 中国平台配置
For Weibo, Douyin, Bilibili, and Xiaohongshu, you need a self-hosted RSSHub:
使用微博、抖音、B站、小红书需要自建 RSSHub:
docker run -d --name rsshub -p 1200:1200 diygod/rsshub:chromium-bundled
export RSSHUB_BASE_URL="http://localhost:1200"
# Optional: tighter network timeout for bot timeout limits
export RSS_HTTP_TIMEOUT="10"
export RSS_HTTP_RETRIES="2"
export RSS_XHS_TIMEOUT="6"
export RSS_XHS_RETRIES="1"
export RSS_YTDLP_TIMEOUT="12"---
📂 Project Structure | 项目结构
lets-go-rss/
├── SKILL.md # Claude Skill entry point | AI 技能入口
├── README.md # This file | 本文件
├── requirements.txt # Python deps | Python 依赖
├── scripts/
│ ├── lets_go_rss.py # Main entry | 主入口
│ ├── rss_engine.py # Core engine | 核心引擎
│ ├── scrapers.py # Platform scrapers | 平台爬虫
│ ├── database.py # SQLite manager | 数据库
│ ├── classifier.py # AI classification | AI 分类
│ ├── rss_generator.py # XML generation | XML 生成
│ ├── report_generator.py # Markdown reports | 报告生成
│ ├── run_update_cron.sh # Stable update command | 稳定更新命令
│ └── run_status_push.sh # Stable status command | 稳定推送命令
└── assets/ # Runtime data (gitignored) | 运行时数据⏰ Scheduled Updates | 定时更新
# Recommended stable commands
cd /path/to/lets-go-rss && ./scripts/run_update_cron.sh
cd /path/to/lets-go-rss && ./scripts/run_status_push.sh
# crontab -e — update at :55 every 2 hours, push at every even hour
55 */2 * * * cd /path/to/lets-go-rss && ./scripts/run_update_cron.sh >> /tmp/rss_cron.log 2>&1
0 */2 * * * cd /path/to/lets-go-rss && ./scripts/run_status_push.shThe engine now uses assets/.update.lock to prevent overlapping update jobs.
🤝 AI Classification (Optional) | AI 分类(可选)
pip install anthropic
export ANTHROPIC_API_KEY="your-key"
# Update with AI classification | 使用 AI 分类更新
python3 scripts/lets_go_rss.py --updateLicense
MIT
httpx>=0.27.0
yt-dlp
python-dateutil
#!/usr/bin/env python3
"""
Chrome session bridge for lets-go-rss.
Goal:
- Reuse the user's real Chrome session when remote debugging is enabled.
- Keep the implementation skill-local and portable across machines.
- Avoid hard-coding machine-specific paths beyond standard Chrome defaults.
Design:
- Discover DevToolsActivePort from standard Chrome profile locations.
- Connect over CDP via Playwright.
- Reuse an existing browser context if available.
- Open a temporary tab, extract page text / DOM payload, then close the tab.
Notes:
- This does NOT keep a process permanently running by itself yet.
- It is a bridge layer that allows the skill to connect to an already-enabled
user Chrome session in a portable way.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Callable, Any, Optional
class ChromeSessionUnavailable(RuntimeError):
pass
class ChromeSessionBridge:
def __init__(self):
self.chrome_name = os.environ.get("RSS_CHROME_NAME", "Google/Chrome")
def _candidate_devtools_files(self) -> list[Path]:
home = Path.home()
candidates = [
home / "Library/Application Support/Google/Chrome/DevToolsActivePort", # macOS Chrome stable
home / "Library/Application Support/Chromium/DevToolsActivePort", # macOS Chromium
home / ".config/google-chrome/DevToolsActivePort", # Linux Chrome
home / ".config/chromium/DevToolsActivePort", # Linux Chromium
]
custom = os.environ.get("RSS_CHROME_DEVTOOLS_FILE")
if custom:
candidates.insert(0, Path(custom).expanduser())
return candidates
def discover_ws_url(self) -> str:
for path in self._candidate_devtools_files():
if not path.exists():
continue
try:
lines = path.read_text(encoding="utf-8").splitlines()
if len(lines) >= 2:
port = lines[0].strip()
ws_path = lines[1].strip()
if port and ws_path:
return f"ws://127.0.0.1:{port}{ws_path}"
except Exception:
continue
raise ChromeSessionUnavailable(
"Chrome remote debugging not available. Open Chrome, enable remote debugging, then retry."
)
def with_page(self, url: str, callback: Callable[[Any], Any], wait_ms: int = 6000) -> Any:
try:
from playwright.sync_api import sync_playwright
except Exception as e:
raise ChromeSessionUnavailable(f"Playwright unavailable: {e}")
ws_url = self.discover_ws_url()
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(ws_url)
context = browser.contexts[0] if browser.contexts else browser.new_context()
page = context.new_page()
try:
page.goto(url, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(wait_ms)
return callback(page)
finally:
try:
page.close()
except Exception:
pass
try:
browser.close()
except Exception:
pass
def extract_basic_page_state(self, url: str, wait_ms: int = 6000) -> dict:
def _cb(page):
body = ""
try:
body = page.locator("body").inner_text()[:4000]
except Exception:
pass
return {
"url": page.url,
"title": page.title(),
"body": body,
}
return self.with_page(url, _cb, wait_ms=wait_ms)
"""
LLM-based content classification module
Classifies RSS items into categories using AI, with keyword-based hot fallback.
"""
import os
import json
import time
from typing import List, Dict, Any, Optional
CATEGORIES = ["科技", "人文", "设计", "娱乐", "其他"]
# Strengthened Chinese keyword table — covers common Bilibili/Xiaohongshu/Weibo content
SIMPLE_KEYWORDS: Dict[str, List[str]] = {
"科技": [
# 通用
"技术", "编程", "代码", "算法", "开发", "开源", "软件", "硬件", "芯片",
"人工智能", "AI", "机器学习", "深度学习", "大模型", "LLM", "GPT", "Claude",
"量子", "航天", "火箭", "科学", "物理", "生物", "化学", "实验", "研究",
"数据库", "云", "服务器", "网络", "安全", "漏洞", "加密", "区块链", "比特币",
"Linux", "macOS", "Windows", "iOS", "Android", "Docker", "Kubernetes",
"前端", "后端", "全栈", "API", "SDK", "框架", "库", "GitHub", "Git",
"评测", "测评", "性能", "基准", "跑分", "配置", "升级", "安装",
# 英文
"tech", "code", "programming", "software", "hardware", "algorithm",
"machine learning", "deep learning", "neural", "model", "compute",
],
"人文": [
"文学", "历史", "哲学", "社会", "文化", "人文", "思想", "书籍", "阅读", "读书",
"诗", "诗歌", "散文", "小说", "经典", "古籍", "古代", "近代", "现代史",
"宗教", "信仰", "伦理", "心理", "心理学", "社会学", "人类学", "政治",
"传统", "民俗", "考古", "博物", "博物馆", "文物", "遗产",
"literature", "history", "philosophy", "culture", "society", "politics",
],
"设计": [
"设计", "UI", "UX", "交互", "平面", "产品设计", "工业设计", "字体", "排版",
"品牌", "VI", "logo", "标志", "海报", "插画", "配色", "色彩",
"艺术", "摄影", "视觉", "美学", "审美", "灵感", "灵感来源", "案例",
"手绘", "素描", "水彩", "油画", "装置", "雕塑", "空间", "建筑", "室内",
"design", "art", "photography", "visual", "typography", "illustration",
"architecture", "interior", "brand", "poster",
],
"娱乐": [
# 游戏
"游戏", "手游", "端游", "主机", "Steam", "Switch", "PS5", "Xbox",
"原神", "王者荣耀", "英雄联盟", "LOL", "吃鸡", "PUBG", "我的世界", "Minecraft",
"攻略", "通关", "速通", "直播", "主播",
# 影视
"电影", "影视", "剧集", "电视剧", "综艺", "动漫", "番剧", "动画",
"漫威", "DC", "奥斯卡", "票房", "导演", "演员", "明星", "偶像",
"解说", "影评", "剪辑", "预告片",
# 音乐 / 体育 / 生活
"音乐", "歌曲", "MV", "演唱会", "乐队", "歌手", "专辑",
"体育", "足球", "篮球", "NBA", "世界杯", "奥运", "电竞",
"美食", "做饭", "菜谱", "探店", "吃播",
"穿搭", "时尚", "美妆", "护肤", "口红", "化妆",
"旅行", "旅游", "vlog", "日常", "生活", "搞笑", "段子",
"game", "gaming", "movie", "film", "music", "entertainment",
"sport", "vlog", "fashion", "makeup", "food",
],
}
def _notify(msg: str) -> None:
"""Uniform error surfacing — prefixed so it's greppable in cron logs."""
print(f" ⚠️ [classifier] {msg}", flush=True)
def _classify_error_kind(exc: BaseException) -> str:
"""Bucket an anthropic/HTTP error into a short kind tag."""
name = type(exc).__name__.lower()
msg = str(exc).lower()
if "ratelimit" in name or "rate_limit" in msg or "429" in msg:
return "rate_limit"
if "auth" in name or "401" in msg or "403" in msg or "invalid x-api-key" in msg or "api key" in msg:
return "auth"
if "timeout" in name or "timeout" in msg or "connect" in name or "network" in msg:
return "network"
if "notfound" in name or "404" in msg or "model" in msg and "not found" in msg:
return "model_missing"
return "other"
class ContentClassifier:
"""Classifies content using Claude API, with SimpleClassifier as hot fallback."""
DEFAULT_MODEL = os.environ.get(
"RSS_CLASSIFIER_MODEL", "claude-haiku-4-5-20251001"
)
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
if not self.api_key:
raise ValueError("ANTHROPIC_API_KEY environment variable is required")
try:
import anthropic
except ImportError:
raise ImportError("anthropic package not installed. Run: pip install anthropic")
self._anthropic = anthropic
self.client = anthropic.Anthropic(api_key=self.api_key)
self.model = model or self.DEFAULT_MODEL
self.categories = CATEGORIES
self._simple = SimpleClassifier() # hot fallback
self._consecutive_errors = 0
self.system_prompt = """你是一个内容分类专家。你的任务是将提供的内容分类到以下类别之一:
- 科技: 技术、编程、科学、AI、软件、硬件等
- 人文: 文学、历史、哲学、社会、文化等
- 设计: UI/UX、平面设计、产品设计、艺术、摄影等
- 娱乐: 游戏、影视、音乐、综艺、体育、美食、时尚、生活等
- 其他: 不属于以上类别的内容
请只返回类别名称,不要添加任何解释。如果内容跨越多个类别,选择最主要的一个。"""
def _fallback(self, title: str, description: str, reason: str) -> str:
"""LLM → SimpleClassifier → 其他 三级回退。"""
cat = self._simple.classify_item(title, description)
_notify(f"LLM classification failed ({reason}); keyword fallback → {cat}")
return cat
def classify_item(self, title: str, description: str = "") -> str:
"""Classify a single item. Never silently returns 其他 on LLM errors —
always goes through keyword fallback first."""
content = f"标题: {title}\n"
if description:
content += f"描述: {description[:500]}"
try:
message = self.client.messages.create(
model=self.model,
max_tokens=50,
temperature=0,
system=self.system_prompt,
messages=[{"role": "user", "content": content}],
)
self._consecutive_errors = 0
category = message.content[0].text.strip()
if category in self.categories:
return category
for cat in self.categories:
if cat in category:
return cat
# LLM returned something unparseable — treat as soft failure
return self._fallback(title, description, f"unparseable response: {category[:40]!r}")
except self._anthropic.RateLimitError as e:
self._consecutive_errors += 1
# Adaptive sleep; let the caller's batch loop retry on next item
sleep = min(5.0, 1.0 * self._consecutive_errors)
_notify(f"rate_limit — sleeping {sleep:.1f}s")
time.sleep(sleep)
return self._fallback(title, description, "rate_limit")
except Exception as e:
self._consecutive_errors += 1
return self._fallback(title, description, f"{_classify_error_kind(e)}: {e}")
def classify_batch(self, items: List[Dict[str, Any]], batch_size: int = 5) -> List[Dict[str, Any]]:
"""Classify multiple items with rate limiting."""
classified_items = []
for i in range(0, len(items), batch_size):
batch = items[i:i + batch_size]
for item in batch:
item["category"] = self.classify_item(
item.get("title", ""),
item.get("description", ""),
)
classified_items.append(item)
if i + batch_size < len(items):
time.sleep(0.4)
return classified_items
class SimpleClassifier:
"""Keyword-based classifier — also used as hot fallback for ContentClassifier."""
def __init__(self):
self.keywords = SIMPLE_KEYWORDS
def classify_item(self, title: str, description: str = "") -> str:
text = (title + " " + description).lower()
scores = {cat: 0 for cat in self.keywords}
for category, keywords in self.keywords.items():
for keyword in keywords:
if keyword.lower() in text:
scores[category] += 1
max_score = max(scores.values())
if max_score > 0:
return max(scores, key=scores.get)
return "其他"
def get_classifier(use_llm: bool = True) -> Any:
"""Factory function to get appropriate classifier."""
if use_llm:
try:
return ContentClassifier()
except ValueError:
print("⚠️ ANTHROPIC_API_KEY not found, falling back to keyword-based classifier")
return SimpleClassifier()
return SimpleClassifier()
import re
import sqlite3
import threading
import time
from typing import List, Optional, Dict, Any
from datetime import datetime
from email.utils import parsedate_to_datetime
import json
_ISO_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}')
# Module-level lock: serialises ALL SQLite opens / writes so that
# Playwright's Chromium sub-process can never hold a WAL shm-lock
# while another thread tries to open the same database.
_DB_LOCK = threading.Lock()
class RSSDatabase:
def __init__(self, db_path: str = "rss_database.db"):
self.db_path = db_path
self.init_database()
def _connect(self, retries: int = 3):
"""Create a SQLite connection with WAL mode, busy timeout, and retry."""
last_err = None
for attempt in range(retries):
try:
with _DB_LOCK:
conn = sqlite3.connect(self.db_path, timeout=30)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
except sqlite3.OperationalError as e:
last_err = e
if attempt < retries - 1:
time.sleep(0.5 * (attempt + 1))
raise last_err
def init_database(self):
"""Initialize database schema + idempotent migrations."""
with self._connect() as conn:
cursor = conn.cursor()
# Subscriptions table
cursor.execute("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT UNIQUE NOT NULL,
platform TEXT NOT NULL,
title TEXT,
description TEXT,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_updated TIMESTAMP,
active INTEGER DEFAULT 1
)
""")
# Items table - stores all fetched items
cursor.execute("""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT UNIQUE NOT NULL,
subscription_id INTEGER NOT NULL,
title TEXT,
description TEXT,
link TEXT,
category TEXT,
pub_date TIMESTAMP,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
)
""")
# Create indices for faster lookups
cursor.execute("CREATE INDEX IF NOT EXISTS idx_item_id ON items(item_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscription_id ON items(subscription_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_category ON items(category)")
# --- Migrations (idempotent) ---
self._migrate_subscriptions(cursor)
conn.commit()
@staticmethod
def _column_names(cursor, table: str) -> set:
cursor.execute(f"PRAGMA table_info({table})")
return {row[1] for row in cursor.fetchall()}
def _migrate_subscriptions(self, cursor) -> None:
cols = self._column_names(cursor, "subscriptions")
if "consecutive_failures" not in cols:
cursor.execute(
"ALTER TABLE subscriptions ADD COLUMN consecutive_failures INTEGER DEFAULT 0"
)
if "last_error" not in cols:
cursor.execute(
"ALTER TABLE subscriptions ADD COLUMN last_error TEXT"
)
if "last_error_kind" not in cols:
cursor.execute(
"ALTER TABLE subscriptions ADD COLUMN last_error_kind TEXT"
)
if "last_success_at" not in cols:
cursor.execute(
"ALTER TABLE subscriptions ADD COLUMN last_success_at TIMESTAMP"
)
def record_fetch_outcome(self, subscription_id: int, *,
success: bool, error: Optional[str] = None,
error_kind: Optional[str] = None) -> None:
"""Update a subscription's health signals after a fetch attempt.
- On success: clear consecutive_failures, clear last_error, bump
last_success_at.
- On failure: increment consecutive_failures, record error + kind.
"""
now = datetime.now().isoformat()
with self._connect() as conn:
cur = conn.cursor()
if success:
cur.execute(
"UPDATE subscriptions "
"SET consecutive_failures=0, last_error=NULL, last_error_kind=NULL, "
" last_success_at=?, last_updated=? "
"WHERE id=?",
(now, now, subscription_id),
)
else:
cur.execute(
"UPDATE subscriptions "
"SET consecutive_failures=COALESCE(consecutive_failures, 0) + 1, "
" last_error=?, last_error_kind=?, last_updated=? "
"WHERE id=?",
(error, error_kind, now, subscription_id),
)
conn.commit()
def add_subscription(self, url: str, platform: str, title: str = "", description: str = "") -> int:
"""Add a new subscription"""
with self._connect() as conn:
cursor = conn.cursor()
try:
cursor.execute("""
INSERT INTO subscriptions (url, platform, title, description)
VALUES (?, ?, ?, ?)
""", (url, platform, title, description))
conn.commit()
return cursor.lastrowid
except sqlite3.IntegrityError:
# Subscription already exists, return existing id
cursor.execute("SELECT id FROM subscriptions WHERE url = ?", (url,))
return cursor.fetchone()[0]
def get_subscriptions(self, active_only: bool = True) -> List[Dict[str, Any]]:
"""Get all subscriptions"""
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
if active_only:
cursor.execute("SELECT * FROM subscriptions WHERE active = 1")
else:
cursor.execute("SELECT * FROM subscriptions")
return [dict(row) for row in cursor.fetchall()]
def item_exists(self, item_id: str) -> bool:
"""Check if an item has already been fetched"""
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM items WHERE item_id = ?", (item_id,))
return cursor.fetchone() is not None
@staticmethod
def _normalize_date(date_str: Optional[str]) -> str:
"""Normalize any date format to ISO 8601 (YYYY-MM-DDTHH:MM:SS)."""
if not date_str:
return datetime.now().isoformat()
# Already ISO 8601? (e.g. 2026-02-11T09:00:00)
if _ISO_DATE_RE.match(date_str):
return date_str
# Try RFC 822 (e.g. 'Wed, 11 Feb 2026 02:07:30 GMT')
try:
dt = parsedate_to_datetime(date_str)
return dt.isoformat()
except Exception:
pass
# Fallback: store as-is
return date_str
def add_item(self, item_id: str, subscription_id: int, title: str,
description: str = "", link: str = "", category: str = "",
pub_date: Optional[str] = None, metadata: Optional[Dict] = None) -> bool:
"""Add a new item. Uses INSERT OR IGNORE for thread-safe dedup."""
normalized_date = self._normalize_date(pub_date)
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR IGNORE INTO items (item_id, subscription_id, title,
description, link, category, pub_date, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (item_id, subscription_id, title, description, link, category,
normalized_date,
json.dumps(metadata) if metadata else None))
conn.commit()
return cursor.rowcount > 0
def get_items_by_category(self, category: Optional[str] = None,
since: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get items, optionally filtered by category and date"""
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = "SELECT * FROM items WHERE 1=1"
params = []
if category:
query += " AND category = ?"
params.append(category)
if since:
query += " AND fetched_at >= ?"
params.append(since)
query += " ORDER BY pub_date DESC"
cursor.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
def get_new_items_since(self, since: str) -> List[Dict[str, Any]]:
"""Get all items fetched since a specific timestamp"""
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT i.*, s.platform, s.url as subscription_url
FROM items i
JOIN subscriptions s ON i.subscription_id = s.id
WHERE i.fetched_at >= ?
ORDER BY i.category, i.pub_date DESC
""", (since,))
return [dict(row) for row in cursor.fetchall()]
def get_latest_per_subscription(self) -> list:
"""Get the single latest item for each subscription, sorted by pub_date DESC."""
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT i.*, s.platform, s.url as subscription_url, s.title as subscription_title
FROM items i
JOIN subscriptions s ON i.subscription_id = s.id
WHERE i.id = (
SELECT i2.id FROM items i2
WHERE i2.subscription_id = i.subscription_id
ORDER BY i2.pub_date DESC
LIMIT 1
)
ORDER BY i.pub_date DESC
""")
return [dict(row) for row in cursor.fetchall()]
def update_subscription_timestamp(self, subscription_id: int):
"""Update the last_updated timestamp for a subscription"""
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE subscriptions
SET last_updated = CURRENT_TIMESTAMP
WHERE id = ?
""", (subscription_id,))
conn.commit()
def update_subscription_title(self, subscription_id: int, title: str):
"""Auto-update subscription title from feed channel name."""
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE subscriptions SET title = ?
WHERE id = ? AND (title LIKE '%Subscription%' OR title = '')
""", (title, subscription_id))
conn.commit()
def get_all_items(self) -> List[Dict[str, Any]]:
"""Get all items for RSS feed generation"""
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT i.*, s.platform
FROM items i
JOIN subscriptions s ON i.subscription_id = s.id
ORDER BY i.pub_date DESC
LIMIT 1000
""")
return [dict(row) for row in cursor.fetchall()]
#!/usr/bin/env python3
"""
Let's Go RSS - Main entry point
Lightweight RSS subscription manager for multiple platforms.
"""
import sys
import os
from pathlib import Path
# Resolve directories relative to this script
SKILL_DIR = Path(__file__).parent.parent
SCRIPTS_DIR = SKILL_DIR / "scripts"
ASSETS_DIR = SKILL_DIR / "assets"
# Add scripts directory to Python path
sys.path.insert(0, str(SCRIPTS_DIR))
# Ensure assets directory exists
ASSETS_DIR.mkdir(exist_ok=True)
def ensure_dependencies():
"""Check and install dependencies if needed."""
setup_script = SCRIPTS_DIR / "setup.py"
if not setup_script.exists():
return True
import subprocess
result = subprocess.run(
[sys.executable, str(setup_script)],
capture_output=True, text=True
)
if result.returncode != 0:
print("⚠️ Setup check completed with warnings")
return False
return True
def print_cached_status() -> int:
"""Print cached report directly without importing engine dependencies."""
report_path = ASSETS_DIR / "latest_update.md"
if report_path.exists():
print(report_path.read_text(encoding="utf-8"))
return 0
print("⚠️ 尚无缓存报告。请先运行 --update 生成。")
return 0
def run_doctor(auto_fix: bool = False) -> int:
"""Print a health snapshot and optionally auto-fix what can be fixed.
Auto-fixable: stale rsshub pidfile, unhealthy :1201 (restart worker).
Not auto-fixable (printed as actionables): XHS cookies expired, missing
ANTHROPIC_API_KEY.
"""
import json
import shutil
import subprocess as _sp
import urllib.request
sys.path.insert(0, str(SCRIPTS_DIR))
print("# Let's Go RSS — Doctor\n")
# Runtime deps
print("## 运行时")
for name in ("python3", "node", "npx", "yt-dlp"):
where = shutil.which(name)
print(f"- {name}: {where or '❌ MISSING'}")
try:
import httpx # noqa: F401
print("- httpx (py): OK")
except Exception as e:
print(f"- httpx (py): ❌ {e}")
try:
import anthropic # noqa: F401
print("- anthropic (py): OK")
except Exception as e:
print(f"- anthropic (py): ⚠️ {e}")
key = os.environ.get("ANTHROPIC_API_KEY")
if key:
print(f"- ANTHROPIC_API_KEY: ✅ present (len={len(key)})")
else:
env_path = SKILL_DIR / ".env"
hint = f" (put it in {env_path})" if not env_path.exists() else ""
print(f"- ANTHROPIC_API_KEY: ⚠️ missing — classifier will use keyword fallback{hint}")
# RSSHub
print("\n## RSSHub")
for label, url in (("primary (:1201, managed)", "http://127.0.0.1:1201/healthz"),
("fallback (:1200, ready-cowork)", "http://127.0.0.1:1200/healthz")):
try:
with urllib.request.urlopen(url, timeout=1.5) as r:
print(f"- {label}: ✅ {r.status}")
except Exception as e:
print(f"- {label}: ❌ {e}")
# Sub health
try:
from database import RSSDatabase
db = RSSDatabase(str(ASSETS_DIR / "rss_database.db"))
subs = db.get_subscriptions()
worst = sorted(
[s for s in subs if (s.get("consecutive_failures") or 0) > 0],
key=lambda s: -(s.get("consecutive_failures") or 0),
)[:5]
print(f"\n## 源健康 ({len(subs)} 总)")
if worst:
print("最不健康的源:")
for s in worst:
print(f"- {s.get('title') or s['platform']}: "
f"fails={s.get('consecutive_failures')} "
f"kind={s.get('last_error_kind')} "
f"error={(s.get('last_error') or '')[:80]}")
else:
print("全部健康 ✅")
except Exception as e:
print(f"⚠️ failed to read DB: {e}")
# XHS cookies
xhs_cookie = Path(os.path.expanduser("~/.mcp/rednote/cookies.json"))
print("\n## XHS cookies")
if xhs_cookie.exists():
import time as _t
age_days = (_t.time() - xhs_cookie.stat().st_mtime) / 86400
if age_days > 14:
print(f"- ⚠️ {xhs_cookie} is {age_days:.0f} days old — consider `npx rednote-mcp init`")
else:
print(f"- ✅ {xhs_cookie} ({age_days:.1f} days old)")
else:
print(f"- ❌ {xhs_cookie} not present — run `npx rednote-mcp init`")
# Playwright managed browser
print("\n## Playwright (skill-managed Chromium)")
try:
import playwright # noqa: F401
print("- playwright (py): OK")
except Exception:
print("- playwright (py): ❌ not installed — `pip install playwright && python -m playwright install chromium`")
pw_platforms = os.environ.get("RSS_PLAYWRIGHT_PLATFORMS", "").strip()
print(f"- RSS_PLAYWRIGHT_PLATFORMS: {pw_platforms or '(unset — Playwright tier disabled)'}")
pw_profile = Path(os.environ.get(
"RSS_PLAYWRIGHT_PROFILE",
str(Path.home() / ".lets-go-rss" / "browser-profile"),
))
if pw_profile.exists():
cookies_sqlite = pw_profile / "Default" / "Network" / "Cookies"
size = cookies_sqlite.stat().st_size if cookies_sqlite.exists() else 0
print(f"- profile: {pw_profile} (cookies db {size} B)")
else:
print(f"- profile: {pw_profile} (not yet initialised)")
print(" login commands: `python scripts/lets_go_rss.py --login {twitter,xiaohongshu}`")
# Auto-fix pass
if auto_fix:
print("\n## Auto-fix")
mgr = str(SCRIPTS_DIR / "rsshub_manager.py")
# Restart managed RSSHub if unhealthy
try:
r = _sp.run([sys.executable, mgr, "status"],
capture_output=True, text=True, timeout=5)
info = json.loads(r.stdout or "{}")
if not info.get("healthy"):
print("- managed rsshub unhealthy → restart")
_sp.run([sys.executable, mgr, "restart"], timeout=120)
else:
print("- managed rsshub healthy ✅ (no action)")
except Exception as e:
print(f"- rsshub check failed: {e}")
# Clear stale update.lock if no pid is alive
lock = ASSETS_DIR / ".update.lock"
if lock.exists():
try:
txt = lock.read_text()
# Best-effort: remove if it's clearly stale (> 2h old)
age = _t.time() - lock.stat().st_mtime
if age > 7200:
lock.unlink()
print(f"- cleared stale update.lock ({age/60:.0f} min old)")
except Exception:
pass
return 0
def main():
"""Main entry point — delegates to rss_engine with correct paths."""
# Fast path: --status should not require runtime scraping dependencies (e.g. httpx).
if "--status" in sys.argv:
sys.exit(print_cached_status())
# Fast path: --overview reads full_overview.md
if "--overview" in sys.argv:
overview_path = ASSETS_DIR / "full_overview.md"
if overview_path.exists():
print(overview_path.read_text(encoding="utf-8"))
else:
print("⚠️ 尚无全量概览。请先运行 --update --digest 生成。")
sys.exit(0)
# Fast path: --doctor / --auto-fix — diagnostics, no scraping.
if "--doctor" in sys.argv or "--auto-fix" in sys.argv:
sys.exit(run_doctor(auto_fix=("--auto-fix" in sys.argv)))
# Fast path: --login <platform> — end-to-end flow:
# 1. opens visible Chromium for one-time sign-in
# 2. verifies login by fetching an existing subscription
# 3. writes RSS_PLAYWRIGHT_PLATFORMS=<...,platform> to .env
if "--login" in sys.argv:
idx = sys.argv.index("--login")
platform = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else ""
if not platform or platform.startswith("-"):
print("Usage: python scripts/lets_go_rss.py --login <twitter|xiaohongshu|bilibili>")
sys.exit(2)
sys.path.insert(0, str(SCRIPTS_DIR))
from playwright_adapter import run_login_flow
db_path = str(ASSETS_DIR / "rss_database.db")
sys.exit(run_login_flow(platform, skill_dir=SKILL_DIR, db_path=db_path))
# Skip setup for --skip-setup (cron jobs)
skip_setup = '--skip-setup' in sys.argv
if not skip_setup:
if not ensure_dependencies():
print("⚠️ Dependency setup had warnings, continuing anyway...")
# Remove --skip-setup from argv so argparse doesn't complain
sys.argv = [a for a in sys.argv if a != '--skip-setup']
# Pass absolute db_path to engine instead of os.chdir()
db_path = str(ASSETS_DIR / "rss_database.db")
os.environ.setdefault("RSS_ASSETS_DIR", str(ASSETS_DIR))
from rss_engine import main as rss_main
rss_main(db_path=db_path)
if __name__ == "__main__":
main()
"""
Skill-owned Playwright Chromium — scrapes platforms that anti-scrape RSSHub.
Why this exists
---------------
Bilibili / Twitter / Xiaohongshu all 风控-block server-side scrapers (RSSHub
routes get 503 / JSON error envelopes). A real browser with a normal
reputation gets through. We launch our own headless Chromium via Playwright
(already a pip dep + its Chromium is already cached in
~/Library/Caches/ms-playwright/), using a persistent user-data-dir so
login state (when needed) sticks between runs.
Zero user intervention required for:
* Bilibili user videos — public data, no login
* Bilibili user dynamic — public for most users
(Twitter timeline / XHS notes do require a one-time login via
`python scripts/lets_go_rss.py --login <platform>`.)
Enable in .env:
RSS_PLAYWRIGHT_PLATFORMS=bilibili,xiaohongshu,twitter
Concurrency note
----------------
Playwright's sync API is strict single-thread (the BrowserContext belongs
to the thread that created it). rss_engine uses a ThreadPoolExecutor for
parallel fetches, so we route ALL Playwright work through a single
dedicated worker thread via a ThreadPoolExecutor(max_workers=1). Other
fetch threads submit jobs and block on the returned Future.
"""
from __future__ import annotations
import hashlib
import os
import re
import sys
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
# ---------------------------------------------------------------------------
# Config / env
# ---------------------------------------------------------------------------
PROFILE_DIR = Path(os.environ.get(
"RSS_PLAYWRIGHT_PROFILE",
str(Path.home() / ".lets-go-rss" / "browser-profile"),
))
DEFAULT_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
def enabled_platforms() -> set:
raw = os.environ.get("RSS_PLAYWRIGHT_PLATFORMS", "").strip()
if not raw:
return set()
return {p.strip().lower() for p in raw.split(",") if p.strip()}
def is_platform_enabled(platform: str) -> bool:
return platform.lower() in enabled_platforms()
# ---------------------------------------------------------------------------
# Dedicated single-thread worker — owns Playwright + the BrowserContext
# ---------------------------------------------------------------------------
#
# Sync Playwright objects are bound to their creator thread, so all calls
# must run on the SAME thread. We use an Executor(max_workers=1) as that
# thread, with thread-local initialization that spins up Playwright on
# first use. Other threads submit jobs via `_submit()` and wait on the
# returned Future.
_worker_init_lock = threading.Lock()
_worker_executor: Optional[ThreadPoolExecutor] = None
_thread_local = threading.local()
def _worker_init():
"""Initializer running inside the worker thread itself — creates
Playwright + a persistent BrowserContext bound to this thread."""
try:
from playwright.sync_api import sync_playwright
except ImportError as e:
_thread_local.init_error = (
"playwright is not installed. Run: pip install playwright && "
"python -m playwright install chromium"
)
return
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
try:
pw = sync_playwright().start()
ctx = pw.chromium.launch_persistent_context(
user_data_dir=str(PROFILE_DIR),
headless=True,
viewport={"width": 1280, "height": 800},
user_agent=DEFAULT_UA,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-features=IsolateOrigins,site-per-process",
],
ignore_default_args=["--enable-automation"],
)
ctx.add_init_script(_STEALTH_INIT_JS)
_thread_local.pw = pw
_thread_local.ctx = ctx
_thread_local.init_error = None
except Exception as e:
_thread_local.init_error = f"playwright init failed: {e}"
def _ensure_executor() -> ThreadPoolExecutor:
global _worker_executor
with _worker_init_lock:
if _worker_executor is None:
_worker_executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="rss-pw",
initializer=_worker_init,
)
import atexit
atexit.register(_teardown)
return _worker_executor
def _teardown():
global _worker_executor
if _worker_executor is None:
return
# Submit a close job on the worker thread
try:
_worker_executor.submit(_close_context).result(timeout=5)
except Exception:
pass
_worker_executor.shutdown(wait=False, cancel_futures=True)
_worker_executor = None
def _close_context():
ctx = getattr(_thread_local, "ctx", None)
pw = getattr(_thread_local, "pw", None)
try:
if ctx is not None:
ctx.close()
except Exception:
pass
try:
if pw is not None:
pw.stop()
except Exception:
pass
def _run_on_worker(job_fn: Callable[[Any], Any], *, page_timeout: float = 30.0):
"""Execute `job_fn(page)` on the worker thread using the shared context.
The worker thread owns Playwright; this helper creates a fresh Page,
passes it to the callback, and closes it afterwards. Errors propagate
back to the caller through the Future.
"""
def _run():
if getattr(_thread_local, "init_error", None):
raise RuntimeError(_thread_local.init_error)
ctx = getattr(_thread_local, "ctx", None)
if ctx is None:
raise RuntimeError("playwright worker not initialised")
page = ctx.new_page()
page.set_default_navigation_timeout(int(page_timeout * 1000))
try:
return job_fn(page)
finally:
try:
page.close()
except Exception:
pass
exec_ = _ensure_executor()
fut = exec_.submit(_run)
# Cap wait at page_timeout + a margin; lets a stuck call fail loudly
return fut.result(timeout=page_timeout + 10.0)
# ---------------------------------------------------------------------------
# Item mapping helpers
# ---------------------------------------------------------------------------
def _item_id(platform: str, seed: str) -> str:
return f"{platform}_{hashlib.md5(seed.encode()).hexdigest()[:12]}"
def _clip(s: Any, n: int = 500) -> str:
if not s:
return ""
s = str(s)
return s[:n]
# ---------------------------------------------------------------------------
# Platform: Bilibili — public, no login needed
# ---------------------------------------------------------------------------
def fetch_bilibili_user(uid: str, timeout: float = 25.0) -> List[Dict[str, Any]]:
"""Fetch a Bilibili UP's recent videos.
Strategy: visit space.bilibili.com/<uid>, block on the XHR response to
/x/space/(wbi/)?arc/search (contains vlist). Bilibili's anti-bot
happily serves real cookies to a legit browser, so no login is needed.
"""
def _is_arc_search(response) -> bool:
u = response.url
return ("/x/space/wbi/arc/search" in u
or "/x/space/arc/search" in u)
def _job(page):
with page.expect_response(_is_arc_search, timeout=int(timeout * 1000)) as info:
page.goto(
f"https://space.bilibili.com/{uid}",
wait_until="domcontentloaded",
)
response = info.value
if response.status != 200:
raise RuntimeError(f"arc/search returned HTTP {response.status}")
data = response.json()
if not isinstance(data, dict) or data.get("code") != 0:
code = data.get("code") if isinstance(data, dict) else None
msg = data.get("message") if isinstance(data, dict) else None
raise RuntimeError(f"arc/search code={code} msg={msg}")
return (data.get("data", {}).get("list", {}).get("vlist") or [])
# Bilibili's anti-bot occasionally 412s — retry once after a cool-down
# the browser state usually clears in 2-3s.
vlist = None
last_err: Optional[Exception] = None
for attempt in range(2):
try:
vlist = _run_on_worker(_job, page_timeout=timeout)
if vlist:
break
except Exception as e:
last_err = e
msg = str(e).lower()
# Only retry on transient-looking errors
if "412" not in msg and "timeout" not in msg and "timed out" not in msg:
raise
if attempt == 0:
time.sleep(3.0)
if not vlist:
if last_err:
raise last_err
raise RuntimeError("Bilibili arc/search returned empty vlist")
items: List[Dict[str, Any]] = []
for v in vlist[:20]:
bvid = v.get("bvid") or ""
link = f"https://www.bilibili.com/video/{bvid}" if bvid else ""
title = v.get("title") or ""
# Bilibili `created` is unix seconds
created = v.get("created")
pub_date = ""
if created:
try:
from datetime import datetime as _dt
pub_date = _dt.fromtimestamp(int(created)).isoformat()
except Exception:
pass
items.append({
"item_id": _item_id("bilibili", bvid or link or title),
"title": title,
"description": _clip(v.get("description")),
"link": link,
"pub_date": pub_date,
"metadata": {
"source": "playwright:bilibili",
"_channel_title": v.get("author") or "",
"duration": v.get("length") or "",
"play": v.get("play") or 0,
},
})
return items
# ---------------------------------------------------------------------------
# Platform: Xiaohongshu — requires one-time login (note: platform constraint)
# ---------------------------------------------------------------------------
def fetch_xhs_user(user_id: str, timeout: float = 25.0) -> List[Dict[str, Any]]:
"""Fetch an XHS user's recent notes via our managed profile.
XHS renders the profile's notes list server-side into DOM (no user_posted
XHR any more as of 2025+). We parse `section.note-item` cards directly.
If the profile is not logged in, XHS redirects to captcha/login — raised
so the error_kind is tagged `auth`.
"""
def _job(page):
page.goto(
f"https://www.xiaohongshu.com/user/profile/{user_id}",
wait_until="domcontentloaded",
)
final_url = page.url
if "captcha" in final_url or "login" in final_url:
raise RuntimeError(
"XHS profile not logged in — run: "
"python scripts/lets_go_rss.py --login xiaohongshu"
)
# Let the note grid render (lazy + image loading doesn't matter to us)
try:
page.wait_for_selector("section.note-item", timeout=8000)
except Exception:
pass
# Extract note cards in a single evaluate (robust to DOM churn)
return page.evaluate("""() => {
const out = [];
const cards = document.querySelectorAll('section.note-item');
for (const c of cards) {
const a = c.querySelector("a[href*='/explore/']") ||
c.querySelector("a[href*='/user/profile/']");
if (!a) continue;
const href = a.getAttribute('href') || '';
const m = href.match(/\\/explore\\/([0-9a-f]+)|\\/profile\\/[^/]+\\/([0-9a-f]+)/i);
const noteId = m ? (m[1] || m[2]) : '';
if (!noteId) continue;
// Title: first non-empty innerText line that isn't the pinned badge
const lines = (c.innerText || '').split('\\n').map(s => s.trim()).filter(Boolean);
const title = lines.find(l => l !== '置顶') || '';
out.push({ note_id: noteId, title });
if (out.length >= 20) break;
}
return out;
}""")
raw = _run_on_worker(_job, page_timeout=timeout)
if not raw:
raise RuntimeError("XHS DOM extraction returned no notes")
items: List[Dict[str, Any]] = []
for n in raw:
note_id = n.get("note_id", "")
title = (n.get("title") or "").strip()
if not note_id:
continue
link = f"https://www.xiaohongshu.com/explore/{note_id}"
items.append({
"item_id": _item_id("xiaohongshu", note_id),
"title": title,
"description": "",
"link": link,
"pub_date": "",
"metadata": {"source": "playwright:xiaohongshu"},
})
return items
# ---------------------------------------------------------------------------
# Platform: Twitter/X — requires one-time login
# ---------------------------------------------------------------------------
def fetch_twitter_user(username: str, timeout: float = 25.0) -> List[Dict[str, Any]]:
"""Fetch an X/Twitter user's recent tweets via our managed profile.
Twitter requires login to view timelines as of 2024+. After one-time
`--login twitter`, cookies persist in the profile.
"""
username = username.lstrip("@")
captured: Dict[str, Any] = {"tweets": []}
tweet_pattern = re.compile(r"status/(\d+)")
def _on_response(response):
try:
url = response.url
if "UserTweets" in url and response.status == 200:
data = response.json()
# Twitter GraphQL: dig through timeline instructions
entries = _extract_tweet_entries(data)
if entries:
captured["tweets"].extend(entries)
except Exception:
pass
def _job(page):
page.on("response", _on_response)
page.goto(f"https://x.com/{username}", wait_until="domcontentloaded")
final = page.url
if "login" in final or "/i/flow/login" in final:
raise RuntimeError(
"Twitter not logged in — run: "
"python scripts/lets_go_rss.py --login twitter"
)
deadline = time.time() + 10.0
while time.time() < deadline and not captured["tweets"]:
page.wait_for_timeout(500)
return captured["tweets"]
tweets = _run_on_worker(_job, page_timeout=timeout)
if not tweets:
raise RuntimeError("Twitter XHR capture failed (timeline empty or blocked)")
items: List[Dict[str, Any]] = []
seen_ids: set = set()
for t in tweets[:30]:
tid = t.get("rest_id") or t.get("id_str")
if not tid or tid in seen_ids:
continue
seen_ids.add(tid)
text = t.get("full_text") or ""
link = f"https://x.com/{username}/status/{tid}"
items.append({
"item_id": _item_id("twitter", tid),
"title": (text[:120] + "…") if len(text) > 120 else text,
"description": _clip(text),
"link": link,
"pub_date": t.get("created_at") or "",
"metadata": {"source": "playwright:twitter"},
})
if len(items) >= 20:
break
return items
def _extract_tweet_entries(payload: Any) -> List[Dict[str, Any]]:
"""Walk Twitter's GraphQL timeline JSON, yielding tweet result dicts.
Layout is nested: data.user.result.timeline_v2.timeline.instructions[].entries[].
Each entry contains content.itemContent.tweet_results.result with rest_id + legacy.full_text.
"""
out: List[Dict[str, Any]] = []
try:
instructions = (
payload.get("data", {})
.get("user", {})
.get("result", {})
.get("timeline_v2", {})
.get("timeline", {})
.get("instructions", [])
)
except AttributeError:
return out
for ins in instructions:
for entry in ins.get("entries", []) or []:
content = entry.get("content", {}) or {}
item_content = content.get("itemContent") or {}
tw = (item_content.get("tweet_results") or {}).get("result") or {}
if not tw:
continue
legacy = tw.get("legacy") or {}
out.append({
"rest_id": tw.get("rest_id") or legacy.get("id_str"),
"id_str": legacy.get("id_str"),
"full_text": legacy.get("full_text") or "",
"created_at": legacy.get("created_at") or "",
})
return out
# ---------------------------------------------------------------------------
# Interactive login — opens a visible browser for the user to sign in
# ---------------------------------------------------------------------------
LOGIN_URLS = {
"twitter": "https://x.com/i/flow/login",
"xiaohongshu": "https://www.xiaohongshu.com/",
"bilibili": "https://passport.bilibili.com/login",
}
_STEALTH_INIT_JS = """
// Hide classic automation signals that sites like x.com check on keystroke.
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// Fill in plugins / languages to match a real browser session
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5] });
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN','zh','en-US','en'] });
// Provide a believable chrome.runtime object
window.chrome = window.chrome || { runtime: {}, app: {}, csi: function(){}, loadTimes: function(){} };
// Permissions.query('notifications') must behave like a real browser
const origQuery = navigator.permissions && navigator.permissions.query;
if (origQuery) {
navigator.permissions.query = (params) =>
params && params.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: origQuery.call(navigator.permissions, params);
}
"""
def _open_login_window(platform: str) -> None:
"""Open a visible real Chrome pointed at the platform's login page.
Blocks until the user closes the window. Cookies persist in our profile.
Uses `channel='chrome'` (the user's real Google Chrome binary) when
available, falling back to bundled Chromium. Real Chrome is dramatically
harder for sites like x.com to fingerprint as automation. We also strip
the `--enable-automation` launch flag and inject a stealth init script
(navigator.webdriver / plugins / languages / chrome.runtime).
"""
_teardown() # release any headless context first
from playwright.sync_api import sync_playwright
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
def _launch(pw, channel: Optional[str]):
kwargs = dict(
user_data_dir=str(PROFILE_DIR),
headless=False,
viewport={"width": 1280, "height": 800},
user_agent=DEFAULT_UA,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-features=IsolateOrigins,site-per-process",
],
ignore_default_args=["--enable-automation"],
)
if channel:
kwargs["channel"] = channel
return pw.chromium.launch_persistent_context(**kwargs)
with sync_playwright() as pw:
ctx = None
last_err = None
# Prefer real Chrome, then stable Chrome channels, then bundled Chromium
for channel in ("chrome", "chrome-beta", "chrome-dev", None):
try:
ctx = _launch(pw, channel)
if channel:
print(f"[login] launched real Chrome (channel={channel})")
else:
print("[login] launched bundled Chromium (no real Chrome found)")
break
except Exception as e:
last_err = e
continue
if ctx is None:
raise RuntimeError(f"could not launch any Chrome channel: {last_err}")
ctx.add_init_script(_STEALTH_INIT_JS)
page = ctx.new_page()
page.goto(LOGIN_URLS[platform])
print(f"[login] opened {LOGIN_URLS[platform]}")
print("[login] sign in, then close the Chromium window to continue.")
try:
page.wait_for_event("close", timeout=0)
except Exception:
pass
ctx.close()
def _pick_test_user(platform: str, db_path: str) -> Optional[str]:
"""Return a user_id/username from an existing DB subscription of this
platform, for post-login verification. Returns None if no sub exists."""
import sqlite3
try:
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute(
"SELECT url FROM subscriptions WHERE platform=? LIMIT 1",
(platform,),
)
row = cur.fetchone()
conn.close()
except Exception:
return None
if not row:
return None
url = row[0]
# Reuse the scraper's own extract_user_id logic
try:
sys.path.insert(0, str(Path(__file__).parent))
from scrapers import ScraperFactory
scraper = ScraperFactory.get_scraper(platform)
if scraper is None:
return None
return scraper.extract_user_id(url)
except Exception:
return None
def _verify_platform(platform: str, db_path: str) -> Dict[str, Any]:
"""Run a test fetch against an existing subscription of this platform.
Returns {"ok": bool, "detail": str, "items": int}."""
user_id = _pick_test_user(platform, db_path)
if not user_id:
return {"ok": False, "detail": "no existing subscription to test against", "items": 0}
fetchers = {
"bilibili": fetch_bilibili_user,
"xiaohongshu": fetch_xhs_user,
"twitter": fetch_twitter_user,
}
fn = fetchers.get(platform)
if not fn:
return {"ok": False, "detail": f"no fetcher for {platform}", "items": 0}
try:
items = fn(user_id)
except Exception as e:
return {"ok": False, "detail": str(e)[:150], "items": 0}
return {"ok": bool(items), "detail": f"fetched user={user_id}", "items": len(items)}
def _enable_platform_in_env(platform: str, env_path: Path) -> bool:
"""Idempotently add `platform` to RSS_PLAYWRIGHT_PLATFORMS in .env.
Creates .env if missing. Returns True if the file was written."""
lines: List[str] = []
if env_path.exists():
lines = env_path.read_text(encoding="utf-8").splitlines()
# Find existing active line (not a comment)
key = "RSS_PLAYWRIGHT_PLATFORMS"
found_idx = -1
current_list: List[str] = []
for i, line in enumerate(lines):
stripped = line.lstrip()
if stripped.startswith("#"):
continue
if stripped.startswith(f"{key}="):
found_idx = i
raw = stripped.split("=", 1)[1].strip().strip('"').strip("'")
current_list = [p.strip() for p in raw.split(",") if p.strip()]
break
if platform in current_list:
return False # already enabled; no write needed
# If no existing value, seed with the cron default so we don't silently
# demote bilibili (which run_update_cron.sh enables by default).
if not current_list:
current_list = ["bilibili"]
new_list = current_list + [p for p in [platform] if p not in current_list]
new_line = f"{key}={','.join(new_list)}"
if found_idx >= 0:
lines[found_idx] = new_line
else:
if lines and lines[-1].strip():
lines.append("")
lines.append(f"# Added by --login {platform} verification flow")
lines.append(new_line)
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return True
def run_login_flow(platform: str, *, skill_dir: Path, db_path: str,
verify: bool = True, enable: bool = True) -> int:
"""End-to-end: open browser → user logs in → verify → persist in .env.
This is the one-stop "set up platform X" entry point for an agent to
orchestrate. Returns 0 on success, nonzero on failure.
"""
platform = platform.lower()
if platform not in LOGIN_URLS:
print(f"Unknown platform: {platform!r}. Supported: {list(LOGIN_URLS)}",
file=sys.stderr)
return 2
print(f"\n[{platform}] step 1/3: opening Chromium for you to sign in …")
_open_login_window(platform)
if not verify:
print(f"[{platform}] step 2/3: skipped (verify=False)")
else:
print(f"[{platform}] step 2/3: verifying login by fetching a real sub …")
result = _verify_platform(platform, db_path)
if not result["ok"]:
print(f"[{platform}] ❌ verification failed: {result['detail']}")
print(f"[{platform}] cookies may not have been saved, or platform")
print(f"[{platform}] still flags us as non-logged-in. Try again.")
return 3
print(f"[{platform}] ✅ verified: got {result['items']} items ({result['detail']})")
if not enable:
print(f"[{platform}] step 3/3: skipped (enable=False)")
return 0
env_path = skill_dir / ".env"
changed = _enable_platform_in_env(platform, env_path)
if changed:
print(f"[{platform}] ✅ step 3/3: enabled in {env_path}")
else:
print(f"[{platform}] step 3/3: already enabled in {env_path} (no change)")
return 0
def login_platform(platform: str) -> int:
"""Back-compat wrapper — opens the browser but does NOT verify/enable.
Prefer `run_login_flow` for the end-to-end path."""
platform = platform.lower()
if platform not in LOGIN_URLS:
print(f"Unknown platform: {platform!r}. Supported: {list(LOGIN_URLS)}",
file=sys.stderr)
return 2
_open_login_window(platform)
print(f"[login] done — cookies saved to {PROFILE_DIR}")
return 0
"""
Markdown report generator
Creates formatted latest_update.md with categorized content
"""
import os
from typing import List, Dict, Any
from datetime import datetime
from collections import defaultdict
class MarkdownReportGenerator:
"""Generate markdown reports for RSS updates"""
def __init__(self):
self.categories = ["科技", "人文", "设计", "娱乐", "其他"]
self.platform_emojis = {
"bilibili": "📺",
"xiaohongshu": "📕",
"weibo": "📱",
"youtube": "🎬",
"vimeo": "🎥",
"behance": "🎨",
"douyin": "🎵",
"twitter": "🐦",
"zsxq": "⭐",
}
def generate_update_report(self, new_items: List[Dict[str, Any]],
output_path: str = "latest_update.md",
digest: bool = False) -> str:
"""Generate latest update report.
Args:
digest: If True, generate delta-only report (changed accounts only).
If no new items, output a single-line "无更新".
"""
if not new_items:
content = self._generate_empty_report()
elif digest:
content = self._generate_delta_report(new_items, output_dir=os.path.dirname(output_path) or ".")
else:
content = self._generate_full_report(new_items)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
return output_path
def generate_full_overview(self, all_items: List[Dict[str, Any]],
output_path: str = "full_overview.md") -> str:
"""Generate full overview report — all accounts, latest 1 item each.
This file is for querying (--overview), NOT for push notifications.
"""
content = self._generate_overview_report(all_items)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
return output_path
def _build_snapshot(self, items: List[Dict[str, Any]], output_dir: str) -> tuple:
"""Build current snapshot and load previous one for comparison.
Returns: (by_account, changed_keys, current_snapshot)
"""
import json
from collections import OrderedDict
# Group by subscription_url (= per account), keep newest
by_account = OrderedDict()
for item in items:
key = item.get("subscription_url", item.get("platform", "unknown"))
if key not in by_account:
by_account[key] = item
# Load previous digest snapshot
snapshot_path = os.path.join(output_dir, "last_digest.json")
prev_snapshot = {}
try:
with open(snapshot_path, "r", encoding="utf-8") as f:
prev_snapshot = json.load(f) # {sub_url: item_id}
except (FileNotFoundError, json.JSONDecodeError):
pass
# Determine which accounts have new content
changed_keys = set()
current_snapshot = {}
for sub_url, item in by_account.items():
item_id = item.get("item_id", "")
current_snapshot[sub_url] = item_id
if item_id != prev_snapshot.get(sub_url):
changed_keys.add(sub_url)
# Save current snapshot for next comparison
with open(snapshot_path, "w", encoding="utf-8") as f:
json.dump(current_snapshot, f, ensure_ascii=False)
return by_account, changed_keys, current_snapshot
def _format_account_line(self, item: Dict[str, Any], tag: str = "") -> List[str]:
"""Format a single account entry for reports."""
platform = item.get("platform", "").lower()
emoji = self.platform_emojis.get(platform, "🔗")
title = item.get("title", "Untitled")
link = item.get("link", "")
sub_title = item.get("subscription_title", "")
account = sub_title if sub_title and "Subscription" not in sub_title else ""
name = account or platform.title()
# Format pub_date if available
pub_date_str = ""
raw_date = item.get("pub_date", "")
if raw_date:
try:
from dateutil import parser as dateparser
dt = dateparser.parse(raw_date)
pub_date_str = dt.strftime("%m-%d %H:%M")
except Exception:
pub_date_str = raw_date[:10] if len(raw_date) >= 10 else ""
date_suffix = f" {pub_date_str}" if pub_date_str else ""
lines = [f"{tag}{emoji} {name}{date_suffix}"]
if link:
lines.append(f" [{title}]({link})")
else:
lines.append(f" {title}")
lines.append("")
return lines
def _generate_delta_report(self, items: List[Dict[str, Any]],
output_dir: str = ".") -> str:
"""Generate delta-only report — ONLY accounts with new content.
This is the file used for push notifications (Feishu, etc.).
Only changed accounts appear; unchanged accounts are omitted.
"""
by_account, changed_keys, _ = self._build_snapshot(items, output_dir)
now = datetime.now().strftime("%Y-%m-%d %H:%M")
new_count = len(changed_keys)
if not new_count:
return "这会rss么的更新"
header = f"白,rss有更新了!\n\n📡 RSS 增量更新 | {now} | {new_count} 个账号有新内容"
lines = [header, ""]
# Show ONLY changed accounts
for sub_url, item in by_account.items():
if sub_url in changed_keys:
lines.extend(self._format_account_line(item, tag="🆕 "))
return "\n".join(lines)
def _generate_overview_report(self, items: List[Dict[str, Any]]) -> str:
"""Generate full overview report — ALL accounts, no change markers.
This file is for user queries (--overview), not for push.
"""
from collections import OrderedDict
by_account = OrderedDict()
for item in items:
key = item.get("subscription_url", item.get("platform", "unknown"))
if key not in by_account:
by_account[key] = item
now = datetime.now().strftime("%Y-%m-%d %H:%M")
total = len(by_account)
header = f"📡 RSS 全量概览 | {now} | {total} 个订阅"
lines = [header, ""]
for sub_url, item in by_account.items():
lines.extend(self._format_account_line(item))
return "\n".join(lines)
def _generate_empty_report(self) -> str:
"""Generate report when no new items"""
return f"""# RSS 更新报告
**生成时间**: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
**更新状态**: 无新内容
本次更新未发现新内容。
---
*Generated by Universal RSS Engine*
"""
def _generate_full_report(self, new_items: List[Dict[str, Any]]) -> str:
"""Generate full report with categorized items"""
# Group items by category
categorized = defaultdict(list)
for item in new_items:
category = item.get("category", "其他")
categorized[category].append(item)
# Sort categories
sorted_categories = []
for cat in self.categories:
if cat in categorized:
sorted_categories.append(cat)
# Generate markdown
lines = [
"# RSS 更新报告",
"",
f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
f"**新增内容**: {len(new_items)} 条",
"",
]
# Table of contents
lines.append("## 目录")
lines.append("")
for category in sorted_categories:
count = len(categorized[category])
lines.append(f"- [{category}](#{category}) ({count}条)")
lines.append("")
lines.append("---")
lines.append("")
# Content by category
for category in sorted_categories:
items = categorized[category]
lines.append(f"## {category}")
lines.append("")
lines.append(f"*共 {len(items)} 条新内容*")
lines.append("")
for item in items:
lines.extend(self._format_item(item))
lines.append("")
lines.append("---")
lines.append("")
# Statistics
lines.append("## 统计信息")
lines.append("")
lines.append("| 分类 | 数量 |")
lines.append("|------|------|")
for category in sorted_categories:
lines.append(f"| {category} | {len(categorized[category])} |")
lines.append("")
# Platform statistics
platform_stats = defaultdict(int)
for item in new_items:
platform = item.get("platform", "unknown")
platform_stats[platform] += 1
lines.append("### 平台分布")
lines.append("")
lines.append("| 平台 | 数量 |")
lines.append("|------|------|")
for platform, count in sorted(platform_stats.items(), key=lambda x: x[1], reverse=True):
emoji = self.platform_emojis.get(platform, "🔗")
lines.append(f"| {emoji} {platform.title()} | {count} |")
lines.append("")
lines.append("---")
lines.append("*Generated by Universal RSS Engine*")
return "\n".join(lines)
def _format_item(self, item: Dict[str, Any]) -> List[str]:
"""Format a single item for markdown"""
lines = []
# Platform emoji
platform = item.get("platform", "").lower()
emoji = self.platform_emojis.get(platform, "🔗")
# Title and link
title = item.get("title", "Untitled")
link = item.get("link", "")
if link:
lines.append(f"### {emoji} [{title}]({link})")
else:
lines.append(f"### {emoji} {title}")
lines.append("")
# Description
description = item.get("description", "")
if description:
# Limit description length
desc_preview = description[:200] + "..." if len(description) > 200 else description
lines.append(f"> {desc_preview}")
lines.append("")
# Metadata
metadata_parts = []
# Platform
if platform:
metadata_parts.append(f"**平台**: {platform.title()}")
# Date
pub_date = item.get("pub_date", "")
if pub_date:
try:
if isinstance(pub_date, str):
dt = datetime.fromisoformat(pub_date.replace("Z", "+00:00"))
formatted_date = dt.strftime("%Y-%m-%d %H:%M")
metadata_parts.append(f"**发布时间**: {formatted_date}")
except:
pass
if metadata_parts:
lines.append(" | ".join(metadata_parts))
lines.append("")
return lines
def _build_health_section(self, subscriptions: List[Dict[str, Any]],
out_dir: str) -> List[str]:
"""Build the 🩺 health section: current-run status + stale sources."""
import json
# Load last run health if present
run_health = None
health_path = os.path.join(out_dir, ".last_run_health.json")
try:
with open(health_path, "r", encoding="utf-8") as f:
run_health = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
pass
# Compute staleness from last_success_at (preferred) or last_updated.
# last_success_at only advances on actual success, so it accurately
# flags sources that have been failing for a while.
stale_threshold_days = 3
now = datetime.now()
stale_rows = [] # (days_since, sub)
for sub in subscriptions:
signal = sub.get("last_success_at") or sub.get("last_updated")
if not signal:
stale_rows.append((None, sub))
continue
try:
dt = datetime.fromisoformat(str(signal).replace("Z", "+00:00"))
if dt.tzinfo is not None:
dt = dt.replace(tzinfo=None)
days = (now - dt).days
if days >= stale_threshold_days:
stale_rows.append((days, sub))
except Exception:
stale_rows.append((None, sub))
# Nothing to show
if not run_health and not stale_rows:
return []
out = ["## 🩺 健康度", ""]
if run_health:
per_source = run_health.get("per_source", {})
total = len(per_source)
ok = sum(1 for s in per_source.values() if s.get("status") == "ok")
no_new = sum(1 for s in per_source.values() if s.get("status") == "no_new")
errors = sum(1 for s in per_source.values() if s.get("status") == "error")
elapsed = run_health.get("elapsed_sec", "?")
new_count = run_health.get("total_new", 0)
out.append(
f"**本次运行** (耗时 {elapsed}s)"
f":✅ {ok} 正常 / → {no_new} 无新增 / ❌ {errors} 失败"
f" (共 {total} 源,本轮新增 {new_count} 条)"
)
out.append("")
failed = [
(url, info) for url, info in per_source.items()
if info.get("status") == "error"
]
if failed:
out.append("**失败源**:")
out.append("")
for url, info in failed:
platform = info.get("platform", "").lower()
emoji = self.platform_emojis.get(platform, "🔗")
name = info.get("title") or platform.title()
err_short = (info.get("error") or "").split("\n")[0][:120]
out.append(f"- {emoji} **{name}** — {err_short}")
out.append(f" `{url}`")
out.append("")
# Stale sources (>= 3 days)
if stale_rows:
# Sort: longest stale first; "None" (never updated) at the bottom
stale_rows.sort(key=lambda x: (x[0] is None, -(x[0] or 0)))
out.append(f"**陈旧源** (超过 {stale_threshold_days} 天未成功更新):")
out.append("")
for days, sub in stale_rows:
platform = (sub.get("platform") or "").lower()
emoji = self.platform_emojis.get(platform, "🔗")
name = sub.get("title") or platform.title()
age_str = "从未更新" if days is None else f"{days} 天前"
out.append(f"- {emoji} **{name}** — 上次成功: {age_str}")
out.append("")
out.append("---")
out.append("")
return out
def generate_summary_report(self, db, output_path: str = "summary.md") -> str:
"""Generate overall summary report"""
subscriptions = db.get_subscriptions()
all_items = db.get_all_items()
lines = [
"# RSS 订阅总览",
"",
f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
]
# Health section (🩺) — reads .last_run_health.json from same dir
health_lines = self._build_health_section(
subscriptions, os.path.dirname(output_path) or "."
)
if health_lines:
lines.extend(health_lines)
# Subscription statistics
lines.append("## 订阅统计")
lines.append("")
lines.append(f"**总订阅数**: {len(subscriptions)}")
lines.append(f"**总内容数**: {len(all_items)}")
lines.append("")
# Subscriptions by platform
platform_subs = defaultdict(int)
for sub in subscriptions:
platform_subs[sub.get("platform", "unknown")] += 1
lines.append("### 按平台分布")
lines.append("")
lines.append("| 平台 | 订阅数 |")
lines.append("|------|--------|")
for platform, count in sorted(platform_subs.items(), key=lambda x: x[1], reverse=True):
emoji = self.platform_emojis.get(platform, "🔗")
lines.append(f"| {emoji} {platform.title()} | {count} |")
lines.append("")
# Category statistics
category_items = defaultdict(int)
for item in all_items:
category_items[item.get("category", "其他")] += 1
lines.append("### 按分类分布")
lines.append("")
lines.append("| 分类 | 内容数 |")
lines.append("|------|--------|")
for category in self.categories:
if category in category_items:
lines.append(f"| {category} | {category_items[category]} |")
lines.append("")
# Subscriptions list
lines.append("## 订阅列表")
lines.append("")
# Group by platform
platform_groups = defaultdict(list)
for sub in subscriptions:
platform_groups[sub.get("platform", "unknown")].append(sub)
for platform in sorted(platform_groups.keys()):
emoji = self.platform_emojis.get(platform, "🔗")
lines.append(f"### {emoji} {platform.title()}")
lines.append("")
for sub in platform_groups[platform]:
title = sub.get("title") or sub.get("url", "")
url = sub.get("url", "")
last_updated = sub.get("last_updated", "从未更新")
lines.append(f"- **{title}**")
lines.append(f" - URL: `{url}`")
lines.append(f" - 最后更新: {last_updated}")
lines.append("")
lines.append("---")
lines.append("*Generated by Universal RSS Engine*")
content = "\n".join(lines)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
return output_path
#!/usr/bin/env python3
"""
Universal RSS Engine
A powerful RSS aggregator with AI-powered categorization
"""
import argparse
import sys
import os
import time
from datetime import datetime
from typing import List, Dict, Any
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import contextmanager
from database import RSSDatabase
from scrapers import ScraperFactory
from classifier import get_classifier
from rss_generator import RSSGenerator, OPMLGenerator
from report_generator import MarkdownReportGenerator
def classify_fetch_error(err: str) -> str:
"""Bucket a free-form scraper error into a short kind tag used for health
tracking and adaptive retry decisions."""
e = (err or "").lower()
if "cookies expired" in e or "api key" in e or "401" in e or "403" in e:
return "auth"
if "429" in e or "too many requests" in e or "rate limit" in e:
return "rate_limit"
if "connection refused" in e or "errno 61" in e or "timeout" in e or "timed out" in e:
return "network"
if "503" in e or "service unavailable" in e or "风控" in e or "captcha" in e or "waf" in e:
return "upstream_block"
if "non-rss" in e or "parse" in e or "json" in e:
return "parse"
return "other"
@contextmanager
def update_lock(lock_path: str):
"""Ensure only one update job runs at a time."""
lock_file = None
try:
lock_file = open(lock_path, "a+", encoding="utf-8")
try:
import fcntl # Unix only
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError(f"Another update is already running (lock: {lock_path})")
except ImportError:
# Non-Unix platforms: proceed without advisory lock.
pass
lock_file.seek(0)
lock_file.truncate()
lock_file.write(f"pid={os.getpid()} started={datetime.now().isoformat()}\n")
lock_file.flush()
yield
finally:
if lock_file:
try:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
except Exception:
pass
lock_file.close()
class RSSEngine:
"""Main RSS Engine class"""
def __init__(self, db_path: str = "rss_database.db", use_llm: bool = True):
self.db = RSSDatabase(db_path)
self.scraper_factory = ScraperFactory()
self._use_llm = use_llm
self._classifier = None # lazy init
self.rss_generator = RSSGenerator()
self.report_generator = MarkdownReportGenerator()
@property
def classifier(self):
"""Lazy-load classifier only when needed."""
if self._classifier is None:
self._classifier = get_classifier(self._use_llm)
return self._classifier
def add_subscription(self, url: str) -> bool:
"""Add a new subscription"""
print(f"\n🔍 Analyzing URL: {url}")
# Detect platform
platform = self.scraper_factory.detect_platform(url)
if platform == "unknown":
print("❌ Error: Unsupported platform")
print("Supported platforms: Bilibili, Xiaohongshu, Weibo, YouTube, Vimeo, Behance, Douyin")
return False
print(f"✓ Detected platform: {platform.title()}")
# Add to database
subscription_id = self.db.add_subscription(
url=url,
platform=platform,
title=f"{platform.title()} Subscription",
description=f"Content from {platform}"
)
print(f"✓ Subscription added with ID: {subscription_id}")
# Try to fetch initial content
print(f"\n📥 Fetching initial content...")
initial_fetch_ok = True
try:
self._fetch_subscription(subscription_id, url, platform)
except Exception as e:
print(f"\n⚠️ Initial fetch failed: {e}")
initial_fetch_ok = False
if initial_fetch_ok:
print("\n✅ Subscription added successfully!")
return True
else:
print("\n⚠️ Subscription added, but initial fetch failed. Will retry on next update.")
return True
def update_all(self, use_classification: bool = True, digest: bool = False) -> Dict[str, Any]:
"""Update all subscriptions in parallel."""
started_at = datetime.now()
print(f"\n🔄 Starting RSS update... [{started_at.strftime('%Y-%m-%d %H:%M:%S')}]")
subscriptions = self.db.get_subscriptions()
if not subscriptions:
print("⚠️ No subscriptions found. Use --add to add subscriptions first.")
return {"new_items": [], "total_subscriptions": 0}
print(f"📋 Found {len(subscriptions)} active subscriptions")
max_workers = max(1, int(os.environ.get("RSS_MAX_WORKERS", "5")))
print(f"⚡ Fetching in parallel... (workers={max_workers})\n")
# Track update start time
update_start = datetime.now().isoformat()
t0 = time.time()
all_new_items = []
results = {} # sub_id -> (count, error)
error_rows = []
# Parallel fetch all subscriptions
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_sub = {}
for sub in subscriptions:
future = executor.submit(
self._fetch_subscription,
sub["id"], sub["url"], sub["platform"], use_classification,
int(sub.get("consecutive_failures") or 0),
)
future_to_sub[future] = sub
for future in as_completed(future_to_sub):
sub = future_to_sub[future]
platform = sub["platform"].title()
try:
# as_completed() yields only finished futures; no extra timeout needed here.
new_items = future.result()
if new_items:
all_new_items.extend(new_items)
results[sub["id"]] = (len(new_items), None)
print(f" ✓ {platform}: +{len(new_items)} new")
else:
results[sub["id"]] = (0, None)
print(f" → {platform}: no new items")
self.db.record_fetch_outcome(sub["id"], success=True)
except Exception as e:
err_msg = str(e)
results[sub["id"]] = (0, err_msg)
print(f" ❌ {platform}: {err_msg[:80]}")
error_rows.append({
"platform": sub["platform"],
"url": sub["url"],
"error": err_msg,
})
self.db.record_fetch_outcome(
sub["id"], success=False,
error=err_msg, error_kind=classify_fetch_error(err_msg),
)
elapsed = time.time() - t0
total_new = sum(r[0] for r in results.values())
errors = sum(1 for r in results.values() if r[1])
ended_at = datetime.now()
print(f"\n✅ Done in {elapsed:.1f}s | +{total_new} new | {errors} errors")
print(f"🕒 Window: {started_at.strftime('%H:%M:%S')} -> {ended_at.strftime('%H:%M:%S')}\n")
if error_rows:
print("⚠️ Error summary:")
for row in error_rows:
print(f" - {row['platform'].title()}: {row['error'][:100]}")
print(f" {row['url']}")
print("")
# Output directory = same dir as database (assets/)
out_dir = os.path.dirname(self.db.db_path) or "."
# Persist per-source run health so summary.md can surface it.
try:
import json as _json
per_source = {}
for sub in subscriptions:
count, err = results.get(sub["id"], (0, None))
per_source[sub["url"]] = {
"platform": sub["platform"],
"title": sub.get("title") or sub["platform"].title(),
"status": "error" if err else ("ok" if count else "no_new"),
"error": err,
"new_count": count,
}
health_path = os.path.join(out_dir, ".last_run_health.json")
with open(health_path, "w", encoding="utf-8") as f:
_json.dump({
"generated_at": ended_at.isoformat(),
"elapsed_sec": round(elapsed, 1),
"total_new": total_new,
"errors": errors,
"per_source": per_source,
}, f, ensure_ascii=False, indent=2)
except Exception as _e:
print(f" ⚠️ Failed to write run health: {_e}")
# Generate RSS feeds
print("📝 Generating outputs...")
all_items = self.db.get_all_items()
feed_paths = self.rss_generator.create_categorized_feeds(all_items, out_dir)
print(f"✓ {len(feed_paths)} RSS feeds")
opml_gen = OPMLGenerator()
opml_gen.create_opml(subscriptions, os.path.join(out_dir, "subscriptions.opml"))
print("✓ OPML")
if digest:
# Digest mode: always show latest 1 item per subscription (by pub_date)
report_items = self.db.get_latest_per_subscription()
else:
# Full mode: show only items fetched in this update cycle
report_items = self.db.get_new_items_since(update_start)
self.report_generator.generate_update_report(report_items, os.path.join(out_dir, "latest_update.md"), digest=digest)
print("✓ latest_update.md (增量)")
if digest:
self.report_generator.generate_full_overview(report_items, os.path.join(out_dir, "full_overview.md"))
print("✓ full_overview.md (全量)")
self.report_generator.generate_summary_report(self.db, os.path.join(out_dir, "summary.md"))
print("✓ summary.md")
return {
"new_items": all_new_items,
"total_subscriptions": len(subscriptions),
"feed_paths": feed_paths
}
def _fetch_subscription(self, subscription_id: int, url: str, platform: str,
use_classification: bool = True,
consecutive_failures: int = 0) -> List[Dict[str, Any]]:
"""Fetch content from a subscription"""
# Get scraper
scraper = self.scraper_factory.get_scraper(platform)
if not scraper:
raise ValueError(f"No scraper available for platform: {platform}")
# Adaptive budget: sources that have been failing repeatedly get a
# tight timeout + no retry, so a single bad source can't blow up the
# overall run time. BaseScraper.get honors this attribute.
scraper._adaptive_health_hint = consecutive_failures
# Fetch items
items = scraper.fetch_items(url)
if not items:
# Only raise if scraper recorded a real error (not just "no content")
scraper_error = getattr(scraper, "last_error", None)
if scraper_error:
raise RuntimeError(scraper_error)
return []
# Auto-update subscription title from feed channel name
first_meta = items[0].get("metadata", {}) or {}
channel_name = first_meta.get("_channel_title") or first_meta.get("channel") or ""
if channel_name:
# Clean up platform-specific suffixes
import re as _re
channel_name = _re.sub(r'\s*的\s*bilibili\s*空间$', '', channel_name)
channel_name = _re.sub(r'\s*的微博$', '', channel_name)
channel_name = _re.sub(r'^Vimeo\s*/\s*', '', channel_name)
channel_name = _re.sub(r"['\u2019]s\s*videos$", '', channel_name)
channel_name = channel_name.strip()
if channel_name:
self.db.update_subscription_title(subscription_id, channel_name)
# Filter out existing items and classify new ones
new_items = []
for item in items:
item_id = item.get("item_id")
if not item_id:
continue
# Fast path: avoid unnecessary classification work for existing items.
# INSERT OR IGNORE in add_item() still protects against race conditions.
if self.db.item_exists(item_id):
continue
if use_classification:
# classify_item never raises — it has a keyword fallback internally
item["category"] = self.classifier.classify_item(
item.get("title", ""),
item.get("description", ""),
)
else:
item["category"] = "其他"
# Atomic insert — INSERT OR IGNORE handles dedup
added = self.db.add_item(
item_id=item_id,
subscription_id=subscription_id,
title=item.get("title", ""),
description=item.get("description", ""),
link=item.get("link", ""),
category=item.get("category", "其他"),
pub_date=item.get("pub_date"),
metadata=item.get("metadata")
)
if added:
new_items.append(item)
return new_items
def list_subscriptions(self):
"""List all subscriptions"""
subscriptions = self.db.get_subscriptions()
if not subscriptions:
print("No subscriptions found.")
return
print("\n📚 Subscriptions:\n")
for sub in subscriptions:
print(f"ID: {sub['id']}")
print(f"Platform: {sub['platform']}")
print(f"URL: {sub['url']}")
print(f"Added: {sub['added_at']}")
print(f"Last Updated: {sub.get('last_updated', 'Never')}")
print("-" * 60)
def show_stats(self):
"""Show statistics"""
subscriptions = self.db.get_subscriptions()
all_items = self.db.get_all_items()
print("\n📊 Statistics:\n")
print(f"Total Subscriptions: {len(subscriptions)}")
print(f"Total Items: {len(all_items)}")
# Category breakdown
categories = {}
for item in all_items:
cat = item.get("category", "其他")
categories[cat] = categories.get(cat, 0) + 1
print("\nCategory Breakdown:")
for cat, count in sorted(categories.items(), key=lambda x: x[1], reverse=True):
print(f" {cat}: {count}")
def main(db_path: str = None):
"""Main CLI entry point"""
parser = argparse.ArgumentParser(
description="Universal RSS Engine - AI-powered content aggregator",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Add a subscription
python rss_engine.py --add "https://space.bilibili.com/123456"
# Update all subscriptions
python rss_engine.py --update
# Update without LLM classification
python rss_engine.py --update --no-llm
# List subscriptions
python rss_engine.py --list
# Show statistics
python rss_engine.py --stats
"""
)
parser.add_argument("--add", metavar="URL", help="Add a new subscription")
parser.add_argument("--update", action="store_true", help="Update all subscriptions")
parser.add_argument("--status", action="store_true", help="Read cached report (for bot push, no fetching)")
parser.add_argument("--list", action="store_true", help="List all subscriptions")
parser.add_argument("--stats", action="store_true", help="Show statistics")
parser.add_argument("--no-llm", action="store_true", help="Disable LLM classification")
parser.add_argument("--digest", action="store_true", help="Digest mode: show only latest 1 item per account")
parser.add_argument("--overview", action="store_true", help="Print full overview (all accounts, latest item each)")
parser.add_argument("--db", default="rss_database.db", help="Database path (default: rss_database.db)")
args = parser.parse_args()
# Check if any action specified
if not any([args.add, args.update, args.status, args.list, args.stats, args.overview]):
parser.print_help()
return
# --status is a fast path: just read cached file, no engine needed
if args.status:
report_path = os.path.join(os.path.dirname(db_path or args.db) or ".", "latest_update.md")
if os.path.exists(report_path):
with open(report_path, "r", encoding="utf-8") as f:
print(f.read())
else:
print("⚠️ 尚无缓存报告。请先运行 --update 生成。")
return
# --overview is also a fast path: read full_overview.md
if args.overview:
overview_path = os.path.join(os.path.dirname(db_path or args.db) or ".", "full_overview.md")
if os.path.exists(overview_path):
with open(overview_path, "r", encoding="utf-8") as f:
print(f.read())
else:
print("⚠️ 尚无全量概览。请先运行 --update --digest 生成。")
return
# Initialize engine
use_llm = not args.no_llm
actual_db_path = db_path or args.db
engine = RSSEngine(db_path=actual_db_path, use_llm=use_llm)
# Execute actions
try:
if args.add:
engine.add_subscription(args.add)
if args.update:
lock_path = os.path.join(os.path.dirname(actual_db_path) or ".", ".update.lock")
with update_lock(lock_path):
engine.update_all(use_classification=use_llm, digest=args.digest)
if args.list:
engine.list_subscriptions()
if args.stats:
engine.show_stats()
except KeyboardInterrupt:
print("\n\n⚠️ Operation cancelled by user")
sys.exit(1)
except Exception as e:
if "Another update is already running" in str(e):
print(f"\n⚠️ {e}")
return
print(f"\n❌ Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
"""
RSS feed generator
Creates standard RSS 2.0 XML files from database items
"""
from typing import List, Dict, Any
from datetime import datetime
import xml.etree.ElementTree as ET
from xml.dom import minidom
class RSSGenerator:
"""Generate RSS 2.0 XML feeds"""
def __init__(self, title: str = "Universal RSS Feed",
description: str = "Aggregated content from multiple platforms",
link: str = "https://localhost"):
self.feed_title = title
self.feed_description = description
self.feed_link = link
def create_feed(self, items: List[Dict[str, Any]], output_path: str = "feed.xml"):
"""Generate RSS feed from items"""
# Create root RSS element
rss = ET.Element("rss", {
"version": "2.0",
"xmlns:atom": "http://www.w3.org/2005/Atom",
"xmlns:content": "http://purl.org/rss/1.0/modules/content/"
})
# Create channel
channel = ET.SubElement(rss, "channel")
# Add channel metadata
ET.SubElement(channel, "title").text = self.feed_title
ET.SubElement(channel, "link").text = self.feed_link
ET.SubElement(channel, "description").text = self.feed_description
ET.SubElement(channel, "language").text = "zh-CN"
ET.SubElement(channel, "lastBuildDate").text = self._format_date(datetime.now())
# Add atom:link for self-reference
ET.SubElement(channel, "{http://www.w3.org/2005/Atom}link", {
"href": f"{self.feed_link}/feed.xml",
"rel": "self",
"type": "application/rss+xml"
})
# Add items
for item_data in items:
item = ET.SubElement(channel, "item")
# Required elements
ET.SubElement(item, "title").text = item_data.get("title", "")
ET.SubElement(item, "link").text = item_data.get("link", "")
# Optional elements
description = item_data.get("description", "")
if description:
ET.SubElement(item, "description").text = description
# Category
category = item_data.get("category", "")
if category:
ET.SubElement(item, "category").text = category
# Pub date
pub_date = item_data.get("pub_date")
if pub_date:
formatted_date = self._format_date(pub_date)
if formatted_date:
ET.SubElement(item, "pubDate").text = formatted_date
# GUID (unique identifier)
guid = item_data.get("item_id", item_data.get("link", ""))
ET.SubElement(item, "guid", {"isPermaLink": "false"}).text = guid
# Platform as custom element
platform = item_data.get("platform", "")
if platform:
ET.SubElement(item, "source").text = platform
# Pretty print and save
xml_string = self._prettify(rss)
with open(output_path, "w", encoding="utf-8") as f:
f.write(xml_string)
return output_path
def create_categorized_feeds(self, items: List[Dict[str, Any]], output_dir: str = "."):
"""Generate separate RSS feeds for each category"""
# Category name mapping: Chinese to English
category_mapping = {
"科技": "tech",
"人文": "humanities",
"设计": "design",
"娱乐": "entertainment",
"其他": "others"
}
# Initialize all categories with empty lists
categories = {cat: [] for cat in category_mapping.keys()}
# Group items by category
for item in items:
category = item.get("category", "其他")
if category in categories:
categories[category].append(item)
else:
# If unknown category, add to "其他"
categories["其他"].append(item)
# Generate feed for each category (including empty ones)
feed_paths = {}
for category, category_items in categories.items():
# Use English filename
english_name = category_mapping.get(category, "others")
output_path = f"{output_dir}/{english_name}_feed.xml"
self.feed_title = f"Universal RSS - {category}"
self.feed_description = f"{category}类内容聚合"
# Generate feed even if empty
self.create_feed(category_items, output_path)
feed_paths[category] = output_path
# Also create master feed with all items
self.feed_title = "Universal RSS Feed"
self.feed_description = "Aggregated content from multiple platforms"
master_path = f"{output_dir}/feed.xml"
self.create_feed(items, master_path)
feed_paths["master"] = master_path
return feed_paths
def _format_date(self, date_input) -> str:
"""Format date to RFC 822 format for RSS"""
try:
if isinstance(date_input, str):
# Try to parse ISO format
try:
dt = datetime.fromisoformat(date_input.replace("Z", "+00:00"))
except:
# Try other common formats
dt = datetime.strptime(date_input, "%Y-%m-%d %H:%M:%S")
elif isinstance(date_input, datetime):
dt = date_input
else:
return ""
# Format to RFC 822
return dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
except:
return ""
def _escape_text(self, text: str) -> str:
"""Return text as-is. XML escaping is handled by ElementTree."""
return text if text else ""
def _prettify(self, elem: ET.Element) -> str:
"""Return a pretty-printed XML string"""
rough_string = ET.tostring(elem, encoding="utf-8")
reparsed = minidom.parseString(rough_string)
return reparsed.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8")
class OPMLGenerator:
"""Generate OPML file for subscription management"""
def __init__(self, title: str = "Universal RSS Subscriptions"):
self.title = title
def create_opml(self, subscriptions: List[Dict[str, Any]], output_path: str = "subscriptions.opml"):
"""Generate OPML from subscriptions"""
opml = ET.Element("opml", {"version": "2.0"})
# Head
head = ET.SubElement(opml, "head")
ET.SubElement(head, "title").text = self.title
ET.SubElement(head, "dateCreated").text = datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000")
# Body
body = ET.SubElement(opml, "body")
# Group by platform
platforms = {}
for sub in subscriptions:
platform = sub.get("platform", "other")
if platform not in platforms:
platforms[platform] = []
platforms[platform].append(sub)
# Create outline for each platform
for platform, subs in platforms.items():
platform_outline = ET.SubElement(body, "outline", {
"text": platform.title(),
"title": platform.title()
})
for sub in subs:
ET.SubElement(platform_outline, "outline", {
"type": "rss",
"text": sub.get("title", sub.get("url", "")),
"title": sub.get("title", sub.get("url", "")),
"xmlUrl": sub.get("url", ""),
"htmlUrl": sub.get("url", "")
})
# Save
xml_string = ET.tostring(opml, encoding="utf-8")
reparsed = minidom.parseString(xml_string)
pretty_xml = reparsed.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8")
with open(output_path, "w", encoding="utf-8") as f:
f.write(pretty_xml)
return output_path
// Minimal skill-owned RSSHub worker.
// Starts an HTTP server on 127.0.0.1:${RSSHUB_PORT} that proxies requests to
// the rsshub npm package (installed in SKILL/node_modules/rsshub).
//
// Companion to scripts/rsshub_manager.py — the manager spawns this as a
// detached node process. Keeping the logic here minimal; all control (start /
// stop / health / upgrade) lives in the Python manager.
import { createServer } from 'node:http'
import { fileURLToPath } from 'node:url'
import { readFile } from 'node:fs/promises'
import { init } from 'rsshub'
const PORT = Number.parseInt(process.env.RSSHUB_PORT || '1201', 10)
const HOST = '127.0.0.1'
async function resolveRsshubApp () {
// The rsshub package entry is a tiny shim that dynamically imports a
// versioned app-*.mjs. Read the shim and pull out the target path so we
// can import the actual hono app that exposes request().
const pkgEntryUrl = await import.meta.resolve('rsshub')
const pkgSource = await readFile(fileURLToPath(pkgEntryUrl), 'utf8')
const match = pkgSource.match(/import\("(\.\/app-[^"]+\.mjs)"\)/)
if (!match?.[1]) {
throw new Error('Cannot resolve RSSHub app module from package entry')
}
const appUrl = new URL(match[1], pkgEntryUrl).href
const mod = await import(appUrl)
if (!mod?.default?.request) {
throw new Error('RSSHub app module is missing request()')
}
return mod.default
}
async function forwardToNode (res, response) {
res.statusCode = response.status
response.headers.forEach((value, key) => {
if (key.toLowerCase() === 'content-length') return
res.setHeader(key, value)
})
const body = Buffer.from(await response.arrayBuffer())
res.end(body)
}
async function main () {
await init({
IS_PACKAGE: true,
CACHE_TYPE: 'memory',
ALLOW_ORIGIN: '*',
NODE_ENV: 'production',
})
const app = await resolveRsshubApp()
const server = createServer(async (req, res) => {
try {
const reqUrl = new URL(req.url || '/', `http://${HOST}:${PORT}`)
if (reqUrl.pathname === '/healthz') {
res.statusCode = 200
res.setHeader('content-type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ ok: true, service: 'lets-go-rss-rsshub', port: PORT }))
return
}
if (reqUrl.pathname === '/shutdown' && req.method === 'POST') {
res.statusCode = 202
res.end('{"ok":true}')
setTimeout(() => process.exit(0), 50)
return
}
const headers = new Headers()
for (const [k, v] of Object.entries(req.headers)) {
const lk = k.toLowerCase()
if (lk === 'host' || lk === 'connection' || lk === 'content-length') continue
if (Array.isArray(v)) headers.set(k, v.join(', '))
else if (typeof v === 'string') headers.set(k, v)
}
let body
if (req.method !== 'GET' && req.method !== 'HEAD') {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const buf = Buffer.concat(chunks)
if (buf.length > 0) body = buf
}
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 30_000)
const response = await app.request(`${reqUrl.pathname}${reqUrl.search}`, {
method: req.method || 'GET',
headers,
body,
signal: ctrl.signal,
})
clearTimeout(t)
await forwardToNode(res, response)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
console.error(`[rsshub-worker:request] ${msg}`)
res.statusCode = 500
res.setHeader('content-type', 'application/json; charset=utf-8')
res.end(JSON.stringify({ error: msg }))
}
})
server.on('error', (e) => {
console.error(`[rsshub-worker:server] ${e.message}`)
process.exit(1)
})
server.listen(PORT, HOST, () => {
console.error(`[rsshub-worker] listening on ${HOST}:${PORT}`)
})
}
process.on('uncaughtException', (e) => {
// Node best practice: bail on uncaught exceptions (state may be corrupt);
// the Python manager will restart us.
console.error(`[rsshub-worker:uncaught] ${e?.message || e}`)
process.exit(1)
})
process.on('unhandledRejection', (e) => {
// Many RSSHub routes throw in unexpected places (e.g. bad JSON upstream);
// log and continue rather than killing the whole worker.
console.error(`[rsshub-worker:unhandled] ${e?.message || e}`)
})
main().catch((e) => {
console.error(`[rsshub-worker:start] ${e?.message || e}`)
process.exit(1)
})