@@ -0,0 +1,50 @@
|
||||
name: Update RSS
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Gitea Actions 使用 UTC;每 4 小时运行一次。
|
||||
- cron: "0 */4 * * *"
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- "feeds.yml"
|
||||
- "merge.py"
|
||||
- "requirements.txt"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: python -m pip install -r requirements.txt
|
||||
|
||||
- name: Generate feeds
|
||||
run: python merge.py
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git config user.name "RSS Bot"
|
||||
git config user.email "rss-bot@localhost"
|
||||
git add public/
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "No RSS changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore: update RSS feeds"
|
||||
git push
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.tmp
|
||||
.venv/
|
||||
@@ -0,0 +1,52 @@
|
||||
# RSS Merge for Gitea Actions
|
||||
|
||||
用一个 Python 脚本合并多个 RSS/Atom 源,并由 Gitea Actions 每 4 小时更新一次。生成结果保存在 `public/*.xml`,可以直接通过 Gitea Raw URL 订阅。
|
||||
|
||||
## 使用
|
||||
|
||||
1. 编辑 [`feeds.yml`](feeds.yml),替换示例订阅源并按需增加分组。
|
||||
2. 将仓库推送到 Gitea。
|
||||
3. 在仓库设置中启用 Actions,并确保实例上已有可用的 `act_runner`。
|
||||
4. 在 Actions 页面手动运行一次 `Update RSS`,确认 `public/*.xml` 被提交。
|
||||
|
||||
订阅地址格式:
|
||||
|
||||
```text
|
||||
https://git.example.com/{owner}/{repo}/raw/branch/main/public/all.xml
|
||||
```
|
||||
|
||||
计划任务写在 [`.gitea/workflows/update.yml`](.gitea/workflows/update.yml),`0 */4 * * *` 表示按 UTC 每 4 小时运行一次。修改 `feeds.yml`、脚本或依赖也会立即触发更新。
|
||||
|
||||
## 配置
|
||||
|
||||
每个 `groups` 条目会生成一个 RSS 文件,默认路径为 `public/{分组名}.xml`:
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
tech:
|
||||
title: Tech News
|
||||
description: My technology feeds
|
||||
max_items: 100
|
||||
self_url: https://git.example.com/me/rss/raw/branch/main/public/tech.xml
|
||||
sources:
|
||||
- https://hnrss.org/frontpage
|
||||
- url: https://example.com/feed.xml
|
||||
name: Optional source name
|
||||
```
|
||||
|
||||
- `settings.allow_partial`:默认是 `true`。部分源抓取失败时,仍使用成功获取的源生成输出;一个分组的所有源都失败时才跳过该分组。
|
||||
- `settings.user_agent`:默认使用 Chrome User-Agent,也可以自行替换。
|
||||
- `output`:可为分组指定仓库内的相对输出路径。
|
||||
|
||||
同一个 URL 被多个分组引用时只会请求一次。输出按发布时间倒序排列并截取 `max_items` 条,不进行过滤或去重。内容不变时不会重写文件,也就不会制造空提交。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
python -m pip install -r requirements.txt
|
||||
python merge.py
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
首次成功运行后会创建 `public/` 目录。请将生成的 XML 一并提交,后续源临时不可用时阅读器仍可访问最近一次结果。
|
||||
@@ -0,0 +1,45 @@
|
||||
settings:
|
||||
timeout: 20
|
||||
retries: 2
|
||||
# 部分源不可用时,仍使用成功获取的源生成输出。
|
||||
allow_partial: true
|
||||
max_feed_bytes: 10485760
|
||||
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
|
||||
groups:
|
||||
world:
|
||||
title: 世界新闻
|
||||
max_items: 100
|
||||
sources:
|
||||
- url: https://rsshub.yuzhen.pub/zaobao/realtime/world
|
||||
name: 联合早报
|
||||
|
||||
china:
|
||||
title: 国内新闻
|
||||
max_items: 100
|
||||
sources:
|
||||
- url: http://rsshub.yuzhen.pub/thepaper/featured
|
||||
name: 澎湃新闻
|
||||
|
||||
ai:
|
||||
title: AI 新闻
|
||||
max_items: 100
|
||||
sources:
|
||||
- url: https://daily.juya.uk/rss.xml
|
||||
name: 橘鸦 AI 早报
|
||||
|
||||
mun:
|
||||
title: 模联新闻
|
||||
max_items: 100
|
||||
sources:
|
||||
- url: https://wewe-rss.yuzhen.pub/feeds/MP_WXS_3925726609.atom?limit=10&page=1
|
||||
name: 模联次位面
|
||||
- url: https://wewe-rss.yuzhen.pub/feeds/MP_WXS_3904685727.atom?limit=10&page=1
|
||||
name: 鹭岛模联
|
||||
|
||||
erzhong:
|
||||
title: 青岛二中新闻
|
||||
max_items: 100
|
||||
sources:
|
||||
- url: https://wewe-rss.yuzhen.pub/feeds/MP_WXS_2398790012.atom?limit=10&page=1
|
||||
name: 青岛二中微信公众号
|
||||
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge multiple RSS/Atom feeds into deterministic RSS 2.0 files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import calendar
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import format_datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import feedparser
|
||||
import requests
|
||||
import yaml
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
||||
LOG = logging.getLogger("rss-merge")
|
||||
ATOM_NS = "http://www.w3.org/2005/Atom"
|
||||
CONTENT_NS = "http://purl.org/rss/1.0/modules/content/"
|
||||
ET.register_namespace("atom", ATOM_NS)
|
||||
ET.register_namespace("content", CONTENT_NS)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Source:
|
||||
url: str
|
||||
name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedItem:
|
||||
title: str
|
||||
link: str
|
||||
guid: str
|
||||
published: datetime | None
|
||||
description: str = ""
|
||||
author: str = ""
|
||||
categories: list[str] = field(default_factory=list)
|
||||
enclosure: dict[str, str] | None = None
|
||||
source_title: str = ""
|
||||
source_url: str = ""
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise ConfigError(f"无法读取配置 {path}: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError("feeds.yml 顶层必须是对象")
|
||||
groups = raw.get("groups")
|
||||
if not isinstance(groups, dict) or not groups:
|
||||
raise ConfigError("feeds.yml 必须包含非空的 groups")
|
||||
for name, group in groups.items():
|
||||
if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", name):
|
||||
raise ConfigError(f"分组名 {name!r} 只能包含字母、数字、下划线和连字符")
|
||||
if not isinstance(group, dict):
|
||||
raise ConfigError(f"分组 {name!r} 必须是对象")
|
||||
sources = group.get("sources")
|
||||
if not isinstance(sources, list) or not sources:
|
||||
raise ConfigError(f"分组 {name!r} 必须包含非空的 sources")
|
||||
parse_sources(sources, name)
|
||||
max_items = group.get("max_items", 200)
|
||||
if not isinstance(max_items, int) or isinstance(max_items, bool) or max_items < 1:
|
||||
raise ConfigError(f"分组 {name!r} 的 max_items 必须是正整数")
|
||||
return raw
|
||||
|
||||
|
||||
def parse_sources(values: list[Any], group_name: str) -> list[Source]:
|
||||
result: list[Source] = []
|
||||
for value in values:
|
||||
if isinstance(value, str):
|
||||
url, name = value.strip(), ""
|
||||
elif isinstance(value, dict) and isinstance(value.get("url"), str):
|
||||
url = value["url"].strip()
|
||||
name = str(value.get("name", "")).strip()
|
||||
else:
|
||||
raise ConfigError(f"分组 {group_name!r} 中的源必须是 URL 字符串或包含 url 的对象")
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise ConfigError(f"分组 {group_name!r} 中的源不是 HTTP(S) URL: {url!r}")
|
||||
result.append(Source(url=url, name=name))
|
||||
return result
|
||||
|
||||
|
||||
def make_session(user_agent: str, retries: int) -> requests.Session:
|
||||
retry = Retry(
|
||||
total=retries,
|
||||
connect=retries,
|
||||
read=retries,
|
||||
status=retries,
|
||||
backoff_factor=0.8,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=frozenset(("GET",)),
|
||||
)
|
||||
session = requests.Session()
|
||||
session.headers.update({"User-Agent": user_agent, "Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*;q=0.5"})
|
||||
session.mount("http://", HTTPAdapter(max_retries=retry))
|
||||
session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
return session
|
||||
|
||||
|
||||
def fetch_feed(
|
||||
session: requests.Session,
|
||||
source: Source,
|
||||
timeout: float,
|
||||
max_bytes: int,
|
||||
) -> list[FeedItem]:
|
||||
with session.get(source.url, timeout=timeout, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
size += len(chunk)
|
||||
if size > max_bytes:
|
||||
raise ValueError(f"响应超过限制 {max_bytes} bytes")
|
||||
chunks.append(chunk)
|
||||
body = b"".join(chunks)
|
||||
parsed = feedparser.parse(body, response_headers=dict(response.headers))
|
||||
|
||||
if parsed.bozo and not parsed.entries:
|
||||
raise ValueError(f"Feed 解析失败: {parsed.bozo_exception}")
|
||||
source_title = source.name or clean_text(parsed.feed.get("title", "")) or source.url
|
||||
return [entry_to_item(entry, source_title, source.url) for entry in parsed.entries]
|
||||
|
||||
|
||||
def clean_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def parsed_datetime(entry: Any) -> datetime | None:
|
||||
value = entry.get("published_parsed") or entry.get("updated_parsed") or entry.get("created_parsed")
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(calendar.timegm(value), tz=timezone.utc)
|
||||
except (OverflowError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def entry_to_item(entry: Any, source_title: str, source_url: str) -> FeedItem:
|
||||
title = clean_text(entry.get("title")) or "(无标题)"
|
||||
link = clean_text(entry.get("link"))
|
||||
guid = clean_text(entry.get("id")) or link
|
||||
content = entry.get("content") or []
|
||||
description = ""
|
||||
if content and isinstance(content[0], dict):
|
||||
description = clean_text(content[0].get("value"))
|
||||
description = description or clean_text(entry.get("summary")) or clean_text(entry.get("description"))
|
||||
if not guid:
|
||||
fingerprint = f"{title}\0{description}\0{entry.get('published', '')}"
|
||||
guid = "urn:rss-merge:" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
|
||||
|
||||
categories = []
|
||||
for tag in entry.get("tags", []) or []:
|
||||
if isinstance(tag, dict) and clean_text(tag.get("term")):
|
||||
categories.append(clean_text(tag["term"]))
|
||||
|
||||
enclosure = None
|
||||
enclosures = entry.get("enclosures", []) or []
|
||||
if enclosures and isinstance(enclosures[0], dict) and enclosures[0].get("href"):
|
||||
raw = enclosures[0]
|
||||
enclosure = {
|
||||
"url": clean_text(raw.get("href")),
|
||||
"type": clean_text(raw.get("type")) or "application/octet-stream",
|
||||
"length": clean_text(raw.get("length")) or "0",
|
||||
}
|
||||
|
||||
return FeedItem(
|
||||
title=title,
|
||||
link=link,
|
||||
guid=guid,
|
||||
published=parsed_datetime(entry),
|
||||
description=description,
|
||||
author=clean_text(entry.get("author")),
|
||||
categories=categories,
|
||||
enclosure=enclosure,
|
||||
source_title=source_title,
|
||||
source_url=source_url,
|
||||
)
|
||||
|
||||
|
||||
def sort_and_limit(items: Iterable[FeedItem], max_items: int) -> list[FeedItem]:
|
||||
ordered = sorted(
|
||||
items,
|
||||
key=lambda item: (item.published or datetime.min.replace(tzinfo=timezone.utc), item.guid),
|
||||
reverse=True,
|
||||
)
|
||||
return ordered[:max_items]
|
||||
|
||||
|
||||
def add_text(parent: ET.Element, tag: str, text: str | None, **attrs: str) -> ET.Element | None:
|
||||
if text is None or text == "":
|
||||
return None
|
||||
child = ET.SubElement(parent, tag, attrs)
|
||||
child.text = text
|
||||
return child
|
||||
|
||||
|
||||
def build_rss(group_name: str, group: dict[str, Any], items: list[FeedItem]) -> bytes:
|
||||
root = ET.Element("rss", {"version": "2.0"})
|
||||
channel = ET.SubElement(root, "channel")
|
||||
title = clean_text(group.get("title")) or group_name
|
||||
self_url = clean_text(group.get("self_url"))
|
||||
sources = parse_sources(group["sources"], group_name)
|
||||
channel_link = clean_text(group.get("link")) or self_url or sources[0].url
|
||||
add_text(channel, "title", title)
|
||||
add_text(channel, "link", channel_link)
|
||||
add_text(channel, "description", clean_text(group.get("description")) or f"Merged RSS feed: {title}")
|
||||
add_text(channel, "language", clean_text(group.get("language")))
|
||||
add_text(channel, "generator", "rss-merge")
|
||||
if self_url:
|
||||
ET.SubElement(channel, f"{{{ATOM_NS}}}link", {"href": self_url, "rel": "self", "type": "application/rss+xml"})
|
||||
dated_items = [item.published for item in items if item.published]
|
||||
if dated_items:
|
||||
add_text(channel, "lastBuildDate", format_datetime(max(dated_items), usegmt=True))
|
||||
|
||||
for value in items:
|
||||
node = ET.SubElement(channel, "item")
|
||||
add_text(node, "title", value.title)
|
||||
add_text(node, "link", value.link)
|
||||
guid = add_text(node, "guid", value.guid)
|
||||
if guid is not None:
|
||||
guid.set("isPermaLink", "true" if value.guid == value.link and bool(value.link) else "false")
|
||||
if value.published:
|
||||
add_text(node, "pubDate", format_datetime(value.published, usegmt=True))
|
||||
add_text(node, "description", value.description)
|
||||
if value.description:
|
||||
add_text(node, f"{{{CONTENT_NS}}}encoded", value.description)
|
||||
add_text(node, "author", value.author)
|
||||
if value.source_title:
|
||||
add_text(node, "source", value.source_title, url=value.source_url)
|
||||
for category in value.categories:
|
||||
add_text(node, "category", category)
|
||||
if value.enclosure:
|
||||
ET.SubElement(node, "enclosure", value.enclosure)
|
||||
|
||||
ET.indent(root, space=" ")
|
||||
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def safe_output_path(repo_root: Path, group_name: str, group: dict[str, Any]) -> Path:
|
||||
relative = Path(clean_text(group.get("output")) or f"public/{group_name}.xml")
|
||||
if relative.is_absolute():
|
||||
raise ConfigError(f"分组 {group_name!r} 的 output 必须是相对路径")
|
||||
output = (repo_root / relative).resolve()
|
||||
root = repo_root.resolve()
|
||||
if output == root or root not in output.parents:
|
||||
raise ConfigError(f"分组 {group_name!r} 的 output 超出仓库目录")
|
||||
return output
|
||||
|
||||
|
||||
def write_if_changed(path: Path, content: bytes) -> bool:
|
||||
if path.exists() and path.read_bytes() == content:
|
||||
return False
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False) as handle:
|
||||
temp_path = Path(handle.name)
|
||||
handle.write(content)
|
||||
temp_path.replace(path)
|
||||
return True
|
||||
|
||||
|
||||
def run(config_path: Path) -> int:
|
||||
config = load_config(config_path)
|
||||
settings = config.get("settings", {}) or {}
|
||||
if not isinstance(settings, dict):
|
||||
raise ConfigError("settings 必须是对象")
|
||||
timeout = float(settings.get("timeout", 20))
|
||||
retries = int(settings.get("retries", 2))
|
||||
max_bytes = int(settings.get("max_feed_bytes", 10 * 1024 * 1024))
|
||||
allow_partial = settings.get("allow_partial", True)
|
||||
if timeout <= 0 or retries < 0 or max_bytes <= 0:
|
||||
raise ConfigError("settings.timeout 和 max_feed_bytes 必须大于 0,retries 不能小于 0")
|
||||
if not isinstance(allow_partial, bool):
|
||||
raise ConfigError("settings.allow_partial 必须是 true 或 false")
|
||||
user_agent = clean_text(settings.get("user_agent")) or (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
groups: dict[str, dict[str, Any]] = config["groups"]
|
||||
repo_root = config_path.resolve().parent
|
||||
all_sources: dict[str, Source] = {}
|
||||
group_urls: dict[str, list[str]] = {}
|
||||
output_paths: dict[str, Path] = {}
|
||||
claimed_outputs: dict[Path, str] = {}
|
||||
for group_name, group in groups.items():
|
||||
sources = parse_sources(group["sources"], group_name)
|
||||
group_urls[group_name] = [source.url for source in sources]
|
||||
output = safe_output_path(repo_root, group_name, group)
|
||||
if output in claimed_outputs:
|
||||
raise ConfigError(f"分组 {group_name!r} 和 {claimed_outputs[output]!r} 使用了同一个 output")
|
||||
claimed_outputs[output] = group_name
|
||||
output_paths[group_name] = output
|
||||
for source in sources:
|
||||
all_sources.setdefault(source.url, source)
|
||||
|
||||
session = make_session(user_agent, retries)
|
||||
fetched: dict[str, list[FeedItem]] = {}
|
||||
errors: dict[str, str] = {}
|
||||
for url, source in all_sources.items():
|
||||
try:
|
||||
fetched[url] = fetch_feed(session, source, timeout, max_bytes)
|
||||
LOG.info("已获取 %s(%d 条)", url, len(fetched[url]))
|
||||
except Exception as exc: # One source should produce a useful aggregated error.
|
||||
errors[url] = str(exc)
|
||||
LOG.error("获取失败 %s: %s", url, exc)
|
||||
|
||||
if errors and not allow_partial:
|
||||
LOG.error("有 %d 个源失败;为避免临时丢失条目,本次不改写输出。可设置 settings.allow_partial: true 改变此行为。", len(errors))
|
||||
return 1
|
||||
|
||||
changed = 0
|
||||
generated = 0
|
||||
for group_name, group in groups.items():
|
||||
available_urls = [url for url in group_urls[group_name] if url in fetched]
|
||||
if not available_urls:
|
||||
LOG.error("分组 %s 没有成功获取的源,跳过输出", group_name)
|
||||
continue
|
||||
generated += 1
|
||||
items = [item for url in available_urls for item in fetched[url]]
|
||||
merged = sort_and_limit(items, int(group.get("max_items", 200)))
|
||||
output = output_paths[group_name]
|
||||
if write_if_changed(output, build_rss(group_name, group, merged)):
|
||||
changed += 1
|
||||
LOG.info("已生成 %s(%d 条)", output, len(merged))
|
||||
else:
|
||||
LOG.info("无变化 %s(%d 条)", output, len(merged))
|
||||
if generated == 0:
|
||||
LOG.error("没有任何分组成功生成")
|
||||
return 1
|
||||
LOG.info("完成:%d 个文件有变化", changed)
|
||||
return 0 if not errors or allow_partial else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=Path("feeds.yml"), help="配置文件路径(默认:feeds.yml)")
|
||||
parser.add_argument("--verbose", action="store_true", help="显示调试日志")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(levelname)s %(message)s")
|
||||
try:
|
||||
return run(args.config)
|
||||
except ConfigError as exc:
|
||||
LOG.error("配置错误:%s", exc)
|
||||
return 2
|
||||
except Exception:
|
||||
LOG.exception("生成 RSS 时发生未处理错误")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
feedparser>=6.0,<7
|
||||
PyYAML>=6.0,<7
|
||||
requests>=2.31,<3
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from merge import FeedItem, build_rss, sort_and_limit
|
||||
|
||||
|
||||
def item(title, link, hour, description=""):
|
||||
return FeedItem(
|
||||
title=title,
|
||||
link=link,
|
||||
guid=link,
|
||||
published=datetime(2026, 1, 1, hour, tzinfo=timezone.utc),
|
||||
description=description,
|
||||
source_title="Test Feed",
|
||||
source_url="https://example.com/feed.xml",
|
||||
)
|
||||
|
||||
|
||||
class MergeTests(unittest.TestCase):
|
||||
def test_sorts_keeps_duplicates_and_limits(self):
|
||||
values = [
|
||||
item("Older", "https://example.com/old", 1),
|
||||
item("Newest", "https://example.com/new", 3),
|
||||
item("Duplicate title", "https://example.com/new#comments", 2),
|
||||
]
|
||||
merged = sort_and_limit(values, 2)
|
||||
self.assertEqual([value.title for value in merged], ["Newest", "Duplicate title"])
|
||||
|
||||
def test_builds_valid_rss(self):
|
||||
group = {
|
||||
"title": "Merged",
|
||||
"sources": ["https://example.com/feed.xml"],
|
||||
"self_url": "https://example.com/all.xml",
|
||||
}
|
||||
document = build_rss("all", group, [item("Hello", "https://example.com/hello", 1, "<p>Body</p>")])
|
||||
root = ET.fromstring(document)
|
||||
self.assertEqual(root.tag, "rss")
|
||||
self.assertEqual(root.findtext("channel/title"), "Merged")
|
||||
self.assertEqual(root.findtext("channel/item/title"), "Hello")
|
||||
self.assertEqual(root.findtext("channel/item/description"), "<p>Body</p>")
|
||||
self.assertEqual(root.find("channel/item/source").get("url"), "https://example.com/feed.xml")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user