scan.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. """Low-frequency token scan for local pool files."""
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. import threading
  6. from concurrent.futures import ThreadPoolExecutor, as_completed
  7. from dataclasses import asdict, dataclass
  8. from datetime import datetime
  9. from pathlib import Path
  10. from curl_cffi import requests
  11. from platforms.chatgpt.fingerprint import OPENAI_FINGERPRINT_PROFILE, build_browser_headers
  12. from .common import DEFAULT_POOL_DIR
  13. DEFAULT_TIMEOUT = 15
  14. DEFAULT_WORKERS = 5
  15. DEFAULT_TRANSPORT_MAX_ATTEMPTS = 3
  16. VALIDATE_URL = "https://chatgpt.com/backend-api/wham/usage"
  17. RESPONSES_VALIDATE_URL = "https://chatgpt.com/backend-api/codex/responses"
  18. PROBE_FINGERPRINT_PROFILE = OPENAI_FINGERPRINT_PROFILE
  19. THREAD_LOCAL = threading.local()
  20. PROJECT_DIR = Path(__file__).resolve().parents[1]
  21. DEFAULT_OUTPUT_DIR = PROJECT_DIR / "logs"
  22. RESPONSES_PROBE_PAYLOAD = {
  23. "model": "gpt-5.4",
  24. "instructions": "Return exactly OK.",
  25. "input": [{"role": "user", "content": "ping"}],
  26. "stream": True,
  27. "store": False,
  28. "text": {"verbosity": "low"},
  29. }
  30. @dataclass(frozen=True)
  31. class ScanResult:
  32. file: str
  33. category: str
  34. status_code: int | None
  35. detail: str
  36. def now_iso() -> str:
  37. return datetime.now().astimezone().isoformat(timespec="seconds")
  38. def compact_text(value: str, limit: int = 240) -> str:
  39. return " ".join(str(value or "").split())[:limit]
  40. def get_session() -> requests.Session:
  41. session = getattr(THREAD_LOCAL, "session", None)
  42. if session is None:
  43. session = requests.Session()
  44. THREAD_LOCAL.session = session
  45. return session
  46. def reset_session() -> None:
  47. session = getattr(THREAD_LOCAL, "session", None)
  48. if session is None:
  49. return
  50. try:
  51. session.close()
  52. except Exception:
  53. pass
  54. THREAD_LOCAL.session = None
  55. def is_transient_transport_error(exc: Exception) -> bool:
  56. message = str(exc or "").lower()
  57. markers = (
  58. "connection closed abruptly",
  59. "connection timed out",
  60. "connection reset",
  61. "connection refused",
  62. "tls connect error",
  63. "recv failure",
  64. "send failure",
  65. "http/2 stream",
  66. "operation timed out",
  67. "unexpected eof",
  68. "tls handshake timeout",
  69. "eof",
  70. "curl: (7)",
  71. "curl: (28)",
  72. "curl: (35)",
  73. "curl: (52)",
  74. "curl: (55)",
  75. "curl: (56)",
  76. "curl: (16)",
  77. "nghttp2",
  78. )
  79. return any(marker in message for marker in markers)
  80. def iter_token_files(token_dir: Path, limit: int | None = None) -> list[Path]:
  81. files = sorted(path for path in token_dir.glob("*.json") if path.is_file())
  82. if limit is not None and limit >= 0:
  83. return files[:limit]
  84. return files
  85. def _load_token_payload(path: Path) -> tuple[dict[str, object] | None, ScanResult | None]:
  86. try:
  87. payload = json.loads(path.read_text(encoding="utf-8"))
  88. except FileNotFoundError as exc:
  89. return None, ScanResult(file=path.name, category="missing", status_code=None, detail=f"missing_file: {exc}")
  90. except Exception as exc:
  91. return None, ScanResult(file=path.name, category="suspicious", status_code=None, detail=f"invalid_json: {exc}")
  92. if not isinstance(payload, dict):
  93. return None, ScanResult(file=path.name, category="suspicious", status_code=None, detail="invalid_json: token record must be object")
  94. return payload, None
  95. def _extract_credentials(path: Path, payload: dict[str, object]) -> tuple[str, str] | ScanResult:
  96. access_token = str(payload.get("access_token") or "").strip()
  97. account_id = str(payload.get("account_id") or "").strip()
  98. if not access_token or not account_id:
  99. return ScanResult(
  100. file=path.name,
  101. category="suspicious",
  102. status_code=None,
  103. detail="missing access_token or account_id",
  104. )
  105. return access_token, account_id
  106. def _request_with_retry(
  107. method: str,
  108. url: str,
  109. *,
  110. headers: dict[str, str],
  111. json_body: object | None,
  112. proxy: str | None,
  113. timeout: int,
  114. ) -> ScanResult | object:
  115. proxies = {"http": proxy, "https": proxy} if proxy else None
  116. last_exc: Exception | None = None
  117. response = None
  118. for attempt in range(1, DEFAULT_TRANSPORT_MAX_ATTEMPTS + 1):
  119. try:
  120. request_fn = getattr(get_session(), method.lower())
  121. response = request_fn(
  122. url,
  123. headers=headers,
  124. json=json_body,
  125. proxies=proxies,
  126. impersonate="chrome",
  127. timeout=timeout,
  128. )
  129. break
  130. except Exception as exc:
  131. last_exc = exc
  132. if not is_transient_transport_error(exc):
  133. return ScanResult(file="", category="suspicious", status_code=None, detail=f"request_error: {exc}")
  134. reset_session()
  135. if attempt >= DEFAULT_TRANSPORT_MAX_ATTEMPTS:
  136. return ScanResult(file="", category="transport_error", status_code=None, detail=f"transport_error: {exc}")
  137. if response is None:
  138. return ScanResult(file="", category="transport_error", status_code=None, detail=f"transport_error: {last_exc or 'request failed'}")
  139. return response
  140. def _probe_usage_path(path: Path, access_token: str, account_id: str, proxy: str | None, timeout: int) -> ScanResult:
  141. response = _request_with_retry(
  142. "GET",
  143. VALIDATE_URL,
  144. headers=build_browser_headers(
  145. access_token=access_token,
  146. account_id=account_id,
  147. accept="application/json",
  148. content_type="application/json",
  149. ),
  150. json_body=None,
  151. proxy=proxy,
  152. timeout=timeout,
  153. )
  154. if isinstance(response, ScanResult):
  155. return ScanResult(file=path.name, category=response.category, status_code=response.status_code, detail=response.detail)
  156. detail = compact_text(response.text)
  157. if response.status_code == 200:
  158. return ScanResult(file=path.name, category="normal", status_code=200, detail=detail)
  159. if response.status_code == 401:
  160. return ScanResult(file=path.name, category="invalid", status_code=401, detail=detail)
  161. if response.status_code == 429:
  162. return ScanResult(file=path.name, category="rate_limited", status_code=429, detail=detail)
  163. if int(response.status_code or 0) >= 500:
  164. return ScanResult(file=path.name, category="service_error", status_code=int(response.status_code), detail=detail)
  165. return ScanResult(file=path.name, category="suspicious", status_code=response.status_code, detail=detail)
  166. def _probe_responses_path(path: Path, access_token: str, account_id: str, proxy: str | None, timeout: int) -> ScanResult:
  167. response = _request_with_retry(
  168. "POST",
  169. RESPONSES_VALIDATE_URL,
  170. headers=build_browser_headers(
  171. access_token=access_token,
  172. account_id=account_id,
  173. accept="text/event-stream",
  174. content_type="application/json",
  175. ),
  176. json_body=RESPONSES_PROBE_PAYLOAD,
  177. proxy=proxy,
  178. timeout=max(timeout, 20),
  179. )
  180. if isinstance(response, ScanResult):
  181. return ScanResult(file=path.name, category=response.category, status_code=response.status_code, detail=response.detail)
  182. detail = compact_text(response.text, limit=320)
  183. if response.status_code == 200:
  184. if "response.failed" in detail or '"status":"failed"' in detail:
  185. return ScanResult(file=path.name, category="service_error", status_code=200, detail=detail)
  186. return ScanResult(file=path.name, category="normal", status_code=200, detail="responses_ok")
  187. if response.status_code == 401:
  188. return ScanResult(file=path.name, category="invalid", status_code=401, detail=detail)
  189. if response.status_code == 429:
  190. return ScanResult(file=path.name, category="rate_limited", status_code=429, detail=detail)
  191. if int(response.status_code or 0) >= 500:
  192. return ScanResult(file=path.name, category="service_error", status_code=int(response.status_code), detail=detail)
  193. return ScanResult(file=path.name, category="suspicious", status_code=response.status_code, detail=detail)
  194. def classify_token_file(
  195. path: Path,
  196. proxy: str | None,
  197. timeout: int,
  198. *,
  199. require_response_path: bool = False,
  200. ) -> ScanResult:
  201. payload, load_error = _load_token_payload(path)
  202. if load_error is not None:
  203. return load_error
  204. assert payload is not None
  205. credentials = _extract_credentials(path, payload)
  206. if isinstance(credentials, ScanResult):
  207. return credentials
  208. access_token, account_id = credentials
  209. usage_result = _probe_usage_path(path, access_token, account_id, proxy, timeout)
  210. if not require_response_path or usage_result.category != "normal":
  211. return usage_result
  212. response_result = _probe_responses_path(path, access_token, account_id, proxy, timeout)
  213. if response_result.category == "normal":
  214. return ScanResult(file=path.name, category="normal", status_code=200, detail="usage_ok | responses_ok")
  215. return response_result
  216. def scan_once(
  217. token_dir: Path,
  218. proxy: str | None,
  219. timeout: int,
  220. workers: int,
  221. output_dir: Path,
  222. limit: int | None = None,
  223. ) -> dict[str, object]:
  224. output_dir.mkdir(parents=True, exist_ok=True)
  225. files = iter_token_files(token_dir, limit=limit)
  226. results: list[ScanResult] = []
  227. with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
  228. future_map = {executor.submit(classify_token_file, path, proxy, timeout): path for path in files}
  229. for future in as_completed(future_map):
  230. results.append(future.result())
  231. results.sort(key=lambda item: item.file)
  232. summary = {
  233. "total": len(results),
  234. "normal": sum(1 for item in results if item.category == "normal"),
  235. "invalid": sum(1 for item in results if item.category == "invalid"),
  236. "rate_limited": sum(1 for item in results if item.category == "rate_limited"),
  237. "suspicious": sum(1 for item in results if item.category == "suspicious"),
  238. "service_error": sum(1 for item in results if item.category == "service_error"),
  239. "transport_error": sum(1 for item in results if item.category == "transport_error"),
  240. "missing": sum(1 for item in results if item.category == "missing"),
  241. }
  242. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  243. report_path = output_dir / f"scan_report_{timestamp}.json"
  244. payload = {
  245. "generated_at": now_iso(),
  246. "token_dir": str(token_dir),
  247. "proxy": proxy,
  248. "timeout_seconds": timeout,
  249. "workers": workers,
  250. "limit": limit,
  251. "summary": summary,
  252. "results": [asdict(item) for item in results],
  253. }
  254. report_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  255. return {
  256. "summary": summary,
  257. "report_path": str(report_path),
  258. "results": payload["results"],
  259. }
  260. def main() -> None:
  261. parser = argparse.ArgumentParser(description="Low-frequency scan for local zhuce6 token files")
  262. parser.add_argument("--token-dir", default=str(DEFAULT_POOL_DIR), help="Token directory, default zhuce6 pool")
  263. parser.add_argument("--proxy", default=None, help="Optional proxy URL")
  264. parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="Per-request timeout seconds")
  265. parser.add_argument("--workers", type=int, default=DEFAULT_WORKERS, help="Concurrent workers")
  266. parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR), help="Report output directory")
  267. parser.add_argument("--limit", type=int, default=None, help="Optional cap for scanned files")
  268. args = parser.parse_args()
  269. token_dir = Path(args.token_dir).expanduser().resolve()
  270. if not token_dir.is_dir():
  271. raise SystemExit(f"token directory does not exist: {token_dir}")
  272. summary = scan_once(
  273. token_dir=token_dir,
  274. proxy=str(args.proxy or "").strip() or None,
  275. timeout=max(1, int(args.timeout)),
  276. workers=max(1, int(args.workers)),
  277. output_dir=Path(args.output_dir).expanduser().resolve(),
  278. limit=args.limit,
  279. )
  280. stats = summary["summary"]
  281. print(f"[scan] dir={token_dir}")
  282. print(f"[scan] total={stats['total']} normal={stats['normal']} invalid={stats['invalid']} suspicious={stats['suspicious']}")
  283. print(f"[scan] report={summary['report_path']}")
  284. if __name__ == "__main__":
  285. main()