news webhook posting mode: replaces py3.8 getnews (bounded state, seed-on-first-run, ssrf-guarded)
This commit is contained in:
+139
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user