news webhook posting mode: replaces py3.8 getnews (bounded state, seed-on-first-run, ssrf-guarded)

This commit is contained in:
Oleksandr Kozachuk
2026-07-13 19:47:30 +02:00
parent 2caa18a17f
commit 7628faf551
3 changed files with 269 additions and 2 deletions
+139 -1
View File
@@ -90,6 +90,102 @@ class NewsFetcher:
return out
DEFAULT_SEEN_CAP = 5000
DEFAULT_POST_PER_FEED = 5
DEFAULT_POST_MAX_PER_RUN = 8
def item_key(item: Dict[str, str]) -> str:
return item.get("link") or item.get("title") or ""
class NewsPoster:
"""Post NEW feed items to Discord channel webhooks (ggg model, SPEC-013 NEWS-04..06)."""
def __init__(self, guard, fetch_bytes, post_webhook) -> None:
self._guard = guard
self._fetch_bytes = fetch_bytes
self._post_webhook = post_webhook
async def run_post(
self,
feeds: List[Tuple[str, str, str]],
webhooks: Dict[str, str],
seen: set,
per_feed: int,
max_per_run: int,
seed_only: bool,
) -> Tuple[int, set]:
"""Returns (posted_count, updated_seen). seed_only marks new items seen without posting."""
posted = 0
for url, label, channel in feeds:
reason = self._guard(url)
if reason:
logging.warning(f"news-post: skipping feed {label}{reason}")
continue
try:
data = await self._fetch_bytes(url)
except Exception as err:
logging.warning(f"news-post: fetch failed for {label}: {repr(err)}")
continue
for item in parse_feed(data, label)[:per_feed]:
key = item_key(item)
if not key or key in seen:
continue
seen.add(key)
may_post = not seed_only and posted < max_per_run
if may_post and await self._deliver(item, label, channel, webhooks):
posted += 1
return posted, seen
async def _deliver(self, item: Dict[str, str], label: str, channel: str, webhooks: Dict[str, str]) -> bool:
hook = webhooks.get(channel)
if not hook:
logging.warning(f"news-post: no webhook for channel {channel!r} ({label})")
return False
title = sanitize_external_text(item["title"], 300)
link = item.get("link", "")
content = f"**[{label}]** {title}" + (f"\n{link}" if link else "")
try:
await self._post_webhook(hook, content)
return True
except Exception as err:
logging.warning(f"news-post: webhook post failed ({label}): {repr(err)}")
return False
def load_seen(path: str) -> Tuple[set, bool]:
"""(seen-set, existed). Missing/broken state -> empty set, existed=False (seed run)."""
import json
import os
if not os.path.exists(path):
return set(), False
try:
with open(path, encoding="utf-8") as fd:
return set(json.load(fd)), True
except Exception as err:
logging.warning(f"news-post: unreadable state {path}: {err!r} — reseeding")
return set(), False
def save_seen(path: str, seen: set, cap: int = DEFAULT_SEEN_CAP) -> None:
import json
# keep the newest `cap` keys (insertion order preserved by Python sets? no — use a bounded slice)
keys = list(seen)[-cap:]
with open(path, "w", encoding="utf-8") as fd:
json.dump(keys, fd)
def _post_feeds_from_config(config: Dict[str, Any]) -> List[Tuple[str, str, str]]:
feeds = []
for entry in config.get("news-post-feeds", []):
if isinstance(entry, (list, tuple)) and len(entry) >= 3:
feeds.append((str(entry[0]), str(entry[1]), str(entry[2])))
return feeds
def _feeds_from_config(config: Dict[str, Any]) -> List[Tuple[str, str]]:
"""news-feeds = [["url", "label"], ...] or ["url", ...]."""
feeds = []
@@ -113,6 +209,42 @@ async def _aiohttp_fetch(url: str) -> bytes:
return await read_capped(response, 4 * 1024 * 1024)
async def _aiohttp_post(hook: str, content: str) -> None:
import aiohttp
timeout = aiohttp.ClientTimeout(total=FETCH_TIMEOUT_S)
async with aiohttp.ClientSession(timeout=timeout) as session:
# allowed_mentions none: a headline can never ping the channel (SAF-02 spirit)
payload = {"content": content[:2000], "allowed_mentions": {"parse": []}}
async with session.post(hook, json=payload) as response:
response.raise_for_status()
async def run_post(config: Dict[str, Any]) -> int:
"""Webhook-posting mode (ggg): post new items to channels. Returns posted count."""
from .url_reader import guard_url
webhooks = dict(config.get("news-post-webhooks", {}))
feeds = _post_feeds_from_config(config)
state_path = config.get("news-post-state", "news_state.json")
if not webhooks or not feeds:
logging.error("news-post: need news-post-webhooks and news-post-feeds")
return 0
seen, existed = load_seen(state_path)
poster = NewsPoster(guard_url, _aiohttp_fetch, _aiohttp_post)
posted, seen = await poster.run_post(
feeds,
webhooks,
seen,
int(config.get("news-post-per-feed", DEFAULT_POST_PER_FEED)),
int(config.get("news-post-max-per-run", DEFAULT_POST_MAX_PER_RUN)),
seed_only=not existed, # first run seeds without flooding the channels
)
save_seen(state_path, seen, int(config.get("news-post-seen-cap", DEFAULT_SEEN_CAP)))
logging.info(f"news-post: posted {posted} item(s)" + (" (seed run — nothing posted)" if not existed else ""))
return posted
async def run(config: Dict[str, Any]) -> Optional[str]:
from .url_reader import guard_url
@@ -140,11 +272,17 @@ def main() -> int:
import tomlkit
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
parser = argparse.ArgumentParser(description="Fetch RSS/Atom feeds into the {news} digest file")
parser = argparse.ArgumentParser(
description="Fetch RSS/Atom feeds: --post to channel webhooks (ggg) or default {news} digest file (kroa)"
)
parser.add_argument("--config", required=True)
parser.add_argument("--post", action="store_true", help="webhook-posting mode (post new items to Discord channels)")
args = parser.parse_args()
with open(args.config, encoding="utf-8") as fd:
config = tomlkit.load(fd)
if args.post:
asyncio.run(run_post(config))
return 0
result = asyncio.run(run(config))
return 0 if result else 1