"""抓取并整理国际 / 国内 AI 时讯 RSS。""" from __future__ import annotations import logging import re import time import html import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone, timedelta from email.utils import parsedate_to_datetime from typing import Any from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import certifi import httpx from daily.config import env, env_int, news_summary_limit from daily.news.content_filter import filter_news_items from daily.news.feeds import NEWS_CATEGORIES, NewsCategory, NewsFeed from daily.news.feeds_cn import CN_AI_TITLE_KEYWORDS, CN_NEWS_CATEGORIES logger = logging.getLogger(__name__) USER_AGENT = "Mozilla/5.0 (compatible; daily-robots/1.0; +https://skills.sh)" BROWSER_USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/131.0.0.0 Safari/537.36" ) STRIP_HTML = re.compile(r"<[^>]+>") WS = re.compile(r"\s+") ATOM_NS = {"a": "http://www.w3.org/2005/Atom"} RSS_NS = {"r": "http://purl.org/rss/1.0/modules/content/"} def _enabled() -> bool: raw = (env("DAILY_AI_NEWS") or "1").strip().lower() return raw not in {"0", "false", "no", "off"} def _cn_enabled() -> bool: raw = (env("DAILY_CN_AI_NEWS") or "1").strip().lower() return raw not in {"0", "false", "no", "off"} def _hours_window() -> int: return max(1, env_int("DAILY_AI_NEWS_HOURS", 72)) def _per_feed_limit() -> int: return max(1, env_int("DAILY_AI_NEWS_PER_FEED", 3)) def _per_category_limit() -> int: return max(1, env_int("DAILY_AI_NEWS_PER_CATEGORY", 5)) def _wecom_limit() -> int: return max(1, env_int("DAILY_WECOM_AI_NEWS", 10)) def _wecom_cn_limit() -> int: return max(1, env_int("DAILY_WECOM_CN_AI_NEWS", 8)) def _matches_cn_ai_title(title: str) -> bool: text = title.strip() if not text: return False lower = text.lower() for keyword in CN_AI_TITLE_KEYWORDS: if keyword.lower() in lower: return True return False def _filter_ai_entries(entries: list[dict[str, Any]], *, ai_filter: bool) -> list[dict[str, Any]]: if not ai_filter: return entries return [item for item in entries if _matches_cn_ai_title(item.get("title", ""))] def _now_utc() -> datetime: return datetime.now(timezone.utc) def _parse_datetime(value: str | None) -> datetime | None: if not value: return None text = value.strip() if not text: return None try: dt = parsedate_to_datetime(text) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) except (TypeError, ValueError, OverflowError): pass for fmt in ( "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d", ): try: dt = datetime.strptime(text[: len(fmt.replace("%z", "+0000"))], fmt.replace("%z", "")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) except ValueError: continue return None def _clean_text(text: str | None, limit: int = 200) -> str: if not text: return "" plain = STRIP_HTML.sub(" ", html.unescape(text)) plain = WS.sub(" ", plain).strip() if limit <= 0 or len(plain) <= limit: return plain return plain[: limit - 3] + "..." def _normalize_link(link: str) -> str: parsed = urlparse(link.strip()) query = parse_qs(parsed.query, keep_blank_values=False) for key in list(query.keys()): if key.lower().startswith("utm_") or key.lower() in {"ref", "source"}: query.pop(key, None) clean_query = urlencode({k: v[0] for k, v in query.items() if v}, doseq=False) return urlunparse((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", clean_query, "")) def _normalize_title(title: str) -> str: return WS.sub(" ", title.strip().lower()) def _entry_datetime(entry: dict[str, Any]) -> datetime | None: for key in ("published", "updated"): dt = _parse_datetime(entry.get(key)) if dt: return dt return None def _parse_atom(content: str, feed_name: str, category: NewsCategory) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] try: root = ET.fromstring(content) except ET.ParseError: return items for entry in root.findall("a:entry", ATOM_NS): title_el = entry.find("a:title", ATOM_NS) link_el = entry.find("a:link", ATOM_NS) summary_el = entry.find("a:summary", ATOM_NS) or entry.find("a:content", ATOM_NS) updated_el = entry.find("a:updated", ATOM_NS) or entry.find("a:published", ATOM_NS) title = title_el.text.strip() if title_el is not None and title_el.text else "" link = "" if link_el is not None: link = link_el.get("href") or (link_el.text or "").strip() if not title or not link: continue items.append( { "title": title, "link": link, "summary": _clean_text(summary_el.text if summary_el is not None else ""), "published": updated_el.text.strip() if updated_el is not None and updated_el.text else "", "source_name": feed_name, "category_id": category.id, "category_name": category.name, "category_icon": category.icon, } ) return items def _parse_rss(content: str, feed_name: str, category: NewsCategory) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] try: root = ET.fromstring(content) except ET.ParseError: return items channel = root.find("channel") if channel is None: return items for item in channel.findall("item"): title_el = item.find("title") link_el = item.find("link") desc_el = item.find("description") pub_el = item.find("pubDate") title = title_el.text.strip() if title_el is not None and title_el.text else "" link = link_el.text.strip() if link_el is not None and link_el.text else "" if not title or not link: continue items.append( { "title": title, "link": link, "summary": _clean_text(desc_el.text if desc_el is not None else ""), "published": pub_el.text.strip() if pub_el is not None and pub_el.text else "", "source_name": feed_name, "category_id": category.id, "category_name": category.name, "category_icon": category.icon, } ) return items def _parse_feed(content: str, feed_name: str, category: NewsCategory) -> list[dict[str, Any]]: text = content.lstrip("\ufeff").strip() if not text: return [] if text.startswith("" in text[:500]: return _parse_rss(text, feed_name, category) if text.startswith("" in text[:500]: return _parse_atom(text, feed_name, category) if "" in text[:2000]: return _parse_rss(text, feed_name, category) return _parse_atom(text, feed_name, category) def _is_reddit_url(url: str) -> bool: host = urlparse(url).netloc.lower() return host.endswith("reddit.com") def _reddit_auth_params() -> dict[str, str]: user = (env("REDDIT_RSS_USER") or "").strip() feed = (env("REDDIT_RSS_FEED") or "").strip() if user and feed: return {"user": user, "feed": feed} return {} def _with_query_params(url: str, extra: dict[str, str]) -> str: if not extra: return url parsed = urlparse(url) query = parse_qs(parsed.query, keep_blank_values=True) for key, value in extra.items(): if value and key not in query: query[key] = [value] clean_query = urlencode({k: v[0] for k, v in query.items() if v and v[0]}, doseq=False) return urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", clean_query, "")) def _reddit_old_url(url: str) -> str: parsed = urlparse(url) host = parsed.netloc.lower() if host.startswith("old."): return url if host in {"www.reddit.com", "reddit.com"}: return urlunparse((parsed.scheme, "old.reddit.com", parsed.path, "", parsed.query, "")) return url def _request_headers(base: dict[str, str], url: str) -> dict[str, str]: if not _is_reddit_url(url): return base return { **base, "User-Agent": BROWSER_USER_AGENT, "Accept-Language": "en-US,en;q=0.9", } def _reddit_fetch_urls(feed_url: str) -> list[str]: primary = _with_query_params(feed_url, _reddit_auth_params()) if not _is_reddit_url(primary): return [primary] fallback = _reddit_old_url(primary) if fallback == primary: return [primary] return [primary, fallback] def _fetch_one( client: httpx.Client, category: NewsCategory, feed: NewsFeed, ) -> tuple[list[dict[str, Any]], bool]: last_exc: Exception | None = None for url in _reddit_fetch_urls(feed.url): try: headers = _request_headers(dict(client.headers), url) resp = client.get(url, headers=headers) resp.raise_for_status() entries = _parse_feed(resp.text, feed.name, category) return _filter_ai_entries(entries, ai_filter=feed.ai_filter), True except Exception as exc: last_exc = exc continue logger.warning("RSS fetch failed [%s] %s: %s", feed.name, feed.url, last_exc) return [], False def _dedupe_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: seen_links: set[str] = set() seen_titles: set[str] = set() result: list[dict[str, Any]] = [] for item in items: link_key = _normalize_link(item["link"]) title_key = _normalize_title(item["title"]) if link_key in seen_links or title_key in seen_titles: continue seen_links.add(link_key) seen_titles.add(title_key) result.append(item) return result def _within_window(item: dict[str, Any], cutoff: datetime) -> bool: dt = _entry_datetime(item) if dt is None: return True return dt >= cutoff def _sort_key(item: dict[str, Any]) -> tuple[int, datetime]: dt = _entry_datetime(item) if dt is None: return (1, datetime.min.replace(tzinfo=timezone.utc)) return (0, dt) def _fetch_news(categories: tuple[NewsCategory, ...]) -> dict[str, Any]: hours = _hours_window() per_feed = _per_feed_limit() per_category = _per_category_limit() cutoff = _now_utc() - timedelta(hours=hours) headers = {"User-Agent": USER_AGENT, "Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*"} tasks: list[tuple[NewsCategory, NewsFeed]] = [] for category in categories: for feed in category.feeds: tasks.append((category, feed)) raw_by_category: dict[str, list[dict[str, Any]]] = {c.id: [] for c in categories} stats: dict[str, Any] = { "feeds_total": len(tasks), "feeds_ok": 0, "items_raw": 0, "feeds_failed": [], "content_filtered": 0, } with httpx.Client(timeout=15.0, verify=certifi.where(), follow_redirects=True, headers=headers) as client: fast_tasks = [t for t in tasks if not t[1].slow] slow_tasks = [t for t in tasks if t[1].slow] with ThreadPoolExecutor(max_workers=8) as pool: futures = { pool.submit(_fetch_one, client, cat, feed): (cat.id, feed.name) for cat, feed in fast_tasks } for future in as_completed(futures): cat_id, feed_name = futures[future] try: entries, ok = future.result() except Exception as exc: logger.warning("RSS 任务异常 [%s]: %s", feed_name, exc) stats["feeds_failed"].append(feed_name) continue if ok: stats["feeds_ok"] += 1 else: stats["feeds_failed"].append(feed_name) stats["items_raw"] += len(entries) raw_by_category[cat_id].extend(entries[:per_feed]) for cat, feed in slow_tasks: entries, ok = _fetch_one(client, cat, feed) if ok: stats["feeds_ok"] += 1 else: stats["feeds_failed"].append(feed.name) stats["items_raw"] += len(entries) raw_by_category[cat.id].extend(entries[:per_feed]) if _is_reddit_url(feed.url): time.sleep(2.0) else: time.sleep(1.0) categories_out: list[dict[str, Any]] = [] flat: list[dict[str, Any]] = [] for category in categories: items = raw_by_category[category.id] items = [i for i in items if _within_window(i, cutoff)] items, filtered_count = filter_news_items(items) if filtered_count: stats["content_filtered"] = stats.get("content_filtered", 0) + filtered_count items.sort(key=_sort_key, reverse=True) items = _dedupe_items(items)[:per_category] for item in items: dt = _entry_datetime(item) item["published_fmt"] = dt.astimezone(timezone(timedelta(hours=8))).strftime("%m-%d %H:%M") if dt else "" if items: categories_out.append( { "id": category.id, "name": category.name, "icon": category.icon, "items": items, } ) flat.extend(items) flat.sort(key=_sort_key, reverse=True) flat = _dedupe_items(flat) return { "enabled": True, "hours": hours, "categories": categories_out, "flat": flat, "stats": stats, } def fetch_ai_news() -> dict[str, Any]: """按类别抓取国际 AI 时讯,返回 {enabled, hours, categories, flat, stats}。""" if not _enabled(): return {"enabled": False, "categories": [], "flat": [], "stats": {}} return _fetch_news(NEWS_CATEGORIES) def fetch_cn_ai_news() -> dict[str, Any]: """按类别抓取国内 AI 时讯,返回 {enabled, hours, categories, flat, stats}。""" if not _cn_enabled(): return {"enabled": False, "categories": [], "flat": [], "stats": {}} return _fetch_news(CN_NEWS_CATEGORIES) def format_news_section(news: dict[str, Any], *, section_no: int, wecom_limit: int | None = None) -> list[str]: return _format_news_section( news, section_no=section_no, title="国际 AI 时讯", disabled_hint="AI 时讯已关闭(`DAILY_AI_NEWS=0`)。", wecom_limit=wecom_limit, ) def format_cn_news_section(news: dict[str, Any], *, section_no: int, wecom_limit: int | None = None) -> list[str]: return _format_news_section( news, section_no=section_no, title="国内 AI 时讯", disabled_hint="国内 AI 时讯已关闭(`DAILY_CN_AI_NEWS=0`)。", wecom_limit=wecom_limit, ) def _format_news_section( news: dict[str, Any], *, section_no: int, title: str, disabled_hint: str, wecom_limit: int | None = None, ) -> list[str]: if not news.get("enabled"): return ["---", "", f"## {section_no}、{title}", "", f"*{disabled_hint}*", ""] categories = news.get("categories") or [] hours = news.get("hours", 72) lines = [ "---", "", f"## {section_no}、{title}", "", f"> 近 **{hours}h** · {news.get('stats', {}).get('feeds_ok', 0)}/{news.get('stats', {}).get('feeds_total', 0)} 源可用", "", ] failed = news.get("stats", {}).get("feeds_failed") or [] if failed: preview = "、".join(failed[:5]) suffix = "…" if len(failed) > 5 else "" lines.append(f"> ⚠️ {len(failed)} 个源抓取失败:{preview}{suffix}") lines.append("") if not categories: lines.append("*暂无可用条目(网络/RSS 源异常或时间窗口内无更新)。*") lines.append("") return lines if wecom_limit is not None: flat = (news.get("flat") or [])[:wecom_limit] for i, item in enumerate(flat, 1): pub = f" · {item['published_fmt']}" if item.get("published_fmt") else "" lines.append( f"{i}. [{item['title']}]({item['link']}) · `{item['source_name']}`{pub}" ) lines.append("") return lines for cat in categories: lines.append(f"### {cat['icon']} {cat['name']}") lines.append("") for i, item in enumerate(cat["items"], 1): pub = f" · {item['published_fmt']}" if item.get("published_fmt") else "" lines.append(f"{i}. **[{item['title']}]({item['link']})** · `{item['source_name']}`{pub}") summary = item.get("summary", "") if summary: lines.append(f" - {_clean_text(summary, news_summary_limit())}") lines.append("") return lines def _sorted_flat(news: dict[str, Any]) -> list[dict[str, Any]]: flat = list(news.get("flat") or []) flat.sort( key=lambda item: (float(item.get("score") or 0), _sort_key(item)[1]), reverse=True, ) return flat def prepare_wecom_news_items(news: dict[str, Any], *, limit: int | None = None) -> list[dict[str, Any]]: if not news.get("enabled"): return [] pick_limit = limit or _wecom_limit() flat = _sorted_flat(news) preferred = ("media", "newsletter", "official", "community", "research", "developer") picked: list[dict[str, Any]] = [] seen: set[str] = set() for cat in preferred: for item in flat: link = _normalize_link(item.get("link", "")) if item.get("category_id") != cat or link in seen: continue picked.append(item) seen.add(link) if len(picked) >= pick_limit: break if len(picked) >= pick_limit: break items: list[dict[str, Any]] = [] for item in picked[:pick_limit]: items.append( { "title": item.get("title", "?"), "link": item.get("link", ""), "source_name": item.get("source_name", "?"), "published_fmt": item.get("published_fmt", ""), "desc_short": _clean_text(item.get("summary", ""), 36), "score": item.get("score"), } ) return items def prepare_wecom_cn_news_items(news: dict[str, Any], *, limit: int | None = None) -> list[dict[str, Any]]: if not news.get("enabled"): return [] pick_limit = limit or _wecom_cn_limit() flat = _sorted_flat(news) preferred = ("media", "tech", "dev") picked: list[dict[str, Any]] = [] seen_links: set[str] = set() seen_sources: set[str] = set() for cat in preferred: for item in flat: link = _normalize_link(item.get("link", "")) source = item.get("source_name", "?") if item.get("category_id") != cat or not link or link in seen_links or source in seen_sources: continue picked.append(item) seen_links.add(link) seen_sources.add(source) if len(picked) >= pick_limit: break if len(picked) >= pick_limit: break if len(picked) < pick_limit: for item in flat: link = _normalize_link(item.get("link", "")) if not link or link in seen_links: continue picked.append(item) seen_links.add(link) if len(picked) >= pick_limit: break items: list[dict[str, Any]] = [] for item in picked[:pick_limit]: items.append( { "title": item.get("title", "?"), "link": item.get("link", ""), "source_name": item.get("source_name", "?"), "published_fmt": item.get("published_fmt", ""), "desc_short": _clean_text(item.get("summary", ""), 36), "score": item.get("score"), } ) return items