"""skills.sh 数据查询与命令解析。""" from __future__ import annotations import json import logging import re import time from dataclasses import dataclass from pathlib import Path from typing import Any, Literal import certifi import httpx logger = logging.getLogger(__name__) FEED_URLS = [ # jsDelivr 在国内通常比 raw.githubusercontent.com 更稳定 "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", ] CACHE_TTL_SECONDS = 600 CACHE_DIR = Path(__file__).resolve().parent / ".cache" CACHE_FILE = CACHE_DIR / "feed.json" _cache: dict[str, Any] = {"data": None, "fetched_at": 0.0} Board = Literal["trending", "hot", "all"] @dataclass class Command: kind: Literal["help", "list", "search", "detail"] board: Board = "trending" limit: int = 10 query: str = "" def _fetch_json(url: str) -> dict[str, Any]: headers = { "User-Agent": "skills-hot-bot/1.0", "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_disk_cache() -> dict[str, Any] | None: if not CACHE_FILE.exists(): return None try: return json.loads(CACHE_FILE.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: logger.warning("读取本地缓存失败: %s", exc) return None def _save_disk_cache(data: dict[str, Any]) -> None: CACHE_DIR.mkdir(parents=True, exist_ok=True) 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 _cache["data"] and now - _cache["fetched_at"] < CACHE_TTL_SECONDS: return _cache["data"] errors: list[str] = [] for url in FEED_URLS: for attempt in range(3): try: data = _fetch_json(url) _cache["data"] = data _cache["fetched_at"] = now _save_disk_cache(data) logger.info("skills 数据已更新: %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_disk_cache() if stale: logger.warning("网络不可用,回退到本地缓存") _cache["data"] = stale _cache["fetched_at"] = now return stale raise RuntimeError(f"无法获取 skills 数据。最近错误: {errors[-1]}") def warm_feed_cache() -> None: """启动时预加载,避免首条消息才触发网络请求。""" load_feed(force=True) def _normalize_text(text: str) -> str: text = re.sub(r"@\S+\s*", "", text) return text.strip().lower() def _parse_limit(raw: str | None, default: int = 10) -> int: if not raw: return default try: n = int(raw) except ValueError: return default return max(1, min(n, 30)) def _match_list(raw: str, board: Board, aliases: str) -> Command | None: m = re.match(rf"^({aliases})(?:\s+top)?\s*(\d+)?$", raw) if m: return Command(kind="list", board=board, limit=_parse_limit(m.group(2))) m = re.match(rf"^(查|查询)\s+({aliases})(?:\s+top)?\s*(\d+)?$", raw) if m: return Command(kind="list", board=board, limit=_parse_limit(m.group(3))) return None def parse_command(text: str) -> Command: raw = _normalize_text(text) if not raw or raw in {"help", "帮助", "?", "h"}: return Command(kind="help") for board, aliases in ( ("trending", "trending|趋势|top"), ("hot", "hot|实时|热门"), ("all", "all|总榜|alltime|all-time"), ): cmd = _match_list(raw, board, aliases) if cmd: return cmd m = re.match(r"^(search|搜索|find|查)\s+(.+)$", raw) if m: return Command(kind="search", query=m.group(2).strip(), limit=5) m = re.match(r"^(detail|详情|skill|info)\s+(.+)$", raw) if m: return Command(kind="detail", query=m.group(2).strip()) if raw.startswith("trending") or raw.startswith("趋势"): parts = raw.split(maxsplit=1) return Command(kind="list", board="trending", limit=_parse_limit(parts[1] if len(parts) > 1 else None)) return Command(kind="search", query=raw, limit=5) 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 _board_items(feed: dict[str, Any], board: Board) -> list[dict[str, Any]]: key = {"trending": "topTrending", "hot": "topHot", "all": "topAllTime"}[board] return feed.get(key, []) def _board_title(board: Board) -> str: return { "trending": "Trending(近期增长)", "hot": "Hot(实时热度)", "all": "All Time(总安装榜)", }[board] def format_list(board: Board, limit: int) -> str: feed = load_feed() items = _board_items(feed, board)[:limit] updated = feed.get("updatedAt", "未知")[:10] lines = [ f"**skills.sh {_board_title(board)} Top {limit}**", f"> 数据更新:{updated}", "", ] for i, item in enumerate(items, 1): title = item.get("title", "?") source = item.get("source", "?") installs = _format_installs(item.get("installs", 0)) desc = item.get("description", "") if len(desc) > 80: desc = desc[:77] + "..." link = item.get("link", "") lines.append(f"{i}. **{title}** · {installs}") lines.append(f" `{source}`") if desc: lines.append(f" {desc}") if link: lines.append(f" [查看]({link})") lines.append("") return "\n".join(lines).strip() def format_search(query: str, limit: int) -> str: feed = load_feed() q = query.lower() seen: set[str] = set() matches: list[dict[str, Any]] = [] for board in ("topTrending", "topHot", "topAllTime"): for item in feed.get(board, []): item_id = item.get("id") or item.get("title", "") if item_id in seen: continue haystack = " ".join( [ item.get("title", ""), item.get("source", ""), item.get("description", ""), ] ).lower() if q in haystack: seen.add(item_id) matches.append(item) if len(matches) >= limit: break if len(matches) >= limit: break if not matches: return f"未找到与 **{query}** 相关的 skill。\n\n试试:`trending 10` / `hot 10` / `搜索 react`" lines = [f"**搜索「{query}」** 共 {len(matches)} 条", ""] for i, item in enumerate(matches, 1): title = item.get("title", "?") source = item.get("source", "?") installs = _format_installs(item.get("installs", 0)) link = item.get("link", "") lines.append(f"{i}. **{title}** · {installs} · `{source}`") if link: lines.append(f" [查看]({link})") return "\n".join(lines) def format_detail(name: str) -> str: feed = load_feed() q = name.lower().strip() best: dict[str, Any] | None = None for board in ("topTrending", "topHot", "topAllTime"): for item in feed.get(board, []): title = (item.get("title") or "").lower() item_id = (item.get("id") or "").lower() if title == q or q in title or q in item_id: if best is None or item.get("installs", 0) > best.get("installs", 0): best = item if not best: return f"未找到 skill:**{name}**\n\n试试:`搜索 {name}`" desc = best.get("description", "无描述") return "\n".join( [ f"**{best.get('title', '?')}**", f"`{best.get('source', '?')}`", f"安装量:**{_format_installs(best.get('installs', 0))}**", "", desc, "", f"[skills.sh 详情]({best.get('link', 'https://skills.sh')})", "", f"安装:`npx skills add {best.get('source', '')}/{best.get('title', '')}`", ] ) def format_help() -> str: return "\n".join( [ "**Skills 助手 · 命令帮助**", "", "`trending 10` / `趋势 10` — 近期增长榜", "`hot 10` / `实时 10` — 实时热度榜", "`all 10` / `总榜 10` — 历史总安装榜", "`搜索 react` / `search tdd` — 关键词搜索", "`详情 find-skills` — 查看单个 skill", "`preview` / `截图` / `预览` — 单页截图", "`browser 场景名` — 执行 YAML 场景(见 bot/scenarios/)", "自然语言 — 如:访问登录页,输入账号密码,点击登录,点击智能体管理,截图", "`preview /about 5173` — 指定路径和端口", "", "示例:", "• trending top10", "• 查 grill", "• 详情 remotion-render", ] ) def handle_command(text: str) -> str: cmd = parse_command(text) if cmd.kind == "help": return format_help() if cmd.kind == "list": return format_list(cmd.board, cmd.limit) if cmd.kind == "search": return format_search(cmd.query, cmd.limit) if cmd.kind == "detail": return format_detail(cmd.query) return format_help()