account_survival.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. """Fixed cohort survival tracking for newly created accounts."""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. import json
  5. from pathlib import Path
  6. from typing import Any
  7. from platforms.chatgpt.fingerprint import OPENAI_FINGERPRINT_PROFILE
  8. from platforms.chatgpt.constants import OPENAI_USER_AGENT
  9. from platforms.chatgpt.pool import load_token_record
  10. from .scan import ScanResult, classify_token_file
  11. def now_iso() -> str:
  12. return datetime.now().astimezone().isoformat(timespec="seconds")
  13. def _parse_iso(value: str) -> datetime | None:
  14. raw = str(value or "").strip()
  15. if not raw:
  16. return None
  17. try:
  18. return datetime.fromisoformat(raw)
  19. except Exception:
  20. return None
  21. def _duration_seconds(started_at: str, ended_at: str) -> int | None:
  22. start_dt = _parse_iso(started_at)
  23. end_dt = _parse_iso(ended_at)
  24. if start_dt is None or end_dt is None:
  25. return None
  26. return max(0, int((end_dt - start_dt).total_seconds()))
  27. def _compact_text(value: str, limit: int = 240) -> str:
  28. return " ".join(str(value or "").split())[:limit]
  29. def _extract_error_facts(detail: str) -> tuple[str, str]:
  30. raw = str(detail or "").strip()
  31. if not raw:
  32. return "", ""
  33. try:
  34. payload = json.loads(raw)
  35. except Exception:
  36. return "", raw[:160]
  37. error = payload.get("error")
  38. if not isinstance(error, dict):
  39. return "", raw[:160]
  40. return str(error.get("code") or "").strip(), str(error.get("message") or "").strip()[:160]
  41. def _state_template(
  42. *,
  43. pool_dir: Path,
  44. cohort_size: int,
  45. proxy: str | None,
  46. timeout_seconds: int,
  47. seed_source: str = "latest_generated_pool_files",
  48. ) -> dict[str, Any]:
  49. return {
  50. "updated_at": "",
  51. "seeded_at": "",
  52. "seed_source": seed_source,
  53. "pool_dir": str(pool_dir),
  54. "cohort_size": max(1, int(cohort_size)),
  55. "proxy": str(proxy or "").strip() or None,
  56. "probe_fingerprint_profile": OPENAI_FINGERPRINT_PROFILE,
  57. "probe_user_agent": OPENAI_USER_AGENT,
  58. "timeout_seconds": max(5, int(timeout_seconds)),
  59. "members": [],
  60. "summary": {
  61. "tracked": 0,
  62. "alive": 0,
  63. "invalid": 0,
  64. "missing": 0,
  65. "removed_after_invalid": 0,
  66. "transport_error": 0,
  67. "suspicious": 0,
  68. "never_probed": 0,
  69. "first_invalid_count": 0,
  70. },
  71. "changes": [],
  72. }
  73. def load_account_survival_state(path: Path) -> dict[str, Any]:
  74. if not path.is_file():
  75. return {}
  76. try:
  77. payload = json.loads(path.read_text(encoding="utf-8"))
  78. except Exception:
  79. return {}
  80. return payload if isinstance(payload, dict) else {}
  81. def _persist_state(path: Path, payload: dict[str, Any]) -> None:
  82. path.parent.mkdir(parents=True, exist_ok=True)
  83. tmp_path = path.with_name(f"{path.name}.tmp")
  84. tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  85. tmp_path.replace(path)
  86. def _seed_member(path: Path) -> dict[str, Any] | None:
  87. try:
  88. payload = load_token_record(path)
  89. except Exception:
  90. return None
  91. email = str(payload.get("email") or "").strip()
  92. access_token = str(payload.get("access_token") or "").strip()
  93. account_id = str(payload.get("account_id") or "").strip()
  94. if not email or not access_token or not account_id:
  95. return None
  96. created_at = str(payload.get("created_at") or "").strip()
  97. if not created_at:
  98. created_at = datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds")
  99. selected_at = now_iso()
  100. return {
  101. "email": email,
  102. "file_name": path.name,
  103. "path": str(path),
  104. "created_at": created_at,
  105. "selected_at": selected_at,
  106. "first_probe_at": "",
  107. "last_probe_at": "",
  108. "probe_count": 0,
  109. "last_probe_status_code": None,
  110. "last_probe_category": "",
  111. "last_probe_detail": "",
  112. "transport_error_count": 0,
  113. "suspicious_count": 0,
  114. "missing_at": "",
  115. "removed_after_invalid_at": "",
  116. "last_missing_detail": "",
  117. "first_invalid_at": "",
  118. "first_invalid_error_code": "",
  119. "first_invalid_error_message": "",
  120. "first_use_at": "",
  121. "first_use_age_seconds": None,
  122. "first_use_fingerprint_profile": "",
  123. "fingerprint_consistent": None,
  124. "registration_fingerprint_profile": str(payload.get("registration_fingerprint_profile") or "").strip(),
  125. "registration_proxy_key": str(payload.get("registration_proxy_key") or "").strip(),
  126. "registration_proxy_region": str(payload.get("registration_proxy_region") or "").strip(),
  127. "registration_post_create_gate": str(payload.get("registration_post_create_gate") or "").strip(),
  128. "survival_seconds": None,
  129. "state": "tracking",
  130. }
  131. def _seed_members(pool_dir: Path, cohort_size: int) -> list[dict[str, Any]]:
  132. candidates: list[tuple[float, dict[str, Any]]] = []
  133. for path in pool_dir.glob("*.json"):
  134. if not path.is_file():
  135. continue
  136. member = _seed_member(path)
  137. if member is None:
  138. continue
  139. created_at = _parse_iso(str(member.get("created_at") or ""))
  140. sort_ts = created_at.timestamp() if created_at is not None else path.stat().st_mtime
  141. candidates.append((sort_ts, member))
  142. candidates.sort(key=lambda item: item[0], reverse=True)
  143. return [member for _ts, member in candidates[: max(1, int(cohort_size))]]
  144. def _member_outcome(member: dict[str, Any]) -> str:
  145. state = str(member.get("state") or "").strip()
  146. if state == "invalid_removed":
  147. return "invalid_removed"
  148. category = str(member.get("last_probe_category") or "").strip()
  149. return category or "never_probed"
  150. def _member_has_invalid_history(member: dict[str, Any]) -> bool:
  151. state = str(member.get("state") or "").strip()
  152. category = str(member.get("last_probe_category") or "").strip()
  153. return bool(str(member.get("first_invalid_at") or "").strip()) or state in {"invalid", "invalid_removed"} or category == "invalid"
  154. def _preserve_terminal_invalid(member: dict[str, Any]) -> None:
  155. if str(member.get("last_probe_category") or "").strip() != "invalid":
  156. member["last_probe_category"] = "invalid"
  157. if member.get("last_probe_status_code") in {None, ""}:
  158. member["last_probe_status_code"] = 401
  159. detail = str(member.get("last_probe_detail") or "").strip()
  160. if not detail or detail.startswith("missing_file:"):
  161. member["last_probe_detail"] = "invalid_before_pool_removal"
  162. def _update_member(member: dict[str, Any], result: ScanResult, probed_at: str) -> dict[str, Any]:
  163. previous_outcome = _member_outcome(member)
  164. member["last_probe_at"] = probed_at
  165. if not str(member.get("first_probe_at") or "").strip():
  166. member["first_probe_at"] = probed_at
  167. if not str(member.get("first_use_at") or "").strip():
  168. member["first_use_at"] = probed_at
  169. member["first_use_age_seconds"] = _duration_seconds(
  170. str(member.get("created_at") or "").strip(),
  171. probed_at,
  172. )
  173. member["first_use_fingerprint_profile"] = OPENAI_FINGERPRINT_PROFILE
  174. registration_profile = str(member.get("registration_fingerprint_profile") or "").strip()
  175. if registration_profile:
  176. member["fingerprint_consistent"] = registration_profile == OPENAI_FINGERPRINT_PROFILE
  177. member["probe_count"] = int(member.get("probe_count") or 0) + 1
  178. if result.category == "missing" and _member_has_invalid_history(member):
  179. if not str(member.get("missing_at") or "").strip():
  180. member["missing_at"] = probed_at
  181. if not str(member.get("removed_after_invalid_at") or "").strip():
  182. member["removed_after_invalid_at"] = probed_at
  183. member["last_missing_detail"] = _compact_text(result.detail or "")
  184. _preserve_terminal_invalid(member)
  185. member["state"] = "invalid_removed"
  186. next_outcome = _member_outcome(member)
  187. detail = _compact_text(
  188. f"removed_after_invalid | {member.get('last_missing_detail') or ''}"
  189. )
  190. return {
  191. "email": str(member.get("email") or "").strip(),
  192. "from": previous_outcome,
  193. "to": next_outcome,
  194. "probed_at": probed_at,
  195. "survival_seconds": member.get("survival_seconds"),
  196. "detail": detail,
  197. }
  198. if result.category != "invalid" and _member_has_invalid_history(member):
  199. member["post_invalid_probe_at"] = probed_at
  200. member["post_invalid_probe_category"] = result.category
  201. member["post_invalid_probe_detail"] = _compact_text(result.detail or "")
  202. _preserve_terminal_invalid(member)
  203. member["state"] = "invalid"
  204. next_outcome = _member_outcome(member)
  205. return {
  206. "email": str(member.get("email") or "").strip(),
  207. "from": previous_outcome,
  208. "to": next_outcome,
  209. "probed_at": probed_at,
  210. "survival_seconds": member.get("survival_seconds"),
  211. "detail": member["post_invalid_probe_detail"],
  212. }
  213. member["last_probe_status_code"] = result.status_code
  214. member["last_probe_category"] = result.category
  215. member["last_probe_detail"] = _compact_text(result.detail or "")
  216. if result.category == "transport_error":
  217. member["transport_error_count"] = int(member.get("transport_error_count") or 0) + 1
  218. elif result.category == "suspicious":
  219. member["suspicious_count"] = int(member.get("suspicious_count") or 0) + 1
  220. elif result.category == "missing" and not str(member.get("missing_at") or "").strip():
  221. member["missing_at"] = probed_at
  222. if result.category == "invalid":
  223. if not str(member.get("first_invalid_at") or "").strip():
  224. member["first_invalid_at"] = probed_at
  225. survival_seconds = _duration_seconds(
  226. str(member.get("created_at") or "").strip() or str(member.get("first_probe_at") or "").strip(),
  227. probed_at,
  228. )
  229. member["survival_seconds"] = survival_seconds
  230. error_code, error_message = _extract_error_facts(result.detail or "")
  231. member["first_invalid_error_code"] = error_code
  232. member["first_invalid_error_message"] = error_message
  233. member["state"] = "invalid"
  234. elif result.category == "missing":
  235. member["state"] = "missing"
  236. else:
  237. member["state"] = "tracking"
  238. next_outcome = _member_outcome(member)
  239. return {
  240. "email": str(member.get("email") or "").strip(),
  241. "from": previous_outcome,
  242. "to": next_outcome,
  243. "probed_at": probed_at,
  244. "survival_seconds": member.get("survival_seconds"),
  245. "detail": member["last_probe_detail"],
  246. }
  247. def _build_summary(members: list[dict[str, Any]]) -> dict[str, int]:
  248. summary = {
  249. "tracked": len(members),
  250. "alive": 0,
  251. "invalid": 0,
  252. "missing": 0,
  253. "removed_after_invalid": 0,
  254. "transport_error": 0,
  255. "suspicious": 0,
  256. "never_probed": 0,
  257. "first_invalid_count": 0,
  258. }
  259. for member in members:
  260. outcome = _member_outcome(member)
  261. if outcome == "never_probed":
  262. summary["never_probed"] += 1
  263. elif outcome == "normal":
  264. summary["alive"] += 1
  265. elif outcome == "invalid":
  266. summary["invalid"] += 1
  267. elif outcome == "invalid_removed":
  268. summary["invalid"] += 1
  269. summary["removed_after_invalid"] += 1
  270. elif outcome == "missing":
  271. summary["missing"] += 1
  272. elif outcome == "transport_error":
  273. summary["transport_error"] += 1
  274. else:
  275. summary["suspicious"] += 1
  276. if str(member.get("first_invalid_at") or "").strip():
  277. summary["first_invalid_count"] += 1
  278. return summary
  279. def account_survival_once(
  280. *,
  281. pool_dir: Path,
  282. state_file: Path,
  283. cohort_size: int,
  284. proxy: str | None,
  285. timeout_seconds: int,
  286. reseed: bool = False,
  287. ) -> dict[str, Any]:
  288. state = load_account_survival_state(state_file)
  289. seeded = False
  290. reseeded = False
  291. if not state or reseed:
  292. state = _state_template(
  293. pool_dir=pool_dir,
  294. cohort_size=cohort_size,
  295. proxy=proxy,
  296. timeout_seconds=timeout_seconds,
  297. )
  298. state["members"] = _seed_members(pool_dir, int(state.get("cohort_size") or cohort_size))
  299. state["seeded_at"] = now_iso()
  300. seeded = True
  301. reseeded = reseed
  302. else:
  303. state.setdefault("pool_dir", str(pool_dir))
  304. state.setdefault("cohort_size", max(1, int(cohort_size)))
  305. state.setdefault("proxy", str(proxy or "").strip() or None)
  306. state.setdefault("probe_fingerprint_profile", OPENAI_FINGERPRINT_PROFILE)
  307. state.setdefault("probe_user_agent", OPENAI_USER_AGENT)
  308. state.setdefault("timeout_seconds", max(5, int(timeout_seconds)))
  309. state.setdefault("members", [])
  310. state.setdefault("summary", {})
  311. state.setdefault("changes", [])
  312. state.setdefault("seed_source", "latest_generated_pool_files")
  313. if not isinstance(state.get("members"), list):
  314. state["members"] = []
  315. if not state["members"]:
  316. state["members"] = _seed_members(pool_dir, int(state.get("cohort_size") or cohort_size))
  317. state["seeded_at"] = now_iso()
  318. seeded = True
  319. changes: list[dict[str, Any]] = []
  320. for raw_member in state["members"]:
  321. if not isinstance(raw_member, dict):
  322. continue
  323. member = raw_member
  324. probed_at = now_iso()
  325. result = classify_token_file(
  326. Path(str(member.get("path") or "")),
  327. str(state.get("proxy") or "").strip() or None,
  328. max(5, int(state.get("timeout_seconds") or timeout_seconds)),
  329. )
  330. change = _update_member(member, result, probed_at)
  331. if change["from"] != change["to"]:
  332. changes.append(change)
  333. state["updated_at"] = now_iso()
  334. state["summary"] = _build_summary([member for member in state["members"] if isinstance(member, dict)])
  335. state["changes"] = changes
  336. state["seeded"] = seeded
  337. state["reseeded"] = reseeded
  338. state["state_file"] = str(state_file)
  339. _persist_state(state_file, state)
  340. return state
  341. def print_account_survival_summary(result: dict[str, Any]) -> None:
  342. summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
  343. tracked = int(summary.get("tracked") or 0)
  344. alive = int(summary.get("alive") or 0)
  345. invalid = int(summary.get("invalid") or 0)
  346. missing = int(summary.get("missing") or 0)
  347. removed_after_invalid = int(summary.get("removed_after_invalid") or 0)
  348. transport_error = int(summary.get("transport_error") or 0)
  349. suspicious = int(summary.get("suspicious") or 0)
  350. state_file = str(result.get("state_file") or "")
  351. print(
  352. f"[survival] summary | tracked={tracked} | alive={alive} | invalid={invalid} "
  353. f"| missing={missing} | removed_after_invalid={removed_after_invalid} "
  354. f"| transport_error={transport_error} | suspicious={suspicious}"
  355. )
  356. if result.get("seeded"):
  357. members = result.get("members") if isinstance(result.get("members"), list) else []
  358. emails = ", ".join(
  359. str(item.get("email") or "").strip()
  360. for item in members
  361. if isinstance(item, dict) and str(item.get("email") or "").strip()
  362. )
  363. print(f"[survival] seeded fixed cohort | count={len(members)} | members={emails}")
  364. for change in result.get("changes") or []:
  365. if not isinstance(change, dict):
  366. continue
  367. survival_seconds = change.get("survival_seconds")
  368. survival_text = f" | survival={survival_seconds}s" if survival_seconds is not None else ""
  369. print(
  370. f"[survival] state change | {change.get('email') or '?'} | "
  371. f"{change.get('from') or 'never_probed'} -> {change.get('to') or '?'}{survival_text}"
  372. )
  373. if state_file:
  374. print(f"[survival] state={state_file}")