From d2db326c7016470323c40923ac70ecbc55c3eeea Mon Sep 17 00:00:00 2001 From: daimon Date: Thu, 23 Jul 2026 20:47:55 +0800 Subject: [PATCH] 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). --- .gitignore | 17 + LICENSE | 22 + README.md | 191 ++++ README.zh-CN.md | 190 ++++ README.zh-TW.md | 190 ++++ config.example.json | 36 + controllers/oauth2.py | 1329 +++++++++++++++++++++++++ controllers/outlook_controller.py | 1541 +++++++++++++++++++++++++++++ controllers/recovery_bind.py | 534 ++++++++++ controllers/temp_mail.py | 218 ++++ main.py | 942 ++++++++++++++++++ requirements.txt | 5 + utils.py | 37 + 13 files changed, 5252 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 README.zh-TW.md create mode 100644 config.example.json create mode 100644 controllers/oauth2.py create mode 100644 controllers/outlook_controller.py create mode 100644 controllers/recovery_bind.py create mode 100644 controllers/temp_mail.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f7902f --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +log/ +*.log +browser_profiles/ + +# Local secrets & runtime data (never commit) +config.json +config.local.json +Results/ +!Results/.gitkeep + +# Local tooling +_merge_from_monorepo.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9b678b --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 daimon3332 +Portions copyright (c) LainsNL/OutlookRegister contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b02f3d --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +

OutlookRegister

+ +

+ Automated Outlook / Hotmail registration and Microsoft Graph OAuth2 refresh_token collection (browser automation via patchright). +

+ +

+ English · + 简体中文 · + 繁體中文 +

+ +

+ Python 3.10+ + License MIT + patchright + Platform +

+ +> Community fork based on **[LainsNL/OutlookRegister](https://github.com/LainsNL/OutlookRegister)**. +> This fork focuses on registration + OAuth2 hardening: recovery-email binding (optional), multi-stage login challenges, cookie-first OAuth, batching, and interrupt-safe summaries. + +--- + +## Getting started + +### 1. Requirements + +- Python 3.10+ recommended +- A working HTTP/SOCKS proxy (strongly recommended) +- Optional: a temp-mail / CF Temp Mail compatible API if you enable recovery-email binding + +### 2. Install + +```bash +git clone +cd OutlookRegister +pip install -r requirements.txt +patchright install chromium +``` + +### 3. Configure + +```bash +# Windows +copy config.example.json config.json + +# Linux / macOS +cp config.example.json config.json +``` + +Edit `config.json`: set **proxy** at minimum. Leave secrets empty in any copy you publish. + +### 4. Run + +```bash +python main.py +``` + +Successful tokens are **appended** to `Results/oauth2.txt`: + +```text +email----password----client_id----refresh_token +``` + +Logs are written under `log/`. Press **Ctrl+C** to stop; the process writes a summary, then cleans browsers/profiles. + +--- + +## `config.json` field reference + +Ship template: `config.example.json` (same shape as the empty `config.json`). + +### Top-level + +| Field | Type | Description | +| --- | --- | --- | +| `email_suffix` | string | Mail domain suffix used when generating accounts, e.g. `@outlook.com` or `@hotmail.com`. | +| `headless` | bool | `false` = show browser windows; `true` = headless. | +| `bot_protection_wait` | number | Base pacing for form fill (seconds). Code multiplies by 1000 for delays. | +| `max_captcha_retries` | number | Extra press/retry rounds for the press-and-hold captcha (about `max_captcha_retries + 1` hold attempts). | +| `captcha_strategy` | number | Captcha / handoff mode (see table below). | +| `concurrent_flows` | number | Concurrent worker threads (parallel browsers). | +| `tasks` | number | Global cap on submitted tasks. Stops when this or `success_tasks` is reached (whichever first). | +| `success_tasks` | number \| null | Global success cap. `null` = no success cap (still limited by `tasks`). | +| `batch_success_limit` | number | Successes per batch before resetting in-process proxy weights / stats and starting the next batch. Cumulative success/time keep counting. Does **not** change a fixed proxy’s real exit IP. | +| `proxy` | object | Proxy pool (required for real use). | +| `oauth2` | object | Graph OAuth2 settings. | +| `temp_mail` | object | Optional recovery-email binding via temp-mail API. | + +### `captcha_strategy` + +| Value | Behavior | +| --- | --- | +| `0` | Fully automatic (captcha + mailbox + OAuth). | +| `1` | Semi-automatic: you press captcha; rest automatic. | +| `2` | Hand-off after captcha UI appears; no program OAuth for that task. | + +### `proxy` + +| Field | Type | Description | +| --- | --- | --- | +| `mode` | string | `single` = one port (`single_port`); `multiple` = port range `port_start`…`port_end`. | +| `type` | string | Proxy scheme, e.g. `http`, `socks5`. | +| `host` | string | Proxy host, e.g. `127.0.0.1`. **Fill this** before running. | +| `single_port` | number | Port when `mode` is `single`. | +| `port_start` | number | First port when `mode` is `multiple`. | +| `port_end` | number | Last port when `mode` is `multiple` (inclusive). | +| `max_per_proxy` | number | Max times a port may be selected before it is skipped (usage counters reset when all ports are exhausted or a batch resets). | + +### `oauth2` + +| Field | Type | Description | +| --- | --- | --- | +| `enable_oauth2` | bool | If `false`, registration success alone counts without fetching tokens. | +| `client_id` | string | Azure/public client id used for authorize + token exchange. | +| `redirect_url` | string | Redirect URI registered for the client (default `http://localhost`). | +| `Scopes` | string[] | OAuth scopes, typically `offline_access` + Graph default. | + +### `temp_mail` + +Used only if Microsoft shows **“Help us protect your account”** and binding is enabled. + +| Field | Type | Description | +| --- | --- | --- | +| `enabled` | bool | `false` = never auto-bind recovery mail (skip path when the page appears). | +| `base_url` | string | Temp-mail API base URL (your deployment). Leave empty if unused. | +| `admin_password` | string | Admin password for creating addresses. **Do not publish.** | +| `domain` | string | Mail domain for new addresses. | +| `name_prefix` | string | Optional local-part prefix. | +| `enable_prefix` | bool | Whether to apply `name_prefix`. | +| `code_timeout` | number | Seconds to wait for a verification code email. | +| `poll_interval` | number | Seconds between inbox polls. | + +--- + +## What it does + +```text +Generate email/password + -> open signup page + -> fill form + -> press-and-hold captcha + -> (optional) bind recovery email if Microsoft asks + -> enter Outlook mailbox + -> OAuth2 (cookie-first, then new browser with injected cookies) + -> append refresh_token to Results/oauth2.txt +``` + +OAuth handles common intermediate pages: personal/work account chooser, protect-account, email proof (or password path), KMSI “No”, consent, and code capture. + +--- + +## Key features + +- Concurrent multi-proxy registration +- Batch success limit with in-process weight reset +- Cookie-first OAuth + cold/new-browser fallback with `storage_state` when possible +- Optional recovery-email binding via configurable temp-mail API +- Ctrl+C: write interrupt summary, then close browsers and clear profiles +- Failure breakdown and progress logs under `log/` + +--- + +## Output and privacy + +Do **not** commit or share filled secrets. Gitignored / local-only examples: + +```text +config.json # your real proxy & temp_mail secrets +Results/oauth2.txt # accounts + refresh tokens +log/ # runtime logs +browser_profiles/ # temporary browser data +``` + +Use `config.example.json` as the documented empty template. + +--- + +## Upstream and acknowledgements + +- [LainsNL/OutlookRegister](https://github.com/LainsNL/OutlookRegister) — original project this fork is based on +- [Microsoft identity platform / Graph](https://learn.microsoft.com/en-us/graph/auth-v2-user) — OAuth2 and Graph scopes +- [patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) — browser automation + +--- + +## License + +Distributed under the [MIT License](./LICENSE). +Attribution retained for upstream OutlookRegister and MIT-licensed portions this project builds on. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..526ddb3 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,190 @@ +

OutlookRegister

+ +

+ Outlook / Hotmail 自动注册,并获取 Microsoft Graph OAuth2 refresh_token(基于 patchright 浏览器自动化)。 +

+ +

+ English · + 简体中文 · + 繁體中文 +

+ +

+ Python 3.10+ + License MIT + patchright + Platform +

+ +> 本项目基于 **[LainsNL/OutlookRegister](https://github.com/LainsNL/OutlookRegister)** 二开。 +> 增强点包括:可选辅助邮箱绑定、更完整的 OAuth 中间页处理、cookie 优先授权、分批调度,以及 Ctrl+C 中断后的汇总与清理。 + +--- + +## 使用教程 + +### 1. 环境要求 + +- 建议 Python 3.10+ +- 可用的 HTTP/SOCKS 代理(强烈建议) +- 若开启辅助邮箱绑定:兼容的临时邮箱 / CF Temp Mail 类 API + +### 2. 安装 + +```bash +git clone <本仓库地址> +cd OutlookRegister +pip install -r requirements.txt +patchright install chromium +``` + +### 3. 配置 + +```bash +# Windows +copy config.example.json config.json + +# Linux / macOS +cp config.example.json config.json +``` + +编辑 `config.json`:至少填好 **proxy**。对外分享时不要带上真实密钥。 + +### 4. 运行 + +```bash +python main.py +``` + +成功账号会**追加**写入 `Results/oauth2.txt`: + +```text +邮箱----密码----client_id----refresh_token +``` + +日志在 `log/`。按 **Ctrl+C** 可中断:先写汇总,再关闭浏览器并清理 profile。 + +--- + +## `config.json` 字段说明 + +模板见 `config.example.json`(与空的 `config.json` 结构一致)。 + +### 顶层字段 + +| 字段 | 类型 | 含义 | +| --- | --- | --- | +| `email_suffix` | string | 注册邮箱后缀,如 `@outlook.com` 或 `@hotmail.com`。 | +| `headless` | bool | `false` 显示浏览器窗口;`true` 无头。 | +| `bot_protection_wait` | number | 填表节奏基准(秒)。代码内会 ×1000 作为等待。 | +| `max_captcha_retries` | number | 验证码按压额外重试次数(约 `max_captcha_retries + 1` 轮 Hold)。 | +| `captcha_strategy` | number | 验证码/交接策略,见下表。 | +| `concurrent_flows` | number | 并发线程数(同时打开的浏览器任务数)。 | +| `tasks` | number | 全局提交任务上限;与 `success_tasks` **任一达标**即结束。 | +| `success_tasks` | number \| null | 全局成功上限。`null` = 不按成功数截断(仍受 `tasks` 限制)。 | +| `batch_success_limit` | number | 单批成功数上限;达到后重置程序内代理权重/统计并开下一批。累计成功/耗时保留。**不会**更换固定代理的真实出口 IP。 | +| `proxy` | object | 代理配置(正式使用必填)。 | +| `oauth2` | object | Graph OAuth2 配置。 | +| `temp_mail` | object | 可选:保护帐户页自动绑定辅助邮箱。 | + +### `captcha_strategy` + +| 值 | 行为 | +| --- | --- | +| `0` | 全自动(验证码 + 进邮箱 + OAuth)。 | +| `1` | 半自动:你手动过验证码,其余自动。 | +| `2` | 验证码界面出现后交给人工;该任务程序不跑 OAuth。 | + +### `proxy` + +| 字段 | 类型 | 含义 | +| --- | --- | --- | +| `mode` | string | `single` 使用 `single_port`;`multiple` 使用 `port_start`~`port_end` 端口池。 | +| `type` | string | 代理协议,如 `http`、`socks5`。 | +| `host` | string | 代理主机,如 `127.0.0.1`。**运行前请填写。** | +| `single_port` | number | `mode=single` 时的端口。 | +| `port_start` | number | `mode=multiple` 时起始端口。 | +| `port_end` | number | `mode=multiple` 时结束端口(含)。 | +| `max_per_proxy` | number | 单个端口在进程内最多被选中次数;用满后暂不选,全满或批次重置后计数清零。 | + +### `oauth2` + +| 字段 | 类型 | 含义 | +| --- | --- | --- | +| `enable_oauth2` | bool | `false` 时注册成功即可计成功,不拉 token。 | +| `client_id` | string | 授权与换 token 使用的客户端 ID。 | +| `redirect_url` | string | 重定向 URI(默认 `http://localhost`)。 | +| `Scopes` | string[] | 授权范围,一般为 `offline_access` + Graph 默认范围。 | + +### `temp_mail` + +仅在微软弹出 **「让我们来保护你的帐户」** 且开启绑定时使用。 + +| 字段 | 类型 | 含义 | +| --- | --- | --- | +| `enabled` | bool | `false` = 不自动绑定(出现保护页时走跳过等逻辑)。 | +| `base_url` | string | 临时邮箱 API 根地址。不用则留空。 | +| `admin_password` | string | 创建地址用的管理员密码。**勿公开。** | +| `domain` | string | 新建邮箱域名。 | +| `name_prefix` | string | 本地部分前缀(可选)。 | +| `enable_prefix` | bool | 是否启用 `name_prefix`。 | +| `code_timeout` | number | 等待验证码邮件的超时(秒)。 | +| `poll_interval` | number | 轮询收件箱间隔(秒)。 | + +--- + +## 项目用途 + +```text +生成邮箱/密码 + -> 打开注册页并填表 + -> 按压验证码 + ->(可选)绑定辅助邮箱 + -> 进入 Outlook 邮箱 + -> OAuth2(优先 cookie,失败再新浏览器并可注入 cookie) + -> 将 refresh_token 追加写入 Results/oauth2.txt +``` + +OAuth 可处理:个人/工作帐户选择、保护帐户、验证电子邮件、保持登录「否」、同意授权与 code 捕获等。 + +--- + +## 主要功能 + +- 多代理并发注册 +- 按批成功上限重置程序内权重 +- Cookie 优先 OAuth + 冷启动/新环境兜底 +- 可选 temp_mail 辅助邮箱绑定 +- Ctrl+C:先汇总再关浏览器、清 profile +- `log/` 进度与失败分类 + +--- + +## 输出与隐私 + +请勿提交或分享含密钥的文件。常见本地文件: + +```text +config.json # 真实代理与 temp_mail 密钥 +Results/oauth2.txt # 账号与 refresh_token +log/ # 运行日志 +browser_profiles/ # 临时浏览器数据 +``` + +文档与仓库请使用 `config.example.json` 空模板。 + +--- + +## 上游与致谢 + +- [LainsNL/OutlookRegister](https://github.com/LainsNL/OutlookRegister) — 本项目二开来源 +- [Microsoft identity platform / Graph](https://learn.microsoft.com/en-us/graph/auth-v2-user) — OAuth2 与 Graph +- [patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) — 浏览器自动化 + +--- + +## 开源协议 + +本项目采用 [MIT License](./LICENSE)。 +保留对上游 OutlookRegister 及所依赖 MIT 组件的署名说明。 diff --git a/README.zh-TW.md b/README.zh-TW.md new file mode 100644 index 0000000..d37f529 --- /dev/null +++ b/README.zh-TW.md @@ -0,0 +1,190 @@ +

OutlookRegister

+ +

+ Outlook / Hotmail 自動註冊,並取得 Microsoft Graph OAuth2 refresh_token(以 patchright 進行瀏覽器自動化)。 +

+ +

+ English · + 简体中文 · + 繁體中文 +

+ +

+ Python 3.10+ + License MIT + patchright + Platform +

+ +> 本專案基於 **[LainsNL/OutlookRegister](https://github.com/LainsNL/OutlookRegister)** 二開。 +> 強化重點:可選輔助信箱綁定、更完整的 OAuth 中間頁、cookie 優先授權、分批排程,以及 Ctrl+C 中斷後的彙總與清理。 + +--- + +## 使用教學 + +### 1. 環境需求 + +- 建議 Python 3.10+ +- 可用的 HTTP/SOCKS 代理(強烈建議) +- 若啟用輔助信箱綁定:相容的臨時信箱 / CF Temp Mail 類 API + +### 2. 安裝 + +```bash +git clone <本倉庫網址> +cd OutlookRegister +pip install -r requirements.txt +patchright install chromium +``` + +### 3. 設定 + +```bash +# Windows +copy config.example.json config.json + +# Linux / macOS +cp config.example.json config.json +``` + +編輯 `config.json`:至少填好 **proxy**。對外分享時請勿包含真實金鑰。 + +### 4. 執行 + +```bash +python main.py +``` + +成功帳號會**追加**寫入 `Results/oauth2.txt`: + +```text +信箱----密碼----client_id----refresh_token +``` + +日誌位於 `log/`。按 **Ctrl+C** 可中斷:先寫彙總,再關閉瀏覽器並清理 profile。 + +--- + +## `config.json` 欄位說明 + +範本見 `config.example.json`(與空白 `config.json` 結構相同)。 + +### 頂層欄位 + +| 欄位 | 類型 | 說明 | +| --- | --- | --- | +| `email_suffix` | string | 註冊信箱後綴,如 `@outlook.com` 或 `@hotmail.com`。 | +| `headless` | bool | `false` 顯示瀏覽器視窗;`true` 無頭。 | +| `bot_protection_wait` | number | 填表節奏基準(秒)。程式內會 ×1000 作為等待。 | +| `max_captcha_retries` | number | 驗證碼按壓額外重試次數(約 `max_captcha_retries + 1` 輪 Hold)。 | +| `captcha_strategy` | number | 驗證碼/交接策略,見下表。 | +| `concurrent_flows` | number | 並發執行緒數(同時開啟的瀏覽器任務數)。 | +| `tasks` | number | 全域提交任務上限;與 `success_tasks` **任一達標**即結束。 | +| `success_tasks` | number \| null | 全域成功上限。`null` = 不依成功數截斷(仍受 `tasks` 限制)。 | +| `batch_success_limit` | number | 單批成功上限;達標後重置程式內代理權重/統計並開下一批。累計成功/耗時保留。**不會**更換固定代理的真實出口 IP。 | +| `proxy` | object | 代理設定(正式使用必填)。 | +| `oauth2` | object | Graph OAuth2 設定。 | +| `temp_mail` | object | 可選:保護帳戶頁自動綁定輔助信箱。 | + +### `captcha_strategy` + +| 值 | 行為 | +| --- | --- | +| `0` | 全自動(驗證碼 + 進信箱 + OAuth)。 | +| `1` | 半自動:你手動過驗證碼,其餘自動。 | +| `2` | 驗證碼介面出現後交由人工;該任務程式不跑 OAuth。 | + +### `proxy` + +| 欄位 | 類型 | 說明 | +| --- | --- | --- | +| `mode` | string | `single` 使用 `single_port`;`multiple` 使用 `port_start`~`port_end` 連接埠池。 | +| `type` | string | 代理協定,如 `http`、`socks5`。 | +| `host` | string | 代理主機,如 `127.0.0.1`。**執行前請填寫。** | +| `single_port` | number | `mode=single` 時的連接埠。 | +| `port_start` | number | `mode=multiple` 時起始連接埠。 | +| `port_end` | number | `mode=multiple` 時結束連接埠(含)。 | +| `max_per_proxy` | number | 單一連接埠在行程內最多被選中次數;用滿後暫不選,全滿或批次重置後計數清零。 | + +### `oauth2` + +| 欄位 | 類型 | 說明 | +| --- | --- | --- | +| `enable_oauth2` | bool | `false` 時註冊成功即可計成功,不拉 token。 | +| `client_id` | string | 授權與換 token 使用的用戶端 ID。 | +| `redirect_url` | string | 重新導向 URI(預設 `http://localhost`)。 | +| `Scopes` | string[] | 授權範圍,一般為 `offline_access` + Graph 預設範圍。 | + +### `temp_mail` + +僅在 Microsoft 出現 **「讓我們來保護你的帳戶」** 且啟用綁定時使用。 + +| 欄位 | 類型 | 說明 | +| --- | --- | --- | +| `enabled` | bool | `false` = 不自動綁定(出現保護頁時走跳過等邏輯)。 | +| `base_url` | string | 臨時信箱 API 根位址。不用則留空。 | +| `admin_password` | string | 建立地址用的管理員密碼。**勿公開。** | +| `domain` | string | 新建信箱網域。 | +| `name_prefix` | string | 本地部分前綴(可選)。 | +| `enable_prefix` | bool | 是否啟用 `name_prefix`。 | +| `code_timeout` | number | 等待驗證碼郵件逾時(秒)。 | +| `poll_interval` | number | 輪詢收件匣間隔(秒)。 | + +--- + +## 專案用途 + +```text +產生信箱/密碼 + -> 開啟註冊頁並填表 + -> 按壓驗證碼 + ->(可選)綁定輔助信箱 + -> 進入 Outlook 信箱 + -> OAuth2(優先 cookie,失敗再新瀏覽器並可注入 cookie) + -> 將 refresh_token 追加寫入 Results/oauth2.txt +``` + +OAuth 可處理:個人/工作帳戶選擇、保護帳戶、驗證電子郵件、保持登入「否」、同意授權與 code 擷取等。 + +--- + +## 主要功能 + +- 多代理並發註冊 +- 依批成功上限重置程式內權重 +- Cookie 優先 OAuth + 冷啟動/新環境後援 +- 可選 temp_mail 輔助信箱綁定 +- Ctrl+C:先彙總再關瀏覽器、清 profile +- `log/` 進度與失敗分類 + +--- + +## 輸出與隱私 + +請勿提交或分享含金鑰的檔案。常見本機檔案: + +```text +config.json # 真實代理與 temp_mail 金鑰 +Results/oauth2.txt # 帳號與 refresh_token +log/ # 執行日誌 +browser_profiles/ # 暫存瀏覽器資料 +``` + +文件與倉庫請使用 `config.example.json` 空白範本。 + +--- + +## 上游與致謝 + +- [LainsNL/OutlookRegister](https://github.com/LainsNL/OutlookRegister) — 本專案二開來源 +- [Microsoft identity platform / Graph](https://learn.microsoft.com/en-us/graph/auth-v2-user) — OAuth2 與 Graph +- [patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) — 瀏覽器自動化 + +--- + +## 授權條款 + +本專案採用 [MIT License](./LICENSE)。 +保留對上游 OutlookRegister 及所依賴 MIT 元件之署名說明。 diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..8bc293b --- /dev/null +++ b/config.example.json @@ -0,0 +1,36 @@ +{ + "email_suffix": "@outlook.com", + "headless": false, + "bot_protection_wait": 15, + "max_captcha_retries": 3, + "captcha_strategy": 0, + "concurrent_flows": 1, + "tasks": 1, + "success_tasks": null, + "batch_success_limit": 300, + "proxy": { + "mode": "single", + "type": "http", + "host": "", + "single_port": 0, + "port_start": 0, + "port_end": 0, + "max_per_proxy": 20 + }, + "oauth2": { + "enable_oauth2": true, + "client_id": "9e5f94bc-e8a4-4e73-b8be-63364c29d753", + "redirect_url": "http://localhost", + "Scopes": ["offline_access", "https://graph.microsoft.com/.default"] + }, + "temp_mail": { + "enabled": false, + "base_url": "", + "admin_password": "", + "domain": "", + "name_prefix": "", + "enable_prefix": false, + "code_timeout": 120, + "poll_interval": 3 + } +} diff --git a/controllers/oauth2.py b/controllers/oauth2.py new file mode 100644 index 0000000..778910f --- /dev/null +++ b/controllers/oauth2.py @@ -0,0 +1,1329 @@ +import time +from urllib.parse import parse_qs, quote, urlparse + +import requests + +# === OAuth2 常量 === +CLIENT_ID = "9e5f94bc-e8a4-4e73-b8be-63364c29d753" +REDIRECT_URI = "https://localhost" +SCOPE = "https://graph.microsoft.com/.default offline_access" +AUTHORIZE_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + +CONSENT_SELECTOR = '[data-testid="appConsentPrimaryButton"]' +EMAIL_SELECTOR = "#i0116" +EMAIL_NEXT_SELECTOR = "#idSIButton9" +PRIMARY_SELECTOR = '[data-testid="primaryButton"],input[data-testid="primaryButton"],input[type="submit"]' +# 个人帐户 HRD 选择(勿点工作/学校帐户) +MSA_TILE_SELECTOR = "#msaTile" +MSA_TILE_TITLE_SELECTOR = "#msaTileTitle" +PASSWORD_BYPASS_TEXTS = [ + "使用密码", + "使用密码登录", + "Use password instead", + "Use your password", + "Sign in with a password", +] +PASSWORD_WRONG_TEXTS = [ + "此密码不是你的 Microsoft 帐户的正确密码", + "This password is incorrect", + "你的帐户或密码不正确", + "帐户或密码不正确", + "账户或密码不正确", + "Your account or password is incorrect", + "incorrect account or password", +] +PASSWORD_BLOCKED_TEXTS = [ + "密码登录不可用", + "请尝试其他方法", + "Password login is not available", + "Try another way", + "Try a different way", + "Sign-in method isn't available", +] +ACCOUNT_TYPE_HINT_TEXTS = [ + "哪种类型的帐户", + "哪种类型的账户", + "which type of account", + "Work or school account", + "工作或学校帐户", + "工作或学校账户", + "个人帐户", + "个人账户", + "Personal account", +] +AUTH_NAV_TIMEOUT_MS = 45000 +AUTH_ENTRY_TIMEOUT_MS = 45000 + + +def build_auth_url(prefer_sso=True): + """构造授权 URL。 + + prefer_sso=True(默认,COOKIE 路径): + - 不加 sso_reload,尽量用注册会话静默登录直接到 consent + prefer_sso=False(NEW 冷启动): + - 可加 prompt=login 强制账密(一般仍不建议;默认也不加) + """ + params = { + 'client_id': CLIENT_ID, + 'response_type': 'code', + 'redirect_uri': REDIRECT_URI, + 'scope': SCOPE, + } + # 历史问题:sso_reload=true 会强制打断 cookie SSO,COOKIE 路径几乎必掉 #i0116 + if not prefer_sso: + params['sso_reload'] = 'true' + return f"{AUTHORIZE_URL}?{'&'.join(f'{k}={quote(v)}' for k, v in params.items())}" + + +def _extract_code_from_url(url): + if 'localhost' not in url or 'code=' not in url: + return None + parsed = urlparse(url) + query_params = parse_qs(parsed.query) + return query_params.get('code', [None])[0] + + +def _wait_for_code_capture(page, captured_code, timeout_ms=180000, poll_ms=250): + if captured_code[0]: + return captured_code[0] + code = _extract_code_from_url(page.url) + if code: + captured_code[0] = code + return code + try: + js_url = page.evaluate('window.location.href') + code = _extract_code_from_url(js_url) + if code: + captured_code[0] = code + return code + except Exception: + pass + deadline = time.time() + timeout_ms / 1000 + while time.time() < deadline: + if captured_code[0]: + return captured_code[0] + code = _extract_code_from_url(page.url) + if code: + captured_code[0] = code + return code + try: + js_url = page.evaluate('window.location.href') + code = _extract_code_from_url(js_url) + if code: + captured_code[0] = code + return code + except Exception: + pass + page.wait_for_timeout(poll_ms) + return None + + +def _compact_exc(exc, max_len=180): + """压缩 Playwright 异常,去掉多行 Call log,保持单行日志。""" + text = str(exc) if exc is not None else "" + if not text: + return "" + # 只保留第一行语义(如 Locator.click: Timeout 5000ms exceeded.) + first = text.strip().splitlines()[0].strip() + # 去掉 Call log 及之后整段 + for marker in ("Call log:", "\nCall log"): + idx = text.find(marker) + if idx >= 0: + first = text[:idx].strip().splitlines()[0].strip() + break + if len(first) > max_len: + first = first[: max_len - 3] + "..." + return first + + +def _wait_for_auth_state_or_code(page, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, poll_ms=500, ignore_states=None): + ignore_states = set(ignore_states or ()) + deadline = time.time() + timeout_ms / 1000 + while time.time() < deadline: + if captured_code and _wait_for_code_capture(page, captured_code, timeout_ms=0): + return 'code' + state = _current_auth_entry_state(page) + if state != 'unknown' and state not in ignore_states: + return state + page.wait_for_timeout(poll_ms) + if captured_code and _wait_for_code_capture(page, captured_code, timeout_ms=0): + return 'code' + state = _current_auth_entry_state(page) + if state != 'unknown' and state not in ignore_states: + return state + return 'unknown' + + +def _locator_visible(locator): + try: + return locator.count() > 0 and locator.first.is_visible() + except Exception: + return False + + +def _text_exists(page, text): + try: + return page.get_by_text(text).count() > 0 + except Exception: + return False + + +def _password_input(page): + """密码框:中英 accessible name + 常见 id。""" + for name in ("密码", "Password", "password"): + try: + loc = page.get_by_role("textbox", name=name) + if _locator_visible(loc): + return loc + except Exception: + pass + for sel in ("#passwordEntry", "#i0118", 'input[type="password"]'): + loc = page.locator(sel) + if _locator_visible(loc): + return loc + return page.get_by_role("textbox", name="密码") + + +def _is_account_type_page(page): + """个人/工作帐户选择页(HRD splitter)。""" + if _locator_visible(page.locator(MSA_TILE_SELECTOR)): + return True + if _locator_visible(page.locator(MSA_TILE_TITLE_SELECTOR)): + return True + # 文案兜底:同时出现个人 + 工作/学校 更稳 + has_personal = ( + _text_exists(page, "个人帐户") + or _text_exists(page, "个人账户") + or _text_exists(page, "Personal account") + ) + has_work = ( + _text_exists(page, "工作或学校帐户") + or _text_exists(page, "工作或学校账户") + or _text_exists(page, "Work or school account") + ) + if has_personal and has_work: + return True + for t in ACCOUNT_TYPE_HINT_TEXTS: + if "哪种类型" in t or "which type" in t.lower(): + if _text_exists(page, t): + return True + return False + + +def _is_protect_account_page(page): + """「让我们来保护你的帐户」备用邮箱页。""" + try: + from controllers.recovery_bind import is_protect_account_page, is_ott_code_page + return is_protect_account_page(page) or is_ott_code_page(page) + except Exception: + if _locator_visible(page.locator("#EmailAddress")): + return True + if _locator_visible(page.locator("#iOttText")): + return True + return _text_exists(page, "保护你的帐户") or _text_exists(page, "保护您的帐户") + + +def _is_proof_verify_page(page): + """冷登录:验证已绑定辅助邮箱 / 6 格验证码(不含仅 KMSI)。""" + try: + from controllers.recovery_bind import is_proof_confirm_page, is_code_entry_page + return is_proof_confirm_page(page) or is_code_entry_page(page) + except Exception: + if _locator_visible(page.locator("#proof-confirmation-email-input")): + return True + if _locator_visible(page.locator("#codeEntry-0")): + return True + return _text_exists(page, "验证你的电子邮件") or _text_exists(page, "输入你的代码") + + +def _is_kmsi_only_page(page): + try: + from controllers.recovery_bind import is_kmsi_page, is_proof_confirm_page, is_code_entry_page + return is_kmsi_page(page) and not is_proof_confirm_page(page) and not is_code_entry_page(page) + except Exception: + return _text_exists(page, "保持登录") or _text_exists(page, "Stay signed in") + + +def _current_auth_entry_state(page): + """登录页状态机(可见 DOM 锚点,固定优先级)。 + + consent > account_type > protect_account > proof_verify > kmsi > login_email > login_password > unknown + """ + if _locator_visible(page.locator(CONSENT_SELECTOR)): + return 'consent' + if _is_account_type_page(page): + return 'account_type' + if _is_protect_account_page(page): + return 'protect_account' + if _is_proof_verify_page(page): + return 'proof_verify' + if _is_kmsi_only_page(page): + return 'kmsi' + if _locator_visible(page.locator(EMAIL_SELECTOR)): + return 'login_email' + if _locator_visible(_password_input(page)): + return 'login_password' + return 'unknown' + + +def _handle_kmsi(page, log): + """保持登录状态?→ 点「否」secondaryButton。""" + try: + from controllers.recovery_bind import is_kmsi_page, _click_kmsi_no + if is_kmsi_page(page): + if _click_kmsi_no(page, log=log): + log('kmsi', '已点保持登录「否」', 'OK') + else: + log('kmsi', '点击「否」失败', 'WARN') + page.wait_for_timeout(800) + except Exception as exc: + log('kmsi', f'处理异常: {_compact_exc(exc)}', 'WARN') + return _current_auth_entry_state(page) + + +def _handle_proof_verify(page, log, temp_mail_cfg=None, recovery_session=None, failure_hook=None): + """OAuth 冷登录:验证已绑定辅助邮箱(发码 → #codeEntry-0..5 自动提交 → KMSI 否)。""" + # 仅 KMSI 时不需要 jwt + if _is_kmsi_only_page(page): + return _handle_kmsi(page, log) + + log('proof_verify', '检测到「验证电子邮件/输入代码」页', 'WARN') + session = recovery_session + if not session or not session.get('address') or not session.get('jwt'): + log('proof_verify', '无注册阶段保存的辅助邮箱 jwt,无法接码', 'FAIL') + if failure_hook: + try: + failure_hook('recovery_bind_fail') + except Exception: + pass + return _current_auth_entry_state(page) + try: + from controllers.recovery_bind import verify_bound_email_on_login + ok = verify_bound_email_on_login( + page, session, temp_mail_cfg or {}, log=log, + ) + except Exception as exc: + log('proof_verify', f'验证异常: {_compact_exc(exc)}', 'FAIL') + ok = False + if ok: + log('proof_verify', '辅助邮箱验证流程完成', 'OK') + else: + if failure_hook: + try: + failure_hook('recovery_bind_fail') + except Exception: + pass + log('proof_verify', '辅助邮箱验证失败', 'FAIL') + page.wait_for_timeout(500) + return _current_auth_entry_state(page) + + +def _handle_protect_account(page, log, temp_mail_cfg=None, failure_hook=None, already_bound=False): + """OAuth 中的保护帐户页(兜底,非 100% 出现)。 + + 主路径应在「注册成功 → mail/0 前」完成绑定;此处仅当注册时跳过/失败后才常见。 + already_bound=True:注册阶段已绑成功,优先点跳过离开,避免重复绑定。 + """ + if already_bound: + log('protect_account', '注册阶段已绑定过,OAuth 侧优先离开此页', 'INFO') + try: + if _locator_visible(page.locator('#iShowSkip')): + page.locator('#iShowSkip').first.click(timeout=4000) + page.wait_for_timeout(800) + except Exception: + pass + return _current_auth_entry_state(page) + + log('protect_account', 'OAuth 出现保护帐户页(概率事件),尝试绑定', 'WARN') + cfg = temp_mail_cfg or {} + ok = False + if cfg.get('enabled', True): + try: + from controllers.recovery_bind import bind_recovery_email + result = bind_recovery_email(page, cfg, log=log) + if isinstance(result, tuple): + ok = bool(result[0]) + else: + ok = bool(result) + except Exception as exc: + log('protect_account', f'绑定异常: {exc}', 'FAIL') + ok = False + if ok: + log('protect_account', 'OAuth 阶段备用邮箱绑定成功', 'OK') + page.wait_for_timeout(800) + try: + if _locator_visible(page.locator('#iShowSkip')): + page.locator('#iShowSkip').first.click(timeout=2500) + log('protect_account', '绑定后仍有跳过链,已点击', 'INFO') + except Exception: + pass + else: + if failure_hook: + try: + failure_hook('recovery_bind_fail') + except Exception: + pass + try: + if _locator_visible(page.locator('#iShowSkip')): + page.locator('#iShowSkip').first.click(timeout=4000) + log('protect_account', 'OAuth 绑定失败,已 #iShowSkip', 'WARN') + page.wait_for_timeout(800) + except Exception as exc: + log('protect_account', f'跳过失败: {exc}', 'WARN') + page.wait_for_timeout(500) + st = _current_auth_entry_state(page) + if ok and st == 'protect_account': + try: + if not _locator_visible(page.locator('#EmailAddress')) and not _locator_visible(page.locator('#iOttText')): + return 'unknown' + except Exception: + pass + return st + + +def _dump_auth_page(page, log, stage='auth_dump'): + """失败时记录 URL + 正文摘要,便于对照截图。""" + try: + url = page.url or '' + except Exception: + url = '' + body = '' + try: + body = (page.locator('body').inner_text(timeout=800) or '')[:240].replace('\n', ' ') + except Exception: + body = '' + state = _current_auth_entry_state(page) + log(stage, f"state={state} url={url[:180]} body={body!r}", 'WARN') + return state + + +def _click_personal_account(page, log=None): + """点 HRD「个人帐户」#msaTile(禁止点工作/学校)。""" + clicked = False + # 1) 标准 msa tile + try: + tile = page.locator(MSA_TILE_SELECTOR) + if _locator_visible(tile): + tile.first.click(timeout=5000) + clicked = True + if log: + log('account_type', '已点击 #msaTile 个人帐户', 'OK') + except Exception as exc: + if log: + log('account_type', f'#msaTile 点击失败: {exc}', 'WARN') + + # 2) 标题区域 + if not clicked: + try: + title = page.locator(MSA_TILE_TITLE_SELECTOR) + if _locator_visible(title): + title.first.click(timeout=5000) + clicked = True + if log: + log('account_type', '已点击 #msaTileTitle', 'OK') + except Exception: + pass + + # 3) 文案 role=button / 文本 + if not clicked: + for text in ("个人帐户", "个人账户", "Personal account"): + try: + btn = page.get_by_role("button", name=text) + if _locator_visible(btn): + btn.first.click(timeout=5000) + clicked = True + if log: + log('account_type', f'已点击 button:{text}', 'OK') + break + except Exception: + pass + try: + loc = page.get_by_text(text, exact=False) + if _locator_visible(loc): + # 避免点到「重命名你的个人 Microsoft 帐户」链接:优先含 display 的 tile + loc.first.click(timeout=5000) + clicked = True + if log: + log('account_type', f'已点击 text:{text}', 'OK') + break + except Exception: + pass + + if clicked: + try: + page.wait_for_timeout(1200) + except Exception: + pass + elif log: + log('account_type', '未找到可点击的个人帐户入口', 'WARN') + return clicked + + +def _resolve_account_type(page, log, captured_code=None, max_rounds=3): + """若在帐户类型页,点击个人帐户并返回新状态。""" + state = _current_auth_entry_state(page) + for _ in range(max_rounds): + if state != 'account_type': + return state + log('account_type', '检测到个人/工作帐户选择页,点击个人帐户', 'WARN') + if not _click_personal_account(page, log): + _dump_auth_page(page, log, 'account_type_dump') + return 'account_type' + try: + _settle_auth_page(page, log, 'account_type') + except Exception: + page.wait_for_timeout(800) + state = _wait_for_auth_state_or_code( + page, + captured_code, + timeout_ms=15000, + ignore_states=set(), + ) + # 点完仍可能短暂 unknown + if state == 'unknown': + state = _current_auth_entry_state(page) + return state + + +def _wait_for_auth_entry_state(page, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, poll_ms=500, ignore_states=None): + return _wait_for_auth_state_or_code(page, None, timeout_ms=timeout_ms, poll_ms=poll_ms, ignore_states=ignore_states) + + +def _settle_auth_page(page, log, stage, timeout_ms=AUTH_NAV_TIMEOUT_MS): + try: + page.wait_for_load_state('domcontentloaded', timeout=timeout_ms) + except Exception as e: + log(stage, f'等待 domcontentloaded 超时: {e}', 'WARN') + try: + page.wait_for_load_state('load', timeout=timeout_ms) + except Exception as e: + log(stage, f'等待 load 超时,继续检测入口: {e}', 'WARN') + page.wait_for_timeout(1200) + + +def _disable_auth_page_autofill(page, log=None): + try: + page.evaluate( + """() => { + document.querySelectorAll('input').forEach((el) => { + try { + el.setAttribute('autocomplete', 'off'); + el.setAttribute('autocapitalize', 'off'); + el.setAttribute('autocorrect', 'off'); + el.setAttribute('spellcheck', 'false'); + el.setAttribute('data-lpignore', 'true'); + } catch (e) {} + }); + }""" + ) + if log: + log('autofill', '已尝试关闭页面输入框自动填充提示', 'INFO') + except Exception as exc: + if log: + log('autofill', f'关闭页面自动填充提示失败: {exc}', 'WARN') + + +def _submit_email_fill(page, full_email): + _disable_auth_page_autofill(page) + locator = page.locator(EMAIL_SELECTOR).first + locator.click(timeout=5000) + page.keyboard.press("Escape") + page.wait_for_timeout(150) + locator.fill("") + page.wait_for_timeout(100) + locator.fill(full_email, timeout=5000) + page.wait_for_timeout(300) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.locator(EMAIL_NEXT_SELECTOR).click(timeout=5000) + + +def _submit_email_type(page, full_email): + _disable_auth_page_autofill(page) + locator = page.locator(EMAIL_SELECTOR).first + locator.click(timeout=5000) + page.keyboard.press("Control+A") + page.keyboard.press("Backspace") + page.wait_for_timeout(100) + locator.type(full_email, delay=35, timeout=10000) + page.wait_for_timeout(250) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.locator(EMAIL_NEXT_SELECTOR).click(timeout=5000) + + +def _submit_email_js_exact(page, full_email): + page.wait_for_selector(EMAIL_SELECTOR, state="visible", timeout=10000) + _disable_auth_page_autofill(page) + page.eval_on_selector( + EMAIL_SELECTOR, + """(el, value) => { + el.focus(); + el.setAttribute('autocomplete', 'off'); + const nativeInputValueSetter = + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + nativeInputValueSetter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); + }""", + full_email, + ) + page.wait_for_timeout(500) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.locator(EMAIL_NEXT_SELECTOR).click(timeout=5000) + + +def _submit_email(page, full_email, log): + page.wait_for_selector(EMAIL_SELECTOR, state="visible", timeout=10000) + methods = [ + ("fill", _submit_email_fill), + ("type", _submit_email_type), + ("js_exact", _submit_email_js_exact), + ("js_exact_retry", _submit_email_js_exact), + ] + success_states = ('login_password', 'consent', 'code', 'account_type', 'protect_account') + last_error = None + last_stage = 'unknown' + for name, method in methods: + try: + # 提交过程中可能已跳到帐户类型/密码/保护帐户 + cur = _current_auth_entry_state(page) + if cur in success_states: + if cur == 'account_type': + cur = _resolve_account_type(page, log) + log('oauth_email', f"提交前已在阶段={cur}", 'OK') + return cur + if page.locator(EMAIL_SELECTOR).count() == 0: + cur = _current_auth_entry_state(page) + if cur == 'account_type': + cur = _resolve_account_type(page, log) + return cur + current = page.eval_on_selector(EMAIL_SELECTOR, "(el) => (el.value || '').trim()") + log('oauth_email', f"尝试 {name},提交前值={current!r}", 'INFO') + method(page, full_email) + stage = _wait_for_auth_entry_state(page, timeout_ms=12000) + last_stage = stage + if stage == 'account_type': + stage = _resolve_account_type(page, log) + last_stage = stage + if stage in ('login_password', 'consent', 'code', 'protect_account'): + log('oauth_email', f"{name} 成功进入阶段={stage}", 'OK') + return stage + still_here = page.locator(EMAIL_SELECTOR).count() > 0 and page.locator(EMAIL_SELECTOR).first.is_visible() + err = "" + if still_here: + err = page.eval_on_selector("#usernameError", "(el) => (el.innerText || '').trim()") if page.locator("#usernameError").count() > 0 else "" + log('oauth_email', f"{name} 后仍未进入下一阶段 stage={stage} error={err!r}", 'WARN') + except Exception as exc: + last_error = exc + brief = _compact_exc(exc) + log('oauth_email', f"{name} 失败: {brief}", 'WARN') + # type 时常见:Next 点击超时但页面已导航到密码/帐户类型/同意页 + stage = _current_auth_entry_state(page) + if stage == 'account_type': + stage = _resolve_account_type(page, log) + last_stage = stage + if stage in ('login_password', 'consent', 'code', 'protect_account'): + log('oauth_email', f"{name} 异常后已在阶段={stage}", 'OK') + return stage + if last_stage in ('login_password', 'consent', 'code', 'account_type', 'protect_account'): + if last_stage == 'account_type': + last_stage = _resolve_account_type(page, log) + return last_stage + if last_error: + raise RuntimeError(f"邮箱提交失败: {_compact_exc(last_error)}") + raise RuntimeError("邮箱提交后未进入密码页") + + +def _click_use_password(page): + for text in PASSWORD_BYPASS_TEXTS: + try: + btn = page.get_by_role("button", name=text) + if btn.count() > 0 and btn.first.is_visible(): + btn.first.click(timeout=5000) + page.wait_for_timeout(1500) + return + except Exception: + pass + try: + btn = page.get_by_text(text) + if btn.count() > 0 and btn.first.is_visible(): + btn.first.click(timeout=5000) + page.wait_for_timeout(1500) + return + except Exception: + pass + + +def _describe_password_candidates(page): + parts = [] + for selector in ('#passwordEntry', '#i0118', 'input[type="password"]'): + try: + locator = page.locator(selector) + count = locator.count() + rows = [] + for idx in range(count): + item = locator.nth(idx) + try: + visible = item.is_visible() + except Exception as exc: + visible = f"err:{exc.__class__.__name__}" + try: + meta = item.evaluate( + """(el) => ({ + id: el.id || '', + name: el.name || '', + type: el.getAttribute('type') || '', + tabindex: el.getAttribute('tabindex') || '', + ariaHidden: el.getAttribute('aria-hidden') || '', + readonly: el.hasAttribute('readonly'), + disabled: !!el.disabled + })""" + ) + except Exception: + meta = {} + rows.append(f"{idx}:visible={visible},meta={meta}") + parts.append(f"{selector} count={count} [{' ; '.join(rows)}]") + except Exception: + parts.append(f"{selector} error") + return " | ".join(parts) + + +def _password_locator(page, log, timeout_ms=15000): + deadline = time.time() + timeout_ms / 1000 + last_snapshot = "" + while time.time() < deadline: + _click_use_password(page) + for selector in ('#passwordEntry', '#i0118', 'input[type="password"]'): + try: + locator = page.locator(selector) + count = locator.count() + except Exception: + continue + for idx in range(count): + item = locator.nth(idx) + try: + if item.is_visible(): + log('oauth_password', f"使用密码框 {selector}[{idx}]", 'INFO') + return item, f"{selector}[{idx}]" + except Exception: + continue + last_snapshot = _describe_password_candidates(page) + page.wait_for_timeout(300) + raise RuntimeError(f"未找到可见密码框:{last_snapshot}") + + +def _submit_password(page, password, log): + _click_use_password(page) + _disable_auth_page_autofill(page) + log('oauth_password', f"密码候选快照:{_describe_password_candidates(page)}", 'INFO') + locator, locator_name = _password_locator(page, log=log, timeout_ms=15000) + locator.evaluate( + """(el, value) => { + el.focus(); + el.removeAttribute('readonly'); + el.removeAttribute('aria-hidden'); + el.style.opacity = '1'; + el.style.pointerEvents = 'auto'; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }""", + password, + ) + page.wait_for_timeout(200) + try: + filled_len = locator.evaluate("(el) => (el.value || '').length") + log('oauth_password', f"{locator_name} 已写入密码,长度={filled_len}", 'INFO') + except Exception: + log('oauth_password', f"{locator_name} 已写入密码", 'INFO') + page.wait_for_timeout(400) + try: + page.get_by_test_id("primaryButton").click(timeout=5000) + log('oauth_password', "点击 data-testid=primaryButton 提交密码", 'INFO') + except Exception: + page.keyboard.press("Enter") + log('oauth_password', "主按钮点击失败,改用 Enter 提交密码", 'WARN') + + +def _has_invalid_password(page): + for t in PASSWORD_WRONG_TEXTS: + if _text_exists(page, t): + return True + return False + + +def _has_password_login_blocked(page): + for t in PASSWORD_BLOCKED_TEXTS: + if _text_exists(page, t): + return True + return False + + +def _has_unknown_account(page): + return ( + _text_exists(page, '找不到使用该用户名的帐户') + or _text_exists(page, '找不到使用该用户名的账户') + or _text_exists(page, "We couldn't find an account with that username") + or _text_exists(page, "That Microsoft account doesn't exist") + or _locator_visible(page.locator('#usernameError')) + ) + + +def _dismiss_passkey_setup(page, log=None): + """密码后可能跳到「正在设置密钥」/ fido create,尝试取消回到同意流。""" + try: + url = page.url or '' + except Exception: + url = '' + body_hint = False + try: + body_hint = ( + _text_exists(page, '正在设置密钥') + or _text_exists(page, '安全窗口') + or _text_exists(page, 'passkey') + or _text_exists(page, '通行密钥') + or 'fido/create' in url + ) + except Exception: + pass + if not body_hint and 'fido' not in url: + return False + if log: + log('passkey', f'检测到密钥设置页 url={url[:120]}', 'WARN') + for text in ('取消', 'Cancel', '以后再说', 'Not now', '暂时跳过', 'Skip'): + try: + if _locator_visible(page.get_by_role('button', name=text)): + page.get_by_role('button', name=text).first.click(timeout=3000) + page.wait_for_timeout(1000) + if log: + log('passkey', f'已点击 {text}', 'OK') + return True + except Exception: + pass + try: + loc = page.locator(f'input[type="button"][value="{text}"]') + if _locator_visible(loc): + loc.first.click(timeout=3000) + page.wait_for_timeout(1000) + if log: + log('passkey', f'已点击 input {text}', 'OK') + return True + except Exception: + pass + # 最后:若仍在 fido 页,直接跳回我们的 authorize(依赖 cookie) + try: + page.goto(build_auth_url(prefer_sso=True), timeout=AUTH_NAV_TIMEOUT_MS, wait_until='domcontentloaded') + page.wait_for_timeout(1200) + if log: + log('passkey', '密钥页无法取消,已回跳 authorize', 'WARN') + return True + except Exception: + return False + + +def _run_cookie_recovery(page, auth_url, log, entry_timeout_ms=AUTH_ENTRY_TIMEOUT_MS): + last_state = 'unknown' + for method_name, action in [ + ('reload', lambda: page.reload(wait_until="domcontentloaded", timeout=AUTH_NAV_TIMEOUT_MS)), + ('location.reload', lambda: page.evaluate("() => location.reload()")), + ('goto', lambda: page.goto(auth_url, timeout=AUTH_NAV_TIMEOUT_MS, wait_until="domcontentloaded")), + ]: + log('cookie_recovery', f'执行 {method_name}', 'WARN') + try: + if method_name == 'location.reload': + with page.expect_navigation(wait_until="domcontentloaded", timeout=AUTH_NAV_TIMEOUT_MS): + action() + else: + action() + except Exception as e: + log('cookie_recovery', f'{method_name} 失败: {e}', 'WARN') + continue + _settle_auth_page(page, log, 'cookie_recovery') + _disable_auth_page_autofill(page, log) + state = _wait_for_auth_entry_state(page, timeout_ms=entry_timeout_ms) + if state == 'account_type': + state = _resolve_account_type(page, log) + last_state = state + log('cookie_recovery', f'{method_name} 后状态={state}', 'INFO') + if state in ('consent', 'login_password', 'code'): + return state + if state == 'login_email': + continue + if state == 'account_type': + # 已尝试点击个人帐户仍停在选择页 + continue + if method_name == 'goto': + return state + return 'login_email' if last_state == 'login_email' else last_state + + +def _digest_post_email_states( + page, log, state, captured_code=None, temp_mail_cfg=None, + recovery_already_bound=False, recovery_session=None, failure_hook=None, rounds=4, +): + """邮箱提交后可能出现的中间页:帐户类型 / 绑定保护 / 验证辅助邮箱 / 密钥 / KMSI。""" + for _ in range(rounds): + if state == 'account_type': + state = _resolve_account_type(page, log, captured_code=captured_code) + log('account_type', f'处理后状态={state}', 'INFO') + continue + 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('protect_account', f'处理后状态={state}', 'INFO') + continue + 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('proof_verify', f'处理后状态={state}', 'INFO') + continue + if state == 'kmsi': + state = _handle_kmsi(page, log) + log('kmsi', f'处理后状态={state}', 'INFO') + continue + if state == 'unknown': + if _dismiss_passkey_setup(page, log): + state = _wait_for_auth_state_or_code(page, captured_code, timeout_ms=12000) + continue + # KMSI 可能落在 unknown + try: + from controllers.recovery_bind import is_kmsi_page, _click_kmsi_no + if is_kmsi_page(page): + _click_kmsi_no(page, log=log) + state = _wait_for_auth_state_or_code(page, captured_code, timeout_ms=8000) + continue + except Exception: + pass + break + return state + + +def _perform_login_after_cookie_fail( + page, full_email, password, log, failure_hook=None, state='login_email', + captured_code=None, temp_mail_cfg=None, recovery_already_bound=False, recovery_session=None, +): + state = _digest_post_email_states( + page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg, + recovery_already_bound=recovery_already_bound, recovery_session=recovery_session, + failure_hook=failure_hook, rounds=4, + ) + + if state == 'login_email': + log('login_email', '开始输入邮箱', 'WARN') + try: + email_stage = _submit_email(page, full_email, log) + except Exception as exc: + log('login_email', f'邮箱提交异常: {_compact_exc(exc)}', 'WARN') + email_stage = _current_auth_entry_state(page) + if email_stage in ( + 'login_password', 'consent', 'code', 'account_type', + 'protect_account', 'proof_verify', 'kmsi', + ): + state = email_stage + else: + state = _wait_for_auth_state_or_code( + page, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, ignore_states={'login_email'} + ) + state = _digest_post_email_states( + page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg, + recovery_already_bound=recovery_already_bound, recovery_session=recovery_session, + failure_hook=failure_hook, rounds=4, + ) + log('login_email', f'邮箱提交后状态={state}', 'INFO') + if _has_unknown_account(page): + _dump_auth_page(page, log) + log('login_email', '邮箱不存在', 'FAIL') + return False + if state == 'login_email': + if failure_hook: + failure_hook('oauth_login_timeout') + _dump_auth_page(page, log) + log('login_email', '邮箱页停留超时', 'FAIL') + return False + + state = _digest_post_email_states( + page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg, + recovery_already_bound=recovery_already_bound, recovery_session=recovery_session, + failure_hook=failure_hook, rounds=3, + ) + + if state == 'login_password': + # 冷登录验证辅助邮箱后,有时不必再输密码;若出现密码页再填 + if _has_password_login_blocked(page): + if failure_hook: + failure_hook('oauth_password_blocked') + _dump_auth_page(page, log) + log('login_password', '密码登录不可用,跳过硬填', 'FAIL') + return False + log('login_password', '开始输入密码', 'WARN') + _submit_password(page, password, log) + if _has_password_login_blocked(page): + if failure_hook: + failure_hook('oauth_password_blocked') + _dump_auth_page(page, log) + log('login_password', '检测到密码登录不可用', 'FAIL') + return False + if _has_invalid_password(page): + if failure_hook: + failure_hook('oauth_password_wrong') + _dump_auth_page(page, log) + log('login_password', '检测到密码错误提示', 'FAIL') + return False + state = _wait_for_auth_state_or_code( + page, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, ignore_states={'login_password'} + ) + state = _digest_post_email_states( + page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg, + recovery_already_bound=recovery_already_bound, recovery_session=recovery_session, + failure_hook=failure_hook, rounds=4, + ) + log('login_password', f'密码提交后状态={state}', 'INFO') + if state == 'code': + return True + if state != 'consent': + if _has_password_login_blocked(page): + if failure_hook: + failure_hook('oauth_password_blocked') + _dump_auth_page(page, log) + log('login_password', '密码提交后:密码登录不可用', 'FAIL') + elif _has_invalid_password(page): + if failure_hook: + failure_hook('oauth_password_wrong') + _dump_auth_page(page, log) + log('login_password', '检测到密码错误提示', 'FAIL') + else: + if failure_hook: + failure_hook('oauth_consent_fail') + _dump_auth_page(page, log) + log('login_password', f'未进入同意页面 final_state={state}', 'FAIL') + return False + + # 冷登录常见:邮箱 → proof(codeEntry 自动验证) → kmsi 否 → consent(可能无密码页) + if state in ('proof_verify', 'kmsi'): + state = _digest_post_email_states( + page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg, + recovery_already_bound=recovery_already_bound, recovery_session=recovery_session, + failure_hook=failure_hook, rounds=4, + ) + log('proof_verify', f'proof/kmsi 处理后状态={state}', 'INFO') + if state == 'login_password': + # 验证后若仍要密码,再走一轮 + if not _has_password_login_blocked(page): + log('login_password', 'proof 后出现密码页,继续填写', 'WARN') + _submit_password(page, password, log) + state = _wait_for_auth_state_or_code( + page, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, ignore_states={'login_password'} + ) + state = _digest_post_email_states( + page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg, + recovery_already_bound=recovery_already_bound, recovery_session=recovery_session, + failure_hook=failure_hook, rounds=3, + ) + if state == 'code': + return True + if state == 'consent': + return True + if state not in ('consent', 'code'): + if failure_hook: + failure_hook('oauth_consent_fail') + _dump_auth_page(page, log) + log('proof_verify', f'验证后未进入同意页 final_state={state}', 'FAIL') + return False + + return state in ('consent', 'code') + + +def _exchange_code_once(code, proxy_url=None, timeout_sec=20): + proxies = None + if proxy_url: + proxies = {"http": proxy_url, "https": proxy_url} + response = requests.post( + TOKEN_URL, + data={ + 'client_id': CLIENT_ID, + 'code': code, + 'redirect_uri': REDIRECT_URI, + 'grant_type': 'authorization_code', + 'scope': SCOPE, + }, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + timeout=timeout_sec, + proxies=proxies, + ) + response.raise_for_status() + return response.json() + + +def _exchange_code_with_retry(code, log, failure_hook=None, current_proxy="", token_proxy_getter=None): + proxy_candidates = [] + saw_network_error = False + for item in (current_proxy,): + if item and item not in proxy_candidates: + proxy_candidates.append(item) + if token_proxy_getter: + for _ in range(2): + try: + picked = token_proxy_getter(exclude=proxy_candidates[-1] if proxy_candidates else current_proxy) + except TypeError: + picked = token_proxy_getter() + except Exception as exc: + log('token', f'获取新代理失败: {exc}', 'WARN') + picked = "" + if picked and picked not in proxy_candidates: + proxy_candidates.append(picked) + proxy_candidates.append("") + total_attempts = len(proxy_candidates) + last_error = None + for idx in range(total_attempts): + proxy_url = proxy_candidates[idx] + proxy_text = proxy_url or "direct" + try: + log('token', f'开始换 token 第 {idx + 1}/{total_attempts} 次 proxy={proxy_text}', 'INFO') + data = _exchange_code_once(code, proxy_url=proxy_url or None, timeout_sec=20) + if 'refresh_token' not in data: + last_error = RuntimeError(data.get('error_description') or data.get('error') or 'unknown') + log('token', f"token请求失败 proxy={proxy_text}: {data.get('error', 'unknown')}", 'WARN') + if idx < total_attempts - 1: + time.sleep(1.5 + idx) + continue + break + return True, data['refresh_token'] + except (requests.exceptions.SSLError, requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc: + last_error = exc + saw_network_error = True + log('token', f'网络异常 proxy={proxy_text}: {exc}', 'WARN') + if idx < total_attempts - 1: + time.sleep(1.5 + idx) + continue + except Exception as exc: + last_error = exc + log('token', f'换 token 异常 proxy={proxy_text}: {exc}', 'WARN') + if idx < total_attempts - 1: + time.sleep(1.5 + idx) + continue + if saw_network_error and failure_hook: + failure_hook('oauth_token_network_fail') + if failure_hook: + failure_hook('oauth_token_fail') + log('token', f'最终换 token 失败: {last_error}', 'FAIL') + return False, None + + +def _click_consent_and_exchange(page, captured_code, log, failure_hook=None, current_proxy="", token_proxy_getter=None): + accept_btn = page.locator(CONSENT_SELECTOR) + accept_btn.wait_for(state='visible', timeout=60000) + accept_btn.click(timeout=10000) + log('consent', '点击接受授权', 'OK') + + code = _wait_for_code_capture(page, captured_code, timeout_ms=180000) + if not code: + if failure_hook: + failure_hook('oauth_code_fail') + log('callback', '3分钟内未捕获到code', 'FAIL') + return False, None + + log('callback', '捕获到code', 'OK') + return _exchange_code_with_retry( + code, + log=log, + failure_hook=failure_hook, + current_proxy=current_proxy, + token_proxy_getter=token_proxy_getter, + ) + + +def _exchange_captured_code(page, captured_code, log, failure_hook=None, current_proxy="", token_proxy_getter=None): + code = _wait_for_code_capture(page, captured_code, timeout_ms=1000, poll_ms=100) + if not code: + return False, None + log('callback', '已直接捕获到code,跳过同意页', 'OK') + return _exchange_code_with_retry( + code, + log=log, + failure_hook=failure_hook, + current_proxy=current_proxy, + token_proxy_getter=token_proxy_getter, + ) + + +def get_oauth2_token(page, full_email, password, results_dir=None, prefix='', backup_proxy=None, failure_hook=None, log_hook=None, current_proxy="", token_proxy_getter=None, temp_mail_cfg=None, recovery_already_bound=False, recovery_session=None): + # 同 context 必须 prefer_sso:不要 sso_reload,否则 cookie 会话被强制打断 + auth_url = build_auth_url(prefer_sso=True) + + def _log(stage, message, level='INFO'): + if log_hook: + log_hook(stage, message, level) + return + tag = prefix if prefix else "[OAuth2:COOKIE]" + print(f"{tag}[{level}] {time.strftime('%H:%M:%S')} | {stage} | {message}") + + def _try_flow(): + _log('start', '开始 OAuth2 (同浏览器 context 复用 cookie,无 sso_reload)') + # 同一 BrowserContext 新开 tab,共享注册后的 login.live.com cookie + pg = page.context.new_page() + captured_code = [None] + + 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 + + pg.on('request', on_request) + pg.on('framenavigated', on_frame_navigated) + + try: + t1 = time.time() + pg.goto(auth_url, timeout=AUTH_NAV_TIMEOUT_MS, wait_until="domcontentloaded") + _settle_auth_page(pg, _log, 'goto') + _disable_auth_page_autofill(pg, _log) + _log('goto', f"进入auth页面 (+{time.time()-t1:.0f}s)") + + # SSO 有时慢,多给一点时间再判状态 + state = _wait_for_auth_state_or_code(pg, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS) + _log('entry', f'首次检测状态={state}') + + if state == 'account_type': + state = _resolve_account_type(pg, _log, captured_code=captured_code) + _log('entry', f'帐户类型处理后状态={state}') + if state == 'protect_account': + state = _handle_protect_account( + pg, _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( + pg, _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(pg, _log) + _log('entry', f'kmsi 处理后状态={state}') + + # 策略:有 cookie 的环境下,login_email 时**不要**连环 reload(会冲掉 SSO)。 + if state == 'login_email': + _log('entry', '仍需邮箱:跳过连环 recovery,同页直接补登(保留 cookie)', 'WARN') + elif state == 'unknown': + _dump_auth_page(pg, _log, 'entry_unknown') + if _is_account_type_page(pg): + state = _resolve_account_type(pg, _log, captured_code=captured_code) + elif _is_protect_account_page(pg): + state = _handle_protect_account( + pg, _log, temp_mail_cfg=temp_mail_cfg, failure_hook=failure_hook, + already_bound=recovery_already_bound, + ) + elif _is_proof_verify_page(pg): + state = _handle_proof_verify( + pg, _log, temp_mail_cfg=temp_mail_cfg, + recovery_session=recovery_session, failure_hook=failure_hook, + ) + elif _is_kmsi_only_page(pg): + state = _handle_kmsi(pg, _log) + else: + _log('entry', 'unknown:单次 goto 重试 authorize', 'WARN') + try: + pg.goto(auth_url, timeout=AUTH_NAV_TIMEOUT_MS, wait_until="domcontentloaded") + _settle_auth_page(pg, _log, 'goto_retry') + state = _wait_for_auth_state_or_code(pg, captured_code, timeout_ms=15000) + except Exception as exc: + _log('entry', f'goto 重试失败: {_compact_exc(exc)}', 'WARN') + _log('entry', f'处理后状态={state}') + + if state == 'account_type': + state = _resolve_account_type(pg, _log, captured_code=captured_code) + if state == 'protect_account': + state = _handle_protect_account( + pg, _log, temp_mail_cfg=temp_mail_cfg, failure_hook=failure_hook, + already_bound=recovery_already_bound, + ) + if state == 'proof_verify': + state = _handle_proof_verify( + pg, _log, temp_mail_cfg=temp_mail_cfg, + recovery_session=recovery_session, failure_hook=failure_hook, + ) + if state == 'kmsi': + state = _handle_kmsi(pg, _log) + + if state in ('login_email', 'login_password', 'account_type', 'protect_account', 'proof_verify', 'kmsi'): + ok = _perform_login_after_cookie_fail( + pg, + full_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 + state = _wait_for_auth_state_or_code(pg, captured_code, timeout_ms=8000) + state = _digest_post_email_states( + pg, _log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg, + recovery_already_bound=recovery_already_bound, recovery_session=recovery_session, + failure_hook=failure_hook, rounds=3, + ) + _log('entry', f'登录后阶段={state}', 'INFO') + + if state == 'code': + return _exchange_captured_code( + pg, + captured_code, + _log, + failure_hook=failure_hook, + current_proxy=current_proxy, + token_proxy_getter=token_proxy_getter, + ) + if state == 'consent': + return _click_consent_and_exchange( + pg, + captured_code, + _log, + failure_hook=failure_hook, + current_proxy=current_proxy, + token_proxy_getter=token_proxy_getter, + ) + + if failure_hook: + failure_hook('oauth_consent_fail') + _dump_auth_page(pg, _log) + _log('entry', f'未进入同意或登录页面,最终状态={state}', 'FAIL') + return False, None + except Exception as e: + _log('exception', f'异常: {_compact_exc(e)}', 'FAIL') + return False, None + finally: + try: + pg.remove_listener('request', on_request) + pg.remove_listener('framenavigated', on_frame_navigated) + except Exception: + pass + try: + pg.close() + except Exception: + pass + + try: + success, token = _try_flow() + if success: + return True, token + except Exception as e: + _log('outer', f'首次尝试异常: {_compact_exc(e)}', 'FAIL') + return False, None diff --git a/controllers/outlook_controller.py b/controllers/outlook_controller.py new file mode 100644 index 0000000..4b64729 --- /dev/null +++ b/controllers/outlook_controller.py @@ -0,0 +1,1541 @@ +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): + """惩罚当前IP(OAuth2全部失败、账号不存在等严重错误时调用)。增加失败计数,影响后续代理选择权重。""" + 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 自带 Chromium(A/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 context:browser 实际是 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', + '注册阶段绑定失败,已 #iShowSkip;OAuth 仍可能再要求绑定', + ) + 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未消失 → 失败 diff --git a/controllers/recovery_bind.py b/controllers/recovery_bind.py new file mode 100644 index 0000000..3623af9 --- /dev/null +++ b/controllers/recovery_bind.py @@ -0,0 +1,534 @@ +"""微软辅助邮箱相关页面。 + +A) 注册后绑定:「让我们来保护你的帐户」 + #EmailAddress → #iNext → #iOttText → #iNext + (与 manage-webui abuse_recovery 一致) + +B) OAuth 冷登录验证已绑定邮箱(Fluent 新 UI) + 1. 验证你的电子邮件 + #proof-confirmation-email-input + button[data-testid=primaryButton]「发送验证码」 + 2. 输入你的代码(6 格,无提交按钮,填完自动验证) + #codeEntry-0 … #codeEntry-5 + 3. 保持登录状态? + button[data-testid=secondaryButton]「否」 +""" +import time + +from controllers.temp_mail import client_from_config + +# --- 绑定页 --- +BACKUP_EMAIL_SELECTOR = "#EmailAddress" +VERIFY_CODE_SELECTOR = "#iOttText" +NEXT_SELECTOR = "#iNext" + +# --- 冷登录:确认辅助邮箱并发码 --- +PROOF_EMAIL_INPUT = "#proof-confirmation-email-input" +PROOF_EMAIL_INPUT_BY_LABEL = 'label[for="proof-confirmation-email-input"]' + +# --- 冷登录:6 格验证码(填完自动提交,无按钮)--- +CODE_ENTRY_PREFIX = "codeEntry-" +CODE_ENTRY_COUNT = 6 + +# --- 保持登录 --- +KMSI_NO_BTN = 'button[data-testid="secondaryButton"]' + + +def is_protect_account_page(page): + """保护帐户 / 绑定备用邮箱页(#EmailAddress)。""" + try: + if page.locator(BACKUP_EMAIL_SELECTOR).count() > 0: + try: + if page.locator(BACKUP_EMAIL_SELECTOR).first.is_visible(): + return True + except Exception: + return True + except Exception: + pass + try: + body = (page.locator("body").inner_text(timeout=600) or "")[:800] + except Exception: + body = "" + if "保护你的帐户" in body or "保护您的帐户" in body or "protect your account" in body.lower(): + if page.locator("#iShowSkip").count() > 0 or page.locator(NEXT_SELECTOR).count() > 0: + return True + if "备用" in body or "电子邮件" in body: + return True + return False + + +def is_ott_code_page(page): + """旧绑定流单框验证码 #iOttText。""" + try: + loc = page.locator(VERIFY_CODE_SELECTOR) + return loc.count() > 0 and loc.first.is_visible() + except Exception: + return False + + +def is_code_entry_page(page): + """Fluent 6 格验证码页:「输入你的代码」#codeEntry-0..5。""" + try: + loc = page.locator(f"#{CODE_ENTRY_PREFIX}0") + if loc.count() > 0 and loc.first.is_visible(): + return True + except Exception: + pass + try: + body = (page.locator("body").inner_text(timeout=500) or "")[:400] + if "输入你的代码" in body or "Enter your code" in body: + if page.locator(f"[id^='{CODE_ENTRY_PREFIX}']").count() >= 4: + return True + except Exception: + pass + return False + + +def is_proof_confirm_page(page): + """登录时「验证你的电子邮件」:确认已绑定辅助邮箱并发送验证码。""" + try: + loc = page.locator(PROOF_EMAIL_INPUT) + if loc.count() > 0 and loc.first.is_visible(): + return True + except Exception: + pass + try: + if page.locator(PROOF_EMAIL_INPUT_BY_LABEL).count() > 0: + return True + except Exception: + pass + try: + body = (page.locator("body").inner_text(timeout=600) or "")[:900] + except Exception: + body = "" + if any(t in body for t in ("验证你的电子邮件", "验证您的电子邮件", "Verify your email")): + if "发送验证码" in body or "Send code" in body or "已收到代码" in body: + return True + # 掩码辅助邮箱提示(不绑定具体域名) + if "or****" in body or "or*" in body or "@" in body and "发送" in body: + return True + return False + + +def is_kmsi_page(page): + """保持登录状态?→ 是 / 否(secondaryButton)。""" + try: + body = (page.locator("body").inner_text(timeout=500) or "")[:500] + except Exception: + body = "" + if "保持登录" in body or "Stay signed in" in body or "保持登入" in body: + return True + try: + yes_btn = page.get_by_role("button", name="是") + no_btn = page.get_by_test_id("secondaryButton") + if yes_btn.count() > 0 and no_btn.count() > 0 and no_btn.first.is_visible(): + return True + if ( + page.get_by_role("button", name="是").count() > 0 + and page.get_by_role("button", name="否").count() > 0 + and ("登录" in body or "signed" in body.lower()) + ): + return True + except Exception: + pass + return False + + +def _click_i_next(page): + for sel in (NEXT_SELECTOR, 'input#iNext', 'input[type="submit"][value="下一步"]'): + try: + loc = page.locator(sel) + if loc.count() > 0 and loc.first.is_visible(): + loc.first.click(timeout=5000) + return True + except Exception: + continue + try: + page.get_by_role("button", name="下一步").first.click(timeout=5000) + return True + except Exception: + return False + + +def _click_send_code(page): + """点击「发送验证码」data-testid=primaryButton。""" + try: + btn = page.get_by_test_id("primaryButton") + if btn.count() > 0 and btn.first.is_visible(): + btn.first.click(timeout=8000) + return True + except Exception: + pass + for text in ("发送验证码", "Send code", "Send verification code"): + try: + loc = page.get_by_role("button", name=text) + if loc.count() > 0 and loc.first.is_visible(): + loc.first.click(timeout=8000) + return True + except Exception: + pass + return False + + +def _fill_proof_email(page, address): + """填入完整辅助邮箱到 proof-confirmation-email-input。""" + for sel in (PROOF_EMAIL_INPUT, 'input[type="email"]', 'input[name*="proof"]', 'input[placeholder*="电子"]'): + try: + loc = page.locator(sel) + if loc.count() <= 0: + continue + box = loc.first + if not box.is_visible(): + continue + box.click(timeout=3000) + box.fill("") + box.fill(address, timeout=8000) + return True + except Exception: + continue + return False + + +def _fill_code_entry_digits(page, code): + """6 格 #codeEntry-0..5 逐位输入;无提交按钮,满 6 位自动验证。""" + code = "".join(c for c in str(code) if c.isdigit())[:CODE_ENTRY_COUNT] + if len(code) < 4: + return False + + def _set_digit(box, ch): + box.click(timeout=2000) + try: + box.fill("") + except Exception: + pass + box.fill(ch, timeout=3000) + # Fluent/React 需 input 事件才会跳格并在满位时自动提交 + try: + box.evaluate( + """(el, v) => { + const proto = window.HTMLInputElement && window.HTMLInputElement.prototype; + const desc = proto && Object.getOwnPropertyDescriptor(proto, 'value'); + if (desc && desc.set) { desc.set.call(el, v); } + else { el.value = v; } + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }""", + ch, + ) + except Exception: + pass + + try: + first = page.locator(f"#{CODE_ENTRY_PREFIX}0").first + first.wait_for(state="visible", timeout=8000) + for i, ch in enumerate(code): + box = page.locator(f"#{CODE_ENTRY_PREFIX}{i}").first + box.wait_for(state="visible", timeout=5000) + _set_digit(box, ch) + page.wait_for_timeout(100) + page.wait_for_timeout(1500) + return True + except Exception: + try: + first = page.locator(f"#{CODE_ENTRY_PREFIX}0").first + first.click(timeout=2000) + try: + first.fill("") + except Exception: + pass + first.type(code, delay=80, timeout=12000) + page.wait_for_timeout(1500) + return True + except Exception: + return False + + +def _click_kmsi_no(page, log=None): + """保持登录状态?→ 点「否」data-testid=secondaryButton。""" + def _log(msg, level="INFO"): + if log: + log("kmsi", msg, level) + + try: + btn = page.get_by_test_id("secondaryButton") + if btn.count() > 0 and btn.first.is_visible(): + btn.first.click(timeout=5000) + _log("已点击 secondaryButton 否", "OK") + page.wait_for_timeout(1000) + return True + except Exception: + pass + for text in ("否", "No"): + try: + loc = page.get_by_role("button", name=text) + if loc.count() > 0 and loc.first.is_visible(): + loc.first.click(timeout=5000) + _log(f"已点击按钮 {text}", "OK") + page.wait_for_timeout(1000) + return True + except Exception: + pass + try: + loc = page.locator(KMSI_NO_BTN) + if loc.count() > 0 and loc.first.is_visible(): + loc.first.click(timeout=5000) + _log("已点击 KMSI_NO_BTN", "OK") + page.wait_for_timeout(1000) + return True + except Exception: + pass + return False + + +def _skip_protect(page, log): + try: + skip = page.locator("#iShowSkip") + if skip.count() > 0 and skip.first.is_visible(): + skip.first.click(timeout=4000) + if log: + log("recovery", "绑定失败后回退:已点 #iShowSkip 暂时跳过", "WARN") + page.wait_for_timeout(800) + return True + except Exception: + pass + return False + + +def bind_recovery_email(page, temp_mail_cfg, log=None, code_timeout=120): + """在保护帐户页绑定备用邮箱并输入验证码。 + + 成功返回 (True, session_dict),session 含 address/jwt 供 OAuth 冷登录复用。 + 失败返回 (False, None)。 + """ + def _log(stage, msg, level="INFO"): + if log: + log(stage, msg, level) + + if not is_protect_account_page(page) and not is_ott_code_page(page): + return False, None + + if is_ott_code_page(page) and not is_protect_account_page(page): + try: + if page.locator(BACKUP_EMAIL_SELECTOR).count() == 0: + _log("recovery", "仅代码页且无邮箱框,跳过二次绑定", "WARN") + return True, None + except Exception: + pass + _log("recovery", "已在代码页但无法新建接码会话", "FAIL") + return False, None + + client = client_from_config(temp_mail_cfg or {}) + try: + addr, jwt = client.create_address() + except Exception as exc: + _log("recovery", f"创建临时邮箱失败: {exc}", "FAIL") + _skip_protect(page, log) + return False, None + + session = { + "address": addr, + "jwt": jwt, + "base_url": client.base_url, + "admin_password": client.admin_password, + "domain": client.domain, + } + _log("recovery", f"临时邮箱已创建 addr={addr}(本任务独立 jwt)", "OK") + after_ts = time.time() + + try: + email_box = page.locator(BACKUP_EMAIL_SELECTOR).first + email_box.wait_for(state="visible", timeout=10000) + email_box.click(timeout=3000) + email_box.fill("") + email_box.fill(addr, timeout=5000) + page.wait_for_timeout(300) + if not _click_i_next(page): + raise RuntimeError("无法点击下一步提交备用邮箱") + _log("recovery", f"已提交备用邮箱 {addr}", "INFO") + except Exception as exp: + _log("recovery", f"填写备用邮箱失败: {exp}", "FAIL") + _skip_protect(page, log) + return False, None + + try: + page.locator(VERIFY_CODE_SELECTOR).first.wait_for(state="visible", timeout=30000) + except Exception: + if is_protect_account_page(page): + _log("recovery", "提交后仍在保护帐户页", "WARN") + _skip_protect(page, log) + return False, None + if not is_ott_code_page(page): + _log("recovery", "未出现验证码输入框,视为可能已完成", "WARN") + return True, session + + page.wait_for_timeout(2000) + code = client.wait_for_code( + timeout_sec=int((temp_mail_cfg or {}).get("code_timeout", code_timeout)), + poll_sec=float((temp_mail_cfg or {}).get("poll_interval", 3)), + after_ts=after_ts - 5, + log=log, + ) + if not code: + _log("recovery", "未收到微软验证码,尝试暂时跳过", "FAIL") + try: + page.go_back(timeout=5000) + page.wait_for_timeout(1000) + _skip_protect(page, log) + except Exception: + pass + return False, None + + try: + ott = page.locator(VERIFY_CODE_SELECTOR).first + ott.click(timeout=3000) + ott.fill("") + ott.fill(code, timeout=5000) + page.wait_for_timeout(300) + if not _click_i_next(page): + raise RuntimeError("无法点击下一步提交验证码") + _log("recovery", f"已提交验证码 code={code}", "OK") + page.wait_for_timeout(1500) + return True, session + except Exception as exc: + _log("recovery", f"提交验证码失败: {exc}", "FAIL") + return False, None + + +def _client_from_session(session, temp_mail_cfg): + """用绑定阶段保存的 jwt 重建 client,才能收同一邮箱的验证码。""" + if not session or not session.get("jwt"): + return None + cfg = dict(temp_mail_cfg or {}) + if session.get("base_url"): + cfg["base_url"] = session["base_url"] + if session.get("admin_password"): + cfg["admin_password"] = session["admin_password"] + if session.get("domain"): + cfg["domain"] = session["domain"] + client = client_from_config(cfg) + client.address = session.get("address") + client.jwt = session.get("jwt") + return client + + +def verify_bound_email_on_login(page, bound_session, temp_mail_cfg, log=None, code_timeout=180): + """OAuth 冷登录验证已绑定辅助邮箱全流程。 + + bound_session: {address, jwt, ...} 注册绑定阶段保存。 + 步骤: + 1) 填 #proof-confirmation-email-input + 点「发送验证码」 + 2) 轮询临时邮箱取码 → 填 #codeEntry-0..5(无提交按钮,自动验证) + 3) 若出现「保持登录」→ 点 secondaryButton「否」 + """ + def _log(stage, msg, level="INFO"): + if log: + log(stage, msg, level) + + # 仅「保持登录」页:点否即可 + if is_kmsi_page(page) and not is_proof_confirm_page(page) and not is_code_entry_page(page): + ok = _click_kmsi_no(page, log=log) + if ok: + _log("proof_verify", "仅 KMSI 页,已点「否」", "OK") + return ok + + if not bound_session or not bound_session.get("address"): + _log("proof_verify", "无已绑定辅助邮箱会话,无法验证", "FAIL") + return False + + bound_address = bound_session["address"] + client = _client_from_session(bound_session, temp_mail_cfg) + if client is None: + _log("proof_verify", "缺少绑定阶段 jwt,无法接码", "FAIL") + return False + + # --- 发码页 --- + if is_proof_confirm_page(page): + if not _fill_proof_email(page, bound_address): + _log("proof_verify", f"无法填写辅助邮箱框 addr={bound_address}", "FAIL") + return False + _log("proof_verify", f"已填写辅助邮箱 {bound_address}", "INFO") + page.wait_for_timeout(300) + after_ts = time.time() + if not _click_send_code(page): + _log("proof_verify", "无法点击「发送验证码」", "FAIL") + return False + _log("proof_verify", "已点击发送验证码", "OK") + elif is_code_entry_page(page) or is_ott_code_page(page): + after_ts = time.time() - 30 + _log("proof_verify", "已在代码页,直接接码", "INFO") + else: + return False + + # 等 6 格或旧单框 + code_ready = False + for _ in range(40): + if is_code_entry_page(page) or is_ott_code_page(page): + code_ready = True + break + # 「已收到代码」入口 + try: + for text in ("已收到代码", "I have a code", "I already have a code"): + loc = page.get_by_text(text, exact=False) + if loc.count() > 0 and loc.first.is_visible(): + loc.first.click(timeout=4000) + page.wait_for_timeout(800) + break + except Exception: + pass + page.wait_for_timeout(500) + + if not code_ready: + _log("proof_verify", "未出现验证码输入页", "FAIL") + return False + + page.wait_for_timeout(1500) + code = client.wait_for_code( + timeout_sec=int((temp_mail_cfg or {}).get("code_timeout", code_timeout)), + poll_sec=float((temp_mail_cfg or {}).get("poll_interval", 3)), + after_ts=after_ts - 5, + log=log, + ) + if not code: + _log("proof_verify", "未收到验证码", "FAIL") + return False + + # 填码 + if is_code_entry_page(page): + if not _fill_code_entry_digits(page, code): + _log("proof_verify", f"6 格填码失败 code={code}", "FAIL") + return False + _log("proof_verify", f"已填入 6 格验证码 code={code}(自动提交)", "OK") + else: + try: + ott = page.locator(VERIFY_CODE_SELECTOR).first + ott.click(timeout=3000) + ott.fill("") + ott.fill(code, timeout=5000) + if not _click_i_next(page): + page.keyboard.press("Enter") + _log("proof_verify", f"已提交单框验证码 code={code}", "OK") + except Exception as exc: + _log("proof_verify", f"单框填码失败: {exc}", "FAIL") + return False + + # 等跳转 / KMSI + page.wait_for_timeout(2000) + for _ in range(15): + if is_kmsi_page(page): + if _click_kmsi_no(page, log=log): + _log("proof_verify", "已点保持登录「否」", "OK") + break + # 已到 consent / 其它页 + try: + if page.locator('[data-testid="appConsentPrimaryButton"]').count() > 0: + break + if "localhost" in (page.url or "") and "code=" in (page.url or ""): + break + except Exception: + pass + page.wait_for_timeout(400) + + # 再扫一次 KMSI(有时慢) + if is_kmsi_page(page): + _click_kmsi_no(page, log=log) + + return True diff --git a/controllers/temp_mail.py b/controllers/temp_mail.py new file mode 100644 index 0000000..4af2f84 --- /dev/null +++ b/controllers/temp_mail.py @@ -0,0 +1,218 @@ +"""CF Temp Mail 客户端:每任务独立地址 + JWT,避免多线程验证码串号。""" +import random +import re +import string +import threading +import time + +import requests + +# 公开发行:无内置密钥/域名,须由 config.json 的 temp_mail 段填写 +DEFAULT_BASE = "" +DEFAULT_DOMAIN = "" +DEFAULT_ADMIN = "" +DEFAULT_PREFIX = "orx" + +# 优先带「验证码/安全代码」上下文的数字,避免误匹配邮箱本地部分里的数字 +_LABELED_CODE_RES = [ + re.compile(r"(?:安全代码|验证码|security\s*code|verification\s*code)[^\d]{0,48}(\d{4,8})", re.I), + re.compile(r"(?:输入|enter)[^\d]{0,20}(?:代码|code)[^\d]{0,20}(\d{4,8})", re.I), + re.compile(r"(?:code|代码)\s*[::]\s*(\d{4,8})", re.I), +] + + +class TempMailClient: + """线程安全:创建地址与收信均用本实例自己的 address/jwt,不共享全局邮箱。""" + + def __init__( + self, + base_url=DEFAULT_BASE, + admin_password=DEFAULT_ADMIN, + domain=DEFAULT_DOMAIN, + name_prefix=DEFAULT_PREFIX, + enable_prefix=False, + timeout=30, + ): + self.base_url = (base_url or DEFAULT_BASE).rstrip("/") + self.admin_password = admin_password or DEFAULT_ADMIN + self.domain = domain or DEFAULT_DOMAIN + self.name_prefix = name_prefix or DEFAULT_PREFIX + self.enable_prefix = bool(enable_prefix) + self.timeout = timeout + self._lock = threading.Lock() + self.address = None + self.jwt = None + self.address_id = None + self._session = requests.Session() + self._session.headers.update({"User-Agent": "OutlookRegister/1.0"}) + + def _unique_name(self): + # orx + mmddHHMMSS + 线程低位 + 随机,降低多线程碰撞 + ts = time.strftime("%m%d%H%M%S") + tid = abs(threading.get_ident()) % 10000 + rnd = "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + return f"{self.name_prefix}{ts}{tid:04d}{rnd}" + + def create_address(self, name=None, domain=None): + """POST /admin/new_address → 本实例独有 address + jwt。""" + name = name or self._unique_name() + domain = domain or self.domain + url = f"{self.base_url}/admin/new_address" + headers = { + "Content-Type": "application/json", + "x-admin-auth": self.admin_password, + } + payload = { + "enablePrefix": self.enable_prefix, + "name": name, + "domain": domain, + } + resp = self._session.post(url, json=payload, headers=headers, timeout=self.timeout) + resp.raise_for_status() + data = resp.json() + with self._lock: + self.address = data.get("address") or f"{name}@{domain}" + self.jwt = data.get("jwt") + self.address_id = data.get("address_id") + if not self.jwt: + raise RuntimeError(f"temp_mail create missing jwt: {data}") + return self.address, self.jwt + + def list_mails(self, limit=20, offset=0): + """仅用本实例 jwt 拉信,不会读到其它任务邮箱。""" + if not self.jwt: + raise RuntimeError("temp_mail: create_address first") + url = f"{self.base_url}/api/mails" + headers = {"Authorization": f"Bearer {self.jwt}"} + resp = self._session.get( + url, + params={"limit": limit, "offset": offset}, + headers=headers, + timeout=self.timeout, + ) + resp.raise_for_status() + data = resp.json() + # API 可能用 results 或 data + if isinstance(data, dict): + return data.get("results") or data.get("data") or [] + if isinstance(data, list): + return data + return [] + + @staticmethod + def extract_code_from_text(text, exclude_substrings=None): + """从邮件正文解析验证码。exclude_substrings:排除邮箱地址等中的数字片段。""" + if not text: + return None + text = str(text) + exclude = [str(x) for x in (exclude_substrings or []) if x] + + def _ok(code): + if not code or not code.isdigit(): + return False + # 勿把临时邮箱本地名里的连续数字当成验证码(日志曾误提 072468) + for ex in exclude: + if code in ex.replace("@", ""): + return False + return True + + for rx in _LABELED_CODE_RES: + m = rx.search(text) + if m and _ok(m.group(1)): + return m.group(1) + # 兜底:独立 6 位(再 4-8 位),仍排除邮箱数字 + for m in re.finditer(r"(? 1e12: + ts /= 1000.0 + else: + # 跳过无法解析的字符串时间,不因格式误杀 + ts = None + if ts is not None and ts + 2 < after_ts: + continue + except Exception: + pass + break + key = mid if mid is not None else id(mail) + if key in seen: + continue + seen.add(key) + blob = self._mail_blob(mail) + code = self.extract_code_from_text( + blob, + exclude_substrings=[self.address, (self.address or "").split("@")[0]], + ) + if code: + if log: + log("temp_mail", f"解析到验证码 code={code} addr={self.address}", "OK") + return code + time.sleep(poll_sec) + if log: + log("temp_mail", f"等待验证码超时 addr={self.address}", "FAIL") + return None + + +def client_from_config(cfg): + """从 config['temp_mail'] 构建客户端。未配置时 base/admin/domain 为空。""" + cfg = cfg or {} + return TempMailClient( + base_url=(cfg.get("base_url") or "").strip(), + admin_password=(cfg.get("admin_password") or "").strip(), + domain=(cfg.get("domain") or "").strip(), + name_prefix=(cfg.get("name_prefix") or DEFAULT_PREFIX).strip() or DEFAULT_PREFIX, + enable_prefix=bool(cfg.get("enable_prefix", False)), + timeout=int(cfg.get("timeout", 30)), + ) + + +if __name__ == "__main__": + print(smoke_test()) diff --git a/main.py b/main.py new file mode 100644 index 0000000..4c82a54 --- /dev/null +++ b/main.py @@ -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 路径:若已注入注册 cookie,prefer_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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9bfd15a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +faker +requests +playwright==1.53.0 +patchright +nest_asyncio diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..0de51dd --- /dev/null +++ b/utils.py @@ -0,0 +1,37 @@ +import random +import string +import secrets + +def random_email(length=None): + # 默认随机长度 11~17 + if length is None: + length = random.randint(11, 17) + + first_char = random.choice(string.ascii_lowercase) + + other_chars = [] + for _ in range(length - 1): + # 数字概率 10% + if random.random() < 0.1: + other_chars.append(random.choice(string.digits)) + else: + other_chars.append(random.choice(string.ascii_lowercase)) + + return first_char + ''.join(other_chars) + +def generate_strong_password(length=None): + if length is None: + length = random.randint(10, 14) + + chars = string.ascii_letters + string.digits + "!@#$%^&*" + + while True: + password = ''.join(secrets.choice(chars) for _ in range(length)) + + if ( + any(c.islower() for c in password) + and any(c.isupper() for c in password) + and any(c.isdigit() for c in password) + and any(c in "!@#$%^&*" for c in password) + ): + return password