Merge pull request #63 from tianheil3/disable-playwright-registration-mode

fix: fall back to HTTP registration when Playwright loses workspace_id
This commit is contained in:
演变
2026-03-22 20:10:41 +08:00
committed by GitHub
14 changed files with 2225 additions and 74 deletions

View File

@@ -236,6 +236,24 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
category=SettingCategory.REGISTRATION,
description="默认密码长度"
),
"registration_mode": SettingDefinition(
db_key="registration.mode",
default_value="http",
category=SettingCategory.REGISTRATION,
description="注册流程模式 (browser/http)"
),
"registration_browser_headless": SettingDefinition(
db_key="registration.browser_headless",
default_value=True,
category=SettingCategory.REGISTRATION,
description="浏览器注册是否启用无头模式"
),
"registration_browser_timeout": SettingDefinition(
db_key="registration.browser_timeout",
default_value=120,
category=SettingCategory.REGISTRATION,
description="浏览器注册超时时间(秒)"
),
"registration_sleep_min": SettingDefinition(
db_key="registration.sleep_min",
default_value=5,
@@ -398,6 +416,8 @@ SETTING_TYPES: Dict[str, Type] = {
"registration_max_retries": int,
"registration_timeout": int,
"registration_default_password_length": int,
"registration_browser_headless": bool,
"registration_browser_timeout": int,
"registration_sleep_min": int,
"registration_sleep_max": int,
"email_service_priority": dict,
@@ -661,6 +681,9 @@ class Settings(BaseModel):
registration_max_retries: int = 3
registration_timeout: int = 120
registration_default_password_length: int = 12
registration_mode: str = "http"
registration_browser_headless: bool = True
registration_browser_timeout: int = 120
registration_sleep_min: int = 5
registration_sleep_max: int = 30

View File

@@ -11,6 +11,7 @@ from .http_client import (
create_http_client,
create_openai_client,
)
from .register_browser import BrowserRegistrationArtifacts, BrowserRegistrationRunner
from .register import RegistrationEngine, RegistrationResult
from .utils import setup_logging, get_data_dir
@@ -25,6 +26,8 @@ __all__ = [
'RequestConfig',
'create_http_client',
'create_openai_client',
'BrowserRegistrationArtifacts',
'BrowserRegistrationRunner',
'RegistrationEngine',
'RegistrationResult',
'setup_logging',

View File

@@ -3,6 +3,7 @@
从 main.py 中提取并重构的注册流程
"""
import base64
import re
import json
import time
@@ -16,6 +17,7 @@ from datetime import datetime
from curl_cffi import requests as cffi_requests
from .openai.oauth import OAuthManager, OAuthStart
from .register_browser import BrowserRegistrationRunner
from .http_client import OpenAIHTTPClient, HTTPClientError
from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType
from ..database import crud
@@ -48,6 +50,7 @@ class RegistrationResult:
refresh_token: str = ""
id_token: str = ""
session_token: str = "" # 会话令牌
cookies: str = "" # 浏览器完整 Cookie 字符串
error_message: str = ""
logs: list = None
metadata: dict = None
@@ -65,6 +68,7 @@ class RegistrationResult:
"refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "",
"id_token": self.id_token[:20] + "..." if self.id_token else "",
"session_token": self.session_token[:20] + "..." if self.session_token else "",
"cookies": self.cookies[:20] + "..." if self.cookies else "",
"error_message": self.error_message,
"logs": self.logs or [],
"metadata": self.metadata or {},
@@ -93,7 +97,8 @@ class RegistrationEngine:
email_service: BaseEmailService,
proxy_url: Optional[str] = None,
callback_logger: Optional[Callable[[str], None]] = None,
task_uuid: Optional[str] = None
task_uuid: Optional[str] = None,
execution_mode: Optional[str] = None,
):
"""
初始化注册引擎
@@ -108,6 +113,7 @@ class RegistrationEngine:
self.proxy_url = proxy_url
self.callback_logger = callback_logger or (lambda msg: logger.info(msg))
self.task_uuid = task_uuid
self.execution_mode = (execution_mode or "").strip().lower() or None
# 创建 HTTP 客户端
self.http_client = OpenAIHTTPClient(proxy_url=proxy_url)
@@ -413,7 +419,10 @@ class RegistrationEngine:
except Exception as e:
logger.warning(f"标记邮箱状态失败: {e}")
def _send_verification_code(self) -> bool:
def _send_verification_code(
self,
referer: str = "https://auth.openai.com/create-account/password",
) -> bool:
"""发送验证码"""
try:
# 记录发送时间戳
@@ -422,7 +431,7 @@ class RegistrationEngine:
response = self.session.get(
OPENAI_API_ENDPOINTS["send_otp"],
headers={
"referer": "https://auth.openai.com/create-account/password",
"referer": referer,
"accept": "application/json",
},
)
@@ -513,48 +522,101 @@ class RegistrationEngine:
def _get_workspace_id(self) -> Optional[str]:
"""获取 Workspace ID"""
try:
auth_cookie = self.session.cookies.get("oai-client-auth-session")
if not auth_cookie:
cookie_names = (
"oai-client-auth-session",
"oai_client_auth_session",
"oai-client-auth-info",
"oai_client_auth_info",
)
found_cookie = False
for cookie_name in cookie_names:
auth_cookie = self.session.cookies.get(cookie_name)
if not auth_cookie:
continue
found_cookie = True
workspace_id = self._extract_workspace_id_from_cookie(auth_cookie)
if workspace_id:
self._log(f"Workspace ID: {workspace_id}")
return workspace_id
if not found_cookie:
self._log("未能获取到授权 Cookie", "error")
return None
# 解码 JWT
import base64
import json as json_module
try:
segments = auth_cookie.split(".")
if len(segments) < 1:
self._log("授权 Cookie 格式错误", "error")
return None
# 解码第一个 segment
payload = segments[0]
pad = "=" * ((4 - (len(payload) % 4)) % 4)
decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
auth_json = json_module.loads(decoded.decode("utf-8"))
workspaces = auth_json.get("workspaces") or []
if not workspaces:
self._log("授权 Cookie 里没有 workspace 信息", "error")
return None
workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
if not workspace_id:
self._log("无法解析 workspace_id", "error")
return None
self._log(f"Workspace ID: {workspace_id}")
return workspace_id
except Exception as e:
self._log(f"解析授权 Cookie 失败: {e}", "error")
return None
self._log("授权 Cookie 里没有 workspace 信息", "error")
return None
except Exception as e:
self._log(f"获取 Workspace ID 失败: {e}", "error")
return None
def _extract_workspace_id_from_cookie(self, cookie_value: str) -> Optional[str]:
"""从授权 Cookie 中提取 Workspace ID。"""
for auth_json in self._decode_cookie_json_candidates(cookie_value):
workspace_id = self._extract_workspace_id_from_auth_json(auth_json)
if workspace_id:
return workspace_id
return None
def _decode_cookie_json_candidates(self, cookie_value: str) -> list[Dict[str, Any]]:
"""尝试从完整 Cookie 或其分段中解码出 JSON。"""
decoded_objects = []
candidates = [cookie_value]
if "." in cookie_value:
candidates.extend(cookie_value.split("."))
for candidate in candidates:
raw = (candidate or "").strip()
if not raw:
continue
pad = "=" * ((4 - (len(raw) % 4)) % 4)
try:
decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii"))
except Exception:
continue
try:
payload = json.loads(decoded.decode("utf-8"))
except Exception:
continue
if isinstance(payload, dict):
decoded_objects.append(payload)
return decoded_objects
def _extract_workspace_id_from_auth_json(self, auth_json: Dict[str, Any]) -> Optional[str]:
"""从解码后的授权 JSON 中提取 Workspace ID。"""
workspaces = auth_json.get("workspaces") or []
if isinstance(workspaces, list):
for workspace in workspaces:
if not isinstance(workspace, dict):
continue
workspace_id = str(workspace.get("id") or "").strip()
if workspace_id:
return workspace_id
for key in ("workspace_id", "default_workspace_id", "active_workspace_id"):
workspace_id = str(auth_json.get(key) or "").strip()
if workspace_id:
return workspace_id
for key in ("workspace", "default_workspace", "active_workspace"):
workspace = auth_json.get(key)
if not isinstance(workspace, dict):
continue
workspace_id = str(workspace.get("id") or "").strip()
if workspace_id:
return workspace_id
return None
def _select_workspace(self, workspace_id: str) -> Optional[str]:
"""选择 Workspace"""
try:
@@ -586,6 +648,306 @@ class RegistrationEngine:
self._log(f"选择 Workspace 失败: {e}", "error")
return None
def _extract_workspace_id_from_html(self, html: str) -> Optional[str]:
if not html:
return None
patterns = [
r'name="workspace_id"[^>]*value="([^"]+)"',
r"name='workspace_id'[^>]*value='([^']+)'",
]
for pattern in patterns:
match = re.search(pattern, html)
if match:
workspace_id = str(match.group(1) or "").strip()
if workspace_id:
return workspace_id
return None
def _extract_hidden_input_value(self, html: str, input_name: str) -> Optional[str]:
if not html or not input_name:
return None
escaped = re.escape(input_name)
patterns = [
rf'name="{escaped}"[^>]*value="([^"]+)"',
rf"name='{escaped}'[^>]*value='([^']+)'",
]
for pattern in patterns:
match = re.search(pattern, html)
if match:
value = str(match.group(1) or "").strip()
if value:
return value
return None
def _extract_consent_verifier(self, url: str) -> Optional[str]:
if not url:
return None
import urllib.parse
parsed = urllib.parse.urlparse(url)
query = urllib.parse.parse_qs(parsed.query)
values = query.get("consent_verifier") or []
if values:
return str(values[0] or "").strip() or None
return None
def _try_reenter_login_flow(self) -> bool:
if not self.oauth_start:
return False
try:
did = self.session.cookies.get("oai-did") if self.session else None
sen_token = self._check_sentinel(did) if did else None
response = self.session.get(
self.oauth_start.auth_url,
timeout=15,
)
html = response.text or ""
if "/log-in/password" in str(getattr(response, "url", "") or "") or 'action="/log-in/password"' in html:
self._log("重新进入登录流程:检测到密码页")
return True
if "/log-in" in str(getattr(response, "url", "") or "") or 'action="/log-in"' in html:
login_data = {
"username": {
"kind": "email",
"value": self.email,
}
}
login_response = self.session.post(
"https://auth.openai.com/api/accounts/authorize/continue",
headers={
"referer": "https://auth.openai.com/log-in",
"accept": "application/json",
"content-type": "application/json",
**(
{
"openai-sentinel-token": json.dumps(
{
"p": "",
"t": "",
"c": sen_token,
"id": did,
"flow": "authorize_continue",
}
)
}
if sen_token and did
else {}
),
},
data=json.dumps(login_data),
timeout=15,
)
login_json = login_response.json() if login_response.status_code == 200 else {}
page_type = str((login_json or {}).get("page", {}).get("type") or "").strip()
continue_url = str((login_json or {}).get("continue_url") or "").strip()
if continue_url:
try:
self.session.get(continue_url, timeout=15)
except Exception:
pass
if login_response.status_code == 200 and page_type in {"password", "login_password"}:
self._log("重新进入登录流程:已推进到密码页")
return True
if login_response.status_code == 200 and "/log-in/password" in continue_url:
self._log("重新进入登录流程:已推进到密码页")
return True
return False
except Exception as e:
self._log(f"重新进入登录流程失败: {e}", "warning")
return False
def _submit_login_password_step(self) -> bool:
if not self.email or not self.password:
return False
try:
did = self.session.cookies.get("oai-did") if self.session else None
sen_token = self._check_sentinel(did) if did else None
response = self.session.post(
"https://auth.openai.com/api/accounts/password/verify",
headers={
"referer": "https://auth.openai.com/log-in/password",
"accept": "application/json",
"content-type": "application/json",
**(
{
"openai-sentinel-token": json.dumps(
{
"p": "",
"t": "",
"c": sen_token,
"id": did,
"flow": "password_verify",
}
)
}
if sen_token and did
else {}
),
},
data=json.dumps({
"password": self.password,
}),
timeout=15,
)
self._log(f"登录密码提交状态: {response.status_code}")
if response.status_code == 200:
try:
payload = response.json() or {}
except Exception:
payload = {}
continue_url = str(payload.get("continue_url") or "").strip()
if continue_url:
try:
self.session.get(continue_url, timeout=15)
except Exception:
pass
return response.status_code in (200, 302, 303)
except Exception as e:
self._log(f"登录密码提交失败: {e}", "warning")
return False
def _submit_login_password_step_and_get_continue_url(self) -> Tuple[bool, Optional[str]]:
if not self.email or not self.password:
return False, None
try:
did = self.session.cookies.get("oai-did") if self.session else None
sen_token = self._check_sentinel(did) if did else None
response = self.session.post(
"https://auth.openai.com/api/accounts/password/verify",
headers={
"referer": "https://auth.openai.com/log-in/password",
"accept": "application/json",
"content-type": "application/json",
**(
{
"openai-sentinel-token": json.dumps(
{
"p": "",
"t": "",
"c": sen_token,
"id": did,
"flow": "password_verify",
}
)
}
if sen_token and did
else {}
),
},
data=json.dumps({
"password": self.password,
}),
timeout=15,
)
self._log(f"登录密码提交状态: {response.status_code}")
if response.status_code not in (200, 302, 303):
return False, None
try:
payload = response.json() or {}
except Exception:
payload = {}
continue_url = str(payload.get("continue_url") or "").strip() or None
if continue_url:
try:
self.session.get(continue_url, timeout=15)
except Exception:
pass
return True, continue_url
except Exception as e:
self._log(f"登录密码提交失败: {e}", "warning")
return False, None
def _validate_verification_code_and_get_continue_url(self, code: str) -> Tuple[bool, Optional[str]]:
try:
code_body = f'{{"code":"{code}"}}'
response = self.session.post(
OPENAI_API_ENDPOINTS["validate_otp"],
headers={
"referer": "https://auth.openai.com/email-verification",
"accept": "application/json",
"content-type": "application/json",
},
data=code_body,
)
self._log(f"验证码校验状态: {response.status_code}")
if response.status_code != 200:
return False, None
try:
payload = response.json() or {}
except Exception:
payload = {}
continue_url = str(payload.get("continue_url") or "").strip() or None
return True, continue_url
except Exception as e:
self._log(f"验证验证码失败: {e}", "error")
return False, None
def _advance_login_authorization(self) -> Tuple[Optional[str], Optional[str]]:
if not self.oauth_start:
return None, None
if not self._init_session():
self._log("重新初始化登录会话失败", "warning")
return None, None
if not self._start_oauth():
self._log("重新开始 OAuth 登录流程失败", "warning")
return None, None
if not self._get_device_id():
self._log("重新登录流程获取 Device ID 失败", "warning")
return None, None
if not self._try_reenter_login_flow():
self._log("未能重新进入登录流程", "warning")
return None, None
password_ok, _ = self._submit_login_password_step_and_get_continue_url()
if not password_ok:
return None, None
code = self._get_verification_code()
if not code:
self._log("登录流程获取验证码失败", "warning")
return None, None
valid, consent_url = self._validate_verification_code_and_get_continue_url(code)
if not valid:
self._log("登录流程验证码校验失败", "warning")
return None, None
auth_target = consent_url or self.oauth_start.auth_url
auth_response = self.session.get(auth_target, timeout=20)
current_url = str(getattr(auth_response, "url", "") or "")
html = auth_response.text or ""
if "sign-in-with-chatgpt/codex/consent" in current_url or 'action="/sign-in-with-chatgpt/codex/consent"' in html:
workspace_id = self._extract_workspace_id_from_html(html)
if not workspace_id:
self._log("consent 页面缺少 workspace_id", "error")
return None, None
continue_url = self._select_workspace(workspace_id)
if not continue_url:
return None, None
callback_url = self._follow_redirects(continue_url)
return workspace_id, callback_url
return None, None
def _follow_redirects(self, start_url: str) -> Optional[str]:
"""跟随重定向链,寻找回调 URL"""
try:
@@ -651,6 +1013,172 @@ class RegistrationEngine:
self._log(f"处理 OAuth 回调失败: {e}", "error")
return None
def _is_browser_mode(self) -> bool:
return False
def _resolved_execution_mode(self) -> str:
return "curl_cffi"
def _run_browser_registration_flow(self):
settings = get_settings()
browser_password = self._generate_password()
user_info = generate_random_user_info()
runner = BrowserRegistrationRunner(
auth_url=self.oauth_start.auth_url,
redirect_uri=self.oauth_start.redirect_uri,
email=self.email,
email_service=self.email_service,
email_info=self.email_info,
password=browser_password,
user_info=user_info,
headless=bool(getattr(settings, "registration_browser_headless", True)),
timeout_seconds=int(getattr(settings, "registration_browser_timeout", 120)),
proxy_url=self.proxy_url,
logger_callback=self._log,
)
return runner.run()
def _extract_workspace_id_from_me_payload(self, payload: Dict[str, Any]) -> Optional[str]:
direct_keys = ("workspace_id", "default_workspace_id", "active_workspace_id", "organization_id")
for key in direct_keys:
value = str(payload.get(key) or "").strip()
if value:
return value
dict_keys = ("workspace", "default_workspace", "active_workspace", "org", "current_workspace")
for key in dict_keys:
nested = payload.get(key)
if not isinstance(nested, dict):
continue
for nested_key in ("id", "workspace_id", "organization_id"):
value = str(nested.get(nested_key) or "").strip()
if value:
return value
workspaces = payload.get("workspaces") or []
if isinstance(workspaces, list):
for workspace in workspaces:
if not isinstance(workspace, dict):
continue
for nested_key in ("id", "workspace_id", "organization_id"):
value = str(workspace.get(nested_key) or "").strip()
if value:
return value
orgs = payload.get("orgs") or {}
if isinstance(orgs, dict):
current = orgs.get("current")
if isinstance(current, dict):
value = str(current.get("id") or current.get("organization_id") or "").strip()
if value:
return value
data = orgs.get("data") or []
if isinstance(data, list):
for org in data:
if not isinstance(org, dict):
continue
for nested_key in ("id", "organization_id", "workspace_id"):
value = str(org.get(nested_key) or "").strip()
if value:
return value
return None
def _get_workspace_id_from_access_token(self, access_token: str) -> Optional[str]:
if not access_token:
return None
try:
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
}
proxies = None
if self.proxy_url:
proxies = {
"http": self.proxy_url,
"https": self.proxy_url,
}
response = cffi_requests.get(
"https://chatgpt.com/backend-api/me",
headers=headers,
proxies=proxies,
timeout=20,
impersonate="chrome110",
)
if response.status_code != 200:
self._log(f"通过 access_token 获取 workspace 失败: HTTP {response.status_code}", "warning")
return None
workspace_id = self._extract_workspace_id_from_me_payload(response.json() or {})
if workspace_id:
self._log(f"通过 access_token 获取 Workspace ID: {workspace_id}")
return workspace_id
self._log("access_token 响应里没有 workspace 信息", "warning")
return None
except Exception as e:
self._log(f"通过 access_token 获取 Workspace ID 失败: {e}", "warning")
return None
def _run_browser_mode(self, result: RegistrationResult) -> RegistrationResult:
self._log("5. 使用 Playwright 浏览器流程...")
artifacts = self._run_browser_registration_flow()
callback_url = str(getattr(artifacts, "callback_url", "") or "").strip()
if not callback_url:
result.error_message = "浏览器流程未捕获到 OAuth 回调 URL"
return result
self._is_existing_account = bool(getattr(artifacts, "is_existing_account", False))
result.cookies = str(getattr(artifacts, "cookies", "") or "")
session_token = str(getattr(artifacts, "session_token", "") or "").strip()
if session_token:
self.session_token = session_token
result.session_token = session_token
self._log("6. 处理 OAuth 回调...")
token_info = self._handle_oauth_callback(callback_url)
if not token_info:
result.error_message = "处理 OAuth 回调失败"
return result
result.account_id = token_info.get("account_id", "")
result.access_token = token_info.get("access_token", "")
result.refresh_token = token_info.get("refresh_token", "")
result.id_token = token_info.get("id_token", "")
result.password = "" if self._is_existing_account else str(getattr(artifacts, "password_used", "") or "")
result.source = "login" if self._is_existing_account else "register"
workspace_id = str(getattr(artifacts, "workspace_id", "") or "").strip()
if not workspace_id:
workspace_id = self._get_workspace_id_from_access_token(result.access_token)
if not workspace_id:
result.error_message = "获取 Workspace ID 失败"
return result
result.workspace_id = workspace_id
self._log("=" * 60)
self._log("浏览器注册流程成功!")
self._log(f"邮箱: {result.email}")
self._log(f"Account ID: {result.account_id}")
self._log(f"Workspace ID: {result.workspace_id}")
self._log("=" * 60)
result.success = True
result.metadata = {
"email_service": self.email_service.service_type.value,
"proxy_used": self.proxy_url,
"registered_at": datetime.now().isoformat(),
"is_existing_account": self._is_existing_account,
"registration_mode": self._resolved_execution_mode(),
}
return result
def run(self) -> RegistrationResult:
"""
执行完整的注册流程
@@ -700,6 +1228,9 @@ class RegistrationEngine:
result.error_message = "开始 OAuth 流程失败"
return result
if self._is_browser_mode():
return self._run_browser_mode(result)
# 5. 获取 Device ID
self._log("5. 获取 Device ID...")
did = self._get_device_id()
@@ -765,31 +1296,48 @@ class RegistrationEngine:
result.error_message = "创建用户账户失败"
return result
# 13. 获取 Workspace ID
self._log("13. 获取 Workspace ID...")
workspace_id = self._get_workspace_id()
if not workspace_id:
result.error_message = "获取 Workspace ID 失败"
return result
next_step = 13
callback_url = None
result.workspace_id = workspace_id
if not self._is_existing_account:
self._log(f"{next_step}. [新账号] 推进 Codex 授权流程...")
workspace_id, callback_url = self._advance_login_authorization()
if workspace_id and callback_url:
result.workspace_id = workspace_id
next_step += 1
# 14. 选择 Workspace
self._log("14. 选择 Workspace...")
continue_url = self._select_workspace(workspace_id)
if not continue_url:
result.error_message = "选择 Workspace 失败"
return result
if not result.workspace_id:
# 获取 Workspace ID
self._log(f"{next_step}. 获取 Workspace ID...")
workspace_id = self._get_workspace_id()
if not workspace_id:
result.error_message = "获取 Workspace ID 失败"
return result
# 15. 跟随重定向链
self._log("15. 跟随重定向链...")
callback_url = self._follow_redirects(continue_url)
if not callback_url:
result.error_message = "跟随重定向链失败"
return result
result.workspace_id = workspace_id
# 16. 处理 OAuth 回调
self._log("16. 处理 OAuth 回调...")
next_step += 1
# 选择 Workspace
self._log(f"{next_step}. 选择 Workspace...")
continue_url = self._select_workspace(result.workspace_id)
if not continue_url:
result.error_message = "选择 Workspace 失败"
return result
next_step += 1
# 跟随重定向链
self._log(f"{next_step}. 跟随重定向链...")
callback_url = self._follow_redirects(continue_url)
if not callback_url:
result.error_message = "跟随重定向链失败"
return result
next_step += 1
# 处理 OAuth 回调
self._log(f"{next_step}. 处理 OAuth 回调...")
token_info = self._handle_oauth_callback(callback_url)
if not token_info:
result.error_message = "处理 OAuth 回调失败"
@@ -829,6 +1377,7 @@ class RegistrationEngine:
"proxy_used": self.proxy_url,
"registered_at": datetime.now().isoformat(),
"is_existing_account": self._is_existing_account,
"registration_mode": self._resolved_execution_mode(),
}
return result
@@ -870,6 +1419,7 @@ class RegistrationEngine:
access_token=result.access_token,
refresh_token=result.refresh_token,
id_token=result.id_token,
cookies=result.cookies,
proxy_used=self.proxy_url,
extra_data=result.metadata,
source=result.source

View File

@@ -0,0 +1,502 @@
"""
Browser-based OpenAI registration flow driven by Playwright.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional
from urllib.parse import urlparse
from ..services.base import BaseEmailService
logger = logging.getLogger(__name__)
@dataclass
class BrowserRegistrationArtifacts:
callback_url: str
session_token: str = ""
cookies: str = ""
workspace_id: str = ""
is_existing_account: bool = True
password_used: str = ""
class BrowserRegistrationRunner:
def __init__(
self,
*,
auth_url: str,
redirect_uri: str,
email: str,
email_service: BaseEmailService,
email_info: Optional[Dict[str, Any]],
password: str,
user_info: Dict[str, Any],
headless: bool = True,
timeout_seconds: int = 120,
proxy_url: Optional[str] = None,
logger_callback: Optional[Callable[[str], None]] = None,
):
self.auth_url = auth_url
self.redirect_uri = redirect_uri
self.email = email
self.email_service = email_service
self.email_info = email_info or {}
self.password = password
self.user_info = user_info
self.headless = headless
self.timeout_seconds = timeout_seconds
self.proxy_url = proxy_url
self.logger_callback = logger_callback or (lambda message: logger.info(message))
def run(self) -> BrowserRegistrationArtifacts:
try:
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
except ImportError as exc:
raise RuntimeError("Playwright is not installed in the current environment") from exc
with sync_playwright() as playwright:
try:
return self._run_once(playwright, headless=self.headless)
except RuntimeError as exc:
if self.headless and "security verification page" in str(exc).lower():
self._log("浏览器流程: headless 命中安全验证页,回退到有界面 Chrome 重试")
return self._run_once(playwright, headless=False)
raise
def _run_once(self, playwright, *, headless: bool) -> BrowserRegistrationArtifacts:
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
captured_callback = {"url": ""}
launch_options = {
"headless": headless,
"channel": "chrome",
"args": ["--disable-blink-features=AutomationControlled"],
}
proxy = self._playwright_proxy()
if proxy:
launch_options["proxy"] = proxy
browser = playwright.chromium.launch(**launch_options)
context = browser.new_context(ignore_https_errors=True)
context.set_default_timeout(self.timeout_seconds * 1000)
context.on("request", lambda request: self._capture_callback(request.url, captured_callback))
page = context.new_page()
try:
self._log("浏览器流程: 打开 OpenAI OAuth 页面")
page.goto(self.auth_url, wait_until="domcontentloaded")
self._wait_for_login_page(page, timeout_ms=20_000)
self._maybe_switch_to_signup_page(page)
self._fill_email_step(page)
self._click_primary(page)
artifacts = BrowserRegistrationArtifacts(callback_url="")
next_stage = self._wait_for_post_email_stage(page, timeout_ms=15_000)
if next_stage == "callback":
artifacts.callback_url = captured_callback["url"] or page.url
return self._finalize_artifacts(context, artifacts)
if next_stage == "password":
self._log("浏览器流程: 检测到密码步骤")
self._fill_first(page, self._password_selectors(), self.password)
artifacts.password_used = self.password
self._click_primary(page)
page.wait_for_timeout(2000)
if next_stage in {"password", "otp"}:
self._complete_otp_step(page)
if self._wait_for_callback(page, captured_callback, timeout_ms=6_000):
artifacts.callback_url = captured_callback["url"]
return self._finalize_artifacts(context, artifacts)
if next_stage == "profile" or self._is_visible(page, self._profile_selectors()):
artifacts.is_existing_account = False
self._complete_profile_step(page)
self._click_primary(page)
self._handle_post_profile_stage(page, captured_callback)
artifacts.callback_url = captured_callback["url"]
return self._finalize_artifacts(context, artifacts)
except PlaywrightTimeoutError as exc:
raise RuntimeError(f"Playwright registration timeout: {exc}") from exc
finally:
browser.close()
def _playwright_proxy(self) -> Optional[Dict[str, str]]:
if not self.proxy_url:
return None
parsed = urlparse(self.proxy_url)
if not parsed.scheme or not parsed.hostname or not parsed.port:
return None
proxy = {"server": f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"}
if parsed.username:
proxy["username"] = parsed.username
if parsed.password:
proxy["password"] = parsed.password
return proxy
def _capture_callback(self, url: str, store: Dict[str, str]) -> None:
if self._is_callback_url(url):
store["url"] = url
def _is_callback_url(self, url: str) -> bool:
return bool(url and url.startswith(self.redirect_uri) and "code=" in url and "state=" in url)
def _wait_for_callback(self, page, store: Dict[str, str], timeout_ms: int) -> bool:
deadline = time.time() + (timeout_ms / 1000)
while time.time() < deadline:
current_url = page.url or ""
if self._is_callback_url(current_url):
store["url"] = current_url
return True
if store.get("url"):
return True
page.wait_for_timeout(250)
return bool(store.get("url"))
def _handle_post_profile_stage(self, page, store: Dict[str, str]) -> None:
if self._wait_for_callback(page, store, timeout_ms=8_000):
return
if self._is_add_phone_page(page):
self._log("浏览器流程: 命中 add-phone切换到重新登录流程")
self._restart_login_flow(page, store)
return
if self._is_consent_page(page):
self._log("浏览器流程: 检测到 consent 页面,继续授权")
self._click_primary(page)
if self._wait_for_callback(page, store, timeout_ms=15_000):
return
self._wait_for_callback(page, store, timeout_ms=self.timeout_seconds * 1000)
def _restart_login_flow(self, page, store: Dict[str, str]) -> None:
page.goto(self.auth_url, wait_until="domcontentloaded")
self._wait_for_login_page(page, timeout_ms=20_000)
self._fill_email_step(page)
self._click_primary(page)
next_stage = self._wait_for_post_email_stage(page, timeout_ms=15_000)
if next_stage == "callback":
store["url"] = store.get("url") or page.url
return
if next_stage == "password":
self._log("浏览器流程: 重新登录时检测到密码步骤")
self._fill_first(page, self._password_selectors(), self.password)
self._click_primary(page)
page.wait_for_timeout(2_000)
if next_stage in {"password", "otp"}:
self._complete_otp_step(page)
if self._wait_for_callback(page, store, timeout_ms=8_000):
return
if self._is_consent_page(page):
self._log("浏览器流程: 重新登录后进入 consent 页面,继续授权")
self._click_primary(page)
if self._wait_for_callback(page, store, timeout_ms=15_000):
return
self._wait_for_callback(page, store, timeout_ms=self.timeout_seconds * 1000)
def _session_ended_selectors(self):
return [
"a[href*='login_with']",
"a:has-text('登录')",
"button:has-text('登录')",
]
def _signup_selectors(self):
return [
"a[href='/create-account']",
"a[href*='create-account']",
"a:has-text('注册')",
"button:has-text('注册')",
]
def _wait_for_login_page(self, page, timeout_ms: int) -> None:
deadline = time.time() + (timeout_ms / 1000)
clicked_session_link = False
while time.time() < deadline:
if self._is_visible(page, self._email_selectors()):
return
if self._is_visible(page, self._session_ended_selectors()):
if not clicked_session_link:
self._log("浏览器流程: 检测到会话已结束页,跳转到登录表单")
self._click_first_visible(page, self._session_ended_selectors())
clicked_session_link = True
page.wait_for_timeout(2_000)
continue
page.wait_for_timeout(250)
current_url = getattr(page, "url", "")
body_text = self._safe_body_text(page)
if self._should_retry_headed(current_url, body_text):
raise RuntimeError(
"Hit Cloudflare security verification page while waiting for login email page "
f"(url={current_url})"
)
raise RuntimeError(
"Could not reach the login email page after opening OAuth URL "
f"(url={current_url})"
)
def _wait_for_post_email_stage(self, page, timeout_ms: int) -> str:
deadline = time.time() + (timeout_ms / 1000)
while time.time() < deadline:
if self._is_callback_url(getattr(page, "url", "") or ""):
return "callback"
if self._is_visible(page, self._password_selectors()):
return "password"
if self._is_visible(page, self._profile_selectors()):
return "profile"
if self._is_visible(page, self._otp_selectors()):
return "otp"
page.wait_for_timeout(250)
current_url = getattr(page, "url", "")
raise RuntimeError(
"Could not determine post-email registration stage "
f"(url={current_url})"
)
def _maybe_switch_to_signup_page(self, page) -> None:
current_url = str(getattr(page, "url", "") or "")
if "/log-in" not in current_url:
return
if not self._is_visible(page, self._signup_selectors()):
return
self._log("浏览器流程: 从登录页切换到注册页")
if self._click_first_visible(page, self._signup_selectors()):
page.wait_for_timeout(2_000)
def _safe_body_text(self, page) -> str:
try:
return page.locator("body").inner_text(timeout=5_000)
except Exception:
return ""
def _is_add_phone_page(self, page) -> bool:
current_url = str(getattr(page, "url", "") or "")
return "/add-phone" in current_url
def _is_consent_page(self, page) -> bool:
current_url = str(getattr(page, "url", "") or "")
return "/sign-in-with-chatgpt/" in current_url
def _should_retry_headed(self, current_url: str, body_text: str) -> bool:
url = str(current_url or "").lower()
text = str(body_text or "").lower()
return (
"api/oauth/oauth2/auth" in url
and (
"执行安全验证" in body_text
or "please wait" in text
or "security verification" in text
or "cloudflare" in text
)
)
def _fill_email_step(self, page) -> None:
self._log(f"浏览器流程: 输入邮箱 {self.email}")
self._fill_first(page, self._email_selectors(), self.email)
def _complete_otp_step(self, page) -> None:
self._log("浏览器流程: 获取并填写邮箱验证码")
code = self.email_service.get_verification_code(
email=self.email,
email_id=self.email_info.get("service_id"),
timeout=self.timeout_seconds,
)
if not code:
raise RuntimeError("Email verification code was not received")
if self._is_visible(page, ["input[autocomplete='one-time-code']", "input[inputmode='numeric']"]):
otp_inputs = page.locator("input[autocomplete='one-time-code'], input[inputmode='numeric']")
count = otp_inputs.count()
if count >= len(code):
for index, digit in enumerate(code):
otp_inputs.nth(index).fill(digit)
else:
otp_inputs.first.fill(code)
else:
self._fill_first(page, ["input[type='tel']", "input[type='text']", "input[type='number']"], code)
self._click_primary(page)
def _complete_profile_step(self, page) -> None:
self._log("浏览器流程: 填写 about-you 信息")
name = str(self.user_info.get("name") or "").strip()
birthdate = str(self.user_info.get("birthdate") or "").strip()
self._fill_first(page, self._profile_selectors(), name)
if birthdate:
year, month, day = birthdate.split("-")
if self._fill_if_visible(page, self._birthday_segment_selectors("year"), year):
self._fill_if_visible(page, self._birthday_segment_selectors("month"), month)
self._fill_if_visible(page, self._birthday_segment_selectors("day"), day)
return
if self._set_input_value(page, ["input[name='birthday']"], birthdate):
return
if self._is_visible(page, ["input[type='date']"]):
self._fill_first(page, ["input[type='date']"], birthdate)
return
if birthdate:
self._select_if_visible(page, ["select[name='month']", "select[aria-label*='Month']"], str(int(month)))
self._select_if_visible(page, ["select[name='day']", "select[aria-label*='Day']"], str(int(day)))
self._select_if_visible(page, ["select[name='year']", "select[aria-label*='Year']"], year)
def _finalize_artifacts(self, context, artifacts: BrowserRegistrationArtifacts) -> BrowserRegistrationArtifacts:
cookies = context.cookies()
artifacts.cookies = self._serialize_cookies(cookies)
artifacts.session_token = self._extract_cookie(cookies, "__Secure-next-auth.session-token")
return artifacts
def _serialize_cookies(self, cookies) -> str:
parts = []
for cookie in cookies:
name = str(cookie.get("name") or "").strip()
value = str(cookie.get("value") or "").strip()
if name:
parts.append(f"{name}={value}")
return "; ".join(parts)
def _extract_cookie(self, cookies, cookie_name: str) -> str:
for cookie in cookies:
if cookie.get("name") == cookie_name:
return str(cookie.get("value") or "")
return ""
def _click_primary(self, page) -> None:
selectors = [
"button[type='submit']",
"[data-testid='continue-button']",
"button:has-text('Continue')",
"button:has-text('Next')",
"button:has-text('Verify')",
"button:has-text('Submit')",
"button:has-text('继续')",
"button:has-text('下一步')",
"button:has-text('验证')",
"button:has-text('登录')",
]
if self._click_first_visible(page, selectors):
return
raise RuntimeError("Could not find a primary action button in the browser flow")
def _click_first_visible(self, page, selectors) -> bool:
for selector in selectors:
locator = page.locator(selector)
if locator.count() and locator.first.is_visible():
locator.first.click()
return True
return False
def _fill_first(self, page, selectors, value: str) -> None:
for selector in selectors:
locator = page.locator(selector)
if locator.count() and locator.first.is_visible():
locator.first.fill(value)
return
raise RuntimeError(f"Could not find an input for selectors: {selectors}")
def _fill_if_visible(self, page, selectors, value: str) -> bool:
for selector in selectors:
locator = page.locator(selector)
if locator.count() and locator.first.is_visible():
locator.first.fill(value)
return True
return False
def _set_input_value(self, page, selectors, value: str) -> bool:
for selector in selectors:
locator = page.locator(selector)
if locator.count():
locator.first.evaluate(
"""(el, nextValue) => {
el.value = nextValue;
el.setAttribute('value', nextValue);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}""",
value,
)
return True
return False
def _select_if_visible(self, page, selectors, value: str) -> bool:
for selector in selectors:
locator = page.locator(selector)
if locator.count() and locator.first.is_visible():
locator.first.select_option(value=value)
return True
return False
def _is_visible(self, page, selectors) -> bool:
for selector in selectors:
locator = page.locator(selector)
if locator.count() and locator.first.is_visible():
return True
return False
def _email_selectors(self):
return [
"input[type='email']",
"input[name='username']",
"input[autocomplete='username']",
"input[inputmode='email']",
"input[name='email']",
]
def _password_selectors(self):
return [
"input[type='password']",
"input[name='password']",
"input[autocomplete='new-password']",
"input[autocomplete='current-password']",
]
def _otp_selectors(self):
return [
"input[autocomplete='one-time-code']",
"input[inputmode='numeric']",
"input[type='tel']",
"input[type='number']",
]
def _profile_selectors(self):
return [
"input[name='name']",
"input[autocomplete='given-name']",
"input[placeholder*='name']",
]
def _birthday_segment_selectors(self, segment: str):
return [
f"[role='spinbutton'][data-type='{segment}']",
]
def _log(self, message: str) -> None:
self.logger_callback(message)

View File

@@ -27,6 +27,7 @@ def create_account(
access_token: Optional[str] = None,
refresh_token: Optional[str] = None,
id_token: Optional[str] = None,
cookies: Optional[str] = None,
proxy_used: Optional[str] = None,
expires_at: Optional['datetime'] = None,
extra_data: Optional[Dict[str, Any]] = None,
@@ -46,6 +47,7 @@ def create_account(
access_token=access_token,
refresh_token=refresh_token,
id_token=id_token,
cookies=cookies,
proxy_used=proxy_used,
expires_at=expires_at,
extra_data=extra_data or {},
@@ -713,4 +715,4 @@ def delete_tm_service(db: Session, service_id: int) -> bool:
return False
db.delete(svc)
db.commit()
return True
return True

181
src/services/fivesim.py Normal file
View File

@@ -0,0 +1,181 @@
"""
5SIM user API client.
"""
import logging
import time
from typing import Any, Dict, List, Optional, Union
from ..core.http_client import HTTPClient, RequestConfig
logger = logging.getLogger(__name__)
class FiveSimError(Exception):
"""Raised when a 5SIM API request fails."""
class FiveSimClient:
"""Thin wrapper around the 5SIM user API."""
def __init__(
self,
api_token: str,
base_url: str = "https://5sim.net",
timeout: int = 30,
max_retries: int = 3,
proxy_url: Optional[str] = None,
) -> None:
if not str(api_token or "").strip():
raise ValueError("api_token is required")
self.api_token = str(api_token).strip()
self.base_url = str(base_url).rstrip("/")
self.http_client = HTTPClient(
proxy_url=proxy_url,
config=RequestConfig(timeout=timeout, max_retries=max_retries),
)
def _headers(self) -> Dict[str, str]:
return {
"Accept": "application/json",
"Authorization": f"Bearer {self.api_token}",
}
def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
url = f"{self.base_url}{path}"
response = self.http_client.request(
method,
url,
headers=self._headers(),
params=params,
)
if response.status_code >= 400:
raise FiveSimError(self._format_error(response))
try:
return response.json()
except Exception as exc:
raise FiveSimError(f"Failed to decode 5SIM response from {path}: {exc}") from exc
def _format_error(self, response: Any) -> str:
prefix = f"5SIM API request failed with status {response.status_code}"
try:
payload = response.json()
except Exception:
text = str(getattr(response, "text", "") or "").strip()
return f"{prefix}: {text or 'unknown error'}"
if isinstance(payload, dict):
message = payload.get("message") or payload.get("error") or payload.get("detail")
if message:
return f"{prefix}: {message}"
return f"{prefix}: {payload}"
def get_countries(self) -> Dict[str, Any]:
return self._request("GET", "/v1/guest/countries")
def get_products(self, country: str, operator: str) -> Dict[str, Any]:
return self._request("GET", f"/v1/guest/products/{country}/{operator}")
def get_prices(
self,
country: Optional[str] = None,
product: Optional[str] = None,
) -> Dict[str, Any]:
params: Dict[str, Any] = {}
if country:
params["country"] = country
if product:
params["product"] = product
return self._request("GET", "/v1/guest/prices", params=params or None)
def buy_activation(
self,
country: str,
operator: str,
product: str,
*,
forwarding: Optional[bool] = None,
number: Optional[str] = None,
reuse: bool = False,
voice: bool = False,
ref: Optional[str] = None,
max_price: Optional[float] = None,
) -> Dict[str, Any]:
params: Dict[str, Any] = {}
if forwarding is not None:
params["forwarding"] = 1 if forwarding else 0
if number:
params["number"] = number
if reuse:
params["reuse"] = 1
if voice:
params["voice"] = 1
if ref:
params["ref"] = ref
if max_price is not None:
params["maxPrice"] = max_price
return self._request(
"GET",
f"/v1/user/buy/activation/{country}/{operator}/{product}",
params=params or None,
)
def check_order(self, order_id: Union[int, str]) -> Dict[str, Any]:
return self._request("GET", f"/v1/user/check/{order_id}")
def finish_order(self, order_id: Union[int, str]) -> Dict[str, Any]:
return self._request("GET", f"/v1/user/finish/{order_id}")
def cancel_order(self, order_id: Union[int, str]) -> Dict[str, Any]:
return self._request("GET", f"/v1/user/cancel/{order_id}")
def ban_order(self, order_id: Union[int, str]) -> Dict[str, Any]:
return self._request("GET", f"/v1/user/ban/{order_id}")
def extract_codes(self, order: Dict[str, Any]) -> List[str]:
codes: List[str] = []
for sms in order.get("sms", []) or []:
code = sms.get("code")
if code:
codes.append(str(code))
return codes
def get_latest_code(self, order: Dict[str, Any]) -> Optional[str]:
codes = self.extract_codes(order)
if not codes:
return None
return codes[-1]
def wait_for_code(
self,
order_id: Union[int, str],
*,
timeout: int = 300,
poll_interval: float = 3.0,
finish_on_success: bool = False,
) -> Optional[str]:
started_at = time.time()
while time.time() - started_at < timeout:
order = self.check_order(order_id)
code = self.get_latest_code(order)
if code:
if finish_on_success:
self.finish_order(order_id)
return code
time.sleep(poll_interval)
logger.info("Timed out waiting for 5SIM code for order %s", order_id)
return None

View File

@@ -23,6 +23,8 @@ from ..task_manager import task_manager
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_EXECUTION_MODES = {"curl_cffi", "playwright"}
# 任务存储(简单的内存存储,生产环境应使用 Redis
running_tasks: dict = {}
# 批量任务存储
@@ -68,6 +70,7 @@ def update_proxy_usage(db, proxy_id: Optional[int]):
class RegistrationTaskCreate(BaseModel):
"""创建注册任务请求"""
email_service_type: str = "tempmail"
execution_mode: str = "curl_cffi"
proxy: Optional[str] = None
email_service_config: Optional[dict] = None
email_service_id: Optional[int] = None
@@ -83,6 +86,7 @@ class BatchRegistrationRequest(BaseModel):
"""批量注册请求"""
count: int = 1
email_service_type: str = "tempmail"
execution_mode: str = "curl_cffi"
proxy: Optional[str] = None
email_service_config: Optional[dict] = None
email_service_id: Optional[int] = None
@@ -152,6 +156,7 @@ class OutlookAccountsListResponse(BaseModel):
class OutlookBatchRegistrationRequest(BaseModel):
"""Outlook 批量注册请求"""
service_ids: List[int]
execution_mode: str = "curl_cffi"
skip_registered: bool = True
proxy: Optional[str] = None
interval_min: int = 5
@@ -221,7 +226,17 @@ def _normalize_email_service_config(
return normalized
def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None):
def _validate_execution_mode(execution_mode: str) -> str:
mode = (execution_mode or "curl_cffi").strip().lower()
if mode == "playwright":
logger.warning("Playwright execution mode is disabled; falling back to curl_cffi")
return "curl_cffi"
if mode not in ALLOWED_EXECUTION_MODES:
raise HTTPException(status_code=400, detail=f"无效的执行方式: {execution_mode}")
return mode
def _run_sync_registration_task(task_uuid: str, email_service_type: str, execution_mode: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None):
"""
在线程池中执行的同步注册任务
@@ -398,7 +413,8 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
email_service=email_service,
proxy_url=actual_proxy_url,
callback_logger=log_callback,
task_uuid=task_uuid
task_uuid=task_uuid,
execution_mode=execution_mode,
)
# 执行注册
@@ -541,7 +557,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
pass
async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None):
async def run_registration_task(task_uuid: str, email_service_type: str, execution_mode: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None):
"""
异步执行注册任务
@@ -563,6 +579,7 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy:
_run_sync_registration_task,
task_uuid,
email_service_type,
execution_mode,
proxy,
email_service_config,
email_service_id,
@@ -616,6 +633,7 @@ async def run_batch_parallel(
batch_id: str,
task_uuids: List[str],
email_service_type: str,
execution_mode: str,
proxy: Optional[str],
email_service_config: Optional[dict],
email_service_id: Optional[int],
@@ -640,7 +658,7 @@ async def run_batch_parallel(
prefix = f"[任务{idx + 1}]"
async with semaphore:
await run_registration_task(
uuid, email_service_type, proxy, email_service_config, email_service_id,
uuid, email_service_type, execution_mode, proxy, email_service_config, email_service_id,
log_prefix=prefix, batch_id=batch_id,
auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [],
auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [],
@@ -680,6 +698,7 @@ async def run_batch_pipeline(
batch_id: str,
task_uuids: List[str],
email_service_type: str,
execution_mode: str,
proxy: Optional[str],
email_service_config: Optional[dict],
email_service_id: Optional[int],
@@ -706,7 +725,7 @@ async def run_batch_pipeline(
async def _run_and_release(idx: int, uuid: str, pfx: str):
try:
await run_registration_task(
uuid, email_service_type, proxy, email_service_config, email_service_id,
uuid, email_service_type, execution_mode, proxy, email_service_config, email_service_id,
log_prefix=pfx, batch_id=batch_id,
auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [],
auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [],
@@ -769,6 +788,7 @@ async def run_batch_registration(
batch_id: str,
task_uuids: List[str],
email_service_type: str,
execution_mode: str,
proxy: Optional[str],
email_service_config: Optional[dict],
email_service_id: Optional[int],
@@ -786,7 +806,7 @@ async def run_batch_registration(
"""根据 mode 分发到并行或流水线执行"""
if mode == "parallel":
await run_batch_parallel(
batch_id, task_uuids, email_service_type, proxy,
batch_id, task_uuids, email_service_type, execution_mode, proxy,
email_service_config, email_service_id, concurrency,
auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids,
auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids,
@@ -794,7 +814,7 @@ async def run_batch_registration(
)
else:
await run_batch_pipeline(
batch_id, task_uuids, email_service_type, proxy,
batch_id, task_uuids, email_service_type, execution_mode, proxy,
email_service_config, email_service_id,
interval_min, interval_max, concurrency,
auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids,
@@ -825,6 +845,7 @@ async def start_registration(
status_code=400,
detail=f"无效的邮箱服务类型: {request.email_service_type}"
)
execution_mode = _validate_execution_mode(request.execution_mode)
# 创建任务
task_uuid = str(uuid.uuid4())
@@ -841,6 +862,7 @@ async def start_registration(
run_registration_task,
task_uuid,
request.email_service_type,
execution_mode,
request.proxy,
request.email_service_config,
request.email_service_id,
@@ -882,6 +904,7 @@ async def start_batch_registration(
status_code=400,
detail=f"无效的邮箱服务类型: {request.email_service_type}"
)
execution_mode = _validate_execution_mode(request.execution_mode)
if request.interval_min < 0 or request.interval_max < request.interval_min:
raise HTTPException(status_code=400, detail="间隔时间参数无效")
@@ -916,6 +939,7 @@ async def start_batch_registration(
batch_id,
task_uuids,
request.email_service_type,
execution_mode,
request.proxy,
request.email_service_config,
request.email_service_id,
@@ -1321,6 +1345,7 @@ async def get_outlook_accounts_for_registration():
async def run_outlook_batch_registration(
batch_id: str,
service_ids: List[int],
execution_mode: str,
skip_registered: bool,
proxy: Optional[str],
interval_min: int,
@@ -1363,6 +1388,7 @@ async def run_outlook_batch_registration(
batch_id=batch_id,
task_uuids=task_uuids,
email_service_type="outlook",
execution_mode=execution_mode,
proxy=proxy,
email_service_config=None,
email_service_id=None, # 每个任务已绑定了独立的 email_service_id
@@ -1408,6 +1434,7 @@ async def start_outlook_batch_registration(
if request.mode not in ("parallel", "pipeline"):
raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline")
execution_mode = _validate_execution_mode(request.execution_mode)
# 过滤掉已注册的邮箱
actual_service_ids = request.service_ids
@@ -1468,6 +1495,7 @@ async def start_outlook_batch_registration(
run_outlook_batch_registration,
batch_id,
actual_service_ids,
execution_mode,
request.skip_registered,
request.proxy,
request.interval_min,

View File

@@ -26,6 +26,8 @@ let availableServices = {
duck_mail: { available: false, services: [] },
freemail: { available: false, services: [] }
};
const EXECUTION_MODE_STORAGE_KEY = 'registrationExecutionMode';
const ENABLED_EXECUTION_MODE = 'curl_cffi';
// WebSocket 相关变量
let webSocket = null;
@@ -40,6 +42,7 @@ let activeBatchId = null; // 当前活跃的批量任务 ID用于页面重
const elements = {
form: document.getElementById('registration-form'),
emailService: document.getElementById('email-service'),
executionMode: document.getElementById('execution-mode'),
regMode: document.getElementById('reg-mode'),
regModeGroup: document.getElementById('reg-mode-group'),
batchCountGroup: document.getElementById('batch-count-group'),
@@ -99,6 +102,7 @@ const elements = {
// 初始化
document.addEventListener('DOMContentLoaded', () => {
initEventListeners();
restoreExecutionMode();
loadAvailableServices();
loadRecentAccounts();
startAccountsPolling();
@@ -196,6 +200,10 @@ function initEventListeners() {
// 邮箱服务切换
elements.emailService.addEventListener('change', handleServiceChange);
if (elements.executionMode) {
elements.executionMode.addEventListener('change', handleExecutionModeChange);
}
// 取消按钮
elements.cancelBtn.addEventListener('click', handleCancelTask);
@@ -220,6 +228,28 @@ function initEventListeners() {
});
}
function restoreExecutionMode() {
if (!elements.executionMode) return;
const savedMode = localStorage.getItem(EXECUTION_MODE_STORAGE_KEY);
const normalizedMode = savedMode === ENABLED_EXECUTION_MODE ? savedMode : ENABLED_EXECUTION_MODE;
elements.executionMode.value = normalizedMode;
localStorage.setItem(EXECUTION_MODE_STORAGE_KEY, normalizedMode);
}
function handleExecutionModeChange(e) {
const normalizedMode = e.target.value === ENABLED_EXECUTION_MODE ? e.target.value : ENABLED_EXECUTION_MODE;
e.target.value = normalizedMode;
localStorage.setItem(EXECUTION_MODE_STORAGE_KEY, normalizedMode);
}
function getCurrentExecutionMode() {
return elements.executionMode ? (elements.executionMode.value || ENABLED_EXECUTION_MODE) : ENABLED_EXECUTION_MODE;
}
function getExecutionModeFailureHint() {
return '';
}
// 加载可用的邮箱服务
async function loadAvailableServices() {
try {
@@ -463,6 +493,7 @@ async function handleStartRegistration(e) {
}
const [emailServiceType, serviceId] = selectedValue.split(':');
const executionMode = getCurrentExecutionMode();
// 禁用开始按钮
elements.startBtn.disabled = true;
@@ -474,6 +505,7 @@ async function handleStartRegistration(e) {
// 构建请求数据(代理从设置中自动获取)
const requestData = {
email_service_type: emailServiceType,
execution_mode: executionMode,
auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false,
cpa_service_ids: elements.autoUploadCpa && elements.autoUploadCpa.checked ? getSelectedServiceIds(elements.cpaServiceSelect) : [],
auto_upload_sub2api: elements.autoUploadSub2api ? elements.autoUploadSub2api.checked : false,
@@ -487,6 +519,8 @@ async function handleStartRegistration(e) {
requestData.email_service_id = parseInt(serviceId);
}
addLog('info', `[系统] 执行方式: ${executionMode}`);
if (isBatchMode) {
await handleBatchRegistration(requestData);
} else {
@@ -520,7 +554,7 @@ async function handleSingleRegistration(requestData) {
} catch (error) {
addLog('error', `[错误] 启动失败: ${error.message}`);
toast.error(error.message);
toast.error(error.message + getExecutionModeFailureHint());
resetButtons();
}
}
@@ -794,8 +828,8 @@ function startLogPolling(taskUuid) {
// 刷新账号列表
loadRecentAccounts();
} else if (data.status === 'failed') {
addLog('error', '[错误] 注册失败');
toast.error('注册失败');
addLog('error', `[错误] 注册失败${getExecutionModeFailureHint()}`);
toast.error(`注册失败${getExecutionModeFailureHint()}`);
} else if (data.status === 'cancelled') {
addLog('warning', '[警告] 任务已取消');
}
@@ -836,7 +870,7 @@ function startBatchPolling(batchId) {
// 刷新账号列表
loadRecentAccounts();
} else {
toast.warning('批量注册完成,但没有成功注册任何账号');
toast.warning(`批量注册完成,但没有成功注册任何账号${getExecutionModeFailureHint()}`);
}
}
}
@@ -1179,6 +1213,7 @@ async function handleOutlookBatchRegistration() {
const requestData = {
service_ids: selectedIds,
execution_mode: getCurrentExecutionMode(),
skip_registered: skipRegistered,
interval_min: intervalMin,
interval_max: intervalMax,
@@ -1192,6 +1227,7 @@ async function handleOutlookBatchRegistration() {
tm_service_ids: elements.autoUploadTm && elements.autoUploadTm.checked ? getSelectedServiceIds(elements.tmServiceSelect) : [],
};
addLog('info', `[系统] 执行方式: ${requestData.execution_mode}`);
addLog('info', `[系统] 正在启动 Outlook 批量注册 (${selectedIds.length} 个账户)...`);
try {
@@ -1219,7 +1255,7 @@ async function handleOutlookBatchRegistration() {
} catch (error) {
addLog('error', `[错误] 启动失败: ${error.message}`);
toast.error(error.message);
toast.error(error.message + getExecutionModeFailureHint());
resetButtons();
}
}
@@ -1280,11 +1316,11 @@ function connectBatchWebSocket(batchId) {
toast.success(`Outlook 批量注册完成,成功 ${data.success}`);
loadRecentAccounts();
} else {
toast.warning('Outlook 批量注册完成,但没有成功注册任何账号');
toast.warning(`Outlook 批量注册完成,但没有成功注册任何账号${getExecutionModeFailureHint()}`);
}
} else if (data.status === 'failed') {
addLog('error', '[错误] 批量任务执行失败');
toast.error('批量任务执行失败');
toast.error(`批量任务执行失败${getExecutionModeFailureHint()}`);
} else if (data.status === 'cancelled' || data.status === 'cancelling') {
addLog('warning', '[警告] 批量任务已取消');
}
@@ -1395,7 +1431,7 @@ function startOutlookBatchPolling(batchId) {
toast.success(`Outlook 批量注册完成,成功 ${data.success}`);
loadRecentAccounts();
} else {
toast.warning('Outlook 批量注册完成,但没有成功注册任何账号');
toast.warning(`Outlook 批量注册完成,但没有成功注册任何账号${getExecutionModeFailureHint()}`);
}
}
}

View File

@@ -186,6 +186,13 @@
</select>
</div>
<div class="form-group">
<label for="execution-mode">执行方式</label>
<select id="execution-mode" name="execution_mode">
<option value="curl_cffi">curl_cffi</option>
</select>
</div>
<!-- Outlook 批量注册区域 -->
<div id="outlook-batch-section" style="display: none;">
<div class="form-group">

View File

@@ -0,0 +1,176 @@
from contextlib import contextmanager
from types import SimpleNamespace
import src.config.settings as settings_module
import src.core.register as register_module
import src.web.routes.registration as registration_routes
from src.config.constants import EmailServiceType
from src.core.openai.oauth import OAuthStart
from src.services.base import BaseEmailService
class DummyEmailService(BaseEmailService):
def __init__(self):
super().__init__(EmailServiceType.TEMPMAIL, "dummy")
def create_email(self, config=None):
return {"email": "tester@example.com", "service_id": "svc-1"}
def get_verification_code(
self,
email,
email_id=None,
timeout=60,
pattern=r"(?<!\d)(\d{6})(?!\d)",
otp_sent_at=None,
):
return "123456"
def list_emails(self, **kwargs):
return []
def delete_email(self, email_id):
return True
def check_health(self):
return True
def _browser_settings():
return SimpleNamespace(
openai_client_id="client-id",
openai_auth_url="https://auth.openai.com/oauth/authorize",
openai_token_url="https://auth.openai.com/oauth/token",
openai_redirect_uri="http://localhost:1455/auth/callback",
openai_scope="openid email profile offline_access",
registration_mode="browser",
registration_browser_headless=True,
registration_browser_timeout=120,
)
def _make_engine(monkeypatch, settings=None):
monkeypatch.setattr(
register_module,
"get_settings",
lambda: settings or _browser_settings(),
)
return register_module.RegistrationEngine(email_service=DummyEmailService())
def test_settings_default_to_http_registration_mode():
settings = settings_module.Settings()
assert settings.registration_mode == "http"
assert settings.registration_browser_headless is True
def test_browser_registration_runner_is_available():
from src.core.register_browser import (
BrowserRegistrationArtifacts,
BrowserRegistrationRunner,
)
assert BrowserRegistrationRunner is not None
assert BrowserRegistrationArtifacts is not None
def test_registration_engine_ignores_browser_mode_and_uses_http_flow(monkeypatch):
engine = _make_engine(monkeypatch)
http_flow_used = {"called": False}
monkeypatch.setattr(engine, "_check_ip_location", lambda: (True, "US"))
monkeypatch.setattr(engine, "_init_session", lambda: True)
def fake_start_oauth():
engine.oauth_start = OAuthStart(
auth_url="https://auth.openai.com/oauth/authorize?state=state-1",
state="state-1",
code_verifier="verifier-1",
redirect_uri="http://localhost:1455/auth/callback",
)
return True
monkeypatch.setattr(engine, "_start_oauth", fake_start_oauth)
monkeypatch.setattr(
engine,
"_run_browser_registration_flow",
lambda: (_ for _ in ()).throw(
AssertionError("browser flow should not run when playwright mode is disabled")
),
)
monkeypatch.setattr(
engine,
"_get_device_id",
lambda: http_flow_used.update(called=True) or "did-1",
)
monkeypatch.setattr(engine, "_check_sentinel", lambda did: None)
monkeypatch.setattr(
engine,
"_submit_signup_form",
lambda *args, **kwargs: register_module.SignupFormResult(
success=False,
error_message="stop after confirming HTTP flow",
),
)
result = engine.run()
assert http_flow_used["called"] is True
assert result.success is False
def test_save_to_database_persists_browser_cookies(monkeypatch):
engine = _make_engine(monkeypatch)
captured = {}
@contextmanager
def fake_get_db():
yield object()
monkeypatch.setattr(register_module, "get_db", fake_get_db)
monkeypatch.setattr(
register_module.crud,
"create_account",
lambda db, **kwargs: captured.update(kwargs) or SimpleNamespace(id=1),
)
result = register_module.RegistrationResult(
success=True,
email="tester@example.com",
password="Password123!",
account_id="account-1",
workspace_id="ws-browser",
access_token="access-1",
refresh_token="refresh-1",
id_token="id-1",
session_token="session-token",
logs=[],
metadata={},
source="register",
cookies="oai-did=device-1; __Secure-next-auth.session-token=session-token",
)
assert engine.save_to_database(result) is True
assert captured["cookies"].startswith("oai-did=device-1")
def test_execution_mode_http_override_skips_browser_mode(monkeypatch):
engine = _make_engine(monkeypatch)
engine.execution_mode = "curl_cffi"
assert engine._is_browser_mode() is False
def test_execution_mode_playwright_override_is_downgraded_to_http(monkeypatch):
settings = SimpleNamespace(**vars(_browser_settings()))
settings.registration_mode = "http"
engine = _make_engine(monkeypatch, settings=settings)
engine.execution_mode = "playwright"
assert engine._is_browser_mode() is False
assert engine._resolved_execution_mode() == "curl_cffi"
def test_route_execution_mode_playwright_is_normalized_to_http():
assert registration_routes._validate_execution_mode("playwright") == "curl_cffi"

View File

@@ -0,0 +1,130 @@
import pytest
from src.services.fivesim import FiveSimClient, FiveSimError
class FakeResponse:
def __init__(self, status_code=200, payload=None, text=""):
self.status_code = status_code
self._payload = payload
self.text = text
def json(self):
if self._payload is None:
raise ValueError("no json payload")
return self._payload
class FakeHTTPClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append({
"method": method,
"url": url,
"kwargs": kwargs,
})
if not self.responses:
raise AssertionError(f"unexpected request: {method} {url}")
return self.responses.pop(0)
def test_buy_activation_builds_expected_request_and_returns_order():
client = FiveSimClient(
api_token="token-123",
base_url="https://5sim.test",
)
fake_http = FakeHTTPClient([
FakeResponse(
payload={
"id": 1001,
"phone": "+1234567890",
"status": "PENDING",
}
)
])
client.http_client = fake_http
order = client.buy_activation(
"usa",
"any",
"openai",
reuse=True,
voice=True,
ref="ref-1",
max_price=0.5,
)
assert order["id"] == 1001
call = fake_http.calls[0]
assert call["method"] == "GET"
assert call["url"] == "https://5sim.test/v1/user/buy/activation/usa/any/openai"
assert call["kwargs"]["headers"]["Authorization"] == "Bearer token-123"
assert call["kwargs"]["headers"]["Accept"] == "application/json"
assert call["kwargs"]["params"] == {
"reuse": 1,
"voice": 1,
"ref": "ref-1",
"maxPrice": 0.5,
}
def test_get_latest_code_returns_last_sms_code():
client = FiveSimClient(api_token="token-123")
code = client.get_latest_code(
{
"sms": [
{"code": "111111"},
{"code": "222222"},
]
}
)
assert code == "222222"
def test_wait_for_code_polls_until_code_and_finishes_order():
client = FiveSimClient(
api_token="token-123",
base_url="https://5sim.test",
)
fake_http = FakeHTTPClient([
FakeResponse(payload={"id": 1001, "status": "PENDING", "sms": []}),
FakeResponse(payload={"id": 1001, "status": "RECEIVED", "sms": [{"code": "654321"}]}),
FakeResponse(payload={"id": 1001, "status": "FINISHED"}),
])
client.http_client = fake_http
code = client.wait_for_code(
1001,
timeout=5,
poll_interval=0,
finish_on_success=True,
)
assert code == "654321"
assert [call["url"] for call in fake_http.calls] == [
"https://5sim.test/v1/user/check/1001",
"https://5sim.test/v1/user/check/1001",
"https://5sim.test/v1/user/finish/1001",
]
def test_buy_activation_raises_error_with_response_details():
client = FiveSimClient(
api_token="token-123",
base_url="https://5sim.test",
)
fake_http = FakeHTTPClient([
FakeResponse(
status_code=400,
payload={"message": "not enough user balance"},
)
])
client.http_client = fake_http
with pytest.raises(FiveSimError, match="not enough user balance"):
client.buy_activation("usa", "any", "openai")

View File

@@ -0,0 +1,220 @@
from src.config.constants import EmailServiceType
from src.core.register_browser import BrowserRegistrationRunner
from src.services.base import BaseEmailService
class DummyEmailService(BaseEmailService):
def __init__(self):
super().__init__(EmailServiceType.TEMPMAIL, "dummy")
def create_email(self, config=None):
return {"email": "tester@example.com", "service_id": "svc-1"}
def get_verification_code(
self,
email,
email_id=None,
timeout=60,
pattern=r"(?<!\d)(\d{6})(?!\d)",
otp_sent_at=None,
):
return "123456"
def list_emails(self, **kwargs):
return []
def delete_email(self, email_id):
return True
def check_health(self):
return True
class FakeLocator:
def __init__(self, page, selector):
self.page = page
self.selector = selector
def count(self):
return 1 if self.selector in self.page.visible else 0
@property
def first(self):
return self
def is_visible(self):
return self.selector in self.page.visible
def click(self):
self.page.clicked.append(self.selector)
if self.selector == "a[href*='login_with']":
self.page.visible.discard(self.selector)
self.page.visible.add("input[type='email']")
class FakePage:
def __init__(self):
self.visible = {"a[href*='login_with']"}
self.clicked = []
self.waits = []
self.url = "https://auth.openai.com/session-ended"
self.stage_after_waits = {}
def locator(self, selector):
return FakeLocator(self, selector)
def wait_for_timeout(self, ms):
self.waits.append(ms)
next_visible = self.stage_after_waits.get(len(self.waits))
if next_visible is not None:
self.visible = set(next_visible)
class ProfileLocator:
def __init__(self, page, selector):
self.page = page
self.selector = selector
def count(self):
return 1 if self.selector in self.page.available else 0
@property
def first(self):
return self
def is_visible(self):
return self.selector in self.page.visible
def fill(self, value):
self.page.filled.append((self.selector, value))
def select_option(self, value=None):
self.page.selected.append((self.selector, value))
def evaluate(self, script, value):
self.page.evaluated.append((self.selector, value))
class ProfilePage:
def __init__(self):
self.available = {"input[name='name']", "input[name='birthday']"}
self.visible = {"input[name='name']"}
self.filled = []
self.selected = []
self.evaluated = []
def locator(self, selector):
return ProfileLocator(self, selector)
def _build_runner():
return BrowserRegistrationRunner(
auth_url="https://auth.openai.com/oauth/authorize",
redirect_uri="http://localhost:1455/auth/callback",
email="tester@example.com",
email_service=DummyEmailService(),
email_info={"service_id": "svc-1"},
password="Password123!",
user_info={"name": "Tester", "birthdate": "1995-01-01"},
headless=True,
timeout_seconds=5,
)
def test_wait_for_login_page_enters_via_session_ended_link():
runner = _build_runner()
page = FakePage()
runner._wait_for_login_page(page, timeout_ms=1_000)
assert page.clicked == ["a[href*='login_with']"]
assert runner._is_visible(page, runner._email_selectors()) is True
def test_maybe_switch_to_signup_page_clicks_register_link_from_login():
runner = _build_runner()
page = FakePage()
page.url = "https://auth.openai.com/log-in"
page.visible = {"input[type='email']", "a[href='/create-account']"}
runner._maybe_switch_to_signup_page(page)
assert page.clicked == ["a[href='/create-account']"]
def test_should_retry_headed_on_cloudflare_verification_page():
runner = _build_runner()
assert (
runner._should_retry_headed(
"https://auth.openai.com/api/oauth/oauth2/auth?foo=bar",
"auth.openai.com\n执行安全验证\n此网站使用安全服务来防范恶意自动程序",
)
is True
)
assert (
runner._should_retry_headed(
"https://auth.openai.com/log-in",
"欢迎回来\n电子邮件地址\n继续",
)
is False
)
def test_wait_for_post_email_stage_waits_until_password_appears():
runner = _build_runner()
page = FakePage()
page.url = "https://auth.openai.com/create-account"
page.visible = set()
page.stage_after_waits = {
2: {"input[type='password']"},
}
stage = runner._wait_for_post_email_stage(page, timeout_ms=1_000)
assert stage == "password"
def test_complete_profile_step_sets_hidden_birthday_input_when_present():
runner = _build_runner()
page = ProfilePage()
runner.user_info = {"name": "Tester", "birthdate": "1995-05-16"}
runner._complete_profile_step(page)
assert ("input[name='name']", "Tester") in page.filled
assert ("input[name='birthday']", "1995-05-16") in page.evaluated
def test_complete_profile_step_fills_birthday_spinbutton_segments_when_present():
runner = _build_runner()
page = ProfilePage()
page.available = {
"input[name='name']",
"[role='spinbutton'][data-type='year']",
"[role='spinbutton'][data-type='month']",
"[role='spinbutton'][data-type='day']",
}
page.visible = set(page.available)
runner.user_info = {"name": "Tester", "birthdate": "1995-05-16"}
runner._complete_profile_step(page)
assert ("[role='spinbutton'][data-type='year']", "1995") in page.filled
assert ("[role='spinbutton'][data-type='month']", "05") in page.filled
assert ("[role='spinbutton'][data-type='day']", "16") in page.filled
def test_handle_post_profile_stage_restarts_login_when_add_phone_detected(monkeypatch):
runner = _build_runner()
page = FakePage()
page.url = "https://auth.openai.com/add-phone"
captured = {"url": ""}
calls = []
monkeypatch.setattr(runner, "_wait_for_callback", lambda *args, **kwargs: False)
monkeypatch.setattr(runner, "_restart_login_flow", lambda *args, **kwargs: calls.append("restart"))
runner._handle_post_profile_stage(page, captured)
assert calls == ["restart"]

View File

@@ -0,0 +1,210 @@
from src.config.constants import EmailServiceType
from src.core.register import RegistrationEngine, SignupFormResult
from src.services.base import BaseEmailService
class DummyEmailService(BaseEmailService):
def __init__(self):
super().__init__(EmailServiceType.TEMPMAIL, "dummy")
def create_email(self, config=None):
raise NotImplementedError
def get_verification_code(
self,
email,
email_id=None,
timeout=60,
pattern=r"(?<!\d)(\d{6})(?!\d)",
otp_sent_at=None,
):
raise NotImplementedError
def list_emails(self, **kwargs):
return []
def delete_email(self, email_id):
raise NotImplementedError
def check_health(self):
return True
class DummyCookies:
def __init__(self, values):
self.values = values
def get(self, key, default=None):
return self.values.get(key, default)
class DummySession:
def __init__(self, values):
self.cookies = DummyCookies(values)
class TrackingRegistrationEngine(RegistrationEngine):
def __init__(self):
super().__init__(email_service=DummyEmailService())
self.events = []
self.sent_otp_count = 0
self.codes_to_return = ["111111", "222222"]
self.validated_codes = []
self.auth_url_checks = 0
def _pop_test_otp_code(self, stage):
if not self.codes_to_return:
raise AssertionError(f"test setup exhausted OTP codes during {stage}")
code = self.codes_to_return.pop(0)
self.events.append(f"get_otp:{code}")
return code
def _check_ip_location(self):
self.events.append("check_ip_location")
return True, "US"
def _create_email(self):
self.events.append("create_email")
self.email = "new-account@example.com"
self.email_info = {"email": self.email, "service_id": "email-id"}
return True
def _init_session(self):
self.events.append("init_session")
self.session = DummySession(
{"__Secure-next-auth.session-token": "session-token"}
)
return True
def _start_oauth(self):
self.events.append("start_oauth")
return True
def _is_browser_mode(self):
return False
def _get_device_id(self):
self.events.append("get_device_id")
return "device-id"
def _check_sentinel(self, did):
self.events.append(f"check_sentinel:{did}")
return None
def _submit_signup_form(self, did, sen_token):
self.events.append(f"submit_signup_form:{did}")
return SignupFormResult(success=True, page_type="password")
def _register_password(self):
self.events.append("register_password")
self.password = "generated-password"
return True, self.password
def _send_verification_code(self, referer="https://auth.openai.com/create-account/password"):
self.sent_otp_count += 1
self.events.append(f"send_otp:{self.sent_otp_count}:{referer}")
self._otp_sent_at = float(self.sent_otp_count)
return True
def _get_verification_code(self):
return self._pop_test_otp_code("signup OTP retrieval")
def _validate_verification_code(self, code):
self.validated_codes.append(code)
self.events.append(f"validate_otp:{code}")
return True
def _create_user_account(self):
self.events.append("create_user_account")
return True
def _advance_login_authorization(self):
code = self._pop_test_otp_code("login OTP retrieval")
self.validated_codes.append(code)
self.events.append(f"validate_otp:{code}")
self.events.append("advance_login_authorization")
return "workspace-id", "http://localhost:1455/auth/callback?code=code&state=state"
def _try_reenter_login_flow(self):
self.events.append("try_reenter_login_flow")
return True
def _submit_login_password_step(self):
self.events.append("submit_login_password_step")
return True
def _get_workspace_id(self):
self.events.append("get_workspace_id")
return "workspace-id"
def _select_workspace(self, workspace_id):
self.events.append(f"select_workspace:{workspace_id}")
return "https://example.com/continue"
def _follow_redirects(self, start_url):
self.events.append("follow_redirects")
return "http://localhost:1455/auth/callback?code=code&state=state"
def _handle_oauth_callback(self, callback_url):
self.events.append("handle_oauth_callback")
return {
"account_id": "account-id",
"access_token": "access-token",
"refresh_token": "refresh-token",
"id_token": "id-token",
}
def test_new_account_restarts_login_otp_flow_after_create_account():
engine = TrackingRegistrationEngine()
result = engine.run()
assert result.success is True
assert result.source == "register"
assert result.password == "generated-password"
assert result.session_token == "session-token"
assert engine.sent_otp_count == 1
assert engine.validated_codes == ["111111", "222222"]
assert engine.events == [
"check_ip_location",
"create_email",
"init_session",
"start_oauth",
"get_device_id",
"check_sentinel:device-id",
"submit_signup_form:device-id",
"register_password",
"send_otp:1:https://auth.openai.com/create-account/password",
"get_otp:111111",
"validate_otp:111111",
"create_user_account",
"get_otp:222222",
"validate_otp:222222",
"advance_login_authorization",
"handle_oauth_callback",
]
def test_tracking_registration_engine_fails_clearly_when_signup_otp_pool_is_exhausted():
engine = TrackingRegistrationEngine()
engine.codes_to_return = []
try:
engine._get_verification_code()
except AssertionError as exc:
assert str(exc) == "test setup exhausted OTP codes during signup OTP retrieval"
else:
raise AssertionError("expected a clear assertion when signup OTP codes run out")
def test_tracking_registration_engine_fails_clearly_when_login_otp_pool_is_exhausted():
engine = TrackingRegistrationEngine()
engine.codes_to_return = []
try:
engine._advance_login_authorization()
except AssertionError as exc:
assert str(exc) == "test setup exhausted OTP codes during login OTP retrieval"
else:
raise AssertionError("expected a clear assertion when login OTP codes run out")

View File

@@ -0,0 +1,83 @@
import base64
import json
from src.config.constants import EmailServiceType
from src.core.register import RegistrationEngine
from src.services.base import BaseEmailService
def _encode_cookie_segment(payload):
raw = json.dumps(payload).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
class DummyEmailService(BaseEmailService):
def __init__(self):
super().__init__(EmailServiceType.TEMPMAIL, "dummy")
def create_email(self, config=None):
raise NotImplementedError
def get_verification_code(
self,
email,
email_id=None,
timeout=60,
pattern=r"(?<!\d)(\d{6})(?!\d)",
otp_sent_at=None,
):
raise NotImplementedError
def list_emails(self, **kwargs):
return []
def delete_email(self, email_id):
raise NotImplementedError
def check_health(self):
return True
class DummyCookies:
def __init__(self, values):
self.values = values
def get(self, key, default=None):
return self.values.get(key, default)
class DummySession:
def __init__(self, values):
self.cookies = DummyCookies(values)
def _build_engine(cookie_values):
engine = RegistrationEngine(email_service=DummyEmailService())
engine.session = DummySession(cookie_values)
return engine
def test_get_workspace_id_reads_payload_segment_from_auth_session_cookie():
auth_cookie = ".".join(
[
_encode_cookie_segment({"alg": "HS256", "typ": "JWT"}),
_encode_cookie_segment({"workspaces": [{"id": "ws_from_payload"}]}),
"signature",
]
)
engine = _build_engine({"oai-client-auth-session": auth_cookie})
workspace_id = engine._get_workspace_id()
assert workspace_id == "ws_from_payload"
def test_get_workspace_id_falls_back_to_auth_info_cookie():
auth_info_cookie = _encode_cookie_segment(
{"workspaces": [{"id": "ws_from_auth_info"}]}
)
engine = _build_engine({"oai_client_auth_info": auth_info_cookie})
workspace_id = engine._get_workspace_id()
assert workspace_id == "ws_from_auth_info"