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).
This commit is contained in:
daimon
2026-07-23 20:47:55 +08:00
commit d2db326c70
13 changed files with 5252 additions and 0 deletions
+942
View File
@@ -0,0 +1,942 @@
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