Files
lanshu bf6062fd9a feat: add 5 new sources + user OPML, fix RSS summary truncation
新增数据源(全部纯 Python、零额外依赖):
- lobsters: lobste.rs/hottest.json
- devto: dev.to/api/articles?top=1
- sspai: 少数派 RSS
- arxiv: cs.AI/cs.CL/cs.LG Atom API(带 3 次重试,timeout 45s)
- infoq_cn: InfoQ 中文 RSS(菜单默认配 --deep 拿正文)

用户自定义订阅源:
- 新增 scripts/fetch_user_feeds.py 解析 OPML(兼容 Feedly/Inoreader 导出)
- user_sources.opml.example 含 3 个示例订阅
- 优先读 ~/.config/news-aggregator/user_sources.opml,回退 skill 根目录

修复:
- rss_parser.py:66 三元运算符优先级 bug,≤300 字 summary 不再被截成空串
  受益:sspai / infoq_cn 及原有 8 个 RSS sub-sources

菜单与文档:
- templates.md 加 #36-41(扩展源 v2)+ OPML 首次使用提示
- SKILL.md 源数 28→33+,加 Tech Community / Chinese / Custom 分类
- README.md 同步源数和"扩展源 (v2)"小节

刻意不做:
- 不引入 SQLite 持久化
- 不动 except: pass 静默吞异常
- 不加 cookie/登录态
2026-05-24 21:49:49 +08:00

111 lines
3.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
用户自定义 RSS/Atom 订阅源聚合器。
读取 OPML 文件(兼容 Feedly / Inoreader / NetNewsWire 导出格式),
并发抓取每个订阅源,输出统一 JSON 到 stdout。
OPML 查找优先级:
1. ~/.config/news-aggregator/user_sources.opml
2. <skill_root>/user_sources.opml
用法:
python fetch_user_feeds.py --limit 15 --per-feed 3
"""
import sys
import os
import json
import argparse
import concurrent.futures
from xml.etree import ElementTree as ET
# 复用 rss_parser
sys.path.insert(0, os.path.dirname(__file__))
from rss_parser import fetch_rss_feed
def find_opml_file():
"""按优先级查找 OPML 文件,返回第一个存在的路径或 None。"""
candidates = [
os.path.expanduser("~/.config/news-aggregator/user_sources.opml"),
os.path.join(os.path.dirname(os.path.dirname(__file__)), "user_sources.opml"),
]
for path in candidates:
if os.path.exists(path):
return path
return None
def parse_opml(path):
"""解析 OPML,返回 [(name, xml_url), ...] 列表。"""
tree = ET.parse(path)
root = tree.getroot()
feeds = []
for outline in root.iter("outline"):
xml_url = outline.get("xmlUrl")
if not xml_url:
continue
name = outline.get("title") or outline.get("text") or xml_url
feeds.append((name, xml_url))
return feeds
def fetch_all_feeds(feeds, limit_per_feed=3):
"""并发抓取所有 feed,返回合并后的 items 列表。"""
all_items = []
if not feeds:
return all_items
max_workers = min(8, len(feeds))
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_name = {
executor.submit(fetch_rss_feed, url, name, limit_per_feed): name
for name, url in feeds
}
for future in concurrent.futures.as_completed(future_to_name):
name = future_to_name[future]
try:
items = future.result()
all_items.extend(items)
except Exception as e:
print(f"User feed '{name}' fetch error: {e}", file=sys.stderr)
return all_items
def main():
parser = argparse.ArgumentParser(
description="Fetch user-defined RSS feeds from OPML and emit JSON."
)
parser.add_argument("--opml", help="Override OPML file path")
parser.add_argument("--limit", type=int, default=15, help="Total max items returned")
parser.add_argument("--per-feed", type=int, default=3, help="Max items per individual feed")
args = parser.parse_args()
opml_path = args.opml or find_opml_file()
if not opml_path:
print("[]")
print(
"No OPML file found. Copy user_sources.opml.example to "
"~/.config/news-aggregator/user_sources.opml or to skill root.",
file=sys.stderr,
)
return
try:
feeds = parse_opml(opml_path)
except Exception as e:
print(f"Failed to parse OPML {opml_path}: {e}", file=sys.stderr)
print("[]")
return
if not feeds:
print("[]")
print(f"No <outline xmlUrl=...> entries in {opml_path}", file=sys.stderr)
return
print(f"Fetching {len(feeds)} feeds from {opml_path}...", file=sys.stderr)
items = fetch_all_feeds(feeds, args.per_feed)
print(json.dumps(items[:args.limit], ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()