"""CLI entry point for chatgpt-register-sub2api. Subcommands: init Write default config.yaml register Register N ChatGPT accounts join-workspace Join registered accounts to K12 workspace login-team Re-login with Team space selection export Export to sub2api JSON run Full pipeline (register → join → login → export) """ from __future__ import annotations import argparse import logging import sys from pathlib import Path from chatgpt_register_sub2api import __version__ from chatgpt_register_sub2api.config import ( DEFAULT_CONFIG_FILE, generate_default_config, load_config, ) from chatgpt_register_sub2api.pipeline import ( load_accounts, run_export, run_full_pipeline, run_join_workspace, run_re_login, run_register, save_accounts, ) from chatgpt_register_sub2api.export.checker import check_sub2api_file from chatgpt_register_sub2api.export.cpa import export_cpa_json_files, export_cpa_zip from chatgpt_register_sub2api.pool import clean_pool from chatgpt_register_sub2api.cdk import generate_cdk def setup_logging(config: dict, verbose: bool = False) -> None: """Configure Python logging based on config + CLI verbosity.""" log_cfg = config.get("logging", {}) level_name = "DEBUG" if verbose else str(log_cfg.get("level", "INFO")).upper() level = getattr(logging, level_name, logging.INFO) fmt = "%(asctime)s [%(levelname)-5s] %(message)s" datefmt = "%H:%M:%S" log_file = str(log_cfg.get("file", "")).strip() if log_file: logging.basicConfig( level=level, format=fmt, datefmt=datefmt, handlers=[ logging.FileHandler(log_file, encoding="utf-8"), logging.StreamHandler(sys.stderr), ], ) else: logging.basicConfig( level=level, format=fmt, datefmt=datefmt, stream=sys.stderr, ) def cmd_init(args) -> int: """Write default config.yaml.""" path = Path(args.config) if args.config else DEFAULT_CONFIG_FILE try: output = generate_default_config(path) print(f"Config written to {output}") return 0 except FileExistsError as e: print(f"Error: {e}", file=sys.stderr) return 1 def cmd_cdk(args) -> int: import datetime as dt expires = args.expires if not expires: expires = ( dt.datetime.now(dt.timezone(dt.timedelta(hours=8))) + dt.timedelta(days=max(0, int(args.days))) ).strftime("%Y%m%d") print(generate_cdk(args.secret, expires=expires, max_count=args.max_count, nonce=args.nonce)) return 0 def cmd_register(args) -> int: """Register N ChatGPT accounts.""" config = load_config(args.config) setup_logging(config, args.verbose) logger = logging.getLogger(__name__) config_dir = Path(config.get("_config_dir", ".")) accounts_file = config_dir / "registered_accounts.json" results = run_register( config=config, accounts_file=accounts_file, count=args.count, ) print(f"\nRegistered: {len(results)} accounts") for acc in results: print(f" {acc['email']}") return 0 def cmd_join_workspace(args) -> int: """Join registered accounts to K12 workspace.""" config = load_config(args.config) setup_logging(config, args.verbose) logger = logging.getLogger(__name__) # Override workspace IDs from CLI if args.workspace_id: config.setdefault("workspace", {})["ids"] = args.workspace_id config_dir = Path(config.get("_config_dir", ".")) input_file = Path(args.input) if args.input else config_dir / "registered_accounts.json" accounts = load_accounts(input_file) if not accounts: print(f"No accounts found in {input_file}", file=sys.stderr) return 1 accounts = run_join_workspace(config, accounts) save_accounts(input_file, accounts) joined = sum(1 for a in accounts if a.get("join_status") == "ok") print(f"\nJoined: {joined}/{len(accounts)} accounts") return 0 def cmd_login_team(args) -> int: """Re-login with Team space selection.""" config = load_config(args.config) setup_logging(config, args.verbose) logger = logging.getLogger(__name__) config_dir = Path(config.get("_config_dir", ".")) input_file = Path(args.input) if args.input else config_dir / "registered_accounts.json" accounts = load_accounts(input_file) if not accounts: print(f"No accounts found in {input_file}", file=sys.stderr) return 1 accounts = run_re_login(config, accounts) save_accounts(input_file, accounts) team_logged = sum(1 for a in accounts if a.get("team_login_status") == "ok") print(f"\nTeam logged: {team_logged}/{len(accounts)} accounts") return 0 def cmd_export(args) -> int: """Export to sub2api JSON.""" config = load_config(args.config) setup_logging(config, args.verbose) logger = logging.getLogger(__name__) config_dir = Path(config.get("_config_dir", ".")) input_file = Path(args.input) if args.input else config_dir / "registered_accounts.json" accounts = load_accounts(input_file) if not accounts: print(f"No accounts found in {input_file}", file=sys.stderr) return 1 output_file = Path(args.output) if args.output else None json_str = run_export(config, accounts, output_file) actual_path = str(config.get("_last_export_file") or output_file or "") if args.stdout: print(json_str) else: print(f"Exported to {actual_path}") if args.check: report = _check_exported_file(config, actual_path, args) _print_check_report(report) return 0 if report["summary"]["failed"] == 0 else 2 return 0 def _resolve_check_input(config: dict, input_path: str | None = None) -> Path: """Resolve a sub2api JSON file for live-check.""" config_dir = Path(config.get("_config_dir", ".")) if input_path: return Path(input_path) configured = str(config.get("sub2api", {}).get("output_file") or "").strip() if configured: path = Path(configured) if not path.is_absolute(): path = config_dir / path if path.exists(): return path candidates = sorted( config_dir.glob("sub2api*.json"), key=lambda item: item.stat().st_mtime, reverse=True, ) if candidates: return candidates[0] return (config_dir / configured) if configured else config_dir / "sub2api_bundle.json" def _check_exported_file(config: dict, path: str | Path, args) -> dict: """Run direct /v1/me checks for an exported sub2api JSON file.""" check_path = Path(path) if not check_path.exists(): raise FileNotFoundError(f"Sub2API JSON not found: {check_path}") proxy = str(config.get("proxy", {}).get("url") or "").strip() return check_sub2api_file( check_path, timeout=float(getattr(args, "timeout", 15)), retries=int(getattr(args, "retries", 2)), proxy=proxy, limit=getattr(args, "limit", None), ) def _print_check_report(report: dict) -> None: summary = report.get("summary", {}) print(f"\nSub2API Live Check: {report.get('path', '')}") print( " Total: {total} OK: {ok} Failed: {failed} " "401: {unauthorized_401} Auth404: {authenticated_404} " "Expired(local): {expired_local} " "Temporary: {temporary_error}".format(**summary) ) for item in report.get("results", []): http_status = item.get("http_status") http_text = f"HTTP {http_status}" if http_status is not None else "local" exp = item.get("exp") or "-" identity = item.get("email") or item.get("name") or f"#{item.get('index')}" print( f" [{item.get('index')}] {item.get('status')} " f"{http_text}, attempts={item.get('attempts')}, exp={exp}, {identity}" ) if not item.get("ok") and item.get("error"): print(f" {str(item.get('error'))[:200]}") def cmd_check(args) -> int: """Live-check an exported sub2api JSON file.""" config = load_config(args.config) setup_logging(config, args.verbose) input_file = _resolve_check_input(config, args.input) try: report = _check_exported_file(config, input_file, args) except FileNotFoundError as error: print(str(error), file=sys.stderr) return 1 _print_check_report(report) return 0 if report["summary"]["failed"] == 0 else 2 def cmd_export_cpa(args) -> int: """Export one CPA/CLIProxyAPI Codex JSON per account and optional ZIP.""" config = load_config(args.config) setup_logging(config, args.verbose) config_dir = Path(config.get("_config_dir", ".")) input_file = Path(args.input) if args.input else config_dir / "registered_accounts.json" accounts = load_accounts(input_file) if not accounts: print(f"No accounts found in {input_file}", file=sys.stderr) return 1 output_dir = Path(args.output_dir) if args.output_dir else config_dir / "cpa_auths" written = export_cpa_json_files(accounts, output_dir) print(f"CPA JSON exported: {len(written)} files -> {output_dir.resolve()}") zip_path = None if args.zip: zip_path = export_cpa_zip(accounts, args.zip) print(f"CPA ZIP exported: {zip_path}") if args.prune_config: state_file = config_dir / "data" / "outlook_token_state.json" result = clean_pool( config_file=args.config or DEFAULT_CONFIG_FILE, state_file=state_file, exported_accounts_file=input_file, reset_retryable=not args.no_reset_retryable, reset_all_failed=args.reset_all_failed, prune_exported=True, backup=True, ) print( "Pool cleaned: " f"removed={result.removed_lines}, reset_states={result.reset_states}, " f"remaining={result.remaining_lines}, backup={result.backup_file}" ) return 0 def cmd_pool_clean(args) -> int: """Clean outlook token pool state/config.""" config = load_config(args.config) setup_logging(config, args.verbose) config_dir = Path(config.get("_config_dir", ".")) state_file = Path(args.state_file) if args.state_file else config_dir / "data" / "outlook_token_state.json" accounts_file = Path(args.accounts) if args.accounts else None result = clean_pool( config_file=args.config or DEFAULT_CONFIG_FILE, state_file=state_file, exported_accounts_file=accounts_file, reset_retryable=not args.no_reset_retryable, reset_all_failed=args.reset_all_failed, prune_exported=not args.no_prune_exported, backup=not args.no_backup, ) print("Pool clean summary:") print(f" Config: {result.config_file}") print(f" State: {result.state_file}") print(f" Backup: {result.backup_file}") print(f" Removed: {result.removed_lines}") print(f" Reset state: {result.reset_states}") print(f" Remaining: {result.remaining_lines}") return 0 def cmd_run(args) -> int: """Run the full pipeline.""" config = load_config(args.config) setup_logging(config, args.verbose) # Override workspace IDs from CLI if args.workspace_id: config.setdefault("workspace", {})["ids"] = args.workspace_id summary = run_full_pipeline( config=config, count=args.count, output_file=args.output, accounts_file=args.accounts, ) print(f"\n{'='*40}") print(f"Pipeline Summary:") print(f" Registered: {summary['registered']}") print(f" Joined: {summary['joined']}") print(f" K12 Refreshed: {summary['refreshed']}") print(f" Exported: {summary['exported']}") print(f" Accounts: {summary['accounts_file']}") print(f" Output: {summary.get('output_file', '')}") if args.check and summary.get("exported", 0) > 0: report = _check_exported_file(config, summary.get("output_file", ""), args) _print_check_report(report) if report["summary"]["failed"] > 0: return 2 return 0 if summary["registered"] > 0 else 1 def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser( prog="chatgpt-register", description="ChatGPT 账号注册 + K12 母号加入 + Sub2API JSON 导出", ) parser.add_argument( "--version", action="version", version=f"%(prog)s {__version__}" ) sub = parser.add_subparsers(dest="command", help="Available commands") # ── init ── p_init = sub.add_parser("init", help="Write default config.yaml") p_init.add_argument("--config", "-c", default=None, help="Config file path") p_init.set_defaults(func=cmd_init) # ── cdk ── p_cdk = sub.add_parser("cdk", help="Generate an offline algorithmic CDK") p_cdk.add_argument("--secret", required=True, help="CDK_SECRET configured on the Worker") p_cdk.add_argument("--days", type=int, default=7, help="Valid days from today, default 7") p_cdk.add_argument("--expires", default="", help="Explicit expiry date YYYYMMDD") p_cdk.add_argument("--max-count", type=int, default=1, help="Max accounts per submitted job") p_cdk.add_argument("--nonce", default="", help="Optional nonce; auto-generated by default") p_cdk.set_defaults(func=cmd_cdk) # ── register ── p_reg = sub.add_parser("register", help="Register ChatGPT accounts") p_reg.add_argument("--config", "-c", default=None, help="Config file path") p_reg.add_argument("--verbose", "-v", action="store_true", help="Verbose output") p_reg.add_argument("--count", "-n", type=int, default=None, help="Number of accounts") p_reg.set_defaults(func=cmd_register) # ── join-workspace ── p_join = sub.add_parser("join-workspace", help="Join workspace") p_join.add_argument("--config", "-c", default=None, help="Config file path") p_join.add_argument("--verbose", "-v", action="store_true", help="Verbose output") p_join.add_argument("--workspace-id", action="append", default=None, help="Workspace UUID (repeatable)") p_join.add_argument("--input", "-i", default=None, help="Input accounts JSON") p_join.set_defaults(func=cmd_join_workspace) # ── login-team ── p_login = sub.add_parser("login-team", help="Re-login with Team space") p_login.add_argument("--config", "-c", default=None, help="Config file path") p_login.add_argument("--verbose", "-v", action="store_true", help="Verbose output") p_login.add_argument("--input", "-i", default=None, help="Input accounts JSON") p_login.set_defaults(func=cmd_login_team) # ── export ── p_export = sub.add_parser("export", help="Export sub2api JSON") p_export.add_argument("--config", "-c", default=None, help="Config file path") p_export.add_argument("--verbose", "-v", action="store_true", help="Verbose output") p_export.add_argument("--output", "-o", default=None, help="Output file path") p_export.add_argument("--input", "-i", default=None, help="Input accounts JSON") p_export.add_argument("--stdout", action="store_true", help="Print to stdout") p_export.add_argument("--check", action="store_true", help="Live-check exported sub2api JSON via /v1/me") p_export.add_argument("--timeout", type=float, default=15, help="Live-check request timeout seconds") p_export.add_argument("--retries", type=int, default=2, help="Live-check retry count for temporary errors") p_export.add_argument("--limit", type=int, default=None, help="Live-check only first N accounts") p_export.set_defaults(func=cmd_export) # ── check ── p_check = sub.add_parser("check", help="Live-check exported sub2api JSON") p_check.add_argument("--config", "-c", default=None, help="Config file path") p_check.add_argument("--verbose", "-v", action="store_true", help="Verbose output") p_check.add_argument("--input", "-i", default=None, help="Input sub2api JSON") p_check.add_argument("--timeout", type=float, default=15, help="Request timeout seconds") p_check.add_argument("--retries", type=int, default=2, help="Retry count for temporary errors") p_check.add_argument("--limit", type=int, default=None, help="Check only first N accounts") p_check.set_defaults(func=cmd_check) # ── export-cpa ── p_cpa = sub.add_parser("export-cpa", help="Export CPA/CLIProxyAPI per-account JSON") p_cpa.add_argument("--config", "-c", default=None, help="Config file path") p_cpa.add_argument("--verbose", "-v", action="store_true", help="Verbose output") p_cpa.add_argument("--input", "-i", default=None, help="Input accounts JSON") p_cpa.add_argument("--output-dir", default=None, help="Directory for per-account JSON files") p_cpa.add_argument("--zip", default=None, help="Optional ZIP output path") p_cpa.add_argument("--prune-config", action="store_true", help="Remove exported account mailboxes from config.yaml") p_cpa.add_argument("--no-reset-retryable", action="store_true", help="Do not clear retryable failed pool states") p_cpa.add_argument("--reset-all-failed", action="store_true", help="Clear all failed states, not only retryable failures") p_cpa.set_defaults(func=cmd_export_cpa) # ── pool-clean ── p_pool = sub.add_parser("pool-clean", help="Clean outlook pool config/state") p_pool.add_argument("--config", "-c", default=None, help="Config file path") p_pool.add_argument("--verbose", "-v", action="store_true", help="Verbose output") p_pool.add_argument("--accounts", default=None, help="Exported registered_accounts.json to prune from config") p_pool.add_argument("--state-file", default=None, help="Outlook token state JSON path") p_pool.add_argument("--no-reset-retryable", action="store_true", help="Do not clear retryable failed pool states") p_pool.add_argument("--reset-all-failed", action="store_true", help="Clear all failed states") p_pool.add_argument("--no-prune-exported", action="store_true", help="Do not remove exported mailboxes from config") p_pool.add_argument("--no-backup", action="store_true", help="Do not create config backup") p_pool.set_defaults(func=cmd_pool_clean) # ── run ── p_run = sub.add_parser("run", help="Full pipeline") p_run.add_argument("--config", "-c", default=None, help="Config file path") p_run.add_argument("--verbose", "-v", action="store_true", help="Verbose output") p_run.add_argument("--count", "-n", type=int, default=None, help="Number of accounts") p_run.add_argument("--workspace-id", action="append", default=None, help="Workspace UUID (repeatable)") p_run.add_argument("--output", "-o", default=None, help="Output sub2api JSON file") p_run.add_argument("--accounts", default=None, help="Accounts store JSON file") p_run.add_argument("--check", action="store_true", help="Live-check exported sub2api JSON via /v1/me") p_run.add_argument("--timeout", type=float, default=15, help="Live-check request timeout seconds") p_run.add_argument("--retries", type=int, default=2, help="Live-check retry count for temporary errors") p_run.add_argument("--limit", type=int, default=None, help="Live-check only first N accounts") p_run.set_defaults(func=cmd_run) args = parser.parse_args(argv) if not args.command: parser.print_help() sys.exit(1) sys.exit(args.func(args) or 0) if __name__ == "__main__": main()