Files
2026-07-13 12:36:27 +08:00

430 lines
15 KiB
Python

from __future__ import annotations
import re
import os
import collections
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Any
import json
import time
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _workspace_root() -> Path:
return Path(__file__).resolve().parents[3]
def watchlist_path() -> Path:
return _workspace_root() / "Claude_input" / "x资讯" / "关注列表.md"
def brief_config_path() -> Path:
return _workspace_root() / "Claude_input" / "x资讯" / "简报配置.md"
def x_input_dir() -> Path:
return _workspace_root() / "Claude_input" / "x资讯"
def x_cache_dir() -> Path:
return _repo_root() / "data" / "x_brief"
def latest_json_path() -> Path:
return x_cache_dir() / "latest.json"
def latest_raw_path() -> Path:
return x_cache_dir() / "latest_raw.json"
def _clean_handle(raw: str) -> str:
token = raw.strip()
if token.startswith("@"):
token = token[1:]
# 兼容历史写法/旧 handle,统一映射到当前可用 handle。
aliases = {
"andrejkarpathy": "karpathy",
}
token = aliases.get(token.lower(), token)
return token
def parse_watchers_from_markdown(md_text: str) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for line in md_text.splitlines():
if "|" not in line or line.count("|") < 5:
continue
cells = [c.strip() for c in line.strip().split("|")]
if len(cells) < 4:
continue
# 兼容两种格式:
# 1) 关注列表.md:| 序号 | 账号 | 名称 | 简介 | 当前分 |
# 2) 简报配置.md:| 账号 | 名称 | 当前权重 | 偏好内容类型 | 备注 |
account_idx = -1
for i, c in enumerate(cells):
if c.startswith("@"):
account_idx = i
break
if account_idx < 0:
continue
account_cell = cells[account_idx]
name_cell = ""
if account_idx + 1 < len(cells):
name_cell = cells[account_idx + 1]
handle = _clean_handle(account_cell)
if not re.fullmatch(r"[A-Za-z0-9_]+", handle):
rows.append(
{
"handle": handle,
"account": account_cell,
"name": name_cell,
"category": "",
"score": cells[6] if len(cells) > 6 else "",
"skip_reason": "invalid_handle_for_nitter",
}
)
continue
rows.append(
{
"handle": handle,
"account": f"@{handle}",
"name": name_cell,
"category": "",
"score": cells[6] if len(cells) > 6 else "",
"skip_reason": "",
}
)
return rows
def load_watchers() -> list[dict[str, Any]]:
cfg = brief_config_path()
if cfg.exists():
rows = parse_watchers_from_markdown(cfg.read_text(encoding="utf-8"))
if rows:
return rows
p = watchlist_path()
if not p.exists():
raise RuntimeError(f"关注列表不存在: {p}")
return parse_watchers_from_markdown(p.read_text(encoding="utf-8"))
def _fetch_url(url: str, timeout: int = 12) -> bytes:
req = urllib.request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0 (compatible; TradingAgents-XBrief/1.0)",
"Accept": "application/rss+xml,application/xml;q=0.9,*/*;q=0.8",
},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
def _parse_rss_items(xml_bytes: bytes) -> list[dict[str, Any]]:
root = ET.fromstring(xml_bytes)
out: list[dict[str, Any]] = []
for item in root.findall("./channel/item"):
title = (item.findtext("title") or "").strip()
link = (item.findtext("link") or "").strip()
pub = (item.findtext("pubDate") or "").strip()
if not link:
continue
dt: datetime | None = None
if pub:
try:
dt = parsedate_to_datetime(pub)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
except Exception:
dt = None
out.append(
{
"title": title,
"link": link,
"published_at": dt.isoformat() if dt else "",
"published_at_obj": dt,
}
)
return out
def _extract_tweet_id(link: str) -> str:
m = re.search(r"/status/(\d+)", link)
return m.group(1) if m else link
def _to_x_post_url(link: str, handle: str) -> str:
link = (link or "").strip()
if not link:
return ""
tweet_id = _extract_tweet_id(link)
if tweet_id and re.fullmatch(r"\d+", tweet_id):
return f"https://x.com/{handle}/status/{tweet_id}"
# 非标准链接时仅做域名替换兜底
return re.sub(r"^https?://nitter\.[^/]+", "https://x.com", link)
def normalize_to_x_url(link: str, handle: str = "") -> str:
"""
对外暴露的 URL 规范化函数:
- 优先产出 x.com/<handle>/status/<id>
- 无法提取状态 ID 时做 nitter 域名替换兜底
"""
return _to_x_post_url(link, handle)
def fetch_nitter_rss_for_handle(handle: str) -> list[dict[str, Any]]:
mirrors = [
f"https://nitter.net/{handle}/rss",
f"https://nitter.privacydev.net/{handle}/rss",
]
last_error: str = "unknown_error"
for url in mirrors:
try:
body = _fetch_url(url)
return _parse_rss_items(body)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ET.ParseError) as exc:
last_error = f"{type(exc).__name__}: {exc}"
continue
raise RuntimeError(last_error)
def fetch_rsshub_for_handle(handle: str) -> list[dict[str, Any]]:
mirrors = [
f"https://rsshub.app/twitter/user/{handle}",
f"https://rsshub.rssforever.com/twitter/user/{handle}",
]
last_error: str = "unknown_error"
for url in mirrors:
try:
body = _fetch_url(url)
return _parse_rss_items(body)
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ET.ParseError) as exc:
last_error = f"{type(exc).__name__}: {exc}"
continue
raise RuntimeError(last_error)
def fetch_snscrape_for_handle(handle: str) -> list[dict[str, Any]]:
try:
from snscrape.modules.twitter import TwitterUserScraper # type: ignore
except Exception as exc:
raise RuntimeError(f"snscrape_unavailable: {exc}") from exc
out: list[dict[str, Any]] = []
try:
scraper = TwitterUserScraper(handle)
for tw in scraper.get_items():
dt = getattr(tw, "date", None)
if dt is not None:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
url = getattr(tw, "url", "") or ""
out.append(
{
"title": (getattr(tw, "rawContent", None) or getattr(tw, "content", None) or "").strip(),
"link": url,
"published_at": dt.isoformat() if isinstance(dt, datetime) else "",
"published_at_obj": dt if isinstance(dt, datetime) else None,
}
)
if len(out) >= 300:
break
except Exception as exc:
raise RuntimeError(f"snscrape_error: {exc}") from exc
return out
def _parse_source_priority() -> list[str]:
raw = os.getenv("X_SOURCE_PRIORITY", "nitter,snscrape,rsshub")
items = [x.strip().lower() for x in raw.split(",") if x.strip()]
allowed = {"nitter", "snscrape", "rsshub"}
ordered = [x for x in items if x in allowed]
if not ordered:
ordered = ["nitter", "snscrape", "rsshub"]
# dedup keep order
seen: set[str] = set()
out: list[str] = []
for x in ordered:
if x not in seen:
out.append(x)
seen.add(x)
return out
def _fetch_with_priority(handle: str, source_priority: list[str]) -> tuple[str, list[dict[str, Any]], dict[str, str]]:
errors: dict[str, str] = {}
for source in source_priority:
try:
if source == "nitter":
return source, fetch_nitter_rss_for_handle(handle), errors
if source == "snscrape":
return source, fetch_snscrape_for_handle(handle), errors
if source == "rsshub":
return source, fetch_rsshub_for_handle(handle), errors
errors[source] = "unsupported_source"
except Exception as exc:
errors[source] = str(exc)
continue
return "none", [], errors
def _collect_x_rss(days: int, per_account_limit: int) -> dict[str, Any]:
watchers = load_watchers()
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
all_tweets: list[dict[str, Any]] = []
account_stats: list[dict[str, Any]] = []
seen: set[str] = set()
source_priority = _parse_source_priority()
source_hits: collections.Counter[str] = collections.Counter()
source_errors: collections.Counter[str] = collections.Counter()
t0 = time.perf_counter()
valid_watchers = [w for w in watchers if not w.get("skip_reason")]
print(
f"[xbrief] fetch-start mode=weekly days={days} watchers={len(watchers)} "
f"valid={len(valid_watchers)} per_account_limit={per_account_limit} sources={'>'.join(source_priority)}"
)
for w in watchers:
handle = w["handle"]
if w.get("skip_reason"):
account_stats.append(
{
**w,
"status": "skipped",
"tweets_count": 0,
"error": w["skip_reason"],
}
)
continue
try:
source, items, errors = _fetch_with_priority(handle, source_priority)
if source == "none":
raise RuntimeError("; ".join([f"{k}={v}" for k, v in errors.items()]) or "all_sources_failed")
kept = 0
for it in items:
dt = it.get("published_at_obj")
if not isinstance(dt, datetime) or dt < cutoff:
continue
tweet_id = _extract_tweet_id(it.get("link", ""))
dedup_key = f"{handle}:{tweet_id}"
if dedup_key in seen:
continue
seen.add(dedup_key)
all_tweets.append(
{
"handle": handle,
"account": w["account"],
"name": w["name"],
"title": it.get("title", ""),
"url": _to_x_post_url(str(it.get("link", "")), handle),
"published_at": dt.isoformat(),
"source": source,
}
)
kept += 1
if kept >= per_account_limit:
break
source_hits[source] += 1
account_stats.append(
{
**w,
"status": "ok",
"tweets_count": kept,
"error": "",
"source": source,
}
)
except Exception as exc:
err_s = str(exc)
for s in source_priority:
if f"{s}=" in err_s or s in err_s:
source_errors[s] += 1
account_stats.append(
{
**w,
"status": "error",
"tweets_count": 0,
"error": err_s,
"source": "none",
}
)
all_tweets.sort(key=lambda x: x.get("published_at", ""), reverse=True)
active_accounts = len([x for x in account_stats if x.get("tweets_count", 0) > 0])
error_accounts = [x for x in account_stats if x.get("status") == "error"]
elapsed_ms = int((time.perf_counter() - t0) * 1000)
sample_fail = ",".join(x.get("handle", "") for x in error_accounts[:3]) or "-"
hit_ratio = ",".join([f"{k}:{v}" for k, v in source_hits.items()]) or "-"
err_ratio = ",".join([f"{k}:{v}" for k, v in source_errors.items()]) or "-"
print(
f"[xbrief] fetch-done mode=weekly active={active_accounts}/{len(account_stats)} "
f"tweets={len(all_tweets)} failed={len(error_accounts)} elapsed_ms={elapsed_ms} "
f"source_hits={hit_ratio} source_errors={err_ratio} fail_sample={sample_fail}"
)
period_end = datetime.now(timezone.utc)
period_start = cutoff
month_tag = period_end.strftime("%Y%m")
return {
"generated_at": period_end.isoformat(),
"report_type": "weekly",
"input": {
"watchlist_path": str(brief_config_path() if brief_config_path().exists() else watchlist_path()),
"watchers": [{"handle": w.get("handle", ""), "name": w.get("name", "")} for w in watchers],
"days": days,
"per_account_limit": per_account_limit,
},
"output": {
"description": "关注博主近一周推文",
"tweet_window_days": days,
"tweet_count": len(all_tweets),
},
"period": {
"days": days,
"start_utc": period_start.isoformat(),
"end_utc": period_end.isoformat(),
"month_tag": month_tag,
},
"overview": {
"total_accounts": len(account_stats),
"active_accounts": active_accounts,
"total_tweets": len(all_tweets),
"failed_accounts": len([x for x in account_stats if x.get("status") == "error"]),
"source_hits": dict(source_hits),
"source_errors": dict(source_errors),
},
"accounts": account_stats,
"tweets": all_tweets,
}
def collect_x_rss_last_week(days: int = 7, per_account_limit: int = 80) -> dict[str, Any]:
# 周报默认 7 天,允许调用方传更短/更长窗口。
return _collect_x_rss(days=days, per_account_limit=per_account_limit)
def collect_x_rss_last_month(days: int = 30, per_account_limit: int = 80) -> dict[str, Any]:
# 兼容旧调用;内部统一走同一数据请求链路。
return _collect_x_rss(days=days, per_account_limit=per_account_limit)
def save_raw_payload(payload: dict[str, Any]) -> None:
cache_dir = x_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
latest_raw_path().write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")