Initial public release of OutlookRegister.

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