Files
grok-free-register-oss/keys/acpa_watchdog.py
chaos d10009d639 Initial commit: grok-free-register-oss
Open-source Grok free registration CLI, xai_enroller auth pipeline,
local auth service, tests and docs.
2026-07-16 21:04:05 +08:00

945 lines
33 KiB
Python
Executable File
Raw 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
"""
acpa_watchdog.py — 自动 CPA 凭据整备驻守进程
职责:
1. 盯 xai_enroller (auth-service.sh) 的出货目录:
$XAI_ENROLLER_LOCAL_AUTH_DIR/authenticated/xai-*.json
默认 ~/Downloads/grok-free-register-auth/authenticated/
xai_enroller 产出的 CPA 文件 base_url=api.x.ai/v1 (付费口, 免费号必 402)
+ headers=None (缺 grok-cli 身份头) + email=None。
2. 每个新号原样不动地「整备」一份到 PROJECT/keys/cpa_ready/xai-*.json:
- base_url → https://cli-chat-proxy.grok.com/v1 (免费 Grok 4.5 口)
- headers → grok-cli 身份头(x-grok-client-version/identifier 等)
- email → 从 id_token /access_token JWT 里挖(便于辨识, 不影响通信用)
- 权限 0600 (合规: 含 refresh_token)
3. 探活: 打 cli-chat-proxy /v1/responses model=grok-4.5
请求对齐 new-api grok-cli 完整指纹session 族头 + input 数组 + max_output_tokens 极小)。
首探 ≤MAX_PROBES(默认 1省 free 2M 额度)200 → alive。
新号落盘后 ACPA_PROBE_WARMUP_SEC(默认 3s) 再探,避免 mint 后瞬时 permission-denied。
单次探针内 403 当场短重试 ACPA_403_IMMEDIATE_RETRIES×SLEEP默认 2×4s
仍 403 → chat_denied不丢RETEST_AFTER_SEC(默认 180s) 后回测 1 次;
回测活 → alive仍 403 → chat_dead 丢。
429 free-usage-exhausted / rate_limited / 401 → 立刻丢。
alive 永不复探。
4. 状态: keys/cpa_ready/_state.tsv
name\tsub\temail\tstatus\tts\tprobes
每轮 prunejson 在 cpa_ready/_discarded 都不存在的行直接从 TSV 删掉
(删库跑路后不会残留幽灵 alive盘上有 json 但无 state 会重新进探活)。
只读 AUTH_DIR, 只写 keys/cpa_ready/。源 CPA 文件不动; 不重铸 device flow;
不做 HTTP 灌库 (入库步骤留给灌库脚本, 此处只把号整备到可直接喂 cliproxy / new-api)。
CLI:
python3 acpa_watchdog.py # 常驻轮询 (默认 2s), Ctrl-C 退
python3 acpa_watchdog.py --once # 单次扫一轮现存号就退 (CLI 自检)
python3 acpa_watchdog.py --interval 4
"""
from __future__ import annotations
import argparse
import base64
import copy
import json
import os
import signal
import sys
import time
from pathlib import Path
# ---------------------------------------------------------------------------
# paths — portable: follow project root of this script; AUTH overridable via env
# ---------------------------------------------------------------------------
PROJECT_ROOT = Path(__file__).resolve().parent.parent # .../grok-free-register
# Align with xai_enroller.service DEFAULT_LOCAL_AUTH_DIR
# (~/Downloads/grok-free-register-auth/authenticated by default)
_AUTH_BASE = Path(
os.environ.get(
"XAI_ENROLLER_LOCAL_AUTH_DIR",
os.environ.get(
"XAI_AUTH_SERVICE_LOCAL_DIR",
str(Path.home() / "Downloads" / "grok-free-register-auth"),
),
)
).expanduser()
AUTH_DIR = _AUTH_BASE / "authenticated"
OUT_DIR = PROJECT_ROOT / "keys" / "cpa_ready"
DISCARD_DIR = OUT_DIR / "_discarded"
STATE_FILE = OUT_DIR / "_state.tsv"
# grok-cli 静态身份头(对齐 new-api common.GrokCLI* / SetupRequestHeader
# session/req/agent 等 per-request 头只在探针里生成,不写进落盘 json。
CLIPROXY_BASE_URL = "https://cli-chat-proxy.grok.com/v1"
CLIPROXY_HEADERS = {
"x-grok-client-version": "0.2.93",
"x-xai-token-auth": "xai-grok-cli",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-authenticateresponse": "authenticate-response",
"x-grok-client-identifier": "grok-shell",
"x-compaction-at": "400000",
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
}
RUN = True
def _sig(signum, _frame):
global RUN
RUN = False
signal.signal(signal.SIGINT, _sig)
signal.signal(signal.SIGTERM, _sig)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def log(msg: str) -> None:
print(f"[acpa {time.strftime('%H:%M:%S')}] {msg}", flush=True)
def b64url_decode(seg: str) -> bytes:
seg += "=" * (-len(seg) % 4)
return base64.urlsafe_b64decode(seg)
def jwt_payload(token: str) -> dict:
if not token or token.count(".") != 2:
return {}
try:
return json.loads(b64url_decode(token.split(".")[1]))
except Exception:
return {}
def load_source(path: Path) -> dict | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
log(f" ✗ 读 {path.name} 失败: {exc}")
return None
def ensure_dir(p: Path) -> None:
p.mkdir(parents=True, exist_ok=True)
try:
os.chmod(p, 0o700)
except OSError:
pass
def fallback_email(src: dict) -> str:
"""xai_enroller 产的 file email 字段是 None。从 JWT 里挖一个标识。"""
for key in ("id_token", "access_token"):
pl = jwt_payload(src.get(key) or "")
for f in ("email", "preferred_username", "sub"):
v = pl.get(f)
if v:
return str(v)
return ""
def finalize(src: dict) -> tuple[str, dict]:
"""把 xai_enroller 产的 raw CPA 整备成 cli-chat-proxy 可用格式。返回 (sub, dst)。"""
dst = copy.deepcopy(src)
dst["base_url"] = CLIPROXY_BASE_URL
dst["headers"] = dict(CLIPROXY_HEADERS)
# token_endpoint 不变 (auth.x.ai/oauth2/token, refresh 用)
dst.setdefault("auth_kind", "oauth")
dst.setdefault("type", "xai")
if not dst.get("email"):
dst["email"] = fallback_email(src)
sub = dst.get("sub") or jwt_payload(dst.get("access_token", "")).get("sub", "")
dst["sub"] = sub
return sub, dst
def write_out(name: str, entry: dict) -> Path:
ensure_dir(OUT_DIR)
target = OUT_DIR / name
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_text(json.dumps(entry, separators=(",", ":"), ensure_ascii=False),
encoding="utf-8")
os.replace(tmp, target)
os.chmod(target, 0o600)
return target
# ---------------------------------------------------------------------------
# probe liveness — 使用项目自带 .venv(已装 curl_cffi)
# 完整 grok-cli 指纹;默认每号只首探 1 次free ~2M禁止连 ping
# ---------------------------------------------------------------------------
_VENV_PY = PROJECT_ROOT / ".venv" / "bin" / "python"
_PROBE_SRC = r'''
import json, sys, uuid
try:
from curl_cffi import requests
except Exception as e:
print("NO_CURL_CFFI", str(e)); sys.exit(3)
fp = sys.argv[1]
d = json.load(open(fp))
at = d.get("access_token") or ""
if not at:
print(json.dumps({"status": "token_dead", "http": 401, "snippet": "no access_token"}))
sys.exit(0)
# 对齐 new-api-fusion relay/channel/grok_cli SetupRequestHeader + common.GrokCLI*
sid = str(uuid.uuid4())
rid = str(uuid.uuid4())
hdrs = {
"Authorization": "Bearer " + at,
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-grok-client-identifier": "grok-shell",
"x-grok-client-version": "0.2.93",
"x-authenticateresponse": "authenticate-response",
"x-compaction-at": "400000",
"Connection": "Keep-Alive",
"x-grok-session-id": sid,
"x-grok-conv-id": sid,
"x-grok-req-id": rid,
"x-grok-turn-idx": "1",
"x-grok-agent-id": "agent-" + rid[:8],
"x-grok-model-override": "grok-4.5",
}
email = d.get("email") or ""
sub = d.get("sub") or ""
if email:
hdrs["x-email"] = str(email)
if sub:
hdrs["x-userid"] = str(sub)
base = (d.get("base_url") or "https://cli-chat-proxy.grok.com/v1").rstrip("/")
# 兼容 base 已带 /v1 或只有 host
if base.endswith("/responses"):
url = base
else:
url = base + "/responses"
# input 必须是 items 数组;裸字符串会被上游拒 / 误 403
# max_output_tokens 压到极小free 号额度只花探针这一枪
body = {
"model": "grok-4.5",
"store": False,
"stream": False,
"max_output_tokens": 16,
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "ok"}],
}
],
}
try:
r = requests.post(url, json=body, headers=hdrs, impersonate="chrome", timeout=45)
txt = r.text or ""
code = r.status_code
except Exception as e:
print("EXC", str(e)[:200]); sys.exit(2)
status = "unknown"
low = txt.lower()
if code == 200 and ("output" in txt or "completed" in low or '"status"' in low):
# 200 即视为通路可用;不要求模型真回 "ok"(省解码/推理)
status = "alive"
elif (
code == 429
or "free-usage-exhausted" in low
or "used all the include" in low
or "rate limit" in low
or "rate_limit" in low
):
status = "free_exhausted" if ("free-usage" in low or "used all" in low) else "rate_limited"
elif code == 402 or "personal-team-blocked" in txt or "spending-limit" in txt:
status = "no_quota_paid_end"
elif code == 403 or "permission-denied" in txt or "chat endpoint is denied" in low:
status = "chat_denied"
elif code == 401:
status = "token_dead"
elif code == 422:
# body 形态问题:标 unknown 便于发现探针回归,不直接当号死
status = "unknown"
elif "forbidden" in low:
status = "chat_denied"
print(json.dumps({"status": status, "http": code, "snippet": txt[:200]}))
sys.exit(0)
'''
# 省额度:默认首探 1 次403 当场短重试后再标 soft默认 3 分钟后延迟回测 1 次。
# alive 永不复探。429 free-usage-exhausted / token_dead 等真坏号立刻丢。
MAX_PROBES = int(
os.environ.get(
"ACPA_MAX_PROBES",
os.environ.get("ACPA_PROBE_RETRIES", "1"),
)
)
PROBE_RETRY_SLEEP = float(os.environ.get("ACPA_PROBE_RETRY_SLEEP", "1.0"))
# 新号 finalize 后稍等再首探:上游常对刚 mint 的 access 回瞬时 permission-denied
PROBE_WARMUP_SEC = float(os.environ.get("ACPA_PROBE_WARMUP_SEC", "3.0"))
# 单次 probe_one 内 403 当场短重试(不算额外 MAX_PROBES 预算)
IMMEDIATE_403_RETRIES = int(os.environ.get("ACPA_403_IMMEDIATE_RETRIES", "2"))
IMMEDIATE_403_SLEEP = float(os.environ.get("ACPA_403_IMMEDIATE_SLEEP", "4.0"))
RETEST_AFTER_SEC = int(os.environ.get("ACPA_RETEST_AFTER_SEC", "180")) # 3 分钟
RETEST_MAX = int(os.environ.get("ACPA_RETEST_MAX", "1")) # 403 延迟回测次数
# 终态不再探alive 保留;下列坏号 discard
TERMINAL_OK = frozenset({"alive"})
TERMINAL_BAD = frozenset({
"free_exhausted", # 429 免费额度耗尽 → 丢
"rate_limited", # 其它 429 → 丢
"token_dead", # 401
"no_quota_paid_end",
"chat_dead", # 5 分钟回测后仍 403 → 真坏,丢
"discarded",
})
TERMINAL_STATUSES = TERMINAL_OK | TERMINAL_BAD
# 仅这些立刻/确认后丢出 cpa_ready403 首探不在此列)
DISCARD_STATUSES = frozenset({
"free_exhausted",
"rate_limited",
"token_dead",
"no_quota_paid_end",
"chat_dead",
})
# 等 5 分钟回测的软状态(保留 json不进 acc不丢
SOFT_RETEST_STATUSES = frozenset({
"chat_denied",
"forbidden_domain_ban", # 历史误标,按 403 软状态回测
"probe_cap", # 历史:首探 2 次 403 被钉死,捞回可回测
})
def probe_one(path: Path) -> dict:
"""用子进程跑探活(隔离 curl_cffi 环境)。返回 {status, http, snippet}."""
import subprocess
cp = subprocess.run(
[str(_VENV_PY), "-c", _PROBE_SRC, str(path)],
capture_output=True, text=True, timeout=60,
)
out = cp.stdout.strip()
if out.startswith("alive") or out.lstrip().startswith("{"):
try:
idx = out.find("{")
if idx >= 0:
return json.loads(out[idx:])
except Exception:
pass
return {"status": "probe_err", "http": -1, "snippet": (out or cp.stderr[:200])[:200]}
def probe_one_soft(path: Path) -> dict:
"""
一次「逻辑探针」:底层请求 + 403/瞬时错误当场短重试。
仍计为 probe_budgeted 的 1 次 used不因短重试烧额外额度记账
"""
last = {"status": "probe_err", "http": -1, "snippet": ""}
attempts = 1 + max(0, IMMEDIATE_403_RETRIES)
for i in range(attempts):
last = probe_one(path)
st = last.get("status")
if st in (
"alive", "free_exhausted", "rate_limited",
"no_quota_paid_end", "token_dead",
):
return last
if st in ("chat_denied", "forbidden_domain_ban", "probe_err", "unknown"):
if i + 1 < attempts:
time.sleep(IMMEDIATE_403_SLEEP)
continue
break
if last.get("status") == "forbidden_domain_ban":
last = {
"status": "chat_denied",
"http": last.get("http", 403),
"snippet": last.get("snippet", "")[:200],
}
return last
def probe_budgeted(path: Path, budget: int) -> tuple[dict, int]:
"""
在 budget 次内探活。返回 (最后结果, 实际探活次数)。
alive / 额度终态立刻停403 可在预算内连探;最终 403 保持 chat_denied不改 probe_cap
"""
budget = max(0, int(budget))
if budget <= 0:
return {"status": "chat_denied", "http": -1, "snippet": "no probe budget"}, 0
last = {"status": "probe_err", "http": -1, "snippet": ""}
used = 0
for i in range(budget):
last = probe_one_soft(path)
used += 1
st = last.get("status")
if st in (
"alive", "free_exhausted", "rate_limited",
"no_quota_paid_end", "token_dead",
):
return last, used
if i + 1 < budget and st in (
"chat_denied", "forbidden_domain_ban", "probe_err", "unknown",
):
time.sleep(PROBE_RETRY_SLEEP)
continue
break
# 首探/回测结束仍是 403 → 保持 chat_denied交给延迟回测或 chat_dead
if last.get("status") in ("forbidden_domain_ban",):
last = {
"status": "chat_denied",
"http": last.get("http", 403),
"snippet": last.get("snippet", "")[:200],
}
return last, used
# ---------------------------------------------------------------------------
# state — name \t sub \t email \t status \t ts [\t probes]
# ---------------------------------------------------------------------------
def load_state() -> dict:
"""{name: {sub, email, status, ts, probes}}"""
if not STATE_FILE.exists():
return {}
st = {}
for ln in STATE_FILE.read_text(encoding="utf-8").splitlines():
parts = ln.split("\t")
if len(parts) >= 5:
name, sub, email, status, ts = parts[:5]
probes = 0
if len(parts) >= 6:
try:
probes = int(parts[5] or 0)
except ValueError:
probes = 0
# 历史行没有 probes已有终态按已探满处理避免重启后重烧额度
if probes <= 0 and status in TERMINAL_STATUSES | {
"chat_denied", "forbidden_domain_ban", "unknown", "probe_err",
}:
probes = MAX_PROBES if status != "alive" else 1
if probes <= 0 and status == "alive":
probes = 1
st[name] = {
"sub": sub, "email": email, "status": status,
"ts": ts, "probes": probes,
}
return st
def write_state(st: dict) -> None:
ensure_dir(OUT_DIR)
lines = []
for name, v in sorted(st.items()):
lines.append("\t".join([
name, v.get("sub", ""), v.get("email", ""),
v.get("status", ""), str(v.get("ts", "")),
str(int(v.get("probes") or 0)),
]))
tmp = STATE_FILE.with_suffix(".tsv.tmp")
tmp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
os.replace(tmp, STATE_FILE)
os.chmod(STATE_FILE, 0o600)
def json_exists_for(name: str) -> bool:
"""cpa_ready 主目录或 _discarded 里是否还有该号 json。"""
return (OUT_DIR / name).exists() or (DISCARD_DIR / name).exists()
def prune_orphan_state(st: dict) -> int:
"""
删库跑路 / 手工清 json 后TSV 里既无 cpa_ready 也无 _discarded 文件的行直接 drop。
避免幽灵 alive 把 sync/账本钉死。返回删除行数。
"""
if not st:
return 0
drop = [name for name in list(st.keys()) if not json_exists_for(name)]
if not drop:
return 0
for name in drop:
del st[name]
write_state(st)
log(f" 🧹 prune orphan state {len(drop)} 行(盘上无 json")
return len(drop)
def should_discard(status: str, probes: int = 0) -> bool:
"""仅真坏号丢429 额度耗尽 / 401 / 回测后仍 403(chat_dead)。首探 403 不丢。"""
return status in DISCARD_STATUSES
def discard_bad(name: str, st: dict, *, reason: str = "") -> bool:
"""
坏号移出 keys/cpa_ready/ → keys/cpa_ready/_discarded/
state 标记 discardedsync_acc 只认 alive 故 acc.md 自动剔除。
返回是否执行了丢弃。
"""
entry = st.get(name) or {}
status = entry.get("status", reason or "bad")
probes = _probes(entry) if entry else MAX_PROBES
if status == "discarded":
# 已丢过:确保 json 不在主目录
p = OUT_DIR / name
if p.exists():
ensure_dir(DISCARD_DIR)
dest = DISCARD_DIR / name
try:
os.replace(p, dest)
except OSError:
try:
p.unlink()
except OSError:
pass
return False
if not should_discard(status, probes) and not reason:
return False
ensure_dir(DISCARD_DIR)
src = OUT_DIR / name
moved = False
if src.exists():
dest = DISCARD_DIR / name
try:
if dest.exists():
dest.unlink()
os.replace(src, dest)
os.chmod(dest, 0o600)
moved = True
except OSError as exc:
log(f" ✗ 丢弃移动失败 {name}: {exc}")
try:
src.unlink()
moved = True
except OSError:
pass
st[name] = {
"sub": entry.get("sub", ""),
"email": entry.get("email", ""),
"status": "discarded",
"ts": int(time.time()),
"probes": probes if probes else MAX_PROBES,
}
# 旁路账本:便于扫为何丢
try:
ledger = DISCARD_DIR / "_discarded.tsv"
with ledger.open("a", encoding="utf-8") as fh:
fh.write(
f"{int(time.time())}\t{name}\t{entry.get('email','')}\t"
f"{status}\t{reason or status}\n"
)
os.chmod(ledger, 0o600)
except OSError:
pass
log(
f" 🗑 discard {name} [{status}]"
f"{' → _discarded/' if moved else ' (no json)'}"
f" email={entry.get('email','')}"
)
return True
def sweep_discard(st: dict) -> int:
"""扫 state + cpa_ready丢掉所有真坏号 json。返回丢弃个数。"""
n = 0
for name in list(st.keys()):
if not RUN:
break
v = st[name]
if v.get("status") == "discarded":
discard_bad(name, st) # 清残留 json
continue
# 软 403 已用完延迟回测次数 → 升格 chat_dead 再丢
if v.get("status") in SOFT_RETEST_STATUSES:
if _retests_done(v) >= RETEST_MAX and _age_sec(v) >= RETEST_AFTER_SEC:
v["status"] = "chat_dead"
st[name] = v
if should_discard(v.get("status", ""), _probes(v)):
if discard_bad(name, st):
n += 1
write_state(st)
return n
def rescue_soft_discards(st: dict) -> int:
"""
把误丢的 403/probe_cap 从 _discarded 捞回主目录,标 chat_denied
立刻可做一次延迟回测ts 回拨到已到期)。
账本原因为 soft或 state 已 discarded 但 json 仍在 _discarded 且无硬坏标记。
"""
ensure_dir(DISCARD_DIR)
ensure_dir(OUT_DIR)
soft_reasons = SOFT_RETEST_STATUSES | {"probe_cap", "chat_denied", "forbidden_domain_ban"}
soft_names: set[str] = set()
ledger = DISCARD_DIR / "_discarded.tsv"
if ledger.exists():
for ln in ledger.read_text(encoding="utf-8").splitlines():
parts = ln.split("\t")
if len(parts) >= 5:
name, prev_st, reason = parts[1], parts[3], parts[4]
if prev_st in soft_reasons or reason in soft_reasons:
soft_names.add(name)
elif len(parts) >= 4:
name, prev_st = parts[1], parts[3]
if prev_st in soft_reasons:
soft_names.add(name)
n = 0
for p in sorted(DISCARD_DIR.glob("xai-*.json")):
if not RUN:
break
name = p.name
# 账本标 soft或 state 缺/已 discarded 时默认按 soft 捞(旧误丢)
entry = st.get(name) or {}
prev = entry.get("status", "discarded")
if name not in soft_names and prev not in ("discarded", "") and prev not in soft_reasons:
continue
if name not in soft_names and prev == "discarded":
# 无账本线索:仍捞(用户确认 403 常误杀);真 429 已不在 soft 集合时靠账本
soft_names.add(name)
if name not in soft_names:
continue
dest = OUT_DIR / name
try:
if dest.exists():
dest.unlink()
os.replace(p, dest)
os.chmod(dest, 0o600)
except OSError as exc:
log(f" ✗ rescue 移动失败 {name}: {exc}")
continue
# ts 回拨:立刻进入可回测窗口(不额外空等 RETEST_AFTER_SEC
st[name] = {
"sub": entry.get("sub", ""),
"email": entry.get("email", ""),
"status": "chat_denied",
"ts": int(time.time()) - RETEST_AFTER_SEC,
"probes": MAX_PROBES, # 留给 1 次回测额度(与当前 MAX_PROBES 对齐)
}
n += 1
log(f" ↩ rescue {name} → chat_denied (retest due) probes={MAX_PROBES}")
if n:
write_state(st)
return n
# ---------------------------------------------------------------------------
# main loop
# ---------------------------------------------------------------------------
def _probes(entry: dict) -> int:
try:
return int(entry.get("probes") or 0)
except (TypeError, ValueError):
return 0
def _age_sec(entry: dict) -> int:
try:
return max(0, int(time.time()) - int(entry.get("ts") or 0))
except (TypeError, ValueError):
return RETEST_AFTER_SEC
def _retests_done(entry: dict) -> int:
"""首探预算用 MAX_PROBES超出部分算延迟回测次数。"""
return max(0, _probes(entry) - MAX_PROBES)
def should_probe(name: str, st: dict) -> bool:
"""
新号首探≤MAX_PROBES 次当场连探)。
403 软状态:满首探后等 RETEST_AFTER_SEC再给 RETEST_MAX 次回测。
429/真坏/终态:不探。
"""
if name not in st:
return True
cur = st[name].get("status", "")
if cur == "discarded" or cur in TERMINAL_STATUSES:
return False
probes = _probes(st[name])
if cur in SOFT_RETEST_STATUSES:
if _retests_done(st[name]) >= RETEST_MAX:
return False
# 首探未打满(异常中断)→ 先补完首探,不算延迟回测
if probes < MAX_PROBES:
return True
return _age_sec(st[name]) >= RETEST_AFTER_SEC
# 新号 / pending / 临时错误:未满首探预算可探
if probes < MAX_PROBES and cur in ("", "pending", "probe_err", "unknown"):
return True
return False
def process_file(name: str, st: dict, *, force_probe: bool = False) -> None:
src_path = AUTH_DIR / name
# 回测/整备也可只读 cpa_ready 已有 jsonrescue 后 AUTH 可能仍在)
out_existing = OUT_DIR / name
if not src_path.exists() and not out_existing.exists():
return
src = None
if src_path.exists():
src = load_source(src_path)
if (not src or not src.get("access_token")) and out_existing.exists():
src = load_source(out_existing)
if not src:
return
if not src.get("access_token"):
log(f"{name} 无 access_token, 跳过")
return
if name in st and st[name].get("status") == "discarded":
return
sub, entry = finalize(src)
out_path = write_out(name, entry)
already = _probes(st[name]) if name in st else 0
prev_status = st[name].get("status", "") if name in st else ""
is_soft_retest = (
prev_status in SOFT_RETEST_STATUSES
and already >= MAX_PROBES
and _retests_done(st.get(name, {})) < RETEST_MAX
)
if not force_probe and not should_probe(name, st):
if name in st and should_discard(st[name].get("status", ""), already):
discard_bad(name, st)
write_state(st)
return
# 软状态未到点:静默整备
if prev_status in SOFT_RETEST_STATUSES:
left = max(0, RETEST_AFTER_SEC - _age_sec(st[name]))
if already >= MAX_PROBES and _retests_done(st[name]) < RETEST_MAX:
log(
f"{name} 403 待回测 "
f"({left}s / retest={_retests_done(st[name])}/{RETEST_MAX})"
)
return
log(
f"{name} 整备覆盖 "
f"({prev_status or '-'}/probes={already}) → {out_path.name}"
)
return
if force_probe and already >= MAX_PROBES + RETEST_MAX and not os.environ.get("ACPA_FORCE_IGNORE_CAP"):
log(f"{name} 已 probes={already},跳过强制重探(省额度)")
if should_discard(st.get(name, {}).get("status", ""), already):
discard_bad(name, st)
write_state(st)
return
if is_soft_retest:
budget = 1
phase = f"403回测×1 (after {RETEST_AFTER_SEC}s, done={_retests_done(st[name])}/{RETEST_MAX})"
else:
budget = max(0, MAX_PROBES - already)
phase = f"首探×≤{budget} (used={already}/{MAX_PROBES})"
# 新号首探前 warmup刚 mint 的 access 常被上游瞬时 403
if not is_soft_retest and PROBE_WARMUP_SEC > 0:
time.sleep(PROBE_WARMUP_SEC)
# 新号首探前 warmup刚 mint 的 access 常被上游瞬时 403
if not is_soft_retest and PROBE_WARMUP_SEC > 0:
time.sleep(PROBE_WARMUP_SEC)
log(f"{name} 整备完成, {phase} ...")
r, used = probe_budgeted(out_path, budget)
status = r.get("status", "probe_err")
snip = r.get("snippet", "")
new_probes = already + used
# 延迟回测仍 403 → 真坏 chat_dead
if is_soft_retest and status in (
"chat_denied", "forbidden_domain_ban", "probe_cap", "probe_err", "unknown",
):
status = "chat_dead"
log(
f" {name}: [chat_dead] 回测仍 403/失败 http={r.get('http')} "
f"probes={new_probes} {snip[:70]}"
)
else:
log(
f" {name}: [{status}] http={r.get('http')} "
f"probes={new_probes} {snip[:70]}"
)
st[name] = {
"sub": sub,
"email": entry.get("email", ""),
"status": status,
"ts": int(time.time()),
"probes": new_probes,
}
if should_discard(status, new_probes):
discard_bad(name, st)
write_state(st)
def scan_once(st: dict) -> int:
"""扫一轮新号首探403 到期回测429 等真坏丢弃alive 只刷整备。"""
if not AUTH_DIR.exists():
log(f"⚠ 出货目录不存在: {AUTH_DIR} (auth-service.sh 启动后才会建)")
# 仍处理 cpa_ready 里待回测/rescue 的号
pruned = prune_orphan_state(st)
if pruned:
log(f"本轮 prune 幽灵 state {pruned}")
discarded = sweep_discard(st)
if discarded:
log(f"本轮丢弃坏号 {discarded} 个 → {DISCARD_DIR.name}/")
names: set[str] = set()
if AUTH_DIR.exists():
names.update(p.name for p in AUTH_DIR.glob("xai-*.json"))
# cpa_ready 里所有现存 json 都纳入扫描:
# - soft 到期回测
# - 无 state删 TSV 后残留 / 手工丢入)→ 当新号首探
# - alive 走 should_probe=False 分支只刷整备
if OUT_DIR.exists():
names.update(p.name for p in OUT_DIR.glob("xai-*.json"))
n = 0
for name in sorted(names):
if not RUN:
break
if name in st and st[name].get("status") == "discarded":
continue
if name in st and not should_probe(name, st):
if should_discard(st[name].get("status", ""), _probes(st[name])):
discard_bad(name, st)
write_state(st)
continue
src_path = AUTH_DIR / name if (AUTH_DIR / name).exists() else OUT_DIR / name
src = load_source(src_path) if src_path.exists() else None
if src and src.get("access_token"):
_, entry = finalize(src)
write_out(name, entry)
continue
process_file(name, st)
n += 1
return n
def main() -> int:
ap = argparse.ArgumentParser(description="auto CPA 整备驻守")
ap.add_argument("--interval", type=float, default=2.0)
ap.add_argument("--once", action="store_true", help="扫一轮就退")
ap.add_argument(
"--reprobe-soft",
action="store_true",
help="立刻对 chat_denied/probe_cap 等软 403 做回测(仍 1 次/号,受 RETEST_MAX",
)
ap.add_argument(
"--no-rescue",
action="store_true",
help="启动时不从 _discarded 捞回误丢的 403",
)
args = ap.parse_args()
ensure_dir(OUT_DIR)
ensure_dir(DISCARD_DIR)
st = load_state()
n_prune = prune_orphan_state(st)
# 历史 probe_cap 并入 soft可走延迟回测
for name, v in st.items():
if v.get("status") == "probe_cap":
v["status"] = "chat_denied"
soft_n = sum(1 for v in st.values() if v.get("status") in SOFT_RETEST_STATUSES)
bad_n = sum(
1 for v in st.values()
if v.get("status") != "discarded"
and should_discard(v.get("status", ""), _probes(v))
)
log(
f"启动 | 出货={AUTH_DIR} | 落盘={OUT_DIR} | state={len(st)}"
f"(启动 prune {n_prune}) | 首探≤{MAX_PROBES}/号 "
f"| warmup={PROBE_WARMUP_SEC}s 403即重试={IMMEDIATE_403_RETRIES}×{IMMEDIATE_403_SLEEP}s "
f"| 403回测 after={RETEST_AFTER_SEC}s ×{RETEST_MAX} "
f"| soft_403={soft_n} | pending_discard={bad_n}"
)
if not args.no_rescue:
n_res = rescue_soft_discards(st)
if n_res:
log(f"启动捞回误丢 403 {n_res} 个(将回测)")
n0 = sweep_discard(st)
if n0:
log(f"启动丢弃真坏号 {n0} 个 → {DISCARD_DIR}/")
if st and not args.reprobe_soft:
log(
f"(state 已有: 刷 alive 整备403 满 {RETEST_AFTER_SEC}s 回测×{RETEST_MAX}"
f"429 free_exhausted 立刻丢)"
)
for name in list(st.keys()):
if not RUN:
break
if st[name].get("status") != "alive":
continue
src_path = AUTH_DIR / name
if src_path.exists():
src = load_source(src_path)
if src and src.get("access_token"):
_, entry = finalize(src)
write_out(name, entry)
if args.reprobe_soft:
# soft 立刻回测一次:拨到期 + 把 probes 收成 MAX_PROBES
# 避免旧版 probes=2 在新默认 MAX_PROBES=1 下被算成 retest 已用尽。
# 只动 softalive 绝不复探(省 free 额度)。
now = int(time.time())
targets = []
for name, v in st.items():
if v.get("status") in SOFT_RETEST_STATUSES:
v["ts"] = now - RETEST_AFTER_SEC
v["probes"] = MAX_PROBES
targets.append(name)
write_state(st)
log(f"--reprobe-soft: {len(targets)} 个 403 软状态立刻回测×1alive 不动)")
for name in targets:
if not RUN:
break
process_file(name, st, force_probe=True)
write_state(st)
alive = sum(1 for v in st.values() if v.get("status") == "alive")
dead = sum(
1 for v in st.values()
if v.get("status") in DISCARD_STATUSES or v.get("status") == "discarded"
)
soft = sum(1 for v in st.values() if v.get("status") in SOFT_RETEST_STATUSES)
log(f"回测结束 | alive={alive} soft_403={soft} dead/discard={dead} state={len(st)}")
return 0
while RUN:
try:
n = scan_once(st)
if n:
log(f"本轮处理 {n} 个号(新号/回测)")
except Exception as exc:
log(f"轮询异常: {type(exc).__name__} {exc}")
if args.once:
break
slept = 0.0
while slept < args.interval and RUN:
time.sleep(0.25)
slept += 0.25
log(f"退出 | state={len(st)} 条 写 {STATE_FILE}")
write_state(st)
return 0
if __name__ == "__main__":
sys.exit(main())