From 559447e2ca8efa6eb87f04f80c03c4ea90cf914f Mon Sep 17 00:00:00 2001 From: zhangchen <1987834247@qq.com> Date: Sat, 4 Apr 2026 09:20:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96gpt=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- platforms/chatgpt/chatgpt_client.py | 163 ++- platforms/chatgpt/oauth_client.py | 937 ++++++++++++++++-- .../refresh_token_registration_engine.py | 167 +++- 3 files changed, 1111 insertions(+), 156 deletions(-) diff --git a/platforms/chatgpt/chatgpt_client.py b/platforms/chatgpt/chatgpt_client.py index 18af414..18fcf63 100644 --- a/platforms/chatgpt/chatgpt_client.py +++ b/platforms/chatgpt/chatgpt_client.py @@ -130,6 +130,7 @@ class ChatGPTClient: # 设置 oai-did cookie seed_oai_device_cookie(self.session, self.device_id) self.last_registration_state = FlowState() + self.last_stage = "" def _get_sentinel_token(self, flow: str, *, page_url: str | None = None): prefer_browser = flow in {"username_password_create", "oauth_create_account"} @@ -163,6 +164,14 @@ class ChatGPTClient: if self.verbose: print(f" {msg}") + def _enter_stage(self, stage: str, detail: str = ""): + self.last_stage = str(stage or "").strip() + if self.last_stage: + message = f"[stage={self.last_stage}]" + if detail: + message += f" {detail}" + self._log(message) + def _browser_pause(self, low=0.15, high=0.45): """在 headed 模式下加入轻微停顿,模拟有头浏览器节奏。""" if self.browser_mode == "headed": @@ -352,42 +361,103 @@ class ChatGPTClient: def get_next_auth_session_token(self): """获取 ChatGPT next-auth 会话 Cookie。""" - return self._get_cookie_value("__Secure-next-auth.session-token", "chatgpt.com") + return ( + self._get_cookie_value("__Secure-next-auth.session-token", "chatgpt.com") + or self._get_cookie_value("__Secure-authjs.session-token", "chatgpt.com") + ) - def fetch_chatgpt_session(self): + def fetch_chatgpt_session(self, max_attempts=5, retry_delay=1.2): """请求 ChatGPT Session 接口并返回原始会话数据。""" url = f"{self.BASE}/api/auth/session" - self._browser_pause() - response = self.session.get( - url, - headers=self._headers( - url, - accept="application/json", - referer=f"{self.BASE}/", - fetch_site="same-origin", - ), - timeout=30, - ) - if response.status_code != 200: - return False, f"/api/auth/session -> HTTP {response.status_code}" + last_error = "" - try: - data = response.json() - except Exception as exc: - return False, f"/api/auth/session 返回非 JSON: {exc}" + for attempt in range(max(1, int(max_attempts or 1))): + try: + self._browser_pause() + response = self.session.get( + url, + headers=self._headers( + url, + accept="application/json", + referer=f"{self.BASE}/", + fetch_site="same-origin", + ), + timeout=30, + ) + except Exception as exc: + last_error = f"/api/auth/session 请求异常: {exc}" + if attempt < max_attempts - 1: + self._log( + f"{last_error},等待 {retry_delay:.1f}s 后重试 " + f"({attempt + 1}/{max_attempts})" + ) + time.sleep(retry_delay) + continue + return False, last_error - access_token = str(data.get("accessToken") or "").strip() - if not access_token: - return False, "/api/auth/session 未返回 accessToken" - return True, data + if response.status_code != 200: + last_error = f"/api/auth/session -> HTTP {response.status_code}" + if attempt < max_attempts - 1: + self._log( + f"{last_error},等待 {retry_delay:.1f}s 后重试 " + f"({attempt + 1}/{max_attempts})" + ) + time.sleep(retry_delay) + continue + return False, last_error + + try: + data = response.json() + except Exception as exc: + last_error = f"/api/auth/session 返回非 JSON: {exc}" + if attempt < max_attempts - 1: + self._log( + f"{last_error},等待 {retry_delay:.1f}s 后重试 " + f"({attempt + 1}/{max_attempts})" + ) + time.sleep(retry_delay) + continue + return False, last_error + + access_token = str(data.get("accessToken") or "").strip() + if access_token: + return True, data + + last_error = "/api/auth/session 未返回 accessToken" + if attempt < max_attempts - 1: + self._log( + f"{last_error},等待 {retry_delay:.1f}s 后重试 " + f"({attempt + 1}/{max_attempts})" + ) + try: + self.session.get( + f"{self.BASE}/", + headers=self._headers( + f"{self.BASE}/", + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=f"{self.BASE}/", + navigation=True, + ), + allow_redirects=True, + timeout=30, + ) + except Exception: + pass + time.sleep(retry_delay) + continue + + return False, last_error + + return False, last_error or "/api/auth/session 未返回 accessToken" def reuse_session_and_get_tokens(self): """ - 复用注册阶段已建立的 ChatGPT 会话,直接读取 Session / AccessToken。 + 承接前序阶段已建立的 ChatGPT 会话,直接读取 Session / AccessToken。 Returns: tuple[bool, dict|str]: 成功时返回标准化 token/session 数据;失败时返回错误信息。 """ + self._enter_stage("token_exchange", "reuse session -> /api/auth/session") state = self.last_registration_state or FlowState() self._log("步骤 1/4: 跟随注册回调 external_url ...") if state.page_type == "external_url" or self._state_requires_navigation(state): @@ -402,9 +472,33 @@ class ChatGPTClient: self._log("注册回调已落地,跳过额外跟随") self._log("步骤 2/4: 检查 __Secure-next-auth.session-token ...") - session_cookie = self.get_next_auth_session_token() + session_cookie = "" + for attempt in range(5): + session_cookie = self.get_next_auth_session_token() + if session_cookie: + break + self._log( + f"next-auth session cookie 尚未落地,补一次 ChatGPT 首页触达 " + f"({attempt + 1}/5)" + ) + try: + self._browser_pause(0.2, 0.5) + self.session.get( + f"{self.BASE}/", + headers=self._headers( + f"{self.BASE}/", + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=state.current_url or f"{self.AUTH}/about-you", + navigation=True, + ), + allow_redirects=True, + timeout=30, + ) + except Exception as exc: + self._log(f"补触达 ChatGPT 首页异常: {exc}") + time.sleep(1.0) if not session_cookie: - return False, "缺少 __Secure-next-auth.session-token,注册回调可能未落地" + return False, "缺少 ChatGPT session-token,注册回调可能未完全落地" self._log("步骤 3/4: 请求 ChatGPT /api/auth/session ...") ok, session_or_error = self.fetch_chatgpt_session() @@ -444,7 +538,7 @@ class ChatGPTClient: "raw_session": session_data, } - self._log("步骤 4/4: 已从复用会话中提取 accessToken") + self._log("步骤 4/4: 已从当前会话中提取 accessToken") if account_id: self._log(f"Session Account ID: {account_id}") if user_id: @@ -622,6 +716,7 @@ class ChatGPTClient: Returns: tuple: (success, message) """ + self._enter_stage("authorize_continue", f"register_user email={email}") self._log(f"注册用户: {email}") url = f"{self.AUTH}/api/accounts/user/register" @@ -655,6 +750,7 @@ class ChatGPTClient: if r.status_code == 200: data = r.json() self._log("注册成功") + self._log(f"authorize_continue/register_user 响应 URL: {str(r.url)[:120]}") return True, "注册成功" else: try: @@ -671,6 +767,7 @@ class ChatGPTClient: def send_email_otp(self, referer=None): """触发发送邮箱验证码""" + self._enter_stage("otp", "send email otp") self._log("触发发送验证码...") url = f"{self.AUTH}/api/accounts/email-otp/send" @@ -700,6 +797,7 @@ class ChatGPTClient: if isinstance(payload, dict) and payload: next_state = self._state_from_payload(payload, current_url=str(r.url) or url) self._log(f"验证码发送响应: {describe_flow_state(next_state)}") + self._log(f"otp/send 当前 URL: {str(r.url)[:120]}") else: self._log("验证码发送响应: 非 JSON(按已触发处理)") return True @@ -717,6 +815,7 @@ class ChatGPTClient: Returns: tuple: (success, message) """ + self._enter_stage("otp", f"verify email otp code={otp_code}") self._log(f"验证 OTP 码: {otp_code}") url = f"{self.AUTH}/api/accounts/email-otp/validate" @@ -745,6 +844,7 @@ class ChatGPTClient: data, current_url=str(r.url) or f"{self.AUTH}/about-you" ) self._log(f"验证成功 {describe_flow_state(next_state)}") + self._log(f"otp/validate 当前 URL: {str(r.url)[:120]}") return (True, next_state) if return_state else (True, "验证成功") else: error_msg = r.text[:200] @@ -767,6 +867,7 @@ class ChatGPTClient: Returns: tuple: (success, message) """ + self._enter_stage("about_you", "register create_account") name = f"{first_name} {last_name}" self._log(f"完成账号创建: {name}") url = f"{self.AUTH}/api/accounts/create_account" @@ -813,6 +914,7 @@ class ChatGPTClient: data, current_url=str(r.url) or self.BASE ) self._log(f"账号创建成功 {describe_flow_state(next_state)}") + self._log(f"about_you/create_account 当前 URL: {str(r.url)[:120]}") return (True, next_state) if return_state else (True, "账号创建成功") else: error_code = "" @@ -957,6 +1059,7 @@ class ChatGPTClient: return True, "注册成功" if self._state_is_password_registration(state): + self._enter_stage("authorize_continue", describe_flow_state(state)) self._log("全新注册流程") if register_submitted: return False, "注册密码阶段重复进入" @@ -976,6 +1079,7 @@ class ChatGPTClient: continue if self._state_is_email_otp(state): + self._enter_stage("otp", describe_flow_state(state)) self._log("等待邮箱验证码...") otp_code = skymail_client.wait_for_verification_code( email, timeout=otp_wait_timeout @@ -1008,6 +1112,7 @@ class ChatGPTClient: continue if self._state_is_about_you(state): + self._enter_stage("about_you", describe_flow_state(state)) if stop_before_about_you_submission: self.last_registration_state = state self._log( @@ -1031,6 +1136,10 @@ class ChatGPTClient: continue if self._state_requires_navigation(state): + if "workspace" in f"{state.continue_url} {state.current_url}".lower() or "consent" in f"{state.continue_url} {state.current_url}".lower(): + self._enter_stage("workspace_select", describe_flow_state(state)) + elif state.page_type == "external_url": + self._enter_stage("token_exchange", describe_flow_state(state)) success, next_state = self._follow_flow_state( state, referer=state.current_url or f"{self.AUTH}/about-you", diff --git a/platforms/chatgpt/oauth_client.py b/platforms/chatgpt/oauth_client.py index 85bffb0..6eb9007 100644 --- a/platforms/chatgpt/oauth_client.py +++ b/platforms/chatgpt/oauth_client.py @@ -59,19 +59,79 @@ class OAuthClient: self.last_error = "" self.last_workspace_id = "" self.last_state = FlowState() + self.last_stage = "" + self.device_id = "" + self.ua = "" + self.sec_ch_ua = "" + self.impersonate = "" # 创建 session self.session = curl_requests.Session() if self.proxy: self.session.proxies = build_requests_proxy_config(self.proxy) + def adopt_browser_context( + self, + session, + *, + device_id: str = "", + user_agent: str | None = None, + sec_ch_ua: str | None = None, + accept_language: str | None = None, + ): + """承接前序浏览器上下文,延续已建立的 cookie / session。""" + if session is not None: + self.session = session + + if self.proxy: + try: + if not getattr(self.session, "proxies", None): + self.session.proxies = build_requests_proxy_config(self.proxy) + except Exception: + pass + + header_updates = {} + if user_agent: + header_updates["User-Agent"] = user_agent + if sec_ch_ua: + header_updates["sec-ch-ua"] = sec_ch_ua + if accept_language: + header_updates["Accept-Language"] = accept_language + + if header_updates: + try: + self.session.headers.update(header_updates) + except Exception: + pass + + if device_id: + self.device_id = str(device_id or "").strip() + seed_oai_device_cookie(self.session, device_id) + self._log(f"已接入前序浏览器上下文: device_id={device_id}") + if user_agent: + self.ua = str(user_agent or "").strip() + if sec_ch_ua: + self.sec_ch_ua = str(sec_ch_ua or "").strip() + def _log(self, msg): """输出日志""" if self.verbose: print(f" [OAuth] {msg}") + def _enter_stage(self, stage: str, detail: str = ""): + self.last_stage = str(stage or "").strip() + if self.last_stage: + message = f"[stage={self.last_stage}]" + if detail: + message += f" {detail}" + self._log(message) + def _set_error(self, message): - self.last_error = str(message or "").strip() + raw_message = str(message or "").strip() + if self.last_stage and raw_message and f"[stage={self.last_stage}]" not in raw_message: + self.last_error = f"[stage={self.last_stage}] {raw_message}" + else: + self.last_error = raw_message if self.last_error: self._log(self.last_error) @@ -124,6 +184,9 @@ class OAuthClient: user_agent = user_agent or ua sec_ch_ua = sec_ch_ua or ch_ua impersonate = impersonate or imp + self.ua = str(user_agent or "").strip() + self.sec_ch_ua = str(sec_ch_ua or "").strip() + self.impersonate = str(impersonate or "").strip() try: self.session.headers.update( @@ -305,6 +368,21 @@ class OAuthClient: auth_base=self.oauth_issuer, ) + def _get_cookie_value(self, name, domain_hint=None): + """读取当前会话中的 Cookie。""" + try: + for cookie in self.session.cookies: + cookie_name = cookie.name if hasattr(cookie, "name") else str(cookie) + if cookie_name != name: + continue + cookie_domain = cookie.domain if hasattr(cookie, "domain") else "" + if domain_hint and domain_hint not in (cookie_domain or ""): + continue + return cookie.value if hasattr(cookie, "value") else "" + except Exception: + pass + return "" + def _state_signature(self, state: FlowState): return ( state.page_type or "", @@ -699,6 +777,7 @@ class OAuthClient: screen_hint=None, ): """提交邮箱,获取 OAuth 流程的第一页状态。""" + self._enter_stage("authorize_continue", f"email={email}") self._log("步骤2: POST /api/accounts/authorize/continue") self._log(f"authorize_continue: device_id={device_id}") @@ -760,6 +839,11 @@ class OAuthClient: self._browser_pause() r = self.session.post(request_url, **kwargs) self._log(f"/authorize/continue -> {r.status_code}") + self._log( + "authorize_continue 响应: " + f"referer={(continue_referer or '')[:100]} " + f"current_url={str(r.url)[:120]}" + ) if ( r.status_code == 400 @@ -957,11 +1041,10 @@ class OAuthClient: self._set_error(f"触发 passwordless OTP 异常: {e}") return None - def _submit_about_you_create_account( + def _submit_signup_register( self, - first_name, - last_name, - birthdate, + email, + password, device_id, *, user_agent=None, @@ -969,54 +1052,54 @@ class OAuthClient: impersonate=None, referer=None, ): - """在 OAuth 登录态命中 about_you 后提交资料,完成账户创建。""" - self._log("步骤5: 命中 about_you,提交姓名和生日完成注册") - self._log( - "about_you 参数: " - f"first_name={'已设置' if str(first_name or '').strip() else '缺失'}, " - f"last_name={'已设置' if str(last_name or '').strip() else '缺失'}, " - f"birthdate={str(birthdate or '').strip() or '缺失'}" - ) + """在 OAuth signup 流程中提交邮箱+密码。""" + self._enter_stage("authorize_continue", f"register_user email={email}") + self._log("步骤3: 命中 create_account_password,提交注册密码") - full_name = f"{str(first_name or '').strip()} {str(last_name or '').strip()}".strip() - if not full_name or not str(birthdate or "").strip(): - self._set_error("about_you 资料不完整: 缺少姓名或生日") - return None - - sentinel_token = build_sentinel_token( - self.session, - device_id, - flow="oauth_create_account", - user_agent=user_agent, - sec_ch_ua=sec_ch_ua, - impersonate=impersonate, - ) - if not sentinel_token: - self._set_error("无法获取 sentinel token (oauth_create_account)") - return None - - request_url = f"{self.oauth_issuer}/api/accounts/create_account" + request_url = f"{self.oauth_issuer}/api/accounts/user/register" headers = self._headers( request_url, user_agent=user_agent, sec_ch_ua=sec_ch_ua, accept="application/json", - referer=referer or f"{self.oauth_issuer}/about-you", + referer=referer or f"{self.oauth_issuer}/create-account/password", origin=self.oauth_issuer, content_type="application/json", fetch_site="same-origin", extra_headers={ "oai-device-id": device_id, - "openai-sentinel-token": sentinel_token, }, ) headers.update(generate_datadog_trace()) + sentinel_token = get_sentinel_token_via_browser( + flow="username_password_create", + proxy=self.proxy, + page_url=referer or f"{self.oauth_issuer}/create-account/password", + headless=self.browser_mode != "headed", + device_id=device_id, + log_fn=lambda msg: self._log(f"username_password_create: {msg}"), + ) + if sentinel_token: + self._log("username_password_create: 已通过 Playwright SentinelSDK 获取 token") + else: + sentinel_token = build_sentinel_token( + self.session, + device_id, + flow="username_password_create", + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if sentinel_token: + self._log("username_password_create: 已通过 HTTP PoW 获取 token") + if sentinel_token: + headers["openai-sentinel-token"] = sentinel_token + payload = { - "name": full_name, - "birthdate": str(birthdate).strip(), + "username": email, + "password": password, } - self._log("about_you 请求体已构建,准备 POST /api/accounts/create_account") try: kwargs = { @@ -1030,7 +1113,489 @@ class OAuthClient: self._browser_pause() r = self.session.post(request_url, **kwargs) + self._log(f"/user/register -> {r.status_code}") + + if r.status_code != 200: + self._set_error(f"注册失败: {r.status_code} - {r.text[:180]}") + return False + + self._log("注册成功") + self._log( + f"signup/register 响应: referer={(referer or '')[:100]} current_url={str(r.url)[:120]}" + ) + return True + except Exception as e: + self._set_error(f"注册异常: {e}") + return False + + def _send_signup_email_otp( + self, + device_id, + *, + user_agent=None, + sec_ch_ua=None, + impersonate=None, + referer=None, + ): + """在 OAuth signup 流程中触发邮箱验证码。""" + self._enter_stage("otp", "send signup email otp") + self._log("步骤4: 触发注册邮箱 OTP") + + request_url = f"{self.oauth_issuer}/api/accounts/email-otp/send" + headers = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + referer=referer or f"{self.oauth_issuer}/create-account/password", + navigation=True, + fetch_site="same-origin", + ) + headers.update(generate_datadog_trace()) + + try: + kwargs = { + "headers": headers, + "allow_redirects": True, + "timeout": 30, + } + if impersonate: + kwargs["impersonate"] = impersonate + + self._browser_pause() + r = self.session.get(request_url, **kwargs) + self._log(f"/email-otp/send -> {r.status_code}") + if r.status_code != 200: + self._set_error(f"发送注册 OTP 失败: {r.status_code} - {r.text[:180]}") + return None + + verify_url = f"{self.oauth_issuer}/email-verification" + verify_headers = self._headers( + verify_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + referer=referer or f"{self.oauth_issuer}/create-account/password", + navigation=True, + ) + verify_kwargs = { + "headers": verify_headers, + "allow_redirects": True, + "timeout": 30, + } + if impersonate: + verify_kwargs["impersonate"] = impersonate + + self._browser_pause(0.12, 0.25) + r_verify = self.session.get(verify_url, **verify_kwargs) + self._log(f"/email-verification -> {r_verify.status_code}") + + content_type = (r_verify.headers.get("content-type", "") or "").lower() + if "application/json" in content_type: + try: + flow_state = self._state_from_payload( + r_verify.json(), + current_url=str(r_verify.url) or verify_url, + ) + except Exception: + flow_state = self._state_from_url(str(r_verify.url) or verify_url) + else: + flow_state = self._state_from_url(str(r_verify.url) or verify_url) + + if not self._state_is_email_otp(flow_state): + flow_state = self._state_from_url(verify_url) + self._log(f"注册 OTP 已触发 {describe_flow_state(flow_state)}") + return flow_state + except Exception as e: + self._set_error(f"发送注册 OTP 异常: {e}") + return None + + def signup_and_get_tokens( + self, + email, + password, + first_name, + last_name, + birthdate, + *, + device_id="", + user_agent=None, + sec_ch_ua=None, + impersonate=None, + skymail_client=None, + allow_phone_verification=False, + signup_source="", + ): + """完成 OAuth 单链注册并换取 refresh token。""" + self.last_error = "" + self.last_workspace_id = "" + self.last_state = FlowState() + self._log( + "开始 OAuth 注册流程..." + + (f" (source={signup_source})" if signup_source else "") + ) + self._log( + "OAuth 注册策略: 单链路 signup -> otp -> about_you -> phone(如需) -> consent/workspace -> token" + ) + + if not skymail_client: + self._set_error("OAuth 注册流程缺少接码客户端") + return None + + device_id = str(device_id or "").strip() or str(uuid.uuid4()) + self.device_id = device_id + user_agent, sec_ch_ua, impersonate = self._ensure_oauth_fingerprint( + user_agent, sec_ch_ua, impersonate + ) + + code_verifier, code_challenge = generate_pkce() + oauth_state = secrets.token_urlsafe(32) + authorize_params = { + "response_type": "code", + "client_id": self.oauth_client_id, + "audience": "https://api.openai.com/v1", + "redirect_uri": self.oauth_redirect_uri, + "scope": "openid profile email offline_access", + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "state": oauth_state, + "prompt": "login", + "login_hint": email, + "screen_hint": "login_or_signup", + "ext-oai-did": device_id, + "auth_session_logging_id": str(uuid.uuid4()), + "ext-passkey-client-capabilities": "1111", + "codex_cli_simplified_flow": "true", + "id_token_add_organizations": "true", + } + authorize_url = f"{self.oauth_issuer}/oauth/authorize" + + seed_oai_device_cookie(self.session, device_id) + + self._log("步骤1: Bootstrap OAuth session...") + authorize_final_url = self._bootstrap_oauth_session( + authorize_url, + authorize_params, + device_id=device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not authorize_final_url: + self._set_error("Bootstrap 失败") + return None + + continue_referer = f"{self.oauth_issuer}/create-account" + state = self._submit_authorize_continue( + email, + device_id, + continue_referer, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + authorize_url=authorize_url, + authorize_params=authorize_params, + screen_hint="signup", + ) + if not state: + if not self.last_error: + self._set_error("提交邮箱后未进入有效的 OAuth 注册状态") + return None + + self._log(f"OAuth 注册状态起点: {describe_flow_state(state)}") + referer = continue_referer + seen_states = {} + register_submitted = False + + for step in range(24): + self.last_state = state + self._log(f"注册状态步进[{step + 1}/24]: {describe_flow_state(state)}") + signature = self._state_signature(state) + seen_states[signature] = seen_states.get(signature, 0) + 1 + if seen_states[signature] > 2: + self._set_error(f"OAuth 注册状态卡住: {describe_flow_state(state)}") + return None + + code = self._extract_code_from_state(state) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens( + code, code_verifier, user_agent, impersonate + ) + if tokens: + self._log("✅ OAuth 注册成功") + else: + self._log("换取 tokens 失败") + return tokens + + if self._state_is_create_account_password(state): + if register_submitted: + self._set_error("注册密码阶段重复进入") + return None + ok = self._submit_signup_register( + email, + password, + device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + referer=state.current_url or state.continue_url or referer, + ) + if not ok: + return None + register_submitted = True + state = self._send_signup_email_otp( + device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + referer=state.current_url or state.continue_url or referer, + ) + if not state: + if not self.last_error: + self._set_error("注册 OTP 触发后未进入邮箱验证码状态") + return None + referer = state.current_url or referer + continue + + if self._state_is_email_otp(state): + next_state = self._handle_otp_verification( + email, + device_id, + user_agent, + sec_ch_ua, + impersonate, + skymail_client, + state, + prefer_passwordless_login=False, + allow_cached_code_retry=False, + ) + if not next_state: + if not self.last_error: + self._set_error("注册 OTP 验证后未进入下一步状态") + return None + referer = state.current_url or referer + state = next_state + continue + + if self._state_is_about_you(state): + next_state = self._submit_about_you_create_account( + first_name, + last_name, + birthdate, + device_id, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + referer=state.current_url or state.continue_url or referer, + ) + if not next_state: + if not self.last_error: + self._set_error("about_you 提交后未进入下一步 OAuth 状态") + return None + referer = state.current_url or referer + state = next_state + continue + + if self._state_is_add_phone(state): + try: + raw_dump = json.dumps(state.raw or {}, ensure_ascii=False) + except Exception: + raw_dump = "" + if raw_dump: + self._log(f"add_phone 状态响应体(raw): {raw_dump}") + if not allow_phone_verification: + if not self.last_error: + self._set_error("signup 链路命中 add_phone") + return None + + next_state = self._handle_add_phone_verification( + device_id, + user_agent, + sec_ch_ua, + impersonate, + state, + ) + if not next_state: + if not self.last_error: + self._set_error("手机号验证后未进入下一步 OAuth 状态") + return None + referer = state.current_url or referer + state = next_state + continue + + if self._state_requires_navigation(state): + code, next_state = self._follow_flow_state( + state, + referer=referer, + user_agent=user_agent, + impersonate=impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens( + code, code_verifier, user_agent, impersonate + ) + if tokens: + self._log("✅ OAuth 注册成功") + else: + self._log("换取 tokens 失败") + return tokens + referer = state.current_url or referer + state = next_state + self._log(f"follow state -> {describe_flow_state(state)}") + continue + + if self._state_supports_workspace_resolution(state): + self._log("步骤6: 执行 workspace/org 选择") + consent_entry = ( + state.continue_url + or state.current_url + or f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent" + ) + if self._state_is_add_phone(state): + consent_entry = f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent" + self._log("步骤6: 当前处于 add_phone,改用 canonical consent URL 继续") + code, next_state = self._oauth_submit_workspace_and_org( + consent_entry, + device_id, + user_agent, + impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens( + code, code_verifier, user_agent, impersonate + ) + if tokens: + self._log("✅ OAuth 注册成功") + else: + self._log("换取 tokens 失败") + return tokens + if next_state: + referer = state.current_url or referer + state = next_state + self._log(f"workspace state -> {describe_flow_state(state)}") + continue + if not self.last_error: + self._set_error(f"workspace/org 选择失败: {describe_flow_state(state)}") + return None + + self._set_error(f"未支持的 OAuth 注册状态: {describe_flow_state(state)}") + return None + + self._set_error("OAuth 注册状态机超出最大步数") + return None + + def _submit_about_you_create_account( + self, + first_name, + last_name, + birthdate, + device_id, + *, + user_agent=None, + sec_ch_ua=None, + impersonate=None, + referer=None, + ): + """在 OAuth 登录态命中 about_you 后提交资料,完成账户创建。""" + self._enter_stage("about_you", "submit create_account") + self._log("步骤5: 命中 about_you,提交姓名和生日完成注册") + self._log( + "about_you 参数: " + f"first_name={'已设置' if str(first_name or '').strip() else '缺失'}, " + f"last_name={'已设置' if str(last_name or '').strip() else '缺失'}, " + f"birthdate={str(birthdate or '').strip() or '缺失'}" + ) + + full_name = f"{str(first_name or '').strip()} {str(last_name or '').strip()}".strip() + if not full_name or not str(birthdate or "").strip(): + self._set_error("about_you 资料不完整: 缺少姓名或生日") + return None + + about_you_url = f"{self.oauth_issuer}/about-you" + request_url = f"{self.oauth_issuer}/api/accounts/create_account" + payload = { + "name": full_name, + "birthdate": str(birthdate).strip(), + } + self._log("about_you 请求体已构建,准备 POST /api/accounts/create_account") + + def _build_create_headers(sentinel_token: str = ""): + extra_headers = { + "oai-device-id": device_id, + } + if sentinel_token: + extra_headers["openai-sentinel-token"] = sentinel_token + headers_local = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=referer or about_you_url, + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers=extra_headers, + ) + headers_local.update(generate_datadog_trace()) + return headers_local + + def _post_create(sentinel_token: str = ""): + kwargs = { + "json": payload, + "headers": _build_create_headers(sentinel_token), + "timeout": 30, + "allow_redirects": False, + } + if impersonate: + kwargs["impersonate"] = impersonate + self._browser_pause() + return self.session.post(request_url, **kwargs) + + try: + r = _post_create() self._log(f"/create_account -> {r.status_code}") + self._log( + "about_you 响应: " + f"current_url={str(r.url)[:120]} referer={(referer or '')[:100]}" + ) + + if ( + r.status_code in (401, 403) + or "sentinel" in (r.text or "").lower() + or "challenge" in (r.text or "").lower() + ): + self._log("create_account 首次请求需要额外挑战,补发 sentinel 后重试...") + sentinel_token = build_sentinel_token( + self.session, + device_id, + flow="oauth_create_account", + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + impersonate=impersonate, + ) + if not sentinel_token: + self._set_error("无法获取 sentinel token (oauth_create_account)") + return None + + r = _post_create(sentinel_token) + self._log(f"/create_account(重试) -> {r.status_code}") + self._log( + "about_you 重试响应: " + f"current_url={str(r.url)[:120]} referer={(referer or '')[:100]}" + ) + + if r.status_code == 400 and "already_exists" in (r.text or ""): + consent_state = self._state_from_url( + f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent" + ) + self._log(f"about_you 命中 already_exists,转入 {describe_flow_state(consent_state)}") + return consent_state if r.status_code != 200: self._set_error(f"about_you 提交失败: {r.status_code} - {r.text[:180]}") @@ -1065,7 +1630,7 @@ class OAuthClient: return None def _recreate_session(self): - """重建会话,确保恢复链路使用全新 cookie 容器。""" + """重新创建会话容器。""" self.session = curl_requests.Session() if self.proxy: self.session.proxies = build_requests_proxy_config(self.proxy) @@ -1091,7 +1656,7 @@ class OAuthClient: birthdate="", login_source="", stop_after_login=False, - _recovery_depth=0, + _continue_depth=0, ): """ 完整的 OAuth 登录流程,获取 tokens @@ -1146,6 +1711,7 @@ class OAuthClient: if not device_id: device_id = str(uuid.uuid4()) self._log(f"OAuth device_id 缺失,已生成新的 device_id={device_id}") + self.device_id = str(device_id or "").strip() user_agent, sec_ch_ua, impersonate = self._ensure_oauth_fingerprint( user_agent, sec_ch_ua, impersonate @@ -1353,6 +1919,8 @@ class OAuthClient: impersonate, skymail_client, state, + prefer_passwordless_login=prefer_passwordless_login, + allow_cached_code_retry=_continue_depth > 0, ) if not next_state: if not self.last_error: @@ -1401,9 +1969,43 @@ class OAuthClient: self._log( "步骤5: add_phone 命中,但检测到 workspace 线索,继续尝试 workspace/org 选择" ) - elif prefer_passwordless_login and _recovery_depth < 1: + else: self._log( - "步骤5: add_phone 仍无 workspace/callback,重启一次全新 OAuth session + 新 PKCE" + "步骤5: add_phone 暂无显式 workspace 线索,先尝试 canonical consent URL 抢救" + ) + code, next_state = self._oauth_submit_workspace_and_org( + f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent", + device_id, + user_agent, + impersonate, + ) + if code: + self._log(f"获取到 authorization code: {code[:20]}...") + self._log("步骤7: POST /oauth/token") + tokens = self._exchange_code_for_tokens( + code, code_verifier, user_agent, impersonate + ) + if tokens: + self._log("✅ OAuth 登录成功") + else: + self._log("换取 tokens 失败") + return tokens + if next_state: + referer = state.current_url or referer + state = next_state + self._log(f"add_phone -> workspace state -> {describe_flow_state(state)}") + continue + + workspace_error = str(self.last_error or "").strip() + if prefer_passwordless_login and _continue_depth < 1: + self._log( + "步骤5: canonical consent 仍未拿到 workspace/callback" + + ( + f" ({workspace_error})" + if workspace_error + else "" + ) + + ",重启一次全新 OAuth session + 新 PKCE" ) self._recreate_session() return self.login_and_get_tokens( @@ -1421,15 +2023,16 @@ class OAuthClient: last_name=last_name, birthdate=birthdate, login_source=( - f"{login_source}:add_phone_recovery" + f"{login_source}:add_phone_continue" if login_source - else "add_phone_recovery" + else "add_phone_continue" ), - _recovery_depth=_recovery_depth + 1, + _continue_depth=_continue_depth + 1, ) else: self._set_error( "passwordless 登录后仍停留在 add_phone,未获取到 workspace / callback" + + (f" ({workspace_error})" if workspace_error else "") ) return None else: @@ -1544,6 +2147,7 @@ class OAuthClient: self, consent_url, device_id, user_agent, impersonate, max_retries=3 ): """提交 workspace 和 organization 选择(带重试)""" + self._enter_stage("workspace_select", consent_url[:120] if consent_url else "") session_data = None for attempt in range(max_retries): @@ -1607,6 +2211,9 @@ class OAuthClient: ) self._log(f"workspace/select -> {r.status_code}") + self._log( + f"workspace/select 请求: workspace_id={workspace_id} consent_url={consent_url[:120]}" + ) # 检查重定向 if r.status_code in (301, 302, 303, 307, 308): @@ -1678,6 +2285,9 @@ class OAuthClient: ) self._log(f"organization/select -> {r_org.status_code}") + self._log( + f"organization/select 请求: org_id={org_id} project_id={project_id or '-'}" + ) # 检查重定向 if r_org.status_code in (301, 302, 303, 307, 308): @@ -1929,6 +2539,7 @@ class OAuthClient: def _exchange_code_for_tokens(self, code, code_verifier, user_agent, impersonate): """用 authorization code 换取 tokens""" + self._enter_stage("token_exchange", f"code={str(code or '')[:24]}...") url = f"{self.oauth_issuer}/oauth/token" payload = { @@ -1958,6 +2569,7 @@ class OAuthClient: r = self.session.post(url, **kwargs) if r.status_code == 200: + self._log("token_exchange 成功") return r.json() else: self._set_error(f"换取 tokens 失败: {r.status_code} - {r.text[:200]}") @@ -2017,9 +2629,15 @@ class OAuthClient: return True, next_state, "" def _resend_phone_otp( - self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState + self, + phone_number, + device_id, + user_agent, + sec_ch_ua, + impersonate, + state: FlowState, ): - request_url = f"{self.oauth_issuer}/api/accounts/phone-otp/resend" + request_url = f"{self.oauth_issuer}/api/accounts/add-phone/send" headers = self._headers( request_url, user_agent=user_agent, @@ -2027,26 +2645,64 @@ class OAuthClient: accept="application/json", referer=state.current_url or state.continue_url - or f"{self.oauth_issuer}/phone-verification", + or f"{self.oauth_issuer}/add-phone", origin=self.oauth_issuer, + content_type="application/json", fetch_site="same-origin", extra_headers={"oai-device-id": device_id}, ) headers.update(generate_datadog_trace()) try: - kwargs = {"headers": headers, "timeout": 30, "allow_redirects": False} + kwargs = { + "json": {"phone_number": phone_number}, + "headers": headers, + "timeout": 30, + "allow_redirects": False, + } if impersonate: kwargs["impersonate"] = impersonate self._browser_pause(0.12, 0.25) resp = self.session.post(request_url, **kwargs) except Exception as e: - return False, f"phone-otp/resend 异常: {e}" + return False, f"add-phone/send 重发异常: {e}" - self._log(f"/phone-otp/resend -> {resp.status_code}") + self._log(f"/add-phone/send(resend) -> {resp.status_code}") if resp.status_code == 200: return True, "" - return False, f"phone-otp/resend 失败: {resp.status_code} - {resp.text[:180]}" + return False, f"add-phone/send 重发失败: {resp.status_code} - {resp.text[:180]}" + + def _get_config_value(self, *keys): + for key in keys: + value = str(self.config.get(key, "") or "").strip() + if value: + return value + return "" + + def _get_configured_phone_number(self) -> str: + return self._get_config_value( + "chatgpt_phone_number", + "openai_phone_number", + "phone_number", + ) + + def _get_configured_phone_codes(self) -> list[str]: + raw = self._get_config_value( + "chatgpt_phone_otp_codes", + "chatgpt_phone_otp_code", + "openai_phone_otp_codes", + "openai_phone_otp_code", + "phone_otp_codes", + "phone_otp_code", + ) + if not raw: + return [] + parts = [] + for chunk in raw.replace("\n", ",").replace(";", ",").split(","): + code = str(chunk or "").strip() + if code: + parts.append(code) + return parts def _validate_phone_otp( self, code, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState @@ -2105,10 +2761,64 @@ class OAuthClient: def _handle_add_phone_verification( self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState ): + configured_phone = self._get_configured_phone_number() + configured_codes = self._get_configured_phone_codes() + + if configured_phone: + self._log(f"步骤5: add_phone 使用配置手机号: {configured_phone}") + sent, next_state, detail = self._send_phone_number( + configured_phone, + device_id, + user_agent, + sec_ch_ua, + impersonate, + ) + if not sent or not next_state: + self._set_error(detail or "add-phone/send 未返回有效状态") + return None + + if ( + next_state.page_type != "phone_otp_verification" + and "phone-verification" + not in f"{next_state.continue_url} {next_state.current_url}".lower() + ): + if self._state_supports_workspace_resolution(next_state) or self._state_requires_navigation(next_state): + self._log(f"add_phone 提交后已进入后续状态: {describe_flow_state(next_state)}") + return next_state + self._set_error( + f"add-phone/send 未进入手机验证码页: {describe_flow_state(next_state)}" + ) + return None + + if configured_codes: + for idx, code in enumerate(configured_codes, start=1): + self._log( + f"步骤5: 使用配置手机号验证码 {idx}/{len(configured_codes)}: {code}" + ) + valid, validated_state, detail = self._validate_phone_otp( + code, + device_id, + user_agent, + sec_ch_ua, + impersonate, + next_state, + ) + if valid and validated_state: + return validated_state + self._log(detail or "手机号 OTP 验证失败") + + self._set_error("配置的手机号验证码未通过验证") + return None + + self._set_error( + "已提交配置手机号,但未提供 chatgpt_phone_otp_code,当前流程无法继续" + ) + return None + phone_service = SMSToMePhoneService(self.config, log_fn=self._log) if not phone_service.enabled: self._set_error( - "OAuth 登录被 add_phone 阻断,当前账号需要手机号验证;未配置可用的 SMSToMe 号码池" + "当前链路需要手机号验证,但未配置可用的手机号能力(SMSToMe 或固定手机号验证码)" ) return None @@ -2184,6 +2894,7 @@ class OAuthClient: if not code: self._log("手机号验证码暂未收到,尝试重发一次...") resend_ok, resend_detail = self._resend_phone_otp( + entry.phone, device_id, user_agent, sec_ch_ua, @@ -2228,13 +2939,19 @@ class OAuthClient: impersonate, skymail_client, state, + *, + prefer_passwordless_login=False, + allow_cached_code_retry=False, ): """处理 OAuth 阶段的邮箱 OTP 验证,返回服务端声明的下一步状态。""" + self._enter_stage("otp", f"email={email}") self._log("步骤4: 检测到邮箱 OTP 验证") def _resend_email_otp() -> bool: prefer_passwordless = bool( - self.config.get("prefer_passwordless_login") + prefer_passwordless_login + or allow_cached_code_retry + or self.config.get("prefer_passwordless_login") or self.config.get("force_passwordless_login") ) resend_ok = False @@ -2304,12 +3021,15 @@ class OAuthClient: request_url = f"{self.oauth_issuer}/api/accounts/email-otp/validate" self._log(f"email_otp_validate: device_id={device_id}") + otp_referer = ( + state.current_url + or state.continue_url + or f"{self.oauth_issuer}/email-verification" + ) sentinel_otp = get_sentinel_token_via_browser( flow="email_otp_validate", proxy=self.proxy, - page_url=state.current_url - or state.continue_url - or f"{self.oauth_issuer}/email-verification", + page_url=otp_referer, headless=self.browser_mode != "headed", device_id=device_id, log_fn=lambda msg: self._log(f"email_otp_validate: {msg}"), @@ -2330,23 +3050,25 @@ class OAuthClient: else: self._log("email_otp_validate: 未生成 sentinel token(继续尝试)") - headers_otp = self._headers( - request_url, - user_agent=user_agent, - sec_ch_ua=sec_ch_ua, - accept="application/json", - referer=state.current_url - or state.continue_url - or f"{self.oauth_issuer}/email-verification", - origin=self.oauth_issuer, - content_type="application/json", - fetch_site="same-origin", - extra_headers={ + def _build_otp_headers(): + extra_headers = { "oai-device-id": device_id, - "openai-sentinel-token": sentinel_otp or "", - }, - ) - headers_otp.update(generate_datadog_trace()) + } + if sentinel_otp: + extra_headers["openai-sentinel-token"] = sentinel_otp + headers_otp = self._headers( + request_url, + user_agent=user_agent, + sec_ch_ua=sec_ch_ua, + accept="application/json", + referer=otp_referer, + origin=self.oauth_issuer, + content_type="application/json", + fetch_site="same-origin", + extra_headers=extra_headers, + ) + headers_otp.update(generate_datadog_trace()) + return headers_otp if not hasattr(skymail_client, "_used_codes"): skymail_client._used_codes = set() @@ -2365,15 +3087,19 @@ class OAuthClient: otp_wait_seconds = max(30, min(otp_wait_seconds, 3600)) otp_poll_window = min(30, max(10, otp_wait_seconds)) try: + default_resend_wait_seconds = 45 if prefer_passwordless_login else 120 otp_resend_wait_seconds = int( self.config.get( "chatgpt_oauth_otp_resend_wait_seconds", - self.config.get("chatgpt_otp_resend_wait_seconds", 120), + self.config.get( + "chatgpt_otp_resend_wait_seconds", + default_resend_wait_seconds, + ), ) - or 120 + or default_resend_wait_seconds ) except Exception: - otp_resend_wait_seconds = 120 + otp_resend_wait_seconds = 45 if prefer_passwordless_login else 120 otp_resend_wait_seconds = max(30, min(otp_resend_wait_seconds, 900)) otp_deadline = time.time() + otp_wait_seconds otp_sent_at = time.time() @@ -2389,13 +3115,12 @@ class OAuthClient: try: kwargs = { "json": {"code": code}, - "headers": headers_otp, + "headers": _build_otp_headers(), "timeout": 30, "allow_redirects": False, } if impersonate: kwargs["impersonate"] = impersonate - self._browser_pause(0.12, 0.25) resp_otp = self.session.post(request_url, **kwargs) except Exception as e: @@ -2419,9 +3144,67 @@ class OAuthClient: or (state.current_url or state.continue_url or request_url), ) self._log(f"OTP 验证通过 {describe_flow_state(next_state)}") - skymail_client._used_codes.add(code) + self._log( + f"otp 响应详情: current_url={str(resp_otp.url)[:120]} tried_codes={len(tried_codes)}" + ) + remember_successful_code = getattr( + skymail_client, "remember_successful_code", None + ) + if callable(remember_successful_code): + remember_successful_code(code) + else: + skymail_client._used_codes.add(code) + setattr(skymail_client, "_last_success_code", code) + setattr(skymail_client, "_last_success_code_at", time.time()) return next_state + if allow_cached_code_retry: + cached_code = "" + cached_age = None + get_recent_code = getattr(skymail_client, "get_recent_code", None) + if callable(get_recent_code): + cached_code = str( + get_recent_code( + max_age_seconds=min(180, otp_wait_seconds), + prefer_successful=True, + ) + or "" + ).strip() + cached_age = ( + time.time() - float(getattr(skymail_client, "_last_success_code_at", 0) or 0) + if cached_code + else None + ) + else: + cached_code = str( + getattr(skymail_client, "_last_success_code", "") + or getattr(skymail_client, "_last_code", "") + or "" + ).strip() + cached_ts = float( + getattr(skymail_client, "_last_success_code_at", 0) + or getattr(skymail_client, "_last_code_at", 0) + or 0 + ) + if cached_code and cached_ts: + cached_age = time.time() - cached_ts + if cached_age > min(180, otp_wait_seconds): + cached_code = "" + + if cached_code: + age_text = ( + f"{int(max(0, cached_age or 0))}s前" + if cached_age is not None + else "近期" + ) + self._log( + f"检测到近期缓存 OTP,先直接尝试: {cached_code} ({age_text})" + ) + next_state = validate_otp(cached_code) + if next_state: + return next_state + self._log("缓存 OTP 未通过,继续等待新的 OTP...") + if hasattr(skymail_client, "wait_for_verification_code"): self._log("使用 wait_for_verification_code 进行阻塞式获取新验证码...") while time.time() < otp_deadline: diff --git a/platforms/chatgpt/refresh_token_registration_engine.py b/platforms/chatgpt/refresh_token_registration_engine.py index 3e27d4c..7e2e10e 100644 --- a/platforms/chatgpt/refresh_token_registration_engine.py +++ b/platforms/chatgpt/refresh_token_registration_engine.py @@ -1,11 +1,9 @@ """ ChatGPT Refresh Token 注册引擎。 -新实现不再沿用旧的分步补丁式注册链路,而是直接复用: -1. `ChatGPTClient.register_complete_flow()` 负责完整注册状态机 -2. `OAuthClient.login_and_get_tokens()` 负责全新 OAuth + passwordless OTP 登录拿 RT - -目标是让 refresh_token 模式与当前主状态机链路保持一致,不再以旧流程做兜底。 +主链路采用两段式推进: +1. `ChatGPTClient.register_complete_flow()` 负责把注册状态机推进到 about_you +2. `OAuthClient.login_and_get_tokens()` 承接前序会话继续完成 about_you / workspace / token """ from __future__ import annotations @@ -85,6 +83,46 @@ class EmailServiceAdapter: self.email = email self.log_fn = log_fn self._used_codes: set[str] = set() + self._last_code: str = "" + self._last_code_at: float = 0.0 + self._last_success_code: str = "" + self._last_success_code_at: float = 0.0 + + @property + def last_code(self) -> str: + return self._last_success_code or self._last_code + + def _remember_code(self, code: str, *, successful: bool = False) -> None: + code = str(code or "").strip() + if not code: + return + now = time.time() + self._last_code = code + self._last_code_at = now + self._used_codes.add(code) + if successful: + self._last_success_code = code + self._last_success_code_at = now + + def remember_successful_code(self, code: str) -> None: + self._remember_code(code, successful=True) + + def get_recent_code( + self, + max_age_seconds: int = 180, + *, + prefer_successful: bool = True, + ) -> str: + now = time.time() + if ( + prefer_successful + and self._last_success_code + and now - self._last_success_code_at <= max_age_seconds + ): + return self._last_success_code + if self._last_code and now - self._last_code_at <= max_age_seconds: + return self._last_code + return "" def wait_for_verification_code( self, @@ -103,7 +141,7 @@ class EmailServiceAdapter: ) if code: code = str(code).strip() - self._used_codes.add(code) + self._remember_code(code, successful=False) self.log_fn(f"成功获取验证码: {code}") return code @@ -177,18 +215,6 @@ class RefreshTokenRegistrationEngine: self._log(f"创建邮箱失败: {e}", "error") return False - @staticmethod - def _should_switch_to_login_after_register_failure(message: str) -> bool: - text = str(message or "").lower() - markers = ( - "user_already_exists", - "account already exists", - "please login instead", - "add_phone", - "add-phone", - ) - return any(marker in text for marker in markers) - def _read_int_config( self, primary_key: str, @@ -210,6 +236,18 @@ class RefreshTokenRegistrationEngine: return max(minimum, min(parsed, maximum)) return max(minimum, min(int(default), maximum)) + @staticmethod + def _should_switch_to_login_after_register_failure(message: str) -> bool: + text = str(message or "").lower() + markers = ( + "user_already_exists", + "account already exists", + "please login instead", + "add_phone", + "add-phone", + ) + return any(marker in text for marker in markers) + def _build_chatgpt_client(self) -> ChatGPTClient: client = ChatGPTClient( proxy=self.proxy_url, @@ -229,6 +267,27 @@ class RefreshTokenRegistrationEngine: client._log = lambda msg: self._log(f"[登录链路] {msg}") return client + def _reuse_register_browser_context( + self, + register_client: ChatGPTClient, + oauth_client: OAuthClient, + ) -> None: + oauth_client.adopt_browser_context( + register_client.session, + device_id=getattr(register_client, "device_id", "") or "", + user_agent=getattr(register_client, "ua", None), + sec_ch_ua=getattr(register_client, "sec_ch_ua", None), + accept_language=( + getattr(register_client.session, "headers", {}).get("Accept-Language", "") + if getattr(register_client, "session", None) is not None + else "" + ), + ) + oauth_client.impersonate = str( + getattr(register_client, "impersonate", "") or "" + ).strip() + self._log("已接入前序 session/cookie/fingerprint,继续处理 OAuth 后续步骤") + def _extract_account_info(self, tokens: dict[str, Any]) -> dict[str, Any]: id_token = str((tokens or {}).get("id_token") or "").strip() if not id_token: @@ -270,7 +329,7 @@ class RefreshTokenRegistrationEngine: oauth_client: OAuthClient, registration_message: str, source: str, - register_client: ChatGPTClient, + register_client: Any, ) -> None: account_info = self._extract_account_info(tokens) workspace_id = self._extract_workspace_id(oauth_client) @@ -332,7 +391,7 @@ class RefreshTokenRegistrationEngine: self._log("=" * 60) self._log("ChatGPT RT 全新主链路启动") self._log(f"请求模式: {self.browser_mode}") - self._log("实现策略: 注册状态机 + 全新 OAuth session + 登录流程复刻") + self._log("实现策略: 注册状态机 + OAuth 接续流程") self._log("=" * 60) if not fixed_email: @@ -353,7 +412,7 @@ class RefreshTokenRegistrationEngine: self._log(f"邮箱: {result.email}") self._log(f"密码: {self.password}") self._log(f"注册信息: {first_name} {last_name}, 生日: {birthdate}") - self._log("流程策略: 注册阶段到 about_you 即停,改由 OAuth 新会话补全资料") + self._log("流程策略: 注册阶段推进到 about_you 后切换到 OAuth 流程继续完成后续步骤") self._log( "验证码等待策略: " f"register_wait={register_otp_wait_seconds}s, " @@ -382,17 +441,19 @@ class RefreshTokenRegistrationEngine: ) if not registered: - if not self._should_switch_to_login_after_register_failure(registration_message): + if not self._should_switch_to_login_after_register_failure( + registration_message + ): last_error = f"注册状态机失败: {registration_message}" result.error_message = last_error return result - source = "login" self._log( - "注册阶段命中可恢复终态,直接切换到全新 OAuth + passwordless 登录链路", + "注册阶段命中可继续处理的终态,改走 OAuth 登录流程", "warning", ) self._log(f"切换原因: {registration_message}") + source = "login" else: if registration_message == "pending_about_you_submission": self._log("注册状态机已推进至 about_you,符合预期。下一步进入 OAuth 会话补全资料") @@ -403,43 +464,47 @@ class RefreshTokenRegistrationEngine: ) oauth_client = self._build_oauth_client() - use_login_front_half = registration_message == "pending_about_you_submission" + oauth_client.config.setdefault( + "chatgpt_oauth_otp_wait_seconds", + register_otp_wait_seconds, + ) + oauth_client.config.setdefault( + "chatgpt_oauth_otp_resend_wait_seconds", + register_otp_resend_wait_seconds, + ) - if use_login_front_half: - self._log("3. 新开 OAuth session,严格复刻 login_and_get_tokens 登录链路") - self._log("4. 本轮仅共享邮箱+密码,其它会话数据全新") + use_continued_session = registered and ( + registration_message == "pending_about_you_submission" + ) + + if use_continued_session: + self._reuse_register_browser_context(register_client, oauth_client) + self._log("3. 承接前序 session,继续走 OAuth passwordless 流程") + self._log("4. 沿用前序阶段的 cookie / device_id / 浏览器指纹") self._log("5. 登录成功后提交 about_you,并继续 workspace/token 流程") tokens = oauth_client.login_and_get_tokens( result.email, self.password, - device_id="", - user_agent=None, - sec_ch_ua=None, - impersonate=None, + device_id=getattr(register_client, "device_id", "") or "", + user_agent=getattr(register_client, "ua", None), + sec_ch_ua=getattr(register_client, "sec_ch_ua", None), + impersonate=getattr(register_client, "impersonate", None), skymail_client=email_adapter, - prefer_passwordless_login=False, + prefer_passwordless_login=True, allow_phone_verification=False, - force_new_browser=True, + force_new_browser=False, force_chatgpt_entry=False, screen_hint="login", - force_password_login=True, + force_password_login=False, complete_about_you_if_needed=True, first_name=first_name, last_name=last_name, birthdate=birthdate, - login_source=( - "existing_account_recovery" - if source == "login" - else "post_register_workspace_recovery" - ), - stop_after_login=False, + login_source="post_register_workspace_continue", ) else: self._log("3. 新开 OAuth session,按 screen_hint=login + passwordless OTP 登录...") self._log("4. 若命中 about_you,则在 OAuth 会话内提交姓名+生日,再继续 workspace/token") - oauth_screen_hint = "login" - oauth_force_password_login = False - oauth_force_chatgpt_entry = False tokens = oauth_client.login_and_get_tokens( result.email, self.password, @@ -448,20 +513,18 @@ class RefreshTokenRegistrationEngine: sec_ch_ua=getattr(register_client, "sec_ch_ua", None), impersonate=getattr(register_client, "impersonate", None), skymail_client=email_adapter, - prefer_passwordless_login=not oauth_force_password_login, + prefer_passwordless_login=True, allow_phone_verification=False, force_new_browser=True, - force_chatgpt_entry=oauth_force_chatgpt_entry, - screen_hint=oauth_screen_hint, - force_password_login=oauth_force_password_login, + force_chatgpt_entry=False, + screen_hint="login", + force_password_login=False, complete_about_you_if_needed=True, first_name=first_name, last_name=last_name, birthdate=birthdate, login_source=( - "existing_account_recovery" - if source == "login" - else "post_register_workspace_recovery" + "existing_account_continue" if source == "login" else "post_register_workspace_continue" ), ) @@ -474,7 +537,7 @@ class RefreshTokenRegistrationEngine: result=result, tokens=tokens, oauth_client=oauth_client, - registration_message=registration_message or "register_complete_flow:ok", + registration_message=registration_message, source=source, register_client=register_client, )