Files
OutlookRegister/main.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

943 lines
42 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 json
import atexit
import signal
import threading
from controllers.oauth2 import (
get_oauth2_token, CLIENT_ID, _extract_code_from_url,
build_auth_url, _wait_for_auth_entry_state,
_perform_login_after_cookie_fail, _click_consent_and_exchange, _exchange_captured_code,
_settle_auth_page, _disable_auth_page_autofill, AUTH_NAV_TIMEOUT_MS, AUTH_ENTRY_TIMEOUT_MS,
_resolve_account_type, _dump_auth_page,
)
from concurrent.futures import ThreadPoolExecutor
from utils import random_email, generate_strong_password
from controllers.outlook_controller import OutlookController
RESULTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Results') # 输出目录(oauth2.txt在此)
RESULT_WRITE_LOCK = threading.Lock()
# 默认 fingerprint 目录(与 OutlookController 默认一致)
DEFAULT_BROWSER_PROFILES = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'browser_profiles')
# 供 atexit / signal 在任意退出路径清空 profiles
# interrupt_requested: Ctrl+C 协作停止;summary 必须先于清理写出
_RUNTIME = {
'controller': None,
'profiles_root': DEFAULT_BROWSER_PROFILES,
'cleaned': False,
'interrupt_requested': False,
}
def _cleanup_browser_profiles_on_exit(force_dir_wipe=True):
"""进程退出时关闭浏览器并清空 browser_profiles(正常 / Ctrl+C / 多数异常退出)。
注意:不要在 signal handler 里直接调用——应先写汇总再清理,否则会跳过 Breakdown。
"""
ctrl = _RUNTIME.get('controller')
if not _RUNTIME.get('cleaned'):
_RUNTIME['cleaned'] = True
try:
if ctrl is not None:
ctrl.clean_up(type='all_browser')
except Exception:
pass
# 再兜底清一次目录(幂等;防止 clean_up 中途失败留下残留)
if force_dir_wipe:
root = _RUNTIME.get('profiles_root') or DEFAULT_BROWSER_PROFILES
if ctrl is not None and getattr(ctrl, 'browser_user_data_root', None):
root = ctrl.browser_user_data_root
try:
OutlookController.clear_browser_profiles_dir(root, log_fn=None)
except Exception:
pass
def _request_interrupt(signum=None, frame=None):
"""Ctrl+C / SIGTERM:只置位并唤醒主线程为 KeyboardInterrupt。
不在此处清理浏览器 / SystemExit,否则 main 的汇总分支永远跑不到。
汇总 + clean_up 由 main 的 except/finally 负责;atexit 兜底。
"""
_RUNTIME['interrupt_requested'] = True
raise KeyboardInterrupt()
def interrupt_requested():
return bool(_RUNTIME.get('interrupt_requested'))
def append_oauth_result(email, password, refresh_token):
os.makedirs(RESULTS_DIR, exist_ok=True)
with RESULT_WRITE_LOCK:
with open(os.path.join(RESULTS_DIR, 'oauth2.txt'), 'a', encoding='utf-8') as f:
f.write(f"{email}----{password}----{CLIENT_ID}----{refresh_token}\n")
def _login_and_get_token(page, email, password, prefix='', failure_hook=None, log_hook=None, current_proxy='', token_proxy_getter=None, temp_mail_cfg=None, recovery_already_bound=False, recovery_session=None):
"""
OAuth2 Step 2: 在新浏览器(新IP)中完成完整登录 + 授权。
冷登录常见:邮箱 →「验证电子邮件」→ 发送验证码 → #codeEntry-0..5 自动验证
→ 保持登录「否」→ consent。需 recovery_session(address+jwt) 接码。
返回: (True, refresh_token) 或 (False, None)
"""
from controllers.oauth2 import _handle_protect_account, _handle_proof_verify, _handle_kmsi
# NEW 路径:若已注入注册 cookieprefer_sso 有助于直接 consent;勿强制 sso_reload
auth_url = build_auth_url(prefer_sso=True)
captured_code = [None]
def _log(stage, message, level='INFO'):
if log_hook:
log_hook(stage, message, level)
return
tag = prefix if prefix else "[OAuth2:NEW]"
print(f"{tag}[{level}] {time.strftime('%H:%M:%S')} | {stage} | {message}")
def on_request(request):
code = _extract_code_from_url(request.url)
if code:
captured_code[0] = code
def on_frame_navigated(frame):
code = _extract_code_from_url(frame.url)
if code:
captured_code[0] = code
page.on('request', on_request)
page.on('framenavigated', on_frame_navigated)
try:
_log('start', '开始 OAuth2 (新浏览器+新IP)')
page.goto(auth_url, timeout=AUTH_NAV_TIMEOUT_MS, wait_until="domcontentloaded")
_settle_auth_page(page, _log, 'goto')
_disable_auth_page_autofill(page, _log)
_log('goto', '进入auth页面')
state = _wait_for_auth_entry_state(page, timeout_ms=AUTH_ENTRY_TIMEOUT_MS)
_log('entry', f'首次检测状态={state}')
if state == 'account_type':
state = _resolve_account_type(page, _log, captured_code=captured_code)
_log('entry', f'帐户类型处理后状态={state}')
if state == 'protect_account':
state = _handle_protect_account(
page, _log, temp_mail_cfg=temp_mail_cfg, failure_hook=failure_hook,
already_bound=recovery_already_bound,
)
_log('entry', f'保护帐户处理后状态={state}')
if state == 'proof_verify':
state = _handle_proof_verify(
page, _log, temp_mail_cfg=temp_mail_cfg,
recovery_session=recovery_session, failure_hook=failure_hook,
)
_log('entry', f'proof 验证后状态={state}')
if state == 'kmsi':
state = _handle_kmsi(page, _log)
_log('entry', f'kmsi 处理后状态={state}')
if state in ('login_email', 'login_password', 'account_type', 'protect_account', 'proof_verify', 'kmsi'):
_log('entry', '新浏览器路径禁止 cookie recovery,直接进入登录流程', 'INFO')
ok = _perform_login_after_cookie_fail(
page,
email,
password,
_log,
failure_hook=failure_hook,
state=state,
captured_code=captured_code,
temp_mail_cfg=temp_mail_cfg,
recovery_already_bound=recovery_already_bound,
recovery_session=recovery_session,
)
if not ok:
return False, None
if captured_code[0]:
ok, refresh_token = _exchange_captured_code(
page,
captured_code,
_log,
failure_hook=failure_hook,
current_proxy=current_proxy,
token_proxy_getter=token_proxy_getter,
)
if not ok:
return False, None
_log('token', 'token获取成功!', 'OK')
return True, refresh_token
# 登录后可能刚到 consent / 又弹出帐户类型 / 保护帐户 / proof
state = _wait_for_auth_entry_state(page, timeout_ms=8000)
if state == 'account_type':
state = _resolve_account_type(page, _log, captured_code=captured_code)
if state == 'protect_account':
state = _handle_protect_account(
page, _log, temp_mail_cfg=temp_mail_cfg, failure_hook=failure_hook,
already_bound=recovery_already_bound,
)
if state == 'proof_verify':
state = _handle_proof_verify(
page, _log, temp_mail_cfg=temp_mail_cfg,
recovery_session=recovery_session, failure_hook=failure_hook,
)
if state == 'kmsi':
state = _handle_kmsi(page, _log)
_log('entry', f'登录后阶段={state}')
if state == 'code' and captured_code[0]:
ok, refresh_token = _exchange_captured_code(
page,
captured_code,
_log,
failure_hook=failure_hook,
current_proxy=current_proxy,
token_proxy_getter=token_proxy_getter,
)
if not ok:
return False, None
_log('token', 'token获取成功!', 'OK')
return True, refresh_token
if state == 'consent':
ok, refresh_token = _click_consent_and_exchange(
page,
captured_code,
_log,
failure_hook=failure_hook,
current_proxy=current_proxy,
token_proxy_getter=token_proxy_getter,
)
if not ok:
return False, None
_log('token', 'token获取成功!', 'OK')
return True, refresh_token
if failure_hook:
failure_hook('oauth_consent_fail')
_dump_auth_page(page, _log)
_log('entry', f'未进入同意或登录页面,最终状态={state}', 'FAIL')
return False, None
except Exception as e:
_log('exception', f'异常: {e}', 'FAIL')
return False, None
finally:
page.remove_listener('request', on_request)
page.remove_listener('framenavigated', on_frame_navigated)
def process_single_flow(controller, task_num=0, total=0):
page = None
t_start = time.time()
task_ok = False
progress_noted = False
def _note(ok):
nonlocal progress_noted
if progress_noted:
return
progress_noted = True
try:
controller.note_task_finished(ok, total)
except Exception:
pass
try:
controller.set_task_prefix(task_num, total)
page = controller.get_thread_page()
if not page:
controller.log_event('REGISTER', 'FAIL', 'bootstrap', "浏览器页面创建失败,跳过当前任务")
_note(False)
return False
current_proxy = getattr(controller.thread_local, '_proxy', '')
email = random_email()
password = generate_strong_password()
controller.log_event('REGISTER', 'INFO', 'account', f"Generate {email}{controller.email_suffix}")
# 注册微软邮箱: True / False / 'handed_off'(策略2 仅到验证码后交由人工)
result = controller.outlook_register(page, email, password)
# 策略 2:只自动到验证码界面,过码 + OAuth 全由人工,程序不再跑 OAuth
if result == 'handed_off':
controller.log_event(
'REGISTER', 'OK', 'finish',
f"策略2交接完成(验证码起由人工) ({time.time()-t_start:.0f}s)",
)
task_ok = True
_note(True)
return True
# 如果注册成功且不需要Oauth2,则直接返回true
if result and not controller.enable_oauth2:
controller.log_event('REGISTER', 'OK', 'finish', f"成功 ({time.time()-t_start:.0f}s)")
task_ok = True
_note(True)
return True
# 如果注册失败,直接返回false
if not result:
controller.log_event('REGISTER', 'FAIL', 'finish', f"注册失败 ({time.time()-t_start:.0f}s)")
_note(False)
return False
# 拼接完整的outlook或者hotmail邮箱
full_email = f"{email}{controller.email_suffix}"
controller.log_event('OAUTH_COOKIE', 'INFO', 'start', f"注册完成,开始OAuth2: {full_email}", attempt=1)
rec_status = controller.recovery_bind_status()
recovery_session = rec_status.get('session')
controller.log_event(
'OAUTH_COOKIE', 'INFO', 'recovery_status',
f"注册阶段 recovery_bound={rec_status['bound']} skipped={rec_status['skipped']} "
f"session_addr={(recovery_session or {}).get('address')}",
attempt=1,
)
oauth_ok, token = get_oauth2_token(
page,
full_email,
password,
RESULTS_DIR,
failure_hook=controller.bump_failure,
log_hook=controller.make_logger('OAUTH_COOKIE', attempt=1),
current_proxy=current_proxy,
token_proxy_getter=lambda exclude='': controller.fresh_proxy_url(exclude=exclude),
temp_mail_cfg=getattr(controller, 'temp_mail_cfg', None),
recovery_already_bound=rec_status['bound'],
recovery_session=recovery_session,
)
# 拿到 token 后立刻记进度(在 clean_up 之前)
if oauth_ok:
append_oauth_result(full_email, password, token)
controller.log_event('OAUTH_COOKIE', 'OK', 'finish', f"OAuth2 token获取成功 ({time.time()-t_start:.0f}s)", attempt=1)
task_ok = True
_note(True)
return True
# COOKIE 路径失败:优先再同浏览器重试 1 次(多等 cookie),避免立刻丢掉 SSO
controller.log_event('OAUTH_COOKIE', 'WARN', 'retry_same', '同浏览器再试 OAuth 一次(沉淀 cookie 后)', attempt=1)
try:
page.wait_for_timeout(7000)
except Exception:
pass
oauth_ok, token = get_oauth2_token(
page,
full_email,
password,
RESULTS_DIR,
failure_hook=controller.bump_failure,
log_hook=controller.make_logger('OAUTH_COOKIE', attempt=2),
current_proxy=current_proxy,
token_proxy_getter=lambda exclude='': controller.fresh_proxy_url(exclude=exclude),
temp_mail_cfg=getattr(controller, 'temp_mail_cfg', None),
recovery_already_bound=controller.recovery_bind_status()['bound'],
recovery_session=controller.recovery_bind_status().get('session'),
)
if oauth_ok:
append_oauth_result(full_email, password, token)
controller.log_event('OAUTH_COOKIE', 'OK', 'finish', f"OAuth2 token获取成功(同浏览器重试) ({time.time()-t_start:.0f}s)", attempt=2)
task_ok = True
_note(True)
return True
# 仍失败:导出 storage_state 再开新浏览器注入 cookie(比纯空浏览器好)
# 注意:纯 NEW 无 cookie 时常见「找不到帐户 / 密码登录不可用 / 密钥页」
storage_state = None
try:
storage_state = page.context.storage_state()
controller.log_event('OAUTH_NEW', 'INFO', 'cookie_export', f"已导出 storage_state cookies={len(storage_state.get('cookies') or [])}")
except Exception as exc:
controller.log_event('OAUTH_NEW', 'WARN', 'cookie_export', f"导出 cookie 失败: {exc}")
controller.clean_up(page, "done_browser")
page = None
# 最多 2 次 NEW(比原先 3 次更克制);优先带 cookie 启动
for attempt in range(1, 3):
if time.time() - t_start > 600:
controller.log_event('OAUTH_NEW', 'FAIL', 'timeout', f"任务超10分钟,放弃 ({time.time()-t_start:.0f}s)", attempt=attempt)
controller.penalize_ip(penalty=2)
_note(False)
return False
controller.set_task_prefix(task_num, total)
controller.log_event(
'OAUTH_NEW', 'WARN', 'retry',
f"OAuth2 新环境重试 {attempt}/2(注入注册 cookie={bool(storage_state)}...",
attempt=attempt,
)
try:
page = controller.get_thread_page()
if not page:
controller.log_event('OAUTH_NEW', 'WARN', 'page', f"重试 {attempt} 获取页面失败,等待后重试...", attempt=attempt)
time.sleep(5)
continue
if storage_state:
try:
# 注入注册会话 cookie,避免「找不到该用户名」的冷启动
page.context.add_cookies(storage_state.get('cookies') or [])
controller.log_event('OAUTH_NEW', 'INFO', 'cookie_import', f"已注入 cookies={len(storage_state.get('cookies') or [])}", attempt=attempt)
except Exception as exc:
controller.log_event('OAUTH_NEW', 'WARN', 'cookie_import', f"注入 cookie 失败: {exc}", attempt=attempt)
ok, token = _login_and_get_token(
page,
full_email,
password,
prefix=controller._log_prefix_str(),
failure_hook=controller.bump_failure,
log_hook=controller.make_logger('OAUTH_NEW', attempt=attempt),
current_proxy=getattr(controller.thread_local, '_proxy', ''),
token_proxy_getter=lambda exclude='': controller.fresh_proxy_url(exclude=exclude),
temp_mail_cfg=getattr(controller, 'temp_mail_cfg', None),
recovery_already_bound=controller.recovery_bind_status()['bound'],
recovery_session=controller.recovery_bind_status().get('session'),
)
if ok:
append_oauth_result(full_email, password, token)
controller.log_event('OAUTH_NEW', 'OK', 'finish', f"OAuth2 token获取成功 ({time.time()-t_start:.0f}s)", attempt=attempt)
task_ok = True
_note(True)
return True
except Exception as e:
from controllers.oauth2 import _compact_exc
controller.log_event('OAUTH_NEW', 'FAIL', 'exception', f"重试异常: {_compact_exc(e)}", attempt=attempt)
finally:
if page:
try:
controller.clean_up(page, "done_browser")
except Exception:
pass
page = None
time.sleep(3)
controller.bump_failure('oauth_retry_exhausted')
controller.log_event('OAUTH_NEW', 'FAIL', 'finish', f"OAuth2 同浏览器+新环境均失败 ({time.time()-t_start:.0f}s)", attempt=2)
controller.penalize_ip(penalty=2)
_note(False)
return False
except Exception as e:
from controllers.oauth2 import _compact_exc
controller.log_event('REGISTER', 'FAIL', 'exception', f"异常 ({time.time()-t_start:.0f}s): {_compact_exc(e)}")
_note(False)
return False
finally:
if not progress_noted:
_note(task_ok)
if page:
controller.clean_up(page, "done_browser")
def build_summary_lines(controller, tasks, succeeded_tasks, failed_tasks, elapsed_total, interrupted=False):
fs = controller.failure_stats
total_done = succeeded_tasks + failed_tasks
ip_fail = fs['ip_cant_open'] + fs['ip_blocked']
captcha_reached = total_done - ip_fail
oauth_success = succeeded_tasks
lines = [
"",
"=" * 50,
f"[Result] 状态: {'INTERRUPTED' if interrupted else 'DONE'}",
f"[Result] 总任务目标: {tasks}",
f"[Result] 已完成: {total_done}",
f"[Result] 成功: {succeeded_tasks}/{max(total_done, 1)} ({succeeded_tasks / max(total_done, 1) * 100:.0f}%)",
f"[Result] 耗时: {elapsed_total / 60:.1f}min",
"─" * 50,
f"[Breakdown] IP打不开页面: {fs['ip_cant_open']:>3}个 ({fs['ip_cant_open']/max(total_done, 1)*100:5.1f}%)",
f"[Breakdown] IP被风控拦截: {fs['ip_blocked']:>3}个 ({fs['ip_blocked']/max(total_done, 1)*100:5.1f}%)",
f"[Breakdown] IP失败合计: {ip_fail:>3}个 ({ip_fail/max(total_done, 1)*100:5.1f}%)",
f"[Breakdown] 验证码未通过: {fs['captcha_fail']:>3}个",
f"[Breakdown] btn2从未出现: {fs['captcha_btn2_never_appeared']:>3}个",
f"[Breakdown] btn2出现后失败: {fs['captcha_btn2_appeared_but_failed']:>3}个",
f"[Breakdown] FunCaptcha: {fs['funcaptcha']:>3}个",
f"[Breakdown] 注册页打开失败: {fs['register_page_open_fail']:>3}个",
f"[Breakdown] 注册流程失败: {fs['register_form_fail']:>3}个",
f"[Breakdown] 浏览器启动失败: {fs['browser_launch_fail']:>3}个",
f"[Breakdown] Context创建失败: {fs['browser_context_fail']:>3}个",
f"[Breakdown] Page创建失败: {fs['browser_page_fail']:>3}个",
f"[Breakdown] Playwright异常: {fs['playwright_runtime_fail']:>3}个",
f"[Breakdown] 邮箱未初始化: {fs['mail_init_fail']:>3}个",
f"[Breakdown] OAuth登录超时: {fs['oauth_login_timeout']:>3}个",
f"[Breakdown] OAuth同意失败: {fs['oauth_consent_fail']:>3}个",
f"[Breakdown] OAuth密码错误: {fs.get('oauth_password_wrong', 0):>3}个",
f"[Breakdown] OAuth密码不可用: {fs.get('oauth_password_blocked', 0):>3}个",
f"[Breakdown] 备用邮箱绑定失败: {fs.get('recovery_bind_fail', 0):>3}个",
f"[Breakdown] OAuth抓码失败: {fs['oauth_code_fail']:>3}个",
f"[Breakdown] OAuth网络失败: {fs['oauth_token_network_fail']:>3}个",
f"[Breakdown] OAuth换token失败:{fs['oauth_token_fail']:>3}个",
f"[Breakdown] OAuth重试耗尽: {fs['oauth_retry_exhausted']:>3}个",
"─" * 50,
]
if captcha_reached > 0:
lines.append(f"[Breakdown] 到达验证码: {captcha_reached:>3}个")
lines.append(f"[Breakdown] 剔除IP后成功率: {oauth_success}/{captcha_reached} ({oauth_success/captcha_reached*100:.0f}%)")
lines.append("=" * 50)
return lines
def build_cumulative_lines(total_succeeded, total_failed, total_elapsed, batch_index):
total_done = total_succeeded + total_failed
return [
"─" * 50,
f"[Cumulative] 批次: {batch_index}",
f"[Cumulative] 已完成: {total_done}",
f"[Cumulative] 成功: {total_succeeded}/{max(total_done, 1)} ({total_succeeded / max(total_done, 1) * 100:.0f}%)",
f"[Cumulative] 耗时: {total_elapsed / 60:.1f}min",
"─" * 50,
]
def _collect_done_futures(running_futures, succeeded_tasks, failed_tasks):
"""收集已完成 future,返回 (running, succeeded, failed, got_any)。"""
done_futures = {f for f in running_futures if f.done()}
if not done_futures:
return running_futures, succeeded_tasks, failed_tasks, False
for future in done_futures:
try:
if future.result():
succeeded_tasks += 1
else:
failed_tasks += 1
except Exception:
failed_tasks += 1
running_futures.discard(future)
return running_futures, succeeded_tasks, failed_tasks, True
def run_concurrent_flows(
controller,
concurrent_flows=10,
tasks=100,
success_tasks=None,
task_offset=0,
progress_total=None,
progress_base_succeeded=0,
progress_base_failed=0,
run_started_at=None,
drain_timeout_sec=180,
stall_timeout_sec=600,
):
"""
并发任务调度器。
停止条件(停投递;在途任务限时收尾):
- tasks: 本批最多提交数
- success_tasks: 本批成功数目标(None=不按成功数截断)
注意:成功目标只看「已完成成功数」,不用「成功+在途」预占名额
(预占会在成功 299、剩余 1 个在途卡住时永久停住)。
在途收尾有超时;超时后取消 future 并强制关浏览器,避免卡死整批。
progress_base_* / run_started_at:跨批次累计展示(成功/失败/耗时连续)。
"""
task_counter = 0
succeeded_tasks = 0 # 本批成功(用于 batch_success_limit
failed_tasks = 0
t_batch_start = time.time()
run_started = run_started_at if run_started_at is not None else t_batch_start
base_ok = int(progress_base_succeeded or 0)
base_fail = int(progress_base_failed or 0)
display_total = progress_total if progress_total is not None else tasks
last_progress_at = t_batch_start
# 展示用累计;本批判定仍用 succeeded_tasks / failed_tasks
if hasattr(controller, 'set_progress_base'):
controller.set_progress_base(base_ok, base_fail, run_started)
else:
controller.update_runtime_stats(
started_at=run_started,
submitted=0,
running=0,
succeeded=base_ok,
failed=base_fail,
)
def _sync_stats(running_n):
# runtime_stats 写累计值,保证 [进度] 与中断快照跨批连续
controller.update_runtime_stats(
submitted=task_counter,
running=running_n,
succeeded=base_ok + succeeded_tasks,
failed=base_fail + failed_tasks,
started_at=run_started,
)
def _force_abandon(running_futures, reason, timeout_sec):
nonlocal succeeded_tasks, failed_tasks
abandoned = len(running_futures)
controller.log_plain(
f"[Batch][WARN] {reason}{timeout_sec}s),放弃 {abandoned} 个未完成 "
f"(batch_success={succeeded_tasks}"
f"{'/' + str(success_tasks) if success_tasks is not None else ''})"
)
for fut in list(running_futures):
fut.cancel()
try:
# 强制关浏览器,让卡在 Playwright 的线程尽快抛错退出
controller.clean_up(type="all_browser")
except Exception:
pass
t_end = time.time() + 15
while running_futures and time.time() < t_end:
running_futures, succeeded_tasks, failed_tasks, got = _collect_done_futures(
running_futures, succeeded_tasks, failed_tasks
)
if got:
_sync_stats(len(running_futures))
else:
time.sleep(0.2)
leftover = len(running_futures)
if leftover:
failed_tasks += leftover
running_futures.clear()
controller.log_plain(
f"[Batch][WARN] 仍有 {leftover} 个任务未返回,记为失败并继续下一批"
)
_sync_stats(0)
return running_futures
# 不用 with:默认 shutdown(wait=True) 会在卡死线程上永久阻塞
executor = ThreadPoolExecutor(max_workers=concurrent_flows)
running_futures = set()
stop_submit = False
try:
while True:
running_futures, succeeded_tasks, failed_tasks, got = _collect_done_futures(
running_futures, succeeded_tasks, failed_tasks
)
if got:
last_progress_at = time.time()
_sync_stats(len(running_futures))
if success_tasks is not None and succeeded_tasks >= success_tasks:
stop_submit = True
if task_counter >= tasks:
stop_submit = True
if not stop_submit:
# 只按已完成成功数判断,允许并发略超 batch 目标,避免「成功+在途」预占卡死
while (
len(running_futures) < concurrent_flows
and task_counter < tasks
and (success_tasks is None or succeeded_tasks < success_tasks)
):
task_counter += 1
global_num = task_offset + task_counter
new_future = executor.submit(
process_single_flow, controller, global_num, display_total
)
running_futures.add(new_future)
_sync_stats(len(running_futures))
if display_total > 1 and global_num % max(display_total // 2, 1) == 0:
controller.log_plain(f"已提交 {global_num}/{display_total} 任务.")
last_progress_at = time.time()
if stop_submit:
if not running_futures:
break
idle = time.time() - last_progress_at
if idle >= drain_timeout_sec:
running_futures = _force_abandon(
running_futures, "在途收尾超时", drain_timeout_sec
)
break
else:
if not running_futures and task_counter >= tasks:
break
# 未达成功目标但在途全卡住:限时放弃,避免永远停在 299
if running_futures and (time.time() - last_progress_at) >= stall_timeout_sec:
running_futures = _force_abandon(
running_futures, "在途任务停滞", stall_timeout_sec
)
break
# 协作式中断:信号只置位 + KeyboardInterrupt;此处尽快停投递并退出循环
if interrupt_requested():
stop_submit = True
controller.log_plain(
"[Signal][WARN] run_concurrent_flows 检测到中断请求,停止提交并收尾在途计数"
)
# 尽量收集已完成的,避免进度少算;未完成的不无限等
running_futures, succeeded_tasks, failed_tasks, got = _collect_done_futures(
running_futures, succeeded_tasks, failed_tasks
)
if got:
_sync_stats(len(running_futures))
leftover = len(running_futures)
if leftover:
# 在途任务稍后会被 clean_up 掐断;计数上记失败,避免汇总空白
failed_tasks += leftover
running_futures.clear()
controller.log_plain(
f"[Signal][WARN] 中断时放弃 {leftover} 个在途任务(记为失败)"
)
_sync_stats(0)
break
time.sleep(0.2)
except KeyboardInterrupt:
_sync_stats(len(running_futures))
controller.log_plain("[Signal][WARN] run_concurrent_flows 收到 Ctrl+C,停止继续提交任务")
# 尽量同步已完成结果;在途记失败
try:
running_futures, succeeded_tasks, failed_tasks, _ = _collect_done_futures(
running_futures, succeeded_tasks, failed_tasks
)
leftover = len(running_futures)
if leftover:
failed_tasks += leftover
running_futures.clear()
except Exception:
pass
_sync_stats(0)
# 不 re-raise:把本批已有计数带回 main 写汇总,再由 main 清理
_RUNTIME['interrupt_requested'] = True
finally:
try:
executor.shutdown(wait=False, cancel_futures=True)
except TypeError:
# Python < 3.9 无 cancel_futures
executor.shutdown(wait=False)
except Exception:
pass
elapsed_batch = time.time() - t_batch_start
controller.update_runtime_stats(
submitted=task_counter,
running=0,
succeeded=base_ok + succeeded_tasks,
failed=base_fail + failed_tasks,
started_at=run_started,
)
# 中断时本批 Breakdown 由 main 用 interrupted=True 再打一次,避免重复
if not interrupt_requested():
for line in build_summary_lines(controller, display_total, succeeded_tasks, failed_tasks, elapsed_batch):
controller.log_plain(line)
return succeeded_tasks, failed_tasks
if __name__ == "__main__":
atexit.register(_cleanup_browser_profiles_on_exit)
# 信号只请求中断,不直接 SystemExit/清浏览器,保证能先写汇总
for _sig in (getattr(signal, 'SIGINT', None), getattr(signal, 'SIGTERM', None), getattr(signal, 'SIGBREAK', None)):
if _sig is None:
continue
try:
signal.signal(_sig, _request_interrupt)
except Exception:
pass
with open('config.json', 'r', encoding='utf-8') as f:
raw = f.read()
lines = [line for line in raw.split('\n') if not line.strip().startswith('//')]
data = json.loads('\n'.join(lines))
# 启动时先清掉上次异常残留的 profiles
browser_cfg = data.get('browser') or {}
profiles_root = (browser_cfg.get('user_data_root') or '').strip() or DEFAULT_BROWSER_PROFILES
_RUNTIME['profiles_root'] = profiles_root
OutlookController.clear_browser_profiles_dir(profiles_root, log_fn=None)
# 全局结束条件:tasks(提交数)与 success_tasks(成功数)任一达标即整次运行结束
tasks = max(1, int(data["tasks"]))
raw_success = data.get("success_tasks")
success_tasks = None if raw_success is None else max(1, int(raw_success))
concurrent_flows = data["concurrent_flows"]
batch_success_limit = max(1, int(data.get("batch_success_limit", 300)))
total_succeeded = 0
total_failed = 0
total_submitted = 0
total_elapsed = 0.0
batch_index = 0
interrupted = False
selected_controller = None
run_started_at = time.time() # 整次运行起点:进度总耗时跨批连续
try:
while True:
remaining_tasks = tasks - total_submitted
if remaining_tasks <= 0:
break
if success_tasks is not None and total_succeeded >= success_tasks:
break
remaining_success = None if success_tasks is None else (success_tasks - total_succeeded)
if remaining_success is not None and remaining_success <= 0:
break
# 每批成功上限始终生效(含 success_tasks=null);并受剩余成功目标约束
if remaining_success is None:
batch_target = batch_success_limit
else:
batch_target = min(batch_success_limit, remaining_success)
batch_index += 1
selected_controller = OutlookController(data)
_RUNTIME['controller'] = selected_controller
_RUNTIME['profiles_root'] = selected_controller.browser_user_data_root
_RUNTIME['cleaned'] = False
# 进度基数:累计成功/失败 + 整次起点,保证 [进度] 跨批连续
selected_controller.set_progress_base(total_succeeded, total_failed, run_started_at)
if batch_index == 1:
selected_controller.log_plain(f"[Log] 本次日志文件: {selected_controller.log_path}")
selected_controller.log_plain(
"[Batch] 代理为固定出口 IP;批次重启仅刷新程序内权重/统计,不会更换外部出口 IP"
)
selected_controller.log_plain(
f"[Batch] 全局结束条件: tasks={tasks} 或 success_tasks="
f"{success_tasks if success_tasks is not None else 'null(不限)'} 任一达标即停; "
f"batch_success_limit={batch_success_limit}"
)
selected_controller.log_plain(
"[Batch] 满 batch_success_limit 后清 IP/代理权重并开下一批;"
"累计成功/失败/耗时/[进度] 连续累加,不因换批归零"
)
rem_s = remaining_success if remaining_success is not None else "unlimited"
selected_controller.log_plain(
f"[Batch] start index={batch_index} batch_success_target={batch_target} "
f"remaining_tasks={remaining_tasks} remaining_success={rem_s} "
f"cumulative_success={total_succeeded} cumulative_submitted={total_submitted}"
)
batch_succeeded = 0
batch_failed = 0
batch_interrupted = False
try:
batch_succeeded, batch_failed = run_concurrent_flows(
selected_controller,
concurrent_flows,
tasks=remaining_tasks,
success_tasks=batch_target,
task_offset=total_submitted,
progress_total=tasks,
progress_base_succeeded=total_succeeded,
progress_base_failed=total_failed,
run_started_at=run_started_at,
)
if interrupt_requested():
batch_interrupted = True
interrupted = True
except KeyboardInterrupt:
interrupted = True
batch_interrupted = True
_RUNTIME['interrupt_requested'] = True
selected_controller.log_plain(
"[Signal][WARN] 检测到 Ctrl+C,准备写入中断汇总并清理资源"
)
# 若中断发生在 run_concurrent_flows 之外,用 runtime 快照兜底本批计数
if batch_succeeded == 0 and batch_failed == 0:
snapshot = selected_controller.get_runtime_stats()
cum_ok = snapshot.get('succeeded', total_succeeded)
cum_fail = snapshot.get('failed', total_failed)
batch_succeeded = max(0, int(cum_ok) - total_succeeded)
batch_failed = max(0, int(cum_fail) - total_failed)
# ★ 先写汇总,再关浏览器(避免旧逻辑 signal 里先 clean 导致无汇总)
batch_elapsed = time.time() - run_started_at - total_elapsed
if batch_elapsed < 0:
batch_elapsed = 0.0
if batch_interrupted:
selected_controller.log_plain(
"[Signal][WARN] 中断汇总:以下为当前批次/累计统计(在途任务可能未完全计入成功)"
)
for line in build_summary_lines(
selected_controller, tasks, batch_succeeded, batch_failed, batch_elapsed, interrupted=True
):
selected_controller.log_plain(line)
# 正常结束时 run_concurrent_flows 内已打本批 Breakdown,此处不重复
total_succeeded += batch_succeeded
total_failed += batch_failed
batch_done = batch_succeeded + batch_failed
total_submitted += batch_done
total_elapsed += batch_elapsed
for line in build_cumulative_lines(total_succeeded, total_failed, total_elapsed, batch_index):
selected_controller.log_plain(line)
if interrupted:
selected_controller.log_plain(
f"[Batch] 中断退出 total_success={total_succeeded} total_failed={total_failed} "
f"total_submitted={total_submitted} batches={batch_index} "
f"elapsed={total_elapsed / 60:.1f}min"
)
selected_controller.log_plain(f"[Log] 已写入: {selected_controller.log_path}")
selected_controller.log_plain("[Signal][WARN] 开始清理浏览器与 browser_profiles…")
try:
selected_controller.clean_up(type="all_browser")
except Exception:
pass
_RUNTIME['cleaned'] = True
break
# 正常批次结束:再清浏览器
try:
selected_controller.clean_up(type="all_browser")
except Exception:
pass
_RUNTIME['cleaned'] = True
if success_tasks is not None and total_succeeded >= success_tasks:
selected_controller.log_plain(
f"[Batch] stop: success_tasks 达标 ({total_succeeded}/{success_tasks})"
)
break
if total_submitted >= tasks:
selected_controller.log_plain(
f"[Batch] stop: tasks 达标 ({total_submitted}/{tasks})"
)
break
if batch_done <= 0:
selected_controller.log_plain("[Batch] stop: 本批无完成任务,避免空转")
break
# 未达全局上限:清代理/IP 权重与验证码统计,开下一批(累计成功/失败/耗时保留)
OutlookController.reset_shared_state()
selected_controller.log_plain(
f"[Batch] reset index={batch_index} "
f"cumulative_success={total_succeeded} cumulative_failed={total_failed} "
f"cumulative_submitted={total_submitted}"
)
if selected_controller and not interrupted:
selected_controller.log_plain(
f"[Batch] 结束 total_success={total_succeeded} total_failed={total_failed} "
f"total_submitted={total_submitted} batches={batch_index} "
f"elapsed={total_elapsed / 60:.1f}min"
)
selected_controller.log_plain(f"[Log] 已写入: {selected_controller.log_path}")
except KeyboardInterrupt:
# 主循环外(例如批次间隙)再次 Ctrl+C
interrupted = True
_RUNTIME['interrupt_requested'] = True
ctrl = _RUNTIME.get('controller') or selected_controller
if ctrl is not None:
try:
ctrl.log_plain("[Signal][WARN] 主循环外收到 Ctrl+C,写入可用汇总后退出")
snap = ctrl.get_runtime_stats()
cum_ok = int(snap.get('succeeded', total_succeeded) or total_succeeded)
cum_fail = int(snap.get('failed', total_failed) or total_failed)
# 若本批尚未累加进 total_*,用快照
if cum_ok >= total_succeeded:
show_ok, show_fail = cum_ok, cum_fail
else:
show_ok, show_fail = total_succeeded, total_failed
show_done = show_ok + show_fail
elapsed = time.time() - run_started_at
for line in build_cumulative_lines(show_ok, show_fail, elapsed, max(batch_index, 1)):
ctrl.log_plain(line)
ctrl.log_plain(
f"[Batch] 中断退出 total_success={show_ok} total_failed={show_fail} "
f"total_submitted={show_done} batches={batch_index} "
f"elapsed={elapsed / 60:.1f}min"
)
if getattr(ctrl, 'log_path', None):
ctrl.log_plain(f"[Log] 已写入: {ctrl.log_path}")
except Exception:
pass
finally:
# 始终清理浏览器与 profiles(汇总应已在上面写完)
try:
_cleanup_browser_profiles_on_exit()
except Exception:
pass