daily 自建 Cursor bridge;DAILY_LLM_PROVIDER 控制后端;config/feeds.yaml 与 sensitive_words 可配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
611 lines
20 KiB
Python
611 lines
20 KiB
Python
"""抓取并整理国际 / 国内 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("<rss") or "<channel>" in text[:500]:
|
|
return _parse_rss(text, feed_name, category)
|
|
if text.startswith("<feed") or "<entry>" in text[:500]:
|
|
return _parse_atom(text, feed_name, category)
|
|
if "<item>" 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
|