Files
xiaolajiaoyyds 6f71df4849 Initial open-source release
多平台账号批量注册系统,支持 OpenAI / Grok / Kiro / Gemini 平台的全自动注册。

Features:
- 用户名/密码注册登录(首个用户自动管理员)
- JWT 认证 + bcrypt 密码加密
- 积分系统与多用户管理
- HuggingFace Space 弹性 Worker 节点管理
- Cloudflare Worker 负载均衡
- 实时日志推送(SSE/WebSocket)
- 管理后台(用户管理、系统设置、代理池、卡池)

Tech Stack: Go 1.25 / Gin / GORM / PostgreSQL / Vue 3 / Vite / TailwindCSS
2026-03-22 03:44:42 +08:00

2930 lines
122 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Browser-based account provisioning service (Kiro + Gemini).
API:
POST /kiro/process { email, proxy?, yydsmail_url, yydsmail_key, ... }
POST /gemini/process { email, proxy?, yydsmail_url, yydsmail_key, mail_provider?, mail_meta? }
Env:
KIRO_REG_PORT (default 5076)
KIRO_REG_HOST (default 0.0.0.0)
KIRO_MAX_CONCURRENT (default 3)
GEMINI_MAX_CONCURRENT (default 2)
Usage: python server.py [--port 5076] [--host 0.0.0.0]
"""
import argparse
import asyncio
import json
import logging
import os
import queue
import quopri
import random
import re
import secrets
import ssl
import string
import subprocess
import time
import urllib.parse
import urllib.request
import urllib.error
import warnings
from threading import Event
from typing import Optional
# 代理链支持:环境变量代理(Clash)→ 后端代理 → 目标
try:
from proxy_chain import chain_proxy, curl_proxy_args
except ImportError:
# 未部署 proxy_chain 模块时,退化为直连
def chain_proxy(p): return p or ""
def curl_proxy_args(p): return []
# 禁止 urllib3 InsecureRequestWarning 刷屏
warnings.filterwarnings("ignore", message="Unverified HTTPS request")
# Python 3.14 SSL 兼容性修复:安装全局自定义 opener(跳过证书验证)
# undetected_chromedriver 已移除,但 OIDC / mail polling 的 urllib 仍需要此 opener
_SSL_CTX = ssl.create_default_context()
_SSL_CTX.check_hostname = False
_SSL_CTX.verify_mode = ssl.CERT_NONE
urllib.request.install_opener(
urllib.request.build_opener(
urllib.request.ProxyHandler({}),
urllib.request.HTTPSHandler(context=_SSL_CTX),
)
)
from quart import Quart, jsonify, request
from camoufox.async_api import AsyncCamoufox
logger = logging.getLogger("app-service")
logger.setLevel(logging.INFO)
_h = logging.StreamHandler()
_h.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%H:%M:%S"))
logger.addHandler(_h)
app = Quart(__name__)
# 延长响应超时(默认 60s 不够浏览器注册流程)
app.config["RESPONSE_TIMEOUT"] = 400
app.config["BODY_TIMEOUT"] = 30
# 并发控制:允许多个浏览器同时注册(HF Space 可通过环境变量调整)
_MAX_CONCURRENT = int(os.getenv("KIRO_MAX_CONCURRENT", "3"))
_reg_semaphore = asyncio.Semaphore(_MAX_CONCURRENT)
# Gemini 并发控制(独立信号量,不与 Kiro 竞争;与 system_setting.go gemini_max_concurrent 默认值保持一致)
_GEMINI_MAX_CONCURRENT = int(os.getenv("GEMINI_MAX_CONCURRENT", "2"))
_gemini_semaphore = asyncio.Semaphore(_GEMINI_MAX_CONCURRENT)
# 邮件轮询超时(秒)
_MAIL_TIMEOUT = int(os.getenv("KIRO_MAIL_TIMEOUT", "120"))
# ─── 常量 ───────────────────────────────────────────────────────────────────
FIRST_NAMES = [
"James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda",
"David", "Elizabeth", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica",
"Thomas", "Sarah", "Christopher", "Karen", "Charles", "Lisa", "Daniel", "Nancy",
"Emma", "Oliver", "Sophia", "Liam", "Ava", "Noah", "Isabella", "Ethan",
"Mia", "Mason", "Charlotte", "Logan", "Amelia", "Lucas", "Harper", "Aiden",
]
LAST_NAMES = [
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis",
"Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson",
"Taylor", "Thomas", "Jackson", "White", "Harris", "Martin", "Thompson", "Moore",
]
def _detect_ip_location(proxy: str | None) -> dict:
"""
通过代理出口 IP 自动检测地理位置,返回 timezone / locale / geo 信息。
失败时返回美国东部默认值(安全回退)。
"""
default = {
"timezone": "America/New_York",
"locale": "en-US",
"geo": {"latitude": 40.7128, "longitude": -74.0060, "accuracy": 100},
"country": "US",
}
apis = [
"http://ip-api.com/json/?fields=status,countryCode,lat,lon,timezone",
"https://ipapi.co/json/",
]
for api_url in apis:
try:
# 代理链:通过 Clash 链到后端代理,确保出口 IP 是后端代理
_vp = _validate_proxy(proxy) if proxy else ""
chained = chain_proxy(_vp) if _vp else ""
if chained:
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({"http": chained, "https": chained}),
urllib.request.HTTPSHandler(context=_SSL_CTX),
)
else:
opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=_SSL_CTX),
)
req = urllib.request.Request(api_url)
req.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)")
with opener.open(req, timeout=8) as resp:
data = json.loads(resp.read())
# ip-api.com 格式
if "countryCode" in data and data.get("status") == "success":
cc = data["countryCode"]
tz = data.get("timezone", default["timezone"])
lat = data.get("lat", default["geo"]["latitude"])
lon = data.get("lon", default["geo"]["longitude"])
# ipapi.co 格式
elif "country_code" in data:
cc = data["country_code"]
tz = data.get("timezone", default["timezone"])
lat = data.get("latitude", default["geo"]["latitude"])
lon = data.get("longitude", default["geo"]["longitude"])
else:
continue
# 国家码 → locale 映射
locale_map = {
"US": "en-US", "GB": "en-GB", "CA": "en-CA", "AU": "en-AU",
"DE": "de-DE", "FR": "fr-FR", "JP": "ja-JP", "KR": "ko-KR",
"BR": "pt-BR", "MX": "es-MX", "ES": "es-ES", "IT": "it-IT",
"NL": "nl-NL", "SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK",
"IN": "en-IN", "SG": "en-SG", "HK": "zh-HK", "TW": "zh-TW",
}
locale = locale_map.get(cc, "en-US")
logger.info("IP 地理位置检测: country=%s tz=%s lat=%.2f lon=%.2f", cc, tz, lat, lon)
return {
"timezone": tz,
"locale": locale,
"geo": {"latitude": lat, "longitude": lon, "accuracy": 100},
"country": cc,
}
except Exception as e:
logger.debug("IP 地理位置检测失败 (%s): %s", api_url, e)
continue
logger.warning("IP 地理位置检测全部失败,使用美国东部默认值")
return default
# ─── AWS OIDC Device Flow 常量 ───────────────────────────────────────────────
# AWS OIDC 服务端点(us-east-1,Builder ID 唯一可用区域)
OIDC_BASE_URL = "https://oidc.us-east-1.amazonaws.com"
# Kiro / Amazon Q Builder ID 起始 URL
OIDC_START_URL = "https://view.awsapps.com/start"
# 模拟 AWS SDK Rust 客户端头(绕过简单的 UA 过滤)
_OIDC_HEADERS = {
"Content-Type": "application/json",
"User-Agent": "aws-sdk-rust/1.3.9 os/windows lang/rust/1.87.0",
"x-amz-user-agent": (
"aws-sdk-rust/1.3.9 ua/2.1 api/ssooidc/1.88.0 "
"os/windows lang/rust/1.87.0 m/E app/AmazonQ-For-CLI"
),
"amz-sdk-request": "attempt=1; max=3",
}
# AWS Builder ID 注册发件人域名白名单
AWS_SENDER_DOMAINS = (
"signin.aws", "verify.signin.aws", "amazon.com",
"aws.amazon.com", "noreply@", "no-reply@",
)
# OTP 精准提取模式(优先高精度,回退宽泛)
# 支持纯数字和字母数字混合验证码(Google Gemini 可能发送 A-Z0-9 混合 6 位码)
RE_OTP_PRECISE = re.compile(r'(?:verification code|verify|code)[^0-9]{0,30}(\d{6})', re.IGNORECASE)
RE_OTP_ALNUM = re.compile(r'(?:verification code|verify|code)\s*[::\s]*\b([A-Z0-9]{6})\b', re.IGNORECASE)
RE_OTP_FALLBACK = re.compile(r'\b(\d{6})\b')
RE_OTP_ALNUM_ANYWHERE = re.compile(r'\b([A-Z0-9]{6,8})\b')
# 排除常见英文单词误匹配(全大写 6 字母)
_OTP_WORD_BLACKLIST = frozenset({"GOOGLE", "GEMINI", "VERIFY", "SIGNIN", "PLEASE", "CHANGE", "ACCEPT", "CANCEL", "SUBMIT"})
# Google 验证邮件 HTML class 精确提取(<span class="verification-code">AB12CD</span>)
RE_HTML_SPAN_CODE = re.compile(
r'<span[^>]*class="[^"]*verification-code[^"]*"[^>]*>\s*([A-Z0-9]{6})\s*</span>',
re.IGNORECASE
)
# 已移除域名黑名单机制:后台配什么邮箱 provider 就用什么
# ─── Google Gemini 常量 ──────────────────────────────────────────────────────
GEMINI_AUTH_URL = "https://auth.business.gemini.google/login"
# Google 验证邮件发件人域名白名单
GOOGLE_SENDER_DOMAINS = (
"google.com", "googlemail.com", "accounts.google.com",
"noreply@google", "no-reply@google", "workspace-noreply@google",
)
def _is_google_email(sender: str, subject: str) -> bool:
"""判断是否为 Google 验证邮件(Gemini Business 注册用)。"""
sender_lower = _normalize_sender(sender).lower()
subject_lower = subject.lower()
domain_match = any(d in sender_lower for d in GOOGLE_SENDER_DOMAINS)
if not domain_match and sender_lower:
domain_match = ("google" in sender_lower) or ("gemini" in sender_lower)
subject_match = any(kw in subject_lower for kw in
("verification code", "verify", "验证码", "code",
"sign in", "登录", "google", "gemini", "workspace", "business"))
return subject_match and (domain_match or not sender_lower.strip())
def _get_email_domain(email: str) -> str:
"""提取邮箱 @ 后的域名后缀"""
return email.rsplit("@", 1)[-1].lower() if "@" in email else ""
def _normalize_sender(sender_obj) -> str:
"""把不同 provider 返回的发件人结构归一成邮箱字符串。"""
if isinstance(sender_obj, dict):
return str(sender_obj.get("address", "") or sender_obj.get("name", "") or "")
if isinstance(sender_obj, list):
for item in sender_obj:
sender = _normalize_sender(item)
if sender:
return sender
return ""
return str(sender_obj) if sender_obj else ""
def _iter_text_chunks(value) -> list[str]:
"""统一展开 text/html 字段,兼容字符串和字符串数组。"""
if isinstance(value, str):
return [value] if value else []
if isinstance(value, list):
return [item for item in value if isinstance(item, str) and item]
return []
def _is_plausible_otp_candidate(code: str, *, allow_plain_digits: bool = True) -> bool:
"""过滤明显不是验证码的 6-8 位候选值。"""
if not code:
return False
code = code.upper()
if code in _OTP_WORD_BLACKLIST:
return False
if code.isdigit():
if not allow_plain_digits:
return False
if re.match(r'^(19|20)\d{4}$|^0{6,8}$', code):
return False
return True
return any(ch.isdigit() for ch in code)
# ─── 工具函数 ────────────────────────────────────────────────────────────────
def _generate_password(length: int = 16) -> str:
"""生成高强度密码:大小写 + 数字 + 特殊字符(使用 CSPRNG)"""
chars = string.ascii_letters + string.digits + "!@#$%^&*"
pwd = (
secrets.choice(string.ascii_uppercase)
+ secrets.choice(string.ascii_lowercase)
+ secrets.choice(string.digits)
+ secrets.choice("!@#$%^&*")
+ "".join(secrets.choice(chars) for _ in range(length - 4))
)
lst = list(pwd)
# Fisher-Yates shuffle with CSPRNG
for i in range(len(lst) - 1, 0, -1):
j = secrets.randbelow(i + 1)
lst[i], lst[j] = lst[j], lst[i]
return "".join(lst)
def _random_name() -> tuple:
return random.choice(FIRST_NAMES), random.choice(LAST_NAMES)
# ─── GPTMail 轮询 ────────────────────────────────────────────────────────────
def _is_aws_email(sender: str, subject: str) -> bool:
"""
精准判断是否为 AWS Builder ID 验证邮件。
发件人域名白名单 + 主题关键词双重过滤,防止误匹配广告邮件。
"""
combined = (sender + " " + subject).lower()
# 发件人域名白名单匹配
domain_match = any(d in sender.lower() for d in AWS_SENDER_DOMAINS)
# 主题关键词:AWS 特定词
subject_match = any(kw in subject.lower() for kw in
("verification code", "verify your", "builder id", "builder-id", "sign in",
"构建者 id", "构建者id", "验证"))
# 发件人为空时(GPTMail 部分邮件不填 from)只用主题判断
return subject_match and (domain_match or not sender.strip())
def _extract_otp(text: str) -> Optional[str]:
"""
从邮件文本中提取 6 位 OTP 验证码。
支持纯数字和字母数字混合格式(Google Gemini 验证码可能包含字母)。
优先级:HTML span.verification-code > 关键词精准 > 字母数字混合 > quopri解码重试 > 6位数字回退。
"""
if not text:
return None
# 最高优先级:HTML span.verification-code 精确提取
m = RE_HTML_SPAN_CODE.search(text)
if m:
code = m.group(1).upper()
if code not in _OTP_WORD_BLACKLIST:
return code
# 高精度模式:纯数字验证码(宽松间距)
m = RE_OTP_PRECISE.search(text)
if m:
return m.group(1)
# 字母数字混合模式(Google Gemini 验证码)
m = RE_OTP_ALNUM.search(text)
if m:
code = m.group(1).upper()
if code not in _OTP_WORD_BLACKLIST:
return code
# quopri 解码重试(邮件正文可能使用 quoted-printable 编码,导致验证码被截断)
try:
decoded = quopri.decodestring(text.encode("utf-8", errors="ignore")).decode("utf-8", errors="ignore")
if decoded and decoded != text:
for pat in (RE_OTP_PRECISE, RE_OTP_ALNUM):
m = pat.search(decoded)
if m:
code = m.group(1).upper() if pat is RE_OTP_ALNUM else m.group(1)
if pat is not RE_OTP_ALNUM or code not in _OTP_WORD_BLACKLIST:
return code
except Exception:
pass
# HTML 邮件里验证码常被单独放进块级元素中,兜底扫描任意 6-8 位字母数字串。
# 只接受包含数字的候选,避免把 GOOGLE/GEMINI 这类单词误判成验证码。
for candidate in RE_OTP_ALNUM_ANYWHERE.findall(text.upper()):
if _is_plausible_otp_candidate(candidate, allow_plain_digits=False):
return candidate
# 回退:任意 6 位数字(排除明显的年份/ID)
candidates = RE_OTP_FALLBACK.findall(text)
for c in candidates:
if _is_plausible_otp_candidate(c):
return c
return None
def _poll_yydsmail(yydsmail_url: str, yydsmail_key: str, email: str, meta: dict,
timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
email_filter=None) -> Optional[str]:
"""轮询 YYDS Mail API 获取验证码。
1. GET /v1/messages?address=xxx (Bearer token) 获取邮件列表
2. GET /v1/messages/{id} (Bearer token) 获取邮件详情
3. 从 subject/text 中提取验证码
email_filter: 可选的邮件过滤函数 (sender, subject) -> bool
"""
token = meta.get("token", "")
if not token:
logger.warning("[%s] yydsmail: 缺少 token", email)
return None
list_url = f"{yydsmail_url.rstrip('/')}/v1/messages?address={urllib.parse.quote(email)}"
start = time.time()
attempt = 0
_consecutive_errors = 0
_last_checked_at: dict[str, float] = {}
_recheck_interval = 5.0
while time.time() - start < timeout:
if cancel is not None and cancel.is_set():
logger.info("[%s] yydsmail 轮询被取消", email)
return None
attempt += 1
if log_q is not None and attempt % 5 == 1 and attempt > 1:
elapsed = int(time.time() - start)
log_q.put(f"等待验证码 (yydsmail)... ({elapsed}s / {timeout}s)")
try:
req = urllib.request.Request(list_url)
req.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)")
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp:
data = json.loads(resp.read())
if not data.get("success"):
_consecutive_errors += 1
if _consecutive_errors >= 3:
logger.warning("[%s] yydsmail 连续错误,放弃", email)
return None
time.sleep(2)
continue
_consecutive_errors = 0
messages = data.get("data", {}).get("messages", [])
for msg in messages:
msg_id = msg.get("id", "")
if msg_id:
last_checked = _last_checked_at.get(msg_id, 0.0)
if last_checked and time.time() - last_checked < _recheck_interval:
continue
_last_checked_at[msg_id] = time.time()
subject = msg.get("subject", "") or ""
sender = _normalize_sender(msg.get("from", {}))
# 可选邮件过滤
if email_filter and not email_filter(sender, subject):
continue
# 先从 subject 提取
code = _extract_otp(subject)
if code:
logger.info("[%s] yydsmail subject 验证码: %s (第 %d 次)", email, code, attempt)
return code
# 获取邮件详情
if msg_id:
try:
detail_url = f"{yydsmail_url.rstrip('/')}/v1/messages/{urllib.parse.quote(msg_id, safe='')}"
dreq = urllib.request.Request(detail_url)
dreq.add_header("Authorization", f"Bearer {token}")
dreq.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)")
with urllib.request.urlopen(dreq, timeout=10, context=_SSL_CTX) as dresp:
detail = json.loads(dresp.read())
if detail.get("success"):
d = detail.get("data", {})
for field in ("text", "html"):
for text in _iter_text_chunks(d.get(field, "")):
code = _extract_otp(text)
if code:
logger.info("[%s] yydsmail %s 验证码: %s (第 %d 次)", email, field, code, attempt)
return code
except Exception as e:
logger.debug("[%s] yydsmail 详情获取失败(id=%s): %s", email, msg_id, e)
except urllib.error.HTTPError as exc:
logger.warning("[%s] yydsmail HTTP %s (第%d次)", email, exc.code, attempt)
_consecutive_errors += 1
if _consecutive_errors >= 3:
return None
except Exception as exc:
logger.warning("[%s] yydsmail 轮询出错(第%d次): %s", email, attempt, exc)
_consecutive_errors += 1
if _consecutive_errors >= 5:
return None
if cancel is not None:
if cancel.wait(2):
return None
else:
time.sleep(2)
return None
# ─── Mail.tm 轮询 ─────────────────────────────────────────────────────────────
def _poll_mailtm(email: str, meta: dict, timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
email_filter=None) -> Optional[str]:
"""轮询 Mail.tm 收件箱,提取验证码。email_filter 可切换 AWS/Google 过滤。"""
_filter = email_filter or _is_aws_email
token = meta.get("token", "")
if not token:
logger.warning("[%s] Mail.tm: 缺少 token", email)
return None
base_url = "https://api.mail.tm"
start = time.time()
attempt = 0
_consecutive_errors = 0
while time.time() - start < timeout:
if cancel is not None and cancel.is_set():
return None
attempt += 1
if log_q is not None and attempt % 5 == 1 and attempt > 1:
log_q.put(f"等待验证码 (Mail.tm)... ({int(time.time() - start)}s / {timeout}s)")
try:
req = urllib.request.Request(f"{base_url}/messages")
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp:
data = json.loads(resp.read())
_consecutive_errors = 0
members = data.get("hydra:member", data) if isinstance(data, dict) else data
if not isinstance(members, list):
members = []
for msg in members:
if not isinstance(msg, dict):
continue
subject = msg.get("subject", "") or ""
sender = msg.get("from", {})
if isinstance(sender, dict):
sender = sender.get("address", "")
elif isinstance(sender, list) and sender:
sender = sender[0].get("address", "") if isinstance(sender[0], dict) else str(sender[0])
else:
sender = str(sender) if sender else ""
if not _filter(sender, subject):
continue
code = _extract_otp(subject)
if code:
logger.info("[%s] Mail.tm 从 subject 提取验证码: %s", email, code)
return code
msg_id = msg.get("id", "")
if not msg_id:
continue
try:
detail_req = urllib.request.Request(f"{base_url}/messages/{msg_id}")
detail_req.add_header("Authorization", f"Bearer {token}")
detail_req.add_header("Accept", "application/json")
with urllib.request.urlopen(detail_req, timeout=10, context=_SSL_CTX) as dresp:
detail = json.loads(dresp.read())
for field in ("text", "html", "intro"):
text = detail.get(field, "") or ""
if text:
code = _extract_otp(text)
if code:
logger.info("[%s] Mail.tm 从 %s 提取验证码: %s", email, field, code)
return code
except Exception as e:
logger.warning("[%s] Mail.tm 读取消息详情失败: %s", email, e)
except Exception as exc:
logger.warning("[%s] Mail.tm 轮询出错(第%d次): %s", email, attempt, exc)
_consecutive_errors += 1
if _consecutive_errors >= 5:
logger.warning("[%s] Mail.tm 连续 %d 次错误,放弃轮询", email, _consecutive_errors)
return None
if cancel is not None:
if cancel.wait(3):
return None
else:
time.sleep(3)
return None
# ─── Guerrilla Mail 轮询 ──────────────────────────────────────────────────────
def _poll_guerrillamail(email: str, meta: dict, timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
email_filter=None) -> Optional[str]:
"""轮询 Guerrilla Mail 收件箱,提取验证码。email_filter 可切换 AWS/Google 过滤。"""
_filter = email_filter or _is_aws_email
sid_token = meta.get("sid_token", "")
if not sid_token:
logger.warning("[%s] GuerrillaMail: 缺少 sid_token", email)
return None
base_url = "https://api.guerrillamail.com/ajax.php"
start = time.time()
attempt = 0
_consecutive_errors = 0
while time.time() - start < timeout:
if cancel is not None and cancel.is_set():
return None
attempt += 1
if log_q is not None and attempt % 5 == 1 and attempt > 1:
log_q.put(f"等待验证码 (GuerrillaMail)... ({int(time.time() - start)}s / {timeout}s)")
try:
url = f"{base_url}?f=check_email&seq=0&sid_token={sid_token}"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp:
data = json.loads(resp.read())
_consecutive_errors = 0
mail_list = data.get("list", [])
for msg in mail_list:
if not isinstance(msg, dict):
continue
subject = msg.get("mail_subject", "") or ""
sender = msg.get("mail_from", "") or ""
body = msg.get("mail_body", "") or ""
if "Welcome to Guerrilla Mail" in subject:
continue
if not _filter(sender, subject):
continue
for text in (subject, body):
if text:
code = _extract_otp(text)
if code:
logger.info("[%s] GuerrillaMail 提取验证码: %s", email, code)
return code
except Exception as exc:
logger.warning("[%s] GuerrillaMail 轮询出错(第%d次): %s", email, attempt, exc)
_consecutive_errors += 1
if _consecutive_errors >= 5:
logger.warning("[%s] GuerrillaMail 连续 %d 次错误,放弃轮询", email, _consecutive_errors)
return None
if cancel is not None:
if cancel.wait(3):
return None
else:
time.sleep(3)
return None
# ─── tempmail.lol 轮询 ────────────────────────────────────────────────────────
def _poll_templol(email: str, meta: dict, timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
email_filter=None) -> Optional[str]:
"""轮询 tempmail.lol 收件箱,提取验证码。email_filter 可切换 AWS/Google 过滤。"""
_filter = email_filter or _is_aws_email
token = meta.get("token", "")
if not token:
logger.warning("[%s] tempmail.lol: 缺少 token", email)
return None
base_url = "https://api.tempmail.lol"
start = time.time()
attempt = 0
_consecutive_errors = 0
while time.time() - start < timeout:
if cancel is not None and cancel.is_set():
return None
attempt += 1
if log_q is not None and attempt % 5 == 1 and attempt > 1:
log_q.put(f"等待验证码 (tempmail.lol)... ({int(time.time() - start)}s / {timeout}s)")
try:
url = f"{base_url}/auth/{token}"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp:
data = json.loads(resp.read())
_consecutive_errors = 0
emails_list = data.get("email", [])
for msg in emails_list:
if not isinstance(msg, dict):
continue
subject = msg.get("subject", "") or ""
sender = msg.get("from", "") or ""
body = msg.get("body", "") or msg.get("html", "") or ""
if not _filter(sender, subject):
continue
for text in (subject, body):
if text:
code = _extract_otp(text)
if code:
logger.info("[%s] tempmail.lol 提取验证码: %s (第 %d 次轮询)", email, code, attempt)
return code
except Exception as exc:
logger.warning("[%s] tempmail.lol 轮询出错(第%d次): %s", email, attempt, exc)
_consecutive_errors += 1
if _consecutive_errors >= 5:
logger.warning("[%s] tempmail.lol 连续 %d 次错误,放弃轮询", email, _consecutive_errors)
return None
if cancel is not None:
if cancel.wait(3):
return None
else:
time.sleep(3)
return None
# ─── mail.gw 轮询 ────────────────────────────────────────────────────────────
def _poll_mailgw(email: str, meta: dict, timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
email_filter=None) -> Optional[str]:
"""轮询 mail.gw 收件箱,提取验证码。API 与 Mail.tm 完全一致。email_filter 可切换 AWS/Google 过滤。"""
_filter = email_filter or _is_aws_email
token = meta.get("token", "")
if not token:
logger.warning("[%s] mail.gw: 缺少 token", email)
return None
base_url = "https://api.mail.gw"
start = time.time()
attempt = 0
_consecutive_errors = 0
while time.time() - start < timeout:
if cancel is not None and cancel.is_set():
return None
attempt += 1
if log_q is not None and attempt % 5 == 1 and attempt > 1:
log_q.put(f"等待验证码 (mail.gw)... ({int(time.time() - start)}s / {timeout}s)")
try:
req = urllib.request.Request(f"{base_url}/messages")
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp:
data = json.loads(resp.read())
_consecutive_errors = 0
members = data.get("hydra:member", data) if isinstance(data, dict) else data
if not isinstance(members, list):
members = []
for msg in members:
if not isinstance(msg, dict):
continue
subject = msg.get("subject", "") or ""
sender = msg.get("from", {})
if isinstance(sender, dict):
sender = sender.get("address", "")
elif isinstance(sender, list) and sender:
sender = sender[0].get("address", "") if isinstance(sender[0], dict) else str(sender[0])
else:
sender = str(sender) if sender else ""
if not _filter(sender, subject):
continue
code = _extract_otp(subject)
if code:
logger.info("[%s] mail.gw 从 subject 提取验证码: %s (第 %d 次轮询)", email, code, attempt)
return code
msg_id = msg.get("id", "")
if not msg_id:
continue
try:
detail_req = urllib.request.Request(f"{base_url}/messages/{msg_id}")
detail_req.add_header("Authorization", f"Bearer {token}")
detail_req.add_header("Accept", "application/json")
with urllib.request.urlopen(detail_req, timeout=10, context=_SSL_CTX) as dresp:
detail = json.loads(dresp.read())
for field in ("text", "html", "intro"):
text = detail.get(field, "") or ""
if text:
code = _extract_otp(text)
if code:
logger.info("[%s] mail.gw 从 %s 提取验证码: %s", email, field, code)
return code
except Exception as e:
logger.warning("[%s] mail.gw 读取消息详情失败: %s", email, e)
except Exception as exc:
logger.warning("[%s] mail.gw 轮询出错(第%d次): %s", email, attempt, exc)
_consecutive_errors += 1
if _consecutive_errors >= 5:
logger.warning("[%s] mail.gw 连续 %d 次错误,放弃轮询", email, _consecutive_errors)
return None
if cancel is not None:
if cancel.wait(3):
return None
else:
time.sleep(3)
return None
# ─── temp-mail.io 轮询 ───────────────────────────────────────────────────────
def _poll_tempmailio(email: str, meta: dict, timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
email_filter=None) -> Optional[str]:
"""轮询 temp-mail.io 收件箱,提取验证码。email_filter 可切换 AWS/Google 过滤。"""
_filter = email_filter or _is_aws_email
token = meta.get("token", "")
if not token:
logger.warning("[%s] temp-mail.io: 缺少 token", email)
return None
base_url = "https://api.internal.temp-mail.io/api/v3"
start = time.time()
attempt = 0
_consecutive_errors = 0
while time.time() - start < timeout:
if cancel is not None and cancel.is_set():
return None
attempt += 1
if log_q is not None and attempt % 5 == 1 and attempt > 1:
log_q.put(f"等待验证码 (temp-mail.io)... ({int(time.time() - start)}s / {timeout}s)")
try:
url = f"{base_url}/email/{email}/messages"
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp:
data = json.loads(resp.read())
_consecutive_errors = 0
if not isinstance(data, list):
data = []
for msg in data:
if not isinstance(msg, dict):
continue
subject = msg.get("subject", "") or ""
sender = msg.get("from", "") or ""
body = msg.get("body_text", "") or msg.get("body_html", "") or ""
if not _filter(sender, subject):
continue
for text in (subject, body):
if text:
code = _extract_otp(text)
if code:
logger.info("[%s] temp-mail.io 提取验证码: %s (第 %d 次轮询)", email, code, attempt)
return code
except Exception as exc:
logger.warning("[%s] temp-mail.io 轮询出错(第%d次): %s", email, attempt, exc)
_consecutive_errors += 1
if _consecutive_errors >= 5:
logger.warning("[%s] temp-mail.io 连续 %d 次错误,放弃轮询", email, _consecutive_errors)
return None
if cancel is not None:
if cancel.wait(3):
return None
else:
time.sleep(3)
return None
# ─── Mailfree 自建邮箱轮询 ────────────────────────────────────────────────────
def _poll_mailfree(email: str, meta: dict, timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
email_filter=None) -> Optional[str]:
"""轮询 mailfree 自建 Cloudflare Workers 邮箱服务,提取验证码。
mailfree API 自带 verification_code 字段,优先使用服务端提取结果。
email_filter 可切换 AWS/Google 过滤(用于 fallback 匹配)。"""
_filter = email_filter or _is_aws_email
base_url = meta.get("base_url", "").rstrip("/")
admin_token = meta.get("admin_token", "")
if not base_url or not admin_token:
logger.warning("[%s] mailfree: 缺少 base_url 或 admin_token", email)
return None
api_url = f"{base_url}/api/emails?mailbox={urllib.parse.quote(email)}"
_opener = urllib.request.build_opener(
urllib.request.ProxyHandler({}),
urllib.request.HTTPSHandler(context=_SSL_CTX),
)
start = time.time()
attempt = 0
_consecutive_errors = 0
while time.time() - start < timeout:
if cancel is not None and cancel.is_set():
return None
attempt += 1
if log_q is not None and attempt % 5 == 1 and attempt > 1:
log_q.put(f"等待验证码 (mailfree)... ({int(time.time() - start)}s / {timeout}s)")
try:
req = urllib.request.Request(api_url)
req.add_header("Authorization", f"Bearer {admin_token}")
req.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)")
with _opener.open(req, timeout=10) as resp:
data = json.loads(resp.read())
_consecutive_errors = 0
if not isinstance(data, list):
data = []
for msg in data:
if not isinstance(msg, dict):
continue
# 优先使用服务端提取的验证码
vc = msg.get("verification_code", "")
if vc:
logger.info("[%s] mailfree 服务端验证码: %s (第 %d 次轮询)", email, vc, attempt)
return str(vc)
subject = msg.get("subject", "") or ""
sender = msg.get("from", "") or ""
if _filter and not _filter(sender, subject):
continue
# 列表接口只返回截断文本,需要拉取完整邮件内容
email_id = msg.get("id", "")
if email_id:
try:
detail_url = f"{base_url}/api/email/{urllib.parse.quote(str(email_id), safe='')}"
dreq = urllib.request.Request(detail_url)
dreq.add_header("Authorization", f"Bearer {admin_token}")
dreq.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)")
with _opener.open(dreq, timeout=10) as dresp:
full_msg = json.loads(dresp.read())
if isinstance(full_msg, dict):
# 合并完整内容到 msg
for k, v in full_msg.items():
if v and not msg.get(k):
msg[k] = v
except Exception as e:
logger.debug("[%s] mailfree 邮件详情获取失败(id=%s): %s", email, email_id, e)
for field in ("subject", "content", "html_content", "text_content", "body", "text"):
text = msg.get(field, "") or ""
if text:
code = _extract_otp(text)
if code:
logger.info("[%s] mailfree 客户端提取验证码: %s (字段: %s, 第 %d 次轮询)", email, code, field, attempt)
return code
except Exception as exc:
logger.warning("[%s] mailfree 轮询出错(第%d次): %s", email, attempt, exc)
_consecutive_errors += 1
if _consecutive_errors >= 5:
logger.warning("[%s] mailfree 连续 %d 次错误,放弃轮询", email, _consecutive_errors)
return None
if cancel is not None:
if cancel.wait(2):
return None
else:
time.sleep(2)
return None
# ─── 验证码轮询 provider 分发 ────────────────────────────────────────────────
def _poll_by_provider(email: str, provider: str, meta: dict,
yydsmail_url: str = "", yydsmail_key: str = "",
timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
email_filter=None) -> Optional[str]:
provider_key = (provider or meta.get("provider") or "yydsmail").strip().lower()
if provider_key == "yydsmail":
return _poll_yydsmail(yydsmail_url, yydsmail_key, email, meta,
timeout=timeout, log_q=log_q, cancel=cancel,
email_filter=email_filter)
if provider_key in {"mailtm", "mail.tm"}:
return _poll_mailtm(email, meta, timeout=timeout, log_q=log_q, cancel=cancel,
email_filter=email_filter)
if provider_key in {"guerrillamail", "guerrilla", "guerrilla_mail"}:
return _poll_guerrillamail(email, meta, timeout=timeout, log_q=log_q, cancel=cancel,
email_filter=email_filter)
if provider_key in {"templol", "tempmail.lol", "tempmail_lol"}:
return _poll_templol(email, meta, timeout=timeout, log_q=log_q, cancel=cancel,
email_filter=email_filter)
if provider_key in {"mailgw", "mail.gw"}:
return _poll_mailgw(email, meta, timeout=timeout, log_q=log_q, cancel=cancel,
email_filter=email_filter)
if provider_key in {"tempmailio", "temp-mail.io", "temp_mail_io"}:
return _poll_tempmailio(email, meta, timeout=timeout, log_q=log_q, cancel=cancel,
email_filter=email_filter)
if provider_key == "mailfree":
return _poll_mailfree(email, meta, timeout=timeout, log_q=log_q, cancel=cancel,
email_filter=email_filter)
logger.warning("[%s] 未知邮箱 provider=%s,回退 yydsmail 轮询", email, provider_key)
return _poll_yydsmail(yydsmail_url, yydsmail_key, email, meta,
timeout=timeout, log_q=log_q, cancel=cancel,
email_filter=email_filter)
# ─── 统一验证码轮询分发 ───────────────────────────────────────────────────────
def _poll_verification_code(email: str, provider: str, meta: dict,
yydsmail_url: str = "", yydsmail_key: str = "",
timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None) -> Optional[str]:
"""验证码轮询(按 provider 分发,AWS 使用默认过滤规则)。"""
logger.info("[%s] 验证码轮询: provider=%s", email, provider)
return _poll_by_provider(email, provider, meta,
yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key,
timeout=timeout, log_q=log_q, cancel=cancel)
# ─── Gemini 验证码轮询 ──────────────────────────────────────────────────────
def _poll_gemini_verification_code(email: str, provider: str, meta: dict,
yydsmail_url: str = "", yydsmail_key: str = "",
timeout: int = 120,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None) -> Optional[str]:
"""Gemini 验证码轮询(按 provider 分发,并套用 Google 邮件过滤)。"""
logger.info("[%s] Gemini 验证码轮询: provider=%s", email, provider)
return _poll_by_provider(email, provider, meta,
yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key,
timeout=timeout, log_q=log_q, cancel=cancel,
email_filter=_is_google_email)
# ─── Playwright 页面交互辅助 ─────────────────────────────────────────────────
async def _pw_click_next(page, lq=None):
"""
点击注册流程的「下一步」按钮,多选择器兜底。
Playwright locator 模式,不再依赖 WebDriverWait。
"""
# 精确 data-testid 选择器(优先)
selectors = [
'[data-testid="email-verification-verify-button"]',
'[data-testid="signup-next-button"]',
'[data-testid="test-primary-button"]',
'button[type="submit"]',
'input[type="submit"]',
]
for sel in selectors:
try:
btn = page.locator(sel).first
if await btn.count() > 0 and await btn.is_visible():
if lq:
lq(f"点击按钮: {sel[:60]}")
await btn.click()
return True
except Exception:
pass
# 文本匹配兜底
for text in ["Next", "Continue", "继续", "Create", "Verify", "Submit", "Confirm"]:
try:
btn = page.locator(f"button:has-text('{text}')").first
if await btn.count() > 0 and await btn.is_visible():
if lq:
lq(f"点击按钮: text={text}")
await btn.click()
return True
except Exception:
pass
# 按钮 class 兜底
try:
btn = page.locator("button.primary, button[class*='primary']").first
if await btn.count() > 0 and await btn.is_visible():
if lq:
lq("点击按钮: class=primary")
await btn.click()
return True
except Exception:
pass
# 最后手段:Enter 键
if lq:
lq("[!] 找不到下一步按钮,尝试 Enter 键提交")
try:
await page.keyboard.press("Enter")
return True
except Exception:
pass
return False
async def _pw_wait_navigation(page, old_url: str, timeout: float = 15,
expect_selector: str = "", lq=None):
"""
等待页面 URL 变化或目标元素出现(SPA 场景)。
表单提交后调用,确认页面已跳转再进入下一步。
expect_selector: 期望出现的目标元素选择器,避免误判。
"""
interval = 0.5
elapsed = 0.0
while elapsed < timeout:
if page.url != old_url:
if lq:
lq(f"页面已跳转: {page.url[:80]}")
return True
# SPA 路由:URL 不变但目标元素已出现
if expect_selector and elapsed > 2:
try:
cnt = await page.locator(expect_selector).count()
if cnt > 0:
if lq:
lq("页面内容已更新(检测到目标元素)")
return True
except Exception:
pass
await asyncio.sleep(interval)
elapsed += interval
if lq:
lq(f"[!] 等待页面跳转超时({timeout}s),URL: {page.url[:80]}")
return False
async def _pw_dismiss_cookie(page):
"""快速处理 AWS Cookie 弹窗(接受/拒绝都行,只要关掉)"""
for sel in [
"button.awsccc-u-btn-primary",
"button[id*='truste-consent']",
]:
try:
btn = page.locator(sel).first
if await btn.count() > 0 and await btn.is_visible():
await btn.click()
await asyncio.sleep(0.8)
return
except Exception:
pass
# 文本匹配:AWS cookie 弹窗按钮通常包含 awsccc class
for text in ["Accept", "接受", "Decline", "拒绝"]:
try:
# 限定 awsccc 或 cookie 相关容器内的按钮
btn = page.locator(f".awsccc-cs-container button:has-text('{text}')").first
if await btn.count() > 0 and await btn.is_visible():
await btn.click()
await asyncio.sleep(0.8)
return
except Exception:
pass
async def _pw_check_success(page) -> bool:
"""通过页面内容判断注册是否真正成功"""
try:
page_text = (await page.locator("body").inner_text()).lower()
success_keywords = [
"successfully created", "account created", "welcome to",
"you're all set", "get started", "verification complete",
"your account is ready", "sign in", "builder id created",
"请求已批准", "request approved", "您可以关闭此窗口",
]
return any(kw in page_text for kw in success_keywords)
except Exception:
return False
# ─── 代理处理 ────────────────────────────────────────────────────────────────
_PROXY_RE = re.compile(
r'^(https?|socks[45])://[a-zA-Z0-9._\-:@]+(:\d{1,5})?(/[^\s]*)?$'
)
# 允许的 region 值
_ALLOWED_REGIONS = {"usa", "germany", "japan"}
def _mask_proxy(proxy: str) -> str:
"""脱敏代理 URL,隐藏用户名和密码。http://user:pass@host:port → http://***@host:port"""
if not proxy or "@" not in proxy:
return proxy or ""
scheme_rest = proxy.split("://", 1)
if len(scheme_rest) != 2:
return "***"
scheme, rest = scheme_rest
host_part = rest.split("@", 1)[-1]
return f"{scheme}://***@{host_part}"
def _validate_proxy(proxy: str) -> Optional[str]:
"""校验代理 URL 格式,防止恶意字符串注入。"""
if not proxy:
return None
proxy = proxy.strip()
if not _PROXY_RE.match(proxy):
logger.warning("代理格式无效,已拒绝: %r", proxy[:100])
return None
# Docker 容器内 127.0.0.1 指向容器自身,需替换为宿主机地址
if os.path.exists("/.dockerenv"):
proxy = proxy.replace("127.0.0.1", "host.docker.internal")
proxy = proxy.replace("localhost", "host.docker.internal")
return proxy
def _parse_proxy_for_playwright(proxy_str: str) -> Optional[dict]:
"""将代理 URL 解析为 Playwright proxy 配置格式。"""
if not proxy_str:
return None
# 格式: scheme://user:pass@host:port 或 scheme://host:port
if "://" in proxy_str and "@" in proxy_str:
scheme, rest = proxy_str.split("://", 1)
auth, addr = rest.rsplit("@", 1)
user, pwd = auth.split(":", 1)
return {
"server": f"{scheme}://{addr}",
"username": user,
"password": pwd,
}
return {"server": proxy_str}
# ─── AWS OIDC Device Flow ────────────────────────────────────────────────────
def _curl_post(url: str, payload: dict, headers: dict,
proxy: Optional[str] = None, timeout: int = 30,
retries: int = 3) -> dict:
"""HTTPS POST via curl — 代理 SSL 隧道不稳定时自动重试。"""
cmd = ["curl", "-s", "-X", "POST", url,
"--max-time", str(timeout), "--connect-timeout", "10",
"--tlsv1.2", "-H", "Content-Type: application/json",
"--retry", "2", "--retry-delay", "1"]
for k, v in headers.items():
if k.lower() != "content-type":
cmd.extend(["-H", f"{k}: {v}"])
cmd.extend(["-d", json.dumps(payload)])
if proxy:
cmd.extend(curl_proxy_args(proxy)) # 代理链:--preproxy Clash
cmd.extend(["--proxy", proxy, "--insecure", "--proxy-insecure"])
last_err = None
for attempt in range(1, retries + 1):
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 10)
if result.returncode == 0 and result.stdout.strip():
return json.loads(result.stdout)
stderr_info = result.stderr.strip()[:300] if result.stderr else "(no stderr)"
last_err = f"curl exit {result.returncode}: {stderr_info}"
except subprocess.TimeoutExpired:
last_err = f"curl subprocess timeout ({timeout + 10}s)"
except Exception as e:
last_err = str(e)
if attempt < retries:
delay = attempt * 3 # 递增重试间隔: 3s, 6s
logger.warning("_curl_post 第 %d 次失败: %s,%ds 后重试...", attempt, last_err, delay)
time.sleep(delay)
raise RuntimeError(last_err or "curl failed")
def _oidc_get_verification_url(proxy: Optional[str] = None, log_q=None):
"""
通过 AWS OIDC Device Flow 获取新鲜的 verificationUriComplete。
每次调用都向 AWS API 请求全新 session,不会像硬编码 workflowID 那样过期。
流程:
1. POST /client/register → clientId + clientSecret
2. POST /device_authorization → verificationUriComplete + deviceCode
返回: (verificationUriComplete, deviceCode, clientId, clientSecret, interval)
"""
def lq(msg):
logger.info("[oidc] %s", msg)
if log_q:
log_q.put(msg)
# OIDC API 通过 curl 走代理(urllib HTTPS 隧道 SSL 不稳定)
def _oidc_post(path, payload):
return _curl_post(f"{OIDC_BASE_URL}{path}", payload, _OIDC_HEADERS, proxy=proxy)
# Step 1: 注册 OIDC 公共客户端
lq("OIDC Step 1: 注册客户端...")
reg_data = _oidc_post("/client/register", {
"clientName": "Amazon Q Developer for command line",
"clientType": "public",
"scopes": [
"codewhisperer:completions",
"codewhisperer:analysis",
"codewhisperer:conversations",
],
"grantTypes": [
"urn:ietf:params:oauth:grant-type:device_code",
"refresh_token",
],
"issuerUrl": OIDC_START_URL,
})
client_id = reg_data["clientId"]
client_secret = reg_data["clientSecret"]
lq(f"OIDC: 客户端已注册 ({client_id[:24]}...)")
# Step 2: 发起设备授权,拿到 verificationUriComplete
lq("OIDC Step 2: 获取设备授权 URL...")
auth_data = _oidc_post("/device_authorization", {
"clientId": client_id,
"clientSecret": client_secret,
"startUrl": OIDC_START_URL,
})
verification_url = auth_data["verificationUriComplete"]
device_code = auth_data["deviceCode"]
user_code = auth_data.get("userCode", "")
interval = max(int(auth_data.get("interval", 5)), 5)
lq(f"OIDC: 授权 URL 已获取,user_code={user_code}")
return verification_url, device_code, client_id, client_secret, interval
def _oidc_poll_token(client_id, client_secret, device_code, interval=5,
proxy=None, timeout=60, log_q=None):
"""
OIDC Device Flow 最后一步:轮询 /token 获取 access_token + refresh_token。
在浏览器完成设备授权确认(Confirm + Allow)后调用。
返回: dict(含 accessToken, refreshToken 等)或 None(超时/错误)
"""
def lq(msg):
logger.info("[oidc-token] %s", msg)
if log_q:
log_q.put(msg)
# OIDC token 轮询通过 curl 走代理
lq("开始轮询 OIDC token...")
start = time.time()
while time.time() - start < timeout:
try:
data = _curl_post(
f"{OIDC_BASE_URL}/token",
{
"clientId": client_id,
"clientSecret": client_secret,
"deviceCode": device_code,
"grantType": "urn:ietf:params:oauth:grant-type:device_code",
},
_OIDC_HEADERS, proxy=proxy, timeout=15,
)
if "accessToken" in data:
lq("OIDC token 获取成功")
return data
# authorization_pending / slow_down 在正常 JSON 响应中
if "slow_down" in str(data).lower():
interval = min(interval + 2, 15)
except RuntimeError as exc:
err_str = str(exc).lower()
if "authorization_pending" in err_str:
pass
elif "slow_down" in err_str:
interval = min(interval + 2, 15)
else:
lq(f"OIDC token 轮询错误: {exc}")
return None
except Exception as e:
lq(f"OIDC token 轮询异常: {e}")
return None
time.sleep(interval)
lq("OIDC token 轮询超时")
return None
# ─── 核心注册逻辑(异步,Camoufox + Playwright)──────────────────────────────
async def _do_register(
email: str,
proxy: Optional[str],
yydsmail_url: str,
yydsmail_key: str,
region: str = "usa",
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
mail_provider: str = "",
mail_meta: Optional[dict] = None,
) -> dict:
def lq(msg: str):
"""同时写入 logger 和流式日志队列"""
logger.info("[%s] %s", email, msg)
if log_q is not None:
log_q.put(msg)
def _cancelled() -> bool:
return cancel is not None and cancel.is_set()
domain = _get_email_domain(email)
first_name, last_name = _random_name()
full_name = f"{first_name} {last_name}"
password = _generate_password()
# IP 地理位置自动检测(同步函数,放线程执行)
ip_loc = await asyncio.to_thread(_detect_ip_location, proxy)
tz = ip_loc["timezone"]
geo = ip_loc["geo"]
detected_locale = ip_loc["locale"]
lq(f"IP 地理位置: {ip_loc['country']} tz={tz} locale={detected_locale}")
browser = None
context = None
page = None
try:
if _cancelled():
return {"ok": False, "error": "任务已取消"}
# ── Camoufox + OIDC 并行启动 ──────────────────────────────────────
lq("并行启动: Camoufox + OIDC Device Flow...")
# OIDC 在后台线程跑(纯 HTTP,不依赖浏览器)
oidc_task = asyncio.create_task(asyncio.to_thread(
_oidc_get_verification_url, proxy=proxy, log_q=log_q,
))
# Headless 模式控制(与旧版逻辑一致)
import sys as _sys
_headless_env = os.getenv("KIRO_HEADLESS", "").lower()
if _headless_env in ("false", "0", "no"):
_use_headless = False
elif _headless_env in ("true", "1", "yes"):
_use_headless = True
else:
_use_headless = _sys.platform not in ("darwin", "win32") and not os.getenv("DISPLAY")
# 启动 Camoufox 浏览器实例(每次注册独立实例,指纹完全隔离)
cf = AsyncCamoufox(headless=_use_headless)
browser = await cf.start()
# 构建 Playwright context 选项(代理 + 地理 + locale + 时区)
context_opts = {
"locale": detected_locale,
"timezone_id": tz,
"geolocation": {"latitude": geo["latitude"], "longitude": geo["longitude"], "accuracy": geo["accuracy"]},
"permissions": ["geolocation"],
}
if proxy:
proxy = _validate_proxy(proxy)
if proxy:
# 代理链:浏览器连本地链式代理,经 Clash 转发到后端代理
chained = chain_proxy(proxy)
pw_proxy = _parse_proxy_for_playwright(chained)
if pw_proxy:
context_opts["proxy"] = pw_proxy
logger.info("[%s] 使用代理: %s (chain→%s)", email, _mask_proxy(proxy), chained.split(":")[-1] if "127.0.0.1" in chained else "direct")
else:
lq("[!] 代理格式无效,已忽略")
context = await browser.new_context(**context_opts)
page = await context.new_page()
lq("浏览器已启动")
# 等待 OIDC 结果(Chrome 启动期间已在并行执行,通常此时已完成)
try:
oidc_result = await asyncio.wait_for(oidc_task, timeout=30)
except asyncio.TimeoutError:
lq("[!] OIDC API 超时(30s 内未返回)")
return {"ok": False, "error": "AWS OIDC API 超时"}
except Exception as exc:
lq(f"[!] OIDC API 调用失败: {exc}")
return {"ok": False, "error": f"AWS OIDC API 失败: {exc}"}
signup_url, _device_code, _oidc_cid, _oidc_csec, _oidc_itv = oidc_result
# ── Step 1: 打开注册链接 ──────────────────────────────────────────
lq(f"注册链接: {signup_url[:80]}...")
await page.goto(signup_url, wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(1)
lq(f"页面: {(await page.title())[:60]} | {page.url[:80]}")
# 随机初始延迟(模拟人类初次浏览)
await asyncio.sleep(random.uniform(0.5, 1.5))
# Cookie 弹窗
await _pw_dismiss_cookie(page)
# ── Step 2: 设备确认页(SPA 渲染需要时间,轮询等待按钮出现)────
# 扩展等待时间到 60s,并增加更多选择器以覆盖 AWS UI 变化
_confirm_clicked = False
_initial_url = page.url
lq(f"初始 URL: {_initial_url[:80]}")
# 尝试更多选择器:按 text 内容、CSS class、aria-label 等
_button_selectors = [
"button#cli_verification_btn",
"button[data-testid='confirm-device-button']",
"button:has-text('Confirm')",
"button:has-text('Continue')",
"button:has-text('confirm')",
"button.awsui-button:has-text('Confirm')",
"[data-testid='confirm-button']",
"button[type='submit']",
]
for _wait in range(120): # 最多等 60s(120 × 0.5s)
if _cancelled():
return {"ok": False, "error": "任务已取消"}
# 检查 URL 是否已变化(可能页面已自动跳转)
if page.url != _initial_url and "device" not in page.url:
lq(f"[+] 页面已自动跳转: {page.url[:80]}")
_confirm_clicked = True # 标记为已确认(自动跳转)
break
for sel in _button_selectors:
try:
btn = page.locator(sel).first
if await btn.count() > 0:
# 检查按钮是否可见(避免点击隐藏元素)
try:
if await btn.is_visible(timeout=1000):
lq(f"设备确认页:点击按钮 ({sel})...")
await btn.click()
_confirm_clicked = True
await asyncio.sleep(2)
lq(f"确认后 URL: {page.url[:80]}")
break
except Exception:
# is_visible() 可能失败,尝试直接点击
lq(f"设备确认页:尝试点击按钮 ({sel})...")
await btn.click(force=True)
_confirm_clicked = True
await asyncio.sleep(2)
lq(f"确认后 URL: {page.url[:80]}")
break
except Exception:
continue
if _confirm_clicked:
break
# 每 10 秒输出一次进度
if _wait > 0 and _wait % 20 == 0:
lq(f"[*] 仍在等待设备确认... ({_wait * 0.5:.0f}s/60s)")
await asyncio.sleep(0.5)
if not _confirm_clicked:
lq("[!] 设备确认按钮未找到(60s 超时)")
# 如果仍在设备授权页面,说明需要手动确认,返回明确错误
if "device" in page.url and "user_code" in page.url:
lq(f"[!] 仍在设备授权页面,需要手动确认: {page.url[:80]}")
return {
"ok": False,
"error": "设备授权超时:页面停留在设备确认页(需在另一设备上确认),无法自动继续",
"retriable": False,
}
lq("[!] 设备确认按钮未找到,尝试继续...")
# ── Step 3: 填写邮箱 ─────────────────────────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
lq(f"填写邮箱: {email} | 当前页: {page.url[:80]}")
# 等待 SPA 渲染完成(AWS signin 是 React SPA,domcontentloaded 时 UI 还没挂载)
try:
await page.wait_for_load_state("networkidle", timeout=20000)
except Exception:
lq("[!] networkidle 等待超时(20s),继续尝试...")
await asyncio.sleep(1)
# 等待邮箱输入框出现(多选择器并行等待,覆盖 AWS CloudScape UI)
email_input = page.locator(
'input[placeholder="username@example.com"], '
'input[type="email"], '
'input[name="email"], '
'input[autocomplete="username"], '
'input[autocomplete="email"], '
'input[data-testid="username-input"], '
'input[data-testid="email-input"], '
'input.awsui-input-type-text, '
'#awsui-input-0'
).first
try:
await email_input.wait_for(state="visible", timeout=30000)
except Exception:
# SPA 可能还没渲染完,再等一轮重试
await asyncio.sleep(5)
try:
email_input = page.locator('input[type="text"], input[type="email"]').first
await email_input.wait_for(state="visible", timeout=10000)
except Exception:
lq(f"[!] 找不到邮箱输入框,URL={page.url[:100]}")
return {"ok": False, "error": "找不到邮箱输入框,页面可能已变化或被重定向"}
lq(f"邮箱输入框已找到")
await asyncio.sleep(random.uniform(0.3, 0.8))
# Playwright fill() 生成 isTrusted:true 事件,无需 React setter hack
await email_input.fill(email)
filled = await email_input.input_value()
lq(f"邮箱 read-back: '{filled}' (期望: '{email}')")
await asyncio.sleep(0.3)
await _pw_click_next(page, lq)
await asyncio.sleep(0.5)
await _pw_dismiss_cookie(page)
# ── Step 4: 填写姓名 ─────────────────────────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
lq(f"填写姓名: {full_name} | 当前页: {page.url[:80]}")
# 等 URL 跳转到 profile.aws(最多 15s)
for _i in range(15):
if "profile.aws" in page.url:
break
await asyncio.sleep(1)
else:
lq(f"[!] 邮件提交后未跳转 profile.aws")
return {"ok": False, "error": "email_domain_rejected", "retriable": True}
# 再等 1s 让 SPA 路由稳定
await asyncio.sleep(1)
if "profile.aws" not in page.url:
lq(f"邮箱域名被 AWS 拒绝(中转后跳回 signin.aws): {page.url[:80]}")
return {"ok": False, "error": "email_domain_rejected", "retriable": True}
name_input = page.locator(
'input[placeholder*="Silva"], '
'input[placeholder*="name" i], '
'input[name="full_name"], '
'input[name="name"]'
).first
try:
await name_input.wait_for(state="visible", timeout=15000)
except Exception:
logger.warning("[%s] 未找到姓名输入框,跳过", email)
name_input = None
if name_input:
lq(f"姓名输入框已找到")
await asyncio.sleep(random.uniform(0.3, 0.8))
await name_input.fill(full_name)
filled = await name_input.input_value()
lq(f"姓名字段 read-back: '{filled}' (期望: '{full_name}')")
await asyncio.sleep(0.3)
await _pw_dismiss_cookie(page)
await _pw_click_next(page, lq)
# 轮询等待跳转,同时检查错误
_name_err_texts = ("抱歉,处理您的请求时出错", "Sorry, there was an error", "Please try again", "错误")
_jumped = False
for _i in range(10):
await asyncio.sleep(1)
await _pw_dismiss_cookie(page)
if "verify-otp" in page.url or "create-password" in page.url:
_jumped = True
break
try:
_body = await page.locator("body").inner_text()
except Exception:
_body = ""
if any(k in _body for k in _name_err_texts):
lq(f"[!] 名字页报错: {_body[:120]}")
return {"ok": False, "error": "email_domain_rejected", "retriable": True}
if not _jumped:
try:
_body = await page.locator("body").inner_text()
except Exception:
_body = ""
lq(f"名字页 10s 未跳转: {_body[:80]}")
return {"ok": False, "error": "name_page_blocked", "retriable": True}
await asyncio.sleep(0.3)
# ── Step 5: 等待验证码并填写(仅在确实处于 OTP 页时执行)────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
otp_selectors = [
'input[placeholder="6 位数"]',
'input[placeholder*="digit" i]',
'input[placeholder*="code" i]',
'input[autocomplete="one-time-code"]',
'input[name="code"]',
'input[name="otp"]',
'input[type="text"][maxlength="6"]',
'input[type="number"]',
]
pwd_selector = 'input[type="password"]'
otp_input = None
direct_password_ready = False
for _ in range(10):
if _cancelled():
return {"ok": False, "error": "任务已取消"}
if "create-password" in page.url:
direct_password_ready = True
break
try:
if await page.locator(pwd_selector).count() > 0:
direct_password_ready = True
break
except Exception:
pass
for sel in otp_selectors:
try:
loc = page.locator(sel).first
if await loc.count() > 0 and await loc.is_visible():
otp_input = loc
break
except Exception:
pass
if otp_input:
break
await asyncio.sleep(0.5)
if direct_password_ready:
lq("检测到流程已直接进入密码设置页,跳过邮箱验证码轮询")
else:
lq(f"等待 AWS 验证码邮件(最长 120s)... | provider={mail_provider or 'yydsmail'} | 当前页: {page.url[:80]}")
# 邮件轮询在线程中执行(同步阻塞函数)
code = await asyncio.to_thread(
_poll_verification_code,
email, provider=mail_provider or "yydsmail", meta=mail_meta or {},
yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key,
timeout=_MAIL_TIMEOUT, log_q=log_q, cancel=cancel,
)
if not code:
return {"ok": False, "error": f"验证码超时({_MAIL_TIMEOUT}s),provider={mail_provider or 'yydsmail'}"}
lq("验证码已收到,正在填写...")
if not otp_input:
for _ in range(30):
if _cancelled():
return {"ok": False, "error": "任务已取消"}
for sel in otp_selectors:
try:
loc = page.locator(sel).first
if await loc.count() > 0 and await loc.is_visible():
otp_input = loc
break
except Exception:
pass
if otp_input:
break
await asyncio.sleep(0.5)
if not otp_input:
return {"ok": False, "error": "找不到验证码输入框"}
lq("验证码输入框已找到")
await asyncio.sleep(random.uniform(0.3, 0.6))
await otp_input.fill(code)
lq(f"验证码 read-back: '{await otp_input.input_value()}'")
await asyncio.sleep(0.3)
_url_before_otp = page.url
await _pw_click_next(page, lq)
# 等待页面跳转到密码设置页,而不是盲等 1s
await _pw_wait_navigation(page, _url_before_otp, timeout=20,
expect_selector=pwd_selector, lq=lq)
await asyncio.sleep(0.5)
# ── Step 6: 设置密码 ─────────────────────────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
lq("设置账号密码...")
# 快速轮询等密码框出现(从 8s 提升到 20s,给页面跳转留足时间)
pwd_inputs = []
for _ in range(40):
pwd_inputs = await page.locator('input[type="password"]').all()
if len(pwd_inputs) >= 2:
break
# 也检查单个密码框的情况
if len(pwd_inputs) == 1:
await asyncio.sleep(1)
pwd_inputs = await page.locator('input[type="password"]').all()
break
await asyncio.sleep(0.5)
if len(pwd_inputs) >= 2:
lq(f"密码框已找到({len(pwd_inputs)} 个),快速填写...")
await pwd_inputs[0].fill(password)
await pwd_inputs[1].fill(password)
_url_before_pwd = page.url
await _pw_click_next(page, lq)
await _pw_wait_navigation(page, _url_before_pwd, timeout=15,
expect_selector='button#cli_verification_btn, [data-testid="allow-access-button"]', lq=lq)
await asyncio.sleep(0.5)
elif len(pwd_inputs) == 1:
lq("只找到 1 个密码框,填写...")
await pwd_inputs[0].fill(password)
_url_before_pwd = page.url
await _pw_click_next(page, lq)
await _pw_wait_navigation(page, _url_before_pwd, timeout=15,
expect_selector='button#cli_verification_btn, [data-testid="allow-access-button"]', lq=lq)
await asyncio.sleep(0.5)
else:
lq("[!] 未找到密码输入框,尝试等待更长时间...")
# 最后兜底:再等 10s 看看密码框会不会出现
_last_chance = []
for _ in range(20):
_last_chance = await page.locator('input[type="password"]').all()
if _last_chance:
break
await asyncio.sleep(0.5)
if _last_chance:
lq(f"兜底等待成功,找到 {len(_last_chance)} 个密码框")
await _last_chance[0].fill(password)
if len(_last_chance) >= 2:
await _last_chance[1].fill(password)
_url_before_pwd = page.url
await _pw_click_next(page, lq)
await _pw_wait_navigation(page, _url_before_pwd, timeout=15,
expect_selector='button#cli_verification_btn, [data-testid="allow-access-button"]', lq=lq)
await asyncio.sleep(0.5)
else:
lq("[!] 密码输入框始终未出现,流程可能已跳过")
# ── Step 7: 二次设备确认 ──────────────────────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
lq("等待设备确认页面...")
_dev_clicked = False
for _ in range(30):
if _cancelled():
return {"ok": False, "error": "任务已取消"}
for sel in ["button#cli_verification_btn", "button[data-testid='confirm-device-button']"]:
try:
btn = page.locator(sel).first
if await btn.count() > 0 and await btn.is_visible():
lq("设备确认:点击 Confirm...")
await btn.click()
_dev_clicked = True
break
except Exception:
pass
if _dev_clicked:
break
await asyncio.sleep(0.5)
if _dev_clicked:
await asyncio.sleep(3)
else:
lq("未找到设备确认按钮(可能已自动跳过),继续...")
# ── Step 7.5: 允许访问授权页 ──────────────────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
lq("等待允许访问页面...")
_allow_clicked = False
for _ in range(30):
if _cancelled():
return {"ok": False, "error": "任务已取消"}
try:
btn = page.locator('[data-testid="allow-access-button"]').first
if await btn.count() > 0 and await btn.is_visible():
lq("点击「允许访问」完成授权...")
await btn.click()
_allow_clicked = True
break
except Exception:
pass
await asyncio.sleep(0.5)
if _allow_clicked:
await asyncio.sleep(3)
else:
lq("未找到允许访问按钮(可能已自动跳过),继续...")
# ── Step 7.7: OIDC token 轮询 ────────────────────────────────────
_token_data = None
if _dev_clicked or _allow_clicked:
if not _cancelled():
_token_data = await asyncio.to_thread(
_oidc_poll_token,
_oidc_cid, _oidc_csec, _device_code,
_oidc_itv, proxy, 30, log_q,
)
# ── Step 8: 验证注册结果 ──────────────────────────────────────────
await asyncio.sleep(0.5)
final_url = page.url
lq(f"最终 URL: {final_url}")
page_success = await _pw_check_success(page)
url_success = (
any(d in final_url for d in (
"builder.aws.com", "aws.amazon.com", "profile.aws",
"view.awsapps.com",
))
and "sign-in" not in final_url
and "error" not in final_url.lower()
)
token_success = _token_data is not None and "accessToken" in (_token_data or {})
lq(f"页面标题: {(await page.title())[:60]}")
if page_success or url_success or token_success:
lq(f"注册成功(page={page_success} url={url_success} token={token_success})")
result = {"ok": True, "email": email, "password": password, "name": full_name}
if _token_data and "accessToken" in _token_data:
from datetime import datetime, timezone, timedelta
expires_in = _token_data.get("expiresIn", 3600)
now = datetime.now(timezone(timedelta(hours=8)))
result["access_token"] = _token_data["accessToken"]
result["refresh_token"] = _token_data.get("refreshToken", "")
result["client_id"] = _oidc_cid
result["client_secret"] = _oidc_csec
result["expires_at"] = (now + timedelta(seconds=expires_in)).strftime("%Y-%m-%dT%H:%M:%S+08:00")
result["last_refresh"] = now.strftime("%Y-%m-%dT%H:%M:%S+08:00")
result["region"] = "us-east-1"
return result
else:
logger.warning("[%s] 注册流程完成但无法确认成功,final_url=%s", email, final_url)
return {"ok": False, "error": "注册流程已执行但无法确认账户创建成功,请检查邮箱是否已激活"}
except asyncio.CancelledError:
logger.info("[%s] 注册任务被取消", email)
return {"ok": False, "error": "任务已取消"}
except Exception as exc:
logger.error("[%s] 注册异常: %s", email, exc, exc_info=True)
return {"ok": False, "error": str(exc)}
finally:
# Playwright 资源清理(page → context → browser)
for resource in [page, context, browser]:
if resource:
try:
await resource.close()
except Exception:
pass
# ─── Gemini 辅助函数 ──────────────────────────────────────────────────────────
def _extract_gemini_xsrf(html: str) -> str:
"""从 Gemini 登录页面提取 XSRF Token(4 种策略:meta tag / hidden input / JS var / URL param)。"""
for pattern in [
r'name=["\']xsrf-token["\']\s+content=["\']([^"\']+)["\']',
r'name=["\']xsrfToken["\'][^>]*value=["\']([A-Za-z0-9_-]{20,})["\']',
r'xsrfToken["\']?\s*[=:]\s*["\']([A-Za-z0-9_-]{20,})["\']',
r'xsrfToken=([A-Za-z0-9_-]{20,})',
]:
m = re.search(pattern, html, re.IGNORECASE)
if m:
return m.group(1)
# 不再使用硬编码 fallback(会过期),返回空串让调用方处理
logger.warning("XSRF Token 提取失败,所有策略均未匹配")
return ""
async def _get_page_text(page) -> str:
"""安全获取页面 body 文本"""
try:
return await page.locator("body").inner_text()
except Exception:
return ""
async def _human_type(page, locator, text: str):
"""模拟人类打字:逐字符输入,随机 80-180ms 间隔,20% 概率额外停顿 200-500ms。"""
await locator.click()
await asyncio.sleep(random.uniform(0.1, 0.3))
for ch in text:
await page.keyboard.type(ch)
delay = random.uniform(0.08, 0.18)
if random.random() < 0.2:
delay += random.uniform(0.2, 0.5)
await asyncio.sleep(delay)
_GEMINI_CODE_INPUT_SELECTORS = [
"input[jsname='ovqh0b']",
"input[type='tel']",
"input[name='pinInput']",
"input[autocomplete='one-time-code']",
"input[aria-label*='验证码']",
"input[aria-label*='verification code' i]",
]
_GEMINI_EMAIL_INPUT_SELECTORS = [
"#email-input",
"input[name='loginHint']",
"input[aria-label='邮箱']",
"input[aria-label*='email' i]",
]
_GEMINI_EMAIL_CONTINUE_SELECTORS = [
"#log-in-button",
"button#log-in-button",
"button[jsname='jXw9Fb']",
"button[aria-label='使用邮箱继续']",
"button[aria-label='Continue with email']",
"button[aria-label='Use email to continue']",
"button[type='submit']",
]
def _classify_gemini_page_state(
url: str,
body_text: str = "",
*,
has_code_input: bool = False,
has_email_input: bool = False,
has_login_button: bool = False,
) -> str:
"""把 Gemini 页面粗分为邮箱输入页 / 验证码页 / 已登录 / signin-error。"""
url_lower = (url or "").lower()
body_lower = (body_text or "").lower()
if "signin-error" in url_lower:
return "signin_error"
if "business.gemini.google" in url_lower and "csesidx=" in url_lower and "/cid/" in url_lower:
return "signed_in"
code_hints = (
"请输入验证码",
"重新发送验证码",
"we sent a 6 character code",
"enter the verification code",
"verification code sent",
)
if "verify-oob-code" in url_lower or has_code_input or any(h in body_lower for h in code_hints):
return "verify_code"
email_hints = (
"使用邮箱继续",
"继续使用邮箱",
"work email",
"login or create a free trial account",
"create account or sign in",
)
if has_email_input or has_login_button or any(h in body_lower for h in email_hints):
return "email_entry"
return "unknown"
async def _get_first_visible_locator(page, selectors):
"""返回首个可见 locator;找不到时返回 None。"""
for selector in selectors:
try:
loc = page.locator(selector).first
if await loc.count() > 0 and await loc.is_visible():
return loc
except Exception:
continue
return None
async def _get_gemini_page_state(page):
"""读取当前 Gemini 页面状态和正文文本。"""
body_text = await _get_page_text(page)
code_input = await _get_first_visible_locator(page, _GEMINI_CODE_INPUT_SELECTORS)
email_input = await _get_first_visible_locator(page, _GEMINI_EMAIL_INPUT_SELECTORS)
login_button = await _get_first_visible_locator(page, _GEMINI_EMAIL_CONTINUE_SELECTORS[:-1])
state = _classify_gemini_page_state(
page.url,
body_text,
has_code_input=code_input is not None,
has_email_input=email_input is not None,
has_login_button=login_button is not None,
)
return {
"state": state,
"url": page.url,
"body_text": body_text,
"code_input": code_input,
"email_input": email_input,
"login_button": login_button,
}
async def _wait_for_gemini_page_state(page, timeout: float = 15.0, interval: float = 0.5, lq=None):
"""
等待 Gemini 页面稳定到可识别状态。
解决 Google 前端渲染较慢时,3-5s 固定 sleep 还没落到真实页面的问题。
"""
deadline = asyncio.get_running_loop().time() + timeout
last_state = None
last_info = None
while True:
info = await _get_gemini_page_state(page)
last_info = info
if info["state"] != last_state:
last_state = info["state"]
if lq:
lq(f"Gemini 页面状态: {info['state']} | {info['url'][:80]}")
if info["state"] != "unknown":
return info
if asyncio.get_running_loop().time() >= deadline:
return info
await asyncio.sleep(interval)
async def _click_gemini_email_continue(page, email: str, lq) -> bool:
"""在邮箱输入页点击当前真实的“使用邮箱继续”按钮。"""
email_input = await _get_first_visible_locator(page, _GEMINI_EMAIL_INPUT_SELECTORS)
if email_input is not None:
try:
current_value = (await email_input.input_value()).strip()
except Exception:
current_value = ""
if current_value.lower() != email.lower():
try:
await email_input.fill("")
except Exception:
pass
await asyncio.sleep(random.uniform(0.2, 0.5))
await _human_type(page, email_input, email)
await asyncio.sleep(random.uniform(0.3, 0.8))
lq("邮箱输入框内容已补齐")
continue_button = await _get_first_visible_locator(page, _GEMINI_EMAIL_CONTINUE_SELECTORS)
if continue_button is not None:
label = ""
try:
label = (await continue_button.inner_text()).strip()
except Exception:
try:
label = (await continue_button.get_attribute("aria-label")) or ""
except Exception:
label = ""
await continue_button.click()
await asyncio.sleep(random.uniform(1.0, 2.0))
lq(f"点击邮箱继续按钮: {label or 'submit'}")
return True
for text_kw in ("使用邮箱继续", "Continue with email", "Use email to continue", "继续", "Next"):
try:
btn = page.locator(f"button:has-text('{text_kw}'), div[role='button']:has-text('{text_kw}')").first
if await btn.count() > 0 and await btn.is_visible():
await btn.click()
await asyncio.sleep(random.uniform(1.0, 2.0))
lq(f"点击文本按钮成功: {text_kw}")
return True
except Exception:
continue
return False
async def _extract_gemini_config(page, context, email: str, lq) -> dict:
"""从 Gemini Business 页面提取 config_id、csesidx 和 cookies。"""
url = page.url
if "cid/" not in url:
try:
await page.goto("https://business.gemini.google/",
wait_until="domcontentloaded", timeout=30000)
await asyncio.sleep(random.uniform(2, 4))
url = page.url
except Exception:
pass
if "cid/" not in url:
return {"ok": False, "error": "cid not found in URL"}
try:
config_id = url.split("cid/")[1].split("?")[0].split("/")[0]
except (IndexError, ValueError):
return {"ok": False, "error": "无法解析 config_id"}
csesidx = ""
if "csesidx=" in url:
try:
csesidx = url.split("csesidx=")[1].split("&")[0]
except (IndexError, ValueError):
pass
# 轮询等待 cookies 就位
c_ses = ""
c_oses = ""
ses_obj = None
for _ in range(20):
cookies = await context.cookies()
c_ses = next((c["value"] for c in cookies if c["name"] == "__Secure-C_SES"), "")
c_oses = next((c["value"] for c in cookies if c["name"] == "__Host-C_OSES"), "")
ses_obj = next((c for c in cookies if c["name"] == "__Secure-C_SES"), None)
if c_ses and c_oses:
break
await asyncio.sleep(1)
if not c_ses or not c_oses:
missing = []
if not c_ses:
missing.append("C_SES")
if not c_oses:
missing.append("C_OSES")
lq(f"[!] Cookie 不完整 ({', '.join(missing)} 缺失),账号不可用")
return {"ok": False, "error": f"Cookie 不完整 ({', '.join(missing)} 缺失)", "retriable": False}
from datetime import datetime, timezone as dt_timezone, timedelta
beijing_tz = dt_timezone(timedelta(hours=8))
if ses_obj and "expires" in ses_obj:
cookie_expire = datetime.fromtimestamp(ses_obj["expires"], tz=beijing_tz)
expires_at = (cookie_expire - timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%S+08:00")
else:
expires_at = (datetime.now(beijing_tz) + timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%S+08:00")
# 提取试用期
trial_end_date = await _extract_gemini_trial_end(page, csesidx, config_id, lq)
result = {
"ok": True,
"email": email,
"config_id": config_id,
"csesidx": csesidx,
"c_ses": c_ses,
"c_oses": c_oses,
"expires_at": expires_at,
}
if trial_end_date:
result["trial_end_date"] = trial_end_date
lq(f"配置提取完成: config_id={config_id}, csesidx={csesidx[:20]}...")
return result
async def _extract_gemini_trial_end(page, csesidx: str, config_id: str, lq) -> Optional[str]:
"""从页面源码中提取 Gemini 试用期到期日期。"""
from datetime import datetime, timezone as dt_timezone, timedelta
def _days_to_end(days: int) -> str:
return (datetime.now(dt_timezone(timedelta(hours=8))) + timedelta(days=days)).strftime("%Y-%m-%d")
def _search_source(source: str) -> Optional[str]:
m = re.search(r'"daysLeft"\s*:\s*(\d+)', source)
if m:
return _days_to_end(int(m.group(1)))
m = re.search(r'"trialDaysRemaining"\s*:\s*(\d+)', source)
if m:
return _days_to_end(int(m.group(1)))
m = re.search(r'\[(\d{4}),(\d{1,2}),(\d{1,2})\].*?\[(\d{4}),(\d{1,2}),(\d{1,2})\]', source)
if m:
try:
end_date = f"{m.group(4):0>4}-{int(m.group(5)):02d}-{int(m.group(6)):02d}"
if 2025 <= int(m.group(4)) <= 2030:
return end_date
except Exception:
pass
m = re.search(r'(\d+)\s*days?\s*left', source, re.IGNORECASE)
if m:
return _days_to_end(int(m.group(1)))
# ISO 日期格式(freeTrialEndDate / trialExpiry / expirationDate)
m = re.search(r'(?:freeTrialEndDate|trialExpiry|expirationDate|endDate)["\s:]+(\d{4}-\d{2}-\d{2})', source)
if m:
try:
year = int(m.group(1)[:4])
if 2025 <= year <= 2030:
return m.group(1)
except Exception:
pass
return None
try:
source = await page.content()
result = _search_source(source or "")
if result:
lq(f"试用期到期: {result}")
return result
# 尝试 settings 页
settings_url = f"https://business.gemini.google/cid/{config_id}/settings?csesidx={csesidx}"
await page.goto(settings_url, wait_until="domcontentloaded", timeout=30000)
await asyncio.sleep(random.uniform(1.5, 3))
source = await page.content()
result = _search_source(source or "")
if result:
lq(f"试用期到期: {result}")
return result
except Exception as e:
lq(f"[!] 获取试用期失败: {e}")
return None
# ─── 403/风控页面检测(Gemini 专用)────────────────────────────────────────
_GEMINI_BLOCK_PATTERNS = [
"Access Restricted",
"This service is restricted",
"account suspended",
"not available for your account",
"account has been disabled",
"unusual activity",
"can't sign you in",
"couldn't verify",
]
def _detect_gemini_block(body_text: str, email: str, lq) -> Optional[dict]:
"""检测 403 / 风控页面,返回错误 dict 或 None。"""
for pattern in _GEMINI_BLOCK_PATTERNS:
if pattern.lower() in body_text.lower():
domain = email.rsplit("@", 1)[-1] if "@" in email else "unknown"
lq(f"[!] 页面被拦截: {pattern} ({domain})")
return {"ok": False, "error": f"blocked: {pattern} ({domain})", "retriable": False}
return None
# ── CAPTCHA / 人机验证检测 ──────────────────────────────────────────────────
_CAPTCHA_PATTERNS = [
"recaptcha",
"g-recaptcha",
"verify you're a human",
"prove you're not a robot",
"unusual traffic from your computer",
"automated queries",
"complete the security check",
"are you a robot",
]
def _detect_captcha(body_text: str, email: str, lq) -> Optional[dict]:
"""检测 CAPTCHA / 人机验证拦截,返回错误 dict 或 None。"""
lower = body_text.lower()
for pattern in _CAPTCHA_PATTERNS:
if pattern.lower() in lower:
lq(f"[!] CAPTCHA 拦截: {pattern}")
return {"ok": False, "error": f"captcha: {pattern}", "retriable": True}
return None
# ─── Gemini 注册核心逻辑(异步,Camoufox + Playwright)──────────────────────
async def _do_gemini_register(
email: str,
proxy: Optional[str],
yydsmail_url: str,
yydsmail_key: str,
log_q: Optional[queue.Queue] = None,
cancel: Optional[Event] = None,
mail_provider: str = "",
mail_meta: Optional[dict] = None,
) -> dict:
"""Gemini Business 注册流程(Camoufox + Playwright)。"""
def lq(msg: str):
logger.info("[%s] %s", email, msg)
if log_q is not None:
log_q.put(msg)
def _cancelled() -> bool:
return cancel is not None and cancel.is_set()
first_name, last_name = _random_name()
full_name = f"{first_name} {last_name}"
# IP 地理位置自动检测
ip_loc = await asyncio.to_thread(_detect_ip_location, proxy)
tz = ip_loc["timezone"]
geo = ip_loc["geo"]
detected_locale = ip_loc["locale"]
lq(f"IP 地理位置: {ip_loc['country']} tz={tz}")
browser = None
context = None
page = None
try:
if _cancelled():
return {"ok": False, "error": "任务已取消"}
# ── 启动 Camoufox ──────────────────────────────────────────
lq("启动 Camoufox 浏览器...")
import sys as _sys
_headless_env = os.getenv("GEMINI_HEADLESS", os.getenv("KIRO_HEADLESS", "")).lower()
if _headless_env in ("false", "0", "no"):
_use_headless = False
elif _headless_env in ("true", "1", "yes"):
_use_headless = True
else:
_use_headless = _sys.platform not in ("darwin", "win32") and not os.getenv("DISPLAY")
cf = AsyncCamoufox(headless=_use_headless)
browser = await cf.start()
context_opts = {
"locale": detected_locale,
"timezone_id": tz,
"geolocation": {"latitude": geo["latitude"], "longitude": geo["longitude"], "accuracy": geo["accuracy"]},
"permissions": ["geolocation"],
}
if proxy:
proxy = _validate_proxy(proxy)
if proxy:
chained = chain_proxy(proxy)
pw_proxy = _parse_proxy_for_playwright(chained)
if pw_proxy:
context_opts["proxy"] = pw_proxy
logger.info("[%s] Gemini 使用代理: %s", email, _mask_proxy(proxy))
else:
lq("[!] 代理格式无效,已忽略")
context = await browser.new_context(**context_opts)
# WebRTC 泄漏保护:阻止通过 RTCPeerConnection 暴露真实 IP
await context.add_init_script("""
Object.defineProperty(navigator, 'mediaDevices', {
get: () => ({
enumerateDevices: () => Promise.resolve([]),
getUserMedia: () => Promise.reject(new DOMException('NotAllowedError')),
})
});
window.RTCPeerConnection = undefined;
window.mozRTCPeerConnection = undefined;
window.webkitRTCPeerConnection = undefined;
""")
page = await context.new_page()
lq("浏览器已启动")
# ── Step 1: 打开登录页面 ──────────────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
lq("打开 Gemini 登录页面...")
await page.goto(GEMINI_AUTH_URL, wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(random.uniform(1, 2))
lq(f"页面: {(await page.title())[:60]} | {page.url[:80]}")
# ── Step 2: 提取 XSRF Token + 设置 Cookie ────────────────
html = await page.content()
xsrf_token = _extract_gemini_xsrf(html)
if not xsrf_token:
lq("[!] XSRF Token 提取失败,尝试刷新页面重新获取...")
await page.reload(wait_until="domcontentloaded", timeout=30000)
await asyncio.sleep(random.uniform(1, 2))
html = await page.content()
xsrf_token = _extract_gemini_xsrf(html)
if not xsrf_token:
lq("[!] XSRF Token 二次提取仍失败,Google 页面结构可能已变化")
return {"ok": False, "error": "XSRF token extraction failed", "retriable": True}
lq(f"XSRF Token: {xsrf_token[:20]}...")
# __Host- 前缀 cookie 不允许 domain 属性(RFC 6265bis),
# Camoufox (Playwright/Firefox) 会剥离 domain 导致验证失败;
# 改用 url 让 Playwright 自动推导 domain + path
await context.add_cookies([{
"name": "__Host-AP_SignInXsrf",
"value": xsrf_token,
"url": "https://auth.business.gemini.google/",
"secure": True,
}])
# ── Step 3: URL 方式提交邮箱 ─────────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
login_hint = urllib.parse.quote(email, safe="")
login_url = (
f"https://auth.business.gemini.google/login/email"
f"?continueUrl=https%3A%2F%2Fbusiness.gemini.google%2F"
f"&loginHint={login_hint}&xsrfToken={xsrf_token}"
)
lq("URL 方式提交邮箱...")
await page.goto(login_url, wait_until="domcontentloaded", timeout=60000)
page_state = await _wait_for_gemini_page_state(page, timeout=15.0, lq=lq)
current_url = page_state["url"]
lq(f"当前 URL: {current_url[:80]}")
# 检查 signin-error
if page_state["state"] == "signin_error":
lq("[!] signin-error 页面")
return {"ok": False, "error": "signin-error: token rejected by Google", "retriable": False}
# 检查是否已登录
if page_state["state"] == "signed_in":
lq("已登录,直接提取配置")
return await _extract_gemini_config(page, context, email, lq)
# 检查 403 / 风控
body_text = page_state["body_text"]
block_result = _detect_gemini_block(body_text, email, lq)
if block_result:
return block_result
captcha_result = _detect_captcha(body_text, email, lq)
if captcha_result:
return captcha_result
# ── Step 4: 点击发送验证码 ───────────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
send_ok = False
if page_state["state"] == "verify_code":
lq("URL 提交后已直接进入验证码页,跳过发送按钮点击")
send_ok = True
else:
lq("当前仍在邮箱确认页,尝试点击“使用邮箱继续”...")
for send_attempt in range(3):
if _cancelled():
return {"ok": False, "error": "任务已取消"}
page_state = await _get_gemini_page_state(page)
if page_state["state"] == "verify_code":
lq("检测到验证码页已出现,验证码应已自动发送")
send_ok = True
break
if page_state["state"] == "signin_error":
lq("[!] signin-error 页面")
return {"ok": False, "error": "signin-error: token rejected by Google", "retriable": False}
if page_state["state"] == "signed_in":
lq("已登录,直接提取配置")
return await _extract_gemini_config(page, context, email, lq)
body_text = page_state["body_text"]
block_result = _detect_gemini_block(body_text, email, lq)
if block_result:
return block_result
captcha_result = _detect_captcha(body_text, email, lq)
if captcha_result:
return captcha_result
clicked = await _click_gemini_email_continue(page, email, lq)
if not clicked:
delay = [4, 6, 8][min(send_attempt, 2)]
lq(f"未找到当前页面的邮箱继续按钮,{delay}s 后重试 ({send_attempt + 1}/3)")
await asyncio.sleep(delay)
continue
page_state = await _wait_for_gemini_page_state(page, timeout=20.0, lq=lq)
if page_state["state"] == "verify_code":
lq("点击后进入验证码页,验证码发送流程已触发")
send_ok = True
break
if page_state["state"] == "signed_in":
lq("点击后直接登录成功,跳过验证码轮询")
return await _extract_gemini_config(page, context, email, lq)
if page_state["state"] == "signin_error":
lq("[!] signin-error 页面")
return {"ok": False, "error": "signin-error: token rejected by Google", "retriable": False}
body_text = page_state["body_text"]
block_result = _detect_gemini_block(body_text, email, lq)
if block_result:
return block_result
captcha_result = _detect_captcha(body_text, email, lq)
if captcha_result:
return captcha_result
delay = [4, 6, 8][min(send_attempt, 2)]
lq(f"点击后仍未进入验证码页,{delay}s 后重试 ({send_attempt + 1}/3)")
await asyncio.sleep(delay)
if not send_ok:
lq("[!] 验证码发送失败(可能触发风控)")
return {"ok": False, "error": "send code failed", "retriable": True}
# ── Step 5: 等待验证码输入框 ─────────────────────────────
lq("等待验证码输入框...")
code_input = page_state.get("code_input")
_code_selectors = _GEMINI_CODE_INPUT_SELECTORS
for _ in range(30):
if _cancelled():
return {"ok": False, "error": "任务已取消"}
if code_input is not None:
break
for sel in _code_selectors:
try:
loc = page.locator(sel).first
if await loc.count() > 0 and await loc.is_visible():
code_input = loc
break
except Exception:
pass
if code_input:
break
await asyncio.sleep(1)
if not code_input:
# 检查是否被 CAPTCHA 拦截导致输入框未出现
body_text = await _get_page_text(page)
captcha_result = _detect_captcha(body_text, email, lq)
if captcha_result:
return captcha_result
lq("[!] 验证码输入框未出现")
return {"ok": False, "error": "code input not found"}
lq("验证码输入框已找到")
# ── Step 6: 轮询邮件获取验证码 ───────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
lq(f"等待 Google 验证码邮件... (provider={mail_provider or 'yydsmail'})")
code = await asyncio.to_thread(
_poll_gemini_verification_code,
email, provider=mail_provider or "yydsmail", meta=mail_meta or {},
yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key,
timeout=_MAIL_TIMEOUT, log_q=log_q, cancel=cancel,
)
if not code:
# 重试:点重发按钮后再轮询
lq("验证码超时,尝试重新发送...")
try:
resend_btn = page.locator(
"button:has-text('重新'), button:has-text('Resend'), button:has-text('resend'), "
"button:has-text('Send again'), button:has-text('Try again'), button:has-text('重新发送')"
).first
if await resend_btn.count() > 0:
await resend_btn.click()
await asyncio.sleep(2)
code = await asyncio.to_thread(
_poll_gemini_verification_code,
email, provider=mail_provider or "yydsmail", meta=mail_meta or {},
yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key,
timeout=90, log_q=log_q, cancel=cancel,
)
except Exception:
pass
if not code:
return {"ok": False, "error": f"验证码超时 (provider={mail_provider or 'yydsmail'})", "retriable": True}
lq(f"验证码: {code[:2]}{'*' * max(len(code) - 2, 0)}")
# ── Step 7: 输入验证码并提交 ─────────────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
# 重新获取输入框(可能已刷新 DOM)
for sel in _code_selectors:
try:
loc = page.locator(sel).first
if await loc.count() > 0 and await loc.is_visible():
code_input = loc
break
except Exception:
pass
await asyncio.sleep(random.uniform(0.3, 0.8))
# 人类模拟打字(逐字符输入,降低风控检测概率)
await _human_type(page, code_input, code)
lq(f"验证码 read-back: {await code_input.input_value()}")
await asyncio.sleep(random.uniform(0.3, 0.6))
# 提交:先 Enter,再找验证按钮兜底
await code_input.press("Enter")
await asyncio.sleep(random.uniform(0.8, 1.5))
if "verify-oob-code" in page.url:
for btn_text in ["验证", "Verify", "Next", "确认"]:
try:
vbtn = page.locator(f"button:has-text('{btn_text}')").first
if await vbtn.count() > 0 and await vbtn.is_visible():
await vbtn.click()
await asyncio.sleep(1)
break
except Exception:
continue
# ── Step 8: 检查提交结果 + 403 检测 ──────────────────────
if _cancelled():
return {"ok": False, "error": "任务已取消"}
await asyncio.sleep(1.5)
body_text = await _get_page_text(page)
block_result = _detect_gemini_block(body_text, email, lq)
if block_result:
return block_result
captcha_result = _detect_captcha(body_text, email, lq)
if captcha_result:
return captcha_result
if "verify-oob-code" in page.url:
lq("[!] 验证码提交失败")
return {"ok": False, "error": "verification code submission failed", "retriable": True}
# ── Step 9: 处理用户名设置(新账号)─────────────────────
lq("等待用户名设置页面...")
_name_selectors = [
"input[formcontrolname='fullName']",
"input#mat-input-0",
"input[placeholder='全名']",
"input[placeholder='Full name']",
"input[name='displayName']",
"input[type='text']",
]
name_input = None
for _ in range(30):
if _cancelled():
return {"ok": False, "error": "任务已取消"}
# 检查是否已到达业务页面
if "business.gemini.google" in page.url and "csesidx=" in page.url and "/cid/" in page.url:
lq("已到达业务页面,跳过用户名设置")
break
for sel in _name_selectors:
try:
loc = page.locator(sel).first
if await loc.count() > 0 and await loc.is_visible():
name_input = loc
break
except Exception:
pass
if name_input:
break
await asyncio.sleep(1)
if name_input:
lq(f"输入姓名: {full_name}")
await asyncio.sleep(random.uniform(0.3, 0.8))
await _human_type(page, name_input, full_name)
await asyncio.sleep(random.uniform(0.3, 0.6))
await name_input.press("Enter")
await asyncio.sleep(random.uniform(0.8, 1.5))
# Enter 没跳转时找提交按钮
if "cid" not in page.url:
for btn_text in ["继续", "Continue", "提交", "Submit", "Next"]:
try:
sbtn = page.locator(f"button:has-text('{btn_text}')").first
if await sbtn.count() > 0 and await sbtn.is_visible():
await sbtn.click()
await asyncio.sleep(1)
break
except Exception:
continue
# ── Step 10: 处理协议页面 ─────────────────────────────────
if "/admin/create" in page.url:
try:
agree_btn = page.locator("button.agree-button").first
if await agree_btn.count() > 0:
await agree_btn.click()
await asyncio.sleep(random.uniform(1, 2))
except Exception:
pass
# ── Step 11: 等待业务页面参数(csesidx + cid)──────────
lq("等待 Gemini 工作台 URL...")
_got_params = False
for _ in range(45):
if _cancelled():
return {"ok": False, "error": "任务已取消"}
url = page.url
if "csesidx=" in url and "/cid/" in url:
_got_params = True
break
await asyncio.sleep(1)
if not _got_params:
lq("URL 参数未生成,尝试导航到主页...")
try:
await page.goto("https://business.gemini.google/",
wait_until="domcontentloaded", timeout=30000)
await asyncio.sleep(random.uniform(2, 3))
except Exception:
pass
for _ in range(15):
url = page.url
if "csesidx=" in url and "/cid/" in url:
_got_params = True
break
await asyncio.sleep(1)
if not _got_params:
body_text = await _get_page_text(page)
block_result = _detect_gemini_block(body_text, email, lq)
if block_result:
return block_result
lq(f"[!] URL 参数生成失败: {page.url[:80]}")
return {"ok": False, "error": "URL parameters not found (csesidx/cid)"}
# ── Step 12: 提取配置 ─────────────────────────────────────
lq("注册成功,提取配置...")
return await _extract_gemini_config(page, context, email, lq)
except asyncio.CancelledError:
return {"ok": False, "error": "任务已取消"}
except Exception as exc:
logger.error("[%s] Gemini 注册异常: %s", email, exc, exc_info=True)
return {"ok": False, "error": str(exc)}
finally:
# asyncio.shield 防止 CancelledError 中断浏览器资源释放
async def _cleanup_resources():
for resource in [page, context, browser]:
if resource:
try:
await resource.close()
except Exception:
pass
try:
await asyncio.shield(_cleanup_resources())
except asyncio.CancelledError:
pass
# ─── HTTP 路由 ───────────────────────────────────────────────────────────────
@app.route("/kiro/process", methods=["POST"])
async def register():
data = await request.get_json()
if not data or not data.get("email"):
return jsonify({"ok": False, "error": "invalid request"}), 400
email = data["email"]
proxy = data.get("proxy")
yydsmail_url = data.get("yydsmail_url", "")
yydsmail_key = data.get("yydsmail_key", "")
region = data.get("region", "usa")
mail_provider = data.get("mail_provider", "")
mail_meta = data.get("mail_meta", {})
if region not in _ALLOWED_REGIONS:
return jsonify({"ok": False, "error": f"不支持的 region: {region}"}), 400
logger.info("收到注册请求: %s proxy=%s region=%s", email, "***" if proxy else "none", region)
async def generate():
async with _reg_semaphore:
log_q: queue.Queue = queue.Queue()
cancel_event = Event()
reg_task = asyncio.create_task(
_do_register(
email, proxy, yydsmail_url, yydsmail_key, region,
log_q, cancel_event, mail_provider, mail_meta,
)
)
last_keepalive = asyncio.get_event_loop().time()
try:
while not reg_task.done():
drained = False
while True:
try:
msg = log_q.get_nowait()
yield f"LOG:{msg}\n"
drained = True
last_keepalive = asyncio.get_event_loop().time()
except queue.Empty:
break
# 超过 10s 没有日志则推心跳(Go 侧会忽略)
now = asyncio.get_event_loop().time()
if not drained and now - last_keepalive > 10:
yield "LOG: .\n"
last_keepalive = now
if not drained:
await asyncio.sleep(0.2)
# 排尽剩余日志
while True:
try:
msg = log_q.get_nowait()
yield f"LOG:{msg}\n"
except queue.Empty:
break
# 最后一行:JSON 结果
result = await reg_task
yield json.dumps(result, ensure_ascii=False) + "\n"
except (asyncio.CancelledError, GeneratorExit):
# 客户端断开连接,通知注册协程取消
cancel_event.set()
reg_task.cancel()
try:
await reg_task
except (asyncio.CancelledError, Exception):
pass
logger.info("[%s] 客户端断开,注册任务已取消", email)
raise
return app.response_class(generate(), content_type="text/plain; charset=utf-8")
@app.route("/gemini/process", methods=["POST"])
async def gemini_register():
data = await request.get_json()
if not data or not data.get("email"):
return jsonify({"ok": False, "error": "invalid request"}), 400
email = data["email"]
proxy = data.get("proxy")
yydsmail_url = data.get("yydsmail_url", "")
yydsmail_key = data.get("yydsmail_key", "")
mail_provider = data.get("mail_provider", "")
mail_meta = data.get("mail_meta", {})
logger.info("收到 Gemini 注册请求: %s proxy=%s", email, "***" if proxy else "none")
async def generate():
async with _gemini_semaphore:
log_q: queue.Queue = queue.Queue()
cancel_event = Event()
reg_task = asyncio.create_task(
_do_gemini_register(
email, proxy, yydsmail_url, yydsmail_key,
log_q, cancel_event, mail_provider, mail_meta,
)
)
last_keepalive = asyncio.get_event_loop().time()
try:
while not reg_task.done():
drained = False
while True:
try:
msg = log_q.get_nowait()
yield f"LOG:{msg}\n"
drained = True
last_keepalive = asyncio.get_event_loop().time()
except queue.Empty:
break
now = asyncio.get_event_loop().time()
if not drained and now - last_keepalive > 10:
yield "LOG: .\n"
last_keepalive = now
if not drained:
await asyncio.sleep(0.2)
while True:
try:
msg = log_q.get_nowait()
yield f"LOG:{msg}\n"
except queue.Empty:
break
result = await reg_task
yield json.dumps(result, ensure_ascii=False) + "\n"
except (asyncio.CancelledError, GeneratorExit):
cancel_event.set()
reg_task.cancel()
try:
await reg_task
except (asyncio.CancelledError, Exception):
pass
logger.info("[%s] Gemini 客户端断开,注册任务已取消", email)
raise
return app.response_class(generate(), content_type="text/plain; charset=utf-8")
@app.route("/health")
async def health():
return jsonify({
"status": "ok",
"service": "aws-builder-id-reg",
"engine": "camoufox",
"platforms": ["kiro", "gemini"],
"concurrency": {
"kiro": _MAX_CONCURRENT,
"gemini": _GEMINI_MAX_CONCURRENT,
},
})
@app.route("/")
async def index():
return jsonify({
"name": "edge-service",
"version": "1.0.0",
"status": "healthy",
})
def main():
parser = argparse.ArgumentParser(description="账号注册服务 — Kiro + Gemini (Camoufox)")
parser.add_argument("--host", default=os.getenv("KIRO_REG_HOST", "0.0.0.0"))
parser.add_argument("--port", type=int, default=int(os.getenv("KIRO_REG_PORT", "5076")))
args = parser.parse_args()
logger.info("AWS Builder ID 注册服务启动 (Camoufox): %s:%d", args.host, args.port)
import hypercorn.asyncio as _ha
import hypercorn.config as _hc
_cfg = _hc.Config()
_cfg.bind = [f"{args.host}:{args.port}"]
_cfg.keep_alive_timeout = int(os.getenv("KIRO_KEEPALIVE_TIMEOUT", "310"))
_cfg.response_timeout = int(os.getenv("KIRO_RESPONSE_TIMEOUT", "400"))
asyncio.run(_ha.serve(app, _cfg))
if __name__ == "__main__":
main()