From c3bc9299704d516bafee40775575fa8c34af7a3c Mon Sep 17 00:00:00 2001 From: Yokaimeow Date: Tue, 31 Mar 2026 16:22:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=B7=BB=E5=8A=A0sock5=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/base_mailbox.py | 603 ++++++++++++++++++------- core/executors/playwright.py | 24 +- core/executors/protocol.py | 23 +- core/http_client.py | 36 +- core/proxy_pool.py | 13 +- core/proxy_utils.py | 44 ++ platforms/chatgpt/chatgpt_client.py | 290 +++++++----- platforms/chatgpt/oauth.py | 43 +- platforms/chatgpt/oauth_client.py | 423 ++++++++++++----- platforms/chatgpt/oauth_pkce_client.py | 83 +++- platforms/chatgpt/payment.py | 26 +- platforms/cursor/core.py | 98 ++-- platforms/kiro/core.py | 281 ++++++++---- platforms/openblocklabs/core.py | 284 +++++++----- requirements.txt | 1 + 15 files changed, 1563 insertions(+), 709 deletions(-) create mode 100644 core/proxy_utils.py diff --git a/core/base_mailbox.py b/core/base_mailbox.py index 537c6a6..336ff63 100644 --- a/core/base_mailbox.py +++ b/core/base_mailbox.py @@ -1,10 +1,12 @@ """邮箱池基类 - 抽象临时邮箱/收件服务""" + import json import random from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Optional, Any +from .proxy_utils import build_requests_proxy_config, normalize_proxy_url @dataclass @@ -26,15 +28,22 @@ class BaseMailbox(ABC): ... @abstractmethod - def wait_for_code(self, account: MailboxAccount, keyword: str = "", - timeout: int = 120, before_ids: set = None, - code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: """等待并返回验证码,code_pattern 为自定义正则(默认匹配6位数字)""" ... def _safe_extract(self, text: str, pattern: str = None) -> Optional[str]: """通用验证码提取逻辑:若有捕获组则返回 group(1),否则返回 group(0)""" import re + text = str(text or "") if not text: return None @@ -44,11 +53,13 @@ class BaseMailbox(ABC): patterns.append(pattern) # 先匹配带明显语义的验证码,避免误提取 MIME boundary、时间戳等 6 位数字。 - patterns.extend([ - r'(?is)(?:verification\s+code|one[-\s]*time\s+(?:password|code)|security\s+code|login\s+code|验证码|校验码|动态码|認證碼|驗證碼)[^0-9]{0,30}(\d{6})', - r'(?is)\bcode\b[^0-9]{0,12}(\d{6})', - r'(? str: """解析邮件原始文本 (借鉴自 Fugle),处理 Quoted-Printable 和 HTML 实体""" import quopri, html, re + text = str(raw or "") - if not text: return "" + if not text: + return "" # 简单切分 Header 和 Body if "\r\n\r\n" in text: text = text.split("\r\n\r\n", 1)[1] @@ -75,11 +88,11 @@ class BaseMailbox(ABC): pass # 清除 HTML 标签并反转义 text = html.unescape(text) - text = re.sub(r'(?im)^content-(?:type|transfer-encoding):.*$', ' ', text) - text = re.sub(r'(?im)^--+[_=\w.-]+$', ' ', text) - text = re.sub(r'(?i)----=_part_[\w.]+', ' ', text) - text = re.sub(r'<[^>]+>', ' ', text) - text = re.sub(r'\s+', ' ', text).strip() + text = re.sub(r"(?im)^content-(?:type|transfer-encoding):.*$", " ", text) + text = re.sub(r"(?im)^--+[_=\w.-]+$", " ", text) + text = re.sub(r"(?i)----=_part_[\w.]+", " ", text) + text = re.sub(r"<[^>]+>", " ", text) + text = re.sub(r"\s+", " ", text).strip() return text @abstractmethod @@ -88,9 +101,12 @@ class BaseMailbox(ABC): ... -def create_mailbox(provider: str, extra: dict = None, proxy: str = None) -> 'BaseMailbox': +def create_mailbox( + provider: str, extra: dict = None, proxy: str = None +) -> "BaseMailbox": """工厂方法:根据 provider 创建对应的 mailbox 实例""" extra = extra or {} + proxy = normalize_proxy_url(proxy) if provider == "tempmail_lol": return TempMailLolMailbox(proxy=proxy) elif provider == "skymail": @@ -103,7 +119,9 @@ def create_mailbox(provider: str, extra: dict = None, proxy: str = None) -> 'Bas elif provider == "duckmail": return DuckMailMailbox( api_url=(extra.get("duckmail_api_url") or "https://www.duckmail.sbs"), - provider_url=(extra.get("duckmail_provider_url") or "https://api.duckmail.sbs"), + provider_url=( + extra.get("duckmail_provider_url") or "https://api.duckmail.sbs" + ), bearer=(extra.get("duckmail_bearer") or "kevin273945"), domain=extra.get("duckmail_domain", ""), api_key=extra.get("duckmail_api_key", ""), @@ -160,6 +178,7 @@ def create_mailbox(provider: str, extra: dict = None, proxy: str = None) -> 'Bas class LaoudoMailbox(BaseMailbox): """laoudo.com 邮箱服务""" + def __init__(self, auth_token: str, email: str, account_id: str): self.auth = auth_token self._email = email @@ -177,25 +196,45 @@ class LaoudoMailbox(BaseMailbox): def get_current_ids(self, account: MailboxAccount) -> set: from curl_cffi import requests as curl_requests + try: r = curl_requests.get( f"{self.api}/list", - params={"accountId": account.account_id, "allReceive": 0, - "emailId": 0, "timeSort": 1, "size": 50, "type": 0}, + params={ + "accountId": account.account_id, + "allReceive": 0, + "emailId": 0, + "timeSort": 1, + "size": 50, + "type": 0, + }, headers={"authorization": self.auth, "user-agent": self._ua}, - timeout=15, impersonate="chrome131" + timeout=15, + impersonate="chrome131", ) if r.status_code == 200: mails = r.json().get("data", {}).get("list", []) or [] - return {m.get("id") or m.get("emailId") for m in mails if m.get("id") or m.get("emailId")} + return { + m.get("id") or m.get("emailId") + for m in mails + if m.get("id") or m.get("emailId") + } except Exception: pass return set() - def wait_for_code(self, account: MailboxAccount, keyword: str = "trae", - timeout: int = 120, before_ids: set = None, code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "trae", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: import re, time from curl_cffi import requests as curl_requests + seen = set(before_ids) if before_ids else set() start = time.time() h = {"authorization": self.auth, "user-agent": self._ua} @@ -203,9 +242,17 @@ class LaoudoMailbox(BaseMailbox): try: r = curl_requests.get( f"{self.api}/list", - params={"accountId": account.account_id, "allReceive": 0, - "emailId": 0, "timeSort": 1, "size": 50, "type": 0}, - headers=h, timeout=15, impersonate="chrome131" + params={ + "accountId": account.account_id, + "allReceive": 0, + "emailId": 0, + "timeSort": 1, + "size": 50, + "type": 0, + }, + headers=h, + timeout=15, + impersonate="chrome131", ) if r.status_code == 200: mails = r.json().get("data", {}).get("list", []) or [] @@ -214,8 +261,11 @@ class LaoudoMailbox(BaseMailbox): if not mid or mid in seen: continue seen.add(mid) - text = (str(mail.get("subject", "")) + " " + - str(mail.get("content") or mail.get("html") or "")) + text = ( + str(mail.get("subject", "")) + + " " + + str(mail.get("content") or mail.get("html") or "") + ) if keyword and keyword.lower() not in text.lower(): continue code = self._safe_extract(text, code_pattern) @@ -229,6 +279,7 @@ class LaoudoMailbox(BaseMailbox): class AitreMailbox(BaseMailbox): """mail.aitre.cc 临时邮箱""" + def __init__(self, email: str): self._email = email self.api = "https://mail.aitre.cc/api/tempmail" @@ -238,16 +289,27 @@ class AitreMailbox(BaseMailbox): def get_current_ids(self, account: MailboxAccount) -> set: import requests + try: - r = requests.get(f"{self.api}/emails", params={"email": account.email}, timeout=10) + r = requests.get( + f"{self.api}/emails", params={"email": account.email}, timeout=10 + ) emails = r.json().get("emails", []) return {str(m["id"]) for m in emails if "id" in m} except Exception: return set() - def wait_for_code(self, account: MailboxAccount, keyword: str = "trae", - timeout: int = 120, before_ids: set = None, code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "trae", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: import re, time, requests + seen = set(before_ids) if before_ids else set() last_check = None start = time.time() @@ -260,7 +322,11 @@ class AitreMailbox(BaseMailbox): data = r.json() last_check = data.get("lastChecked") if data.get("count", 0) > 0: - r2 = requests.get(f"{self.api}/emails", params={"email": account.email}, timeout=10) + r2 = requests.get( + f"{self.api}/emails", + params={"email": account.email}, + timeout=10, + ) for mail in r2.json().get("emails", []): mid = str(mail.get("id", "")) if mid in seen: @@ -283,15 +349,16 @@ class TempMailLolMailbox(BaseMailbox): def __init__(self, proxy: str = None): self.api = "https://api.tempmail.lol/v2" - self.proxy = {"http": proxy, "https": proxy} if proxy else None + self.proxy = build_requests_proxy_config(proxy) self._token = None self._email = None def get_email(self) -> MailboxAccount: import requests - r = requests.post(f"{self.api}/inbox/create", - json={}, - proxies=self.proxy, timeout=15) + + r = requests.post( + f"{self.api}/inbox/create", json={}, proxies=self.proxy, timeout=15 + ) data = r.json() email = data.get("address") or data.get("email", "") if not email: @@ -303,34 +370,59 @@ class TempMailLolMailbox(BaseMailbox): def get_current_ids(self, account: MailboxAccount) -> set: import requests + try: - r = requests.get(f"{self.api}/inbox", + r = requests.get( + f"{self.api}/inbox", params={"token": account.account_id}, - proxies=self.proxy, timeout=10) + proxies=self.proxy, + timeout=10, + ) return {str(m["id"]) for m in r.json().get("emails", [])} except Exception: return set() - def wait_for_code(self, account: MailboxAccount, keyword: str = "", - timeout: int = 120, before_ids: set = None, code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: import re, time, requests + seen = set(before_ids or []) otp_sent_at = kwargs.get("otp_sent_at") otp_cutoff = float(otp_sent_at) - 2 if otp_sent_at else None start = time.time() while time.time() - start < timeout: try: - r = requests.get(f"{self.api}/inbox", + r = requests.get( + f"{self.api}/inbox", params={"token": account.account_id}, - proxies=self.proxy, timeout=10) - for mail in sorted(r.json().get("emails", []), key=lambda x: x.get("date", 0), reverse=True): + proxies=self.proxy, + timeout=10, + ) + for mail in sorted( + r.json().get("emails", []), + key=lambda x: x.get("date", 0), + reverse=True, + ): mid = str(mail.get("id", "")) if mid in seen: continue if otp_sent_at and mail.get("date", 0) / 1000 < otp_sent_at: continue seen.add(mid) - text = mail.get("subject", "") + " " + mail.get("body", "") + " " + mail.get("html", "") + text = ( + mail.get("subject", "") + + " " + + mail.get("body", "") + + " " + + mail.get("html", "") + ) if keyword and keyword.lower() not in text.lower(): continue code = self._safe_extract(text, code_pattern) @@ -349,7 +441,7 @@ class SkyMailMailbox(BaseMailbox): self.api = (api_base or "").rstrip("/") self.auth_token = auth_token or "" self.domain = domain or "" - self.proxy = {"http": proxy, "https": proxy} if proxy else None + self.proxy = build_requests_proxy_config(proxy) def _headers(self) -> dict: return { @@ -492,18 +584,21 @@ class SkyMailMailbox(BaseMailbox): class DuckMailMailbox(BaseMailbox): """DuckMail 自动生成邮箱(随机创建账号)""" - def __init__(self, api_url: str = "https://www.duckmail.sbs", - provider_url: str = "https://api.duckmail.sbs", - bearer: str = "kevin273945", - domain: str = "", - api_key: str = "", - proxy: str = None): + def __init__( + self, + api_url: str = "https://www.duckmail.sbs", + provider_url: str = "https://api.duckmail.sbs", + bearer: str = "kevin273945", + domain: str = "", + api_key: str = "", + proxy: str = None, + ): self.api = (api_url or "https://www.duckmail.sbs").rstrip("/") self.provider_url = (provider_url or "https://api.duckmail.sbs").rstrip("/") self.bearer = bearer or "kevin273945" self.domain = str(domain or "").strip() self.api_key = str(api_key or "").strip() - self.proxy = {"http": proxy, "https": proxy} if proxy else None + self.proxy = build_requests_proxy_config(proxy) self._token = None self._address = None # 如果配置了 API Key,直接请求 DuckMail API;否则走前端代理 @@ -526,36 +621,55 @@ class DuckMailMailbox(BaseMailbox): def _request(self, method: str, endpoint: str, token: str = "", **kwargs): """统一请求方法,根据模式选择直连或代理""" import requests + if self._direct: url = f"{self.provider_url}{endpoint}" headers = self._direct_headers(token) else: from urllib.parse import quote + url = f"{self.api}/api/mail?endpoint={quote(endpoint, safe='')}" - headers = self._proxy_headers() if not token else { - "authorization": f"Bearer {token}", - "x-api-provider-base-url": self.provider_url, - } - r = requests.request(method, url, headers=headers, proxies=self.proxy, timeout=15, **kwargs) + headers = ( + self._proxy_headers() + if not token + else { + "authorization": f"Bearer {token}", + "x-api-provider-base-url": self.provider_url, + } + ) + r = requests.request( + method, url, headers=headers, proxies=self.proxy, timeout=15, **kwargs + ) return r def get_email(self) -> MailboxAccount: import random, string + username = "".join(random.choices(string.ascii_lowercase + string.digits, k=10)) password = "Test" + "".join(random.choices(string.digits, k=8)) + "!" - domain = self.domain or self.provider_url.replace("https://api.", "").replace("https://", "") + domain = self.domain or self.provider_url.replace("https://api.", "").replace( + "https://", "" + ) address = f"{username}@{domain}" print(f"[DuckMail] 创建账号: {address} direct={self._direct}") # 创建账号 - r = self._request("POST", "/accounts", json={"address": address, "password": password}) + r = self._request( + "POST", "/accounts", json={"address": address, "password": password} + ) if r.status_code >= 400 or not r.text.strip().startswith("{"): - raise RuntimeError(f"[DuckMail] 创建账号失败: HTTP {r.status_code} body={r.text[:300]}") + raise RuntimeError( + f"[DuckMail] 创建账号失败: HTTP {r.status_code} body={r.text[:300]}" + ) data = r.json() self._address = data.get("address", address) # 登录获取 token - r2 = self._request("POST", "/token", json={"address": self._address, "password": password}) + r2 = self._request( + "POST", "/token", json={"address": self._address, "password": password} + ) if r2.status_code >= 400 or not r2.text.strip().startswith(("{", "[")): - raise RuntimeError(f"[DuckMail] 登录失败: HTTP {r2.status_code} body={r2.text[:300]}") + raise RuntimeError( + f"[DuckMail] 登录失败: HTTP {r2.status_code} body={r2.text[:300]}" + ) self._token = r2.json().get("token", "") return MailboxAccount(email=self._address, account_id=self._token) @@ -566,9 +680,17 @@ class DuckMailMailbox(BaseMailbox): except Exception: return set() - def wait_for_code(self, account: MailboxAccount, keyword: str = "", - timeout: int = 120, before_ids: set = None, code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: import re, time + seen = set(before_ids or []) start = time.time() while time.time() - start < timeout: @@ -577,18 +699,28 @@ class DuckMailMailbox(BaseMailbox): msgs = r.json().get("hydra:member", []) for msg in msgs: mid = str(msg.get("id") or msg.get("msgid") or "") - if mid in seen: continue + if mid in seen: + continue seen.add(mid) # 请求邮件详情获取完整 text try: - r2 = self._request("GET", f"/messages/{mid}", token=account.account_id) + r2 = self._request( + "GET", f"/messages/{mid}", token=account.account_id + ) detail = r2.json() - body = str(detail.get("text") or "") + " " + str(detail.get("subject") or "") + body = ( + str(detail.get("text") or "") + + " " + + str(detail.get("subject") or "") + ) except Exception: body = str(msg.get("subject") or "") - body = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '', body) + body = re.sub( + r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "", body + ) code = self._safe_extract(body, code_pattern) - if code: return code + if code: + return code except Exception: pass time.sleep(3) @@ -610,7 +742,7 @@ class MaliAPIMailbox(BaseMailbox): self.api_key = str(api_key or "").strip() self.domain = str(domain or "").strip() self.auto_domain_strategy = str(auto_domain_strategy or "").strip() - self.proxy = {"http": proxy, "https": proxy} if proxy else None + self.proxy = build_requests_proxy_config(proxy) self._email = None self._temp_token = None @@ -625,8 +757,15 @@ class MaliAPIMailbox(BaseMailbox): headers["Authorization"] = f"Bearer {bearer}" return headers - def _request(self, method: str, path: str, *, json_body: dict = None, - params: dict = None, bearer: str = "") -> Any: + def _request( + self, + method: str, + path: str, + *, + json_body: dict = None, + params: dict = None, + bearer: str = "", + ) -> Any: import requests response = requests.request( @@ -696,10 +835,7 @@ class MaliAPIMailbox(BaseMailbox): email = str(data.get("address") or data.get("email") or "").strip() temp_token = str( - data.get("tempToken") - or data.get("temp_token") - or data.get("token") - or "" + data.get("tempToken") or data.get("temp_token") or data.get("token") or "" ).strip() inbox_id = str(data.get("id") or "").strip() if not email: @@ -729,9 +865,15 @@ class MaliAPIMailbox(BaseMailbox): except Exception: return set() - def wait_for_code(self, account: MailboxAccount, keyword: str = "", - timeout: int = 120, before_ids: set = None, - code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: import re import time @@ -751,13 +893,15 @@ class MaliAPIMailbox(BaseMailbox): except Exception: detail = message - search_text = " ".join([ - str(detail.get("subject") or message.get("subject") or ""), - str(detail.get("text") or ""), - str(detail.get("html") or ""), - str(message.get("subject") or ""), - str(message.get("snippet") or ""), - ]).strip() + search_text = " ".join( + [ + str(detail.get("subject") or message.get("subject") or ""), + str(detail.get("text") or ""), + str(detail.get("html") or ""), + str(message.get("subject") or ""), + str(message.get("snippet") or ""), + ] + ).strip() search_text = self._decode_raw_content(search_text) or search_text search_text = re.sub( r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", @@ -780,10 +924,18 @@ class MaliAPIMailbox(BaseMailbox): class CFWorkerMailbox(BaseMailbox): """Cloudflare Worker 自建临时邮箱服务""" - def __init__(self, api_url: str, admin_token: str = "", domain: str = "", - domain_override: str = "", domains: Any = None, - enabled_domains: Any = None, fingerprint: str = "", - custom_auth: str = "", proxy: str = None): + def __init__( + self, + api_url: str, + admin_token: str = "", + domain: str = "", + domain_override: str = "", + domains: Any = None, + enabled_domains: Any = None, + fingerprint: str = "", + custom_auth: str = "", + proxy: str = None, + ): self.api = api_url.rstrip("/") self.admin_token = admin_token self.domain = self._normalize_domain(domain) @@ -797,7 +949,7 @@ class CFWorkerMailbox(BaseMailbox): self.enabled_domains = raw_enabled_domains self.fingerprint = fingerprint self.custom_auth = custom_auth - self.proxy = {"http": proxy, "https": proxy} if proxy else None + self.proxy = build_requests_proxy_config(proxy) self._token = None def _headers(self) -> dict: @@ -825,8 +977,16 @@ class CFWorkerMailbox(BaseMailbox): raise RuntimeError( f"CF Worker {action} 返回非 JSON 响应: HTTP {response.status_code}, body={snippet}" ) - def _request_json(self, method: str, path: str, *, params: dict | None = None, - payload: dict | None = None, timeout: int = 15): + + def _request_json( + self, + method: str, + path: str, + *, + params: dict | None = None, + payload: dict | None = None, + timeout: int = 15, + ): import requests url = f"{self.api}{path}" @@ -860,6 +1020,7 @@ class CFWorkerMailbox(BaseMailbox): def _generate_local_part(self) -> str: import string + # 避免纯数字开头,提高邮箱格式“像真人”的程度 prefix = "".join(random.choices(string.ascii_lowercase, k=6)) suffix = "".join(random.choices(string.digits, k=4)) @@ -891,7 +1052,9 @@ class CFWorkerMailbox(BaseMailbox): if isinstance(parsed, list): items = parsed else: - items = [part for chunk in text.splitlines() for part in chunk.split(",")] + items = [ + part for chunk in text.splitlines() for part in chunk.split(",") + ] else: items = [value] @@ -920,13 +1083,19 @@ class CFWorkerMailbox(BaseMailbox): if selected_domain: payload["domain"] = selected_domain self._log(f"[CFWorker] 本次使用域名: {selected_domain}") - data = self._request_json("POST", "/admin/new_address", payload=payload, timeout=15) + data = self._request_json( + "POST", "/admin/new_address", payload=payload, timeout=15 + ) email = data.get("email", data.get("address", "")) token = data.get("token", data.get("jwt", "")) if not email or not token: - raise RuntimeError(f"CFWorker API /admin/new_address 返回缺少 email/jwt: {data}") + raise RuntimeError( + f"CFWorker API /admin/new_address 返回缺少 email/jwt: {data}" + ) self._token = token - print(f"[CFWorker] 生成邮箱: {email} token={token[:40] if token else 'NONE'}...") + print( + f"[CFWorker] 生成邮箱: {email} token={token[:40] if token else 'NONE'}..." + ) return MailboxAccount( email=email, account_id=token, @@ -950,8 +1119,15 @@ class CFWorkerMailbox(BaseMailbox): except Exception: return set() - def wait_for_code(self, account: MailboxAccount, keyword: str = "", - timeout: int = 120, before_ids: set = None, code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: import re import time from datetime import datetime, timezone @@ -972,9 +1148,15 @@ class CFWorkerMailbox(BaseMailbox): created_at = str(mail.get("created_at", "") or "").strip() if otp_cutoff and created_at: try: - mail_ts = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc).timestamp() + mail_ts = ( + datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S") + .replace(tzinfo=timezone.utc) + .timestamp() + ) if mail_ts < otp_cutoff: - self._log(f"[CFWorker] \u8df3\u8fc7\u65e7\u90ae\u4ef6 id={mid} created_at={created_at}") + self._log( + f"[CFWorker] \u8df3\u8fc7\u65e7\u90ae\u4ef6 id={mid} created_at={created_at}" + ) continue except Exception: pass @@ -985,18 +1167,26 @@ class CFWorkerMailbox(BaseMailbox): raw = str(mail.get("raw", "")) subject = str(mail.get("subject", "")) search_text = f"{subject} {self._decode_raw_content(raw)}".strip() - search_text = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '', search_text) - search_text = re.sub(r'm=\+\d+\.\d+', '', search_text) - search_text = re.sub(r'\bt=\d+\b', '', search_text) + search_text = re.sub( + r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", + "", + search_text, + ) + search_text = re.sub(r"m=\+\d+\.\d+", "", search_text) + search_text = re.sub(r"\bt=\d+\b", "", search_text) if keyword and keyword.lower() not in search_text.lower(): continue code = self._safe_extract(search_text, code_pattern) if code and code in exclude_codes: - self._log(f"[CFWorker] \u8df3\u8fc7\u5df2\u7528\u9a8c\u8bc1\u7801 id={mid} created_at={created_at} code={code}") + self._log( + f"[CFWorker] \u8df3\u8fc7\u5df2\u7528\u9a8c\u8bc1\u7801 id={mid} created_at={created_at} code={code}" + ) continue if code: - self._log(f"[CFWorker] \u547d\u4e2d\u65b0\u9a8c\u8bc1\u7801 id={mid} created_at={created_at} code={code}") + self._log( + f"[CFWorker] \u547d\u4e2d\u65b0\u9a8c\u8bc1\u7801 id={mid} created_at={created_at} code={code}" + ) return code except Exception: pass @@ -1009,32 +1199,40 @@ class MoeMailMailbox(BaseMailbox): def __init__(self, api_url: str = "https://sall.cc", proxy: str = None): self.api = api_url.rstrip("/") - self.proxy = {"http": proxy, "https": proxy} if proxy else None + self.proxy = build_requests_proxy_config(proxy) self._session_token = None self._email = None def _register_and_login(self) -> str: import requests, random, string + s = requests.Session() s.proxies = self.proxy ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" - s.headers.update({"user-agent": ua, "origin": self.api, "referer": f"{self.api}/zh-CN/login"}) + s.headers.update( + {"user-agent": ua, "origin": self.api, "referer": f"{self.api}/zh-CN/login"} + ) # 注册 username = "".join(random.choices(string.ascii_lowercase + string.digits, k=12)) password = "Test" + "".join(random.choices(string.digits, k=8)) + "!" print(f"[MoeMail] 注册账号: {username} / {password}") - r_reg = s.post(f"{self.api}/api/auth/register", + r_reg = s.post( + f"{self.api}/api/auth/register", json={"username": username, "password": password, "turnstileToken": ""}, - timeout=15) + timeout=15, + ) print(f"[MoeMail] 注册结果: {r_reg.status_code} {r_reg.text[:80]}") # 获取 CSRF csrf_r = s.get(f"{self.api}/api/auth/csrf", timeout=10) csrf = csrf_r.json().get("csrfToken", "") # 登录 - s.post(f"{self.api}/api/auth/callback/credentials", + s.post( + f"{self.api}/api/auth/callback/credentials", headers={"content-type": "application/x-www-form-urlencoded"}, data=f"username={username}&password={password}&csrfToken={csrf}&redirect=false&callbackUrl={self.api}", - allow_redirects=True, timeout=15) + allow_redirects=True, + timeout=15, + ) self._session = s for cookie in s.cookies: if "session-token" in cookie.name: @@ -1049,56 +1247,89 @@ class MoeMailMailbox(BaseMailbox): self._session_token = None self._register_and_login() import random, string + name = "".join(random.choices(string.ascii_letters + string.digits, k=8)) # 获取可用域名列表,随机选一个 domain = "sall.cc" try: cfg_r = self._session.get(f"{self.api}/api/config", timeout=10) - domains = [d.strip() for d in cfg_r.json().get("emailDomains", "sall.cc").split(",") if d.strip()] + domains = [ + d.strip() + for d in cfg_r.json().get("emailDomains", "sall.cc").split(",") + if d.strip() + ] if domains: domain = random.choice(domains) except Exception: pass - r = self._session.post(f"{self.api}/api/emails/generate", + r = self._session.post( + f"{self.api}/api/emails/generate", json={"name": name, "domain": domain, "expiryTime": 86400000}, - timeout=15) + timeout=15, + ) data = r.json() self._email = data.get("email", data.get("address", "")) email_id = data.get("id", "") - print(f"[MoeMail] 生成邮箱: {self._email} id={email_id} domain={domain} status={r.status_code}") + print( + f"[MoeMail] 生成邮箱: {self._email} id={email_id} domain={domain} status={r.status_code}" + ) if not email_id: print(f"[MoeMail] 生成失败: {data}") if email_id: - self._email_count = getattr(self, '_email_count', 0) + 1 + self._email_count = getattr(self, "_email_count", 0) + 1 return MailboxAccount(email=self._email, account_id=str(email_id)) def get_current_ids(self, account: MailboxAccount) -> set: try: - r = self._session.get(f"{self.api}/api/emails/{account.account_id}", timeout=10) + r = self._session.get( + f"{self.api}/api/emails/{account.account_id}", timeout=10 + ) return {str(m.get("id", "")) for m in r.json().get("messages", [])} except Exception: return set() - def wait_for_code(self, account: MailboxAccount, keyword: str = "", - timeout: int = 120, before_ids: set = None, - code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: import re, time + seen = set(before_ids or []) start = time.time() pattern = re.compile(code_pattern) if code_pattern else None while time.time() - start < timeout: try: - r = self._session.get(f"{self.api}/api/emails/{account.account_id}", - timeout=10) + r = self._session.get( + f"{self.api}/api/emails/{account.account_id}", timeout=10 + ) msgs = r.json().get("messages", []) for msg in msgs: mid = str(msg.get("id", "")) - if not mid or mid in seen: continue + if not mid or mid in seen: + continue seen.add(mid) - body = str(msg.get("content") or msg.get("text") or msg.get("body") or msg.get("html") or "") + " " + str(msg.get("subject") or "") - body = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '', body) + body = ( + str( + msg.get("content") + or msg.get("text") + or msg.get("body") + or msg.get("html") + or "" + ) + + " " + + str(msg.get("subject") or "") + ) + body = re.sub( + r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "", body + ) code = self._safe_extract(body, code_pattern) - if code: return code + if code: + return code except Exception: pass time.sleep(3) @@ -1108,14 +1339,20 @@ class MoeMailMailbox(BaseMailbox): class LuckMailMailbox(BaseMailbox): """LuckMail 混合模式:ChatGPT 走购买邮箱,其他平台走订单接码""" - def __init__(self, base_url: str, api_key: str, - project_code: str = "", email_type: str = "", - domain: str = ""): + def __init__( + self, + base_url: str, + api_key: str, + project_code: str = "", + email_type: str = "", + domain: str = "", + ): if not base_url or not api_key: raise RuntimeError( "LuckMail 未配置:请在全局设置中填写 luckmail_base_url 和 luckmail_api_key" ) from .luckmail import LuckMailClient + self._client = LuckMailClient( base_url=base_url, api_key=api_key, @@ -1128,7 +1365,11 @@ class LuckMailMailbox(BaseMailbox): self._email = None def _use_purchase_mode(self, account: MailboxAccount = None) -> bool: - if account and account.account_id and str(account.account_id).startswith("tok_"): + if ( + account + and account.account_id + and str(account.account_id).startswith("tok_") + ): return True if self._token: return True @@ -1161,8 +1402,13 @@ class LuckMailMailbox(BaseMailbox): return item.token return "" - def _extract_code_from_token_mails(self, token: str, code_pattern: str = None, - before_ids: set = None, exclude_codes: set = None) -> Optional[str]: + def _extract_code_from_token_mails( + self, + token: str, + code_pattern: str = None, + before_ids: set = None, + exclude_codes: set = None, + ) -> Optional[str]: try: mail_list = self._client.user.get_token_mails(token) except Exception: @@ -1174,11 +1420,13 @@ class LuckMailMailbox(BaseMailbox): message_id = str(mail.message_id or "") if message_id and message_id in seen: continue - body = " ".join([ - str(mail.subject or ""), - str(mail.body or ""), - str(mail.html_body or ""), - ]) + body = " ".join( + [ + str(mail.subject or ""), + str(mail.body or ""), + str(mail.html_body or ""), + ] + ) code = self._safe_extract(body, code_pattern) if code and code in excluded: continue @@ -1260,9 +1508,15 @@ class LuckMailMailbox(BaseMailbox): except Exception: return set() - def wait_for_code(self, account: MailboxAccount, keyword: str = "", - timeout: int = 120, before_ids: set = None, - code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: if not self._use_purchase_mode(account): self._log("[LuckMail] 等验证码分支: 订单接码") order_no = account.account_id or self._order_no @@ -1296,10 +1550,14 @@ class LuckMailMailbox(BaseMailbox): raise RuntimeError("LuckMail 未找到已购邮箱 Token,无法等待验证码") self._log("[LuckMail] 等验证码分支: 已购邮箱 Token 收码") - exclude_codes = {str(code) for code in (kwargs.get("exclude_codes") or set()) if code} + exclude_codes = { + str(code) for code in (kwargs.get("exclude_codes") or set()) if code + } def on_poll(result): - self._log(f"[LuckMail] 轮询中... 新邮件: {'是' if result.has_new_mail else '否'}") + self._log( + f"[LuckMail] 轮询中... 新邮件: {'是' if result.has_new_mail else '否'}" + ) try: code_result = self._client.user.wait_for_token_code( @@ -1315,7 +1573,9 @@ class LuckMailMailbox(BaseMailbox): if code and code in exclude_codes: code = None if not code and code_result.mail: - parsed_code = self._safe_extract(json.dumps(code_result.mail, ensure_ascii=False), code_pattern) + parsed_code = self._safe_extract( + json.dumps(code_result.mail, ensure_ascii=False), code_pattern + ) if parsed_code and parsed_code not in exclude_codes: code = parsed_code if not code and (code_result.has_new_mail or before_ids is None): @@ -1342,27 +1602,35 @@ class FreemailMailbox(BaseMailbox): 支持管理员令牌或账号密码两种认证方式 """ - def __init__(self, api_url: str, admin_token: str = "", - username: str = "", password: str = "", - proxy: str = None): + def __init__( + self, + api_url: str, + admin_token: str = "", + username: str = "", + password: str = "", + proxy: str = None, + ): self.api = api_url.rstrip("/") self.admin_token = admin_token self.username = username self.password = password - self.proxy = {"http": proxy, "https": proxy} if proxy else None + self.proxy = build_requests_proxy_config(proxy) self._session = None self._email = None def _get_session(self): import requests + s = requests.Session() s.proxies = self.proxy if self.admin_token: s.headers.update({"Authorization": f"Bearer {self.admin_token}"}) elif self.username and self.password: - s.post(f"{self.api}/api/login", + s.post( + f"{self.api}/api/login", json={"username": self.username, "password": self.password}, - timeout=15) + timeout=15, + ) self._session = s return s @@ -1370,6 +1638,7 @@ class FreemailMailbox(BaseMailbox): if not self._session: self._get_session() import requests + r = self._session.get(f"{self.api}/api/generate", timeout=15) data = r.json() email = data.get("email", "") @@ -1379,33 +1648,51 @@ class FreemailMailbox(BaseMailbox): def get_current_ids(self, account: MailboxAccount) -> set: try: - r = self._session.get(f"{self.api}/api/emails", - params={"mailbox": account.email, "limit": 50}, timeout=10) + r = self._session.get( + f"{self.api}/api/emails", + params={"mailbox": account.email, "limit": 50}, + timeout=10, + ) return {str(m["id"]) for m in r.json() if "id" in m} except Exception: return set() - def wait_for_code(self, account: MailboxAccount, keyword: str = "", - timeout: int = 120, before_ids: set = None, code_pattern: str = None, **kwargs) -> str: + def wait_for_code( + self, + account: MailboxAccount, + keyword: str = "", + timeout: int = 120, + before_ids: set = None, + code_pattern: str = None, + **kwargs, + ) -> str: import re, time + seen = set(before_ids or []) start = time.time() while time.time() - start < timeout: try: - r = self._session.get(f"{self.api}/api/emails", - params={"mailbox": account.email, "limit": 20}, timeout=10) + r = self._session.get( + f"{self.api}/api/emails", + params={"mailbox": account.email, "limit": 20}, + timeout=10, + ) for msg in r.json(): mid = str(msg.get("id", "")) - if not mid or mid in seen: continue + if not mid or mid in seen: + continue seen.add(mid) # 直接用 verification_code 字段 code = str(msg.get("verification_code") or "") if code and code != "None": return code # 兜底:从 preview 提取 - text = str(msg.get("preview", "")) + " " + str(msg.get("subject", "")) + text = ( + str(msg.get("preview", "")) + " " + str(msg.get("subject", "")) + ) code = self._safe_extract(text, code_pattern) - if code: return code + if code: + return code except Exception: pass time.sleep(3) diff --git a/core/executors/playwright.py b/core/executors/playwright.py index 6d33467..827a8f3 100644 --- a/core/executors/playwright.py +++ b/core/executors/playwright.py @@ -1,10 +1,12 @@ """Playwright 执行器 - 支持 headless/headed 模式""" + from ..base_executor import BaseExecutor, Response +from ..proxy_utils import build_playwright_proxy_config, normalize_proxy_url class PlaywrightExecutor(BaseExecutor): def __init__(self, proxy: str = None, headless: bool = True): - super().__init__(proxy) + super().__init__(normalize_proxy_url(proxy)) self.headless = headless self._browser = None self._context = None @@ -13,16 +15,18 @@ class PlaywrightExecutor(BaseExecutor): def _init(self): from playwright.sync_api import sync_playwright + self._pw = sync_playwright().start() launch_opts = {"headless": self.headless} if self.proxy: - launch_opts["proxy"] = {"server": self.proxy} + launch_opts["proxy"] = build_playwright_proxy_config(self.proxy) self._browser = self._pw.chromium.launch(**launch_opts) self._context = self._browser.new_context() self._page = self._context.new_page() def get(self, url, *, headers=None, params=None) -> Response: import urllib.parse + if params: url = url + "?" + urllib.parse.urlencode(params) if headers: @@ -37,6 +41,7 @@ class PlaywrightExecutor(BaseExecutor): def post(self, url, *, headers=None, params=None, data=None, json=None) -> Response: import urllib.parse, json as _json + if params: url = url + "?" + urllib.parse.urlencode(params) post_data = None @@ -63,13 +68,16 @@ class PlaywrightExecutor(BaseExecutor): def set_cookies(self, cookies: dict, domain: str = ".example.com") -> None: page_url = self._page.url if self._page else None if page_url and page_url.startswith("http"): - self._context.add_cookies([ - {"name": k, "value": v, "url": page_url} for k, v in cookies.items() - ]) + self._context.add_cookies( + [{"name": k, "value": v, "url": page_url} for k, v in cookies.items()] + ) else: - self._context.add_cookies([ - {"name": k, "value": v, "domain": domain, "path": "/"} for k, v in cookies.items() - ]) + self._context.add_cookies( + [ + {"name": k, "value": v, "domain": domain, "path": "/"} + for k, v in cookies.items() + ] + ) def close(self) -> None: if self._browser: diff --git a/core/executors/protocol.py b/core/executors/protocol.py index 4da6abe..ac46daa 100644 --- a/core/executors/protocol.py +++ b/core/executors/protocol.py @@ -1,20 +1,27 @@ """纯协议执行器 - 基于 curl_cffi""" + from curl_cffi import requests as curl_requests from ..base_executor import BaseExecutor, Response +from ..proxy_utils import build_requests_proxy_config, normalize_proxy_url class ProtocolExecutor(BaseExecutor): def __init__(self, proxy: str = None, impersonate: str = "chrome124"): - super().__init__(proxy) + normalized_proxy = normalize_proxy_url(proxy) + super().__init__(normalized_proxy) self.s = curl_requests.Session() self.s.impersonate = impersonate - if proxy: - self.s.proxies = {"http": proxy, "https": proxy} - self.s.headers.update({ - "user-agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/124.0.0.0 Safari/537.36") - }) + if normalized_proxy: + self.s.proxies = build_requests_proxy_config(normalized_proxy) + self.s.headers.update( + { + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ) + } + ) def _wrap(self, r) -> Response: cookies = {c.name: c.value for c in self.s.cookies.jar} diff --git a/core/http_client.py b/core/http_client.py index 2e9b947..795ea94 100644 --- a/core/http_client.py +++ b/core/http_client.py @@ -1,4 +1,5 @@ """通用 HTTP 客户端 - 基于 curl_cffi,支持代理、重试、会话管理""" + """ HTTP 客户端封装 基于 curl_cffi 的 HTTP 请求封装,支持代理和错误处理 @@ -12,9 +13,7 @@ import logging from curl_cffi import requests as cffi_requests from curl_cffi.requests import Session, Response - - - +from .proxy_utils import build_requests_proxy_config, normalize_proxy_url logger = logging.getLogger(__name__) @@ -23,6 +22,7 @@ logger = logging.getLogger(__name__) @dataclass class RequestConfig: """HTTP 请求配置""" + timeout: int = 30 max_retries: int = 3 retry_delay: float = 1.0 @@ -33,6 +33,7 @@ class RequestConfig: class HTTPClientError(Exception): """HTTP 客户端异常""" + pass @@ -46,7 +47,7 @@ class HTTPClient: self, proxy_url: Optional[str] = None, config: Optional[RequestConfig] = None, - session: Optional[Session] = None + session: Optional[Session] = None, ): """ 初始化 HTTP 客户端 @@ -56,19 +57,14 @@ class HTTPClient: config: 请求配置 session: 可重用的会话对象 """ - self.proxy_url = proxy_url + self.proxy_url = normalize_proxy_url(proxy_url) self.config = config or RequestConfig() self._session = session @property def proxies(self) -> Optional[Dict[str, str]]: """获取代理配置""" - if not self.proxy_url: - return None - return { - "http": self.proxy_url, - "https": self.proxy_url, - } + return build_requests_proxy_config(self.proxy_url) @property def session(self) -> Session: @@ -78,16 +74,11 @@ class HTTPClient: proxies=self.proxies, impersonate=self.config.impersonate, verify=self.config.verify_ssl, - timeout=self.config.timeout + timeout=self.config.timeout, ) return self._session - def request( - self, - method: str, - url: str, - **kwargs - ) -> Response: + def request(self, method: str, url: str, **kwargs) -> Response: """ 发送 HTTP 请求 @@ -123,7 +114,10 @@ class HTTPClient: ) # 如果是服务器错误,重试 - if response.status_code >= 500 and attempt < self.config.max_retries - 1: + if ( + response.status_code >= 500 + and attempt < self.config.max_retries - 1 + ): time.sleep(self.config.retry_delay * (attempt + 1)) continue @@ -188,7 +182,7 @@ class HTTPClient: response = self.get(url, stream=True) response.raise_for_status() - with open(filepath, 'wb') as f: + with open(filepath, "wb") as f: for chunk in response.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) @@ -225,4 +219,4 @@ class HTTPClient: return self def __exit__(self, exc_type, exc_val, exc_tb): - self.close() \ No newline at end of file + self.close() diff --git a/core/proxy_pool.py b/core/proxy_pool.py index aca19ca..de581b7 100644 --- a/core/proxy_pool.py +++ b/core/proxy_pool.py @@ -1,7 +1,9 @@ """代理池 - 从数据库读取代理,支持轮询和按区域选取""" + from typing import Optional from sqlmodel import Session, select from .db import ProxyModel, engine +from .proxy_utils import build_requests_proxy_config import time, threading, random from datetime import datetime, timezone @@ -22,7 +24,7 @@ class ProxyPool: return None proxies.sort( key=lambda p: p.success_count / max(p.success_count + p.fail_count, 1), - reverse=True + reverse=True, ) with self._lock: idx = self._index % len(proxies) @@ -53,14 +55,17 @@ class ProxyPool: def check_all(self) -> dict: """检测所有代理可用性""" import requests + with Session(engine) as s: proxies = s.exec(select(ProxyModel)).all() results = {"ok": 0, "fail": 0} for p in proxies: try: - r = requests.get("https://httpbin.org/ip", - proxies={"http": p.url, "https": p.url}, - timeout=8) + r = requests.get( + "https://httpbin.org/ip", + proxies=build_requests_proxy_config(p.url), + timeout=8, + ) if r.status_code == 200: self.report_success(p.url) results["ok"] += 1 diff --git a/core/proxy_utils.py b/core/proxy_utils.py new file mode 100644 index 0000000..8a0df65 --- /dev/null +++ b/core/proxy_utils.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Optional +from urllib.parse import unquote, urlsplit, urlunsplit + + +def normalize_proxy_url(proxy_url: Optional[str]) -> Optional[str]: + """将 socks5:// 规范化为 socks5h://,避免本地 DNS 泄漏。""" + if proxy_url is None: + return None + + value = str(proxy_url).strip() + if not value: + return None + + parts = urlsplit(value) + if (parts.scheme or "").lower() == "socks5": + parts = parts._replace(scheme="socks5h") + return urlunsplit(parts) + return value + + +def build_requests_proxy_config(proxy_url: Optional[str]) -> Optional[dict[str, str]]: + normalized = normalize_proxy_url(proxy_url) + if not normalized: + return None + return {"http": normalized, "https": normalized} + + +def build_playwright_proxy_config(proxy_url: Optional[str]) -> Optional[dict[str, str]]: + normalized = normalize_proxy_url(proxy_url) + if not normalized: + return None + + parts = urlsplit(normalized) + if not parts.scheme or not parts.hostname or parts.port is None: + return {"server": normalized} + + config = {"server": f"{parts.scheme}://{parts.hostname}:{parts.port}"} + if parts.username: + config["username"] = unquote(parts.username) + if parts.password: + config["password"] = unquote(parts.password) + return config diff --git a/platforms/chatgpt/chatgpt_client.py b/platforms/chatgpt/chatgpt_client.py index 0f4cb82..4fc0ded 100644 --- a/platforms/chatgpt/chatgpt_client.py +++ b/platforms/chatgpt/chatgpt_client.py @@ -7,12 +7,14 @@ import random import uuid import time from urllib.parse import urlparse +from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url try: from curl_cffi import requests as curl_requests except ImportError: print("❌ 需要安装 curl_cffi: pip install curl_cffi") import sys + sys.exit(1) from .sentinel_token import build_sentinel_token @@ -32,18 +34,24 @@ from .utils import ( # Chrome 指纹配置 _CHROME_PROFILES = [ { - "major": 131, "impersonate": "chrome131", - "build": 6778, "patch_range": (69, 205), + "major": 131, + "impersonate": "chrome131", + "build": 6778, + "patch_range": (69, 205), "sec_ch_ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', }, { - "major": 133, "impersonate": "chrome133a", - "build": 6943, "patch_range": (33, 153), + "major": 133, + "impersonate": "chrome133a", + "build": 6943, + "patch_range": (33, 153), "sec_ch_ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"', }, { - "major": 136, "impersonate": "chrome136", - "build": 7103, "patch_range": (48, 175), + "major": 136, + "impersonate": "chrome136", + "build": 7103, + "patch_range": (48, 175), "sec_ch_ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"', }, ] @@ -62,56 +70,66 @@ def _random_chrome_version(): class ChatGPTClient: """ChatGPT 注册客户端""" - + BASE = "https://chatgpt.com" AUTH = "https://auth.openai.com" - + def __init__(self, proxy=None, verbose=True, browser_mode="protocol"): """ 初始化 ChatGPT 客户端 - + Args: proxy: 代理地址 verbose: 是否输出详细日志 browser_mode: protocol | headless | headed """ - self.proxy = proxy + self.proxy = normalize_proxy_url(proxy) self.verbose = verbose self.browser_mode = browser_mode or "protocol" self.device_id = str(uuid.uuid4()) - self.accept_language = random.choice([ - "en-US,en;q=0.9", - "en-US,en;q=0.9,zh-CN;q=0.8", - "en,en-US;q=0.9", - "en-US,en;q=0.8", - ]) - + self.accept_language = random.choice( + [ + "en-US,en;q=0.9", + "en-US,en;q=0.9,zh-CN;q=0.8", + "en,en-US;q=0.9", + "en-US,en;q=0.8", + ] + ) + # 随机 Chrome 版本 - self.impersonate, self.chrome_major, self.chrome_full, self.ua, self.sec_ch_ua = _random_chrome_version() - + ( + self.impersonate, + self.chrome_major, + self.chrome_full, + self.ua, + self.sec_ch_ua, + ) = _random_chrome_version() + # 创建 session self.session = curl_requests.Session(impersonate=self.impersonate) - + if self.proxy: - self.session.proxies = {"http": self.proxy, "https": self.proxy} - + self.session.proxies = build_requests_proxy_config(self.proxy) + # 设置基础 headers - self.session.headers.update({ - "User-Agent": self.ua, - "Accept-Language": self.accept_language, - "sec-ch-ua": self.sec_ch_ua, - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', - "sec-ch-ua-arch": '"x86"', - "sec-ch-ua-bitness": '"64"', - "sec-ch-ua-full-version": f'"{self.chrome_full}"', - "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"', - }) - + self.session.headers.update( + { + "User-Agent": self.ua, + "Accept-Language": self.accept_language, + "sec-ch-ua": self.sec_ch_ua, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version": f'"{self.chrome_full}"', + "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"', + } + ) + # 设置 oai-did cookie seed_oai_device_cookie(self.session, self.device_id) self.last_registration_state = FlowState() - + def _log(self, msg): """输出日志""" if self.verbose: @@ -157,29 +175,39 @@ class ChatGPTClient: def _reset_session(self): """重置浏览器指纹与会话,用于绕过偶发的 Cloudflare/SPA 中间页。""" self.device_id = str(uuid.uuid4()) - self.impersonate, self.chrome_major, self.chrome_full, self.ua, self.sec_ch_ua = _random_chrome_version() - self.accept_language = random.choice([ - "en-US,en;q=0.9", - "en-US,en;q=0.9,zh-CN;q=0.8", - "en,en-US;q=0.9", - "en-US,en;q=0.8", - ]) + ( + self.impersonate, + self.chrome_major, + self.chrome_full, + self.ua, + self.sec_ch_ua, + ) = _random_chrome_version() + self.accept_language = random.choice( + [ + "en-US,en;q=0.9", + "en-US,en;q=0.9,zh-CN;q=0.8", + "en,en-US;q=0.9", + "en-US,en;q=0.8", + ] + ) self.session = curl_requests.Session(impersonate=self.impersonate) if self.proxy: - self.session.proxies = {"http": self.proxy, "https": self.proxy} + self.session.proxies = build_requests_proxy_config(self.proxy) - self.session.headers.update({ - "User-Agent": self.ua, - "Accept-Language": self.accept_language, - "sec-ch-ua": self.sec_ch_ua, - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', - "sec-ch-ua-arch": '"x86"', - "sec-ch-ua-bitness": '"64"', - "sec-ch-ua-full-version": f'"{self.chrome_full}"', - "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"', - }) + self.session.headers.update( + { + "User-Agent": self.ua, + "Accept-Language": self.accept_language, + "sec-ch-ua": self.sec_ch_ua, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version": f'"{self.chrome_full}"', + "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"', + } + ) seed_oai_device_cookie(self.session, self.device_id) def _state_from_url(self, url, method="GET"): @@ -214,7 +242,11 @@ class ChatGPTClient: return ( page_type in {"callback", "chatgpt_home", "oauth_callback"} or ("chatgpt.com" in current_url and "redirect_uri" not in current_url) - or ("chatgpt.com" in continue_url and "redirect_uri" not in continue_url and page_type != "external_url") + or ( + "chatgpt.com" in continue_url + and "redirect_uri" not in continue_url + and page_type != "external_url" + ) ) def _state_is_password_registration(self, state: FlowState): @@ -222,7 +254,11 @@ class ChatGPTClient: def _state_is_email_otp(self, state: FlowState): target = f"{state.continue_url} {state.current_url}".lower() - return state.page_type == "email_otp_verification" or "email-verification" in target or "email-otp" in target + return ( + state.page_type == "email_otp_verification" + or "email-verification" in target + or "email-otp" in target + ) def _state_is_about_you(self, state: FlowState): target = f"{state.continue_url} {state.current_url}".lower() @@ -262,7 +298,9 @@ class ChatGPTClient: content_type = (r.headers.get("content-type", "") or "").lower() if "application/json" in content_type: try: - next_state = self._state_from_payload(r.json(), current_url=final_url) + next_state = self._state_from_payload( + r.json(), current_url=final_url + ) except Exception: next_state = self._state_from_url(final_url) else: @@ -347,7 +385,9 @@ class ChatGPTClient: session_data = session_or_error access_token = str(session_data.get("accessToken") or "").strip() - session_token = str(session_data.get("sessionToken") or session_cookie or "").strip() + session_token = str( + session_data.get("sessionToken") or session_cookie or "" + ).strip() user = session_data.get("user") or {} account = session_data.get("account") or {} jwt_payload = decode_jwt_payload(access_token) @@ -382,7 +422,7 @@ class ChatGPTClient: if user_id: self._log(f"Session User ID: {user_id}") return True, normalized - + def visit_homepage(self): """访问首页,建立 session""" self._log("访问 ChatGPT 首页...") @@ -403,7 +443,7 @@ class ChatGPTClient: except Exception as e: self._log(f"访问首页失败: {e}") return False - + def get_csrf_token(self): """获取 CSRF token""" self._log("获取 CSRF token...") @@ -419,7 +459,7 @@ class ChatGPTClient: ), timeout=30, ) - + if r.status_code == 200: data = r.json() token = data.get("csrfToken", "") @@ -428,19 +468,19 @@ class ChatGPTClient: return token except Exception as e: self._log(f"获取 CSRF token 失败: {e}") - + return None - + def signin(self, email, csrf_token): """ 提交邮箱,获取 authorize URL - + Returns: str: authorize URL """ self._log(f"提交邮箱: {email}") url = f"{self.BASE}/api/auth/signin/openai" - + params = { "prompt": "login", "ext-oai-did": self.device_id, @@ -448,7 +488,7 @@ class ChatGPTClient: "screen_hint": "login_or_signup", "login_hint": email, } - + form_data = { "callbackUrl": f"{self.BASE}/", "csrfToken": csrf_token, @@ -469,9 +509,9 @@ class ChatGPTClient: content_type="application/x-www-form-urlencoded", fetch_site="same-origin", ), - timeout=30 + timeout=30, ) - + if r.status_code == 200: data = r.json() authorize_url = data.get("url", "") @@ -480,21 +520,23 @@ class ChatGPTClient: return authorize_url except Exception as e: self._log(f"提交邮箱失败: {e}") - + return None - + def authorize(self, url, max_retries=3): """ 访问 authorize URL,跟随重定向(带重试机制) 这是关键步骤,建立 auth.openai.com 的 session - + Returns: str: 最终重定向的 URL """ for attempt in range(max_retries): try: if attempt > 0: - self._log(f"访问 authorize URL... (尝试 {attempt + 1}/{max_retries})") + self._log( + f"访问 authorize URL... (尝试 {attempt + 1}/{max_retries})" + ) time.sleep(1) # 重试前等待 else: self._log("访问 authorize URL...") @@ -511,24 +553,30 @@ class ChatGPTClient: allow_redirects=True, timeout=30, ) - + final_url = str(r.url) self._log(f"重定向到: {final_url}") return final_url - + except Exception as e: error_msg = str(e) - is_tls_error = "TLS" in error_msg or "SSL" in error_msg or "curl: (35)" in error_msg - + is_tls_error = ( + "TLS" in error_msg + or "SSL" in error_msg + or "curl: (35)" in error_msg + ) + if is_tls_error and attempt < max_retries - 1: - self._log(f"Authorize TLS 错误 (尝试 {attempt + 1}/{max_retries}): {error_msg[:100]}") + self._log( + f"Authorize TLS 错误 (尝试 {attempt + 1}/{max_retries}): {error_msg[:100]}" + ) continue else: self._log(f"Authorize 失败: {e}") return "" - + return "" - + def callback(self, callback_url=None, referer=None): """完成注册回调""" self._log("执行回调...") @@ -538,17 +586,17 @@ class ChatGPTClient: referer=referer or f"{self.AUTH}/about-you", ) return ok - + def register_user(self, email, password): """ 注册用户(邮箱 + 密码) - + Returns: tuple: (success, message) """ self._log(f"注册用户: {email}") url = f"{self.AUTH}/api/accounts/user/register" - + headers = self._headers( url, accept="application/json", @@ -558,16 +606,16 @@ class ChatGPTClient: fetch_site="same-origin", ) headers.update(generate_datadog_trace()) - + payload = { "username": email, "password": password, } - + try: self._browser_pause() r = self.session.post(url, json=payload, headers=headers, timeout=30) - + if r.status_code == 200: data = r.json() self._log("注册成功") @@ -580,11 +628,11 @@ class ChatGPTClient: error_msg = r.text[:200] self._log(f"注册失败: {r.status_code} - {error_msg}") return False, f"HTTP {r.status_code}: {error_msg}" - + except Exception as e: self._log(f"注册异常: {e}") return False, str(e) - + def send_email_otp(self): """触发发送邮箱验证码""" self._log("触发发送验证码...") @@ -607,20 +655,20 @@ class ChatGPTClient: except Exception as e: self._log(f"发送验证码失败: {e}") return False - + def verify_email_otp(self, otp_code, return_state=False): """ 验证邮箱 OTP 码 - + Args: otp_code: 6位验证码 - + Returns: tuple: (success, message) """ self._log(f"验证 OTP 码: {otp_code}") url = f"{self.AUTH}/api/accounts/email-otp/validate" - + headers = self._headers( url, accept="application/json", @@ -630,39 +678,41 @@ class ChatGPTClient: fetch_site="same-origin", ) headers.update(generate_datadog_trace()) - + payload = {"code": otp_code} - + try: self._browser_pause() r = self.session.post(url, json=payload, headers=headers, timeout=30) - + if r.status_code == 200: try: data = r.json() except Exception: data = {} - next_state = self._state_from_payload(data, current_url=str(r.url) or f"{self.AUTH}/about-you") + next_state = self._state_from_payload( + data, current_url=str(r.url) or f"{self.AUTH}/about-you" + ) self._log(f"验证成功 {describe_flow_state(next_state)}") return (True, next_state) if return_state else (True, "验证成功") else: error_msg = r.text[:200] self._log(f"验证失败: {r.status_code} - {error_msg}") return False, f"HTTP {r.status_code}" - + except Exception as e: self._log(f"验证异常: {e}") return False, str(e) - + def create_account(self, first_name, last_name, birthdate, return_state=False): """ 完成账号创建(提交姓名和生日) - + Args: first_name: 名 last_name: 姓 birthdate: 生日 (YYYY-MM-DD) - + Returns: tuple: (success, message) """ @@ -682,7 +732,7 @@ class ChatGPTClient: self._log("create_account: 已生成 sentinel token") else: self._log("create_account: 未生成 sentinel token,降级继续请求") - + headers = self._headers( url, accept="application/json", @@ -697,37 +747,41 @@ class ChatGPTClient: if sentinel_token: headers["openai-sentinel-token"] = sentinel_token headers.update(generate_datadog_trace()) - + payload = { "name": name, "birthdate": birthdate, } - + try: self._browser_pause() r = self.session.post(url, json=payload, headers=headers, timeout=30) - + if r.status_code == 200: try: data = r.json() except Exception: data = {} - next_state = self._state_from_payload(data, current_url=str(r.url) or self.BASE) + next_state = self._state_from_payload( + data, current_url=str(r.url) or self.BASE + ) self._log(f"账号创建成功 {describe_flow_state(next_state)}") return (True, next_state) if return_state else (True, "账号创建成功") else: error_msg = r.text[:200] self._log(f"创建失败: {r.status_code} - {error_msg}") return False, f"HTTP {r.status_code}" - + except Exception as e: self._log(f"创建异常: {e}") return False, str(e) - - def register_complete_flow(self, email, password, first_name, last_name, birthdate, skymail_client): + + def register_complete_flow( + self, email, password, first_name, last_name, birthdate, skymail_client + ): """ 完整的注册流程(基于原版 run_register 方法) - + Args: email: 邮箱 password: 密码 @@ -735,12 +789,12 @@ class ChatGPTClient: last_name: 姓 birthdate: 生日 skymail_client: Skymail 客户端(用于获取验证码) - + Returns: tuple: (success, message) """ from urllib.parse import urlparse - + max_auth_attempts = 3 final_url = "" final_path = "" @@ -782,13 +836,15 @@ class ChatGPTClient: # /api/accounts/authorize 实际上常对应 Cloudflare 403 中间页,不要继续走 authorize_continue。 if "api/accounts/authorize" in final_path or final_path == "/error": - self._log(f"检测到 Cloudflare/SPA 中间页,准备重试预授权: {final_url[:160]}...") + self._log( + f"检测到 Cloudflare/SPA 中间页,准备重试预授权: {final_url[:160]}..." + ) if auth_attempt < max_auth_attempts - 1: continue return False, f"预授权被拦截: {final_path}" break - + state = self._state_from_url(final_url) self._log(f"注册状态起点: {describe_flow_state(state)}") @@ -862,8 +918,14 @@ class ChatGPTClient: self.last_registration_state = state continue - if (not register_submitted) and (not otp_verified) and (not account_created): - self._log(f"未知起始状态,回退为全新注册流程: {describe_flow_state(state)}") + if ( + (not register_submitted) + and (not otp_verified) + and (not account_created) + ): + self._log( + f"未知起始状态,回退为全新注册流程: {describe_flow_state(state)}" + ) state = self._state_from_url(f"{self.AUTH}/create-account/password") continue diff --git a/platforms/chatgpt/oauth.py b/platforms/chatgpt/oauth.py index 2465b43..8876818 100644 --- a/platforms/chatgpt/oauth.py +++ b/platforms/chatgpt/oauth.py @@ -13,6 +13,7 @@ from dataclasses import dataclass from typing import Any, Dict, Optional from curl_cffi import requests as cffi_requests +from core.proxy_utils import build_requests_proxy_config from .constants import ( OAUTH_CLIENT_ID, @@ -123,10 +124,7 @@ def _to_int(v: Any) -> int: def _post_form( - url: str, - data: Dict[str, str], - timeout: int = 30, - proxy_url: Optional[str] = None + url: str, data: Dict[str, str], timeout: int = 30, proxy_url: Optional[str] = None ) -> Dict[str, Any]: """ 发送 POST 表单请求 @@ -141,18 +139,13 @@ def _post_form( 响应 JSON 数据 """ # 构建代理配置 - proxies = None - if proxy_url: - proxies = { - "http": proxy_url, - "https": proxy_url, - } + proxies = build_requests_proxy_config(proxy_url) headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", } try: @@ -163,7 +156,7 @@ def _post_form( headers=headers, timeout=timeout, proxies=proxies, - impersonate="chrome" + impersonate="chrome", ) if response.status_code != 200: @@ -180,6 +173,7 @@ def _post_form( @dataclass(frozen=True) class OAuthStart: """OAuth 开始信息""" + auth_url: str state: str code_verifier: str @@ -190,7 +184,7 @@ def generate_oauth_url( *, redirect_uri: str = OAUTH_REDIRECT_URI, scope: str = OAUTH_SCOPE, - client_id: str = OAUTH_CLIENT_ID + client_id: str = OAUTH_CLIENT_ID, ) -> OAuthStart: """ 生成 OAuth 授权 URL @@ -236,7 +230,7 @@ def submit_callback_url( redirect_uri: str = OAUTH_REDIRECT_URI, client_id: str = OAUTH_CLIENT_ID, token_url: str = OAUTH_TOKEN_URL, - proxy_url: Optional[str] = None + proxy_url: Optional[str] = None, ) -> str: """ 处理 OAuth 回调 URL,获取访问令牌 @@ -278,7 +272,7 @@ def submit_callback_url( "redirect_uri": redirect_uri, "code_verifier": code_verifier, }, - proxy_url=proxy_url + proxy_url=proxy_url, ) access_token = (token_resp.get("access_token") or "").strip() @@ -321,7 +315,7 @@ class OAuthManager: token_url: str = OAUTH_TOKEN_URL, redirect_uri: str = OAUTH_REDIRECT_URI, scope: str = OAUTH_SCOPE, - proxy_url: Optional[str] = None + proxy_url: Optional[str] = None, ): self.client_id = client_id self.auth_url = auth_url @@ -333,16 +327,11 @@ class OAuthManager: def start_oauth(self) -> OAuthStart: """开始 OAuth 流程""" return generate_oauth_url( - redirect_uri=self.redirect_uri, - scope=self.scope, - client_id=self.client_id + redirect_uri=self.redirect_uri, scope=self.scope, client_id=self.client_id ) def handle_callback( - self, - callback_url: str, - expected_state: str, - code_verifier: str + self, callback_url: str, expected_state: str, code_verifier: str ) -> Dict[str, Any]: """处理 OAuth 回调""" result_json = submit_callback_url( @@ -352,7 +341,7 @@ class OAuthManager: redirect_uri=self.redirect_uri, client_id=self.client_id, token_url=self.token_url, - proxy_url=self.proxy_url + proxy_url=self.proxy_url, ) return json.loads(result_json) @@ -363,8 +352,4 @@ class OAuthManager: auth_claims = claims.get("https://api.openai.com/auth") or {} account_id = str(auth_claims.get("chatgpt_account_id") or "").strip() - return { - "email": email, - "account_id": account_id, - "claims": claims - } \ No newline at end of file + return {"email": email, "account_id": account_id, "claims": claims} diff --git a/platforms/chatgpt/oauth_client.py b/platforms/chatgpt/oauth_client.py index 0b735b8..362187d 100644 --- a/platforms/chatgpt/oauth_client.py +++ b/platforms/chatgpt/oauth_client.py @@ -5,6 +5,7 @@ OAuth 客户端模块 - 处理 Codex OAuth 登录流程 import time import secrets from urllib.parse import urlparse, parse_qs +from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url try: from curl_cffi import requests as curl_requests @@ -28,11 +29,11 @@ from .sentinel_token import build_sentinel_token class OAuthClient: """OAuth 客户端 - 用于获取 Access Token 和 Refresh Token""" - + def __init__(self, config, proxy=None, verbose=True, browser_mode="protocol"): """ 初始化 OAuth 客户端 - + Args: config: 配置字典 proxy: 代理地址 @@ -41,18 +42,22 @@ class OAuthClient: """ self.config = dict(config or {}) self.oauth_issuer = self.config.get("oauth_issuer", "https://auth.openai.com") - self.oauth_client_id = self.config.get("oauth_client_id", "app_EMoamEEZ73f0CkXaXp7hrann") - self.oauth_redirect_uri = self.config.get("oauth_redirect_uri", "http://localhost:1455/auth/callback") - self.proxy = proxy + self.oauth_client_id = self.config.get( + "oauth_client_id", "app_EMoamEEZ73f0CkXaXp7hrann" + ) + self.oauth_redirect_uri = self.config.get( + "oauth_redirect_uri", "http://localhost:1455/auth/callback" + ) + self.proxy = normalize_proxy_url(proxy) self.verbose = verbose self.browser_mode = browser_mode or "protocol" self.last_error = "" - + # 创建 session self.session = curl_requests.Session() if self.proxy: - self.session.proxies = {"http": self.proxy, "https": self.proxy} - + self.session.proxies = build_requests_proxy_config(self.proxy) + def _log(self, msg): """输出日志""" if self.verbose: @@ -151,7 +156,9 @@ class OAuthClient: ) return any(marker in combined for marker in blacklist_markers) - def _blacklist_phone_if_needed(self, phone_service, entry, detail="", state: FlowState | None = None): + def _blacklist_phone_if_needed( + self, phone_service, entry, detail="", state: FlowState | None = None + ): if not entry or not self._should_blacklist_phone_failure(detail, state): return False try: @@ -242,7 +249,11 @@ class OAuthClient: def _state_is_email_otp(self, state: FlowState): target = f"{state.continue_url} {state.current_url}".lower() - return state.page_type == "email_otp_verification" or "email-verification" in target or "email-otp" in target + return ( + state.page_type == "email_otp_verification" + or "email-verification" in target + or "email-otp" in target + ) def _state_is_add_phone(self, state: FlowState): target = f"{state.continue_url} {state.current_url}".lower() @@ -266,14 +277,33 @@ class OAuthClient: def _state_supports_workspace_resolution(self, state: FlowState): target = f"{state.continue_url} {state.current_url}".lower() - if state.page_type in {"consent", "workspace_selection", "organization_selection"}: + if state.page_type in { + "consent", + "workspace_selection", + "organization_selection", + }: return True - if any(marker in target for marker in ("sign-in-with-chatgpt", "consent", "workspace", "organization")): + if any( + marker in target + for marker in ( + "sign-in-with-chatgpt", + "consent", + "workspace", + "organization", + ) + ): return True session_data = self._decode_oauth_session_cookie() or {} return bool(session_data.get("workspaces")) - def _follow_flow_state(self, state: FlowState, referer=None, user_agent=None, impersonate=None, max_hops=16): + def _follow_flow_state( + self, + state: FlowState, + referer=None, + user_agent=None, + impersonate=None, + max_hops=16, + ): """跟随服务端返回的 continue_url / current_url,返回新的状态或 authorization code。""" import re @@ -306,7 +336,7 @@ class OAuthClient: last_url = str(r.url) self._log(f"follow[{hop + 1}] {r.status_code} {last_url[:120]}") except Exception as e: - maybe_localhost = re.search(r'(https?://localhost[^\s\'\"]+)', str(e)) + maybe_localhost = re.search(r"(https?://localhost[^\s\'\"]+)", str(e)) if maybe_localhost: location = maybe_localhost.group(1) code = self._extract_code_from_url(location) @@ -321,7 +351,9 @@ class OAuthClient: return code, self._state_from_url(last_url) if r.status_code in (301, 302, 303, 307, 308): - location = normalize_flow_url(r.headers.get("Location", ""), auth_base=self.oauth_issuer) + location = normalize_flow_url( + r.headers.get("Location", ""), auth_base=self.oauth_issuer + ) if not location: return None, self._state_from_url(last_url or current_url) code = self._extract_code_from_url(location) @@ -334,7 +366,9 @@ class OAuthClient: content_type = (r.headers.get("content-type", "") or "").lower() if "application/json" in content_type: try: - next_state = self._state_from_payload(r.json(), current_url=last_url or current_url) + next_state = self._state_from_payload( + r.json(), current_url=last_url or current_url + ) except Exception: next_state = self._state_from_url(last_url or current_url) else: @@ -344,7 +378,15 @@ class OAuthClient: return None, self._state_from_url(last_url or current_url) - def _bootstrap_oauth_session(self, authorize_url, authorize_params, device_id=None, user_agent=None, sec_ch_ua=None, impersonate=None): + def _bootstrap_oauth_session( + self, + authorize_url, + authorize_params, + device_id=None, + user_agent=None, + sec_ch_ua=None, + impersonate=None, + ): """启动 OAuth 会话,确保 auth 域上的 login_session 已建立。""" if device_id: seed_oai_device_cookie(self.session, device_id) @@ -361,7 +403,12 @@ class OAuthClient: referer="https://chatgpt.com/", navigation=True, ) - kwargs = {"params": authorize_params, "headers": headers, "allow_redirects": True, "timeout": 30} + kwargs = { + "params": authorize_params, + "headers": headers, + "allow_redirects": True, + "timeout": 30, + } if impersonate: kwargs["impersonate"] = impersonate @@ -372,7 +419,8 @@ class OAuthClient: self._log(f"/oauth/authorize -> {r.status_code}, redirects={redirects}") has_login_session = any( - (cookie.name if hasattr(cookie, "name") else str(cookie)) == "login_session" + (cookie.name if hasattr(cookie, "name") else str(cookie)) + == "login_session" for cookie in self.session.cookies ) self._log(f"login_session: {'已获取' if has_login_session else '未获取'}") @@ -405,13 +453,18 @@ class OAuthClient: r2 = self.session.get(oauth2_url, **kwargs) authorize_final_url = str(r2.url) redirects2 = len(getattr(r2, "history", []) or []) - self._log(f"/api/oauth/oauth2/auth -> {r2.status_code}, redirects={redirects2}") + self._log( + f"/api/oauth/oauth2/auth -> {r2.status_code}, redirects={redirects2}" + ) has_login_session = any( - (cookie.name if hasattr(cookie, "name") else str(cookie)) == "login_session" + (cookie.name if hasattr(cookie, "name") else str(cookie)) + == "login_session" for cookie in self.session.cookies ) - self._log(f"login_session(重试): {'已获取' if has_login_session else '未获取'}") + self._log( + f"login_session(重试): {'已获取' if has_login_session else '未获取'}" + ) except Exception as e: self._log(f"/api/oauth/oauth2/auth 异常: {e}") @@ -463,7 +516,12 @@ class OAuthClient: payload = {"username": {"kind": "email", "value": email}} try: - kwargs = {"json": payload, "headers": headers, "timeout": 30, "allow_redirects": False} + kwargs = { + "json": payload, + "headers": headers, + "timeout": 30, + "allow_redirects": False, + } if impersonate: kwargs["impersonate"] = impersonate @@ -471,7 +529,12 @@ class OAuthClient: r = self.session.post(request_url, **kwargs) self._log(f"/authorize/continue -> {r.status_code}") - if r.status_code == 400 and "invalid_auth_step" in (r.text or "") and authorize_url and authorize_params: + if ( + r.status_code == 400 + and "invalid_auth_step" in (r.text or "") + and authorize_url + and authorize_params + ): self._log("invalid_auth_step,重新 bootstrap...") authorize_final_url = self._bootstrap_oauth_session( authorize_url, @@ -489,7 +552,12 @@ class OAuthClient: headers["Referer"] = continue_referer headers["Sec-Fetch-Site"] = "same-origin" headers.update(generate_datadog_trace()) - kwargs = {"json": payload, "headers": headers, "timeout": 30, "allow_redirects": False} + kwargs = { + "json": payload, + "headers": headers, + "timeout": 30, + "allow_redirects": False, + } if impersonate: kwargs["impersonate"] = impersonate self._browser_pause() @@ -501,14 +569,25 @@ class OAuthClient: return None data = r.json() - flow_state = self._state_from_payload(data, current_url=str(r.url) or request_url) + flow_state = self._state_from_payload( + data, current_url=str(r.url) or request_url + ) self._log(describe_flow_state(flow_state)) return flow_state except Exception as e: self._set_error(f"提交邮箱异常: {e}") return None - def _submit_password_verify(self, password, device_id, *, user_agent=None, sec_ch_ua=None, impersonate=None, referer=None): + def _submit_password_verify( + self, + password, + device_id, + *, + user_agent=None, + sec_ch_ua=None, + impersonate=None, + referer=None, + ): """提交密码,获取下一步状态。""" self._log("步骤3: POST /api/accounts/password/verify") @@ -542,7 +621,12 @@ class OAuthClient: headers.update(generate_datadog_trace()) try: - kwargs = {"json": {"password": password}, "headers": headers, "timeout": 30, "allow_redirects": False} + kwargs = { + "json": {"password": password}, + "headers": headers, + "timeout": 30, + "allow_redirects": False, + } if impersonate: kwargs["impersonate"] = impersonate @@ -555,17 +639,28 @@ class OAuthClient: return None data = r.json() - flow_state = self._state_from_payload(data, current_url=str(r.url) or request_url) + flow_state = self._state_from_payload( + data, current_url=str(r.url) or request_url + ) self._log(f"verify {describe_flow_state(flow_state)}") return flow_state except Exception as e: self._set_error(f"密码验证异常: {e}") return None - - def login_and_get_tokens(self, email, password, device_id, user_agent=None, sec_ch_ua=None, impersonate=None, skymail_client=None): + + def login_and_get_tokens( + self, + email, + password, + device_id, + user_agent=None, + sec_ch_ua=None, + impersonate=None, + skymail_client=None, + ): """ 完整的 OAuth 登录流程,获取 tokens - + Args: email: 邮箱 password: 密码 @@ -574,7 +669,7 @@ class OAuthClient: sec_ch_ua: sec-ch-ua header impersonate: curl_cffi impersonate 参数 skymail_client: Skymail 客户端(用于获取 OTP,如果需要) - + Returns: dict: tokens 字典,包含 access_token, refresh_token, id_token """ @@ -645,7 +740,9 @@ class OAuthClient: 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) + tokens = self._exchange_code_for_tokens( + code, code_verifier, user_agent, impersonate + ) if tokens: self._log("✅ OAuth 登录成功") else: @@ -716,7 +813,9 @@ class OAuthClient: 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) + tokens = self._exchange_code_for_tokens( + code, code_verifier, user_agent, impersonate + ) if tokens: self._log("✅ OAuth 登录成功") else: @@ -730,7 +829,9 @@ class OAuthClient: if self._state_supports_workspace_resolution(state): self._log("步骤6: 执行 workspace/org 选择") code, next_state = self._oauth_submit_workspace_and_org( - state.continue_url or state.current_url or f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent", + state.continue_url + or state.current_url + or f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent", device_id, user_agent, impersonate, @@ -738,7 +839,9 @@ class OAuthClient: 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) + tokens = self._exchange_code_for_tokens( + code, code_verifier, user_agent, impersonate + ) if tokens: self._log("✅ OAuth 登录成功") else: @@ -751,7 +854,9 @@ class OAuthClient: continue if not self.last_error: - self._set_error(f"workspace/org 选择失败: {describe_flow_state(state)}") + self._set_error( + f"workspace/org 选择失败: {describe_flow_state(state)}" + ) return None self._set_error(f"未支持的 OAuth 状态: {describe_flow_state(state)}") @@ -759,7 +864,7 @@ class OAuthClient: self._set_error("OAuth 状态机超出最大步数") return None - + def _extract_code_from_url(self, url): """从 URL 中提取 code""" if not url or "code=" not in url: @@ -768,8 +873,10 @@ class OAuthClient: return parse_qs(urlparse(url).query).get("code", [None])[0] except Exception: return None - - def _oauth_follow_for_code(self, start_url, referer, user_agent, impersonate, max_hops=16): + + def _oauth_follow_for_code( + self, start_url, referer, user_agent, impersonate, max_hops=16 + ): """跟随 URL 获取 authorization code(手动跟随重定向)""" code, next_state = self._follow_flow_state( self._state_from_url(start_url), @@ -780,7 +887,9 @@ class OAuthClient: ) return code, (next_state.current_url or next_state.continue_url or start_url) - def _oauth_submit_workspace_and_org(self, consent_url, device_id, user_agent, impersonate, max_retries=3): + def _oauth_submit_workspace_and_org( + self, consent_url, device_id, user_agent, impersonate, max_retries=3 + ): """提交 workspace 和 organization 选择(带重试)""" session_data = None @@ -794,7 +903,9 @@ class OAuthClient: break if attempt < max_retries - 1: - self._log(f"无法获取 consent session 数据 (尝试 {attempt + 1}/{max_retries})") + self._log( + f"无法获取 consent session 数据 (尝试 {attempt + 1}/{max_retries})" + ) time.sleep(0.3) else: self._set_error("无法获取 consent session 数据") @@ -804,14 +915,14 @@ class OAuthClient: if not workspaces: self._set_error("session 中没有 workspace 信息") return None, None - + workspace_id = (workspaces[0] or {}).get("id") if not workspace_id: self._set_error("workspace_id 为空") return None, None - + self._log(f"选择 workspace: {workspace_id}") - + headers = self._headers( f"{self.oauth_issuer}/api/accounts/workspace/select", user_agent=user_agent, @@ -825,28 +936,29 @@ class OAuthClient: }, ) headers.update(generate_datadog_trace()) - + try: kwargs = { "json": {"workspace_id": workspace_id}, "headers": headers, "allow_redirects": False, - "timeout": 30 + "timeout": 30, } if impersonate: kwargs["impersonate"] = impersonate self._browser_pause() r = self.session.post( - f"{self.oauth_issuer}/api/accounts/workspace/select", - **kwargs + f"{self.oauth_issuer}/api/accounts/workspace/select", **kwargs ) - + self._log(f"workspace/select -> {r.status_code}") - + # 检查重定向 if r.status_code in (301, 302, 303, 307, 308): - location = normalize_flow_url(r.headers.get("Location", ""), auth_base=self.oauth_issuer) + location = normalize_flow_url( + r.headers.get("Location", ""), auth_base=self.oauth_issuer + ) if "code=" in location: code = self._extract_code_from_url(location) if code: @@ -854,28 +966,34 @@ class OAuthClient: return code, self._state_from_url(location) if location: return None, self._state_from_url(location) - + # 如果返回 200,检查响应中的 orgs if r.status_code == 200: try: data = r.json() orgs = data.get("data", {}).get("orgs", []) - workspace_state = self._state_from_payload(data, current_url=str(r.url)) + workspace_state = self._state_from_payload( + data, current_url=str(r.url) + ) continue_url = workspace_state.continue_url - + if orgs: org_id = (orgs[0] or {}).get("id") projects = (orgs[0] or {}).get("projects", []) project_id = (projects[0] or {}).get("id") if projects else None - + if org_id: self._log(f"选择 organization: {org_id}") - + org_body = {"org_id": org_id} if project_id: org_body["project_id"] = project_id - - org_referer = continue_url if continue_url and continue_url.startswith("http") else consent_url + + org_referer = ( + continue_url + if continue_url and continue_url.startswith("http") + else consent_url + ) headers = self._headers( f"{self.oauth_issuer}/api/accounts/organization/select", user_agent=user_agent, @@ -889,12 +1007,12 @@ class OAuthClient: }, ) headers.update(generate_datadog_trace()) - + kwargs = { "json": org_body, "headers": headers, "allow_redirects": False, - "timeout": 30 + "timeout": 30, } if impersonate: kwargs["impersonate"] = impersonate @@ -902,48 +1020,63 @@ class OAuthClient: self._browser_pause() r_org = self.session.post( f"{self.oauth_issuer}/api/accounts/organization/select", - **kwargs + **kwargs, ) - + self._log(f"organization/select -> {r_org.status_code}") - + # 检查重定向 if r_org.status_code in (301, 302, 303, 307, 308): - location = normalize_flow_url(r_org.headers.get("Location", ""), auth_base=self.oauth_issuer) + location = normalize_flow_url( + r_org.headers.get("Location", ""), + auth_base=self.oauth_issuer, + ) if "code=" in location: code = self._extract_code_from_url(location) if code: - self._log("从 organization/select 重定向获取到 code") + self._log( + "从 organization/select 重定向获取到 code" + ) return code, self._state_from_url(location) if location: return None, self._state_from_url(location) - + # 检查 continue_url if r_org.status_code == 200: try: - org_state = self._state_from_payload(r_org.json(), current_url=str(r_org.url)) - self._log(f"organization/select -> {describe_flow_state(org_state)}") + org_state = self._state_from_payload( + r_org.json(), current_url=str(r_org.url) + ) + self._log( + f"organization/select -> {describe_flow_state(org_state)}" + ) if self._extract_code_from_state(org_state): - return self._extract_code_from_state(org_state), org_state + return self._extract_code_from_state( + org_state + ), org_state return None, org_state except Exception as e: - self._set_error(f"解析 organization/select 响应异常: {e}") - + self._set_error( + f"解析 organization/select 响应异常: {e}" + ) + # 如果有 continue_url,跟随它 if continue_url: - code, _ = self._oauth_follow_for_code(continue_url, consent_url, user_agent, impersonate) + code, _ = self._oauth_follow_for_code( + continue_url, consent_url, user_agent, impersonate + ) if code: return code, self._state_from_url(continue_url) return None, workspace_state - + except Exception as e: self._set_error(f"处理 workspace/select 响应异常: {e}") return None, None - + except Exception as e: self._set_error(f"workspace/select 异常: {e}") return None, None - + return None, None def _load_workspace_session_data(self, consent_url, user_agent, impersonate): @@ -958,7 +1091,9 @@ class OAuthClient: parsed = self._extract_session_data_from_consent_html(html) if parsed and parsed.get("workspaces"): - self._log(f"从 consent HTML 提取到 {len(parsed.get('workspaces', []))} 个 workspace") + self._log( + f"从 consent HTML 提取到 {len(parsed.get('workspaces', []))} 个 workspace" + ) return parsed return session_data @@ -978,7 +1113,9 @@ class OAuthClient: kwargs["impersonate"] = impersonate self._browser_pause(0.12, 0.3) r = self.session.get(consent_url, **kwargs) - if r.status_code == 200 and "text/html" in (r.headers.get("content-type", "").lower()): + if r.status_code == 200 and "text/html" in ( + r.headers.get("content-type", "").lower() + ): return r.text except Exception: pass @@ -1022,13 +1159,13 @@ class OAuthClient: start = normalized.find('"workspaces"') if start < 0: - start = normalized.find('workspaces') + start = normalized.find("workspaces") if start < 0: return None end = normalized.find('"openai_client_id"', start) if end < 0: - end = normalized.find('openai_client_id', start) + end = normalized.find("openai_client_id", start) if end < 0: end = min(len(normalized), start + 4000) else: @@ -1083,15 +1220,19 @@ class OAuthClient: return parsed return None - + def _decode_oauth_session_cookie(self): """解码 oai-client-auth-session cookie""" try: for cookie in self.session.cookies: try: - name = cookie.name if hasattr(cookie, 'name') else str(cookie) + name = cookie.name if hasattr(cookie, "name") else str(cookie) if name == "oai-client-auth-session": - value = cookie.value if hasattr(cookie, 'value') else self.session.cookies.get(name) + value = ( + cookie.value + if hasattr(cookie, "value") + else self.session.cookies.get(name) + ) if value: data = self._decode_cookie_json_value(value) if data: @@ -1100,7 +1241,7 @@ class OAuthClient: continue except Exception: pass - + return None @staticmethod @@ -1131,11 +1272,11 @@ class OAuthClient: return parsed return None - + def _exchange_code_for_tokens(self, code, code_verifier, user_agent, impersonate): """用 authorization code 换取 tokens""" url = f"{self.oauth_issuer}/oauth/token" - + payload = { "grant_type": "authorization_code", "code": code, @@ -1143,7 +1284,7 @@ class OAuthClient: "client_id": self.oauth_client_id, "code_verifier": code_verifier, } - + headers = self._headers( url, user_agent=user_agent, @@ -1153,7 +1294,7 @@ class OAuthClient: content_type="application/x-www-form-urlencoded", fetch_site="same-origin", ) - + try: kwargs = {"data": payload, "headers": headers, "timeout": 60} if impersonate: @@ -1161,15 +1302,15 @@ class OAuthClient: self._browser_pause() r = self.session.post(url, **kwargs) - + if r.status_code == 200: return r.json() else: self._set_error(f"换取 tokens 失败: {r.status_code} - {r.text[:200]}") - + except Exception as e: self._set_error(f"换取 tokens 异常: {e}") - + return None def _send_phone_number(self, phone, device_id, user_agent, sec_ch_ua, impersonate): @@ -1204,25 +1345,35 @@ class OAuthClient: self._log(f"/add-phone/send -> {resp.status_code}") if resp.status_code != 200: - return False, None, f"add-phone/send 失败: {resp.status_code} - {resp.text[:180]}" + return ( + False, + None, + f"add-phone/send 失败: {resp.status_code} - {resp.text[:180]}", + ) try: data = resp.json() except Exception: return False, None, "add-phone/send 响应不是 JSON" - next_state = self._state_from_payload(data, current_url=str(resp.url) or request_url) + next_state = self._state_from_payload( + data, current_url=str(resp.url) or request_url + ) self._log(f"add-phone/send {describe_flow_state(next_state)}") return True, next_state, "" - def _resend_phone_otp(self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState): + def _resend_phone_otp( + self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState + ): request_url = f"{self.oauth_issuer}/api/accounts/phone-otp/resend" headers = 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}/phone-verification", + referer=state.current_url + or state.continue_url + or f"{self.oauth_issuer}/phone-verification", origin=self.oauth_issuer, fetch_site="same-origin", extra_headers={"oai-device-id": device_id}, @@ -1243,14 +1394,18 @@ class OAuthClient: return True, "" return False, f"phone-otp/resend 失败: {resp.status_code} - {resp.text[:180]}" - def _validate_phone_otp(self, code, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState): + def _validate_phone_otp( + self, code, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState + ): request_url = f"{self.oauth_issuer}/api/accounts/phone-otp/validate" headers = 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}/phone-verification", + referer=state.current_url + or state.continue_url + or f"{self.oauth_issuer}/phone-verification", origin=self.oauth_issuer, content_type="application/json", fetch_site="same-origin", @@ -1276,21 +1431,31 @@ class OAuthClient: if resp.status_code != 200: if resp.status_code == 401: return False, None, "手机号验证码错误" - return False, None, f"phone-otp/validate 失败: {resp.status_code} - {resp.text[:180]}" + return ( + False, + None, + f"phone-otp/validate 失败: {resp.status_code} - {resp.text[:180]}", + ) try: data = resp.json() except Exception: return False, None, "phone-otp/validate 响应不是 JSON" - next_state = self._state_from_payload(data, current_url=str(resp.url) or request_url) + next_state = self._state_from_payload( + data, current_url=str(resp.url) or request_url + ) self._log(f"手机号 OTP 验证通过 {describe_flow_state(next_state)}") return True, next_state, "" - def _handle_add_phone_verification(self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState): + def _handle_add_phone_verification( + self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState + ): phone_service = SMSToMePhoneService(self.config, log_fn=self._log) if not phone_service.enabled: - self._set_error("OAuth 登录被 add_phone 阻断,当前账号需要手机号验证;未配置可用的 SMSToMe 号码池") + self._set_error( + "OAuth 登录被 add_phone 阻断,当前账号需要手机号验证;未配置可用的 SMSToMe 号码池" + ) return None excluded_prefixes = set() @@ -1327,17 +1492,33 @@ class OAuthClient: excluded_prefixes.add(prefix) continue - if next_state.page_type != "phone_otp_verification" and "phone-verification" not in f"{next_state.continue_url} {next_state.current_url}".lower(): + if ( + next_state.page_type != "phone_otp_verification" + and "phone-verification" + not in f"{next_state.continue_url} {next_state.current_url}".lower() + ): last_failure = f"add-phone/send 未进入手机验证码页: {describe_flow_state(next_state)}" self._log(last_failure) - self._blacklist_phone_if_needed(phone_service, entry, last_failure, next_state) + self._blacklist_phone_if_needed( + phone_service, entry, last_failure, next_state + ) excluded_prefixes.add(prefix) continue session_data = self._decode_oauth_session_cookie() or {} - verification_channel = str(session_data.get("phone_verification_channel") or "sms").strip().lower() or "sms" - bound_phone = str(session_data.get("phone_number") or entry.phone).strip() or entry.phone - self._log(f"add_phone 发码成功: phone={bound_phone}, channel={verification_channel}") + verification_channel = ( + str(session_data.get("phone_verification_channel") or "sms") + .strip() + .lower() + or "sms" + ) + bound_phone = ( + str(session_data.get("phone_number") or entry.phone).strip() + or entry.phone + ) + self._log( + f"add_phone 发码成功: phone={bound_phone}, channel={verification_channel}" + ) if verification_channel != "sms": last_failure = f"add_phone 已切到 {verification_channel} 通道,当前 SMSToMe 仅支持短信接码" @@ -1358,7 +1539,9 @@ class OAuthClient: if resend_ok: code = phone_service.wait_for_code(entry) if not code: - last_failure = resend_detail or f"手机号 {entry.phone} 未收到短信验证码" + last_failure = ( + resend_detail or f"手机号 {entry.phone} 未收到短信验证码" + ) self._log(last_failure) excluded_prefixes.add(prefix) continue @@ -1381,8 +1564,17 @@ class OAuthClient: self._set_error(f"add_phone 阶段失败: {last_failure or '未完成手机号验证'}") return None - - def _handle_otp_verification(self, email, device_id, user_agent, sec_ch_ua, impersonate, skymail_client, state): + + def _handle_otp_verification( + self, + email, + device_id, + user_agent, + sec_ch_ua, + impersonate, + skymail_client, + state, + ): """处理 OAuth 阶段的邮箱 OTP 验证,返回服务端声明的下一步状态。""" self._log("步骤4: 检测到邮箱 OTP 验证") @@ -1392,7 +1584,9 @@ class OAuthClient: 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", + 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", @@ -1442,7 +1636,8 @@ class OAuthClient: next_state = self._state_from_payload( otp_data, - current_url=str(resp_otp.url) or (state.current_url or state.continue_url or request_url), + current_url=str(resp_otp.url) + 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) @@ -1506,5 +1701,7 @@ class OAuthClient: break if not self.last_error: - self._set_error(f"OAuth 阶段 OTP 验证失败,已尝试 {len(tried_codes)} 个验证码") + self._set_error( + f"OAuth 阶段 OTP 验证失败,已尝试 {len(tried_codes)} 个验证码" + ) return None diff --git a/platforms/chatgpt/oauth_pkce_client.py b/platforms/chatgpt/oauth_pkce_client.py index 2aafdd3..351a71c 100644 --- a/platforms/chatgpt/oauth_pkce_client.py +++ b/platforms/chatgpt/oauth_pkce_client.py @@ -12,6 +12,7 @@ import urllib.parse from typing import Optional from curl_cffi import requests as curl_requests +from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url from .oauth import ( OAuthStart, @@ -22,7 +23,9 @@ from .oauth import ( AUTH_BASE = "https://auth.openai.com" SENTINEL_API = "https://sentinel.openai.com/backend-api/sentinel/req" -SENTINEL_REFERER = "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6" +SENTINEL_REFERER = ( + "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6" +) CLOUDFLARE_TRACE = "https://cloudflare.com/cdn-cgi/trace" @@ -46,9 +49,9 @@ class OAuthPkceClient: """ def __init__(self, proxy: Optional[str] = None, log_fn=None): - self.proxy = proxy + self.proxy = normalize_proxy_url(proxy) self._log = log_fn or (lambda msg: None) - self._proxies = {"http": proxy, "https": proxy} if proxy else None + self._proxies = build_requests_proxy_config(self.proxy) # 主会话:贯穿整个注册 + 登录流程 self.session = curl_requests.Session( @@ -63,7 +66,9 @@ class OAuthPkceClient: # 内部方法:获取 Sentinel Token(极简模式) # ══════════════════════════════════════════════════════════════════ - def _fetch_sentinel_token(self, device_id: str, flow: str = "authorize_continue") -> str: + def _fetch_sentinel_token( + self, device_id: str, flow: str = "authorize_continue" + ) -> str: """ 获取 Sentinel Token。 @@ -95,10 +100,16 @@ class OAuthPkceClient: if not c_value: raise RuntimeError("Sentinel 响应缺少 token 字段") - return json.dumps({ - "p": "", "t": "", "c": c_value, - "id": device_id, "flow": flow, - }, separators=(",", ":")) + return json.dumps( + { + "p": "", + "t": "", + "c": c_value, + "id": device_id, + "flow": flow, + }, + separators=(",", ":"), + ) # ══════════════════════════════════════════════════════════════════ # 步骤 1:检查 IP 地区 @@ -129,7 +140,11 @@ class OAuthPkceClient: self._log("访问 OAuth 授权 URL...") self.session.get(oauth.auth_url, timeout=15) self._device_id = self.session.cookies.get("oai-did") or "" - self._log(f"oai-did: {self._device_id[:16]}..." if self._device_id else "oai-did: (未获取到)") + self._log( + f"oai-did: {self._device_id[:16]}..." + if self._device_id + else "oai-did: (未获取到)" + ) return oauth # ══════════════════════════════════════════════════════════════════ @@ -153,10 +168,12 @@ class OAuthPkceClient: if not self._sentinel: raise RuntimeError("Sentinel Token 未初始化") - payload = json.dumps({ - "username": {"value": email, "kind": "email"}, - "screen_hint": "signup", - }) + payload = json.dumps( + { + "username": {"value": email, "kind": "email"}, + "screen_hint": "signup", + } + ) self._log(f"提交邮箱: {email}") resp = self.session.post( @@ -171,7 +188,9 @@ class OAuthPkceClient: timeout=30, ) if resp.status_code != 200: - raise RuntimeError(f"提交邮箱失败: HTTP {resp.status_code} {resp.text[:300]}") + raise RuntimeError( + f"提交邮箱失败: HTTP {resp.status_code} {resp.text[:300]}" + ) data = resp.json() self._log(f"邮箱提交成功") @@ -198,7 +217,9 @@ class OAuthPkceClient: timeout=30, ) if resp.status_code != 200: - raise RuntimeError(f"提交密码失败: HTTP {resp.status_code} {resp.text[:300]}") + raise RuntimeError( + f"提交密码失败: HTTP {resp.status_code} {resp.text[:300]}" + ) continue_url = resp.json().get("continue_url") or "" self._log(f"密码提交成功{', continue_url 已获取' if continue_url else ''}") @@ -249,7 +270,9 @@ class OAuthPkceClient: timeout=30, ) if resp.status_code != 200: - raise RuntimeError(f"OTP 验证失败: HTTP {resp.status_code} {resp.text[:300]}") + raise RuntimeError( + f"OTP 验证失败: HTTP {resp.status_code} {resp.text[:300]}" + ) self._log("OTP 验证通过") # ══════════════════════════════════════════════════════════════════ @@ -271,7 +294,9 @@ class OAuthPkceClient: timeout=30, ) if resp.status_code != 200: - raise RuntimeError(f"创建账户失败: HTTP {resp.status_code} {resp.text[:300]}") + raise RuntimeError( + f"创建账户失败: HTTP {resp.status_code} {resp.text[:300]}" + ) self._log("账户创建成功") # ══════════════════════════════════════════════════════════════════ @@ -297,7 +322,11 @@ class OAuthPkceClient: login_oauth = generate_oauth_url() self.session.get(login_oauth.auth_url, timeout=15) login_did = self.session.cookies.get("oai-did") or self._device_id or "" - self._log(f"登录阶段 oai-did: {login_did[:16]}..." if login_did else "登录阶段 oai-did: (空)") + self._log( + f"登录阶段 oai-did: {login_did[:16]}..." + if login_did + else "登录阶段 oai-did: (空)" + ) # 9-2. 获取登录阶段 Sentinel login_sentinel = self._fetch_sentinel_token(login_did) @@ -311,10 +340,12 @@ class OAuthPkceClient: "content-type": "application/json", "openai-sentinel-token": login_sentinel, }, - data=json.dumps({ - "username": {"value": email, "kind": "email"}, - "screen_hint": "login", - }), + data=json.dumps( + { + "username": {"value": email, "kind": "email"}, + "screen_hint": "login", + } + ), timeout=30, ) if login_email_resp.status_code != 200: @@ -373,7 +404,9 @@ class OAuthPkceClient: timeout=30, ) if otp_resp.status_code != 200: - raise RuntimeError(f"登录二次 OTP 失败: HTTP {otp_resp.status_code} {otp_resp.text[:200]}") + raise RuntimeError( + f"登录二次 OTP 失败: HTTP {otp_resp.status_code} {otp_resp.text[:200]}" + ) self._log("登录二次验证通过") self._log("OAuth 登录流程完成") @@ -423,7 +456,9 @@ class OAuthPkceClient: timeout=30, ) if resp.status_code != 200: - raise RuntimeError(f"workspace/select 失败: HTTP {resp.status_code} {resp.text[:300]}") + raise RuntimeError( + f"workspace/select 失败: HTTP {resp.status_code} {resp.text[:300]}" + ) continue_url = str((resp.json() or {}).get("continue_url") or "").strip() if not continue_url: diff --git a/platforms/chatgpt/payment.py b/platforms/chatgpt/payment.py index b3903f8..670de8d 100644 --- a/platforms/chatgpt/payment.py +++ b/platforms/chatgpt/payment.py @@ -10,6 +10,7 @@ import sys from typing import Optional from curl_cffi import requests as cffi_requests +from core.proxy_utils import build_requests_proxy_config # from ..database.models import Account # removed: external dep @@ -20,9 +21,7 @@ TEAM_CHECKOUT_BASE_URL = "https://chatgpt.com/checkout/openai_llc/" def _build_proxies(proxy: Optional[str]) -> Optional[dict]: - if proxy: - return {"http": proxy, "https": proxy} - return None + return build_requests_proxy_config(proxy) _COUNTRY_CURRENCY_MAP = { @@ -46,7 +45,7 @@ def _extract_oai_did(cookies_str: str) -> Optional[str]: for part in cookies_str.split(";"): part = part.strip() if part.startswith("oai-did="): - return part[len("oai-did="):].strip() + return part[len("oai-did=") :].strip() return None @@ -58,12 +57,14 @@ def _parse_cookie_str(cookies_str: str, domain: str) -> list: if "=" not in part: continue name, _, value = part.partition("=") - cookies.append({ - "name": name.strip(), - "value": value.strip(), - "domain": domain, - "path": "/", - }) + cookies.append( + { + "name": name.strip(), + "value": value.strip(), + "domain": domain, + "path": "/", + } + ) return cookies @@ -79,7 +80,9 @@ def _open_url_system_browser(url: str) -> bool: except Exception: continue elif platform == "darwin": - subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--incognito", url]) + subprocess.Popen( + ["open", "-a", "Google Chrome", "--args", "--incognito", url] + ) return True else: for binary in ["google-chrome", "chromium-browser", "chromium"]: @@ -197,6 +200,7 @@ def generate_team_link( def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool: """用 Playwright 以无痕模式打开 URL,可注入 cookie""" import threading + try: from playwright.sync_api import sync_playwright except ImportError: diff --git a/platforms/cursor/core.py b/platforms/cursor/core.py index a812516..7782584 100644 --- a/platforms/cursor/core.py +++ b/platforms/cursor/core.py @@ -1,17 +1,21 @@ """Cursor 注册协议核心实现""" + import re, uuid, json, urllib.parse, random, string from typing import Optional, Callable +from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url -AUTH = "https://authenticator.cursor.sh" +AUTH = "https://authenticator.cursor.sh" CURSOR = "https://cursor.com" -ACTION_SUBMIT_EMAIL = "d0b05a2a36fbe69091c2f49016138171d5c1e4cd" +ACTION_SUBMIT_EMAIL = "d0b05a2a36fbe69091c2f49016138171d5c1e4cd" ACTION_SUBMIT_PASSWORD = "fef846a39073c935bea71b63308b177b113269b7" -ACTION_MAGIC_CODE = "f9e8ae3d58a7cd11cccbcdbf210e6f2a6a2550dd" +ACTION_MAGIC_CODE = "f9e8ae3d58a7cd11cccbcdbf210e6f2a6a2550dd" -UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/145.0.0.0 Safari/537.36") +UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.0.0 Safari/537.36" +) TURNSTILE_SITEKEY = "0x4AAAAAAAMNIvC45A4Wjjln" @@ -23,7 +27,8 @@ def _rand_password(n=16): def _boundary(): return "----WebKitFormBoundary" + "".join( - random.choices(string.ascii_letters + string.digits, k=16)) + random.choices(string.ascii_letters + string.digits, k=16) + ) def _multipart(fields: dict, boundary: str) -> bytes: @@ -41,13 +46,19 @@ def _multipart(fields: dict, boundary: str) -> bytes: class CursorRegister: def __init__(self, proxy: str = None, log_fn: Callable = print): from curl_cffi import requests as curl_req + self.log = log_fn self.s = curl_req.Session(impersonate="safari17_0") + proxy = normalize_proxy_url(proxy) if proxy: - self.s.proxies = {"http": proxy, "https": proxy} + self.s.proxies = build_requests_proxy_config(proxy) def _base_headers(self, next_action, referer, boundary=None): - ct = f"multipart/form-data; boundary={boundary}" if boundary else "application/x-www-form-urlencoded" + ct = ( + f"multipart/form-data; boundary={boundary}" + if boundary + else "application/x-www-form-urlencoded" + ) return { "user-agent": UA, "accept": "text/x-component", @@ -63,10 +74,12 @@ class CursorRegister: state = {"returnTo": "https://cursor.com/dashboard", "nonce": nonce} state_encoded = urllib.parse.quote(urllib.parse.quote(json.dumps(state))) url = f"{AUTH}/?state={state_encoded}" - self.s.get(url, headers={"user-agent": UA, "accept": "text/html"}, allow_redirects=True) + self.s.get( + url, headers={"user-agent": UA, "accept": "text/html"}, allow_redirects=True + ) state_cookie_name = None for cookie in self.s.cookies.jar: - if 'state-' in cookie.name: + if "state-" in cookie.name: state_cookie_name = cookie.name break return state_encoded, state_cookie_name @@ -75,40 +88,61 @@ class CursorRegister: bd = _boundary() referer = f"{AUTH}/sign-up?state={state_encoded}" body = _multipart({"1_state": state_encoded, "email": email}, bd) - self.s.post(f"{AUTH}/sign-up", - headers=self._base_headers(ACTION_SUBMIT_EMAIL, referer, boundary=bd), - data=body, allow_redirects=False) + self.s.post( + f"{AUTH}/sign-up", + headers=self._base_headers(ACTION_SUBMIT_EMAIL, referer, boundary=bd), + data=body, + allow_redirects=False, + ) def step3_submit_password(self, password, email, state_encoded, yescaptcha_key=""): captcha_token = "" if yescaptcha_key: from core.base_captcha import YesCaptcha + self.log("获取 Turnstile token...") - captcha_token = YesCaptcha(yescaptcha_key).solve_turnstile(AUTH, TURNSTILE_SITEKEY) + captcha_token = YesCaptcha(yescaptcha_key).solve_turnstile( + AUTH, TURNSTILE_SITEKEY + ) bd = _boundary() referer = f"{AUTH}/sign-up?state={state_encoded}" - body = _multipart({ - "1_state": state_encoded, "email": email, - "password": password, "captchaToken": captcha_token, - }, bd) - self.s.post(f"{AUTH}/sign-up", - headers=self._base_headers(ACTION_SUBMIT_PASSWORD, referer, boundary=bd), - data=body, allow_redirects=False) + body = _multipart( + { + "1_state": state_encoded, + "email": email, + "password": password, + "captchaToken": captcha_token, + }, + bd, + ) + self.s.post( + f"{AUTH}/sign-up", + headers=self._base_headers(ACTION_SUBMIT_PASSWORD, referer, boundary=bd), + data=body, + allow_redirects=False, + ) def step4_submit_otp(self, otp, email, state_encoded): bd = _boundary() referer = f"{AUTH}/sign-up?state={state_encoded}" body = _multipart({"1_state": state_encoded, "email": email, "otp": otp}, bd) - r = self.s.post(f"{AUTH}/sign-up", - headers=self._base_headers(ACTION_MAGIC_CODE, referer, boundary=bd), - data=body, allow_redirects=False) + r = self.s.post( + f"{AUTH}/sign-up", + headers=self._base_headers(ACTION_MAGIC_CODE, referer, boundary=bd), + data=body, + allow_redirects=False, + ) loc = r.headers.get("location", "") - m = re.search(r'code=([\w-]+)', loc) + m = re.search(r"code=([\w-]+)", loc) return m.group(1) if m else "" def step5_get_token(self, auth_code, state_encoded): url = f"{CURSOR}/api/auth/callback?code={auth_code}&state={state_encoded}" - self.s.get(url, headers={"user-agent": UA, "accept": "text/html"}, allow_redirects=False) + self.s.get( + url, + headers={"user-agent": UA, "accept": "text/html"}, + allow_redirects=False, + ) for cookie in self.s.cookies.jar: if cookie.name == "WorkosCursorSessionToken": return urllib.parse.unquote(cookie.value) @@ -118,9 +152,13 @@ class CursorRegister: return urllib.parse.unquote(cookie.value) return "" - def register(self, email: str, password: str = None, - otp_callback: Optional[Callable] = None, - yescaptcha_key: str = "") -> dict: + def register( + self, + email: str, + password: str = None, + otp_callback: Optional[Callable] = None, + yescaptcha_key: str = "", + ) -> dict: if not password: password = _rand_password() self.log(f"邮箱: {email}") diff --git a/platforms/kiro/core.py b/platforms/kiro/core.py index a5ef004..a1dc690 100644 --- a/platforms/kiro/core.py +++ b/platforms/kiro/core.py @@ -2,6 +2,7 @@ Kiro / AWS Builder ID 自动注册 v11 (Playwright 重构版) 全面使用真实沙盒模拟,规避 AWS Builder ID 高级前台风控 (FWCIM / JWE / CSRF 跳板)。 """ + import uuid import time import json @@ -16,6 +17,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Tuple, Union, Optional from urllib.parse import urlencode, urlparse, parse_qs from urllib.request import Request, build_opener +from core.proxy_utils import build_requests_proxy_config try: from curl_cffi import requests as cffi_requests @@ -23,6 +25,7 @@ except ImportError: cffi_requests = None from playwright.sync_api import sync_playwright, TimeoutError, Page, Locator + try: from playwright_stealth import stealth_sync except ImportError: @@ -58,7 +61,15 @@ _UA_TEMPLATES = [ ] _LOCALE_TIMEZONE_POOLS = [ - ("en-US", ["America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles"]), + ( + "en-US", + [ + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + ], + ), ("en-GB", ["Europe/London"]), ("en-CA", ["America/Toronto", "America/Vancouver"]), ("en-AU", ["Australia/Sydney", "Australia/Melbourne"]), @@ -151,6 +162,7 @@ class _DesktopAuthCallbackServer: self._thread.join(timeout=2) self._thread = None + class KiroRegister: def __init__(self, proxy=None, tag="KIRO", headless=False): self.proxy = proxy @@ -160,7 +172,7 @@ class KiroRegister: self.pw = None self.browser = None self.context = None - + # 保存捕获到的 Token API 响应 self._captured_tokens = {} self._network_debug = [] @@ -173,7 +185,9 @@ class KiroRegister: def _randomize_name(self, base_name: str) -> str: base = (base_name or "Kiro User").strip() - suffix = "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=3)) + str(random.randint(10, 99)) + suffix = "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=3)) + str( + random.randint(10, 99) + ) return f"{base} {suffix}" def _random_chrome_version(self) -> str: @@ -214,7 +228,7 @@ class KiroRegister: "args": [ "--disable-blink-features=AutomationControlled", "--no-sandbox", - ] + ], } if self.proxy: launch_opts["proxy"] = {"server": self.proxy} @@ -241,21 +255,24 @@ class KiroRegister: reduced_motion=random.choice(["reduce", "no-preference"]), ) self.context.set_extra_http_headers({"Accept-Language": f"{locale},en;q=0.9"}) - + # 拦截 Kiro 登录成功相关的请求/响应,提取 Token self.context.on("request", self._on_request) self.context.on("response", self._on_response) def _is_watched_url(self, url: str) -> bool: url = (url or "").lower() - return any(keyword in url for keyword in [ - "kiro", - "token", - "oauth", - "login", - "complete", - "auth", - ]) + return any( + keyword in url + for keyword in [ + "kiro", + "token", + "oauth", + "login", + "complete", + "auth", + ] + ) def _append_network_debug(self, entry): self._network_debug.append(entry) @@ -324,13 +341,26 @@ class KiroRegister: data = json.loads(data) except Exception: import re as _re - for key in ("accessToken", "refreshToken", "clientId", "clientSecret", "sessionToken"): + + for key in ( + "accessToken", + "refreshToken", + "clientId", + "clientSecret", + "sessionToken", + ): m = _re.search(rf'"{key}"\s*:\s*"([^"]+)"', data) if m: found[key] = m.group(1) return found stack = [data] - wanted_keys = {"accessToken", "refreshToken", "clientId", "clientSecret", "sessionToken"} + wanted_keys = { + "accessToken", + "refreshToken", + "clientId", + "clientSecret", + "sessionToken", + } while stack: current = stack.pop() @@ -362,7 +392,7 @@ class KiroRegister: def _accept_cookie_banner_if_present(self, page: Page): try: - if page.locator('text=/cookie/i').count() == 0: + if page.locator("text=/cookie/i").count() == 0: return selectors = [ @@ -397,13 +427,19 @@ class KiroRegister: continue return "" - def _type_like_human(self, page: Page, selector_or_locator: Union[str, Locator], text: str, clear_first: bool = True): + def _type_like_human( + self, + page: Page, + selector_or_locator: Union[str, Locator], + text: str, + clear_first: bool = True, + ): if isinstance(selector_or_locator, str): el = page.locator(selector_or_locator).first else: # It's already a Locator, use its first match el = selector_or_locator.first - + el.click(delay=random.randint(45, 160)) if clear_first: try: @@ -423,7 +459,10 @@ class KiroRegister: def _solve_captcha_if_exists(self, page: Page): try: # 如果遇到 CAPTCHA,此处可以对接外部打码 - if page.locator('iframe[src*="captcha"]').count() > 0 or page.locator('.awsui-captcha').count() > 0: + if ( + page.locator('iframe[src*="captcha"]').count() > 0 + or page.locator(".awsui-captcha").count() > 0 + ): self.log("⚠️ 发现潜在的 CAPTCHA 挑战,尝试等待或者需要挂件自动打码...") page.wait_for_timeout(5000) except Exception: @@ -432,7 +471,7 @@ class KiroRegister: def _click_primary_button(self, page: Page): # 给予 React 状态同步时间,防止打字太快点击导致验证失效 self._human_sleep(0.45, 1.05) - + try: # 依优先级测试页面上可能的提要按钮 selectors = [ @@ -450,9 +489,11 @@ class KiroRegister: btn.click(timeout=2000) self._human_sleep(0.22, 0.65) return - + # 最后的退路:查找未带有 awsccc(Cookie Consent)的可见 Submit 按钮 - fallback = page.locator('button[type="submit"]:not([data-id*="awsccc"]):visible').first + fallback = page.locator( + 'button[type="submit"]:not([data-id*="awsccc"]):visible' + ).first if fallback.count() > 0 and fallback.is_visible(): fallback.click(timeout=2000) self._human_sleep(0.22, 0.65) @@ -496,7 +537,9 @@ class KiroRegister: page.locator('input[name="code"], input[id*="code"]'), ] - def _wait_for_password_step(self, page: Page, timeout_ms: int = 15000) -> Tuple[bool, str]: + def _wait_for_password_step( + self, page: Page, timeout_ms: int = 15000 + ) -> Tuple[bool, str]: deadline = time.time() + (timeout_ms / 1000) password_input = page.locator('input[type="password"]') error_patterns = [ @@ -521,7 +564,9 @@ class KiroRegister: return False, "提交验证码后未进入密码设置页" - def _wait_for_post_email_step(self, page: Page, timeout_ms: int = 30000) -> Tuple[str, Optional[Locator], str]: + def _wait_for_post_email_step( + self, page: Page, timeout_ms: int = 30000 + ) -> Tuple[str, Optional[Locator], str]: deadline = time.time() + (timeout_ms / 1000) error_patterns = [ re.compile(r"error processing your request", re.I), @@ -531,11 +576,15 @@ class KiroRegister: ] while time.time() < deadline: - otp_field = self._get_first_visible_locator(self._otp_input_candidates(page)) + otp_field = self._get_first_visible_locator( + self._otp_input_candidates(page) + ) if otp_field: return "otp", otp_field, "" - name_field = self._get_first_visible_locator(self._name_input_candidates(page)) + name_field = self._get_first_visible_locator( + self._name_input_candidates(page) + ) if name_field: return "name", name_field, "" @@ -549,7 +598,9 @@ class KiroRegister: return "timeout", None, "等待姓名或 OTP 输入框超时" - def _wait_for_otp_step(self, page: Page, timeout_ms: int = 18000) -> Tuple[bool, str, Optional[Locator]]: + def _wait_for_otp_step( + self, page: Page, timeout_ms: int = 18000 + ) -> Tuple[bool, str, Optional[Locator]]: deadline = time.time() + (timeout_ms / 1000) error_patterns = [ re.compile(r"error processing your request", re.I), @@ -609,10 +660,12 @@ class KiroRegister: "impersonate": "chrome131", } if self.proxy: - kwargs["proxies"] = {"http": self.proxy, "https": self.proxy} + kwargs["proxies"] = build_requests_proxy_config(self.proxy) response = cffi_requests.post(url, **kwargs) if response.status_code != 200: - raise RuntimeError(f"HTTP {response.status_code}: {response.text[:300]}") + raise RuntimeError( + f"HTTP {response.status_code}: {response.text[:300]}" + ) return response.json() data = json.dumps(payload).encode("utf-8") @@ -623,10 +676,16 @@ class KiroRegister: return json.loads(body) def _capture_kiro_web_tokens(self, page: Page): - if not self._captured_tokens.get("webAccessToken") and self._captured_tokens.get("accessToken"): - self._captured_tokens["webAccessToken"] = self._captured_tokens["accessToken"] + if not self._captured_tokens.get( + "webAccessToken" + ) and self._captured_tokens.get("accessToken"): + self._captured_tokens["webAccessToken"] = self._captured_tokens[ + "accessToken" + ] - if self._captured_tokens.get("webAccessToken") and self._captured_tokens.get("sessionToken"): + if self._captured_tokens.get("webAccessToken") and self._captured_tokens.get( + "sessionToken" + ): return try: @@ -645,16 +704,24 @@ class KiroRegister: if not self._captured_tokens.get("webAccessToken"): try: ls = page.evaluate("() => JSON.stringify(window.localStorage)") - self._captured_tokens.update(self._extract_tokens_from_object(json.loads(ls))) - if self._captured_tokens.get("accessToken") and not self._captured_tokens.get("webAccessToken"): - self._captured_tokens["webAccessToken"] = self._captured_tokens["accessToken"] + self._captured_tokens.update( + self._extract_tokens_from_object(json.loads(ls)) + ) + if self._captured_tokens.get( + "accessToken" + ) and not self._captured_tokens.get("webAccessToken"): + self._captured_tokens["webAccessToken"] = self._captured_tokens[ + "accessToken" + ] except Exception: pass if not self._captured_tokens.get("sessionToken"): try: ss = page.evaluate("() => JSON.stringify(window.sessionStorage)") - self._captured_tokens.update(self._extract_tokens_from_object(json.loads(ss))) + self._captured_tokens.update( + self._extract_tokens_from_object(json.loads(ss)) + ) except Exception: pass @@ -674,13 +741,17 @@ class KiroRegister: client_id = response.get("clientId", "") client_secret = response.get("clientSecret", "") if not client_id or not client_secret: - raise RuntimeError(f"桌面端 OIDC Client 注册失败: {json.dumps(response, ensure_ascii=False)[:300]}") + raise RuntimeError( + f"桌面端 OIDC Client 注册失败: {json.dumps(response, ensure_ascii=False)[:300]}" + ) return { "clientId": client_id, "clientSecret": client_secret, "clientSecretExpiresAt": response.get("clientSecretExpiresAt"), "clientIdHash": hashlib.sha1( - json.dumps({"startUrl": KIRO_IDC_START_URL}, separators=(",", ":")).encode("utf-8") + json.dumps( + {"startUrl": KIRO_IDC_START_URL}, separators=(",", ":") + ).encode("utf-8") ).hexdigest(), } @@ -706,14 +777,18 @@ class KiroRegister: }, ) if not response.get("accessToken") or not response.get("refreshToken"): - raise RuntimeError(f"桌面端 token 交换失败: {json.dumps(response, ensure_ascii=False)[:300]}") + raise RuntimeError( + f"桌面端 token 交换失败: {json.dumps(response, ensure_ascii=False)[:300]}" + ) return response def _handle_desktop_auth_page(self, page: Page, email: str = "", pwd: str = ""): # 复用已有 AWS 会话时通常会自动跳转;只有在会话丢失时才需要人工补登录。 try: if email: - email_input = page.locator('input[placeholder="username@example.com"], input[type="email"]').first + email_input = page.locator( + 'input[placeholder="username@example.com"], input[type="email"]' + ).first if email_input.count() > 0 and email_input.is_visible(): self.log("桌面授权页要求重新登录,正在填写 Email ...") try: @@ -752,14 +827,20 @@ class KiroRegister: except Exception: continue - def _complete_desktop_idc_flow(self, email: str = "", pwd: str = "", otp_callback=None) -> dict: + def _complete_desktop_idc_flow( + self, email: str = "", pwd: str = "", otp_callback=None + ) -> dict: region = KIRO_IDC_REGION client_registration = self._register_desktop_client(region) state = str(uuid.uuid4()) code_verifier = uuid.uuid4().hex + uuid.uuid4().hex - code_challenge = base64.urlsafe_b64encode( - hashlib.sha256(code_verifier.encode("utf-8")).digest() - ).decode("utf-8").rstrip("=") + code_challenge = ( + base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode("utf-8")).digest() + ) + .decode("utf-8") + .rstrip("=") + ) callback_server = _DesktopAuthCallbackServer(expected_state=state) callback_server.start() @@ -806,7 +887,9 @@ class KiroRegister: self._human_sleep(0.7, 1.5) continue except Exception as otp_error: - raise RuntimeError(f"桌面授权验证码处理失败: {otp_error}") from otp_error + raise RuntimeError( + f"桌面授权验证码处理失败: {otp_error}" + ) from otp_error self._handle_desktop_auth_page(auth_page, email=email, pwd=pwd) self._human_sleep(0.6, 1.3) @@ -836,7 +919,9 @@ class KiroRegister: pass callback_server.close() - def fetch_desktop_tokens(self, email: str, pwd: str, otp_callback=None) -> Tuple[bool, dict]: + def fetch_desktop_tokens( + self, email: str, pwd: str, otp_callback=None + ) -> Tuple[bool, dict]: page = None created_browser = False try: @@ -845,7 +930,9 @@ class KiroRegister: created_browser = True page = self.context.new_page() page.goto(KIRO_SIGNIN_URL, wait_until="domcontentloaded") - tokens = self._complete_desktop_idc_flow(email=email, pwd=pwd, otp_callback=otp_callback) + tokens = self._complete_desktop_idc_flow( + email=email, pwd=pwd, otp_callback=otp_callback + ) return True, tokens except Exception as e: return False, {"error": str(e)} @@ -858,8 +945,15 @@ class KiroRegister: if created_browser: self._close_browser() - def register(self, email: str, pwd: str = None, name: str = "Kiro User", - mail_token: str = None, otp_timeout: int = 120, otp_callback=None) -> Tuple[bool, dict]: + def register( + self, + email: str, + pwd: str = None, + name: str = "Kiro User", + mail_token: str = None, + otp_timeout: int = 120, + otp_callback=None, + ) -> Tuple[bool, dict]: if not pwd: pwd = f"Aa!1{uuid.uuid4().hex[:8]}" name = self._randomize_name(name) @@ -869,10 +963,10 @@ class KiroRegister: try: self._init_browser() page = self.context.new_page() - + if stealth_sync: stealth_sync(page) - + self.log("加载 Kiro Login ...") page.goto(KIRO_SIGNIN_URL, wait_until="domcontentloaded") self._human_sleep(1.9, 3.4) @@ -890,7 +984,7 @@ class KiroRegister: page.click('button:has-text("Builder ID")') elif page.locator('text="AWS Builder ID"').count() > 0: page.locator('text="AWS Builder ID"').first.click() - + self.log("等待跳转到 AWS SSO ...") page.wait_for_url(re.compile(r"signin\.aws"), timeout=30000) self._accept_cookie_banner_if_present(page) @@ -902,23 +996,33 @@ class KiroRegister: try: inputs_info = [] for field in page.locator("input").all(): - inputs_info.append(f"id={field.get_attribute('id')} type={field.get_attribute('type')} name={field.get_attribute('name')}") + inputs_info.append( + f"id={field.get_attribute('id')} type={field.get_attribute('type')} name={field.get_attribute('name')}" + ) self.log(f"Page Inputs: {inputs_info}") except Exception: pass - + # 宽泛定位器,涵盖大量 aws SSO 可能出现的情况 # AWS 的极度变态之处:它不用 type="email" 也不用 name="email",而是动态生成类似于 id="formField14-1774542604278-6990" 的 type="text" - email_input = page.locator('input[placeholder="username@example.com"], input[type="email"]').first + email_input = page.locator( + 'input[placeholder="username@example.com"], input[type="email"]' + ).first email_input.wait_for(state="visible", timeout=15000) - self._type_like_human(page, 'input[placeholder="username@example.com"], input[type="email"]', email) + self._type_like_human( + page, + 'input[placeholder="username@example.com"], input[type="email"]', + email, + ) self._click_primary_button(page) self._human_sleep(1.1, 2.4) self._solve_captcha_if_exists(page) # 2. 等待邮箱后的实际下一步(某些 AWS 页面会延迟很久才出现姓名输入框) self.log("2. 等待姓名或 OTP 阶段...") - stage, stage_input, stage_error = self._wait_for_post_email_step(page, timeout_ms=30000) + stage, stage_input, stage_error = self._wait_for_post_email_step( + page, timeout_ms=30000 + ) if stage == "error": return False, {"error": f"Email 提交后 AWS 返回错误: {stage_error}"} if stage == "timeout": @@ -932,7 +1036,9 @@ class KiroRegister: self._human_sleep(1.1, 2.4) self.log("3. 等待触发 OTP...") - otp_ready, otp_wait_error, otp_input = self._wait_for_otp_step(page, timeout_ms=30000) + otp_ready, otp_wait_error, otp_input = self._wait_for_otp_step( + page, timeout_ms=30000 + ) else: self.log("2. 当前流程直接进入 OTP,跳过姓名填写") otp_ready, otp_wait_error = True, "" @@ -943,10 +1049,10 @@ class KiroRegister: otp_code = None if otp_callback: otp_code = otp_callback() - + if not otp_code: return False, {"error": "未获取到邮箱验证码(OTP Timeout)"} - + self.log(f"获取到验证码: {otp_code},正在填入...") self._type_like_human(page, otp_input, otp_code) self._click_primary_button(page) @@ -954,21 +1060,26 @@ class KiroRegister: # 4. 设定与确认密码 self.log("4. 设定与确认密码...") - password_ready, otp_error = self._wait_for_password_step(page, timeout_ms=15000) + password_ready, otp_error = self._wait_for_password_step( + page, timeout_ms=15000 + ) if not password_ready: return False, {"error": f"OTP 提交后未通过: {otp_error}"} self._fill_password_fields(page, pwd) - + self._click_primary_button(page) self._human_sleep(1.3, 2.8) self._solve_captcha_if_exists(page) - password_error = self._get_first_visible_text(page, [ - re.compile(r"passwords must match", re.I), - re.compile(r"invalid password", re.I), - re.compile(r"enter password", re.I), - ]) + password_error = self._get_first_visible_text( + page, + [ + re.compile(r"passwords must match", re.I), + re.compile(r"invalid password", re.I), + re.compile(r"enter password", re.I), + ], + ) if password_error: return False, {"error": f"密码设置未通过: {password_error}"} @@ -977,7 +1088,7 @@ class KiroRegister: if allow_btn.count() > 0: self.log("点击 Allow 授权应用...") allow_btn.click() - + # 6. 等待返回 Kiro 拿 Token self.log("等待回到 Kiro...") try: @@ -990,15 +1101,17 @@ class KiroRegister: if "kiro.dev" not in page.url: err_text = "" # try to extract some error - if page.locator('.awsui-alert-content').count() > 0: - err_text = page.locator('.awsui-alert-content').text_content() + if page.locator(".awsui-alert-content").count() > 0: + err_text = page.locator(".awsui-alert-content").text_content() self.log(f"未回到 Kiro,当前 URL: {page.url}") try: self.log(f"当前页面标题: {page.title()}") except Exception: pass try: - self.log(f"当前页面按钮: {page.locator('button').all_text_contents()}") + self.log( + f"当前页面按钮: {page.locator('button').all_text_contents()}" + ) except Exception: pass try: @@ -1009,7 +1122,7 @@ class KiroRegister: self.log("回跳失败 HTML 已保存为 kiro_return_error.html") except Exception: pass - + return False, {"error": f"Failed to return to kiro.dev - {err_text}"} self._capture_kiro_web_tokens(page) @@ -1018,11 +1131,15 @@ class KiroRegister: if not self._captured_tokens.get("webAccessToken"): self.log(f"当前 URL: {page.url}") try: - self.log(f"localStorage: {page.evaluate('() => JSON.stringify(window.localStorage)')[:2000]}") + self.log( + f"localStorage: {page.evaluate('() => JSON.stringify(window.localStorage)')[:2000]}" + ) except Exception: pass try: - self.log(f"sessionStorage: {page.evaluate('() => JSON.stringify(window.sessionStorage)')[:2000]}") + self.log( + f"sessionStorage: {page.evaluate('() => JSON.stringify(window.sessionStorage)')[:2000]}" + ) except Exception: pass try: @@ -1033,13 +1150,16 @@ class KiroRegister: "path": c.get("path", ""), } for c in self.context.cookies() - if "kiro.dev" in c.get("domain", "") or "aws" in c.get("domain", "") + if "kiro.dev" in c.get("domain", "") + or "aws" in c.get("domain", "") ] self.log(f"Cookies: {json.dumps(cookies[:20], ensure_ascii=False)}") except Exception: pass if self._network_debug: - self.log(f"网络调试样本: {json.dumps(self._network_debug[-15:], ensure_ascii=False)}") + self.log( + f"网络调试样本: {json.dumps(self._network_debug[-15:], ensure_ascii=False)}" + ) try: page.screenshot(path="kiro_token_error.png") self.log("Token 提取失败截图已保存为 kiro_token_error.png") @@ -1048,7 +1168,9 @@ class KiroRegister: self.log("Token 提取失败 HTML 已保存为 kiro_token_error.html") except Exception: pass - return False, {"error": "注册看似完成,但未能提取出 OAuth Token。可能是网络拦截失败。"} + return False, { + "error": "注册看似完成,但未能提取出 OAuth Token。可能是网络拦截失败。" + } try: desktop_tokens = self._complete_desktop_idc_flow(email=email, pwd=pwd) @@ -1061,7 +1183,8 @@ class KiroRegister: "email": email, "password": pwd, "name": name, - "accessToken": self._captured_tokens.get("accessToken", "") or self._captured_tokens.get("webAccessToken", ""), + "accessToken": self._captured_tokens.get("accessToken", "") + or self._captured_tokens.get("webAccessToken", ""), "refreshToken": self._captured_tokens.get("refreshToken", ""), "clientId": self._captured_tokens.get("clientId", ""), "clientSecret": self._captured_tokens.get("clientSecret", ""), @@ -1078,7 +1201,7 @@ class KiroRegister: if page: page.screenshot(path="kiro_error.png") self.log("截图已保存为 kiro_error.png") - + with open("kiro_error.html", "w", encoding="utf-8") as f: f.write(page.content()) self.log("HTML 已保存为 kiro_error.html") diff --git a/platforms/openblocklabs/core.py b/platforms/openblocklabs/core.py index 84330c2..6967fb7 100644 --- a/platforms/openblocklabs/core.py +++ b/platforms/openblocklabs/core.py @@ -14,40 +14,48 @@ OpenBlockLabs 自动注册 (WorkOS AuthKit) pip install curl_cffi requests """ + import re, json, time, base64, random, string, os from urllib.parse import urlencode, urlparse, parse_qs from curl_cffi import requests as curl_requests import requests as std_requests +from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url # ─── 配置 ─────────────────────────────────────────────────────────────────── -AUTH_BASE = "https://auth.openblocklabs.com" -DASHBOARD_BASE = "https://dashboard.openblocklabs.com" -DASHBOARD_CALLBACK = f"{DASHBOARD_BASE}/auth/callback" -CLIENT_ID = "client_01K8YDZSSKDMK8GYTEHBAW4N4S" +AUTH_BASE = "https://auth.openblocklabs.com" +DASHBOARD_BASE = "https://dashboard.openblocklabs.com" +DASHBOARD_CALLBACK = f"{DASHBOARD_BASE}/auth/callback" +CLIENT_ID = "client_01K8YDZSSKDMK8GYTEHBAW4N4S" # ──────────────────────────────────────────────────────────────────────────── -UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/145.0.0.0 Safari/537.36") +UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/145.0.0.0 Safari/537.36" +) def _rand_password(n=14): chars = string.ascii_letters + string.digits + "!@#" - pw = (random.choice(string.ascii_uppercase) - + random.choice(string.ascii_lowercase) - + random.choice(string.digits) - + random.choice("!@#") - + "".join(random.choices(chars, k=n - 4))) + pw = ( + random.choice(string.ascii_uppercase) + + random.choice(string.ascii_lowercase) + + random.choice(string.digits) + + random.choice("!@#") + + "".join(random.choices(chars, k=n - 4)) + ) lst = list(pw) random.shuffle(lst) return "".join(lst) -def _build_multipart(fields: list, boundary: str = "----WebKitFormBoundaryPyAPI") -> tuple: +def _build_multipart( + fields: list, boundary: str = "----WebKitFormBoundaryPyAPI" +) -> tuple: body = "" for name, value in fields: - body += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n" + body += f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n' body += f"--{boundary}--\r\n" return body.encode("utf-8"), f"multipart/form-data; boundary={boundary}" @@ -64,10 +72,14 @@ def _make_signals() -> str: "appVersion": UA.split("Mozilla/5.0 ")[1] if "Mozilla" in UA else UA, "platform": "MacIntel", "screen": { - "width": 1470, "height": 956, - "availWidth": 1470, "availHeight": 956, - "windowOuterWidth": 1470, "windowOuterHeight": 956, - "colorDepth": 24, "pixelDepth": 24, + "width": 1470, + "height": 956, + "availWidth": 1470, + "availHeight": 956, + "windowOuterWidth": 1470, + "windowOuterHeight": 956, + "colorDepth": 24, + "pixelDepth": 24, }, "maxTouchPoints": 0, "deviceMemory": 8, @@ -85,19 +97,20 @@ def _make_signals() -> str: return base64.b64encode(json.dumps(data).encode()).decode() - - # ─── Register ──────────────────────────────────────────────────────────────── class OpenBlockLabsRegister: def __init__(self, proxy: str = None): + proxy = normalize_proxy_url(proxy) self.s = curl_requests.Session() self.s.impersonate = "chrome131" if proxy: - self.s.proxies = {"http": proxy, "https": proxy} - self.s.headers.update({ - "user-agent": UA, - "accept-language": "zh-CN,zh;q=0.9", - }) + self.s.proxies = build_requests_proxy_config(proxy) + self.s.headers.update( + { + "user-agent": UA, + "accept-language": "zh-CN,zh;q=0.9", + } + ) self.authorization_session_id = None self._action_id = None @@ -106,7 +119,8 @@ class OpenBlockLabsRegister: def _get_headers(self, referer: str = None, accept: str = None) -> dict: h = { - "accept": accept or "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "accept": accept + or "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"macOS"', @@ -122,17 +136,22 @@ class OpenBlockLabsRegister: def _post_action(self, url: str, fields: list, router_state: str): all_fields = fields + [("0", '["$K1"]')] body, ct = _build_multipart(all_fields) - return self.s.post(url, data=body, headers={ - "accept": "text/x-component", - "content-type": ct, - "origin": AUTH_BASE, - "referer": url, - "next-action": self._action_id, - "next-router-state-tree": router_state, - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", - }, allow_redirects=False) + return self.s.post( + url, + data=body, + headers={ + "accept": "text/x-component", + "content-type": ct, + "origin": AUTH_BASE, + "referer": url, + "next-action": self._action_id, + "next-router-state-tree": router_state, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + }, + allow_redirects=False, + ) def step1_initiate_signup(self) -> bool: """GET auth.openblocklabs.com/sign-up → authorization_session_id + action ID""" @@ -146,7 +165,7 @@ class OpenBlockLabsRegister: ) if r.status_code == 200: break - self.log(f" CF拦截 (status={r.status_code}), 重试 {attempt+1}/5...") + self.log(f" CF拦截 (status={r.status_code}), 重试 {attempt + 1}/5...") time.sleep(2) final_url = str(r.url) parsed = urlparse(final_url) @@ -160,7 +179,9 @@ class OpenBlockLabsRegister: self.authorization_session_id = m.group(1) break self._action_id = self._extract_action_id(r.text) - self.log(f" session_id={self.authorization_session_id}, action={self._action_id and self._action_id[:16]}...") + self.log( + f" session_id={self.authorization_session_id}, action={self._action_id and self._action_id[:16]}..." + ) return bool(self.authorization_session_id) def step2_get_signup_page(self) -> bool: @@ -170,36 +191,48 @@ class OpenBlockLabsRegister: def step3_submit_signup(self, email: str, first_name: str, last_name: str) -> bool: """POST /sign-up (first_name/last_name/email/intent=sign-up) → 303 → /sign-up/password""" self.log(f"Step3: POST /sign-up email={email}") - url = f"{AUTH_BASE}/sign-up?" + urlencode({ - "redirect_uri": DASHBOARD_CALLBACK, - "authorization_session_id": self.authorization_session_id, - }) - router_state = ( - '%5B%22%22%2C%7B%22children%22%3A%5B%22%28main%29%22%2C%7B%22children%22%3A%5B%22%28root%29%22%2C' - '%7B%22children%22%3A%5B%22sign-up%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D' - '%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D' + url = f"{AUTH_BASE}/sign-up?" + urlencode( + { + "redirect_uri": DASHBOARD_CALLBACK, + "authorization_session_id": self.authorization_session_id, + } + ) + router_state = ( + "%5B%22%22%2C%7B%22children%22%3A%5B%22%28main%29%22%2C%7B%22children%22%3A%5B%22%28root%29%22%2C" + "%7B%22children%22%3A%5B%22sign-up%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D" + "%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D" + ) + resp = self._post_action( + url, + [ + ("1_browser_supports_passkeys", "true"), + ("1_signals", ""), + ("1_first_name", first_name), + ("1_last_name", last_name), + ("1_email", email), + ("1_intent", "sign-up"), + ("1_redirect_uri", DASHBOARD_CALLBACK), + ("1_authorization_session_id", self.authorization_session_id), + ], + router_state, ) - resp = self._post_action(url, [ - ("1_browser_supports_passkeys", "true"), - ("1_signals", ""), - ("1_first_name", first_name), - ("1_last_name", last_name), - ("1_email", email), - ("1_intent", "sign-up"), - ("1_redirect_uri", DASHBOARD_CALLBACK), - ("1_authorization_session_id", self.authorization_session_id), - ], router_state) self.log(f" -> {resp.status_code}") return resp.status_code == 303 def step4_get_password_page(self) -> bool: """GET /sign-up/password → 提取 next-action ID""" self.log("Step4: GET /sign-up/password") - url = f"{AUTH_BASE}/sign-up/password?" + urlencode({ - "redirect_uri": DASHBOARD_CALLBACK, - "authorization_session_id": self.authorization_session_id, - }) - r = self.s.get(url, headers=self._get_headers(referer=f"{AUTH_BASE}/sign-up"), allow_redirects=True) + url = f"{AUTH_BASE}/sign-up/password?" + urlencode( + { + "redirect_uri": DASHBOARD_CALLBACK, + "authorization_session_id": self.authorization_session_id, + } + ) + r = self.s.get( + url, + headers=self._get_headers(referer=f"{AUTH_BASE}/sign-up"), + allow_redirects=True, + ) self.log(f" -> {r.status_code}") action = self._extract_action_id(r.text) if action: @@ -207,30 +240,38 @@ class OpenBlockLabsRegister: self.log(f" action={action[:16]}...") return r.status_code == 200 - def step5_submit_password(self, email: str, password: str, first_name: str, last_name: str) -> str: + def step5_submit_password( + self, email: str, password: str, first_name: str, last_name: str + ) -> str: """POST /sign-up/password → RSC body 包含 pendingAuthenticationToken""" self.log("Step5: POST /sign-up/password") - url = f"{AUTH_BASE}/sign-up/password?" + urlencode({ - "redirect_uri": DASHBOARD_CALLBACK, - "authorization_session_id": self.authorization_session_id, - }) - router_state = ( - '%5B%22%22%2C%7B%22children%22%3A%5B%22%28main%29%22%2C%7B%22children%22%3A%5B%22%28root%29%22%2C' - '%7B%22children%22%3A%5B%22sign-up%22%2C%7B%22children%22%3A%5B%22password%22%2C%7B%22children%22%3A' - '%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D' - '%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D' + url = f"{AUTH_BASE}/sign-up/password?" + urlencode( + { + "redirect_uri": DASHBOARD_CALLBACK, + "authorization_session_id": self.authorization_session_id, + } + ) + router_state = ( + "%5B%22%22%2C%7B%22children%22%3A%5B%22%28main%29%22%2C%7B%22children%22%3A%5B%22%28root%29%22%2C" + "%7B%22children%22%3A%5B%22sign-up%22%2C%7B%22children%22%3A%5B%22password%22%2C%7B%22children%22%3A" + "%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D" + "%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D" + ) + resp = self._post_action( + url, + [ + ("1_browser_supports_passkeys", "true"), + ("1_signals", _make_signals()), + ("1_first_name", first_name), + ("1_last_name", last_name), + ("1_email", email), + ("1_password", password), + ("1_intent", "sign-up"), + ("1_redirect_uri", DASHBOARD_CALLBACK), + ("1_authorization_session_id", self.authorization_session_id), + ], + router_state, ) - resp = self._post_action(url, [ - ("1_browser_supports_passkeys", "true"), - ("1_signals", _make_signals()), - ("1_first_name", first_name), - ("1_last_name", last_name), - ("1_email", email), - ("1_password", password), - ("1_intent", "sign-up"), - ("1_redirect_uri", DASHBOARD_CALLBACK), - ("1_authorization_session_id", self.authorization_session_id), - ], router_state) self.log(f" -> {resp.status_code}") body = resp.text m = re.search(r'"pendingAuthenticationToken"\s*:\s*"([^"]+)"', body) @@ -243,11 +284,17 @@ class OpenBlockLabsRegister: def step6_get_email_verification_page(self) -> bool: """GET /email-verification → 提取 next-action ID""" self.log("Step6: GET /email-verification") - url = f"{AUTH_BASE}/email-verification?" + urlencode({ - "redirect_uri": DASHBOARD_CALLBACK, - "authorization_session_id": self.authorization_session_id, - }) - r = self.s.get(url, headers=self._get_headers(referer=f"{AUTH_BASE}/sign-up/password"), allow_redirects=True) + url = f"{AUTH_BASE}/email-verification?" + urlencode( + { + "redirect_uri": DASHBOARD_CALLBACK, + "authorization_session_id": self.authorization_session_id, + } + ) + r = self.s.get( + url, + headers=self._get_headers(referer=f"{AUTH_BASE}/sign-up/password"), + allow_redirects=True, + ) self.log(f" -> {r.status_code}") action = self._extract_action_id(r.text) if action: @@ -258,10 +305,12 @@ class OpenBlockLabsRegister: def step7_submit_otp(self, email: str, code: str, pending_auth_token: str) -> str: """POST /email-verification → 303 → dashboard/auth/callback?code=...""" self.log(f"Step7: POST /email-verification code={code}") - url = f"{AUTH_BASE}/email-verification?" + urlencode({ - "redirect_uri": DASHBOARD_CALLBACK, - "authorization_session_id": self.authorization_session_id, - }) + url = f"{AUTH_BASE}/email-verification?" + urlencode( + { + "redirect_uri": DASHBOARD_CALLBACK, + "authorization_session_id": self.authorization_session_id, + } + ) fields = [ ("1_code", code), ("1_redirect_uri", DASHBOARD_CALLBACK), @@ -272,17 +321,22 @@ class OpenBlockLabsRegister: fields.append(("1_pending_authentication_token", pending_auth_token)) fields.append(("0", '["$K1"]')) body, ct = _build_multipart(fields) - resp = self.s.post(url, data=body, headers={ - "accept": "text/x-component", - "content-type": ct, - "origin": AUTH_BASE, - "referer": url, - "next-action": self._action_id, - "next-router-state-tree": '%5B%22%22%2C%7B%22children%22%3A%5B%22%28main%29%22%2C%7B%22children%22%3A%5B%22%28root%29%22%2C%7B%22children%22%3A%5B%22%28fixed-layout%29%22%2C%7B%22children%22%3A%5B%22email-verification%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D', - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", - }, allow_redirects=False) + resp = self.s.post( + url, + data=body, + headers={ + "accept": "text/x-component", + "content-type": ct, + "origin": AUTH_BASE, + "referer": url, + "next-action": self._action_id, + "next-router-state-tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22%28main%29%22%2C%7B%22children%22%3A%5B%22%28root%29%22%2C%7B%22children%22%3A%5B%22%28fixed-layout%29%22%2C%7B%22children%22%3A%5B%22email-verification%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + }, + allow_redirects=False, + ) self.log(f" -> {resp.status_code}") redirect = resp.headers.get("x-action-redirect", "") self.log(f" x-action-redirect: {redirect[:120]}") @@ -297,7 +351,9 @@ class OpenBlockLabsRegister: """GET dashboard/auth/callback?code=... → wos-session cookie""" self.log("Step8: GET /auth/callback") url = f"{DASHBOARD_CALLBACK}?code={auth_code}" - r = self.s.get(url, headers=self._get_headers(referer=AUTH_BASE), allow_redirects=True) + r = self.s.get( + url, headers=self._get_headers(referer=AUTH_BASE), allow_redirects=True + ) self.log(f" -> {r.status_code} final={str(r.url)[:80]}") for c in self.s.cookies.jar: if "wos-session" in c.name: @@ -327,7 +383,9 @@ class OpenBlockLabsRegister: if not password: password = _rand_password() if not first_name: - first_name = "".join(random.choices(string.ascii_lowercase, k=5)).capitalize() + first_name = "".join( + random.choices(string.ascii_lowercase, k=5) + ).capitalize() if not last_name: last_name = random.choice(string.ascii_uppercase) @@ -340,10 +398,14 @@ class OpenBlockLabsRegister: if not self.step4_get_password_page(): return {"success": False, "error": "get_password_page failed"} - - pending_token = self.step5_submit_password(email, password, first_name, last_name) + pending_token = self.step5_submit_password( + email, password, first_name, last_name + ) if pending_token is None: - return {"success": False, "error": "submit_password failed (email may already be registered)"} + return { + "success": False, + "error": "submit_password failed (email may already be registered)", + } if not self.step6_get_email_verification_page(): return {"success": False, "error": "get_email_verification_page failed"} @@ -360,7 +422,10 @@ class OpenBlockLabsRegister: session_token = self.step8_exchange_callback(auth_code) if not session_token: - return {"success": False, "error": "exchange_callback failed / no wos-session"} + return { + "success": False, + "error": "exchange_callback failed / no wos-session", + } self.step9_create_personal_org() @@ -372,4 +437,3 @@ class OpenBlockLabsRegister: } self.log(f"注册成功: {email}") return result - diff --git a/requirements.txt b/requirements.txt index e7674cb..33b7669 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ quart>=0.19.4 sqlmodel>=0.0.16 curl_cffi>=0.6.0 requests>=2.31.0 +pysocks>=1.7.1 playwright>=1.43.0 patchright>=1.52.5 pydantic>=2.0.0 From c7928411825974191f8325d44d08e9062ae47cac Mon Sep 17 00:00:00 2001 From: Yokaimeow Date: Tue, 31 Mar 2026 17:18:40 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=8F=AA=E5=9C=A8=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E8=A7=84=E8=8C=83=E5=8C=96=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=AD=97=E7=AC=A6=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/tasks.py | 84 +++++++++++++++++++------- core/base_mailbox.py | 3 +- core/executors/playwright.py | 4 +- core/executors/protocol.py | 9 ++- core/http_client.py | 4 +- core/proxy_utils.py | 12 ++-- platforms/chatgpt/chatgpt_client.py | 4 +- platforms/chatgpt/oauth_client.py | 4 +- platforms/chatgpt/oauth_pkce_client.py | 4 +- platforms/cursor/core.py | 3 +- platforms/openblocklabs/core.py | 3 +- 11 files changed, 83 insertions(+), 51 deletions(-) diff --git a/api/tasks.py b/api/tasks.py index a0042f7..477af7d 100644 --- a/api/tasks.py +++ b/api/tasks.py @@ -21,7 +21,8 @@ def _cleanup_old_tasks(): """Remove oldest finished tasks when the dict grows too large.""" with _tasks_lock: finished = [ - (tid, t) for tid, t in _tasks.items() + (tid, t) + for tid, t in _tasks.items() if t.get("status") in ("done", "failed") ] if len(finished) <= MAX_FINISHED_TASKS: @@ -56,7 +57,9 @@ def _prepare_register_request(req: RegisterTaskRequest) -> RegisterTaskRequest: req_data["extra"] = deepcopy(req_data.get("extra") or {}) prepared = RegisterTaskRequest(**req_data) - mail_provider = prepared.extra.get("mail_provider") or config_store.get("mail_provider", "") + mail_provider = prepared.extra.get("mail_provider") or config_store.get( + "mail_provider", "" + ) if mail_provider == "luckmail": platform = prepared.platform if platform in ("tavily", "openblocklabs"): @@ -67,14 +70,16 @@ def _prepare_register_request(req: RegisterTaskRequest) -> RegisterTaskRequest: "cursor": "cursor", "grok": "grok", "kiro": "kiro", - "chatgpt": "openai" + "chatgpt": "openai", } prepared.extra["luckmail_project_code"] = mapping.get(platform, platform) return prepared -def _create_task_record(task_id: str, req: RegisterTaskRequest, source: str, meta: dict | None = None): +def _create_task_record( + task_id: str, req: RegisterTaskRequest, source: str, meta: dict | None = None +): with _tasks_lock: _tasks[task_id] = { "id": task_id, @@ -95,17 +100,21 @@ def enqueue_register_task( meta: dict | None = None, ) -> str: prepared = _prepare_register_request(req) - task_id = f"task_{int(time.time()*1000)}" + task_id = f"task_{int(time.time() * 1000)}" _create_task_record(task_id, prepared, source, meta) if background_tasks is None: - thread = threading.Thread(target=_run_register, args=(task_id, prepared), daemon=True) + thread = threading.Thread( + target=_run_register, args=(task_id, prepared), daemon=True + ) thread.start() else: background_tasks.add_task(_run_register, task_id, prepared) return task_id -def has_active_register_task(*, platform: str | None = None, source: str | None = None) -> bool: +def has_active_register_task( + *, platform: str | None = None, source: str | None = None +) -> bool: with _tasks_lock: for task in _tasks.values(): if task.get("status") not in ("pending", "running"): @@ -128,8 +137,9 @@ def _log(task_id: str, msg: str): print(entry) -def _save_task_log(platform: str, email: str, status: str, - error: str = "", detail: dict = None): +def _save_task_log( + platform: str, email: str, status: str, error: str = "", detail: dict = None +): """Write a TaskLog record to the database.""" with Session(engine) as s: log = TaskLog( @@ -162,6 +172,7 @@ def _run_register(task_id: str, req: RegisterTaskRequest): from core.base_platform import RegisterConfig from core.db import save_account from core.base_mailbox import create_mailbox + from core.proxy_utils import normalize_proxy_url with _tasks_lock: _tasks[task_id]["status"] = "running" @@ -175,8 +186,11 @@ def _run_register(task_id: str, req: RegisterTaskRequest): def _build_mailbox(proxy: Optional[str]): from core.config_store import config_store + merged_extra = config_store.get_all().copy() - merged_extra.update({k: v for k, v in req.extra.items() if v is not None and v != ""}) + merged_extra.update( + {k: v for k, v in req.extra.items() if v is not None and v != ""} + ) return create_mailbox( provider=merged_extra.get("mail_provider", "laoudo"), extra=merged_extra, @@ -191,18 +205,25 @@ def _run_register(task_id: str, req: RegisterTaskRequest): _proxy = req.proxy if not _proxy: _proxy = proxy_pool.get_next() + _proxy = normalize_proxy_url(_proxy) if req.register_delay_seconds > 0: with start_gate_lock: now = time.time() wait_seconds = max(0.0, next_start_time - now) if wait_seconds > 0: - _log(task_id, f"第 {i+1} 个账号启动前延迟 {wait_seconds:g} 秒") + _log( + task_id, + f"第 {i + 1} 个账号启动前延迟 {wait_seconds:g} 秒", + ) time.sleep(wait_seconds) next_start_time = time.time() + req.register_delay_seconds from core.config_store import config_store + merged_extra = config_store.get_all().copy() - merged_extra.update({k: v for k, v in req.extra.items() if v is not None and v != ""}) - + merged_extra.update( + {k: v for k, v in req.extra.items() if v is not None and v != ""} + ) + _config = RegisterConfig( executor_type=req.executor_type, captcha_solver=req.captcha_solver, @@ -215,9 +236,10 @@ def _run_register(task_id: str, req: RegisterTaskRequest): if getattr(_platform, "mailbox", None) is not None: _platform.mailbox._log_fn = _platform._log_fn with _tasks_lock: - _tasks[task_id]["progress"] = f"{i+1}/{req.count}" - _log(task_id, f"开始注册第 {i+1}/{req.count} 个账号") - if _proxy: _log(task_id, f"使用代理: {_proxy}") + _tasks[task_id]["progress"] = f"{i + 1}/{req.count}" + _log(task_id, f"开始注册第 {i + 1}/{req.count} 个账号") + if _proxy: + _log(task_id, f"使用代理: {_proxy}") account = _platform.register( email=req.email or None, password=req.password, @@ -231,15 +253,27 @@ def _run_register(task_id: str, req: RegisterTaskRequest): if mailbox_token: account.extra.setdefault("mailbox_token", mailbox_token) if merged_extra.get("luckmail_project_code"): - account.extra.setdefault("luckmail_project_code", merged_extra.get("luckmail_project_code")) + account.extra.setdefault( + "luckmail_project_code", + merged_extra.get("luckmail_project_code"), + ) if merged_extra.get("luckmail_email_type"): - account.extra.setdefault("luckmail_email_type", merged_extra.get("luckmail_email_type")) + account.extra.setdefault( + "luckmail_email_type", + merged_extra.get("luckmail_email_type"), + ) if merged_extra.get("luckmail_domain"): - account.extra.setdefault("luckmail_domain", merged_extra.get("luckmail_domain")) + account.extra.setdefault( + "luckmail_domain", merged_extra.get("luckmail_domain") + ) if merged_extra.get("luckmail_base_url"): - account.extra.setdefault("luckmail_base_url", merged_extra.get("luckmail_base_url")) + account.extra.setdefault( + "luckmail_base_url", + merged_extra.get("luckmail_base_url"), + ) saved_account = save_account(account) - if _proxy: proxy_pool.report_success(_proxy) + if _proxy: + proxy_pool.report_success(_proxy) _log(task_id, f"✓ 注册成功: {account.email}") _save_task_log(req.platform, account.email, "success") _auto_upload_integrations(task_id, saved_account or account) @@ -247,15 +281,19 @@ def _run_register(task_id: str, req: RegisterTaskRequest): if cashier_url: _log(task_id, f" [升级链接] {cashier_url}") with _tasks_lock: - _tasks[task_id].setdefault("cashier_urls", []).append(cashier_url) + _tasks[task_id].setdefault("cashier_urls", []).append( + cashier_url + ) return True except Exception as e: - if _proxy: proxy_pool.report_fail(_proxy) + if _proxy: + proxy_pool.report_fail(_proxy) _log(task_id, f"✗ 注册失败: {e}") _save_task_log(req.platform, req.email or "", "failed", error=str(e)) return str(e) from concurrent.futures import ThreadPoolExecutor, as_completed + max_workers = min(req.concurrency, req.count, 5) with ThreadPoolExecutor(max_workers=max_workers) as pool: futures = [pool.submit(_do_one, i) for i in range(req.count)] diff --git a/core/base_mailbox.py b/core/base_mailbox.py index 336ff63..abfa730 100644 --- a/core/base_mailbox.py +++ b/core/base_mailbox.py @@ -6,7 +6,7 @@ import random from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Optional, Any -from .proxy_utils import build_requests_proxy_config, normalize_proxy_url +from .proxy_utils import build_requests_proxy_config @dataclass @@ -106,7 +106,6 @@ def create_mailbox( ) -> "BaseMailbox": """工厂方法:根据 provider 创建对应的 mailbox 实例""" extra = extra or {} - proxy = normalize_proxy_url(proxy) if provider == "tempmail_lol": return TempMailLolMailbox(proxy=proxy) elif provider == "skymail": diff --git a/core/executors/playwright.py b/core/executors/playwright.py index 827a8f3..39fd41d 100644 --- a/core/executors/playwright.py +++ b/core/executors/playwright.py @@ -1,12 +1,12 @@ """Playwright 执行器 - 支持 headless/headed 模式""" from ..base_executor import BaseExecutor, Response -from ..proxy_utils import build_playwright_proxy_config, normalize_proxy_url +from ..proxy_utils import build_playwright_proxy_config class PlaywrightExecutor(BaseExecutor): def __init__(self, proxy: str = None, headless: bool = True): - super().__init__(normalize_proxy_url(proxy)) + super().__init__(proxy) self.headless = headless self._browser = None self._context = None diff --git a/core/executors/protocol.py b/core/executors/protocol.py index ac46daa..99bfea9 100644 --- a/core/executors/protocol.py +++ b/core/executors/protocol.py @@ -2,17 +2,16 @@ from curl_cffi import requests as curl_requests from ..base_executor import BaseExecutor, Response -from ..proxy_utils import build_requests_proxy_config, normalize_proxy_url +from ..proxy_utils import build_requests_proxy_config class ProtocolExecutor(BaseExecutor): def __init__(self, proxy: str = None, impersonate: str = "chrome124"): - normalized_proxy = normalize_proxy_url(proxy) - super().__init__(normalized_proxy) + super().__init__(proxy) self.s = curl_requests.Session() self.s.impersonate = impersonate - if normalized_proxy: - self.s.proxies = build_requests_proxy_config(normalized_proxy) + if proxy: + self.s.proxies = build_requests_proxy_config(proxy) self.s.headers.update( { "user-agent": ( diff --git a/core/http_client.py b/core/http_client.py index 795ea94..e629486 100644 --- a/core/http_client.py +++ b/core/http_client.py @@ -13,7 +13,7 @@ import logging from curl_cffi import requests as cffi_requests from curl_cffi.requests import Session, Response -from .proxy_utils import build_requests_proxy_config, normalize_proxy_url +from .proxy_utils import build_requests_proxy_config logger = logging.getLogger(__name__) @@ -57,7 +57,7 @@ class HTTPClient: config: 请求配置 session: 可重用的会话对象 """ - self.proxy_url = normalize_proxy_url(proxy_url) + self.proxy_url = proxy_url self.config = config or RequestConfig() self._session = session diff --git a/core/proxy_utils.py b/core/proxy_utils.py index 8a0df65..95c407c 100644 --- a/core/proxy_utils.py +++ b/core/proxy_utils.py @@ -21,20 +21,18 @@ def normalize_proxy_url(proxy_url: Optional[str]) -> Optional[str]: def build_requests_proxy_config(proxy_url: Optional[str]) -> Optional[dict[str, str]]: - normalized = normalize_proxy_url(proxy_url) - if not normalized: + if not proxy_url: return None - return {"http": normalized, "https": normalized} + return {"http": proxy_url, "https": proxy_url} def build_playwright_proxy_config(proxy_url: Optional[str]) -> Optional[dict[str, str]]: - normalized = normalize_proxy_url(proxy_url) - if not normalized: + if not proxy_url: return None - parts = urlsplit(normalized) + parts = urlsplit(proxy_url) if not parts.scheme or not parts.hostname or parts.port is None: - return {"server": normalized} + return {"server": proxy_url} config = {"server": f"{parts.scheme}://{parts.hostname}:{parts.port}"} if parts.username: diff --git a/platforms/chatgpt/chatgpt_client.py b/platforms/chatgpt/chatgpt_client.py index 4fc0ded..aecb322 100644 --- a/platforms/chatgpt/chatgpt_client.py +++ b/platforms/chatgpt/chatgpt_client.py @@ -7,7 +7,7 @@ import random import uuid import time from urllib.parse import urlparse -from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url +from core.proxy_utils import build_requests_proxy_config try: from curl_cffi import requests as curl_requests @@ -83,7 +83,7 @@ class ChatGPTClient: verbose: 是否输出详细日志 browser_mode: protocol | headless | headed """ - self.proxy = normalize_proxy_url(proxy) + self.proxy = proxy self.verbose = verbose self.browser_mode = browser_mode or "protocol" self.device_id = str(uuid.uuid4()) diff --git a/platforms/chatgpt/oauth_client.py b/platforms/chatgpt/oauth_client.py index 362187d..30525fb 100644 --- a/platforms/chatgpt/oauth_client.py +++ b/platforms/chatgpt/oauth_client.py @@ -5,7 +5,7 @@ OAuth 客户端模块 - 处理 Codex OAuth 登录流程 import time import secrets from urllib.parse import urlparse, parse_qs -from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url +from core.proxy_utils import build_requests_proxy_config try: from curl_cffi import requests as curl_requests @@ -48,7 +48,7 @@ class OAuthClient: self.oauth_redirect_uri = self.config.get( "oauth_redirect_uri", "http://localhost:1455/auth/callback" ) - self.proxy = normalize_proxy_url(proxy) + self.proxy = proxy self.verbose = verbose self.browser_mode = browser_mode or "protocol" self.last_error = "" diff --git a/platforms/chatgpt/oauth_pkce_client.py b/platforms/chatgpt/oauth_pkce_client.py index 351a71c..ec9bcc3 100644 --- a/platforms/chatgpt/oauth_pkce_client.py +++ b/platforms/chatgpt/oauth_pkce_client.py @@ -12,7 +12,7 @@ import urllib.parse from typing import Optional from curl_cffi import requests as curl_requests -from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url +from core.proxy_utils import build_requests_proxy_config from .oauth import ( OAuthStart, @@ -49,7 +49,7 @@ class OAuthPkceClient: """ def __init__(self, proxy: Optional[str] = None, log_fn=None): - self.proxy = normalize_proxy_url(proxy) + self.proxy = proxy self._log = log_fn or (lambda msg: None) self._proxies = build_requests_proxy_config(self.proxy) diff --git a/platforms/cursor/core.py b/platforms/cursor/core.py index 7782584..322820f 100644 --- a/platforms/cursor/core.py +++ b/platforms/cursor/core.py @@ -2,7 +2,7 @@ import re, uuid, json, urllib.parse, random, string from typing import Optional, Callable -from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url +from core.proxy_utils import build_requests_proxy_config AUTH = "https://authenticator.cursor.sh" CURSOR = "https://cursor.com" @@ -49,7 +49,6 @@ class CursorRegister: self.log = log_fn self.s = curl_req.Session(impersonate="safari17_0") - proxy = normalize_proxy_url(proxy) if proxy: self.s.proxies = build_requests_proxy_config(proxy) diff --git a/platforms/openblocklabs/core.py b/platforms/openblocklabs/core.py index 6967fb7..43facf7 100644 --- a/platforms/openblocklabs/core.py +++ b/platforms/openblocklabs/core.py @@ -19,7 +19,7 @@ import re, json, time, base64, random, string, os from urllib.parse import urlencode, urlparse, parse_qs from curl_cffi import requests as curl_requests import requests as std_requests -from core.proxy_utils import build_requests_proxy_config, normalize_proxy_url +from core.proxy_utils import build_requests_proxy_config # ─── 配置 ─────────────────────────────────────────────────────────────────── @@ -100,7 +100,6 @@ def _make_signals() -> str: # ─── Register ──────────────────────────────────────────────────────────────── class OpenBlockLabsRegister: def __init__(self, proxy: str = None): - proxy = normalize_proxy_url(proxy) self.s = curl_requests.Session() self.s.impersonate = "chrome131" if proxy: From 6c26d5f10e2a6547290bbe2c93116f5a6fef2a66 Mon Sep 17 00:00:00 2001 From: zhangchen <1987834247@qq.com> Date: Tue, 31 Mar 2026 21:18:32 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E4=BB=A5=E5=89=8D=20gpt?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E4=BD=BF=E7=94=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- platforms/chatgpt/oauth_pkce_client.py | 653 ++++++++++++++++++++++++- platforms/chatgpt/register_v2.py | 413 ++++++---------- 2 files changed, 780 insertions(+), 286 deletions(-) diff --git a/platforms/chatgpt/oauth_pkce_client.py b/platforms/chatgpt/oauth_pkce_client.py index 2aafdd3..76a01b5 100644 --- a/platforms/chatgpt/oauth_pkce_client.py +++ b/platforms/chatgpt/oauth_pkce_client.py @@ -58,6 +58,14 @@ class OAuthPkceClient: self._device_id: Optional[str] = None self._sentinel: Optional[str] = None + self._consent_url: str = "" + self._workspace_session_data: Optional[dict] = None + self._create_account_continue_url: Optional[str] = None + self._create_account_workspace_id: Optional[str] = None + self._create_account_refresh_token: Optional[str] = None + self._create_account_page_type: Optional[str] = None + self._last_validate_otp_continue_url: Optional[str] = None + self._last_validate_otp_workspace_id: Optional[str] = None # ══════════════════════════════════════════════════════════════════ # 内部方法:获取 Sentinel Token(极简模式) @@ -144,6 +152,85 @@ class OAuthPkceClient: self._log("Sentinel Token 已获取") return self._sentinel + @staticmethod + def _extract_continue_info(resp) -> tuple[str, str]: + """从接口响应中提取 page.type 和 continue_url。""" + try: + data = resp.json() or {} + except Exception: + return "", "" + + page_type = str(((data.get("page") or {}).get("type") or "")).strip() + continue_url = str(data.get("continue_url") or "").strip() + return page_type, continue_url + + @staticmethod + def _extract_workspace_id_from_payload(payload) -> str: + if not isinstance(payload, dict): + return "" + workspace_id = str( + payload.get("workspace_id") + or payload.get("workspaceId") + or payload.get("default_workspace_id") + or ((payload.get("workspace") or {}).get("id") if isinstance(payload.get("workspace"), dict) else "") + or "" + ).strip() + if workspace_id: + return workspace_id + workspaces = payload.get("workspaces") or [] + if isinstance(workspaces, list) and workspaces: + return str((workspaces[0] or {}).get("id") or "").strip() + return "" + + @staticmethod + def _iter_candidate_dicts(payload, max_depth: int = 4): + stack = [(payload, 0)] + seen = set() + while stack: + item, depth = stack.pop() + if id(item) in seen: + continue + seen.add(id(item)) + if isinstance(item, dict): + yield item + if depth < max_depth: + for value in item.values(): + if isinstance(value, (dict, list, tuple)): + stack.append((value, depth + 1)) + elif isinstance(item, (list, tuple)) and depth < max_depth: + for value in item: + if isinstance(value, (dict, list, tuple)): + stack.append((value, depth + 1)) + + def _extract_workspace_and_continue_from_payload(self, payload, base_url: str = "") -> tuple[str, str]: + workspace_id = "" + continue_url = "" + for item in self._iter_candidate_dicts(payload): + if not workspace_id: + workspace_id = self._extract_workspace_id_from_payload(item) + if not continue_url: + for key in ("continue_url", "continueUrl", "next_url", "nextUrl", "redirect_url", "redirectUrl", "url"): + candidate = str(item.get(key) or "").strip() + if not candidate: + continue + if candidate.startswith("/") and base_url: + candidate = urllib.parse.urljoin(base_url, candidate) + continue_url = candidate + break + if workspace_id and continue_url: + break + return workspace_id, continue_url + + @staticmethod + def _is_registration_gate_url(url: str) -> bool: + target = str(url or "").lower() + return any(marker in target for marker in ("/about-you", "/add-phone", "/email-verification")) + + @staticmethod + def _is_workspace_resolution_url(url: str) -> bool: + target = str(url or "").lower() + return any(marker in target for marker in ("sign-in-with-chatgpt", "consent", "workspace", "organization")) + # ══════════════════════════════════════════════════════════════════ # 步骤 4:提交邮箱 # ══════════════════════════════════════════════════════════════════ @@ -250,6 +337,23 @@ class OAuthPkceClient: ) if resp.status_code != 200: raise RuntimeError(f"OTP 验证失败: HTTP {resp.status_code} {resp.text[:300]}") + try: + data = resp.json() or {} + found_workspace, found_continue = self._extract_workspace_and_continue_from_payload( + data, + base_url=f"{AUTH_BASE}/api/accounts/email-otp/validate", + ) + if found_workspace: + self._last_validate_otp_workspace_id = found_workspace + self._log(f"OTP 校验返回 Workspace ID: {found_workspace}") + if found_continue: + self._last_validate_otp_continue_url = found_continue + self._log( + f"OTP 校验返回 continue_url: " + f"{(found_continue[:160] + '...') if len(found_continue) > 160 else found_continue}" + ) + except Exception as e: + self._log(f"解析 OTP 校验返回信息失败: {e}") self._log("OTP 验证通过") # ══════════════════════════════════════════════════════════════════ @@ -260,18 +364,49 @@ class OAuthPkceClient: """提交姓名和生日完成账户创建。""" self._log(f"创建账户: {name} ({birthdate})") + headers = { + "referer": f"{AUTH_BASE}/about-you", + "accept": "application/json", + "content-type": "application/json", + } + if self._sentinel: + headers["openai-sentinel-token"] = self._sentinel + resp = self.session.post( f"{AUTH_BASE}/api/accounts/create_account", - headers={ - "referer": f"{AUTH_BASE}/about-you", - "accept": "application/json", - "content-type": "application/json", - }, + headers=headers, data=json.dumps({"name": name, "birthdate": birthdate}), timeout=30, ) if resp.status_code != 200: raise RuntimeError(f"创建账户失败: HTTP {resp.status_code} {resp.text[:300]}") + try: + data = resp.json() or {} + found_workspace, found_continue = self._extract_workspace_and_continue_from_payload( + data, + base_url=f"{AUTH_BASE}/api/accounts/create_account", + ) + if found_continue: + self._create_account_continue_url = found_continue + self._log( + f"create_account 返回 continue_url,已缓存: " + f"{(found_continue[:160] + '...') if len(found_continue) > 160 else found_continue}" + ) + page_info = data.get("page") if isinstance(data, dict) else None + if isinstance(page_info, dict): + page_type = str(page_info.get("type") or "").strip() + if page_type: + self._create_account_page_type = page_type + self._log(f"create_account 返回 page.type: {page_type}") + if found_workspace: + self._create_account_workspace_id = found_workspace + self._log(f"create_account 返回 workspace_id,已缓存: {found_workspace}") + refresh_token = str(data.get("refresh_token") or "").strip() + if refresh_token: + self._create_account_refresh_token = refresh_token + self._log("create_account 返回 refresh_token,已缓存") + except Exception as e: + self._log(f"解析 create_account 响应失败: {e}") self._log("账户创建成功") # ══════════════════════════════════════════════════════════════════ @@ -320,8 +455,12 @@ class OAuthPkceClient: if login_email_resp.status_code != 200: raise RuntimeError(f"登录提交邮箱失败: HTTP {login_email_resp.status_code}") - page_type = (login_email_resp.json().get("page") or {}).get("type", "") + page_type, continue_url = self._extract_continue_info(login_email_resp) self._log(f"登录页面类型: {page_type}") + self._log( + f"登录提交邮箱响应: page={page_type or '(空)'} " + f"continue_url={(continue_url[:160] + '...') if len(continue_url) > 160 else (continue_url or '(空)')}" + ) # 9-4. 提交密码(login_password 页面) if "password" in page_type: @@ -339,8 +478,12 @@ class OAuthPkceClient: ) if pwd_resp.status_code != 200: raise RuntimeError(f"登录密码验证失败: HTTP {pwd_resp.status_code}") - page_type = (pwd_resp.json().get("page") or {}).get("type", "") + page_type, continue_url = self._extract_continue_info(pwd_resp) self._log(f"密码验证后页面类型: {page_type}") + self._log( + f"密码验证响应: page={page_type or '(空)'} " + f"continue_url={(continue_url[:160] + '...') if len(continue_url) > 160 else (continue_url or '(空)')}" + ) # 9-5. 二次 OTP(复用注册阶段验证码) if "otp" in page_type or "verification" in page_type: @@ -374,8 +517,21 @@ class OAuthPkceClient: ) if otp_resp.status_code != 200: raise RuntimeError(f"登录二次 OTP 失败: HTTP {otp_resp.status_code} {otp_resp.text[:200]}") + page_type, continue_url = self._extract_continue_info(otp_resp) self._log("登录二次验证通过") + self._log( + f"登录二次 OTP 响应: page={page_type or '(空)'} " + f"continue_url={(continue_url[:160] + '...') if len(continue_url) > 160 else (continue_url or '(空)')}" + ) + if continue_url: + self._consent_url = continue_url + self._log(f"登录后 continue_url: {continue_url}") + else: + self._consent_url = f"{AUTH_BASE}/sign-in-with-chatgpt/codex/consent" + self._log("登录响应未返回 continue_url,回退使用默认 consent URL") + + self._workspace_session_data = None self._log("OAuth 登录流程完成") return login_oauth @@ -384,53 +540,484 @@ class OAuthPkceClient: # ══════════════════════════════════════════════════════════════════ def extract_workspace_id(self) -> str: - """从 oai-client-auth-session Cookie(JWT)中解析 workspace_id。""" - auth_cookie = self.session.cookies.get("oai-client-auth-session") or "" - if not auth_cookie: - raise RuntimeError("未找到 oai-client-auth-session Cookie") + """优先从会话/consent 页面提取 workspace_id,失败再回退 Cookie 段解码。""" + cached_workspace = str(self._last_validate_otp_workspace_id or "").strip() + if cached_workspace: + self._log(f"使用 OTP 返回的 Workspace ID: {cached_workspace}") + return cached_workspace - # JWT 段遍历(workspace 可能在第一段或第二段) - segments = auth_cookie.split(".") - for i in range(min(len(segments), 2)): - data = _decode_jwt_segment(segments[i]) - workspaces = data.get("workspaces") or [] + cached_workspace = str(self._create_account_workspace_id or "").strip() + if cached_workspace: + self._log(f"使用 create_account 缓存 Workspace ID: {cached_workspace}") + return cached_workspace + + candidate_urls = [] + for candidate in ( + str(self._create_account_continue_url or "").strip(), + str(self._consent_url or "").strip(), + str(self._last_validate_otp_continue_url or "").strip(), + ): + if candidate and candidate not in candidate_urls: + candidate_urls.append(candidate) + + for candidate_url in candidate_urls: + if not self._is_workspace_resolution_url(candidate_url): + self._log(f"跳过非 workspace 解析地址: {candidate_url}") + continue + self._log(f"尝试从地址解析 workspace: {candidate_url}") + session_data = self._load_workspace_session_data(candidate_url) + workspaces = (session_data or {}).get("workspaces") or [] if workspaces: wid = str((workspaces[0] or {}).get("id") or "").strip() if wid: self._log(f"成功解析 workspace_id: {wid}") return wid - # 调试信息 - first_data = _decode_jwt_segment(segments[0]) if segments else {} - self._log(f"Cookie 字段: {list(first_data.keys())}") - raise RuntimeError("无法从 Cookie 中解析 workspace_id") + auth_cookie = "" + try: + auth_cookie = str( + self.session.cookies.get("oai-client-auth-session", domain=".auth.openai.com") + or self.session.cookies.get("oai-client-auth-session", domain="auth.openai.com") + or self.session.cookies.get("oai-client-auth-session") + or "" + ).strip() + except Exception: + auth_cookie = str(self.session.cookies.get("oai-client-auth-session") or "").strip() + + if auth_cookie: + import base64 + + candidate_payloads = [] + segments = auth_cookie.split(".") + if len(segments) >= 2 and segments[1]: + candidate_payloads.append(segments[1]) + if segments and segments[0]: + candidate_payloads.append(segments[0]) + candidate_payloads.append(auth_cookie) + + for payload in candidate_payloads: + raw = str(payload or "").strip() + if not raw: + continue + auth_json = None + try: + pad = "=" * ((4 - (len(raw) % 4)) % 4) + decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii")) + auth_json = json.loads(decoded.decode("utf-8")) + except Exception: + try: + auth_json = json.loads(raw) + except Exception: + auth_json = None + + wid = self._extract_workspace_id_from_payload(auth_json) + if wid: + self._log(f"Workspace ID (auth-session): {wid}") + return wid + + auth_info_raw = "" + try: + auth_info_raw = str( + self.session.cookies.get("oai-client-auth-info", domain=".auth.openai.com") + or self.session.cookies.get("oai-client-auth-info", domain="auth.openai.com") + or self.session.cookies.get("oai-client-auth-info") + or "" + ).strip() + except Exception: + auth_info_raw = str(self.session.cookies.get("oai-client-auth-info") or "").strip() + + if auth_info_raw: + auth_info_text = auth_info_raw + for _ in range(2): + decoded = urllib.parse.unquote(auth_info_text) + if decoded == auth_info_text: + break + auth_info_text = decoded + try: + stripped = str(auth_info_text or "").strip() + if stripped and (len(stripped) >= 2) and (stripped[0] == stripped[-1]) and (stripped[0] in ("'", '"')): + stripped = stripped[1:-1].strip() + + auth_info_json = None + if stripped and stripped[0] in "{[": + auth_info_json = json.loads(stripped) + else: + import base64 + + candidates_raw = [] + if stripped: + candidates_raw.append(stripped) + if "." in stripped: + for seg in stripped.split("."): + seg = seg.strip() + if seg: + candidates_raw.append(seg) + + for candidate in candidates_raw: + pad = "=" * ((4 - (len(candidate) % 4)) % 4) + decoded_candidates = [] + try: + decoded_candidates.append(base64.urlsafe_b64decode((candidate + pad).encode("ascii"))) + except Exception: + pass + try: + decoded_candidates.append(base64.b64decode((candidate + pad).encode("ascii"))) + except Exception: + pass + for decoded in decoded_candidates: + try: + text = decoded.decode("utf-8") + except Exception: + continue + for _ in range(2): + decoded_text = urllib.parse.unquote(text) + if decoded_text == text: + break + text = decoded_text + text = text.strip() + if text and text[0] in "{[": + auth_info_json = json.loads(text) + break + if auth_info_json is not None: + break + + wid = self._extract_workspace_id_from_payload(auth_info_json) + if wid: + self._log(f"Workspace ID (auth-info): {wid}") + return wid + except Exception as e: + self._log(f"解析 auth-info Cookie 失败: {e}") + + decoded_cookie = self._decode_oauth_session_cookie() or {} + if decoded_cookie: + self._log(f"Cookie 字段: {list(decoded_cookie.keys())}") + + fallback_workspace = str(self._create_account_workspace_id or "").strip() + if fallback_workspace: + self._log(f"Workspace ID (create_account缓存): {fallback_workspace}") + return fallback_workspace + raise RuntimeError("无法从 Cookie/consent 中解析 workspace_id") # ══════════════════════════════════════════════════════════════════ # 步骤 11:选择 workspace # ══════════════════════════════════════════════════════════════════ def select_workspace(self, workspace_id: str) -> str: - """选择 workspace,返回 continue_url。""" + """选择 workspace;如需要再自动选择 organization,返回下一跳 continue_url。""" self._log(f"选择 workspace: {workspace_id}") resp = self.session.post( f"{AUTH_BASE}/api/accounts/workspace/select", headers={ - "referer": f"{AUTH_BASE}/sign-in-with-chatgpt/codex/consent", + "referer": self._consent_url or f"{AUTH_BASE}/sign-in-with-chatgpt/codex/consent", + "accept": "application/json", "content-type": "application/json", }, data=json.dumps({"workspace_id": workspace_id}), timeout=30, ) - if resp.status_code != 200: + if resp.status_code not in (200, 301, 302, 303, 307, 308): raise RuntimeError(f"workspace/select 失败: HTTP {resp.status_code} {resp.text[:300]}") + self._log(f"workspace/select 状态: {resp.status_code}") + + if resp.status_code in (301, 302, 303, 307, 308): + continue_url = urllib.parse.urljoin( + f"{AUTH_BASE}/api/accounts/workspace/select", + resp.headers.get("Location") or "", + ) + if continue_url: + self._log( + f"workspace/select 返回重定向: " + f"{(continue_url[:160] + '...') if len(continue_url) > 160 else continue_url}" + ) + return continue_url + raise RuntimeError("workspace/select 重定向缺少 Location") + + data = resp.json() or {} + orgs = ((data.get("data") or {}).get("orgs") or []) + continue_url = str(data.get("continue_url") or "").strip() + self._log( + f"workspace/select 响应: orgs={len(orgs)} " + f"continue_url={(continue_url[:160] + '...') if len(continue_url) > 160 else (continue_url or '(空)')}" + ) + + if orgs: + org = orgs[0] or {} + org_id = str(org.get("id") or "").strip() + projects = org.get("projects") or [] + project_id = str((projects[0] or {}).get("id") or "").strip() if projects else "" + if not org_id: + raise RuntimeError("workspace/select 返回 orgs,但缺少 org_id") + + self._log(f"选择 organization: {org_id}") + if project_id: + self._log(f"选择 project: {project_id}") + org_payload = {"org_id": org_id} + if project_id: + org_payload["project_id"] = project_id + + org_resp = self.session.post( + f"{AUTH_BASE}/api/accounts/organization/select", + headers={ + "referer": continue_url or self._consent_url or f"{AUTH_BASE}/sign-in-with-chatgpt/codex/consent", + "accept": "application/json", + "content-type": "application/json", + }, + data=json.dumps(org_payload), + timeout=30, + ) + if org_resp.status_code not in (200, 301, 302, 303, 307, 308): + raise RuntimeError(f"organization/select 失败: HTTP {org_resp.status_code} {org_resp.text[:300]}") + self._log(f"organization/select 状态: {org_resp.status_code}") + + if org_resp.status_code in (301, 302, 303, 307, 308): + continue_url = urllib.parse.urljoin( + f"{AUTH_BASE}/api/accounts/organization/select", + org_resp.headers.get("Location") or "", + ) + self._log( + f"organization/select 重定向: " + f"{(continue_url[:160] + '...') if len(continue_url) > 160 else (continue_url or '(空)')}" + ) + else: + org_page_type, continue_url = self._extract_continue_info(org_resp) + self._log( + f"organization/select 响应: page={org_page_type or '(空)'} " + f"continue_url={(continue_url[:160] + '...') if len(continue_url) > 160 else (continue_url or '(空)')}" + ) + else: + self._log("workspace/select 未返回 organization 列表") - continue_url = str((resp.json() or {}).get("continue_url") or "").strip() if not continue_url: - raise RuntimeError("workspace/select 响应缺少 continue_url") + raise RuntimeError("workspace/org 选择后缺少 continue_url") + self._log("workspace 选择成功,continue_url 已获取") return continue_url + def _load_workspace_session_data(self, consent_url: str = "") -> Optional[dict]: + """优先从 cookie 读取 workspace,会话不足时回退到 consent HTML。""" + if self._workspace_session_data and self._workspace_session_data.get("workspaces"): + self._log( + f"复用缓存 workspace session: " + f"{len(self._workspace_session_data.get('workspaces', []))} 个 workspace" + ) + return self._workspace_session_data + + session_data = self._decode_oauth_session_cookie() + if session_data: + self._log( + f"Cookie session 已解码: keys={list(session_data.keys())} " + f"workspaces={len(session_data.get('workspaces', []) or [])}" + ) + if session_data.get("workspaces"): + self._workspace_session_data = session_data + return session_data + else: + self._log("Cookie session 解码失败或为空") + + self._log( + f"Cookie 中无 workspace,回退抓取 consent HTML: " + f"{(consent_url[:160] + '...') if len(consent_url) > 160 else (consent_url or self._consent_url or '(空)')}" + ) + + html = self._fetch_consent_page_html(consent_url) + if not html: + self._log("consent HTML 获取为空") + return session_data + + parsed = self._extract_session_data_from_consent_html(html) + if parsed and parsed.get("workspaces"): + self._workspace_session_data = parsed + self._log( + f"从 consent HTML 提取到 {len(parsed.get('workspaces', []))} 个 workspace, " + f"keys={list(parsed.keys())}" + ) + return parsed + + self._log("consent HTML 中未提取到 workspace") + + return session_data + + def _fetch_consent_page_html(self, consent_url: str) -> str: + """拉取 consent 页 HTML,供本地玩具环境解析 workspace 数据。""" + target = consent_url or self._consent_url + if not target: + self._log("consent URL 为空,无法抓取 HTML") + return "" + + try: + self._log( + f"抓取 consent HTML: " + f"{(target[:160] + '...') if len(target) > 160 else target}" + ) + resp = self.session.get( + target, + headers={ + "referer": f"{AUTH_BASE}/email-verification", + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + allow_redirects=False, + timeout=30, + ) + self._log( + f"consent HTML 响应: status={resp.status_code} " + f"content-type={resp.headers.get('content-type', '')}" + ) + if resp.status_code == 200 and "text/html" in (resp.headers.get("content-type", "").lower()): + return resp.text + if resp.status_code in (301, 302, 303, 307, 308): + self._log(f"consent HTML 重定向到: {resp.headers.get('Location', '')}") + except Exception as e: + self._log(f"获取 consent HTML 失败: {e}") + return "" + + def _decode_oauth_session_cookie(self) -> Optional[dict]: + """解码 oai-client-auth-session 中的 JSON 载荷。""" + try: + for cookie in self.session.cookies: + if getattr(cookie, "name", "") != "oai-client-auth-session": + continue + value = getattr(cookie, "value", "") or "" + if value: + data = self._decode_cookie_json_value(value) + if isinstance(data, dict): + return data + except Exception: + pass + return None + + @staticmethod + def _decode_cookie_json_value(value: str) -> Optional[dict]: + import base64 + + raw_value = str(value or "").strip() + if not raw_value: + return None + + candidates = [raw_value] + if "." in raw_value: + candidates.insert(0, raw_value.split(".", 1)[0]) + + for candidate in candidates: + candidate = candidate.strip() + if not candidate: + continue + padded = candidate + "=" * (-len(candidate) % 4) + for decoder in (base64.urlsafe_b64decode, base64.b64decode): + try: + decoded = decoder(padded).decode("utf-8") + parsed = json.loads(decoded) + except Exception: + continue + if isinstance(parsed, dict): + return parsed + + return None + + def get_cached_continue_url(self) -> str: + """返回可用于继续授权链路的缓存 continue_url。""" + candidates = [ + str(self._last_validate_otp_continue_url or "").strip(), + str(self._create_account_continue_url or "").strip(), + str(self._consent_url or "").strip(), + ] + for candidate in candidates: + if not candidate: + continue + if self._is_registration_gate_url(candidate): + self._log(f"忽略注册门页 continue_url: {candidate}") + continue + return candidate + return "" + + @staticmethod + def _extract_session_data_from_consent_html(html: str) -> Optional[dict]: + """从 consent HTML/stream 片段里提取 workspace 列表。""" + if not html or "workspaces" not in html: + return None + + def _first_match(patterns, text): + for pattern in patterns: + m = re.search(pattern, text, re.S) + if m: + return m.group(1) + return "" + + def _build_from_text(text): + if not text or "workspaces" not in text: + return None + + normalized = text.replace('\\"', '"') + + session_id = _first_match( + [r'"session_id","([^"]+)"', r'"session_id":"([^"]+)"'], + normalized, + ) + client_id = _first_match( + [r'"openai_client_id","([^"]+)"', r'"openai_client_id":"([^"]+)"'], + normalized, + ) + + start = normalized.find('"workspaces"') + if start < 0: + start = normalized.find("workspaces") + if start < 0: + return None + + end = normalized.find('"openai_client_id"', start) + if end < 0: + end = normalized.find("openai_client_id", start) + if end < 0: + end = min(len(normalized), start + 4000) + else: + end = min(len(normalized), end + 600) + + workspace_chunk = normalized[start:end] + ids = re.findall(r'"id"(?:,|:)"([0-9a-fA-F-]{36})"', workspace_chunk) + if not ids: + return None + + kinds = re.findall(r'"kind"(?:,|:)"([^"]+)"', workspace_chunk) + workspaces = [] + seen = set() + for idx, wid in enumerate(ids): + if wid in seen: + continue + seen.add(wid) + item = {"id": wid} + if idx < len(kinds): + item["kind"] = kinds[idx] + workspaces.append(item) + + if not workspaces: + return None + + return { + "session_id": session_id, + "openai_client_id": client_id, + "workspaces": workspaces, + } + + candidates = [html] + + for quoted in re.findall(r'streamController\.enqueue\(("(?:\\.|[^"\\])*")\)', html, re.S): + try: + decoded = json.loads(quoted) + except Exception: + continue + if decoded: + candidates.append(decoded) + + if '\\"' in html: + candidates.append(html.replace('\\"', '"')) + + for candidate in candidates: + parsed = _build_from_text(candidate) + if parsed and parsed.get("workspaces"): + return parsed + + return None + # ══════════════════════════════════════════════════════════════════ # 步骤 12:跟踪重定向链,交换 OAuth code → access_token # ══════════════════════════════════════════════════════════════════ @@ -441,9 +1028,25 @@ class OAuthPkceClient: """跟踪重定向链,捕获 code= 回调 URL,交换 access_token。""" current_url = continue_url + if current_url and "code=" in current_url and "state=" in current_url: + self._log("起始 continue_url 已包含 code,直接交换 Token...") + token_json = submit_callback_url( + callback_url=current_url, + expected_state=oauth_start.state, + code_verifier=oauth_start.code_verifier, + redirect_uri=oauth_start.redirect_uri, + proxy_url=self.proxy, + ) + return json.loads(token_json) + for hop in range(8): resp = self.session.get(current_url, allow_redirects=False, timeout=15) location = resp.headers.get("Location") or "" + self._log( + f"follow[{hop + 1}] status={resp.status_code} " + f"url={(current_url[:160] + '...') if len(current_url) > 160 else current_url} " + f"location={(location[:160] + '...') if len(location) > 160 else (location or '(空)')}" + ) if resp.status_code not in (301, 302, 303, 307, 308) or not location: break diff --git a/platforms/chatgpt/register_v2.py b/platforms/chatgpt/register_v2.py index 153647a..612d445 100644 --- a/platforms/chatgpt/register_v2.py +++ b/platforms/chatgpt/register_v2.py @@ -1,13 +1,8 @@ """ 注册流程引擎 V2 - -采用策略模式封装注册核心(OAuthPkceRegisterStrategy), -走 auth.openai.com OAuth PKCE 直通注册流程。 - -外部接口与 plugin.py 完全兼容,无需改动邮箱适配层。 +基于 curl_cffi 的注册状态机,注册成功后直接复用同一会话提取 ChatGPT Session。 """ -import random import time import logging from datetime import datetime @@ -16,211 +11,33 @@ from typing import Optional, Callable from core.base_platform import AccountStatus from platforms.chatgpt.register import RegistrationResult -from .oauth_pkce_client import OAuthPkceClient +from .chatgpt_client import ChatGPTClient from .utils import generate_random_name, generate_random_birthday logger = logging.getLogger(__name__) +class EmailServiceAdapter: + """\u5c06 V1 \u7684 email_service \u9002\u914d\u6210 V2 \u6240\u9700\u7684\u63a5\u7801\u63a5\u53e3\u3002""" + def __init__(self, email_service, email, log_fn): + self.es = email_service + self.email = email + self.log_fn = log_fn + self._used_codes = set() -# --------------------------------------------------------------------------- -# 名字/生日数据 -# --------------------------------------------------------------------------- - -_FIRST_NAMES = [ - "James", "John", "Robert", "Michael", "David", "William", "Richard", - "Joseph", "Thomas", "Charles", "Mary", "Patricia", "Jennifer", "Linda", - "Barbara", "Elizabeth", "Susan", "Jessica", "Sarah", "Karen", "Daniel", - "Matthew", "Anthony", "Mark", "Steven", "Andrew", "Paul", "Emily", - "Emma", "Olivia", "Sophia", "Ava", "Isabella", "Mia", -] -_LAST_NAMES = [ - "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", - "Davis", "Rodriguez", "Martinez", "Anderson", "Taylor", "Thomas", - "Wilson", "Moore", "Jackson", "Martin", "Lee", "Thompson", "White", -] - - -def _random_name_and_birthday() -> tuple[str, str]: - """随机生成全名和生日。""" - name = f"{random.choice(_FIRST_NAMES)} {random.choice(_LAST_NAMES)}" - year = random.randint(1985, 2004) - month = random.randint(1, 12) - day = random.randint(1, 28) - return name, f"{year}-{month:02d}-{day:02d}" - - -# --------------------------------------------------------------------------- -# 策略:OAuth PKCE 注册策略 -# --------------------------------------------------------------------------- - -class OAuthPkceRegisterStrategy: - """ - OAuth PKCE 注册策略(策略模式) - - 完整注册流程(12 步): - 1. 检查 IP 地区 - 2. 访问 OAuth 授权 URL,获取 oai-did Cookie - 3. 获取 Sentinel Token - 4. 提交邮箱 (authorize/continue) - 5. 提交密码 (user/register) - 6. 发送 OTP (email-otp/send) - 7. 验证 OTP (email-otp/validate) - 8. 创建账户 (create_account) - 9. 注册后重新 OAuth 登录 - 10. 解析 workspace_id - 11. 选择 workspace - 12. 跟踪重定向链,交换 OAuth code → access_token - """ - - def execute( - self, - client: OAuthPkceClient, - email_service, - email: str, - password: str, - log: Callable[[str], None], - ) -> RegistrationResult: - result = RegistrationResult(success=False) - - # ── 步骤 1:IP 检查 ────────────────────────────────────────────── - log("步骤 1/12: 检查 IP 地区...") - client.check_ip_region() - - # ── 步骤 2:创建邮箱 ──────────────────────────────────────────── - log("步骤 2/12: 创建邮箱接码订单...") - email_data = email_service.create_email() - email_addr = email or (email_data.get("email") if email_data else None) - if not email_addr: - result.error_message = "创建邮箱失败" - return result - result.email = email_addr - result.password = password - log(f"邮箱: {email_addr}") - - # ── 步骤 3:初始化 OAuth 会话 ──────────────────────────────────── - log("步骤 3/12: 访问 OAuth 授权 URL,获取 oai-did...") - client.init_oauth_session() - - # ── 步骤 4:获取 Sentinel Token ────────────────────────────────── - log("步骤 4/12: 获取 Sentinel Token...") - client.refresh_sentinel() - - # ── 步骤 5:提交邮箱 ──────────────────────────────────────────── - log("步骤 5/12: 提交邮箱...") - client.submit_email(email_addr) - - # ── 步骤 6:提交密码 ──────────────────────────────────────────── - log("步骤 6/12: 提交密码...") - continue_url = client.submit_password(email_addr, password) - - # ── 步骤 7:发送 OTP + 等待验证码 ──────────────────────────────── - log("步骤 7/12: 发送验证码...") - otp_sent_at = time.time() - client.send_otp(continue_url) - - log("步骤 7/12: 等待邮箱验证码(最多 120s)...") - otp_code = email_service.wait_for_verification_code( - email_addr, - timeout=120, - otp_sent_at=otp_sent_at, - ) - if not otp_code: - result.error_message = "未收到邮箱验证码" - return result - log(f"验证码: {otp_code}") - - # ── 步骤 8:验证 OTP ───────────────────────────────────────────── - log("步骤 8/12: 验证 OTP...") - client.validate_otp(otp_code) - - # ── 步骤 9:创建账户 ──────────────────────────────────────────── - log("步骤 9/12: 填写账户信息(姓名/生日)...") - name, birthdate = _random_name_and_birthday() - log(f"账户信息: {name} ({birthdate})") - client.create_account(name, birthdate) - - # ── 步骤 10:注册后重新 OAuth 登录 ─────────────────────────────── - log("步骤 10/12: 注册后重新 OAuth 登录...") - login_oauth = client.login_after_register(email_addr, password, otp_code) - - # ── 步骤 11:解析 workspace_id ─────────────────────────────────── - log("步骤 11/12: 解析 workspace_id...") - workspace_id = client.extract_workspace_id() - result.workspace_id = workspace_id - - # ── 步骤 12:选择 workspace → 交换 Token ───────────────────────── - log("步骤 12/12: 选择 workspace...") - ws_continue_url = client.select_workspace(workspace_id) - - log("步骤 12/12: 跟踪重定向链,交换 OAuth Token...") - token_data = client.follow_redirects_and_exchange_token(ws_continue_url, login_oauth) - - # ── 组装结果 ───────────────────────────────────────────────────── - result.success = True - result.access_token = token_data.get("access_token", "") - result.refresh_token = token_data.get("refresh_token", "") - result.id_token = token_data.get("id_token", "") - result.account_id = token_data.get("account_id", "") or workspace_id - result.metadata = { - "type": token_data.get("type", "codex"), - "expired": token_data.get("expired", ""), - } - - log("=" * 50) - log("注册流程成功完成!") - log("=" * 50) - return result - - -# --------------------------------------------------------------------------- -# Email Service 适配器(为 OAuthPkceClient 提供统一接码接口) -# --------------------------------------------------------------------------- - -class _EmailServiceAdapter: - """ - 将 V1 email_service(含 create_email / get_verification_code) - 适配为 OAuthPkceRegisterStrategy 期待的接口。 - - 接口: - - create_email() → {'email': str, ...} - - wait_for_verification_code(email, timeout, otp_sent_at) → str | None - """ - - def __init__(self, email_service, log: Callable[[str], None]): - self._svc = email_service - self._log = log - self._used_codes: set = set() - - def create_email(self, config=None): - return self._svc.create_email(config) - - def wait_for_verification_code( - self, email: str, timeout: int = 120, otp_sent_at=None - ) -> Optional[str]: - self._log(f"等待邮箱 {email} 的验证码(timeout={timeout}s)...") - code = self._svc.get_verification_code( - email=email, + def wait_for_verification_code(self, email, timeout=60, otp_sent_at=None, exclude_codes=None): + msg = f"\u6b63\u5728\u7b49\u5f85\u90ae\u7bb1 {email} \u7684\u9a8c\u8bc1\u7801 ({timeout}s)..." + self.log_fn(msg) + code = self.es.get_verification_code( timeout=timeout, otp_sent_at=otp_sent_at, - exclude_codes=self._used_codes, + exclude_codes=exclude_codes or self._used_codes, ) if code: self._used_codes.add(code) - self._log(f"成功获取验证码: {code}") + self.log_fn(f"\u6210\u529f\u83b7\u53d6\u9a8c\u8bc1\u7801: {code}") return code - -# --------------------------------------------------------------------------- -# 注册引擎(对外暴露给 plugin.py,接口完全向后兼容) -# --------------------------------------------------------------------------- - class RegistrationEngineV2: - """ - 注册引擎 V2(外部接口层) - - plugin.py 通过此类发起注册,不感知内部策略变化。 - """ - def __init__( self, email_service, @@ -238,12 +55,12 @@ class RegistrationEngineV2: self.task_uuid = task_uuid self.max_retries = max(1, int(max_retries or 1)) self.extra_config = dict(extra_config or {}) - - self.email: Optional[str] = None - self.password: Optional[str] = None - self.logs: list[str] = [] - - def _log(self, message: str, level: str = "info") -> None: + + self.email = None + self.password = None + self.logs = [] + + def _log(self, message: str, level: str = "info"): timestamp = datetime.now().strftime("%H:%M:%S") log_message = f"[{timestamp}] {message}" self.logs.append(log_message) @@ -254,63 +71,137 @@ class RegistrationEngineV2: else: logger.info(log_message) - def run(self) -> RegistrationResult: - """执行注册流程,支持整流程重试。""" - result = RegistrationResult(success=False, logs=self.logs) - last_error = "" - - for attempt in range(self.max_retries): - try: - if attempt > 0: - self._log(f"整流程重试 {attempt + 1}/{self.max_retries}...") - time.sleep(2) - - adapter = _EmailServiceAdapter(self.email_service, self._log) - client = OAuthPkceClient(proxy=self.proxy_url, log_fn=self._log) - strategy = OAuthPkceRegisterStrategy() - - result = strategy.execute( - client=client, - email_service=adapter, - email=self.email or "", - password=self.password or "AAb1234567890!", - log=self._log, - ) - result.logs = self.logs - - if result.success: - return result - - last_error = result.error_message or "注册失败" - self._log(f"注册失败: {last_error}", "error") - - if attempt < self.max_retries - 1 and self._should_retry(last_error): - self._log("准备重试...") - continue - - return result - - except Exception as e: - last_error = str(e) - self._log(f"注册异常: {last_error}", "error") - if attempt < self.max_retries - 1 and self._should_retry(last_error): - continue - result.error_message = last_error - result.logs = self.logs - return result - - result.error_message = last_error or "注册失败" - result.logs = self.logs - return result - - @staticmethod - def _should_retry(message: str) -> bool: - """判断是否值得重试。""" + def _should_retry(self, message: str) -> bool: text = str(message or "").lower() retriable_markers = [ - "tls", "ssl", "curl: (35)", - "ip 地区检查失败", "sentinel", - "timeout", "timed out", "connection", - "验证码", "otp", + "tls", + "ssl", + "curl: (35)", + "预授权被拦截", + "authorize", + "registration_disallowed", + "http 400", + "创建账号失败", + "未获取到 authorization code", + "consent", + "workspace", + "organization", + "otp", + "验证码", + "session", + "accessToken", + "next-auth", ] - return any(m in text for m in retriable_markers) + return any(marker.lower() in text for marker in retriable_markers) + + def run(self) -> RegistrationResult: + result = RegistrationResult(success=False, logs=self.logs) + try: + last_error = "" + for attempt in range(self.max_retries): + try: + if attempt == 0: + self._log("=" * 60) + self._log("开始注册流程 V2 (Session 复用直取 AccessToken)") + self._log(f"请求模式: {self.browser_mode}") + self._log("=" * 60) + else: + self._log(f"整流程重试 {attempt + 1}/{self.max_retries} ...") + time.sleep(1) + + # 1. 创建邮箱 + email_data = self.email_service.create_email() + email_addr = self.email or (email_data.get('email') if email_data else None) + if not email_addr: + result.error_message = "创建邮箱失败" + return result + + result.email = email_addr + + pwd = self.password or "AAb1234567890!" + result.password = pwd + + # 随机姓名、生日 + first_name, last_name = generate_random_name() + birthdate = generate_random_birthday() + + self._log(f"邮箱: {email_addr}, 密码: {pwd}") + self._log(f"注册信息: {first_name} {last_name}, 生日: {birthdate}") + + # 使用包装器为底层客户端提供接码服务 + skymail_adapter = EmailServiceAdapter(self.email_service, email_addr, self._log) + + # 2. 初始化 V2 客户端 + chatgpt_client = ChatGPTClient( + proxy=self.proxy_url, + verbose=False, + browser_mode=self.browser_mode, + ) + chatgpt_client._log = self._log + + self._log("步骤 1/2: 执行注册状态机...") + + success, msg = chatgpt_client.register_complete_flow( + email_addr, pwd, first_name, last_name, birthdate, skymail_adapter + ) + + if not success: + last_error = f"注册流失败: {msg}" + if attempt < self.max_retries - 1 and self._should_retry(msg): + self._log(f"注册流失败,准备整流程重试: {msg}") + continue + result.error_message = last_error + return result + + self._log("步骤 2/2: 复用注册会话,直接获取 ChatGPT Session / AccessToken...") + session_ok, session_result = chatgpt_client.reuse_session_and_get_tokens() + + if session_ok: + self._log("Token 提取完成!") + result.success = True + result.access_token = session_result.get("access_token", "") + result.session_token = session_result.get("session_token", "") + result.account_id = ( + session_result.get("account_id") + or session_result.get("user_id") + or ("v2_acct_" + chatgpt_client.device_id[:8]) + ) + result.workspace_id = session_result.get("workspace_id", "") + result.metadata = { + "auth_provider": session_result.get("auth_provider", ""), + "expires": session_result.get("expires", ""), + "user_id": session_result.get("user_id", ""), + "user": session_result.get("user") or {}, + "account": session_result.get("account") or {}, + } + + if result.workspace_id: + self._log(f"Session Workspace ID: {result.workspace_id}") + + self._log("=" * 60) + self._log("注册流程成功结束!") + self._log("=" * 60) + return result + + last_error = f"注册成功,但复用会话获取 AccessToken 失败: {session_result}" + if attempt < self.max_retries - 1: + self._log(f"{last_error},准备整流程重试") + continue + result.error_message = last_error + return result + except Exception as attempt_error: + last_error = str(attempt_error) + if attempt < self.max_retries - 1 and self._should_retry(last_error): + self._log(f"本轮出现异常,准备整流程重试: {last_error}") + continue + raise + + result.error_message = last_error or "注册失败" + return result + + except Exception as e: + self._log(f"V2 注册全流程执行异常: {e}", "error") + import traceback + traceback.print_exc() + result.error_message = str(e) + return result