feat: add aggregated RSS feed, CSV and OPML export

This commit is contained in:
tmwgsicp 2026-03-20 01:25:38 +08:00
parent 869c5c0c92
commit 4825edc355
3 changed files with 287 additions and 1 deletions

View File

@ -9,11 +9,14 @@ RSS 订阅路由
订阅管理 + RSS XML 输出 订阅管理 + RSS XML 输出
""" """
import csv
import io
import time import time
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from html import escape as html_escape from html import escape as html_escape
from typing import Optional from typing import Optional
import xml.etree.ElementTree as ET
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response from fastapi.responses import Response
@ -173,6 +176,112 @@ async def poller_status():
) )
# ── 聚合 RSS ─────────────────────────────────────────────
@router.get("/rss/all", summary="聚合 RSS 订阅源",
response_class=Response)
async def get_aggregated_rss_feed(
request: Request,
limit: int = Query(50, ge=1, le=200, description="文章数量上限"),
):
"""
获取所有订阅公众号的聚合 RSS 2.0 订阅源
将此地址添加到 RSS 阅读器即可在一个订阅源中查看所有公众号文章
订阅增减后自动生效无需更换链接
"""
subs = rss_store.list_subscriptions()
nickname_map = {s["fakeid"]: s.get("nickname") or s["fakeid"] for s in subs}
articles = rss_store.get_all_articles(limit=limit) if subs else []
base_url = str(request.base_url).rstrip("/")
xml = _build_aggregated_rss_xml(articles, nickname_map, base_url)
return Response(
content=xml,
media_type="application/rss+xml; charset=utf-8",
headers={"Cache-Control": "public, max-age=600"},
)
# ── 导出 ─────────────────────────────────────────────────
@router.get("/rss/export", summary="导出订阅列表")
async def export_subscriptions(
request: Request,
format: str = Query("csv", regex="^(csv|opml)$", description="导出格式: csv 或 opml"),
):
"""
导出当前订阅列表
- **csv**: 包含公众号名称FakeIDRSS 地址文章数订阅时间
- **opml**: 标准 OPML 格式可直接导入 RSS 阅读器
"""
subs = rss_store.list_subscriptions()
base_url = str(request.base_url).rstrip("/")
if format == "opml":
return _build_opml_response(subs, base_url)
return _build_csv_response(subs, base_url)
def _build_csv_response(subs: list, base_url: str) -> Response:
buf = io.StringIO()
buf.write('\ufeff')
writer = csv.writer(buf)
writer.writerow(["Name", "FakeID", "RSS URL", "Articles", "Subscribed At"])
for s in subs:
rss_url = f"{base_url}/api/rss/{s['fakeid']}"
sub_date = datetime.fromtimestamp(
s.get("created_at", 0), tz=timezone.utc
).strftime("%Y-%m-%d")
writer.writerow([
s.get("nickname") or s["fakeid"],
s["fakeid"],
rss_url,
s.get("article_count", 0),
sub_date,
])
return Response(
content=buf.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="wechat_rss_subscriptions.csv"'},
)
def _build_opml_response(subs: list, base_url: str) -> Response:
opml = ET.Element("opml", version="2.0")
head = ET.SubElement(opml, "head")
ET.SubElement(head, "title").text = "WeChat RSS Subscriptions"
ET.SubElement(head, "dateCreated").text = datetime.now(timezone.utc).strftime(
"%a, %d %b %Y %H:%M:%S +0000"
)
body = ET.SubElement(opml, "body")
group = ET.SubElement(body, "outline", text="WeChat RSS", title="WeChat RSS")
for s in subs:
name = s.get("nickname") or s["fakeid"]
rss_url = f"{base_url}/api/rss/{s['fakeid']}"
ET.SubElement(group, "outline", **{
"type": "rss",
"text": name,
"title": name,
"xmlUrl": rss_url,
"htmlUrl": "https://mp.weixin.qq.com",
"description": f"{name} - WeChat RSS",
})
xml_str = ET.tostring(opml, encoding="unicode", xml_declaration=False)
content = '<?xml version="1.0" encoding="UTF-8"?>\n' + xml_str
return Response(
content=content,
media_type="application/xml; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="wechat_rss_subscriptions.opml"'},
)
# ── RSS XML 输出 ────────────────────────────────────────── # ── RSS XML 输出 ──────────────────────────────────────────
def _rfc822(ts: int) -> str: def _rfc822(ts: int) -> str:
@ -347,3 +456,115 @@ async def get_rss_feed(fakeid: str, request: Request,
media_type="application/rss+xml; charset=utf-8", media_type="application/rss+xml; charset=utf-8",
headers={"Cache-Control": "public, max-age=600"}, headers={"Cache-Control": "public, max-age=600"},
) )
# ── 聚合 RSS XML 构建 ────────────────────────────────────
def _build_aggregated_rss_xml(articles: list, nickname_map: dict,
base_url: str) -> str:
"""Build aggregated RSS XML across all subscriptions."""
from xml.dom import minidom
doc = minidom.Document()
rss = doc.createElement("rss")
rss.setAttribute("version", "2.0")
rss.setAttribute("xmlns:atom", "http://www.w3.org/2005/Atom")
doc.appendChild(rss)
channel = doc.createElement("channel")
rss.appendChild(channel)
def add_text(parent, tag, text):
elem = doc.createElement(tag)
elem.appendChild(doc.createTextNode(str(text)))
parent.appendChild(elem)
return elem
add_text(channel, "title", "WeChat RSS - All Subscriptions")
add_text(channel, "link", base_url)
add_text(channel, "description", "Aggregated feed of all subscribed WeChat accounts")
add_text(channel, "language", "zh-CN")
add_text(channel, "lastBuildDate", _rfc822(int(time.time())))
add_text(channel, "generator", "WeChat Download API")
atom_link = doc.createElement("atom:link")
atom_link.setAttribute("href", f"{base_url}/api/rss/all")
atom_link.setAttribute("rel", "self")
atom_link.setAttribute("type", "application/rss+xml")
channel.appendChild(atom_link)
for a in articles:
item = doc.createElement("item")
source_name = nickname_map.get(a.get("fakeid", ""), "")
title_text = a.get("title", "")
if source_name:
title_text = f"[{source_name}] {title_text}"
add_text(item, "title", title_text)
link = a.get("link", "")
add_text(item, "link", link)
guid = doc.createElement("guid")
guid.setAttribute("isPermaLink", "true")
guid.appendChild(doc.createTextNode(link))
item.appendChild(guid)
if a.get("publish_time"):
add_text(item, "pubDate", _rfc822(a["publish_time"]))
if a.get("author"):
add_text(item, "author", a["author"])
cover = proxy_image_url(a.get("cover", ""), base_url)
digest = html_escape(a.get("digest", "")) if a.get("digest") else ""
author = html_escape(a.get("author", "")) if a.get("author") else ""
title_escaped = html_escape(a.get("title", ""))
content_html = a.get("content", "")
html_parts = []
if content_html:
html_parts.append(
f'<div style="font-size:16px;line-height:1.8;color:#333">'
f'{content_html}</div>'
)
if author:
html_parts.append(
f'<hr style="margin:24px 0;border:none;border-top:1px solid #eee" />'
f'<p style="color:#888;font-size:13px;margin:0">author: {author}</p>'
)
else:
if cover:
html_parts.append(
f'<div style="margin-bottom:12px">'
f'<a href="{html_escape(link)}">'
f'<img src="{html_escape(cover)}" alt="{title_escaped}" '
f'style="max-width:100%;height:auto;border-radius:8px" /></a></div>'
)
if digest:
html_parts.append(
f'<p style="color:#333;font-size:15px;line-height:1.8;'
f'margin:0 0 16px">{digest}</p>'
)
if author:
html_parts.append(
f'<p style="color:#888;font-size:13px;margin:0 0 12px">'
f'author: {author}</p>'
)
html_parts.append(
f'<p style="margin:0"><a href="{html_escape(link)}" '
f'style="color:#1890ff;text-decoration:none;font-size:14px">'
f'Read &rarr;</a></p>'
)
description = doc.createElement("description")
cdata = doc.createCDATASection("\n".join(html_parts))
description.appendChild(cdata)
item.appendChild(description)
channel.appendChild(item)
xml_str = doc.toprettyxml(indent=" ", encoding=None)
lines = [line for line in xml_str.split('\n') if line.strip()]
xml_str = '\n'.join(lines[1:])
return '<?xml version="1.0" encoding="UTF-8"?>\n' + xml_str

View File

@ -405,7 +405,28 @@
</div> </div>
<div class="sub-section"> <div class="sub-section">
<h2>我的订阅</h2> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-md);">
<h2 style="margin-bottom:0;">我的订阅</h2>
<div style="display:flex;gap:var(--space-sm);position:relative;">
<div id="exportDropdown" style="position:relative;">
<button class="btn-sm" onclick="toggleExportMenu()">导出</button>
<div id="exportMenu" style="display:none;position:absolute;right:0;top:100%;margin-top:4px;background:var(--bg-primary);border:1px solid var(--border-base);border-radius:var(--radius-base);box-shadow:var(--shadow-base);z-index:10;min-width:120px;">
<div style="padding:8px 16px;cursor:pointer;font-size:var(--font-sm);transition:background var(--duration-fast);" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''" onclick="doExport('csv')">CSV</div>
<div style="padding:8px 16px;cursor:pointer;font-size:var(--font-sm);transition:background var(--duration-fast);" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background=''" onclick="doExport('opml')">OPML</div>
</div>
</div>
</div>
</div>
<div id="aggRssBar" style="display:none;background:var(--bg-secondary);border:1px solid var(--border-light);border-radius:var(--radius-base);padding:12px var(--space-md);margin-bottom:var(--space-md);">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
<div style="flex:1;min-width:0;">
<div style="font-size:var(--font-xs);font-weight:600;color:var(--text-secondary);margin-bottom:4px;">聚合 RSS 链接</div>
<div id="aggRssUrl" style="font-size:var(--font-xs);color:var(--text-muted);font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
</div>
<button class="btn-copy" onclick="copyRss(document.getElementById('aggRssUrl').textContent)">复制</button>
</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px;">包含所有订阅的公众号文章,订阅变动自动生效,无需更换链接</div>
</div>
<div id="subList"><div class="sub-empty">加载中...</div></div> <div id="subList"><div class="sub-empty">加载中...</div></div>
</div> </div>
@ -454,8 +475,10 @@
var d = await res.json(); var d = await res.json();
if (!d.success || !d.data || d.data.length === 0) { if (!d.success || !d.data || d.data.length === 0) {
el.innerHTML = '<div class="sub-empty">暂无订阅,搜索公众号后添加</div>'; el.innerHTML = '<div class="sub-empty">暂无订阅,搜索公众号后添加</div>';
updateAggRssBar(false);
return; return;
} }
updateAggRssBar(true);
var html = '<ul class="sub-list">'; var html = '<ul class="sub-list">';
d.data.forEach(function(s) { d.data.forEach(function(s) {
var lastPoll = s.last_poll ? new Date(s.last_poll * 1000).toLocaleString('zh-CN') : '从未'; var lastPoll = s.last_poll ? new Date(s.last_poll * 1000).toLocaleString('zh-CN') : '从未';
@ -476,6 +499,7 @@
el.innerHTML = html; el.innerHTML = html;
} catch (e) { } catch (e) {
el.innerHTML = '<div class="sub-empty">加载失败: ' + e.message + '</div>'; el.innerHTML = '<div class="sub-empty">加载失败: ' + e.message + '</div>';
updateAggRssBar(false);
} }
} }
@ -603,6 +627,34 @@
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '&quot;'); return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '&quot;');
} }
function updateAggRssBar(hasSubs) {
var bar = document.getElementById('aggRssBar');
if (hasSubs) {
var url = window.location.origin + '/api/rss/all';
document.getElementById('aggRssUrl').textContent = url;
bar.style.display = 'block';
} else {
bar.style.display = 'none';
}
}
function toggleExportMenu() {
var menu = document.getElementById('exportMenu');
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
function doExport(format) {
document.getElementById('exportMenu').style.display = 'none';
window.location.href = '/api/rss/export?format=' + format;
}
document.addEventListener('click', function(e) {
var dd = document.getElementById('exportDropdown');
if (dd && !dd.contains(e.target)) {
document.getElementById('exportMenu').style.display = 'none';
}
});
document.getElementById('searchInput').addEventListener('keydown', function(e) { document.getElementById('searchInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doSearch(); if (e.key === 'Enter') doSearch();
}); });

View File

@ -190,3 +190,16 @@ def get_all_fakeids() -> List[str]:
conn.close() conn.close()
def get_all_articles(limit: int = 50) -> List[Dict]:
"""Get latest articles across all subscriptions, sorted by publish_time desc."""
conn = _get_conn()
try:
rows = conn.execute(
"SELECT * FROM articles ORDER BY publish_time DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()