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:
+17
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 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.
|
||||
+190
@@ -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
@@ -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 元件之署名說明。
|
||||
@@ -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
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
faker
|
||||
requests
|
||||
playwright==1.53.0
|
||||
patchright
|
||||
nest_asyncio
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user