Files
xiaolajiaoyyds 6f71df4849 Initial open-source release
多平台账号批量注册系统,支持 OpenAI / Grok / Kiro / Gemini 平台的全自动注册。

Features:
- 用户名/密码注册登录(首个用户自动管理员)
- JWT 认证 + bcrypt 密码加密
- 积分系统与多用户管理
- HuggingFace Space 弹性 Worker 节点管理
- Cloudflare Worker 负载均衡
- 实时日志推送(SSE/WebSocket)
- 管理后台(用户管理、系统设置、代理池、卡池)

Tech Stack: Go 1.25 / Gin / GORM / PostgreSQL / Vue 3 / Vite / TailwindCSS
2026-03-22 03:44:42 +08:00

445 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""HF Space 弹性管理器 — 自动检测封禁/扩缩容/替换
功能:
1. 健康检查所有 Space,标记封禁/不可达节点
2. 封禁节点自动删除 + 创建新 Space 替代(随机命名伪装)
3. 健康节点不足时自动扩容
4. 健康节点过剩时自动缩容(删除多余空闲节点)
5. 更新 CF Worker 环境变量(通过 Cloudflare API)
用法:
# 检查并自动修复(默认模式)
python scripts/hf_autoscaler.py --service kiro
# 只检查不修复(dry-run)
python scripts/hf_autoscaler.py --service kiro --dry-run
# 指定目标节点数
python scripts/hf_autoscaler.py --service kiro --target 10
# 所有服务
python scripts/hf_autoscaler.py --service all
依赖:
pip install huggingface_hub requests
"""
import argparse
import json
import os
import sys
import time
import concurrent.futures
from pathlib import Path
import requests
try:
from huggingface_hub import HfApi
except ImportError:
print("Error: pip install huggingface_hub requests", file=sys.stderr)
sys.exit(1)
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from hf_deploy import (
SERVICE_MAP, generate_space_name, generate_readme,
load_tokens, get_accounts, deploy_one_space,
)
# ────────────────────────────────────────────────────────────────────────────
# 配置
# ────────────────────────────────────────────────────────────────────────────
# CF Worker 环境变量名映射
CF_ENV_MAP = {
"openai": "OPENAI_SPACES",
"grok": "GROK_SPACES",
"kiro": "KIRO_SPACES",
"gemini": "GEMINI_SPACES",
"ts": "TS_SPACES",
}
# 健康检查超时(秒)
HEALTH_TIMEOUT = 8
# 默认目标节点数(每个服务)
DEFAULT_TARGET = 5
# 缩容保护:健康节点超过目标数的倍数才缩容
SCALE_DOWN_RATIO = 1.5
# ────────────────────────────────────────────────────────────────────────────
# 健康检查
# ────────────────────────────────────────────────────────────────────────────
def check_space_health(space_url: str) -> dict:
"""检查单个 Space 健康状态,返回 {url, healthy, status, reason}"""
result = {"url": space_url, "healthy": False, "status": 0, "reason": ""}
try:
resp = requests.get(
space_url.rstrip("/") + "/health",
timeout=HEALTH_TIMEOUT,
allow_redirects=True,
)
result["status"] = resp.status_code
if resp.ok:
result["healthy"] = True
elif resp.status_code == 403:
result["reason"] = "banned_or_restricted"
elif resp.status_code == 404:
result["reason"] = "not_found_or_deleted"
elif resp.status_code == 503:
result["reason"] = "sleeping_or_building"
else:
result["reason"] = f"http_{resp.status_code}"
except requests.exceptions.Timeout:
result["reason"] = "timeout"
except requests.exceptions.ConnectionError:
result["reason"] = "connection_error"
except Exception as e:
result["reason"] = str(e)[:80]
return result
def batch_health_check(space_urls: list) -> list:
"""并发健康检查所有 Space"""
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
futures = {pool.submit(check_space_health, url): url for url in space_urls}
for future in concurrent.futures.as_completed(futures):
results.append(future.result())
return results
# ────────────────────────────────────────────────────────────────────────────
# Space 管理
# ────────────────────────────────────────────────────────────────────────────
def find_space_repo_id(api: HfApi, space_url: str) -> str | None:
"""从 Space URL 反推 repo_id(格式: username/space-name)
URL 格式: https://username-space-name.hf.space
"""
try:
host = space_url.replace("https://", "").replace("http://", "").split("/")[0]
slug = host.replace(".hf.space", "")
# HF slug 格式: username-space-name,第一个 - 分隔用户名和空间名
# 但用户名本身可能含 -,所以需要通过 API 查找
# 尝试列出用户的 spaces 来匹配
parts = slug.split("-")
for i in range(1, len(parts)):
username = "-".join(parts[:i])
space_name = "-".join(parts[i:])
repo_id = f"{username}/{space_name}"
try:
api.repo_info(repo_id=repo_id, repo_type="space")
return repo_id
except Exception:
continue
except Exception:
pass
return None
def delete_space(api: HfApi, space_url: str) -> bool:
"""删除一个 Space,返回是否成功"""
repo_id = find_space_repo_id(api, space_url)
if not repo_id:
print(f" [warn] 无法解析 repo_id: {space_url}", file=sys.stderr)
return False
try:
api.delete_repo(repo_id=repo_id, repo_type="space")
print(f" [del] {repo_id}")
return True
except Exception as e:
print(f" [del-fail] {repo_id}: {e}", file=sys.stderr)
return False
# ────────────────────────────────────────────────────────────────────────────
# CF Worker 环境变量更新
# ────────────────────────────────────────────────────────────────────────────
def update_cf_worker_env(
cf_account_id: str,
cf_api_token: str,
worker_name: str,
env_key: str,
space_urls: list,
) -> bool:
"""通过 CF API 更新 Worker 的环境变量(空格分隔的 URL 列表)
使用 Settings API 的 PATCH 方式,只更新指定的 env var,不影响其他变量。
"""
url = (
f"https://api.cloudflare.com/client/v4/accounts/{cf_account_id}"
f"/workers/scripts/{worker_name}/settings"
)
headers = {
"Authorization": f"Bearer {cf_api_token}",
"Content-Type": "application/json",
}
# 读取当前 settings
try:
resp = requests.get(url, headers=headers, timeout=10)
if not resp.ok:
print(f" [cf-err] GET settings: {resp.status_code} {resp.text[:200]}", file=sys.stderr)
return False
except Exception as e:
print(f" [cf-err] GET settings: {e}", file=sys.stderr)
return False
current = resp.json().get("result", {})
bindings = current.get("bindings", [])
# 更新或添加目标 env var
value = " ".join(space_urls)
found = False
for b in bindings:
if b.get("type") == "plain_text" and b.get("name") == env_key:
b["text"] = value
found = True
break
if not found:
bindings.append({"type": "plain_text", "name": env_key, "text": value})
# PATCH 回去
try:
patch_resp = requests.patch(
url,
headers=headers,
json={"bindings": bindings},
timeout=10,
)
if patch_resp.ok:
print(f" [cf] {env_key} updated ({len(space_urls)} urls)")
return True
else:
print(f" [cf-err] PATCH: {patch_resp.status_code} {patch_resp.text[:200]}", file=sys.stderr)
return False
except Exception as e:
print(f" [cf-err] PATCH: {e}", file=sys.stderr)
return False
# ────────────────────────────────────────────────────────────────────────────
# 弹性管理核心逻辑
# ────────────────────────────────────────────────────────────────────────────
def manage_service(
service_name: str,
space_urls: list,
accounts: list,
release_url: str,
gh_pat: str,
extra_secrets: dict,
target: int,
dry_run: bool,
cf_account_id: str = "",
cf_api_token: str = "",
cf_worker_name: str = "",
) -> list:
"""对单个服务执行弹性管理,返回最终的 Space URL 列表"""
service_cfg = SERVICE_MAP[service_name]
env_key = CF_ENV_MAP[service_name]
print(f"\n{'='*60}")
print(f" Service: {service_name} Pool: {len(space_urls)} Target: {target}")
print(f"{'='*60}")
# 1. 健康检查
print(f"\n[1] Health check ({len(space_urls)} spaces)...")
results = batch_health_check(space_urls)
healthy = [r for r in results if r["healthy"]]
unhealthy = [r for r in results if not r["healthy"]]
banned = [r for r in unhealthy if r["reason"] in ("banned_or_restricted", "not_found_or_deleted")]
sleeping = [r for r in unhealthy if r["reason"] == "sleeping_or_building"]
dead = [r for r in unhealthy if r["reason"] not in ("banned_or_restricted", "not_found_or_deleted", "sleeping_or_building")]
print(f" healthy={len(healthy)} banned={len(banned)} sleeping={len(sleeping)} dead={len(dead)}")
for r in unhealthy:
print(f" [-] {r['url']} status={r['status']} reason={r['reason']}")
# 2. 删除封禁/死亡节点
to_delete = [r["url"] for r in banned + dead]
to_keep = [r["url"] for r in healthy + sleeping]
if to_delete:
print(f"\n[2] Removing {len(to_delete)} dead/banned spaces...")
if not dry_run:
# 用第一个账号的 API 来删除(需要是 Space 所有者)
for url in to_delete:
for acc in accounts:
if delete_space(acc["api"], url):
break
else:
for url in to_delete:
print(f" [dry-run] would delete: {url}")
else:
print(f"\n[2] No dead/banned spaces to remove")
# 3. 计算需要创建的数量
active_count = len(to_keep)
need_create = max(0, target - active_count)
# 额外:替换被删除的节点(至少补回被删的数量)
need_replace = len(to_delete)
need_create = max(need_create, need_replace)
if need_create > 0:
print(f"\n[3] Scaling up: creating {need_create} new spaces...")
if not dry_run:
new_urls = []
for i in range(need_create):
account = accounts[i % len(accounts)]
try:
url = deploy_one_space(account, service_cfg, release_url, gh_pat, extra_secrets)
new_urls.append(url)
print(f" [+] {url}")
except Exception as e:
print(f" [create-fail] {e}", file=sys.stderr)
if i < need_create - 1:
time.sleep(1)
to_keep.extend(new_urls)
else:
print(f" [dry-run] would create {need_create} spaces")
else:
print(f"\n[3] No scale-up needed (active={active_count} >= target={target})")
# 4. 缩容:健康节点远超目标时,删除多余的
healthy_urls = [r["url"] for r in healthy]
scale_down_threshold = int(target * SCALE_DOWN_RATIO)
excess = len(to_keep) - scale_down_threshold
if excess > 0 and len(healthy_urls) > target:
# 只删除健康的多余节点(保留 sleeping 的,它们不占资源)
removable = healthy_urls[target:] # 保留前 target 个
to_remove = removable[:excess]
print(f"\n[4] Scaling down: removing {len(to_remove)} excess spaces...")
if not dry_run:
for url in to_remove:
for acc in accounts:
if delete_space(acc["api"], url):
break
to_keep.remove(url)
else:
for url in to_remove:
print(f" [dry-run] would delete excess: {url}")
else:
print(f"\n[4] No scale-down needed")
# 5. 更新 CF Worker 环境变量
final_urls = to_keep
print(f"\n[5] Final pool: {len(final_urls)} spaces")
for url in final_urls:
print(f" {url}")
if cf_account_id and cf_api_token and cf_worker_name and not dry_run:
print(f"\n[6] Updating CF Worker env: {env_key}...")
update_cf_worker_env(cf_account_id, cf_api_token, cf_worker_name, env_key, final_urls)
elif not dry_run:
# 没有 CF 配置,输出手动更新指令
print(f"\n[6] CF Worker env not configured. Manual update:")
print(f" {env_key}={' '.join(final_urls)}")
return final_urls
# ────────────────────────────────────────────────────────────────────────────
# 入口
# ────────────────────────────────────────────────────────────────────────────
def load_space_registry(path: str) -> dict:
"""加载 Space 注册表 JSON(记录每个服务当前的 Space URL 列表)
格式: {"openai": ["url1", "url2"], "grok": [...], ...}
"""
p = Path(path)
if p.exists():
with open(p) as f:
return json.load(f)
return {}
def save_space_registry(path: str, registry: dict):
"""保存 Space 注册表"""
with open(path, "w") as f:
json.dump(registry, f, indent=2, ensure_ascii=False)
print(f"\n[saved] Registry -> {path}")
def main():
parser = argparse.ArgumentParser(description="HF Space Autoscaler")
parser.add_argument("--service", required=True, help="服务类型 (openai/grok/kiro/gemini/ts/all)")
parser.add_argument("--target", type=int, default=DEFAULT_TARGET, help=f"目标节点数 (默认 {DEFAULT_TARGET})")
parser.add_argument("--dry-run", action="store_true", help="只检查不修改")
parser.add_argument("--release-url", default="", help="GitHub Release URL(创建新 Space 时使用)")
parser.add_argument("--gh-pat", default=os.environ.get("GH_PAT", ""), help="GitHub PAT")
parser.add_argument("--tokens", default=str(REPO_ROOT / "scripts" / "hf_tokens.json"), help="HF Token JSON")
parser.add_argument("--registry", default=str(REPO_ROOT / "scripts" / "hf_registry.json"), help="Space 注册表路径")
parser.add_argument("--extra-secrets", type=json.loads, default={}, help="额外 Secrets (JSON)")
# CF Worker 配置(可选,不配置则只输出手动更新指令)
parser.add_argument("--cf-account-id", default=os.environ.get("CF_ACCOUNT_ID", ""), help="Cloudflare Account ID")
parser.add_argument("--cf-api-token", default=os.environ.get("CF_API_TOKEN", ""), help="Cloudflare API Token")
parser.add_argument("--cf-worker-name", default=os.environ.get("CF_WORKER_NAME", "hf-snow-worker"), help="CF Worker 名称")
args = parser.parse_args()
services = list(SERVICE_MAP.keys()) if args.service == "all" else [args.service]
for svc in services:
if svc not in SERVICE_MAP:
print(f"Error: unknown service '{svc}'", file=sys.stderr)
sys.exit(1)
# 加载 Token 和账号
print("[init] Loading HF tokens...")
tokens = load_tokens(args.tokens)
accounts = get_accounts(tokens)
# 加载注册表
registry = load_space_registry(args.registry)
# 逐服务处理
for svc in services:
space_urls = registry.get(svc, [])
if not space_urls and not args.release_url:
print(f"\n[skip] {svc}: no spaces in registry and no --release-url for creating new ones")
continue
final = manage_service(
service_name=svc,
space_urls=space_urls,
accounts=accounts,
release_url=args.release_url,
gh_pat=args.gh_pat,
extra_secrets=args.extra_secrets,
target=args.target,
dry_run=args.dry_run,
cf_account_id=args.cf_account_id,
cf_api_token=args.cf_api_token,
cf_worker_name=args.cf_worker_name,
)
registry[svc] = final
# 保存注册表
if not args.dry_run:
save_space_registry(args.registry, registry)
else:
print(f"\n[dry-run] Registry not saved")
print(f"\nDone.")
if __name__ == "__main__":
main()