Spaces:
Running
Running
Upload 3 files
Browse files- router_items.py +57 -3
- router_proxy.py +58 -0
- 云端_定时版本检测引擎.py +281 -4
router_items.py
CHANGED
|
@@ -7,6 +7,7 @@ import os
|
|
| 7 |
import urllib.request
|
| 8 |
import urllib.error
|
| 9 |
import json
|
|
|
|
| 10 |
import 数据库连接 as db
|
| 11 |
from models import ItemCreate, ItemUpdate, RatingRequest
|
| 12 |
from 安全认证 import require_auth, check_ownership
|
|
@@ -15,6 +16,13 @@ from db_utils import record_view, sort_cache
|
|
| 15 |
|
| 16 |
router = APIRouter()
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def get_last_6_months():
|
| 19 |
res = []
|
| 20 |
today = datetime.date.today()
|
|
@@ -69,7 +77,7 @@ async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): #
|
|
| 69 |
for item in filtered_items:
|
| 70 |
item["commentsData"] = comments_db.get(item["id"], [])
|
| 71 |
item["comments"] = len(item["commentsData"])
|
| 72 |
-
item["latest_version"] =
|
| 73 |
|
| 74 |
# 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息!
|
| 75 |
item["has_private_token"] = bool(item.get("github_token"))
|
|
@@ -331,7 +339,10 @@ async def update_item(item_id: str, update_data: ItemUpdate, current_user: str =
|
|
| 331 |
if update_data.title is not None: item["title"] = update_data.title
|
| 332 |
if update_data.shortDesc is not None: item["shortDesc"] = update_data.shortDesc
|
| 333 |
if update_data.fullDesc is not None: item["fullDesc"] = update_data.fullDesc
|
|
|
|
| 334 |
if update_data.link is not None: item["link"] = update_data.link
|
|
|
|
|
|
|
| 335 |
if update_data.coverUrl is not None: item["coverUrl"] = update_data.coverUrl
|
| 336 |
if update_data.imageUrls is not None: item["imageUrls"] = update_data.imageUrls # 🖼️ 效果展示图列表
|
| 337 |
|
|
@@ -383,6 +394,49 @@ async def update_item(item_id: str, update_data: ItemUpdate, current_user: str =
|
|
| 383 |
sort_cache.invalidate("items:")
|
| 384 |
invalidate_cache("items.json")
|
| 385 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
result = {"status": "success"}
|
| 387 |
if result_holder.get("price_change_info"):
|
| 388 |
price_change_info = result_holder["price_change_info"]
|
|
@@ -472,7 +526,7 @@ async def get_item_by_id(item_id: str):
|
|
| 472 |
# 添加关联数据
|
| 473 |
item["commentsData"] = comments_db.get(item_id, [])
|
| 474 |
item["comments"] = len(item["commentsData"])
|
| 475 |
-
item["latest_version"] =
|
| 476 |
|
| 477 |
# 🔴 【安全防线】:抹除敏感信息
|
| 478 |
item["has_private_token"] = bool(item.get("github_token"))
|
|
@@ -488,7 +542,7 @@ async def get_item_by_id(item_id: str):
|
|
| 488 |
async def get_item_version(item_id: str):
|
| 489 |
"""获取单个资源的最新版本号"""
|
| 490 |
versions_db = db.load_data("versions.json", default_data={})
|
| 491 |
-
return {"status": "success", "version":
|
| 492 |
|
| 493 |
@router.delete("/api/items/{item_id}")
|
| 494 |
async def delete_item(item_id: str, current_user: str = Depends(require_auth)):
|
|
|
|
| 7 |
import urllib.request
|
| 8 |
import urllib.error
|
| 9 |
import json
|
| 10 |
+
import asyncio
|
| 11 |
import 数据库连接 as db
|
| 12 |
from models import ItemCreate, ItemUpdate, RatingRequest
|
| 13 |
from 安全认证 import require_auth, check_ownership
|
|
|
|
| 16 |
|
| 17 |
router = APIRouter()
|
| 18 |
|
| 19 |
+
def _get_version_str(versions_db: dict, item_id: str) -> str:
|
| 20 |
+
"""兼容新旧格式获取版本hash字符串"""
|
| 21 |
+
val = versions_db.get(item_id, "")
|
| 22 |
+
if isinstance(val, dict):
|
| 23 |
+
return val.get("hash", "") or ""
|
| 24 |
+
return val or ""
|
| 25 |
+
|
| 26 |
def get_last_6_months():
|
| 27 |
res = []
|
| 28 |
today = datetime.date.today()
|
|
|
|
| 77 |
for item in filtered_items:
|
| 78 |
item["commentsData"] = comments_db.get(item["id"], [])
|
| 79 |
item["comments"] = len(item["commentsData"])
|
| 80 |
+
item["latest_version"] = _get_version_str(versions_db, item["id"])
|
| 81 |
|
| 82 |
# 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息!
|
| 83 |
item["has_private_token"] = bool(item.get("github_token"))
|
|
|
|
| 339 |
if update_data.title is not None: item["title"] = update_data.title
|
| 340 |
if update_data.shortDesc is not None: item["shortDesc"] = update_data.shortDesc
|
| 341 |
if update_data.fullDesc is not None: item["fullDesc"] = update_data.fullDesc
|
| 342 |
+
old_link = item.get("link", "")
|
| 343 |
if update_data.link is not None: item["link"] = update_data.link
|
| 344 |
+
result_holder["link_changed"] = old_link != item.get("link", "")
|
| 345 |
+
result_holder["new_link"] = item.get("link", "")
|
| 346 |
if update_data.coverUrl is not None: item["coverUrl"] = update_data.coverUrl
|
| 347 |
if update_data.imageUrls is not None: item["imageUrls"] = update_data.imageUrls # 🖼️ 效果展示图列表
|
| 348 |
|
|
|
|
| 394 |
sort_cache.invalidate("items:")
|
| 395 |
invalidate_cache("items.json")
|
| 396 |
|
| 397 |
+
# 🔄 [缓存刷新] 资源 link 变更时清除 ZIP 缓存元信息
|
| 398 |
+
if result_holder.get("link_changed"):
|
| 399 |
+
try:
|
| 400 |
+
versions_db = db.load_data("versions.json", default_data={})
|
| 401 |
+
entry = versions_db.get(item_id)
|
| 402 |
+
if entry is not None:
|
| 403 |
+
if isinstance(entry, str):
|
| 404 |
+
entry = {"hash": entry}
|
| 405 |
+
had_meta = "cached_at" in entry or "zip_size" in entry
|
| 406 |
+
entry.pop("cached_at", None)
|
| 407 |
+
entry.pop("zip_size", None)
|
| 408 |
+
if had_meta:
|
| 409 |
+
versions_db[item_id] = entry
|
| 410 |
+
db.save_data("versions.json", versions_db)
|
| 411 |
+
print(f"[缓存刷新] 已清除 item {item_id} 的 ZIP 缓存元信息")
|
| 412 |
+
|
| 413 |
+
# 可选:触发异步预缓存
|
| 414 |
+
new_link = result_holder.get("new_link", "")
|
| 415 |
+
if new_link and new_link.startswith("https://github.com/"):
|
| 416 |
+
try:
|
| 417 |
+
from 云端_定时版本检测引擎 import precache_github_zip
|
| 418 |
+
# 获取当前 item 的 token 和 version_hash
|
| 419 |
+
items_db = db.load_data("items.json", default_data=[])
|
| 420 |
+
token = None
|
| 421 |
+
for it in items_db:
|
| 422 |
+
if it["id"] == item_id:
|
| 423 |
+
token = it.get("github_token")
|
| 424 |
+
break
|
| 425 |
+
|
| 426 |
+
versions_db = db.load_data("versions.json", default_data={})
|
| 427 |
+
entry = versions_db.get(item_id, {})
|
| 428 |
+
version_hash = entry.get("hash", "") if isinstance(entry, dict) else entry
|
| 429 |
+
|
| 430 |
+
if version_hash:
|
| 431 |
+
asyncio.create_task(precache_github_zip(new_link, token, item_id, version_hash))
|
| 432 |
+
print(f"[缓存刷新] 已触发 item {item_id} 的异步预缓存")
|
| 433 |
+
except ImportError:
|
| 434 |
+
print(f"[缓存刷新] 预缓存模块导入失败,跳过异步预缓存")
|
| 435 |
+
except Exception as e:
|
| 436 |
+
print(f"[缓存刷新] 触发异步预缓存失败: {e}")
|
| 437 |
+
except Exception as e:
|
| 438 |
+
print(f"[缓存刷新] 缓存刷新失败(不影响主流程): {e}")
|
| 439 |
+
|
| 440 |
result = {"status": "success"}
|
| 441 |
if result_holder.get("price_change_info"):
|
| 442 |
price_change_info = result_holder["price_change_info"]
|
|
|
|
| 526 |
# 添加关联数据
|
| 527 |
item["commentsData"] = comments_db.get(item_id, [])
|
| 528 |
item["comments"] = len(item["commentsData"])
|
| 529 |
+
item["latest_version"] = _get_version_str(versions_db, item_id)
|
| 530 |
|
| 531 |
# 🔴 【安全防线】:抹除敏感信息
|
| 532 |
item["has_private_token"] = bool(item.get("github_token"))
|
|
|
|
| 542 |
async def get_item_version(item_id: str):
|
| 543 |
"""获取单个资源的最新版本号"""
|
| 544 |
versions_db = db.load_data("versions.json", default_data={})
|
| 545 |
+
return {"status": "success", "version": _get_version_str(versions_db, item_id)}
|
| 546 |
|
| 547 |
@router.delete("/api/items/{item_id}")
|
| 548 |
async def delete_item(item_id: str, current_user: str = Depends(require_auth)):
|
router_proxy.py
CHANGED
|
@@ -57,6 +57,64 @@ async def proxy_github_zip(req_data: ProxyGithubZipRequest, db: Session = Depend
|
|
| 57 |
if not owned:
|
| 58 |
return JSONResponse(content={"error": "🚨 拒绝访问:未找到购买记录!"}, status_code=403)
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
# 2. 解析 GitHub 仓库信息
|
| 61 |
repo_url = item.get("link", "").rstrip("/")
|
| 62 |
if not repo_url.startswith("https://github.com/"):
|
|
|
|
| 57 |
if not owned:
|
| 58 |
return JSONResponse(content={"error": "🚨 拒绝访问:未找到购买记录!"}, status_code=403)
|
| 59 |
|
| 60 |
+
# ===== ZIP 缓存优先机制(鉴权后、GitHub请求前) =====
|
| 61 |
+
try:
|
| 62 |
+
versions_db = json_db.load_data("versions.json", default_data={})
|
| 63 |
+
item_version = versions_db.get(req_data.item_id)
|
| 64 |
+
|
| 65 |
+
# 兼容新格式:{"item_id": {"hash": "...", "cached_at": "...", "zip_size": ...}}
|
| 66 |
+
if isinstance(item_version, dict) and item_version.get("cached_at"):
|
| 67 |
+
cache_filename = f"zips/{req_data.item_id}_{item_version['hash']}.zip"
|
| 68 |
+
cache_url = f"https://huggingface.co/datasets/ZHIWEI666/ComfyUI-Ranking/resolve/main/{cache_filename}"
|
| 69 |
+
|
| 70 |
+
cache_client = httpx.AsyncClient(follow_redirects=True)
|
| 71 |
+
head_resp = None
|
| 72 |
+
try:
|
| 73 |
+
head_resp = await cache_client.head(cache_url, timeout=10, follow_redirects=True)
|
| 74 |
+
except Exception as head_err:
|
| 75 |
+
print(f"[ZIP缓存] HEAD验证失败: {head_err}")
|
| 76 |
+
await cache_client.aclose()
|
| 77 |
+
raise
|
| 78 |
+
|
| 79 |
+
if head_resp.status_code == 200:
|
| 80 |
+
print(f"[ZIP缓存] 命中: {req_data.item_id}, 大小: {item_version.get('zip_size', 'unknown')}")
|
| 81 |
+
|
| 82 |
+
content_length = head_resp.headers.get('content-length', '') or str(item_version.get('zip_size', ''))
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
stream_resp = await cache_client.send(
|
| 86 |
+
cache_client.build_request("GET", cache_url),
|
| 87 |
+
stream=True
|
| 88 |
+
)
|
| 89 |
+
except Exception as stream_err:
|
| 90 |
+
print(f"[ZIP缓存] 流请求失败: {stream_err}")
|
| 91 |
+
await cache_client.aclose()
|
| 92 |
+
raise
|
| 93 |
+
|
| 94 |
+
cache_headers = {}
|
| 95 |
+
if content_length:
|
| 96 |
+
cache_headers["Content-Length"] = content_length
|
| 97 |
+
|
| 98 |
+
async def cache_stream_generator():
|
| 99 |
+
try:
|
| 100 |
+
if stream_resp.status_code != 200:
|
| 101 |
+
yield b"CACHE_DOWNLOAD_FAILED"
|
| 102 |
+
return
|
| 103 |
+
async for chunk in stream_resp.aiter_bytes():
|
| 104 |
+
yield chunk
|
| 105 |
+
except Exception:
|
| 106 |
+
yield b"CACHE_DOWNLOAD_FAILED"
|
| 107 |
+
finally:
|
| 108 |
+
await stream_resp.aclose()
|
| 109 |
+
await cache_client.aclose()
|
| 110 |
+
|
| 111 |
+
return StreamingResponse(cache_stream_generator(), media_type="application/zip", headers=cache_headers)
|
| 112 |
+
|
| 113 |
+
# 缓存未命中:关闭 client 后继续原有逻辑
|
| 114 |
+
await cache_client.aclose()
|
| 115 |
+
except Exception as e:
|
| 116 |
+
print(f"[ZIP缓存] 查询失败,fallback到GitHub直连: {e}")
|
| 117 |
+
|
| 118 |
# 2. 解析 GitHub 仓库信息
|
| 119 |
repo_url = item.get("link", "").rstrip("/")
|
| 120 |
if not repo_url.startswith("https://github.com/"):
|
云端_定时版本检测引擎.py
CHANGED
|
@@ -3,13 +3,46 @@ import asyncio
|
|
| 3 |
import datetime
|
| 4 |
import httpx
|
| 5 |
import logging
|
|
|
|
|
|
|
|
|
|
| 6 |
import 数据库连接 as db
|
| 7 |
from notifications import add_notification
|
| 8 |
from database_sql import SessionLocal
|
| 9 |
from models_sql import Ownership
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger("ComfyUI-Ranking.VersionCheck")
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
async def fetch_latest_github_hash(repo_url, token):
|
| 14 |
"""
|
| 15 |
请求 GitHub API 获取最新版的 Commit Hash
|
|
@@ -151,6 +184,218 @@ async def trigger_update_notifications(updated_items: list):
|
|
| 151 |
session.close()
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
async def daily_version_check_task():
|
| 155 |
"""每日 02:00 定时执行的守护进程"""
|
| 156 |
while True:
|
|
@@ -168,6 +413,9 @@ async def daily_version_check_task():
|
|
| 168 |
items_db = db.load_data("items.json", default_data=[])
|
| 169 |
versions_db = db.load_data("versions.json", default_data={})
|
| 170 |
|
|
|
|
|
|
|
|
|
|
| 171 |
updated_count = 0
|
| 172 |
updated_items = [] # 记录有更新的插件信息
|
| 173 |
|
|
@@ -176,14 +424,23 @@ async def daily_version_check_task():
|
|
| 176 |
if "github.com" in link:
|
| 177 |
token = item.get("github_token")
|
| 178 |
latest_hash = await fetch_latest_github_hash(link, token)
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
updated_count += 1
|
| 182 |
-
# 记录更新的插件信息
|
| 183 |
updated_items.append({
|
| 184 |
"id": item["id"],
|
| 185 |
"title": item.get("title", "未知插件"),
|
| 186 |
-
"version_hash": latest_hash
|
|
|
|
|
|
|
| 187 |
})
|
| 188 |
|
| 189 |
if updated_count > 0:
|
|
@@ -193,6 +450,26 @@ async def daily_version_check_task():
|
|
| 193 |
await trigger_update_notifications(updated_items)
|
| 194 |
else:
|
| 195 |
print("✅ 版本检测任务完成,暂无任何新版本发现。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
|
| 198 |
async def reset_daily_views_task():
|
|
|
|
| 3 |
import datetime
|
| 4 |
import httpx
|
| 5 |
import logging
|
| 6 |
+
import os
|
| 7 |
+
import tempfile
|
| 8 |
+
import zipfile
|
| 9 |
import 数据库连接 as db
|
| 10 |
from notifications import add_notification
|
| 11 |
from database_sql import SessionLocal
|
| 12 |
from models_sql import Ownership
|
| 13 |
+
from huggingface_hub import upload_file, HfApi
|
| 14 |
|
| 15 |
logger = logging.getLogger("ComfyUI-Ranking.VersionCheck")
|
| 16 |
|
| 17 |
+
# ==========================================
|
| 18 |
+
# 预缓存配置常量
|
| 19 |
+
# ==========================================
|
| 20 |
+
HF_REPO_ID = "ZHIWEI666/ComfyUI-Ranking"
|
| 21 |
+
ZIP_MAX_SIZE = 500 * 1024 * 1024 # 500MB
|
| 22 |
+
CACHE_EXPIRE_DAYS = 30
|
| 23 |
+
PRECACHE_BATCH_SIZE = 5
|
| 24 |
+
PRECACHE_INTERVAL = 10
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_version_hash(versions_db: dict, item_id: str) -> str | None:
|
| 28 |
+
"""兼容新旧格式获取 hash"""
|
| 29 |
+
val = versions_db.get(item_id)
|
| 30 |
+
if isinstance(val, str):
|
| 31 |
+
return val
|
| 32 |
+
elif isinstance(val, dict):
|
| 33 |
+
return val.get("hash")
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _normalize_version_entry(versions_db: dict, item_id: str):
|
| 38 |
+
"""将旧格式字符串转换为新格式字典(原地修改)"""
|
| 39 |
+
val = versions_db.get(item_id)
|
| 40 |
+
if isinstance(val, str):
|
| 41 |
+
versions_db[item_id] = {"hash": val}
|
| 42 |
+
elif not isinstance(val, dict):
|
| 43 |
+
versions_db[item_id] = {"hash": ""}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
async def fetch_latest_github_hash(repo_url, token):
|
| 47 |
"""
|
| 48 |
请求 GitHub API 获取最新版的 Commit Hash
|
|
|
|
| 184 |
session.close()
|
| 185 |
|
| 186 |
|
| 187 |
+
async def precache_github_zip(repo_url: str, token: str | None, item_id: str, version_hash: str):
|
| 188 |
+
"""预下载 GitHub ZIP 并上传到 HF Dataset 缓存"""
|
| 189 |
+
try:
|
| 190 |
+
parts = repo_url.rstrip("/").split("/")
|
| 191 |
+
if len(parts) < 2:
|
| 192 |
+
logger.warning(f"[预缓存] 无效的仓库地址: {repo_url}")
|
| 193 |
+
return False
|
| 194 |
+
owner, repo = parts[-2], parts[-1].replace(".git", "")
|
| 195 |
+
zip_url = f"https://api.github.com/repos/{owner}/{repo}/zipball"
|
| 196 |
+
|
| 197 |
+
headers = {
|
| 198 |
+
"Accept": "application/vnd.github.v3+json",
|
| 199 |
+
"User-Agent": "ComfyUI-Hub"
|
| 200 |
+
}
|
| 201 |
+
if token:
|
| 202 |
+
headers["Authorization"] = f"token {token}"
|
| 203 |
+
|
| 204 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 205 |
+
if not hf_token:
|
| 206 |
+
logger.warning("[预缓存] HF_TOKEN 环境变量未设置,跳过预缓存")
|
| 207 |
+
return False
|
| 208 |
+
|
| 209 |
+
# 下载到临时文件(stream 模式,超时 300s)
|
| 210 |
+
temp_path = None
|
| 211 |
+
async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(300.0, connect=30.0)) as client:
|
| 212 |
+
logger.info(f"[预缓存] 开始下载 {item_id} ZIP: {zip_url}")
|
| 213 |
+
async with client.stream("GET", zip_url, headers=headers) as resp:
|
| 214 |
+
if resp.status_code != 200:
|
| 215 |
+
logger.warning(f"[预缓存] 下载 ZIP 失败,状态码: {resp.status_code}, URL: {zip_url}")
|
| 216 |
+
return False
|
| 217 |
+
|
| 218 |
+
fd, temp_path = tempfile.mkstemp(suffix=".zip", prefix=f"precache_{item_id}_")
|
| 219 |
+
try:
|
| 220 |
+
total_size = 0
|
| 221 |
+
oversized = False
|
| 222 |
+
with os.fdopen(fd, "wb") as f:
|
| 223 |
+
async for chunk in resp.aiter_bytes(chunk_size=8192):
|
| 224 |
+
f.write(chunk)
|
| 225 |
+
total_size += len(chunk)
|
| 226 |
+
if total_size > ZIP_MAX_SIZE:
|
| 227 |
+
logger.warning(
|
| 228 |
+
f"[预缓存] ZIP 超过 {ZIP_MAX_SIZE // (1024*1024)}MB 限制,跳过: "
|
| 229 |
+
f"{item_id} (已下载 {total_size} 字节)"
|
| 230 |
+
)
|
| 231 |
+
oversized = True
|
| 232 |
+
break
|
| 233 |
+
|
| 234 |
+
if oversized:
|
| 235 |
+
if temp_path and os.path.exists(temp_path):
|
| 236 |
+
try:
|
| 237 |
+
os.remove(temp_path)
|
| 238 |
+
except Exception:
|
| 239 |
+
pass
|
| 240 |
+
return False
|
| 241 |
+
except Exception:
|
| 242 |
+
# 清理临时文件
|
| 243 |
+
if temp_path and os.path.exists(temp_path):
|
| 244 |
+
try:
|
| 245 |
+
os.remove(temp_path)
|
| 246 |
+
except Exception:
|
| 247 |
+
pass
|
| 248 |
+
raise
|
| 249 |
+
|
| 250 |
+
if not temp_path or not os.path.exists(temp_path):
|
| 251 |
+
logger.warning(f"[预缓存] 临时文件未生成: {item_id}")
|
| 252 |
+
return False
|
| 253 |
+
|
| 254 |
+
# 验证 ZIP 完整性
|
| 255 |
+
if not zipfile.is_zipfile(temp_path):
|
| 256 |
+
logger.warning(f"[预缓存] ZIP 文件验证失败(非有效 ZIP): {item_id}")
|
| 257 |
+
os.remove(temp_path)
|
| 258 |
+
return False
|
| 259 |
+
|
| 260 |
+
zip_size = os.path.getsize(temp_path)
|
| 261 |
+
logger.info(f"[预缓存] ZIP 下载完成: {item_id}, 大小 {zip_size} 字节")
|
| 262 |
+
|
| 263 |
+
# 上传到 HF Dataset
|
| 264 |
+
path_in_repo = f"zips/{item_id}_{version_hash}.zip"
|
| 265 |
+
try:
|
| 266 |
+
upload_file(
|
| 267 |
+
path_or_fileobj=temp_path,
|
| 268 |
+
path_in_repo=path_in_repo,
|
| 269 |
+
repo_id=HF_REPO_ID,
|
| 270 |
+
repo_type="dataset",
|
| 271 |
+
token=hf_token,
|
| 272 |
+
)
|
| 273 |
+
logger.info(f"[预缓存] 上传成功: {path_in_repo}")
|
| 274 |
+
except Exception as e:
|
| 275 |
+
logger.warning(f"[预缓存] 上传 HF 失败 ({item_id}): {e}")
|
| 276 |
+
os.remove(temp_path)
|
| 277 |
+
return False
|
| 278 |
+
|
| 279 |
+
# 更新 versions.json 缓存元信息
|
| 280 |
+
versions_db = db.load_data("versions.json", default_data={})
|
| 281 |
+
entry = versions_db.get(item_id, {})
|
| 282 |
+
if isinstance(entry, str):
|
| 283 |
+
entry = {"hash": entry}
|
| 284 |
+
entry["cached_at"] = datetime.datetime.utcnow().isoformat() + "Z"
|
| 285 |
+
entry["zip_size"] = zip_size
|
| 286 |
+
versions_db[item_id] = entry
|
| 287 |
+
db.save_data("versions.json", versions_db)
|
| 288 |
+
logger.info(f"[预缓存] 缓存元信息已更新: {item_id}")
|
| 289 |
+
|
| 290 |
+
# 清理临时文件
|
| 291 |
+
os.remove(temp_path)
|
| 292 |
+
return True
|
| 293 |
+
|
| 294 |
+
except Exception as e:
|
| 295 |
+
logger.warning(f"[预缓存] 预缓存异常 ({item_id}): {e}")
|
| 296 |
+
return False
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
async def _delete_old_zip_cache(item_id: str, old_hash: str):
|
| 300 |
+
"""删除指定 item_id + old_hash 对应的 HF Dataset 缓存 ZIP"""
|
| 301 |
+
try:
|
| 302 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 303 |
+
if not hf_token:
|
| 304 |
+
return
|
| 305 |
+
hf_api = HfApi(token=hf_token)
|
| 306 |
+
path_in_repo = f"zips/{item_id}_{old_hash}.zip"
|
| 307 |
+
try:
|
| 308 |
+
hf_api.delete_file(
|
| 309 |
+
path_in_repo=path_in_repo,
|
| 310 |
+
repo_id=HF_REPO_ID,
|
| 311 |
+
repo_type="dataset",
|
| 312 |
+
)
|
| 313 |
+
logger.info(f"[预缓存] 已删除旧版本缓存: {path_in_repo}")
|
| 314 |
+
except Exception as e:
|
| 315 |
+
error_msg = str(e)
|
| 316 |
+
if "404" in error_msg or "Not Found" in error_msg:
|
| 317 |
+
logger.info(f"[预缓存] 旧缓存文件已不存在: {path_in_repo}")
|
| 318 |
+
else:
|
| 319 |
+
logger.warning(f"[预缓存] 删除旧缓存失败: {path_in_repo}, {e}")
|
| 320 |
+
except Exception as e:
|
| 321 |
+
logger.warning(f"[预缓存] 删除旧缓存异常 ({item_id}): {e}")
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
async def cleanup_expired_cache(versions_db: dict):
|
| 325 |
+
"""清理过期的 ZIP 缓存文件"""
|
| 326 |
+
try:
|
| 327 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 328 |
+
if not hf_token:
|
| 329 |
+
logger.warning("[预缓存] HF_TOKEN 未设置,跳过缓存清理")
|
| 330 |
+
return
|
| 331 |
+
|
| 332 |
+
hf_api = HfApi(token=hf_token)
|
| 333 |
+
now = datetime.datetime.utcnow()
|
| 334 |
+
expired_items = []
|
| 335 |
+
|
| 336 |
+
for item_id, val in list(versions_db.items()):
|
| 337 |
+
if isinstance(val, str):
|
| 338 |
+
continue
|
| 339 |
+
if not isinstance(val, dict):
|
| 340 |
+
continue
|
| 341 |
+
|
| 342 |
+
old_hash = val.get("hash")
|
| 343 |
+
cached_at_str = val.get("cached_at")
|
| 344 |
+
|
| 345 |
+
# 规则1: cached_at 超过 30 天的条目
|
| 346 |
+
if cached_at_str:
|
| 347 |
+
try:
|
| 348 |
+
cached_at = datetime.datetime.fromisoformat(cached_at_str.replace("Z", "+00:00"))
|
| 349 |
+
if (now - cached_at).days > CACHE_EXPIRE_DAYS:
|
| 350 |
+
expired_items.append((item_id, old_hash, "过期"))
|
| 351 |
+
continue
|
| 352 |
+
except ValueError:
|
| 353 |
+
pass
|
| 354 |
+
|
| 355 |
+
# 规则2: 版本更新时(hash 变化)——在 daily_version_check_task 中单独处理
|
| 356 |
+
# 本函数仅处理过期时间
|
| 357 |
+
|
| 358 |
+
if not expired_items:
|
| 359 |
+
return
|
| 360 |
+
|
| 361 |
+
deleted_count = 0
|
| 362 |
+
modified_local = False
|
| 363 |
+
for item_id, old_hash, reason in expired_items:
|
| 364 |
+
if old_hash:
|
| 365 |
+
path_in_repo = f"zips/{item_id}_{old_hash}.zip"
|
| 366 |
+
try:
|
| 367 |
+
hf_api.delete_file(
|
| 368 |
+
path_in_repo=path_in_repo,
|
| 369 |
+
repo_id=HF_REPO_ID,
|
| 370 |
+
repo_type="dataset",
|
| 371 |
+
)
|
| 372 |
+
logger.info(f"[预缓存] 已删除过期缓存 ({reason}): {path_in_repo}")
|
| 373 |
+
deleted_count += 1
|
| 374 |
+
except Exception as e:
|
| 375 |
+
error_msg = str(e)
|
| 376 |
+
if "404" in error_msg or "Not Found" in error_msg:
|
| 377 |
+
logger.info(f"[预缓存] 缓存文件已不存在: {path_in_repo}")
|
| 378 |
+
else:
|
| 379 |
+
logger.warning(f"[预缓存] 删除缓存失败: {path_in_repo}, {e}")
|
| 380 |
+
|
| 381 |
+
# 清除本地 cached_at 和 zip_size(保留 hash)
|
| 382 |
+
entry = versions_db.get(item_id, {})
|
| 383 |
+
if isinstance(entry, dict):
|
| 384 |
+
had_meta = "cached_at" in entry or "zip_size" in entry
|
| 385 |
+
entry.pop("cached_at", None)
|
| 386 |
+
entry.pop("zip_size", None)
|
| 387 |
+
versions_db[item_id] = entry
|
| 388 |
+
if had_meta:
|
| 389 |
+
modified_local = True
|
| 390 |
+
|
| 391 |
+
if deleted_count > 0 or modified_local:
|
| 392 |
+
db.save_data("versions.json", versions_db)
|
| 393 |
+
logger.info(f"[预缓存] 过期缓存清理完成,共删除 {deleted_count} 个文件")
|
| 394 |
+
|
| 395 |
+
except Exception as e:
|
| 396 |
+
logger.warning(f"[预缓存] 缓存清理异常: {e}")
|
| 397 |
+
|
| 398 |
+
|
| 399 |
async def daily_version_check_task():
|
| 400 |
"""每日 02:00 定时执行的守护进程"""
|
| 401 |
while True:
|
|
|
|
| 413 |
items_db = db.load_data("items.json", default_data=[])
|
| 414 |
versions_db = db.load_data("versions.json", default_data={})
|
| 415 |
|
| 416 |
+
# 先清理过期缓存(cached_at 超过 30 天)
|
| 417 |
+
await cleanup_expired_cache(versions_db)
|
| 418 |
+
|
| 419 |
updated_count = 0
|
| 420 |
updated_items = [] # 记录有更新的插件信息
|
| 421 |
|
|
|
|
| 424 |
if "github.com" in link:
|
| 425 |
token = item.get("github_token")
|
| 426 |
latest_hash = await fetch_latest_github_hash(link, token)
|
| 427 |
+
old_hash = get_version_hash(versions_db, item["id"])
|
| 428 |
+
if latest_hash and old_hash != latest_hash:
|
| 429 |
+
# 版本更新时,删除旧 hash 对应的缓存文件
|
| 430 |
+
if old_hash:
|
| 431 |
+
await _delete_old_zip_cache(item["id"], old_hash)
|
| 432 |
+
|
| 433 |
+
# 统一转换为新格式并写入新 hash
|
| 434 |
+
_normalize_version_entry(versions_db, item["id"])
|
| 435 |
+
versions_db[item["id"]]["hash"] = latest_hash
|
| 436 |
updated_count += 1
|
| 437 |
+
# 记录更新的插件信息(包含 link/token 供预缓存使用)
|
| 438 |
updated_items.append({
|
| 439 |
"id": item["id"],
|
| 440 |
"title": item.get("title", "未知插件"),
|
| 441 |
+
"version_hash": latest_hash,
|
| 442 |
+
"link": link,
|
| 443 |
+
"github_token": token
|
| 444 |
})
|
| 445 |
|
| 446 |
if updated_count > 0:
|
|
|
|
| 450 |
await trigger_update_notifications(updated_items)
|
| 451 |
else:
|
| 452 |
print("✅ 版本检测任务完成,暂无任何新版本发现。")
|
| 453 |
+
|
| 454 |
+
# 对更新的 items 执行预缓存(最多 5 个,每个间隔 10 秒)
|
| 455 |
+
if updated_items:
|
| 456 |
+
precache_count = 0
|
| 457 |
+
batch = updated_items[:PRECACHE_BATCH_SIZE]
|
| 458 |
+
for idx, item in enumerate(batch):
|
| 459 |
+
success = await precache_github_zip(
|
| 460 |
+
item["link"],
|
| 461 |
+
item.get("github_token"),
|
| 462 |
+
item["id"],
|
| 463 |
+
item["version_hash"]
|
| 464 |
+
)
|
| 465 |
+
if success:
|
| 466 |
+
precache_count += 1
|
| 467 |
+
# 间隔 10 秒(最后一个不需要等待)
|
| 468 |
+
if idx < len(batch) - 1:
|
| 469 |
+
await asyncio.sleep(PRECACHE_INTERVAL)
|
| 470 |
+
logger.info(
|
| 471 |
+
f"[预缓存] 本次预缓存完成: {precache_count}/{len(batch)} 成功"
|
| 472 |
+
)
|
| 473 |
|
| 474 |
|
| 475 |
async def reset_daily_views_task():
|