Files
OutlookRegister/controllers/outlook_controller.py
T
daimon d2db326c70 Initial public release of OutlookRegister.
Fork of LainsNL/OutlookRegister with OAuth hardening, optional recovery email,
batching, and MIT license. Ships example config only (no local secrets).
2026-07-23 20:47:55 +08:00

1542 lines
69 KiB
Python
Raw 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.
import os
import time
import random
import math
import shutil
import threading
from faker import Faker
from patchright.sync_api import sync_playwright
class OutlookController:
"""
Outlook 自动注册控制器。
职责:浏览器管理、代理选择(IP加权)、注册流程、验证码突破。
每个线程独立的浏览器实例,通过 thread_local 隔离。
类变量在所有线程间共享(代理使用计数、IP表现追踪、统计)。
"""
# === 类变量(所有线程共享)===
_proxy_usage = {} # 每个代理端口被选中的次数
_proxy_config = None # 代理配置缓存(只解析一次)
_ip_tracker = {} # IP表现追踪(仅内存,不持久化)
_attempts = 0 # 累计验证码尝试次数
_success = 0 # 累计验证码成功次数
_ip_info_cache = {} # IP地理信息缓存(避免重复查询ipinfo)
_b2_attempts = {'click': 0, 'dblclick': 0, 'hold': 0}
_b2_success = {'click': 0, 'dblclick': 0, 'hold': 0}
_state_lock = threading.Lock()
# 国家代码 → (locale, 默认时区)
LOCALE_MAP = {
'JP': ('ja-JP', 'Asia/Tokyo'), 'US': ('en-US', 'America/Chicago'),
'HK': ('zh-HK', 'Asia/Hong_Kong'), 'SG': ('en-SG', 'Asia/Singapore'),
'KR': ('ko-KR', 'Asia/Seoul'), 'GB': ('en-GB', 'Europe/London'),
'DE': ('de-DE', 'Europe/Berlin'), 'FR': ('fr-FR', 'Europe/Paris'),
'CA': ('en-CA', 'America/Toronto'), 'AU': ('en-AU', 'Australia/Sydney'),
'TW': ('zh-TW', 'Asia/Taipei'), 'CN': ('zh-CN', 'Asia/Shanghai'),
'BR': ('pt-BR', 'America/Sao_Paulo'),'IN': ('en-IN', 'Asia/Kolkata'),
'NL': ('nl-NL', 'Europe/Amsterdam'), 'TH': ('th-TH', 'Asia/Bangkok'),
'VN': ('vi-VN', 'Asia/Ho_Chi_Minh'), 'MY': ('ms-MY', 'Asia/Kuala_Lumpur'),
'PH': ('en-PH', 'Asia/Manila'), 'ID': ('id-ID', 'Asia/Jakarta'),
}
def __init__(self, config_data):
"""初始化:加载配置 → 创建线程存储 → 初始化统计 → 解析代理"""
# config.json 已在 main.py 读取并解析,直接传入 dict
self.wait_time = config_data['bot_protection_wait'] * 1000 # 秒→毫秒
self.max_captcha_retries = config_data['max_captcha_retries']
self.captcha_strategy = config_data.get('captcha_strategy', 0)
self.enable_oauth2 = config_data["oauth2"]['enable_oauth2']
self.headless = config_data.get('headless', False)
self.email_suffix = config_data['email_suffix']
# 公开发行版:固定 patchright 自带 Chromium,不使用指纹/自定义浏览器
browser_cfg = config_data.get('browser', {}) or {}
self.browser_executable_path = '' # 强制空 → patchright builtin
self.fingerprint_enabled = False
self.fingerprint_platform = 'windows'
self.fingerprint_brand = 'Chrome'
user_data_root = (browser_cfg.get('user_data_root') or '').strip()
self.browser_user_data_root = user_data_root or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'browser_profiles',
)
# 备用邮箱绑定(CF Temp Mail):概率出现,非固定步骤
# 注册后 / OAuth 中任一处弹出「保护帐户」页则绑定;未弹出则直接继续
self.temp_mail_cfg = config_data.get('temp_mail', {}) or {}
self.bind_recovery_email = bool(self.temp_mail_cfg.get('enabled', True))
self.thread_local = threading.local()
self.cleanup_lock = threading.Lock()
self.failure_lock = threading.Lock()
self.runtime_lock = threading.Lock()
self.log_lock = threading.Lock()
self.active_resources = []
self.active_playwrights = []
self.log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'log')
os.makedirs(self.log_dir, exist_ok=True)
if self.fingerprint_enabled or self.browser_executable_path:
os.makedirs(self.browser_user_data_root, exist_ok=True)
self.log_path = os.path.join(
self.log_dir,
f"{time.strftime('%Y-%m-%d_%H-%M-%S')}_{os.getpid()}.txt"
)
self.log_plain("[Browser] mode=patchright-chromium (builtin) fingerprint=false")
self.runtime_stats = {
'started_at': time.time(),
'submitted': 0,
'running': 0,
'succeeded': 0,
'failed': 0,
}
self.failure_stats = {
'ip_cant_open': 0,
'ip_blocked': 0,
'captcha_fail': 0,
'captcha_btn2_never_appeared': 0,
'captcha_btn2_appeared_but_failed': 0,
'funcaptcha': 0,
'timeout': 0,
'register_page_open_fail': 0,
'register_form_fail': 0,
'mail_init_fail': 0,
'oauth_login_timeout': 0,
'oauth_consent_fail': 0,
'oauth_code_fail': 0,
'oauth_token_network_fail': 0,
'oauth_token_fail': 0,
'oauth_password_wrong': 0,
'oauth_password_blocked': 0,
'oauth_retry_exhausted': 0,
'recovery_bind_fail': 0,
'browser_launch_fail': 0,
'browser_context_fail': 0,
'browser_page_fail': 0,
'playwright_runtime_fail': 0,
}
cls = type(self)
if cls._proxy_config is None:
cls._proxy_config = self._parse_proxy_config(config_data.get('proxy', {}))
# ============================================================
# IP 信息查询(国家 + 时区 + 坐标,带缓存)
# ============================================================
@classmethod
def _get_ip_info(cls, proxy_url):
"""查询代理IP的地理信息:国家代码、时区、GPS坐标。结果缓存,同一代理只查一次。"""
if not proxy_url:
return {'country': '??', 'timezone': 'UTC', 'loc': None}
with cls._state_lock:
if proxy_url in cls._ip_info_cache:
return cls._ip_info_cache[proxy_url]
info = {'country': '??', 'timezone': 'UTC', 'loc': None}
try:
import requests
r = requests.get('https://ipinfo.io/json', proxies={'https': proxy_url},
timeout=3, headers={'Accept': 'application/json'})
if r.status_code == 200:
d = r.json()
info = {
'country': d.get('country', '??'),
'timezone': d.get('timezone', 'UTC'),
'loc': d.get('loc', None), # "35.68,139.76"
}
except Exception:
pass
with cls._state_lock:
cls._ip_info_cache[proxy_url] = info
return info
def bump_failure(self, *names):
with self.failure_lock:
for name in names:
self.failure_stats[name] = self.failure_stats.get(name, 0) + 1
def _reset_thread_runtime(self):
for attr in ('_proxy', '_ip_info', '_log_prefix'):
if hasattr(self.thread_local, attr):
delattr(self.thread_local, attr)
def prepare_thread_context(self):
proxy = getattr(self.thread_local, '_proxy', None)
if not proxy:
proxy = self._pick_proxy()
info = getattr(self.thread_local, '_ip_info', None)
if info is None:
info = self._get_ip_info(proxy)
self.thread_local._ip_info = info
return proxy, info
def set_task_prefix(self, task_num, total):
"""设置当前线程的日志前缀: [编号/总-国家-IP] 并缓存IP地理信息"""
proxy, info = self.prepare_thread_context()
ip_short = proxy.split('//')[-1] if '//' in proxy else proxy
self.thread_local._log_prefix = f"[{task_num}/{total}-{info['country']}-{ip_short}]"
def log_event(self, flow, level, stage, message, attempt=None):
line = self._format_log_line(flow, level, stage, message, attempt=attempt)
self.write_log_line(line)
def make_logger(self, flow, attempt=None):
def _logger(stage, message, level='INFO'):
self.log_event(flow, level, stage, message, attempt=attempt)
return _logger
def _log(self, msg):
self.log_event('TASK', 'INFO', 'general', msg)
def _log_prefix_str(self):
return getattr(self.thread_local, '_log_prefix', '')
def _format_log_line(self, flow, level, stage, message, attempt=None):
prefix = getattr(self.thread_local, '_log_prefix', '')
attempt_part = f"[A{attempt}]" if attempt is not None else ""
return f"{prefix}[{flow}]{attempt_part}[{level}] {time.strftime('%H:%M:%S')} | {stage} | {message}"
def write_log_line(self, line):
# 强制单行:Playwright Call log 等多行异常不得刷屏
if line is None:
return
text = str(line).replace('\r\n', '\n').replace('\r', '\n')
if '\n' in text:
parts = [p.strip() for p in text.split('\n') if p.strip()]
# 丢弃 Call log 明细行
kept = []
for p in parts:
if p.startswith('Call log:') or p.startswith('- waiting') or p.startswith('- navigated') or p.startswith('- attempting'):
continue
kept.append(p)
text = ' | '.join(kept) if kept else parts[0]
if len(text) > 400:
text = text[:397] + '...'
with self.log_lock:
print(text, flush=True)
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(text + '\n')
f.flush()
def log_plain(self, message):
self.write_log_line(message)
def update_runtime_stats(self, **kwargs):
with self.runtime_lock:
self.runtime_stats.update(kwargs)
def get_runtime_stats(self):
with self.runtime_lock:
return dict(self.runtime_stats)
def set_progress_base(self, succeeded=0, failed=0, started_at=None):
"""跨批次累计基数:进度条连续,不因换批归零。"""
with self.runtime_lock:
self._progress_base_succeeded = int(succeeded or 0)
self._progress_base_failed = int(failed or 0)
self._progress_run_started_at = started_at if started_at is not None else time.time()
self.runtime_stats['succeeded'] = self._progress_base_succeeded
self.runtime_stats['failed'] = self._progress_base_failed
self.runtime_stats['started_at'] = self._progress_run_started_at
def note_task_finished(self, success, total_tasks):
"""任务结束更新计数;仅成功时立刻打印进度(勿等 clean_up)。
字段为**跨批次累计**:成功数 | 当前进度(成功+失败) | 总数 | 成功率 | 总耗时
"""
with self.runtime_lock:
if success:
self.runtime_stats['succeeded'] = self.runtime_stats.get('succeeded', 0) + 1
else:
self.runtime_stats['failed'] = self.runtime_stats.get('failed', 0) + 1
succeeded = self.runtime_stats.get('succeeded', 0)
failed = self.runtime_stats.get('failed', 0)
started = (
getattr(self, '_progress_run_started_at', None)
or self.runtime_stats.get('started_at')
or time.time()
)
if not success:
return
current = succeeded + failed
total = max(int(total_tasks or 0), 1)
elapsed = time.time() - started
rate = succeeded / max(current, 1) * 100
self.log_plain(
f"[进度] 成功 {succeeded} | 当前 {current}/{total} | "
f"成功率 {succeeded}/{current} ({rate:.0f}%) | 总耗时 {elapsed / 60:.1f}min"
)
@classmethod
def reset_shared_state(cls):
with cls._state_lock:
cls._proxy_usage.clear()
cls._ip_tracker.clear()
cls._ip_info_cache.clear()
cls._attempts = 0
cls._success = 0
cls._b2_attempts = {'click': 0, 'dblclick': 0}
cls._b2_success = {'click': 0, 'dblclick': 0}
def penalize_ip(self, penalty=4):
"""惩罚当前IPOAuth2全部失败、账号不存在等严重错误时调用)。增加失败计数,影响后续代理选择权重。"""
proxy = getattr(self.thread_local, '_proxy', '')
key = proxy.split('//')[-1] if '//' in proxy else proxy
if key:
with self._state_lock:
if key not in self._ip_tracker:
self._ip_tracker[key] = {'win': 0, 'total': 0}
self._ip_tracker[key]['total'] += penalty
self.log_event('PROXY', 'WARN', 'penalize', f"{key} 惩罚 +{penalty}")
def fresh_proxy_url(self, exclude: str = "") -> str:
previous_proxy = getattr(self.thread_local, '_proxy', None)
previous_info = getattr(self.thread_local, '_ip_info', None)
try:
if hasattr(self.thread_local, '_proxy'):
del self.thread_local._proxy
if hasattr(self.thread_local, '_ip_info'):
del self.thread_local._ip_info
for _ in range(4):
picked = self._pick_proxy()
if not exclude or picked != exclude:
return picked
return self._pick_proxy()
finally:
if hasattr(self.thread_local, '_proxy'):
del self.thread_local._proxy
if hasattr(self.thread_local, '_ip_info'):
del self.thread_local._ip_info
if previous_proxy:
self.thread_local._proxy = previous_proxy
if previous_info is not None:
self.thread_local._ip_info = previous_info
def _register_active_browser(self, browser):
with self.cleanup_lock:
self.active_resources.append(browser)
def _unregister_active_browser(self, browser):
with self.cleanup_lock:
self.active_resources = [item for item in self.active_resources if item is not browser]
def _register_active_playwright(self, playwright):
with self.cleanup_lock:
self.active_playwrights.append(playwright)
def _unregister_active_playwright(self, playwright):
with self.cleanup_lock:
self.active_playwrights = [item for item in self.active_playwrights if item is not playwright]
@staticmethod
def _browser_failure_key(stage, message):
msg = (message or "").lower()
if stage == 'playwright':
return 'playwright_runtime_fail'
if 'event loop is closed' in msg or 'playwright already stopped' in msg or 'asyncio loop' in msg:
return 'playwright_runtime_fail'
if stage == 'launch':
return 'browser_launch_fail'
if stage == 'context':
return 'browser_context_fail'
return 'browser_page_fail'
def _log_browser_failure(self, stage, exc):
message = str(exc)
failure_key = self._browser_failure_key(stage, message)
self.bump_failure(failure_key)
self.log_event('BROWSER', 'FAIL', stage, f"{message} | class={failure_key}")
return failure_key
def _dispose_thread_playwright(self):
playwright = getattr(self.thread_local, 'playwright', None)
if not playwright:
return
try:
playwright.stop()
except Exception:
pass
self._unregister_active_playwright(playwright)
try:
del self.thread_local.playwright
except Exception:
pass
def _dispose_thread_browser(self):
browser = getattr(self.thread_local, 'browser', None)
if not browser:
return
try:
browser.close()
except Exception:
pass
self._unregister_active_browser(browser)
try:
del self.thread_local.browser
except Exception:
pass
def _thread_playwright(self):
playwright = getattr(self.thread_local, 'playwright', None)
if playwright:
return playwright
try:
playwright = sync_playwright().start()
except Exception as exc:
self._log_browser_failure('playwright', exc)
return None
self.thread_local.playwright = playwright
self._register_active_playwright(playwright)
self.log_event('BROWSER', 'INFO', 'playwright', '线程级 Playwright 已初始化')
return playwright
# ============================================================
# 代理
# ============================================================
@classmethod
def _parse_proxy_config(cls, pc):
"""解析代理配置:单端口 or 端口池。返回 {type, host, ports, max_per}"""
mode = pc.get('mode', 'single')
proxy_type = pc.get('type', 'http')
host = pc.get('host', '127.0.0.1')
if mode == 'single':
ports = [pc.get('single_port', 7890)]
else:
ports = list(range(pc.get('port_start', 24000), pc.get('port_end', 24064) + 1))
return {'type': proxy_type, 'host': host, 'ports': ports, 'max_per': pc.get('max_per_proxy', 20)}
def _pick_proxy(self):
"""选择代理端口:两步——①过滤(排除用满的+烂IP)②加权随机(胜率高的优先)"""
cfg = self._proxy_config
with self._state_lock:
available = []
for p in cfg['ports']:
if self._proxy_usage.get(p, 0) >= cfg['max_per']:
continue
key = f"{cfg['host']}:{p}"
info = self._ip_tracker.get(key, {})
total = info.get('total', 0)
win = info.get('win', 0)
fail = max(total - win, 0)
if total >= 2 and win == 0:
continue
if fail >= 4 and win * 2 < fail:
continue
available.append(p)
if not available:
available = list(cfg['ports'])
for p in available:
self._proxy_usage[p] = 0
weights = []
for p in available:
key = f"{cfg['host']}:{p}"
info = self._ip_tracker.get(key, {})
total = info.get('total', 0)
win = info.get('win', 0)
fail = max(total - win, 0)
rate = win / total if total else 0.5
weight = ((1 + win * 4) / (1 + fail * 3)) * (max(rate, 0.05) ** 2)
weights.append(max(0.01, weight))
port = random.choices(available, weights=weights, k=1)[0]
self._proxy_usage[port] = self._proxy_usage.get(port, 0) + 1
proxy_url = f"{cfg['type']}://{cfg['host']}:{port}"
self.thread_local._proxy = proxy_url
return proxy_url
# ============================================================
# 浏览器管理
# ============================================================
def _resolve_timezone(self, info):
"""代理国家 → 时区;ipinfo 有效时区优先。"""
country = (info or {}).get('country', '??')
locale_map = OutlookController.LOCALE_MAP
tz = locale_map.get(country, locale_map.get('US'))[1]
raw_tz = (info or {}).get('timezone', 'UTC')
if raw_tz and raw_tz != 'UTC':
tz = raw_tz
return tz
def _make_fingerprint_seed(self, proxy_url):
"""每个任务生成独立指纹种子(32-bit 正整数)。"""
# 混入代理端口 + 时间 + 随机,避免多任务共用同一设备指纹
port_part = 0
try:
hostport = proxy_url.split('//')[-1]
port_part = int(hostport.rsplit(':', 1)[-1])
except Exception:
pass
seed = (int(time.time() * 1000) ^ (port_part * 2654435761) ^ random.getrandbits(32)) & 0x7FFFFFFF
if seed == 0:
seed = random.randint(1, 0x7FFFFFFF)
return seed
def _prepare_user_data_dir(self, seed):
"""为本次浏览器创建独立 user-data-dir。"""
base = self.browser_user_data_root
os.makedirs(base, exist_ok=True)
path = os.path.join(base, f"fp_{seed}_{os.getpid()}_{threading.get_ident()}")
os.makedirs(path, exist_ok=True)
return path
def _cleanup_user_data_dir(self, path):
if not path:
return
try:
shutil.rmtree(path, ignore_errors=True)
except Exception:
pass
@staticmethod
def clear_browser_profiles_dir(root, log_fn=None):
"""清空 browser_profiles 目录下全部内容(保留目录本身)。"""
if not root:
return 0
try:
os.makedirs(root, exist_ok=True)
except Exception:
return 0
removed = 0
try:
names = os.listdir(root)
except Exception:
return 0
for name in names:
path = os.path.join(root, name)
try:
if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
else:
try:
os.remove(path)
except Exception:
pass
removed += 1
except Exception:
pass
if log_fn:
try:
log_fn(f"[Cleanup] 已清空 browser_profiles 共 {removed} 项: {root}")
except Exception:
pass
return removed
def clear_browser_profiles_root(self, log=True):
"""清空本实例配置的 fingerprint profile 根目录。"""
root = getattr(self, 'browser_user_data_root', None)
log_fn = self.log_plain if log and hasattr(self, 'log_plain') else None
return self.clear_browser_profiles_dir(root, log_fn=log_fn)
def launch_browser(self):
"""启动浏览器:选代理 → fingerprint-chromium(可选) → 反检测参数。
返回 (playwright, browser_or_context)。
使用自定义 chrome.exe 时走 launch_persistent_context(独立 profile)。
"""
try:
p = self._thread_playwright()
if not p:
return False, False
proxy_url, info = self.prepare_thread_context()
tz = self._resolve_timezone(info)
locale = 'zh-CN'
viewport = {
'width': random.choice([1366, 1440, 1536, 1680, 1920]),
'height': random.choice([768, 864, 900, 1050, 1080]),
}
args = [
'--lang=zh-CN',
'--accept-lang=zh-CN,zh,en-US,en',
'--disable-blink-features=AutomationControlled',
'--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu',
'--disable-autofill-keyboard-accessory-view',
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
'--disable-non-proxied-udp',
# 抑制 Windows Hello / Passkey / 安全密钥系统弹窗(网页层仍可能出「创建通行密钥」,靠后续取消/直达邮箱)
'--disable-webauthn',
'--disable-features=WebAuthentication,WebAuthenticationConditionalUI,WebAuthenticationCable,WebAuthenticationHybridTransport,WebAuthenticationPasskeysUI,Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider,AutofillServerCommunication,PasswordManagerOnboarding,PasswordImport,BiometricAuthenticationInSettings',
'--disable-save-password-bubble',
'--disable-password-manager-reauthentication',
'--disable-component-update',
'--disable-sync', '--disable-default-apps',
f'--timezone={tz}',
]
common = {
'headless': self.headless,
'args': args,
'proxy': {"server": proxy_url, "bypass": "localhost"},
}
exe = self.browser_executable_path
profile_dir = None
seed = None
self.thread_local._persistent_context = False
if exe:
if not os.path.isfile(exe):
self.log_event('BROWSER', 'FAIL', 'launch_detail', f"浏览器路径不存在: {exe}")
return False, False
seed = self._make_fingerprint_seed(proxy_url) if self.fingerprint_enabled else random.randint(1, 0x7FFFFFFF)
profile_dir = self._prepare_user_data_dir(seed)
if self.fingerprint_enabled:
args.append(f'--fingerprint={seed}')
if self.fingerprint_platform:
args.append(f'--fingerprint-platform={self.fingerprint_platform}')
if self.fingerprint_brand:
args.append(f'--fingerprint-brand={self.fingerprint_brand}')
mode = 'fingerprint-chromium' if self.fingerprint_enabled else 'custom-chromium'
self.log_event(
'BROWSER', 'INFO', 'launch',
f"exe={mode} path={exe} seed={seed} fp={self.fingerprint_enabled} tz={tz} proxy={proxy_url.split('//')[-1]}"
)
# Playwright 要求 user_data_dir 走 persistent_context,不能塞进 args
ctx_opts = {
**common,
'executable_path': exe,
'locale': locale,
'timezone_id': tz,
'viewport': viewport,
}
if info.get('loc'):
try:
lat, lng = info['loc'].split(',')
ctx_opts['geolocation'] = {'latitude': float(lat), 'longitude': float(lng)}
except Exception:
pass
b = p.chromium.launch_persistent_context(profile_dir, **ctx_opts)
self.thread_local._persistent_context = True
else:
# executable_path 为空:使用 patchright 自带 ChromiumA/B:对照指纹浏览器)
self.log_event(
'BROWSER', 'INFO', 'launch',
f"exe=patchright-chromium fp=false tz={tz} proxy={proxy_url.split('//')[-1]}"
)
b = p.chromium.launch(**common)
self.thread_local._browser_profile_dir = profile_dir
self.thread_local._fingerprint_seed = seed
self._register_active_browser(b)
return p, b
except Exception as e:
profile_dir = getattr(self.thread_local, '_browser_profile_dir', None)
self._cleanup_user_data_dir(profile_dir)
if hasattr(self.thread_local, '_browser_profile_dir'):
delattr(self.thread_local, '_browser_profile_dir')
failure_key = self._log_browser_failure('launch', e)
self.log_event('BROWSER', 'FAIL', 'launch_detail', f"启动浏览器失败: {e}")
if failure_key == 'playwright_runtime_fail':
self._dispose_thread_browser()
self._dispose_thread_playwright()
return False, False
def get_thread_browser(self):
"""获取当前线程的浏览器。首次调用时创建,之后复用。线程隔离,各自独立。"""
if not hasattr(self.thread_local, "browser"):
p, b = self.launch_browser()
if not p:
return False
self.thread_local.browser = b
return self.thread_local.browser
def get_thread_page(self):
browser = self.get_thread_browser()
if not browser:
return None
# fingerprint-chromium 使用 persistent contextbrowser 实际是 BrowserContext
if getattr(self.thread_local, '_persistent_context', False):
try:
pages = list(browser.pages)
# 优先复用已有标签页(自定义 Chromium 有时禁止 Target.createTarget
if pages:
page = pages[0]
for extra in pages[1:]:
try:
extra.close()
except Exception:
pass
try:
page.goto('about:blank', timeout=10000)
except Exception:
pass
return page
return browser.new_page()
except Exception as exc:
self._log_browser_failure('page', exc)
self._dispose_thread_browser()
return None
_, info = self.prepare_thread_context()
locale = 'zh-CN' # 强制中文(元素定位依赖中文 text)
tz = self._resolve_timezone(info)
viewport = {'width': random.choice([1366, 1440, 1536, 1680, 1920]),
'height': random.choice([768, 864, 900, 1050, 1080])}
context_opts = {
'locale': locale,
'timezone_id': tz,
'viewport': viewport,
}
if info.get('loc'):
try:
lat, lng = info['loc'].split(',')
context_opts['geolocation'] = {'latitude': float(lat), 'longitude': float(lng)}
except Exception:
pass
context = None
try:
context = browser.new_context(**context_opts)
except Exception as exc:
failure_key = self._log_browser_failure('context', exc)
self._dispose_thread_browser()
if failure_key == 'playwright_runtime_fail':
self._dispose_thread_playwright()
return None
try:
return context.new_page()
except Exception as exc:
self._log_browser_failure('page', exc)
try:
context.close()
except Exception:
pass
self._dispose_thread_browser()
return None
def clean_up(self, page=None, type="all_browser"):
"""
资源清理。
- done_browser: 关闭当前线程的浏览器和page(OAuth2重试前调用,确保下次拿新IP)
- all_browser: 关闭所有活跃浏览器(程序结束时调用)
"""
if type == "done_browser":
if page:
try:
page.context.close()
except Exception:
pass
profile_dir = getattr(self.thread_local, '_browser_profile_dir', None)
self._dispose_thread_browser()
self._cleanup_user_data_dir(profile_dir)
if hasattr(self.thread_local, '_browser_profile_dir'):
delattr(self.thread_local, '_browser_profile_dir')
if hasattr(self.thread_local, '_fingerprint_seed'):
delattr(self.thread_local, '_fingerprint_seed')
self._reset_thread_runtime()
elif type == "all_browser":
profile_dir = getattr(self.thread_local, '_browser_profile_dir', None)
with self.cleanup_lock:
browsers = list(self.active_resources)
playwights = list(self.active_playwrights)
self.active_resources.clear()
self.active_playwrights.clear()
for browser in browsers:
try:
browser.close()
except Exception:
pass
for playwright in playwights:
try:
playwright.stop()
except Exception:
pass
self._cleanup_user_data_dir(profile_dir)
if hasattr(self.thread_local, '_browser_profile_dir'):
delattr(self.thread_local, '_browser_profile_dir')
if hasattr(self.thread_local, '_fingerprint_seed'):
delattr(self.thread_local, '_fingerprint_seed')
# 关掉浏览器后再清空整个 profiles 根目录(正常/异常收尾都走这里)
try:
self.clear_browser_profiles_root(log=True)
except Exception:
pass
# ============================================================
# 注册流程
# ============================================================
def outlook_register(self, page, email, password):
"""
完整的Outlook注册流程。
步骤:打开注册页 → 同意条款 → 填邮箱 → 填密码
→ 填生日 → 填姓名 → 提交 → 检测风控 → 通过验证码 → 等邮箱初始化
返回: True(注册成功) 或 False(失败)
"""
fake = Faker()
lastname = fake.last_name()
firstname = fake.first_name()
year = str(random.randint(1999, 2007))
month = str(random.randint(1, 12))
day = str(random.randint(1, 25))
try:
page.goto("https://outlook.live.com/mail/0/?prompt=create_account", timeout=30000, wait_until="domcontentloaded")
page.get_by_text('同意并继续').wait_for(timeout=30000)
start_time = time.time()
page.wait_for_timeout(0.1 * self.wait_time)
page.get_by_text('同意并继续').click(timeout=30000)
except Exception:
self.bump_failure('ip_cant_open', 'register_page_open_fail')
self._log("[Fail:IP] - IP质量不佳,无法打开Outlook注册页面,请换IP重试")
return False
try:
# 选择是 outlook还是hotmail
if self.email_suffix == "@hotmail.com":
page.get_by_text("@outlook.com").click(timeout=10000)
page.locator(f'[role="option"]:text-is("@hotmail.com")').click()
# 填充邮箱
email_input = page.locator('[aria-label="新建电子邮件"]')
email_input.click()
email_input.fill(email, timeout=10000)
# 点击 "下一步
page.locator('[data-testid="primaryButton"]').click(timeout=5000)
page.wait_for_timeout(0.02 * self.wait_time)
#填充密码
page.locator('[type="password"]').type(password, delay=0.004 * self.wait_time, timeout=10000)
page.wait_for_timeout(0.02 * self.wait_time)
# 点击 "下一步
page.locator('[data-testid="primaryButton"]').click(timeout=5000)
page.wait_for_timeout(0.03 * self.wait_time)
# 填充出生的年份
page.locator('[name="BirthYear"]').fill(year, timeout=10000)
# 填充出生日期,实际上不会走 try,走的是Except。因为 有浮层的存在,
try:
# 填充月份
page.wait_for_timeout(0.02 * self.wait_time)
page.locator('[name="BirthMonth"]').select_option(value=month, timeout=1000)
# 填充日期
page.wait_for_timeout(0.05 * self.wait_time)
page.locator('[name="BirthDay"]').select_option(value=day)
except Exception:
# 填充月份
page.locator('[name="BirthMonth"]').click()
page.wait_for_timeout(0.02 * self.wait_time)
page.locator(f'[role="option"]:text-is("{month}月")').click()
page.wait_for_timeout(0.04 * self.wait_time)
# 填充日期
page.locator('[name="BirthDay"]').click()
page.wait_for_timeout(0.03 * self.wait_time)
page.locator(f'[role="option"]:text-is("{day}日")').click()
page.locator('[data-testid="primaryButton"]').click(timeout=5000)
# 填充姓氏
page.locator('#lastNameInput').type(lastname, delay=0.002 * self.wait_time, timeout=10000)
page.wait_for_timeout(0.02 * self.wait_time)
# 填充名字
page.locator('#firstNameInput').fill(firstname, timeout=10000)
if time.time() - start_time < self.wait_time / 1000:
page.wait_for_timeout(self.wait_time - (time.time() - start_time) * 1000)
# 点击 "下一步
page.locator('[data-testid="primaryButton"]').click(timeout=5000)
page.locator('span > [href="https://go.microsoft.com/fwlink/?LinkID=521839"]').wait_for(state='detached', timeout=22000)
page.wait_for_timeout(400)
if page.get_by_text('一些异常活动').count() or page.get_by_text('此站点正在维护,暂时无法使用,请稍后重试。').count() > 0:
self.bump_failure('ip_blocked')
self._log("[Fail:IP] - 当前IP已被微软风控拦截,请更换IP重试")
return False
if page.locator('iframe#enforcementFrame').count() > 0:
self.bump_failure('funcaptcha')
self._log("[Fail:Captcha] - 验证码类型为FunCaptcha而非按压验证码,当前IP暂不支持,请换IP重试")
return False
# 策略 2:只自动填表到验证码界面,验证码 + 进邮箱 + OAuth 全部由你手动
if self.captcha_strategy == 2:
return self._hand_off_at_captcha(page, email, password)
# 验证码是否通过
captcha_result = self.handle_captcha(page)
# 没有通过,报错
if not captcha_result:
raise TimeoutError
# 验证码通过后:跳过辅助邮箱 / 通行密钥拦截,进入邮箱
if self._enter_mailbox_after_register(page):
self._log(f'Success:Captcha] - {email}{self.email_suffix} 验证码通过,已进入邮箱。')
else:
self._log(
f'Success:Captcha] - {email}{self.email_suffix} 验证码通过,但未确认进入邮箱(已尝试跳过/直达)。'
)
except Exception:
self.bump_failure('captcha_fail', 'register_form_fail')
self._log("[Fail:Captcha] - 验证码未通过(已达最大重试次数),请换IP后重新注册")
return False
# 走到这里说明验证码过了,注册成功
self._log(f'Success:Email Registration] - {email}{self.email_suffix}: {password}')
# 如果不需要oauth2,则直接结束,返回true
if not self.enable_oauth2:
return True
# 邮箱初始化 + cookie/SSO 沉淀:进 OAuth 前固定多等几秒
# 证据:过早跳 authorize 常落到 #i0116;重开浏览器更糟
oauth_settle_ms = 7000
try:
page.locator('[aria-label="新邮件"]').wait_for(timeout=32000)
self.log_event('REGISTER', 'INFO', 'mail_init', f'收件箱就绪,等待 {oauth_settle_ms}ms 沉淀 cookie')
page.wait_for_timeout(oauth_settle_ms)
return True
except Exception:
self.bump_failure('mail_init_fail')
self.log_event(
'REGISTER', 'WARN', 'mail_init',
f'邮箱未初始化,仍等待 {oauth_settle_ms}ms 后继续 OAuth2',
)
try:
page.wait_for_timeout(oauth_settle_ms)
except Exception:
pass
return True
def _is_mailbox_url(self, page):
try:
url = page.url or ''
except Exception:
return False
if 'outlook.live.com/mail/' not in url:
return False
# 注册入口不算已进入邮箱
if 'prompt=create_account' in url:
return False
return True
def _click_if_visible(self, locator, timeout_ms=2500):
try:
target = locator.first
if target.count() <= 0:
return False
if not target.is_visible():
return False
target.click(timeout=timeout_ms)
return True
except Exception:
return False
def _try_bind_recovery_email(self, page):
"""保护帐户页:创建临时邮箱 → 填 #EmailAddress → 接码 → #iOttText。失败则调用方再 skip。"""
if not self.bind_recovery_email:
return False
try:
from controllers.recovery_bind import bind_recovery_email, is_protect_account_page, is_ott_code_page
except Exception as exc:
self.log_event('REGISTER', 'WARN', 'recovery', f'加载 recovery_bind 失败: {exc}')
return False
if not is_protect_account_page(page) and not is_ott_code_page(page):
return False
def _log(stage, message, level='INFO'):
self.log_event('REGISTER', level, stage, message)
result = bind_recovery_email(page, self.temp_mail_cfg, log=_log)
# 兼容 (ok, session) 或旧版 bool
if isinstance(result, tuple):
ok, session = result[0], (result[1] if len(result) > 1 else None)
else:
ok, session = bool(result), None
if ok:
self.thread_local.recovery_email_bound = True
self.thread_local.recovery_email_skipped = False
if session:
self.thread_local.recovery_mail_session = session
self.log_event(
'REGISTER', 'INFO', 'recovery_session',
f"已保存辅助邮箱会话 addr={session.get('address')}",
)
else:
self.bump_failure('recovery_bind_fail')
return ok
def _mark_recovery_skipped(self):
"""注册阶段未绑定、点了暂时跳过 → OAuth 仍可能再弹保护帐户页。"""
if not getattr(self.thread_local, 'recovery_email_bound', False):
self.thread_local.recovery_email_skipped = True
def recovery_bind_status(self):
"""供 OAuth 判断:bound / skipped / session(address+jwt 冷登录接码用)。"""
return {
'bound': bool(getattr(self.thread_local, 'recovery_email_bound', False)),
'skipped': bool(getattr(self.thread_local, 'recovery_email_skipped', False)),
'session': getattr(self.thread_local, 'recovery_mail_session', None),
}
def _dismiss_post_register_intercepts(self, page):
"""注册成功后、进 mail/0 前:优先绑定辅助邮箱,再处理通行密钥。
正常路径:验证码通过 →「让我们来保护你的帐户」→ 绑定临时邮箱+接码
→ 取消通行密钥 → mail/0。绑定成功后 OAuth 通常不再出现该页。
"""
acted = False
# 1) 「让我们来保护你的帐户」:主路径绑定;失败才暂时跳过
try:
from controllers.recovery_bind import is_protect_account_page, is_ott_code_page
on_protect = is_protect_account_page(page) or is_ott_code_page(page)
except Exception:
on_protect = page.locator('#EmailAddress').count() > 0 or page.locator('#iOttText').count() > 0
if on_protect and self.bind_recovery_email:
if self._try_bind_recovery_email(page):
self.log_event(
'REGISTER', 'OK', 'recovery_bind',
'注册阶段备用邮箱绑定成功(OAuth 通常不再出现此页)',
)
acted = True
else:
if self._click_if_visible(page.locator('#iShowSkip')):
self._mark_recovery_skipped()
self.log_event(
'REGISTER', 'WARN', 'skip_recovery',
'注册阶段绑定失败,已 #iShowSkipOAuth 仍可能再要求绑定',
)
acted = True
elif on_protect or page.locator('#iShowSkip').count() > 0:
# temp_mail.enabled=false 时:只跳过
if self._click_if_visible(page.locator('#iShowSkip')):
self._mark_recovery_skipped()
self.log_event('REGISTER', 'INFO', 'skip_recovery', '已点击 #iShowSkip 暂时跳过辅助邮箱')
acted = True
else:
for text in ('暂时跳过', 'Skip for now', 'Skip'):
try:
loc = page.get_by_role('link', name=text)
if self._click_if_visible(loc):
self._mark_recovery_skipped()
self.log_event('REGISTER', 'INFO', 'skip_recovery', f'已点击跳过链接: {text}')
acted = True
break
except Exception:
pass
try:
loc = page.get_by_text(text, exact=False)
if self._click_if_visible(loc):
self._mark_recovery_skipped()
self.log_event('REGISTER', 'INFO', 'skip_recovery', f'已点击跳过文案: {text}')
acted = True
break
except Exception:
pass
# 2) Windows 通行密钥 / Hello:优先点「取消」#idBtn_Back
passkey_hint = False
try:
body = (page.locator('body').inner_text(timeout=800) or '')[:1200]
passkey_hint = any(
k in body
for k in (
'通行密钥', 'Windows Hello', 'passkey', 'Passkey',
'更快速地登录', 'face, fingerprint', 'security key',
'使用 Windows Hello', '创建通行密钥',
)
)
except Exception:
pass
back = page.locator('#idBtn_Back')
try:
if back.count() > 0 and back.first.is_visible():
value = ''
try:
value = ((back.first.get_attribute('value') or '') + ' ' + (back.first.inner_text() or '')).strip()
except Exception:
value = ''
is_cancel = any(k in value for k in ('取消', 'Cancel', 'No', 'not now', 'Not now', '暂时不要'))
# 仅在确认是通行密钥/Hello 页时点取消;避免保护帐户流程里误点「取消」
if passkey_hint and is_cancel:
if self._click_if_visible(back):
self.log_event(
'REGISTER', 'INFO', 'skip_passkey',
f'已点击 #idBtn_Back value={value[:40]!r} passkey_hint={passkey_hint}',
)
acted = True
except Exception:
pass
# 文本兜底
if passkey_hint:
for text in ('取消', 'Cancel', '暂时不要', 'Not now', 'Skip for now'):
try:
if self._click_if_visible(page.get_by_role('button', name=text)):
self.log_event('REGISTER', 'INFO', 'skip_passkey', f'已点击按钮: {text}')
acted = True
break
except Exception:
pass
try:
if self._click_if_visible(page.locator(f'input[type="button"][value="{text}"]')):
self.log_event('REGISTER', 'INFO', 'skip_passkey', f'已点击 input: {text}')
acted = True
break
except Exception:
pass
return acted
def _enter_mailbox_after_register(self, page, timeout_ms=45000):
"""验证码通过后进入邮箱。
保护帐户/绑定辅助邮箱为**概率事件**(日志 2026-07-19 多批验证):
- 可能在注册后立刻出现 → 出现则绑定
- 可能完全不出现 → 直接 mail/0 + OAuth(正常)
- 也可能仅在 OAuth 中出现 → OAuth 侧再绑
不因「未出现」而长时间空等。
"""
mail_url = 'https://outlook.live.com/mail/0/'
deadline = time.time() + timeout_ms / 1000.0
force_count = 0
# 短等:给拦截页一点渲染时间;不出现则继续
protect_probe_deadline = time.time() + 5.0
saw_protect = False
try:
page.wait_for_timeout(1200)
except Exception:
pass
while time.time() < deadline:
try:
from controllers.recovery_bind import is_protect_account_page, is_ott_code_page
on_protect = is_protect_account_page(page) or is_ott_code_page(page)
except Exception:
on_protect = (
page.locator('#EmailAddress').count() > 0
or page.locator('#iOttText').count() > 0
or page.locator('#iShowSkip').count() > 0
)
if on_protect:
if not saw_protect:
self.log_event(
'REGISTER', 'INFO', 'recovery',
'检测到保护帐户页(概率出现),开始绑定辅助邮箱',
)
saw_protect = True
if self._dismiss_post_register_intercepts(page):
page.wait_for_timeout(800)
continue
if self._is_mailbox_url(page) and not on_protect:
if page.locator('#iShowSkip').count() == 0 and page.locator('#EmailAddress').count() == 0:
st = self.recovery_bind_status()
self.log_event(
'REGISTER', 'OK', 'mail_enter',
f'已在邮箱页 recovery_bound={st["bound"]} skipped={st["skipped"]} '
f'saw_protect={saw_protect} url={page.url}',
)
return True
# 短探针窗口:仅多等几秒看是否弹出保护页
if self.bind_recovery_email and not saw_protect and time.time() < protect_probe_deadline:
page.wait_for_timeout(400)
continue
if force_count < 2:
force_count += 1
try:
self.log_event(
'REGISTER', 'INFO', 'mail_goto',
f'跳转邮箱({force_count}) saw_protect={saw_protect} {mail_url}',
)
page.goto(mail_url, timeout=25000, wait_until='domcontentloaded')
page.wait_for_timeout(1200)
self._dismiss_post_register_intercepts(page)
page.wait_for_timeout(600)
if self._is_mailbox_url(page):
if not self._dismiss_post_register_intercepts(page):
if page.locator('#EmailAddress').count() == 0 and page.locator('#iShowSkip').count() == 0:
st = self.recovery_bind_status()
self.log_event(
'REGISTER', 'OK', 'mail_enter',
f'直达邮箱 recovery_bound={st["bound"]} skipped={st["skipped"]} saw_protect={saw_protect}',
)
return True
except Exception as exc:
self.log_event('REGISTER', 'WARN', 'mail_goto', f'跳转邮箱失败: {exc}')
page.wait_for_timeout(800)
else:
page.wait_for_timeout(500)
try:
page.goto(mail_url, timeout=20000, wait_until='domcontentloaded')
self._dismiss_post_register_intercepts(page)
except Exception:
pass
ok = self._is_mailbox_url(page)
try:
final_url = page.url
except Exception:
final_url = ''
self.log_event(
'REGISTER',
'OK' if ok else 'WARN',
'mail_enter',
f'最终 url={final_url} ok={ok}',
)
return ok
# ============================================================
# 验证码入口
# ============================================================
def handle_captcha(self, page):
"""验证码入口。captcha_strategy: 0=全自动按压, 1=半自动(暂停等你手动按)"""
if self.captcha_strategy == 1:
return self._captcha_manual(page)
return self._captcha_hold(page)
def _captcha_manual(self, page):
"""半自动模式:程序暂停,轮询检测是否进入邮箱(最多5分钟),你手动按压验证码"""
self.log_event('CAPTCHA', 'WARN', 'manual', '请手动完成验证码按压,等待进入邮箱...')
for _ in range(300):
page.wait_for_timeout(1000)
try:
if 'outlook.live.com/mail/0/' in page.url:
page.wait_for_timeout(2000)
self.log_event('CAPTCHA', 'OK', 'manual', '已进入邮箱!')
return True
except Exception:
pass
self.log_event('CAPTCHA', 'FAIL', 'manual', '超时(5分钟),未进入邮箱。')
return False
# ============================================================
# 全自动按压验证码
# ============================================================
def _captcha_hold(self, page):
"""全自动按压主循环:找目标 → 移动 → 按压 → 微颤 → 点按钮2 → 检查结果"""
if not self._wait_for_captcha_frame(page):
self.bump_failure('captcha_btn2_never_appeared')
self.penalize_ip(penalty=4)
self._log("未检测到验证码iframe")
return False
# 微软验证码是嵌套iframe结构
frame1 = page.frame_locator('iframe[title="验证质询"]')
frame2 = frame1.frame_locator('iframe[style*="display: block"]')
self._human_prelude(page)
btn2_seen = False
for attempt in range(self.max_captcha_retries + 1):
self._log(f"Hold {attempt+1}/{self.max_captcha_retries+1}")
page.wait_for_timeout(random.randint(200, 600))
# ① 在iframe中找到可点击的目标元素
box, target_label = self._find_target(frame2, attempt)
if not box:
continue
cx, cy = box['x'] + box['width'] / 2, box['y'] + box['height'] / 2
# ② 选择按压位置(中心/边缘/角落/随机)
pos_name, x, y = self._pick_position(box, cx, cy)
self._log(f"target={target_label} pos={pos_name}")
# ③ 从远处Bezier曲线移动到目标按钮
from_x, from_y = x + random.uniform(-250, 250), y + random.uniform(-250, 250)
page.mouse.move(from_x, from_y, steps=1)
page.wait_for_timeout(random.randint(40, 150))
self._natural_move(page, from_x, from_y, x, y)
# ④ C:double-tap — 双击→松开→长按
page.mouse.down(); page.wait_for_timeout(random.randint(25, 55))
page.mouse.up(); page.wait_for_timeout(random.randint(80, 220))
page.mouse.down(); page.wait_for_timeout(random.randint(25, 55))
page.mouse.up(); page.wait_for_timeout(random.randint(120, 380))
page.mouse.down()
# ⑤ 按住并圆形微颤,等按钮2出现
appeared = self._hold_and_wait(page, frame2, x, y)
if not appeared:
page.mouse.up()
continue
btn2_seen = True
# ⑥ click或dblclick轻量偏置轮换
bm = self._pick_b2mode()
self._record_b2_attempt(bm)
if not self._execute_b2(page, frame2, x, y, bm):
continue
# ⑦ 检查验证码是否通过
success, retry = self._check_captcha_result(page, frame1, frame2)
if not success:
break
if not retry:
with self._state_lock:
OutlookController._attempts += 1
OutlookController._success += 1
self._record_b2_success(bm)
self._record_ip('win')
self._print_stats()
return True
with self._state_lock:
OutlookController._attempts += 1
if btn2_seen:
self.bump_failure('captcha_btn2_appeared_but_failed')
else:
self.bump_failure('captcha_btn2_never_appeared')
self.penalize_ip(penalty=4)
self._record_ip('loss')
self._print_stats()
return False
def _record_ip(self, result):
"""记录本次运行中IP的表现(仅内存,不持久化)。result: 'win' 或 'loss'"""
proxy = getattr(self.thread_local, '_proxy', '')
key = proxy.split('//')[-1] if '//' in proxy else proxy
if key:
with self._state_lock:
if key not in self._ip_tracker:
self._ip_tracker[key] = {'win': 0, 'total': 0}
self._ip_tracker[key]['total'] += 1
if result == 'win':
self._ip_tracker[key]['win'] += 1
def _print_stats(self):
"""打印当前累计的验证码通过率"""
with self._state_lock:
a = max(OutlookController._attempts, 1)
s = OutlookController._success
b2_attempts = dict(OutlookController._b2_attempts)
b2_success = dict(OutlookController._b2_success)
b2_fragments = []
for mode in ('click', 'dblclick'):
attempts = b2_attempts.get(mode, 0)
if attempts <= 0:
continue
wins = b2_success.get(mode, 0)
rate = wins / attempts * 100
b2_fragments.append(f"{mode}:{wins}/{attempts}={rate:.0f}%")
suffix = f" | b2={' '.join(b2_fragments)}" if b2_fragments else ""
self._log(f"[Stats] {s}/{a}={s / a * 100:.0f}%{suffix}")
# ============================================================
# iframe / 人类化 / 鼠标移动
# ============================================================
def _wait_for_captcha_frame(self, page):
"""轮询等待验证码iframe加载,最多15秒"""
for _ in range(15):
try:
# 微软验证码嵌套iframe:外层title="验证质询",内层style*="display:block"
f1 = page.frame_locator('iframe[title="验证质询"]')
if f1.locator('iframe').count() > 0:
f2 = f1.frame_locator('iframe[style*="display: block"]') # 内层可见iframe
for sel in ['[aria-label="可访问性挑战"]', 'circle', 'svg', '[role="button"]']:
try:
cnt = f2.locator(sel).count()
if cnt > 0:
box = f2.locator(sel).first.bounding_box()
if box and box['width'] > 5:
self._log(f"iframe就绪: {sel}")
page.wait_for_timeout(random.randint(500, 1500))
return True
except Exception: continue
except Exception: pass
page.wait_for_timeout(1000)
return False
def _human_prelude(self, page):
"""验证码前的随机行为:滚动、游荡、停顿、手抖,模拟真人操作"""
for _ in range(random.randint(1, 4)):
act = random.random()
if act < 0.3:
page.evaluate(f'window.scrollBy(0, {random.randint(-200, 200)})')
page.wait_for_timeout(random.randint(200, 800))
elif act < 0.5:
page.mouse.move(random.randint(100, 600), random.randint(100, 500), steps=random.randint(3, 8))
page.wait_for_timeout(random.randint(300, 1200))
elif act < 0.75:
page.wait_for_timeout(random.randint(500, 2500))
else:
try:
pos = page.evaluate('() => ({x: 400 + Math.random()*100, y: 300 + Math.random()*100})')
page.mouse.move(pos['x'], pos['y'], steps=1)
except Exception:
pass
page.wait_for_timeout(random.randint(100, 400))
def _natural_move(self, page, x1, y1, x2, y2):
"""三段式人类鼠标轨迹:阶段1 Bezier加速接近(70%步数) → 阶段2 随机过冲 → 阶段3 微调修正"""
# 控制点随机偏移,确保每次轨迹都不同
cpx = (x1 + x2) / 2 + random.uniform(-150, 150)
cpy = (y1 + y2) / 2 + random.uniform(-120, 120)
# 阶段1: 加速接近 (ease-out 减速)
steps1 = random.randint(8, 18)
for i in range(steps1 + 1):
t = i / steps1
ease = 1 - (1 - t) ** 3
px = (1 - ease) * x1 + ease * x2
py = (1 - ease) * y1 + ease * y2
bx = (1 - t) ** 2 * x1 + 2 * (1 - t) * t * cpx + t ** 2 * x2
py = (1 - t) ** 2 * y1 + 2 * (1 - t) * t * cpy + t ** 2 * y2
px = px * 0.6 + bx * 0.4 # 混合线性进度 + Bezier弯曲
page.mouse.move(px, py, steps=1)
page.wait_for_timeout(random.randint(6, 18))
# 阶段2: 过冲 (超过目标再回来,模拟手没停稳)
if random.random() < 0.6:
page.mouse.move(x2 + random.uniform(2, 8) * random.choice([-1, 1]),
y2 + random.uniform(2, 6) * random.choice([-1, 1]), steps=1)
page.wait_for_timeout(random.randint(30, 80))
# 阶段3: 修正到精确位置
page.mouse.move(x2, y2, steps=1)
page.wait_for_timeout(random.randint(20, 60))
# ============================================================
# 目标定位 / 位置 / 按压 / 微颤 / 按钮2
# ============================================================
def _find_target(self, frame2, attempt):
"""在验证码iframe中遍历候选选择器,找到尺寸>8px的第一个可见目标"""
for sel in ['[aria-label="可访问性挑战"]', 'circle', 'ellipse',
'svg circle', 'svg ellipse', '[role="button"]', 'svg']:
try:
candidates = frame2.locator(sel)
cnt = candidates.count()
if cnt > 0:
box = candidates.nth(attempt % min(cnt, 3)).bounding_box()
if box and box['width'] > 8 and box['height'] > 8:
return box, f"{sel}[{attempt % min(cnt, 3)}/{cnt}]"
except Exception: continue
return None, ""
def _pick_position(self, box, cx, cy):
"""在目标元素上随机选取按压点:中心12%、边缘18%、角落18%、随机偏移52%"""
r = random.random()
if r < 0.12:
return "center", cx + random.uniform(-3, 3), cy + random.uniform(-3, 3)
elif r < 0.30:
e = random.choice(['t', 'b', 'l', 'r'])
if e == 't': return f"edge.{e}", cx + random.uniform(-box['width']*0.3, box['width']*0.3), box['y'] + random.uniform(1, 5)
elif e == 'b': return f"edge.{e}", cx + random.uniform(-box['width']*0.3, box['width']*0.3), box['y']+box['height'] - random.uniform(1, 5)
elif e == 'l': return f"edge.{e}", box['x'] + random.uniform(1, 5), cy + random.uniform(-box['height']*0.3, box['height']*0.3)
else: return f"edge.{e}", box['x']+box['width'] - random.uniform(1, 5), cy + random.uniform(-box['height']*0.3, box['height']*0.3)
elif r < 0.48:
c = random.choice(['tl', 'tr', 'bl', 'br'])
if c == 'tl': return f"corner.{c}", box['x'] + random.uniform(2, 8), box['y'] + random.uniform(2, 8)
elif c == 'tr': return f"corner.{c}", box['x']+box['width'] - random.uniform(2, 8), box['y'] + random.uniform(2, 8)
elif c == 'bl': return f"corner.{c}", box['x'] + random.uniform(2, 8), box['y']+box['height'] - random.uniform(2, 8)
else: return f"corner.{c}", box['x']+box['width'] - random.uniform(2, 8), box['y']+box['height'] - random.uniform(2, 8)
else:
return "random", cx + random.uniform(-box['width']*0.4, box['width']*0.4), cy + random.uniform(-box['height']*0.4, box['height']*0.4)
def _hold_and_wait(self, page, frame2, x, y):
"""按住状态下圆形微颤,等待"再次按下"按钮出现。出现后延续按压1.5-4.5s"""
self._circular_tremor(page, x, y, duration_ms=random.randint(600, 1800))
appeared = False
btn2_selectors = ['[aria-label="再次按下"]', '[aria-label*="再次"]', '[aria-label*="按下"]']
for sel in btn2_selectors:
try:
frame2.locator(sel).wait_for(state='visible', timeout=10000)
appeared = True
break
except Exception: continue
if appeared:
extra_ms = random.randint(1500, 4500)
self._log(f"btn2出现, 延续{extra_ms}ms")
self._circular_tremor(page, x, y, duration_ms=extra_ms)
return appeared
def _circular_tremor(self, page, x, y, duration_ms):
"""按住期间的圆周微颤,模拟手指自然颤抖"""
steps = max(duration_ms // 50, 5)
radius = random.uniform(0.3, 2.0)
for i in range(steps):
angle = 2 * math.pi * i / steps + random.uniform(-0.3, 0.3)
tx = x + math.cos(angle) * radius * random.uniform(0.7, 1.3)
ty = y + math.sin(angle) * radius * random.uniform(0.7, 1.3)
page.mouse.move(tx, ty, steps=1)
page.wait_for_timeout(random.randint(35, 70))
def _pick_b2mode(self):
"""轻量延续旧版策略:保留探索,但优先当前运行中表现更好的btn2模式。"""
with self._state_lock:
attempts = dict(OutlookController._b2_attempts)
wins = dict(OutlookController._b2_success)
weights = {}
for mode in ('click', 'dblclick'):
attempted = attempts.get(mode, 0)
success = wins.get(mode, 0)
if attempted >= 10:
rate = success / max(attempted, 1)
weights[mode] = rate ** 2 * 10 if rate >= 0.30 else max(0.05, rate)
elif attempted >= 5:
weights[mode] = max(0.1, success / max(attempted, 1))
else:
weights[mode] = 1.0
return random.choices(list(weights.keys()), weights=list(weights.values()), k=1)[0]
def _record_b2_attempt(self, mode):
with self._state_lock:
OutlookController._b2_attempts[mode] = OutlookController._b2_attempts.get(mode, 0) + 1
def _record_b2_success(self, mode):
with self._state_lock:
OutlookController._b2_success[mode] = OutlookController._b2_success.get(mode, 0) + 1
def _execute_b2(self, page, frame2, x, y, bm):
"""操作按钮2:定位 → 移动 → click或dblclick"""
page.wait_for_timeout(random.randint(300, 900))
btn2_selectors = ['[aria-label="再次按下"]', '[aria-label*="再次"]', '[aria-label*="按下"]']
btn2_box = None
for sel in btn2_selectors:
try:
btn2_box = frame2.locator(sel).bounding_box()
if btn2_box: break
except Exception: continue
if not btn2_box:
return False
# 在按钮2上随机偏移点击位置
b2cx, b2cy = btn2_box['x']+btn2_box['width']/2, btn2_box['y']+btn2_box['height']/2
x2 = b2cx + random.uniform(-btn2_box['width']*0.35, btn2_box['width']*0.35)
y2 = b2cy + random.uniform(-btn2_box['height']*0.35, btn2_box['height']*0.35)
page.mouse.move(x2, y2, steps=random.randint(3, 10))
page.wait_for_timeout(random.randint(50, 180))
if bm == "dblclick":
page.mouse.click(x2, y2)
page.wait_for_timeout(random.randint(80, 200))
page.mouse.click(x2 + random.uniform(-3, 3), y2 + random.uniform(-3, 3))
else:
page.mouse.click(x2, y2)
return True
def _check_captcha_result(self, page, frame1, frame2):
"""检测验证码结果。返回 (success, retry):
- (True, False): 通过
- (True, True): 需重试
- (False, False): 失败/IP被封
"""
try:
page.locator('.draw').wait_for(state="detached") # 等待加载动画消失
try:
page.locator('[role="status"][aria-label="正在加载..."]').wait_for(timeout=5000)
page.wait_for_timeout(8000)
if page.get_by_text('一些异常活动').count() or page.get_by_text('此站点正在维护').count() > 0:
return False, False # IP被风控
if frame2.locator('[aria-label="可访问性挑战"]').count() > 0:
return True, True # 验证码重置,需要重试
return True, False # 验证码通过
except Exception:
if page.get_by_text('取消').count() > 0:
return True, False # 取消按钮出现 → 通过
frame1.get_by_text("请再试一次").wait_for(timeout=15000) # 提示重试
return True, True
except Exception:
if page.get_by_text('取消').count() > 0:
return True, False
return False, False # .draw未消失 → 失败