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