"""Vault: ключи сервисов с ротацией и остыванием. Источник — data/vault.json (собирается tools/build_vault.py из дампов ключей). Значения живут только на диске и в памяти: в логах и панели печатается маска. Правила: * env важнее файла (FIRECRAWL_API_KEY и т.п.) — ручное управление не ломается; * ключ, получивший 401/402/403, уходит в остывание на час; 429 — на 10 минут; * round-robin, чтобы не долбить один ключ и не выжигать квоту. Использование: from . import vault key = vault.get("firecrawl") # или "" если ничего нет vault.mark("firecrawl", key, "dead") # 401/402/403 vault.mark("firecrawl", key, "rate") # 429 """ import json import os import random import time from pathlib import Path ROOT = Path(__file__).resolve().parents[1] VAULT = ROOT / "data" / "vault.json" STATE = ROOT / "data" / "vault_state.json" ENV = { "firecrawl": "FIRECRAWL_API_KEY", "tavily": "TAVILY_API_KEY", "exa": "EXA_API_KEY", "serper": "SERPER_API_KEY", "jina": "JINA_API_KEY", "apify": "APIFY_TOKEN", "2captcha": "TWOCAPTCHA_KEY", "capmonster": "CAPMONSTER_KEY", "telegram": "TG_BOT_TOKEN", } COOLDOWN = {"dead": 3600, "rate": 600, "empty": 1800} _cache = {"data": None, "state": None, "at": 0.0} def _load(): now = time.time() if _cache["data"] is not None and now - _cache["at"] < 30: return _cache["data"], _cache["state"] data, state = {}, {} try: data = json.loads(VAULT.read_text(encoding="utf-8")) except Exception: data = {} try: state = json.loads(STATE.read_text(encoding="utf-8")) except Exception: state = {} _cache.update(data=data, state=state, at=now) return data, state def _save_state(state): try: STATE.parent.mkdir(parents=True, exist_ok=True) STATE.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") except Exception: pass _cache["state"] = state def services(): data, _ = _load() return sorted(data) def keys(service): data, _ = _load() return [r["key"] for r in (data.get(service) or []) if r.get("key")] def mask(k): k = k or "" return (k[:4] + "…" + k[-2:]) if len(k) > 8 else "…" def get(service, offset=0): """Живой ключ сервиса: env, иначе ротация по пулу с учётом остывания.""" env_name = ENV.get(service) if env_name and os.environ.get(env_name): return os.environ[env_name].strip() pool = keys(service) if not pool: return "" _, state = _load() st = state.get(service, {}) now = time.time() avail = [] for k in pool: until = st.get(k, 0) if until <= now: avail.append(k) if not avail: # всё остывает — берём тот, у кого срок истекает раньше k = min(pool, key=lambda x: st.get(x, 0)) return k return avail[(offset + int(now)) % len(avail)] if len(avail) > 1 else avail[0] def mark(service, key, kind="dead"): if not key: return _, state = _load() st = state.setdefault(service, {}) st[key] = time.time() + COOLDOWN.get(kind, 600) _save_state(state) def stats(): data, state = _load() out = [] now = time.time() for svc in sorted(data): rows = data[svc] st = state.get(svc, {}) cooling = sum(1 for r in rows if st.get(r["key"], 0) > now) out.append({"service": svc, "keys": len(rows), "cooling": cooling, "ready": len(rows) - cooling, "env": bool(ENV.get(svc) and os.environ.get(ENV[svc]))}) return out