refactor: Phase 1 抽出 shared/skills_data 解耦 daily 与 bot
daily 不再通过 sys.path 导入 bot/skills_service,skills feed 数据层上移到 shared/。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,32 +1,20 @@
|
||||
"""skills.sh 数据查询与命令解析。"""
|
||||
"""skills.sh 快查命令解析与企微回复格式化。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from shared.skills_data import Board, board_items, format_installs, load_feed, warm_feed_cache
|
||||
|
||||
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"]
|
||||
__all__ = ["Command", "handle_command", "parse_command", "warm_feed_cache"]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -37,72 +25,6 @@ class Command:
|
||||
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()
|
||||
@@ -157,19 +79,6 @@ def parse_command(text: str) -> Command:
|
||||
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(近期增长)",
|
||||
@@ -180,7 +89,7 @@ def _board_title(board: Board) -> str:
|
||||
|
||||
def format_list(board: Board, limit: int) -> str:
|
||||
feed = load_feed()
|
||||
items = _board_items(feed, board)[:limit]
|
||||
items = board_items(feed, board)[:limit]
|
||||
updated = feed.get("updatedAt", "未知")[:10]
|
||||
|
||||
lines = [
|
||||
@@ -192,7 +101,7 @@ def format_list(board: Board, limit: int) -> str:
|
||||
for i, item in enumerate(items, 1):
|
||||
title = item.get("title", "?")
|
||||
source = item.get("source", "?")
|
||||
installs = _format_installs(item.get("installs", 0))
|
||||
installs = format_installs(item.get("installs", 0))
|
||||
desc = item.get("description", "")
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
@@ -241,7 +150,7 @@ def format_search(query: str, limit: int) -> str:
|
||||
for i, item in enumerate(matches, 1):
|
||||
title = item.get("title", "?")
|
||||
source = item.get("source", "?")
|
||||
installs = _format_installs(item.get("installs", 0))
|
||||
installs = format_installs(item.get("installs", 0))
|
||||
link = item.get("link", "")
|
||||
lines.append(f"{i}. **{title}** · {installs} · `{source}`")
|
||||
if link:
|
||||
@@ -270,7 +179,7 @@ def format_detail(name: str) -> str:
|
||||
[
|
||||
f"**{best.get('title', '?')}**",
|
||||
f"`{best.get('source', '?')}`",
|
||||
f"安装量:**{_format_installs(best.get('installs', 0))}**",
|
||||
f"安装量:**{format_installs(best.get('installs', 0))}**",
|
||||
"",
|
||||
desc,
|
||||
"",
|
||||
|
||||
Reference in New Issue
Block a user