gitinstall 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. gitinstall/__init__.py +61 -0
  2. gitinstall/_sdk.py +541 -0
  3. gitinstall/academic.py +831 -0
  4. gitinstall/admin.html +327 -0
  5. gitinstall/auto_update.py +384 -0
  6. gitinstall/autopilot.py +349 -0
  7. gitinstall/badge.py +476 -0
  8. gitinstall/checkpoint.py +330 -0
  9. gitinstall/cicd.py +499 -0
  10. gitinstall/clawhub.html +718 -0
  11. gitinstall/config_schema.py +353 -0
  12. gitinstall/db.py +984 -0
  13. gitinstall/db_backend.py +445 -0
  14. gitinstall/dep_chain.py +337 -0
  15. gitinstall/dependency_audit.py +1153 -0
  16. gitinstall/detector.py +542 -0
  17. gitinstall/doctor.py +493 -0
  18. gitinstall/education.py +869 -0
  19. gitinstall/enterprise.py +802 -0
  20. gitinstall/error_fixer.py +953 -0
  21. gitinstall/event_bus.py +251 -0
  22. gitinstall/executor.py +577 -0
  23. gitinstall/feature_flags.py +138 -0
  24. gitinstall/fetcher.py +921 -0
  25. gitinstall/huggingface.py +922 -0
  26. gitinstall/hw_detect.py +988 -0
  27. gitinstall/i18n.py +664 -0
  28. gitinstall/installer_registry.py +362 -0
  29. gitinstall/knowledge_base.py +379 -0
  30. gitinstall/license_check.py +605 -0
  31. gitinstall/llm.py +569 -0
  32. gitinstall/log.py +236 -0
  33. gitinstall/main.py +1408 -0
  34. gitinstall/mcp_agent.py +841 -0
  35. gitinstall/mcp_server.py +386 -0
  36. gitinstall/monorepo.py +810 -0
  37. gitinstall/multi_source.py +425 -0
  38. gitinstall/onboard.py +276 -0
  39. gitinstall/planner.py +222 -0
  40. gitinstall/planner_helpers.py +323 -0
  41. gitinstall/planner_known_projects.py +1010 -0
  42. gitinstall/planner_templates.py +996 -0
  43. gitinstall/remote_gpu.py +633 -0
  44. gitinstall/resilience.py +608 -0
  45. gitinstall/run_tests.py +572 -0
  46. gitinstall/skills.py +476 -0
  47. gitinstall/tool_schemas.py +324 -0
  48. gitinstall/trending.py +279 -0
  49. gitinstall/uninstaller.py +415 -0
  50. gitinstall/validate_top100.py +607 -0
  51. gitinstall/watchdog.py +180 -0
  52. gitinstall/web.py +1277 -0
  53. gitinstall/web_ui.html +2277 -0
  54. gitinstall-1.1.0.dist-info/METADATA +275 -0
  55. gitinstall-1.1.0.dist-info/RECORD +59 -0
  56. gitinstall-1.1.0.dist-info/WHEEL +5 -0
  57. gitinstall-1.1.0.dist-info/entry_points.txt +3 -0
  58. gitinstall-1.1.0.dist-info/licenses/LICENSE +21 -0
  59. gitinstall-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,324 @@
1
+ """
2
+ gitinstall Tool Schemas — 让任意 AI 模型调用安装引擎
3
+ ====================================================
4
+
5
+ 提供多种格式的工具定义,使任何支持 Function Calling / Tool Use 的模型
6
+ (无论是 OpenAI、自训练模型、垂直模型、还是本地 Ollama 模型)
7
+ 都能调用 gitinstall 的能力。
8
+
9
+ 用法::
10
+
11
+ # 获取 OpenAI 格式 (也兼容 Ollama / vLLM / LM Studio / 任意 OpenAI 兼容 API)
12
+ from gitinstall.tool_schemas import openai_tools
13
+
14
+ # 获取原始 JSON Schema 格式 (框架无关)
15
+ from gitinstall.tool_schemas import json_schemas
16
+
17
+ # 获取全部工具名列表
18
+ from gitinstall.tool_schemas import tool_names
19
+
20
+ # 执行工具调用(通用分发器)
21
+ from gitinstall.tool_schemas import call_tool
22
+ result = call_tool("detect", {})
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ _THIS_DIR = Path(__file__).parent
32
+ if str(_THIS_DIR) not in sys.path:
33
+ sys.path.insert(0, str(_THIS_DIR))
34
+
35
+ # ── 原始工具定义(格式无关的真相源)──────────────
36
+
37
+ _TOOLS = [
38
+ {
39
+ "name": "detect",
40
+ "description": (
41
+ "Detect the current system environment. Returns OS type, CPU architecture, "
42
+ "GPU (CUDA/ROCm/MPS/CPU), installed runtimes (Python, Node.js, Docker, Rust, Go, FFmpeg), "
43
+ "package managers (pip, conda, brew, apt, npm, cargo), disk space, and network connectivity."
44
+ ),
45
+ "parameters": {
46
+ "type": "object",
47
+ "properties": {},
48
+ },
49
+ },
50
+ {
51
+ "name": "plan",
52
+ "description": (
53
+ "Generate a step-by-step installation plan for a GitHub project. "
54
+ "Analyzes the project's tech stack, dependencies, and the current environment "
55
+ "to produce an optimal sequence of shell commands. Returns steps with commands, "
56
+ "launch command, confidence level (high/medium/low), and strategy used. "
57
+ "The plan is NOT executed — use the 'install' tool to execute it."
58
+ ),
59
+ "parameters": {
60
+ "type": "object",
61
+ "properties": {
62
+ "project": {
63
+ "type": "string",
64
+ "description": "GitHub project identifier: 'owner/repo' or full URL",
65
+ },
66
+ "llm": {
67
+ "type": "string",
68
+ "description": "LLM provider for analysis. Auto-selects if omitted.",
69
+ "enum": ["anthropic", "openai", "openrouter", "gemini", "groq",
70
+ "deepseek", "lmstudio", "ollama", "none"],
71
+ },
72
+ },
73
+ "required": ["project"],
74
+ },
75
+ },
76
+ {
77
+ "name": "install",
78
+ "description": (
79
+ "Execute the installation of a GitHub project on this system. "
80
+ "Runs shell commands with safety filtering (blocks dangerous commands), "
81
+ "automatic error recovery (25+ patterns), and multi-strategy fallback. "
82
+ "Returns success status, install directory, and launch command."
83
+ ),
84
+ "parameters": {
85
+ "type": "object",
86
+ "properties": {
87
+ "project": {
88
+ "type": "string",
89
+ "description": "GitHub project: 'owner/repo' or full URL",
90
+ },
91
+ "install_dir": {
92
+ "type": "string",
93
+ "description": "Target directory for installation.",
94
+ },
95
+ "dry_run": {
96
+ "type": "boolean",
97
+ "description": "If true, show plan only without executing.",
98
+ },
99
+ },
100
+ "required": ["project"],
101
+ },
102
+ },
103
+ {
104
+ "name": "diagnose",
105
+ "description": (
106
+ "Diagnose a software installation error and suggest fixes. "
107
+ "Covers 25+ error patterns: dependency conflicts, permission issues, "
108
+ "missing tools, version mismatches, PEP 668, CUDA/GPU issues, and more."
109
+ ),
110
+ "parameters": {
111
+ "type": "object",
112
+ "properties": {
113
+ "stderr": {
114
+ "type": "string",
115
+ "description": "Error output text to diagnose",
116
+ },
117
+ "command": {
118
+ "type": "string",
119
+ "description": "The command that produced the error.",
120
+ },
121
+ "stdout": {
122
+ "type": "string",
123
+ "description": "Standard output text.",
124
+ },
125
+ },
126
+ "required": ["stderr"],
127
+ },
128
+ },
129
+ {
130
+ "name": "fetch",
131
+ "description": (
132
+ "Fetch metadata about a GitHub project: name, description, stars, "
133
+ "language, license, project type, clone URL, dependency files, README preview."
134
+ ),
135
+ "parameters": {
136
+ "type": "object",
137
+ "properties": {
138
+ "project": {
139
+ "type": "string",
140
+ "description": "GitHub project: 'owner/repo' or full URL",
141
+ },
142
+ },
143
+ "required": ["project"],
144
+ },
145
+ },
146
+ {
147
+ "name": "doctor",
148
+ "description": (
149
+ "Run a comprehensive system diagnostic. Checks tool installations, "
150
+ "runtime versions, configuration issues, and system health."
151
+ ),
152
+ "parameters": {
153
+ "type": "object",
154
+ "properties": {},
155
+ },
156
+ },
157
+ {
158
+ "name": "audit",
159
+ "description": (
160
+ "Audit a GitHub project's dependencies for security vulnerabilities (CVEs)."
161
+ ),
162
+ "parameters": {
163
+ "type": "object",
164
+ "properties": {
165
+ "project": {
166
+ "type": "string",
167
+ "description": "GitHub project: 'owner/repo' or full URL",
168
+ },
169
+ "online": {
170
+ "type": "boolean",
171
+ "description": "Query online CVE databases for thorough results.",
172
+ },
173
+ },
174
+ "required": ["project"],
175
+ },
176
+ },
177
+ ]
178
+
179
+ # ── 工具名列表 ──────────────────────────────
180
+
181
+ tool_names: list[str] = [t["name"] for t in _TOOLS]
182
+
183
+
184
+ # ── OpenAI Function Calling 格式 ────────────
185
+ # 兼容: OpenAI / Ollama / vLLM / LM Studio / Azure OpenAI / Groq /
186
+ # DeepSeek / Together AI / Fireworks / Mistral / 任何 OpenAI 兼容 API
187
+
188
+ openai_tools: list[dict] = [
189
+ {
190
+ "type": "function",
191
+ "function": {
192
+ "name": t["name"],
193
+ "description": t["description"],
194
+ "parameters": t["parameters"],
195
+ },
196
+ }
197
+ for t in _TOOLS
198
+ ]
199
+
200
+
201
+ # ── Anthropic Tool Use 格式 ─────────────────
202
+ # 兼容: Claude API / AWS Bedrock Claude
203
+
204
+ anthropic_tools: list[dict] = [
205
+ {
206
+ "name": t["name"],
207
+ "description": t["description"],
208
+ "input_schema": t["parameters"],
209
+ }
210
+ for t in _TOOLS
211
+ ]
212
+
213
+
214
+ # ── Google Gemini 格式 ──────────────────────
215
+ # 兼容: Gemini API / Vertex AI
216
+
217
+ gemini_tools: list[dict] = [
218
+ {
219
+ "function_declarations": [
220
+ {
221
+ "name": t["name"],
222
+ "description": t["description"],
223
+ "parameters": t["parameters"],
224
+ }
225
+ for t in _TOOLS
226
+ ]
227
+ }
228
+ ]
229
+
230
+
231
+ # ── 纯 JSON Schema 格式(框架无关)────────────
232
+ # 任何框架/模型都可以消费
233
+
234
+ json_schemas: list[dict] = [
235
+ {
236
+ "name": t["name"],
237
+ "description": t["description"],
238
+ "parameters": t["parameters"],
239
+ }
240
+ for t in _TOOLS
241
+ ]
242
+
243
+
244
+ # ── 通用工具调用分发器 ──────────────────────
245
+
246
+ def call_tool(name: str, arguments: dict) -> dict | None:
247
+ """
248
+ 执行 gitinstall 工具调用。
249
+
250
+ 所有 AI 框架集成都可以用这一个函数来分发工具调用:
251
+
252
+ result = call_tool("detect", {})
253
+ result = call_tool("plan", {"project": "comfyanonymous/ComfyUI"})
254
+ result = call_tool("diagnose", {"stderr": "error: ..."})
255
+
256
+ Args:
257
+ name: 工具名 (detect/plan/install/diagnose/fetch/doctor/audit)
258
+ arguments: 工具参数 dict
259
+
260
+ Returns:
261
+ 工具执行结果 dict,或 None(diagnose 无匹配时)
262
+ """
263
+ from _sdk import detect, plan, install, diagnose, fetch, doctor, audit
264
+
265
+ if name == "detect":
266
+ return detect()
267
+
268
+ if name == "plan":
269
+ return plan(
270
+ arguments["project"],
271
+ llm=arguments.get("llm"),
272
+ )
273
+
274
+ if name == "install":
275
+ return install(
276
+ arguments["project"],
277
+ install_dir=arguments.get("install_dir"),
278
+ dry_run=arguments.get("dry_run", False),
279
+ )
280
+
281
+ if name == "diagnose":
282
+ return diagnose(
283
+ arguments["stderr"],
284
+ command=arguments.get("command", ""),
285
+ stdout=arguments.get("stdout", ""),
286
+ )
287
+
288
+ if name == "fetch":
289
+ return fetch(arguments["project"])
290
+
291
+ if name == "doctor":
292
+ return doctor()
293
+
294
+ if name == "audit":
295
+ return audit(
296
+ arguments["project"],
297
+ online=arguments.get("online", False),
298
+ )
299
+
300
+ raise ValueError(f"Unknown tool: {name}. Available: {tool_names}")
301
+
302
+
303
+ # ── 输出为 JSON ──────────────────────────────
304
+
305
+ def to_json(format: str = "openai") -> str:
306
+ """
307
+ 输出指定格式的工具定义 JSON。
308
+
309
+ Args:
310
+ format: "openai" | "anthropic" | "gemini" | "json_schema"
311
+ """
312
+ schemas = {
313
+ "openai": openai_tools,
314
+ "anthropic": anthropic_tools,
315
+ "gemini": gemini_tools,
316
+ "json_schema": json_schemas,
317
+ }
318
+ data = schemas.get(format, openai_tools)
319
+ return json.dumps(data, indent=2, ensure_ascii=False)
320
+
321
+
322
+ if __name__ == "__main__":
323
+ fmt = sys.argv[1] if len(sys.argv) > 1 else "openai"
324
+ print(to_json(fmt))
gitinstall/trending.py ADDED
@@ -0,0 +1,279 @@
1
+ """
2
+ trending.py - 动态 GitHub 热门项目爬取与缓存
3
+ =============================================
4
+
5
+ 设计思路:
6
+ 1. 从 GitHub Search API 获取各分类 Top 项目
7
+ 2. 本地文件缓存,默认 6 小时刷新一次
8
+ 3. 首次启动时先用静态 fallback,后台异步刷新
9
+ 4. 支持手动强制刷新
10
+
11
+ 缓存策略(解决排名动态变化问题):
12
+ - 文件缓存: ~/.cache/gitinstall/trending.json(TTL 6h)
13
+ - 内存缓存: 进程内 dict(避免重复读磁盘)
14
+ - 增量合并: 新排名数据与旧数据合并,避免项目突然消失
15
+ - 稳定性窗口: 用加权积分(当前排名 + 历史出现频次)平滑排名波动
16
+
17
+ 只使用 Python 标准库,无需第三方依赖。
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import time
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ from pathlib import Path
29
+ from threading import Thread, Lock
30
+
31
+ # ─── 配置 ─────────────────────────────────────────
32
+
33
+ _CACHE_DIR = Path.home() / ".cache" / "gitinstall"
34
+ _CACHE_FILE = _CACHE_DIR / "trending.json"
35
+ _CACHE_TTL = int(os.getenv("GITINSTALL_TRENDING_TTL", str(6 * 3600))) # 默认 6 小时
36
+ _MEM_LOCK = Lock()
37
+ _mem_cache: dict | None = None
38
+ _mem_ts: float = 0.0
39
+
40
+ # 分类搜索查询(覆盖 AI / Web / 工具 / IoT 四大分类)
41
+ _SEARCH_QUERIES = [
42
+ # (query, tag, limit)
43
+ ("topic:machine-learning stars:>5000", "AI", 25),
44
+ ("topic:llm stars:>3000", "AI", 25),
45
+ ("topic:web-framework stars:>5000", "Web", 25),
46
+ ("topic:developer-tools stars:>3000", "工具", 25),
47
+ ("topic:home-automation stars:>1000", "IoT", 15),
48
+ ]
49
+
50
+ # 语言 → 图标(emoji)
51
+ _LANG_ICONS = {
52
+ "python": "🐍", "javascript": "🟨", "typescript": "🔷", "go": "🟦",
53
+ "rust": "🦀", "java": "☕", "kotlin": "🟣", "swift": "🍎",
54
+ "c++": "⚙️", "c": "⚙️", "ruby": "💎", "php": "🐘",
55
+ "dart": "🎯", "shell": "🐚", "lua": "🌙", "haskell": "λ",
56
+ "c#": "🟩", "scala": "🔴", "elixir": "💧", "zig": "⚡",
57
+ }
58
+
59
+ # 标签 → 默认图标
60
+ _TAG_ICONS = {"AI": "🤖", "Web": "🌐", "工具": "🔧", "IoT": "🏠"}
61
+
62
+ # ─── 静态 Fallback(首次启动时无缓存可用) ─────────
63
+
64
+ _STATIC_TRENDING = [
65
+ {"repo": "open-webui/open-webui", "name": "Open WebUI", "icon": "🐳",
66
+ "desc": "ChatGPT 风格的本地 AI 聊天界面", "stars": "80k+", "lang": "Python", "tag": "AI"},
67
+ {"repo": "comfyanonymous/ComfyUI", "name": "ComfyUI", "icon": "🎨",
68
+ "desc": "强大的 Stable Diffusion 节点式工作流", "stars": "75k+", "lang": "Python", "tag": "AI"},
69
+ {"repo": "ollama/ollama", "name": "Ollama", "icon": "🤖",
70
+ "desc": "一键运行 LLaMA/Mistral 等大模型", "stars": "130k+", "lang": "Go", "tag": "AI"},
71
+ {"repo": "yt-dlp/yt-dlp", "name": "yt-dlp", "icon": "📺",
72
+ "desc": "最强视频下载工具,支持数千个站点", "stars": "100k+", "lang": "Python", "tag": "工具"},
73
+ {"repo": "fastapi/fastapi", "name": "FastAPI", "icon": "⚡",
74
+ "desc": "高性能 Python Web 框架", "stars": "82k+", "lang": "Python", "tag": "Web"},
75
+ {"repo": "home-assistant/core", "name": "Home Assistant", "icon": "🏠",
76
+ "desc": "开源智能家居自动化平台", "stars": "78k+", "lang": "Python", "tag": "IoT"},
77
+ {"repo": "AUTOMATIC1111/stable-diffusion-webui", "name": "SD WebUI", "icon": "🖼️",
78
+ "desc": "Stable Diffusion 最流行的 Web 界面", "stars": "148k+", "lang": "Python", "tag": "AI"},
79
+ {"repo": "langgenius/dify", "name": "Dify", "icon": "🧠",
80
+ "desc": "LLM 应用开发平台,可视化编排", "stars": "90k+", "lang": "Python", "tag": "AI"},
81
+ {"repo": "pallets/flask", "name": "Flask", "icon": "🌶️",
82
+ "desc": "轻量级 Python Web 微框架", "stars": "69k+", "lang": "Python", "tag": "Web"},
83
+ {"repo": "excalidraw/excalidraw", "name": "Excalidraw", "icon": "✏️",
84
+ "desc": "手绘风格的在线白板协作工具", "stars": "95k+", "lang": "TypeScript", "tag": "工具"},
85
+ {"repo": "rustdesk/rustdesk", "name": "RustDesk", "icon": "🖥️",
86
+ "desc": "开源远程桌面软件,TeamViewer 替代", "stars": "82k+", "lang": "Rust", "tag": "工具"},
87
+ ]
88
+
89
+
90
+ def _fmt_stars(n: int) -> str:
91
+ """格式化 star 数: 1234 → '1.2k', 123456 → '123k'"""
92
+ if n >= 1000:
93
+ return f"{n/1000:.0f}k+" if n >= 10000 else f"{n/1000:.1f}k+"
94
+ return str(n)
95
+
96
+
97
+ def _github_search(query: str, per_page: int = 30) -> list[dict]:
98
+ """调用 GitHub Search API 获取项目列表"""
99
+ url = "https://api.github.com/search/repositories?" + urllib.parse.urlencode({
100
+ "q": query,
101
+ "sort": "stars",
102
+ "order": "desc",
103
+ "per_page": str(min(per_page, 100)),
104
+ })
105
+ headers = {
106
+ "Accept": "application/vnd.github.v3+json",
107
+ "User-Agent": "gitinstall/1.0",
108
+ }
109
+ token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
110
+ if token:
111
+ headers["Authorization"] = f"token {token}"
112
+
113
+ req = urllib.request.Request(url, headers=headers)
114
+ with urllib.request.urlopen(req, timeout=15) as resp:
115
+ return json.loads(resp.read()).get("items", [])
116
+
117
+
118
+ def _item_to_project(item: dict, tag: str) -> dict:
119
+ """将 GitHub API 返回的 item 转换为前端格式"""
120
+ lang = (item.get("language") or "").strip()
121
+ icon = _LANG_ICONS.get(lang.lower(), _TAG_ICONS.get(tag, "📦"))
122
+ return {
123
+ "repo": item["full_name"],
124
+ "name": item["name"],
125
+ "icon": icon,
126
+ "desc": (item.get("description") or "")[:100],
127
+ "stars": _fmt_stars(item.get("stargazers_count", 0)),
128
+ "lang": lang,
129
+ "tag": tag,
130
+ "_stars_num": item.get("stargazers_count", 0),
131
+ "_fetched_at": time.time(),
132
+ }
133
+
134
+
135
+ def _fetch_all() -> list[dict]:
136
+ """从 GitHub 爬取所有分类的热门项目"""
137
+ all_projects = []
138
+ seen_repos = set()
139
+
140
+ for query, tag, limit in _SEARCH_QUERIES:
141
+ try:
142
+ items = _github_search(query, per_page=limit)
143
+ for item in items:
144
+ repo = item["full_name"].lower()
145
+ if repo in seen_repos:
146
+ continue
147
+ seen_repos.add(repo)
148
+ all_projects.append(_item_to_project(item, tag))
149
+ time.sleep(6.5) # GitHub Search API: max 10 req/min unauthenticated
150
+ except Exception:
151
+ continue # 某个分类失败不影响其他分类
152
+
153
+ # 按 star 数降序
154
+ all_projects.sort(key=lambda x: x.get("_stars_num", 0), reverse=True)
155
+ return all_projects[:100] # 保留 Top 100
156
+
157
+
158
+ def _merge_with_old(new_projects: list[dict], old_projects: list[dict]) -> list[dict]:
159
+ """
160
+ 增量合并策略(解决排名动态波动问题):
161
+ - 新旧数据以 repo 为 key 合并
162
+ - 新数据中出现的项目:更新 stars/desc 等字段
163
+ - 旧数据中未在新数据出现但仍有高 star 的:保留(标记为 _stale),避免突然消失
164
+ - 超过 3 次刷新都未出现的 stale 项目:移除
165
+ """
166
+ old_map = {p["repo"].lower(): p for p in old_projects}
167
+ new_map = {p["repo"].lower(): p for p in new_projects}
168
+ merged = {}
169
+
170
+ # 1. 所有新数据直接加入
171
+ for key, proj in new_map.items():
172
+ proj["_stale_count"] = 0
173
+ merged[key] = proj
174
+
175
+ # 2. 旧数据中不在新数据的:标记 stale,保留 3 轮
176
+ for key, proj in old_map.items():
177
+ if key not in merged:
178
+ stale = proj.get("_stale_count", 0) + 1
179
+ if stale <= 3:
180
+ proj["_stale_count"] = stale
181
+ merged[key] = proj
182
+
183
+ # 按 star 数降序,截取 Top 100
184
+ result = sorted(merged.values(), key=lambda x: x.get("_stars_num", 0), reverse=True)
185
+ return result[:100]
186
+
187
+
188
+ def _read_cache() -> dict | None:
189
+ """读取磁盘缓存"""
190
+ try:
191
+ if not _CACHE_FILE.exists():
192
+ return None
193
+ data = json.loads(_CACHE_FILE.read_text("utf-8"))
194
+ return data
195
+ except Exception:
196
+ return None
197
+
198
+
199
+ def _write_cache(projects: list[dict]):
200
+ """写入磁盘缓存"""
201
+ try:
202
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
203
+ data = {
204
+ "version": 1,
205
+ "updated_at": time.time(),
206
+ "projects": projects,
207
+ }
208
+ _CACHE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=1), "utf-8")
209
+ except Exception:
210
+ pass
211
+
212
+
213
+ def _refresh_worker():
214
+ """后台刷新线程"""
215
+ global _mem_cache, _mem_ts
216
+ try:
217
+ new_projects = _fetch_all()
218
+ if not new_projects:
219
+ return
220
+
221
+ # 与旧数据合并
222
+ old_data = _read_cache()
223
+ old_projects = old_data.get("projects", []) if old_data else []
224
+ merged = _merge_with_old(new_projects, old_projects)
225
+
226
+ _write_cache(merged)
227
+ with _MEM_LOCK:
228
+ _mem_cache = merged
229
+ _mem_ts = time.time()
230
+ except Exception:
231
+ pass
232
+
233
+
234
+ def get_trending(force_refresh: bool = False) -> list[dict]:
235
+ """
236
+ 获取热门项目列表(对外唯一接口)。
237
+
238
+ 策略:
239
+ 1. 内存缓存有效 → 直接返回
240
+ 2. 磁盘缓存有效 → 加载到内存并返回
241
+ 3. 缓存过期 → 返回旧数据 + 后台异步刷新
242
+ 4. 无任何缓存 → 返回静态 fallback + 后台异步爬取
243
+ """
244
+ global _mem_cache, _mem_ts
245
+
246
+ now = time.time()
247
+
248
+ # 1. 内存缓存有效且不强制刷新
249
+ with _MEM_LOCK:
250
+ if _mem_cache and (now - _mem_ts < _CACHE_TTL) and not force_refresh:
251
+ return _clean_for_frontend(_mem_cache)
252
+
253
+ # 2. 磁盘缓存
254
+ disk = _read_cache()
255
+ if disk:
256
+ projects = disk.get("projects", [])
257
+ updated_at = disk.get("updated_at", 0)
258
+ with _MEM_LOCK:
259
+ _mem_cache = projects
260
+ _mem_ts = updated_at
261
+
262
+ if (now - updated_at < _CACHE_TTL) and not force_refresh:
263
+ return _clean_for_frontend(projects)
264
+ else:
265
+ # 缓存过期:返回旧数据,后台刷新
266
+ Thread(target=_refresh_worker, daemon=True).start()
267
+ return _clean_for_frontend(projects)
268
+
269
+ # 3. 完全无缓存:返回静态 fallback,后台爬取
270
+ Thread(target=_refresh_worker, daemon=True).start()
271
+ return _STATIC_TRENDING[:]
272
+
273
+
274
+ def _clean_for_frontend(projects: list[dict]) -> list[dict]:
275
+ """移除内部字段,只返回前端需要的数据"""
276
+ return [
277
+ {k: v for k, v in p.items() if not k.startswith("_")}
278
+ for p in projects
279
+ ]