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:
2026-07-03 14:45:39 +08:00
parent 88d08c0da9
commit f533e31adb
7 changed files with 146 additions and 121 deletions

View File

@@ -3,11 +3,16 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
_BOT_DIR = Path(__file__).resolve().parent _BOT_DIR = Path(__file__).resolve().parent
_REPO_ROOT = _BOT_DIR.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
load_dotenv(_BOT_DIR / ".env") load_dotenv(_BOT_DIR / ".env")
load_dotenv(_BOT_DIR / ".env.local", override=True) load_dotenv(_BOT_DIR / ".env.local", override=True)

View File

@@ -1,32 +1,20 @@
"""skills.sh 数据查询与命令解析""" """skills.sh 快查命令解析与企微回复格式化"""
from __future__ import annotations from __future__ import annotations
import json
import logging
import re import re
import time import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
import certifi _REPO_ROOT = Path(__file__).resolve().parent.parent
import httpx 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 = [ __all__ = ["Command", "handle_command", "parse_command", "warm_feed_cache"]
# 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 @dataclass
@@ -37,72 +25,6 @@ class Command:
query: str = "" 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: def _normalize_text(text: str) -> str:
text = re.sub(r"@\S+\s*", "", text) text = re.sub(r"@\S+\s*", "", text)
return text.strip().lower() return text.strip().lower()
@@ -157,19 +79,6 @@ def parse_command(text: str) -> Command:
return Command(kind="search", query=raw, limit=5) 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: def _board_title(board: Board) -> str:
return { return {
"trending": "Trending近期增长", "trending": "Trending近期增长",
@@ -180,7 +89,7 @@ def _board_title(board: Board) -> str:
def format_list(board: Board, limit: int) -> str: def format_list(board: Board, limit: int) -> str:
feed = load_feed() feed = load_feed()
items = _board_items(feed, board)[:limit] items = board_items(feed, board)[:limit]
updated = feed.get("updatedAt", "未知")[:10] updated = feed.get("updatedAt", "未知")[:10]
lines = [ lines = [
@@ -192,7 +101,7 @@ def format_list(board: Board, limit: int) -> str:
for i, item in enumerate(items, 1): for i, item in enumerate(items, 1):
title = item.get("title", "?") title = item.get("title", "?")
source = item.get("source", "?") source = item.get("source", "?")
installs = _format_installs(item.get("installs", 0)) installs = format_installs(item.get("installs", 0))
desc = item.get("description", "") desc = item.get("description", "")
if len(desc) > 80: if len(desc) > 80:
desc = desc[:77] + "..." desc = desc[:77] + "..."
@@ -241,7 +150,7 @@ def format_search(query: str, limit: int) -> str:
for i, item in enumerate(matches, 1): for i, item in enumerate(matches, 1):
title = item.get("title", "?") title = item.get("title", "?")
source = item.get("source", "?") source = item.get("source", "?")
installs = _format_installs(item.get("installs", 0)) installs = format_installs(item.get("installs", 0))
link = item.get("link", "") link = item.get("link", "")
lines.append(f"{i}. **{title}** · {installs} · `{source}`") lines.append(f"{i}. **{title}** · {installs} · `{source}`")
if link: if link:
@@ -270,7 +179,7 @@ def format_detail(name: str) -> str:
[ [
f"**{best.get('title', '?')}**", f"**{best.get('title', '?')}**",
f"`{best.get('source', '?')}`", f"`{best.get('source', '?')}`",
f"安装量:**{_format_installs(best.get('installs', 0))}**", f"安装量:**{format_installs(best.get('installs', 0))}**",
"", "",
desc, desc,
"", "",

View File

@@ -3,13 +3,11 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
BOT_DIR = ROOT / "bot"
OUTPUT_DIR = ROOT / "output" OUTPUT_DIR = ROOT / "output"
LOG_DIR = ROOT / "logs" LOG_DIR = ROOT / "logs"
CACHE_DIR = ROOT / ".cache" CACHE_DIR = ROOT / ".cache"
@@ -49,12 +47,6 @@ load_dotenv(ROOT / ".env")
load_dotenv(ROOT / ".env.local", override=True) load_dotenv(ROOT / ".env.local", override=True)
def ensure_bot_on_path() -> None:
bot = str(BOT_DIR)
if bot not in sys.path:
sys.path.insert(0, bot)
def _clean_env_value(raw: str | None) -> str | None: def _clean_env_value(raw: str | None) -> str | None:
if raw is None: if raw is None:
return None return None

View File

@@ -22,7 +22,6 @@ from daily.config import (
LOG_DIR, LOG_DIR,
OUTPUT_DIR, OUTPUT_DIR,
SNAPSHOT_FILE, SNAPSHOT_FILE,
ensure_bot_on_path,
env, env,
env_int, env_int,
full_desc_limit, full_desc_limit,
@@ -58,9 +57,7 @@ from daily.report_data import (
) )
from daily.skills_board import load_boards from daily.skills_board import load_boards
from daily.skills_group import group_skills_by_source from daily.skills_group import group_skills_by_source
from shared.skills_data import format_installs, load_feed
ensure_bot_on_path()
from skills_service import _format_installs, load_feed # noqa: E402
THEME_RULES: list[tuple[str, str, list[str]]] = [ THEME_RULES: list[tuple[str, str, list[str]]] = [
("🎬", "AI 多媒体 / 视频", ["runcomfy", "remotion", "video", "seedance", "inpaint", "lipsync"]), ("🎬", "AI 多媒体 / 视频", ["runcomfy", "remotion", "video", "seedance", "inpaint", "lipsync"]),
@@ -232,7 +229,7 @@ def _prepare_skill_item(item: dict[str, Any], prev_ids: set[str], rank: int) ->
badge = "🆕" badge = "🆕"
elif rank == 1: elif rank == 1:
badge = "👑" badge = "👑"
installs_fmt = item.get("installs_fmt") or _format_installs(item.get("installs", 0)) installs_fmt = item.get("installs_fmt") or format_installs(item.get("installs", 0))
title = item.get("source", "?") if item.get("cluster") else item.get("title", "?") title = item.get("source", "?") if item.get("cluster") else item.get("title", "?")
desc = item.get("wecom_desc") or item.get("description") or item.get("cluster_titles") or "" desc = item.get("wecom_desc") or item.get("description") or item.get("cluster_titles") or ""
limit = wecom_skill_desc_limit() limit = wecom_skill_desc_limit()
@@ -303,7 +300,7 @@ def _build_highlights(
) )
if trending: if trending:
t0 = trending[0] t0 = trending[0]
points.append(f"📈 Skills 榜首 **{t0.get('title')}**{_format_installs(t0.get('installs', 0))}") points.append(f"📈 Skills 榜首 **{t0.get('title')}**{format_installs(t0.get('installs', 0))}")
if github_trending: if github_trending:
g0 = github_trending[0] g0 = github_trending[0]
stars = g0.get("stars_today_fmt", "") stars = g0.get("stars_today_fmt", "")
@@ -315,7 +312,7 @@ def _build_highlights(
points.append(f"🌱 新兴 [{e0['repo']}]({e0['url']})(⭐ {e0.get('total_stars_fmt', '?')}") points.append(f"🌱 新兴 [{e0['repo']}]({e0['url']})(⭐ {e0.get('total_stars_fmt', '?')}")
elif hot: elif hot:
h0 = hot[0] h0 = hot[0]
points.append(f"🔥 Skills Hot 榜首 **{h0.get('title')}**1H {_format_installs(h0.get('installs', 0))}") points.append(f"🔥 Skills Hot 榜首 **{h0.get('title')}**1H {format_installs(h0.get('installs', 0))}")
while len(points) < 3 and len(trending) > len(points): while len(points) < 3 and len(trending) > len(points):
item = trending[len(points)] item = trending[len(points)]
points.append(f"✨ **{item.get('title')}** · `{item.get('source')}`") points.append(f"✨ **{item.get('title')}** · `{item.get('source')}`")
@@ -397,7 +394,7 @@ def _format_skill_section(items: list[dict[str, Any]], *, hot: bool = False) ->
for i, item in enumerate(items, 1): for i, item in enumerate(items, 1):
skill_id = item.get("id") or f"{item.get('source', '?')}/{item.get('title', '?')}" skill_id = item.get("id") or f"{item.get('source', '?')}/{item.get('title', '?')}"
link = item.get("link", "") link = item.get("link", "")
installs = _format_installs(item.get("installs", 0)) installs = format_installs(item.get("installs", 0))
meta = f"1H {installs}" if hot else f"总安装 {installs}" meta = f"1H {installs}" if hot else f"总安装 {installs}"
if link: if link:
lines.append(f"{i}. **[{skill_id}]({link})** · {meta}") lines.append(f"{i}. **[{skill_id}]({link})** · {meta}")

View File

@@ -74,11 +74,14 @@ def _cursor_chat(system: str, user: str) -> str:
api_key = (env("CURSOR_API_KEY") or "").strip() api_key = (env("CURSOR_API_KEY") or "").strip()
if not api_key: if not api_key:
return "" return ""
import sys
from daily.config import ROOT
from cursor_sdk import Agent, AgentOptions, CursorAgentError, LocalAgentOptions from cursor_sdk import Agent, AgentOptions, CursorAgentError, LocalAgentOptions
from daily.config import ensure_bot_on_path _bot = str(ROOT / "bot")
if _bot not in sys.path:
ensure_bot_on_path() sys.path.insert(0, _bot)
try: try:
from bridge_manager import warm_cursor_bridge from bridge_manager import warm_cursor_bridge
except ImportError: except ImportError:

10
shared/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""Repo-root shared modules (skills feed data, etc.)."""
from shared.skills_data import (
Board,
format_installs,
load_feed,
warm_feed_cache,
)
__all__ = ["Board", "format_installs", "load_feed", "warm_feed_cache"]

109
shared/skills_data.py Normal file
View File

@@ -0,0 +1,109 @@
"""skills.sh feed.json 拉取、缓存与榜单数据。"""
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
from typing import Any, Literal
import certifi
import httpx
logger = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parent.parent
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",
]
CACHE_TTL_SECONDS = 600
CACHE_DIR = ROOT / ".cache"
CACHE_FILE = CACHE_DIR / "skills-feed.json"
USER_AGENT = "Mozilla/5.0 (compatible; daily-robots/1.0; +https://skills.sh)"
_cache: dict[str, Any] = {"data": None, "fetched_at": 0.0}
Board = Literal["trending", "hot", "all"]
BOARD_FEED_KEYS = {
"trending": "topTrending",
"hot": "topHot",
"all": "topAllTime",
}
def _fetch_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_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("读取 skills feed 本地缓存失败: %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("网络不可用,回退到本地 skills feed 缓存")
_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 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]]:
return feed.get(BOARD_FEED_KEYS[board], [])