Files
daily-robots/daily/skills_board.py
yumao 6ea2a4e4c6 feat: 早报系统重构与功能增强
- 新增常驻调度器 daily/scheduler.py + run-scheduler.ps1(定时生成/推送)
- 新增 daily/bridge_manager.py:Windows 兼容的 Cursor SDK 桥接
- 新增 skills/daily-featured-pick 首推 Skill 与叙事轴/去重逻辑
- 新闻抓取窗口、GitHub 搜索、企微 delta 模式等多项改进
- 补充设计文档与 superpowers 计划/规范
- 新增对应测试(scheduler、featured_pick、github_search、news_fetch_window 等)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:12:00 +08:00

198 lines
6.3 KiB
Python

"""从 skills.sh 官网抓取 Trending / Hot 完整榜单(突破 feed.json 50 条限制)。"""
from __future__ import annotations
import json
import logging
import re
import time
from typing import Any, Literal
import certifi
import httpx
from daily.config import CACHE_DIR, env
logger = logging.getLogger(__name__)
Board = Literal["trending", "hot"]
SKILLS_SITE = "https://www.skills.sh"
USER_AGENT = "Mozilla/5.0 (compatible; skills-hot-daily/1.0; +https://skills.sh)"
FEED_URLS = [
"https://cdn.jsdelivr.net/gh/NeverSight/skills.sh_feed@main/data/feed.json",
"https://raw.githubusercontent.com/NeverSight/skills.sh_feed/main/data/feed.json",
]
FEED_CACHE_TTL = 600
FEED_CACHE_FILE = CACHE_DIR / "feed.json"
_feed_cache: dict[str, Any] = {"data": None, "fetched_at": 0.0}
def format_installs(n: int | float) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if n >= 1_000:
return f"{n / 1_000:.1f}K"
return str(int(n))
def _fetch_feed_json(url: str) -> dict[str, Any]:
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
with httpx.Client(
timeout=httpx.Timeout(20.0, connect=10.0),
verify=certifi.where(),
follow_redirects=True,
) as client:
resp = client.get(url, headers=headers)
resp.raise_for_status()
return resp.json()
def _load_feed_disk_cache() -> dict[str, Any] | None:
if not FEED_CACHE_FILE.exists():
return None
try:
return json.loads(FEED_CACHE_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("读取 feed 本地缓存失败: %s", exc)
return None
def _save_feed_disk_cache(data: dict[str, Any]) -> None:
FEED_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
FEED_CACHE_FILE.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def load_feed(force: bool = False) -> dict[str, Any]:
now = time.time()
if not force and _feed_cache["data"] and now - _feed_cache["fetched_at"] < FEED_CACHE_TTL:
return _feed_cache["data"]
errors: list[str] = []
for url in FEED_URLS:
for attempt in range(3):
try:
data = _fetch_feed_json(url)
_feed_cache["data"] = data
_feed_cache["fetched_at"] = now
_save_feed_disk_cache(data)
logger.info("skills feed 已更新: %s", url)
return data
except Exception as exc:
msg = f"{url} (#{attempt + 1}): {exc}"
errors.append(msg)
logger.debug("拉取失败 %s", msg)
time.sleep(0.5 * (attempt + 1))
stale = _load_feed_disk_cache()
if stale:
logger.warning("网络不可用,回退到 feed 本地缓存")
_feed_cache["data"] = stale
_feed_cache["fetched_at"] = now
return stale
raise RuntimeError(f"无法获取 skills 数据。最近错误: {errors[-1] if errors else 'unknown'}")
_SKILL_RE = re.compile(
r'\{"source":"(?P<source>[^"]+)","skillId":"(?P<skill_id>[^"]+)",'
r'"name":"(?P<name>[^"]+)","installs":(?P<installs>\d+)'
)
_RSC_CHUNK_RE = re.compile(r"self\.__next_f\.push\(\[1,\"(.*?)\"\]\)", re.DOTALL)
def board_source() -> str:
return (env("SKILLS_BOARD_SOURCE") or "website").strip().lower()
def _fetch_html(path: str) -> str:
url = f"{SKILLS_SITE}{path}"
headers = {"User-Agent": USER_AGENT, "Accept": "text/html"}
with httpx.Client(timeout=30.0, verify=certifi.where(), follow_redirects=True) as client:
resp = client.get(url, headers=headers)
resp.raise_for_status()
return resp.text
def _rsc_blob(html: str) -> str:
chunks = _RSC_CHUNK_RE.findall(html)
blob = "\n".join(chunks)
return blob.encode("utf-8").decode("unicode_escape", errors="ignore")
def _parse_initial_skills(blob: str, *, limit: int) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
seen: set[str] = set()
for match in _SKILL_RE.finditer(blob):
source = match.group("source")
skill_id = match.group("skill_id")
uid = f"{source}/{skill_id}"
if uid in seen:
continue
seen.add(uid)
items.append(
{
"id": uid,
"title": skill_id,
"source": source,
"installs": int(match.group("installs")),
"link": f"{SKILLS_SITE}/{source}/{skill_id}",
"description": "",
}
)
if len(items) >= limit:
break
return items
def fetch_board(board: Board, *, limit: int) -> list[dict[str, Any]]:
path = "/trending" if board == "trending" else "/hot"
try:
html = _fetch_html(path)
items = _parse_initial_skills(_rsc_blob(html), limit=limit)
if items:
logger.info("skills.sh %s: %d items (limit=%d)", board, len(items), limit)
return items
except Exception as exc:
logger.warning("skills.sh %s fetch failed, fallback to feed.json: %s", board, exc)
return []
def enrich_from_feed(items: list[dict[str, Any]], feed: dict[str, Any]) -> None:
desc_by_id: dict[str, str] = {}
for key in ("topTrending", "topHot", "topAllTime"):
for row in feed.get(key, []):
uid = str(row.get("id") or f"{row.get('source')}/{row.get('title')}")
desc = (row.get("description") or "").strip()
if desc:
desc_by_id[uid] = desc
for item in items:
if not item.get("description"):
item["description"] = desc_by_id.get(item["id"], "")
def load_boards(
feed: dict[str, Any],
*,
trending_limit: int,
hot_limit: int,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""优先 skills.sh 官网;失败时回退 feed.json。"""
if board_source() == "feed":
return (
list(feed.get("topTrending", [])[:trending_limit]),
list(feed.get("topHot", [])[:hot_limit]),
)
trending = fetch_board("trending", limit=trending_limit)
hot = fetch_board("hot", limit=hot_limit)
if not trending:
trending = list(feed.get("topTrending", [])[:trending_limit])
else:
enrich_from_feed(trending, feed)
if not hot:
hot = list(feed.get("topHot", [])[:hot_limit])
else:
enrich_from_feed(hot, feed)
return trending, hot