mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-08-07 13:33:48 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
502
src/core/register_browser.py
Normal file
502
src/core/register_browser.py
Normal 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)
|
||||
@@ -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
181
src/services/fivesim.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
176
tests/test_browser_registration_flow.py
Normal file
176
tests/test_browser_registration_flow.py
Normal 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"
|
||||
130
tests/test_fivesim_service.py
Normal file
130
tests/test_fivesim_service.py
Normal 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")
|
||||
220
tests/test_register_browser_runner.py
Normal file
220
tests/test_register_browser_runner.py
Normal 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"]
|
||||
210
tests/test_registration_post_create_login_flow.py
Normal file
210
tests/test_registration_post_create_login_flow.py
Normal 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")
|
||||
83
tests/test_workspace_cookie_parsing.py
Normal file
83
tests/test_workspace_cookie_parsing.py
Normal 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"
|
||||
Reference in New Issue
Block a user