#!/usr/bin/env python3 from pathlib import Path import ast ROOT = Path(__file__).resolve().parents[1] def replace_once(text, old, new, label): count = text.count(old) if count != 1: raise RuntimeError(f"{label}: expected one match, got {count}") return text.replace(old, new, 1) def replace_between(text, start, end, replacement, label): i = text.find(start) if i < 0: raise RuntimeError(f"{label}: start not found") j = text.find(end, i) if j < 0: raise RuntimeError(f"{label}: end not found") return text[:i] + replacement + text[j:] # 1) Harden the shared orchestration boundaries without changing registration steps. flow_path = ROOT / "registration_flow.py" flow = flow_path.read_text(encoding="utf-8") new_persist = r'''def persist_account_result(result, callbacks, ops): try: ops.persist_account_line(result.email, result.password, result.sso) saved = True save_error = "" pending_saved = False except Exception as exc: saved = False save_error = str(exc) try: pending_saved = bool( ops.queue_unsaved_result( { "email": result.email, "password": result.password, "sso": result.sso, "profile": result.profile, }, save_error, ) ) except Exception as pending_exc: pending_saved = False callbacks.log(f"[!] pending 队列写入异常: {pending_exc}") callbacks.log(f"[!] 账号已注册但主结果文件保存失败: {save_error}") if pending_saved: callbacks.log("[!] 未保存账号已写入 pending 队列,等待人工重试") else: callbacks.log("[!] pending 队列也写入失败,请立即复制当前账号信息") try: pools = ops.add_tokens(result.sso, result.email) if not isinstance(pools, dict): raise TypeError("token pool result must be a dict") except Exception as exc: callbacks.log(f"[!] token 入池后处理异常,账号结果已保留: {exc}") pools = { "internal": { "enabled": True, "ok": False, "error": str(exc), } } for name, state in pools.items(): if isinstance(state, dict) and state.get("enabled") and not state.get("ok"): callbacks.log(f"[!] grok2api {name} 入池失败: {state.get('error')}") try: cpa = ops.export_cpa(result.email, result.password, result.sso) if not isinstance(cpa, dict): raise TypeError("CPA result must be a dict") except Exception as exc: callbacks.log(f"[!] CPA 导出后处理异常,账号结果已保留: {exc}") cpa = {"ok": False, "skipped": False, "error": str(exc)} return OutputResult( registered=True, saved=saved, pending_saved=pending_saved, save_error=save_error, pools=pools, cpa=cpa, ) ''' flow = replace_between(flow, "def persist_account_result(", "def _notify_observer", new_persist, "persist_account_result") new_tail = r'''def _notify_observer(observer, result, account, output, callbacks): try: observer(result, account, output) except Exception as exc: callbacks.log(f"[Debug] observer 执行失败: {exc}") def _run_cleanup_safely(ops, callbacks, reason): try: ops.cleanup(reason) return True except Exception as exc: callbacks.log(f"[!] 清理失败,已忽略且不影响账号统计: {reason}: {exc}") return False def _prepare_next_account(result, settings, callbacks, ops): if result.processed_count >= settings.count: return False if callbacks.cancelled(): result.cancelled = True return False try: if ops.browser_missing(): ops.start_browser() else: ops.restart_browser() ops.sleep(1) return True except ops.cancelled_exception: result.cancelled = True callbacks.log("[!] 已在账号间准备阶段停止") return False def run_batch(count, callbacks, observer, ops, enable_nsfw=True, cleanup_interval=5, max_slot_retry=3, max_mail_retry=3, settings=None): if settings is None: settings = RegistrationSettings( count=int(count), enable_nsfw=bool(enable_nsfw), cleanup_interval=int(cleanup_interval), max_slot_retry=int(max_slot_retry), max_mail_retry=int(max_mail_retry), ) result = BatchResult() retry_count_for_slot = 0 last_cleanup_success_count = 0 try: ops.start_browser() callbacks.log("[*] 浏览器已启动") while result.processed_count < settings.count: if callbacks.cancelled(): result.cancelled = True break callbacks.log(f"--- 开始第 {result.processed_count + 1}/{settings.count} 个账号 ---") account = None output = None continue_batch = True try: account = register_one_account( callbacks, ops, enable_nsfw=settings.enable_nsfw, max_mail_retry=settings.max_mail_retry, ) output = persist_account_result(account, callbacks, ops) result.results.append({"registration": account, "output": output}) retry_count_for_slot = 0 result.processed_count += 1 if output.saved: result.success_count += 1 callbacks.log(f"[+] 注册并保存成功: {account.email}") if ( settings.cleanup_interval > 0 and result.success_count % settings.cleanup_interval == 0 and result.success_count != last_cleanup_success_count and result.processed_count < settings.count ): _run_cleanup_safely( ops, callbacks, f"已成功 {result.success_count} 个账号,执行定期清理", ) last_cleanup_success_count = result.success_count else: result.fail_count += 1 result.registered_unsaved_count += 1 callbacks.log(f"[-] 注册成功但持久化未完成: {account.email}") pool_warning = any( isinstance(state, dict) and state.get("enabled") and not state.get("ok") for state in output.pools.values() ) cpa_warning = bool(output.cpa and not output.cpa.get("ok") and not output.cpa.get("skipped")) if pool_warning or cpa_warning: result.postprocess_warning_count += 1 except ops.cancelled_exception: result.cancelled = True callbacks.log("[!] 注册被停止") continue_batch = False except ops.retry_exception as exc: retry_count_for_slot += 1 if retry_count_for_slot <= settings.max_slot_retry: callbacks.log( f"[!] 当前账号流程卡住,重试第 {retry_count_for_slot}/{settings.max_slot_retry} 次: {exc}" ) else: result.fail_count += 1 result.processed_count += 1 retry_count_for_slot = 0 callbacks.log(f"[-] 当前账号已达到最大重试次数,跳过: {exc}") except Exception as exc: result.fail_count += 1 result.processed_count += 1 retry_count_for_slot = 0 callbacks.log(f"[-] 注册失败: {exc}") finally: _notify_observer(observer, result, account, output, callbacks) if not continue_batch or result.cancelled: break if not _prepare_next_account(result, settings, callbacks, ops): break finally: _run_cleanup_safely(ops, callbacks, "任务结束") return result ''' flow = replace_between(flow, "def _notify_observer", " return result\n", new_tail, "flow tail") # Remove a possible duplicate return left by the marker replacement. flow = flow.replace("\n return result\n return result\n", "\n return result\n") ast.parse(flow) flow_path.write_text(flow, encoding="utf-8") # 2) Move account file and pending recovery operations to a focused module. outputs = r'''"""Account result persistence and pending recovery helpers.""" import json import os import tempfile from datetime import datetime, timezone from filelock import FileLock def append_account_line(path, email, password, sso): with open(path, "a", encoding="utf-8") as handle: handle.write(f"{email}----{password}----{sso}\n") handle.flush() os.fsync(handle.fileno()) def save_mail_credential(base_dir, email, credential): path = os.path.join(base_dir, "mail_credentials.txt") with open(path, "a", encoding="utf-8") as handle: handle.write(f"{email}\t{credential}\n") handle.flush() os.fsync(handle.fileno()) return True def queue_unsaved_account(path, payload, error): pending_path = path + ".pending.jsonl" record = dict(payload) record["save_error"] = str(error) record["queued_at"] = datetime.now(timezone.utc).isoformat() with open(pending_path, "a", encoding="utf-8") as handle: handle.write(json.dumps(record, ensure_ascii=False) + "\n") handle.flush() os.fsync(handle.fileno()) try: os.chmod(pending_path, 0o600) except Exception: pass return True def _existing_account_keys(target_path): keys = set() if not os.path.isfile(target_path): return keys with open(target_path, "r", encoding="utf-8", errors="replace") as handle: for raw_line in handle: parts = raw_line.rstrip("\n").split("----", 2) if len(parts) == 3: keys.add((parts[0].strip(), parts[2].strip())) return keys def retry_pending_file(pending_path, output_path=None, log_callback=None): logger = log_callback or (lambda message: None) pending_path = os.path.realpath(os.path.abspath(os.path.expanduser(str(pending_path)))) if not os.path.isfile(pending_path): raise FileNotFoundError(f"pending 文件不存在: {pending_path}") suffix = ".pending.jsonl" if output_path: target_path = os.path.realpath(os.path.abspath(os.path.expanduser(str(output_path)))) elif pending_path.endswith(suffix): target_path = os.path.realpath(pending_path[:-len(suffix)]) else: target_path = os.path.realpath(pending_path + ".recovered.txt") if os.path.normcase(pending_path) == os.path.normcase(target_path): raise ValueError("pending 输入文件与输出文件不能是同一个文件") lock_path = pending_path + ".lock" with FileLock(lock_path, timeout=30): if not os.path.isfile(pending_path): return {"restored": 0, "remaining": 0, "output_path": target_path} with open(pending_path, "r", encoding="utf-8") as handle: lines = handle.readlines() existing = _existing_account_keys(target_path) unresolved = [] restored = 0 for line_number, raw_line in enumerate(lines, 1): stripped = raw_line.strip() if not stripped: continue try: record = json.loads(stripped) if not isinstance(record, dict): raise ValueError("record must be a JSON object") email = str(record.get("email") or "").strip() password = str(record.get("password") or "") sso = str(record.get("sso") or "").strip() if not email or not sso: raise ValueError("record missing email or sso") key = (email, sso) if key not in existing: append_account_line(target_path, email, password, sso) existing.add(key) restored += 1 logger(f"[+] 已恢复 pending 账号: {email}") except Exception as exc: unresolved.append(raw_line if raw_line.endswith("\n") else raw_line + "\n") logger(f"[!] pending 第 {line_number} 行恢复失败: {exc}") directory = os.path.dirname(pending_path) or "." fd, temp_path = tempfile.mkstemp(prefix=".pending-retry-", suffix=".jsonl.tmp", dir=directory) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.writelines(unresolved) handle.flush() os.fsync(handle.fileno()) if unresolved: os.replace(temp_path, pending_path) temp_path = None try: os.chmod(pending_path, 0o600) except Exception: pass else: os.unlink(temp_path) temp_path = None try: os.unlink(pending_path) except FileNotFoundError: pass finally: if temp_path and os.path.exists(temp_path): os.unlink(temp_path) return {"restored": restored, "remaining": len(unresolved), "output_path": target_path} ''' outputs_path = ROOT / "account_outputs.py" ast.parse(outputs) outputs_path.write_text(outputs, encoding="utf-8") # 3) Split structural and runtime config validation; keep existing public validate_config. main_path = ROOT / "grok_register_ttk.py" main = main_path.read_text(encoding="utf-8") main = replace_once(main, "def validate_config(raw):", "def validate_config_structure(raw):", "rename structural validation") runtime_block = r'''def validate_run_requirements(cfg): cfg = validate_config_structure(cfg) provider = cfg["email_provider"] if provider == "cloudflare" and not cfg["cloudflare_api_base"]: raise ConfigError("Cloudflare 模式需要配置 cloudflare_api_base") if provider == "cloudmail": missing = [ key for key in ("cloudmail_api_base", "cloudmail_public_token", "cloudmail_domains") if not cfg[key] ] if missing: raise ConfigError("Cloud Mail 模式缺少必需配置: " + ", ".join(missing)) if provider == "yyds" and not (cfg["yyds_api_key"] or cfg["yyds_jwt"]): raise ConfigError("YYDS 模式需要至少配置 yyds_api_key 或 yyds_jwt") if cfg["grok2api_auto_add_remote"]: missing = [ key for key in ("grok2api_remote_base", "grok2api_remote_app_key") if not cfg[key] ] if missing: raise ConfigError("远端 token 入池缺少必需配置: " + ", ".join(missing)) if cfg["cpa_copy_to_hotload"] and not cfg["cpa_hotload_dir"]: raise ConfigError("启用 CPA 热加载复制时必须配置 cpa_hotload_dir") return cfg def validate_config(raw): """Backward-compatible full validation used before a run or save.""" return validate_run_requirements(raw) ''' # Remove runtime dependency checks from structural validator. old_runtime = ''' provider = cfg["email_provider"] if provider == "cloudflare" and not cfg["cloudflare_api_base"]: raise ConfigError("Cloudflare 模式需要配置 cloudflare_api_base") if provider == "cloudmail": missing = [ key for key in ("cloudmail_api_base", "cloudmail_public_token", "cloudmail_domains") if not cfg[key] ] if missing: raise ConfigError("Cloud Mail 模式缺少必需配置: " + ", ".join(missing)) if cfg["grok2api_auto_add_remote"]: missing = [ key for key in ("grok2api_remote_base", "grok2api_remote_app_key") if not cfg[key] ] if missing: raise ConfigError("远端 token 入池缺少必需配置: " + ", ".join(missing)) if cfg["cpa_copy_to_hotload"] and not cfg["cpa_hotload_dir"]: raise ConfigError("启用 CPA 热加载复制时必须配置 cpa_hotload_dir") ''' main = replace_once(main, old_runtime, "", "remove runtime checks from structure") main = replace_once(main, " return cfg\n\n\ndef load_config():", " return cfg\n\n\n" + runtime_block + "def load_config():", "insert runtime validator") main = main.replace("config = validate_config(loaded)", "config = validate_config_structure(loaded)", 1) main = main.replace("config = validate_config(DEFAULT_CONFIG.copy())", "config = validate_config_structure(DEFAULT_CONFIG.copy())", 1) # Save permits incomplete editable GUI config but still enforces structure. main = main.replace("config = validate_config(config)\n config_dir", "config = validate_config_structure(config)\n config_dir", 1) # Replace local output implementations with compatibility wrappers. start = main.find("def _save_mail_credential(") end = main.find("def run_registration_common(", start) if start < 0 or end < 0: raise RuntimeError("account output helper block not found") wrappers = r'''def _save_mail_credential(email, credential, log_callback=None): from account_outputs import save_mail_credential try: return save_mail_credential(os.path.dirname(__file__), email, credential) except Exception as exc: log_exception("保存邮箱凭据失败", exc, log_callback) return False def _append_account_line(path, email, password, sso): from account_outputs import append_account_line return append_account_line(path, email, password, sso) def _queue_unsaved_account(path, payload, error, log_callback=None): from account_outputs import queue_unsaved_account try: return queue_unsaved_account(path, payload, error) except Exception as exc: log_exception("写入账号 pending 队列失败", exc, log_callback) return False def retry_pending_file(pending_path, output_path=None, log_callback=None): from account_outputs import retry_pending_file as _retry_pending_file return _retry_pending_file(pending_path, output_path=output_path, log_callback=log_callback) ''' main = main[:start] + wrappers + main[end:] # GUI stats include all batch states. main = main.replace('self.stats_var = tk.StringVar(value="成功: 0 | 失败: 0")', 'self.stats_var = tk.StringVar(value="成功: 0 | 失败: 0 | 待恢复: 0 | 后处理警告: 0")', 1) main = main.replace('self.stats_var.set(f"成功: {event[1]} | 失败: {event[2]}")', 'self.stats_var.set(f"成功: {event[1]} | 失败: {event[2]} | 待恢复: {event[3]} | 后处理警告: {event[4]}")', 1) main = main.replace('self.ui_queue.put(("stats", self.success_count, self.fail_count))', 'self.ui_queue.put(("stats", self.success_count, self.fail_count, self.registered_unsaved_count, self.postprocess_warning_count))', 1) main = main.replace(' self.fail_count = 0\n self.results = []', ' self.fail_count = 0\n self.registered_unsaved_count = 0\n self.postprocess_warning_count = 0\n self.results = []', 1) # Initial GUI object counters. main = main.replace(' self.fail_count = 0\n self.results = []\n self.stop_requested', ' self.fail_count = 0\n self.registered_unsaved_count = 0\n self.postprocess_warning_count = 0\n self.results = []\n self.stop_requested', 1) # Replace start_registration validation/save section to gather all values, validate once, save once. old_start_fragment = ''' try: save_config() except ConfigError as exc: self.log(f"[!] 配置保存失败: {exc}") return if config["email_provider"] == "cloudflare" and not config["cloudflare_api_base"]: self.log("[!] Cloudflare 模式需要先填写 Cloudflare API Base") return if config["email_provider"] == "cloudmail": missing = [] if not config["cloudmail_api_base"]: missing.append("API Base") if not config["cloudmail_public_token"]: missing.append("Public Token") if not config["cloudmail_domains"]: missing.append("域名") if missing: self.log(f"[!] Cloud Mail 模式缺少配置: {', '.join(missing)}") return try: count = int(self.count_var.get()) except Exception: self.log("[!] 注册数量无效") return config["register_count"] = count try: save_config() except ConfigError as exc: self.log(f"[!] 配置保存失败: {exc}") return ''' new_start_fragment = ''' try: count = int(self.count_var.get()) config["register_count"] = count validated = validate_run_requirements(config) config.clear() config.update(validated) save_config() except (ValueError, ConfigError) as exc: self.log(f"[!] 配置无效或保存失败: {exc}") return ''' main = replace_once(main, old_start_fragment, new_start_fragment, "GUI single validation/save") # Observer and final GUI state expose new counters. main = main.replace(' self.fail_count = batch.fail_count\n if account is not None:', ' self.fail_count = batch.fail_count\n self.registered_unsaved_count = batch.registered_unsaved_count\n self.postprocess_warning_count = batch.postprocess_warning_count\n if account is not None:', 1) main = main.replace(' self.fail_count = batch.fail_count\n except Exception as exc:', ' self.fail_count = batch.fail_count\n self.registered_unsaved_count = batch.registered_unsaved_count\n self.postprocess_warning_count = batch.postprocess_warning_count\n self.update_stats()\n except Exception as exc:', 1) # CLI stats include all states. main = main.replace('last_stats = {"success": 0, "fail": 0}', 'last_stats = {"success": 0, "fail": 0, "pending": 0, "warnings": 0}', 1) main = main.replace(' last_stats["fail"] = batch.fail_count\n cli_log(f"[*] 当前统计: 成功 {batch.success_count} | 失败 {batch.fail_count}")', ' last_stats["fail"] = batch.fail_count\n last_stats["pending"] = batch.registered_unsaved_count\n last_stats["warnings"] = batch.postprocess_warning_count\n cli_log(f"[*] 当前统计: 成功 {batch.success_count} | 失败 {batch.fail_count} | 待恢复 {batch.registered_unsaved_count} | 后处理警告 {batch.postprocess_warning_count}")', 1) main = main.replace(' last_stats["fail"] = batch.fail_count\n except KeyboardInterrupt:', ' last_stats["fail"] = batch.fail_count\n last_stats["pending"] = batch.registered_unsaved_count\n last_stats["warnings"] = batch.postprocess_warning_count\n except KeyboardInterrupt:', 1) main = main.replace("cli_log(f\"[*] 任务结束。成功 {last_stats['success']} | 失败 {last_stats['fail']}\")", "cli_log(f\"[*] 任务结束。成功 {last_stats['success']} | 失败 {last_stats['fail']} | 待恢复 {last_stats['pending']} | 后处理警告 {last_stats['warnings']}\")", 1) # CLI performs runtime validation after structural loading. main = main.replace(' count = int(config.get("register_count", 1) or 1)\n cli_log("[*] CLI 已加载配置")', ' try:\n validated = validate_run_requirements(config)\n config.clear()\n config.update(validated)\n except ConfigError as exc:\n cli_log(f"[!] {exc}")\n return\n count = int(config.get("register_count", 1) or 1)\n cli_log("[*] CLI 已加载配置")', 1) ast.parse(main) main_path.write_text(main, encoding="utf-8") # 4) Focused regression tests for each new safety boundary. flow_tests_path = ROOT / "tests" / "test_registration_flow.py" flow_tests = flow_tests_path.read_text(encoding="utf-8") insert_tests = r''' def test_cleanup_failure_does_not_change_success_statistics(self): fake = FakeOps() ops = fake.operations() def cleanup(reason): if "已成功" in reason: raise RuntimeError("cleanup failed") fake.events.append(("cleanup", reason)) ops.cleanup = cleanup batch = run_batch(2, self.callbacks(), lambda *args: None, ops, cleanup_interval=1) self.assertEqual(batch.success_count, 2) self.assertEqual(batch.fail_count, 0) self.assertEqual(batch.processed_count, 2) def test_cancel_during_between_account_sleep_ends_normally(self): fake = FakeOps() ops = fake.operations() ops.sleep = lambda seconds: (_ for _ in ()).throw(Cancelled()) batch = run_batch(2, self.callbacks(), lambda *args: None, ops) self.assertTrue(batch.cancelled) self.assertEqual(batch.success_count, 1) self.assertEqual(batch.processed_count, 1) def test_final_cleanup_failure_does_not_hide_original_error(self): fake = FakeOps() ops = fake.operations() ops.start_browser = lambda: (_ for _ in ()).throw(RuntimeError("original start error")) ops.cleanup = lambda reason: (_ for _ in ()).throw(RuntimeError("cleanup error")) logs = [] with self.assertRaisesRegex(RuntimeError, "original start error"): run_batch(1, self.callbacks(logs), lambda *args: None, ops) self.assertTrue(any("清理失败" in line for line in logs)) def test_postprocessing_exceptions_become_warnings(self): fake = FakeOps() ops = fake.operations() ops.add_tokens = lambda sso, email: (_ for _ in ()).throw(RuntimeError("pool down")) ops.export_cpa = lambda email, password, sso: (_ for _ in ()).throw(RuntimeError("cpa down")) batch = run_batch(1, self.callbacks(), lambda *args: None, ops) self.assertEqual(batch.success_count, 1) self.assertEqual(batch.fail_count, 0) self.assertEqual(batch.postprocess_warning_count, 1) ''' flow_tests = flow_tests.replace('\n\nif __name__ == "__main__":', insert_tests + '\n\nif __name__ == "__main__":') ast.parse(flow_tests) flow_tests_path.write_text(flow_tests, encoding="utf-8") pending_tests = r'''import json import os import tempfile import unittest from account_outputs import retry_pending_file class PendingRecoveryTests(unittest.TestCase): def test_retry_is_idempotent_after_target_was_already_written(self): with tempfile.TemporaryDirectory() as directory: pending = os.path.join(directory, "accounts.txt.pending.jsonl") target = os.path.join(directory, "accounts.txt") record = {"email": "a@example.com", "password": "pw", "sso": "token"} with open(pending, "w", encoding="utf-8") as handle: handle.write(json.dumps(record) + "\n") with open(target, "w", encoding="utf-8") as handle: handle.write("a@example.com----pw----token\n") summary = retry_pending_file(pending) self.assertEqual(summary["restored"], 1) with open(target, "r", encoding="utf-8") as handle: self.assertEqual(handle.readlines(), ["a@example.com----pw----token\n"]) self.assertFalse(os.path.exists(pending)) def test_rejects_same_input_and_output_path(self): with tempfile.TemporaryDirectory() as directory: pending = os.path.join(directory, "pending.jsonl") with open(pending, "w", encoding="utf-8") as handle: handle.write("{}\n") with self.assertRaises(ValueError): retry_pending_file(pending, output_path=pending) if __name__ == "__main__": unittest.main() ''' pending_path = ROOT / "tests" / "test_pending_recovery.py" ast.parse(pending_tests) pending_path.write_text(pending_tests, encoding="utf-8") for path in (flow_path, outputs_path, main_path, flow_tests_path, pending_path): ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) print("residual safety and account outputs refactor applied")