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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
10
shared/__init__.py
Normal 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
109
shared/skills_data.py
Normal 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], [])
|
||||||
Reference in New Issue
Block a user