Compare commits
22
Commits
main
..
d0f06ac93c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f06ac93c | ||
|
|
986bc36f04 | ||
|
|
bc5ad63755 | ||
|
|
7d6d52ac5d | ||
|
|
cc0aa6a433 | ||
|
|
4abdc8aa4a | ||
|
|
d74d14d3f4 | ||
|
|
0417fa3218 | ||
|
|
bf30d40c8b | ||
|
|
96061cbd78 | ||
|
|
8b399277e7 | ||
|
|
2e833f2af3 | ||
|
|
fc879c09ea | ||
|
|
001dc014ea | ||
|
|
a0fb249dff | ||
|
|
df9213463d | ||
|
|
a4dbe948b2 | ||
|
|
1ed18236b9 | ||
|
|
ee151343e0 | ||
|
|
8b3664a2d5 | ||
|
|
90d09db27a | ||
|
|
33f966ccea |
No files matched your search
-124
@@ -1,124 +0,0 @@
|
||||
# grok-free-register 配置模板
|
||||
# 复制为 .env 后按需修改。未填写的项目会使用程序默认值。
|
||||
|
||||
# 邮箱模式
|
||||
# tempmail: 免费临时邮箱,适合快速试跑。
|
||||
# custom: 自建域名邮箱,需要 Cloudflare Email Routing 和本地收信服务。
|
||||
EMAIL_MODE=tempmail
|
||||
|
||||
# custom 模式专用
|
||||
# EMAIL_DOMAIN=example.com
|
||||
# EMAIL_API=http://127.0.0.1:8080
|
||||
|
||||
# 认证账号来源
|
||||
# auto: 配置 SSH 主机时读取远端,否则读取当前项目;一般保持默认即可。
|
||||
# XAI_AUTH_SERVICE_SOURCE=auto # auto | local | ssh
|
||||
# XAI_AUTH_SERVICE_REGISTER_ROOT=. # local 模式下的注册项目目录
|
||||
|
||||
# 注册与认证不在同一台机器时填写
|
||||
# XAI_AUTH_SERVICE_SSH_HOST=user@server.example
|
||||
# XAI_AUTH_SERVICE_SSH_IDENTITY=/path/to/key.pem
|
||||
# XAI_AUTH_SERVICE_REMOTE_ROOT=/opt/grok-free-register
|
||||
# XAI_AUTH_SERVICE_SYNC_SEC=30 # 同步新账号的间隔(秒)
|
||||
|
||||
# 认证节奏
|
||||
# XAI_AUTH_SERVICE_MIN_INTERVAL_SEC=10 # 两次认证请求的基础最小间隔(秒)
|
||||
# # 触发限流后会自动抬高(约18s起,最多约90s),成功连串后再缓慢回落
|
||||
# XAI_AUTH_SERVICE_RETRY_SEC=60 # 被限流后的基础探测间隔(秒);连续撞限会按 1.5x 增长,上限 300s
|
||||
# XAI_AUTH_SERVICE_LOG_MODE=user # user | debug
|
||||
|
||||
# 运行目标
|
||||
# TARGET=0 # 成功 N 个后停止;0=不限
|
||||
|
||||
# 日志显示
|
||||
# REGISTER_LOG_MODE=user # 默认:任务开始、结果、平均速度和限流等待
|
||||
# REGISTER_LOG_MODE=debug # 额外显示 T/Q/并发/阶段耗时调试面板
|
||||
|
||||
# 浏览器并发容量
|
||||
# PHYSICAL_CAP=0 # 0=按 CPU/内存自动估算;>0=手动指定
|
||||
# PHYSICAL_PER_CPU=2 # 自动估算时每个 CPU 核心对应的并发参考值
|
||||
# PHYSICAL_MEM_MB=512 # 自动估算时每个浏览器任务的内存预算(MB)
|
||||
# MIN_FREE_MEM_MB=500 # 自动估算时保留的内存(MB)
|
||||
# CAPACITY_PROFILE= # 可选:离线压测生成的静态 profile(JSON)
|
||||
|
||||
# 缓冲容量
|
||||
# T_SLOT_CAP=8
|
||||
# Q_SLOT_CAP=8
|
||||
# Q_PENDING_CAP=12
|
||||
|
||||
# Worker 数量
|
||||
# 通常不需要手动设置。0 表示根据容量自动派生。
|
||||
# S_WORKERS=0
|
||||
# P_WORKERS=0
|
||||
# C_WORKERS=0
|
||||
|
||||
# 资源有效期
|
||||
# T_MAX_AGE=300 # token 最大年龄(秒)
|
||||
# Q_MAX_AGE=120 # 验证码最大年龄(秒)
|
||||
|
||||
# 阶段超时
|
||||
# P_REQUEST_TIMEOUT=95 # 等待验证码返回超时(秒)
|
||||
# C_CONSUME_TIMEOUT=60 # 最终提交阶段超时(秒)
|
||||
|
||||
# 局部门控和批量发送
|
||||
# 这些项目默认会按容量派生。只有在压测定位后才建议覆盖。
|
||||
# T_TARGET=4 # token 缓冲目标
|
||||
# Q_TARGET=4 # 验证码缓冲目标
|
||||
# T_HIGH_WATER= # token 生产高水位
|
||||
# T_LOW_WATER= # token 生产恢复低水位
|
||||
# Q_HIGH_WATER= # 验证码生产高水位
|
||||
# Q_LOW_WATER= # 验证码生产恢复低水位
|
||||
# P_BATCH_MAX=4 # 单个发送页面最多发送的验证码请求数
|
||||
# P_SEND_CAP=0 # 0=不额外限制;>0=限制发送页面并发
|
||||
|
||||
# 页面和 token 获取
|
||||
# SOLVER_REUSE=1 # token 页面复用
|
||||
# MAX_SOLVER_REUSE=25 # 单个 token 页面最大复用次数
|
||||
# SOLVER_INITIAL_WAIT_MS=500 # token 注入后的首次等待
|
||||
# SOLVER_POLL_INTERVAL_MS=500 # token 轮询间隔
|
||||
# SOLVER_POLL_ATTEMPTS=100 # token 最大轮询次数
|
||||
# SOLVER_HARD_TIMEOUT=90 # 单次 solver 全链路硬截止(秒)
|
||||
# SOLVER_CLEANUP_TIMEOUT=5 # 异常页回收最长等待(秒)
|
||||
# SOLVER_FAST_CLICK=1 # 没有可见验证框时跳过慢点击
|
||||
# SOLVER_MOUSE_CLICK_RETRIES=3 # token 验证框中心点击次数;0=关闭
|
||||
# SOLVER_MOUSE_CLICK_INTERVAL_MS=600 # token 中心点击间隔(ms)
|
||||
# PAGE_GOTO_WAIT_UNTIL=domcontentloaded # 页面导航等待策略
|
||||
# PAGE_POST_WAIT_MS=500 # 页面导航后的短等待
|
||||
|
||||
# 可选性能开关
|
||||
# PAGE_BLOCK_STATIC_ASSETS=0 # 阻断部分静态资源,降低页面准备成本
|
||||
# C_HOT_PAGE_POOL=0 # 复用消费阶段页面,会增加常驻内存
|
||||
# C_HOT_PAGE_POOL_SIZE=0 # 0=按启动期并发派生;>0=手动指定
|
||||
# C_SET_COOKIE_VIA_REQUEST=0 # 用浏览器 request 访问 set-cookie URL;热页池开启时默认启用
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 代理(grok-free-register 本进程出口,给 Playwright + httpx)
|
||||
# 与下面 FlareSolverr 清障栈是两套东西,不要混。
|
||||
# Playwright 不会自动读环境变量,代码里会显式挂到 launch(proxy=...)。
|
||||
# ---------------------------------------------------------------------------
|
||||
# REGISTER_PROXY=http://127.0.0.1:40080 # 优先;指向本机 privoxy→WARP 时用这个
|
||||
# HTTP_PROXY=http://127.0.0.1:7890 # 兜底(REGISTER_PROXY 未设时)
|
||||
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloudflare 清障预热(调用外部 FlareSolverr,不在本仓起 FS)
|
||||
# 默认 FS: docker compose 暴露 127.0.0.1:8191
|
||||
# 预热根域名(无 path 后缀): accounts.x.ai / x.ai / status.x.ai / console.x.ai / auth.x.ai
|
||||
# CLEARANCE_PROXY 是 FS *容器内* 浏览器的出口,必须是容器可达地址;
|
||||
# 不要把 host 的 127.0.0.1 塞进去。走 WARP 时用 docker 网内 privoxy。
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLEARANCE_ENABLED=1
|
||||
# FLARESOLVERR_URL=http://127.0.0.1:8191
|
||||
# CLEARANCE_URLS=https://accounts.x.ai,https://x.ai,https://status.x.ai,https://console.x.ai,https://auth.x.ai
|
||||
# CLEARANCE_PROXY=http://privoxy:8118
|
||||
# CLEARANCE_TIMEOUT_SEC=60
|
||||
# CLEARANCE_REFRESH_SEC=3000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本机注册 → 推 keys 到远端 auth(可选;scripts/push_keys_to_auth.sh)
|
||||
# 无内置默认主机,必须显式填写。
|
||||
# ---------------------------------------------------------------------------
|
||||
# AUTH_SSH_HOST=user@auth-server.example
|
||||
# AUTH_REMOTE_ROOT=/opt/grok-free-register
|
||||
# AUTH_SSH_IDENTITY=
|
||||
# AUTH_PUSH_INTERVAL=20
|
||||
+11
-16
@@ -1,19 +1,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.log
|
||||
logs/
|
||||
*.bak
|
||||
# Python runtime / cache
|
||||
.venv/
|
||||
.setup.lock/
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
xai-enroller-ledger.db*
|
||||
tmp_*.py
|
||||
.mypy_cache/
|
||||
|
||||
# runtime credential outputs under keys/ (keep product scripts tracked)
|
||||
keys/*
|
||||
!keys/acpa_watchdog.py
|
||||
!keys/sync_acc.py
|
||||
!keys/async_auth.sh
|
||||
!keys/README.md
|
||||
# Local agent / IDE
|
||||
.workbuddy/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 grok-free-register 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.
|
||||
@@ -1,370 +0,0 @@
|
||||
# grok-free-register
|
||||
|
||||
`grok-free-register` 是一个命令行注册工具。程序会启动本机浏览器,完成页面操作、邮箱验证码处理和结果保存。
|
||||
|
||||
运行结果写入 `keys/` 目录。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone <your-fork-or-mirror>/grok-free-register.git
|
||||
cd grok-free-register
|
||||
bash start.sh
|
||||
```
|
||||
|
||||
首次运行会自动创建 `.venv`、安装依赖,并引导生成 `.env`。
|
||||
|
||||
需要完整说明时,按用途查看:
|
||||
|
||||
- [注册教程](docs/guides/registration.md)
|
||||
- [本地认证服务](docs/guides/auth-service.md)
|
||||
- [凭据库存与取用](docs/guides/credential-inventory.md)
|
||||
- [运行状态与排障](docs/guides/runtime-troubleshooting.md)
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
bash start.sh # 按当前 .env 前台运行
|
||||
bash start.sh --target 100 # 成功 100 个后停止
|
||||
bash start.sh --max-mem 6G # 自动估算并发时最多使用 6G 内存
|
||||
bash start.sh --reconfig # 重新选择邮箱模式
|
||||
```
|
||||
|
||||
`start.sh` 直接在当前终端显示状态。按 `Ctrl-C` 停止,再次执行同一命令即可重启;不需要额外的会话管理或守护进程依赖。
|
||||
|
||||
代理与 CF 清障是两套配置,不要混:
|
||||
|
||||
```env
|
||||
# 本进程出口(Playwright + httpx 显式挂载;Playwright 不会自己读 env)
|
||||
REGISTER_PROXY=http://127.0.0.1:40080
|
||||
# 或兜底:
|
||||
# HTTP_PROXY=http://127.0.0.1:7890
|
||||
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||
|
||||
# 清障预热:调用外部 FlareSolverr(本仓不跑 FS)
|
||||
CLEARANCE_ENABLED=1
|
||||
FLARESOLVERR_URL=http://127.0.0.1:8191
|
||||
CLEARANCE_URLS=https://accounts.x.ai,https://x.ai,https://status.x.ai,https://console.x.ai,https://auth.x.ai
|
||||
# FS 容器内出口(容器可达名);走 WARP 时用 docker 网内 privoxy
|
||||
# CLEARANCE_PROXY=http://privoxy:8118
|
||||
```
|
||||
|
||||
## 邮箱模式
|
||||
|
||||
`tempmail` 是默认模式,不需要额外配置,适合快速试跑:
|
||||
|
||||
```env
|
||||
EMAIL_MODE=tempmail
|
||||
```
|
||||
|
||||
`custom` 是自建域名邮箱模式,适合长时间运行。需要一个已接入 Cloudflare Email Routing 的域名,并在运行机器上启动本项目的收信服务。
|
||||
|
||||
配置步骤:
|
||||
|
||||
1. 在 Cloudflare 为域名开启 Email Routing。
|
||||
2. 部署 `cloudflare/email-worker.js`。
|
||||
3. 在 Email Routing 中配置 catch-all,动作选择发送到该 Worker。
|
||||
4. 在运行机器上启动收信服务:
|
||||
|
||||
```bash
|
||||
bash start.sh --email-service
|
||||
```
|
||||
|
||||
5. 在 `.env` 中配置:
|
||||
|
||||
```env
|
||||
EMAIL_MODE=custom
|
||||
EMAIL_DOMAIN=example.com
|
||||
EMAIL_API=http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
如果 Worker 需要回调本机服务,`WEBHOOK_URL` 应使用可访问的域名地址。
|
||||
|
||||
## 配置
|
||||
|
||||
完整模板见 `.env.example`。日常使用通常只需要配置邮箱模式、代理、目标数量和内存预算。
|
||||
|
||||
| 配置 | 默认值 | 说明 |
|
||||
|---|---:|---|
|
||||
| `EMAIL_MODE` | `tempmail` | 邮箱模式,支持 `tempmail` 和 `custom` |
|
||||
| `EMAIL_DOMAIN` | 空 | `custom` 模式使用的域名 |
|
||||
| `EMAIL_API` | `http://127.0.0.1:8080` | 本地收信服务地址 |
|
||||
| `TARGET` | `0` | 成功数量目标,`0` 表示不限 |
|
||||
| `PHYSICAL_CAP` | `0` | 浏览器并发上限,`0` 表示启动时自动估算 |
|
||||
| `PHYSICAL_PER_CPU` | `2` | 自动估算时每个 CPU 核心对应的并发参考值 |
|
||||
| `PHYSICAL_MEM_MB` | `512` | 自动估算时每个浏览器任务的内存预算 |
|
||||
| `MIN_FREE_MEM_MB` | `500` | 自动估算时保留的内存 |
|
||||
| `T_SLOT_CAP` | `8` | token 缓冲容量 |
|
||||
| `Q_SLOT_CAP` | `8` | 验证码缓冲容量 |
|
||||
| `Q_PENDING_CAP` | `12` | 等待验证码返回的请求上限 |
|
||||
| `SOLVER_MOUSE_CLICK_RETRIES` | `3` | token 验证框中心点击次数,`0` 表示关闭 |
|
||||
| `PAGE_BLOCK_STATIC_ASSETS` | `0` | 可选:阻断部分静态资源,降低页面准备成本 |
|
||||
| `C_HOT_PAGE_POOL` | `0` | 可选:复用消费阶段页面,减少页面重建开销 |
|
||||
|
||||
不确定怎么设置时,先保持默认值。性能压测时优先观察 `PHYSICAL_CAP` 和内存,不建议先改 Worker 数量。
|
||||
|
||||
## 运行日志
|
||||
|
||||
直接运行 `bash start.sh` 时,终端只输出任务开始、成功或失败、本次运行平均速度、累计数量和限流等待:
|
||||
|
||||
```text
|
||||
[→] 开始注册 #38
|
||||
[✓] 注册成功 #38 | 运行平均 47/分 | 累计 38
|
||||
[⏸] 触发限流 | 60秒后恢复探测
|
||||
[▶] 限流解除 | 实际等待 61秒
|
||||
```
|
||||
|
||||
需要调试并发、库存和阶段耗时时,使用:
|
||||
|
||||
```bash
|
||||
bash start.sh --debug
|
||||
```
|
||||
|
||||
它会在上述任务事件之外,每 8 秒输出一次完整的 T/Q、物理并发和阶段耗时面板。已有自动化环境也可继续使用 `REGISTER_LOG_MODE=debug`。
|
||||
|
||||
常用字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `T` | 当前可用 token 数量 |
|
||||
| `Q` | 当前可用验证码数量 |
|
||||
| `phys` | 空闲浏览器并发许可 |
|
||||
| `s_phys` / `p_phys` / `c_phys` | S/P/C 获取浏览器许可的平均等待秒数 / 平均持有秒数 |
|
||||
| `p_stage` | P 阶段平均耗时:建邮箱 / 准备页面 / 发送请求 |
|
||||
| `c_stage` | C 阶段平均耗时:拿页面 / 验证码校验 / 注册提交 |
|
||||
| `c_hot` | C 热页池命中 / 未命中次数 |
|
||||
| `t_solve_avg` | 平均 token 获取时间 |
|
||||
| `q_sent` / `q_ret` | 已发送 / 已收到的验证码数量 |
|
||||
| `pair` | 已配对消费次数 |
|
||||
| `ok` / `fail` | 成功 / 失败数量 |
|
||||
| `rate` | 当前累计成功速率 |
|
||||
|
||||
简单判断:
|
||||
|
||||
- `T` 长期为 `0` 且 `Q` 有库存,通常是 token 获取较慢。
|
||||
- `Q` 长期为 `0` 且 `T` 有库存,通常是邮箱或验证码链路较慢。
|
||||
- `phys` 长期为 `0`,说明浏览器并发已经用满。
|
||||
- `t_solve_avg` 明显升高,通常表示浏览器压力、网络质量或 token 服务响应变慢。
|
||||
|
||||
可以用日志分析工具解析已有日志:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
from tools.runtime_log_analyzer import analyze_text
|
||||
print(analyze_text(Path("run.log").read_text()))
|
||||
PY
|
||||
```
|
||||
|
||||
## 输出文件
|
||||
|
||||
成功结果写入:
|
||||
|
||||
```text
|
||||
keys/accounts.txt
|
||||
keys/grok.txt
|
||||
```
|
||||
|
||||
`accounts.txt` 每行格式:
|
||||
|
||||
```text
|
||||
email:password:sso_token
|
||||
```
|
||||
|
||||
`keys/` 目录包含运行结果,默认不会提交到 Git。
|
||||
|
||||
认证成功后的 OAuth 出货默认在:
|
||||
|
||||
```text
|
||||
~/Downloads/grok-free-register-auth/authenticated/
|
||||
```
|
||||
|
||||
可用 `keys/async_auth.sh` 把出货整备成 `keys/cpa_ready/` 并同步探活通过的 `access_token` 到 `keys/acc.md`。
|
||||
|
||||
## 清零流水线(删库跑路)
|
||||
|
||||
只删 `keys/acc.md` / `accounts.txt` **不够**:`acpa_watchdog` 会从
|
||||
`~/Downloads/grok-free-register-auth/authenticated/` 把旧号再整备进
|
||||
`keys/cpa_ready/`,`sync_acc` 再写回 `acc.md`。状态文件 `_state.tsv` 也会残留幽灵 alive。
|
||||
|
||||
**全量清零**用项目根的 `reset_pipeline.sh`(默认 dry-run,必须加 `--yes` 才真删):
|
||||
|
||||
```bash
|
||||
# 建议先停 register / auth-service / async_auth
|
||||
bash reset_pipeline.sh # dry-run,只列将删内容
|
||||
bash reset_pipeline.sh --yes # 真删:注册源 + 认证出货 + cpa_ready + state
|
||||
bash reset_pipeline.sh --yes --keep-register
|
||||
# 只清认证/CPA,保留 keys 里新注册的 SSO 源
|
||||
bash reset_pipeline.sh --yes --keep-cpa
|
||||
# 只清认证账本/源快照,保留 cpa_ready(少用)
|
||||
bash reset_pipeline.sh --yes --wipe-salt
|
||||
# 连 enrollment ledger salt 一并清(极少需要)
|
||||
```
|
||||
|
||||
会清掉的大致范围:
|
||||
|
||||
| 范围 | 路径 |
|
||||
|------|------|
|
||||
| 注册源 | `keys/accounts.txt`、`keys/grok.txt`、`keys/auth-sessions.jsonl`、`keys/acc.md` |
|
||||
| CPA | `keys/cpa_ready/xai-*.json`、`_state.tsv`、`_discarded/` |
|
||||
| 认证出货 | `$XAI_AUTH_SERVICE_LOCAL_DIR`(默认 `~/Downloads/grok-free-register-auth`)下的 `authenticated/`、`claimed/`、`enrollment-ledger.db*`、`source-snapshot.jsonl` |
|
||||
|
||||
认证目录可用环境变量覆盖:
|
||||
|
||||
```bash
|
||||
export XAI_AUTH_SERVICE_LOCAL_DIR=~/Downloads/grok-free-register-auth
|
||||
# 或
|
||||
export XAI_ENROLLER_LOCAL_AUTH_DIR=~/Downloads/grok-free-register-auth
|
||||
```
|
||||
|
||||
**只清历史归档、不动现网号池**用 `scripts/clean_history.sh`:
|
||||
|
||||
```bash
|
||||
bash scripts/clean_history.sh # dry-run
|
||||
bash scripts/clean_history.sh --yes # 清 zip / _discarded / logs / 杂包
|
||||
bash scripts/clean_history.sh --yes --deep # 再清 source-snapshot 等更深一层
|
||||
```
|
||||
|
||||
清完后重开:
|
||||
|
||||
```bash
|
||||
bash start.sh # 或远端注册机继续跑
|
||||
bash auth-service.sh # device-flow → authenticated/
|
||||
bash keys/async_auth.sh # acpa_watchdog + sync_acc
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
grok_register/ 注册核心与 custom 邮箱服务
|
||||
xai_enroller/ OAuth 认证服务
|
||||
keys/ acpa_watchdog / sync_acc / async_auth(运行时产物 gitignore)
|
||||
cloudflare/email-worker.js Cloudflare Email Routing Worker 示例
|
||||
start.sh 首次配置和运行
|
||||
auth-service.sh 认证服务入口
|
||||
reset_pipeline.sh 全量清零注册→认证→CPA 运行时数据
|
||||
scripts/clean_history.sh 只清历史归档,不动现网号池
|
||||
scripts/push_keys_to_auth.sh 本机 SSO 源推远端 auth(需显式 AUTH_SSH_*)
|
||||
setup.sh 安装依赖
|
||||
.env.example 配置模板
|
||||
tools/ 日志分析 + GrokSession→CPA/sub2api 前端转换
|
||||
tests/ 自动化测试
|
||||
docs/architecture.md 并发架构说明
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
测试依赖与运行依赖分开安装:
|
||||
|
||||
```bash
|
||||
.venv/bin/pip install -r tests/requirements.txt
|
||||
```
|
||||
|
||||
快速检查:
|
||||
|
||||
```bash
|
||||
python3 -m unittest tests.test_admission_gate tests.test_register_runtime_unittest tests.test_inventory_unittest tests.test_runtime_log_analyzer -v
|
||||
```
|
||||
|
||||
完整测试:
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests -q
|
||||
```
|
||||
|
||||
## xAI OAuth Enroller
|
||||
|
||||
`xai_enroller/` 用于把已有 xAI 账号的 SSO 会话转换为 OAuth 凭据,并导入 CPA
|
||||
凭据库。它独立于注册流程运行:注册机不需要启动,已有账号也不需要重新注册。
|
||||
|
||||
### 默认同机模式
|
||||
|
||||
注册和认证在同一个项目目录运行时,无需配置来源:
|
||||
|
||||
```bash
|
||||
bash auth-service.sh
|
||||
```
|
||||
|
||||
认证服务默认读取本项目的 `keys/auth-sessions.jsonl` 和历史
|
||||
`keys/accounts.txt`,每 30 秒生成一次经过校验的原子快照。注册仍可同时运行;认证服务
|
||||
只读取完整记录,不会读取正在追加的半行。
|
||||
|
||||
### 分离设备的 SSH 模式
|
||||
|
||||
注册机和认证服务分开运行时,服务器继续写入注册结果,本地认证服务
|
||||
每 30 秒通过一次性 SSH 导出全量 JSONL 快照。快照经逐行校验、`fsync` 后原子替换到
|
||||
`~/Downloads/grok-free-register-auth/source-snapshot.jsonl`;同步失败会继续使用上一份有效快照。
|
||||
认证使用本机 CloakBrowser Chromium,
|
||||
成功结果写入 `~/Downloads/grok-free-register-auth/authenticated/`,运行状态、同步快照与
|
||||
认证文件分开保存;认证文件格式可以直接供 CPA 使用。
|
||||
|
||||
先把导出器同步到注册机的 `scripts/` 目录:
|
||||
|
||||
```bash
|
||||
scp scripts/export_registered_sessions.py user@your-server:/opt/grok-free-register/scripts/
|
||||
```
|
||||
|
||||
然后在本地配置 SSH 连接:
|
||||
|
||||
下面这些变量既可在终端 `export`,也可写入由 `.env.example` 复制出的 `.env`;
|
||||
`auth-service.sh` 会自动读取该文件。
|
||||
|
||||
```bash
|
||||
export XAI_AUTH_SERVICE_SSH_HOST=user@your-server
|
||||
export XAI_AUTH_SERVICE_SSH_IDENTITY=/path/to/ssh-key.pem # 使用 ssh-agent 时可省略
|
||||
export XAI_AUTH_SERVICE_REMOTE_ROOT=/opt/grok-free-register
|
||||
export XAI_AUTH_SERVICE_SYNC_SEC=30
|
||||
```
|
||||
|
||||
存在 `XAI_AUTH_SERVICE_SSH_HOST` 时会自动选择 SSH。也可显式设置
|
||||
`XAI_AUTH_SERVICE_SOURCE=local|ssh`;默认值 `auto` 优先兼容已有 SSH 配置,否则使用同机来源。
|
||||
|
||||
启动认证服务:
|
||||
|
||||
```bash
|
||||
bash auth-service.sh
|
||||
```
|
||||
|
||||
需要查看队列、重试、节拍和冷却探针时使用:
|
||||
|
||||
```bash
|
||||
bash auth-service.sh --debug
|
||||
```
|
||||
|
||||
默认每次认证至少间隔 10 秒;实测该值比无间隔运行有更高的长期平均成功速率。限流后
|
||||
每 60 秒只进行一次恢复探测。需要覆盖时使用:
|
||||
|
||||
```bash
|
||||
export XAI_AUTH_SERVICE_MIN_INTERVAL_SEC=10
|
||||
export XAI_AUTH_SERVICE_RETRY_SEC=60
|
||||
```
|
||||
|
||||
终端只在账号开始、认证结果、限流状态或控制状态变化时输出,并在底部保持 `认证> ` 输入行。`s` 查看状态,`p` 暂停,
|
||||
`r` 恢复,`c` 取消当前账号,`q` 退出;`Ctrl-C` 同样会退出。在输入行键入 `take 100`
|
||||
会把最新的 100 个可用凭证登记为已取用,并移动到独立批次目录。认证记录仍保留为
|
||||
`imported`,不会因为凭证被取出而重新认证。
|
||||
|
||||
可用凭证保存在:
|
||||
|
||||
```text
|
||||
~/Downloads/grok-free-register-auth/authenticated/
|
||||
```
|
||||
|
||||
已取用批次保存在:
|
||||
|
||||
```text
|
||||
~/Downloads/grok-free-register-auth/claimed/<batch-id>/
|
||||
```
|
||||
|
||||
库存状态保存在 `enrollment-ledger.db` 的 `credential_inventory` 表中,状态为
|
||||
`available`、`claiming` 或 `claimed`。每条记录预留 `note` 字段,默认留空。
|
||||
|
||||
`auth-service.sh` 首次运行会自动安装项目依赖。正式用户流程只需要这个 Bash 入口;底层 Python 模块保留给开发和测试,不作为另一套使用方式。
|
||||
|
||||
## 开发文档
|
||||
|
||||
[docs/architecture.md](docs/architecture.md) 记录并发模型、资源生命周期和必须保持的不变量。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,50 @@
|
||||
# Temp Mail 部署(cloudflare_temp_email)
|
||||
|
||||
目标域名默认:`zhangyunuo.net`
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `~/projects/cloudflare_temp_email` | 上游源码 |
|
||||
| `~/projects/temp-email-deploy/deploy.sh` | 一键部署脚本 |
|
||||
| `~/projects/temp-email-deploy/secrets.env` | 域名 / Admin 密码 / JWT |
|
||||
|
||||
## 一键部署
|
||||
|
||||
```bash
|
||||
# 1) 登录 Cloudflare(交互,浏览器授权)
|
||||
cd ~/projects/cloudflare_temp_email/worker
|
||||
pnpm exec wrangler login
|
||||
|
||||
# 或设置 token:
|
||||
# export CLOUDFLARE_API_TOKEN=你的token
|
||||
|
||||
# 2) 部署
|
||||
~/projects/temp-email-deploy/deploy.sh
|
||||
```
|
||||
|
||||
## 部署后:Email Routing(必做)
|
||||
|
||||
Cloudflare 面板 → `zhangyunuo.net` → Email → Email Routing:
|
||||
|
||||
1. Enable Email Routing(MX 生效)
|
||||
2. Catch-all → **Send to a Worker** → `temp-email-zhangyunuo`
|
||||
3. 会覆盖现有取码 Worker;若要保留取码,改用子域 `mail.zhangyunuo.net`
|
||||
|
||||
## 自测
|
||||
|
||||
```bash
|
||||
source ~/projects/temp-email-deploy/secrets.env
|
||||
BASE=https://temp-email-zhangyunuo.<subdomain>.workers.dev
|
||||
|
||||
curl -sS -X POST "$BASE/admin/new_address" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-admin-auth: $ADMIN_PASSWORD" \
|
||||
-d '{"name":"test1","domain":"zhangyunuo.net","enablePrefix":true}'
|
||||
```
|
||||
|
||||
成功返回 `address` + `jwt` 后,给该地址发一封测试信,再:
|
||||
|
||||
```bash
|
||||
curl -sS "$BASE/api/mails?limit=20&offset=0" \
|
||||
-H "Authorization: Bearer <jwt>"
|
||||
```
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO="${REPO:-$HOME/projects/cloudflare_temp_email}"
|
||||
WORKER_DIR="$REPO/worker"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/secrets.env"
|
||||
|
||||
DOMAIN="${DOMAIN:-zhangyunuo.net}"
|
||||
WORKER_NAME="${WORKER_NAME:-temp-email-zhangyunuo}"
|
||||
D1_NAME="${D1_NAME:-temp-email-zhangyunuo}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:?ADMIN_PASSWORD missing in secrets.env}"
|
||||
JWT_SECRET="${JWT_SECRET:?JWT_SECRET missing in secrets.env}"
|
||||
|
||||
cd "$WORKER_DIR"
|
||||
export PATH="$WORKER_DIR/node_modules/.bin:$PATH"
|
||||
|
||||
if ! command -v wrangler >/dev/null 2>&1; then
|
||||
echo "[!] wrangler 不在 PATH,尝试 pnpm exec"
|
||||
WR="pnpm exec wrangler"
|
||||
else
|
||||
WR="wrangler"
|
||||
fi
|
||||
|
||||
if [ -z "${CLOUDFLARE_API_TOKEN:-}" ]; then
|
||||
# no token: require interactive oauth session
|
||||
if ! $WR whoami 2>/dev/null | grep -qiE 'Account Name|Account ID|email:'; then
|
||||
echo "[!] 未登录 Cloudflare,且未设置 CLOUDFLARE_API_TOKEN"
|
||||
echo " 方式 A(交互): cd $WORKER_DIR && pnpm exec wrangler login"
|
||||
echo " 方式 B(推荐服务器): export CLOUDFLARE_API_TOKEN=你的token"
|
||||
echo " Token 创建: https://dash.cloudflare.com/profile/api-tokens"
|
||||
echo " 权限至少: Account.D1 Edit + Account.Workers Scripts Edit + Account.Workers KV Storage Edit(可选)"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[*] 使用 CLOUDFLARE_API_TOKEN 部署"
|
||||
fi
|
||||
|
||||
echo "[*] 创建/查找 D1: $D1_NAME"
|
||||
CREATE_OUT=$($WR d1 create "$D1_NAME" 2>&1 || true)
|
||||
echo "$CREATE_OUT"
|
||||
|
||||
DB_ID=""
|
||||
if echo "$CREATE_OUT" | grep -q "database_id"; then
|
||||
DB_ID=$(echo "$CREATE_OUT" | sed -n 's/.*database_id *= *"\([^"]*\)".*/\1/p' | head -1)
|
||||
if [ -z "$DB_ID" ]; then
|
||||
DB_ID=$(echo "$CREATE_OUT" | sed -n 's/.*database_id = \([a-f0-9-]*\).*/\1/p' | head -1)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$DB_ID" ]; then
|
||||
DB_ID=$($WR d1 list --json 2>/dev/null | python3 -c '
|
||||
import sys, json
|
||||
name = sys.argv[1]
|
||||
data = json.load(sys.stdin)
|
||||
items = data if isinstance(data, list) else data.get("result", data.get("databases", []))
|
||||
print(next((str(x.get("uuid") or x.get("id") or "") for x in items if x.get("name") == name), ""), end="")
|
||||
' "$D1_NAME" || true)
|
||||
fi
|
||||
|
||||
if [ -z "$DB_ID" ]; then
|
||||
echo "[!] 无法解析 database_id,请检查 wrangler 登录权限后重试"
|
||||
exit 1
|
||||
fi
|
||||
echo "[+] D1 id=$DB_ID"
|
||||
|
||||
python3 - "$DOMAIN" "$WORKER_NAME" "$D1_NAME" "$DB_ID" "$ADMIN_PASSWORD" "$JWT_SECRET" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
domain, name, db_name, db_id, admin, jwt = sys.argv[1:7]
|
||||
text = f'''name = "{name}"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = ""
|
||||
ADMIN_PASSWORDS = ["{admin}"]
|
||||
DEFAULT_DOMAINS = ["{domain}"]
|
||||
DOMAINS = ["{domain}"]
|
||||
JWT_SECRET = "{jwt}"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_AUTO_REPLY = false
|
||||
DEFAULT_LANG = "zh"
|
||||
TITLE = "Temp Mail"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "{db_name}"
|
||||
database_id = "{db_id}"
|
||||
'''
|
||||
Path("wrangler.toml").write_text(text, encoding="utf-8")
|
||||
print("wrote wrangler.toml")
|
||||
PY
|
||||
|
||||
echo "[*] 初始化 schema"
|
||||
$WR d1 execute "$D1_NAME" --remote --file=../db/schema.sql
|
||||
|
||||
echo "[*] 执行 patches"
|
||||
for f in ../db/*.sql; do
|
||||
base=$(basename "$f")
|
||||
[ "$base" = "schema.sql" ] && continue
|
||||
echo " -> $base"
|
||||
$WR d1 execute "$D1_NAME" --remote --file="$f" || true
|
||||
done
|
||||
|
||||
echo "[*] 部署 Worker"
|
||||
$WR deploy --minify
|
||||
|
||||
echo
|
||||
echo "[+] 完成 Worker: $WORKER_NAME"
|
||||
echo "[+] Admin 密码见 $ROOT/secrets.env"
|
||||
echo "[+] 下一步: Cloudflare Email Routing catch-all -> $WORKER_NAME"
|
||||
echo "[+] 注册机配置示例:"
|
||||
cat <<CFG
|
||||
{
|
||||
"email_provider": "cloudflare",
|
||||
"cloudflare_api_base": "https://${WORKER_NAME}.<subdomain>.workers.dev",
|
||||
"cloudflare_api_key": "${ADMIN_PASSWORD}",
|
||||
"cloudflare_auth_mode": "x-admin-auth",
|
||||
"cloudflare_path_accounts": "/admin/new_address",
|
||||
"cloudflare_path_messages": "/api/mails",
|
||||
"defaultDomains": "${DOMAIN}"
|
||||
}
|
||||
CFG
|
||||
@@ -0,0 +1,5 @@
|
||||
DOMAIN=zhangyunuo.net
|
||||
WORKER_NAME=temp-email-zhangyunuo
|
||||
ADMIN_PASSWORD=change-me-long-random
|
||||
JWT_SECRET=change-me-longer-random
|
||||
D1_NAME=temp-email-zhangyunuo
|
||||
@@ -0,0 +1,4 @@
|
||||
8qdlz5qphj@mail.bblbb.com----N29b1101f!a7#2l9tdADA----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiZWU4YjZhODQtMTIxZi00MDNmLTk5OWYtZThmMzRhMTMwYTJmIn0.KFIY6xP2NyrNHvMcg5Vgl2lL-qlqfZojwcBvWC_1c0U
|
||||
kwlu5unggx@mail.bblbb.com----Naeb449aa!a7#F0TRI-gP----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiNjhhNDVmODgtYWI4MS00Y2MxLTljNDMtOGYzMzdkMTY2ZjhhIn0.ncyCG6YbgAWFUYXNTGRjC0EMYwsnjikzBM4moAbFew0
|
||||
fe89ggxrpc@mail.bblbb.com----Nd150aa5e!a7#O8WfmeAD----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiZTNmMjQ3ZWQtYmM4Yy00NWU5LWI5MzktZDkwMDNlZGMxZDcwIn0.uYTGsoBd1UldH13OTBq7409KRGF0DhdUQubk98juI_Q
|
||||
2in9y6cab4@mail.bblbb.com----N25be9b57!a7#8XA6XO6R----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiNjRiNDZjNzctNTZkNi00YWI0LWIxYjctZjMxYjJkZjIxYTRlIn0.z3U3mI-1nIJPcNtEgy6nkrt4E-hT-7xrMqAFik30Gd4
|
||||
@@ -0,0 +1 @@
|
||||
helenramos281@mail.bblbb.com----N9ee81e6c!a7#VCKDb6V6----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiMWU1OTMzYWEtMTJiYi00YjU5LTg1ZDktOTRiZmFhNDliYzU2In0.gnekLoMUj2B5DscbjE5hyC9FoLqkYjjXP9X3usyyqDQ
|
||||
@@ -0,0 +1 @@
|
||||
zoieroberts1869@mail.bblbb.com----Ndf7a20b2!a7#M3gEsHL-----eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiNjRkOWNjNTMtM2Q0YS00MzNlLThhMTctMTIxYmUyODNmZTU4In0.ra0SYJyU146-eMqSQKcG9J63YDVip_tXOxk0SRXp_54
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
. scripts/ensure_runtime.sh
|
||||
ensure_runtime
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 后台拉起 CPA 整备 + acc token 汇总
|
||||
# acpa_watchdog.py → authenticated/ → keys/cpa_ready/ (整备 cli-chat-proxy 凭据)
|
||||
# sync_acc.py → keys/cpa_ready/ → keys/acc.md (只抽 access_token, 一行一个)
|
||||
# 两者都常驻轮询。不用 exec: exec 会替换 shell, 后台子进程变孤儿且 trap 失效。
|
||||
# 脚本退出(正常 / Ctrl-C / SIGTERM)时一并回收, 避免孤儿。
|
||||
# ---------------------------------------------------------------------------
|
||||
WATCHDOG="keys/acpa_watchdog.py"
|
||||
SYNC_ACC="keys/sync_acc.py"
|
||||
WATCHDOG_PID=""
|
||||
SYNC_PID=""
|
||||
|
||||
cleanup() {
|
||||
for pid in "${SYNC_PID:-}" "${WATCHDOG_PID:-}"; do
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
mkdir -p logs
|
||||
|
||||
if [ -f "$WATCHDOG" ]; then
|
||||
.venv/bin/python "$WATCHDOG" </dev/null >>logs/watchdog.log 2>&1 &
|
||||
WATCHDOG_PID=$!
|
||||
echo "[auth-service] watchdog 已拉起 (pid=$WATCHDOG_PID, 日志 logs/watchdog.log)"
|
||||
else
|
||||
echo "[auth-service] 未找到 $WATCHDOG, 跳过自动整备" >&2
|
||||
fi
|
||||
|
||||
if [ -f "$SYNC_ACC" ]; then
|
||||
.venv/bin/python "$SYNC_ACC" </dev/null >>logs/sync_acc.log 2>&1 &
|
||||
SYNC_PID=$!
|
||||
echo "[auth-service] sync_acc 已拉起 (pid=$SYNC_PID, 日志 logs/sync_acc.log → keys/acc.md)"
|
||||
else
|
||||
echo "[auth-service] 未找到 $SYNC_ACC, 跳过 acc.md 汇总" >&2
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前台跑认证服务 (注册出货)。退出码透传; trap cleanup 在 exit 时兜底回收后台进程。
|
||||
# ---------------------------------------------------------------------------
|
||||
set +e
|
||||
.venv/bin/python -m xai_enroller.service "$@"
|
||||
RC=$?
|
||||
set -e
|
||||
exit "$RC"
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from curl_cffi import requests
|
||||
|
||||
|
||||
def extract_code(text: str, subject: str = "") -> Optional[str]:
|
||||
if subject:
|
||||
m = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", subject, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
for p in [
|
||||
r"verification\s+code[:\s]+(\d{4,8})",
|
||||
r"your\s+code[:\s]+(\d{4,8})",
|
||||
r"confirm(?:ation)?\s+code[:\s]+(\d{4,8})",
|
||||
]:
|
||||
m = re.search(p, text, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def json_or_text(resp: requests.Response) -> Tuple[Optional[Dict[str, Any]], str]:
|
||||
try:
|
||||
data = resp.json()
|
||||
return data, ""
|
||||
except Exception:
|
||||
return None, (resp.text or "")[:400]
|
||||
|
||||
|
||||
def generate_username(length: int = 10) -> str:
|
||||
"""生成 cloudflare_temp_email admin API 需要的随机邮箱名称。"""
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
return "".join(secrets.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
def normalize_path(path: str, default_path: str) -> str:
|
||||
"""标准化 API 路径,避免漏写开头斜杠。"""
|
||||
raw = (path or default_path).strip() or default_path
|
||||
return raw if raw.startswith("/") else f"/{raw}"
|
||||
|
||||
|
||||
def build_auth_headers(auth_mode: str, api_key: str, content_type: bool = False) -> Dict[str, str]:
|
||||
"""按调试参数构造 Cloudflare 临时邮箱接口鉴权请求头。"""
|
||||
headers = {"Content-Type": "application/json"} if content_type else {}
|
||||
key = (api_key or "").strip()
|
||||
mode = (auth_mode or "none").strip().lower()
|
||||
if not key:
|
||||
return headers
|
||||
if mode == "x-admin-auth":
|
||||
headers["x-admin-auth"] = key
|
||||
elif mode == "x-api-key":
|
||||
headers["X-API-Key"] = key
|
||||
elif mode == "bearer":
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
return headers
|
||||
|
||||
|
||||
def create_address(
|
||||
api_base: str,
|
||||
auth_mode: str = "none",
|
||||
api_key: str = "",
|
||||
create_path: str = "/api/new_address",
|
||||
domain: str = "",
|
||||
name: str = "",
|
||||
) -> Tuple[str, str]:
|
||||
"""创建 Cloudflare 临时邮箱地址,支持匿名 API 和 admin API。"""
|
||||
path = normalize_path(create_path, "/api/new_address")
|
||||
is_admin_create = path.rstrip("/").lower() == "/admin/new_address"
|
||||
if is_admin_create:
|
||||
payload: Dict[str, Any] = {
|
||||
"name": name.strip() if name.strip() else generate_username(),
|
||||
"enablePrefix": True,
|
||||
}
|
||||
if domain.strip():
|
||||
payload["domain"] = domain.strip()
|
||||
headers = build_auth_headers(auth_mode, api_key, content_type=True)
|
||||
else:
|
||||
payload = {}
|
||||
if domain.strip():
|
||||
payload["domain"] = domain.strip()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
resp = requests.post(
|
||||
f"{api_base.rstrip('/')}{path}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=20,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data, raw = json_or_text(resp)
|
||||
if not data:
|
||||
raise RuntimeError(f"{path} 非JSON: {raw}")
|
||||
address = str(data.get("address", "")).strip()
|
||||
jwt = str(data.get("jwt", "")).strip()
|
||||
if not address or not jwt:
|
||||
raise RuntimeError(f"{path} 缺少 address/jwt: {data}")
|
||||
return address, jwt
|
||||
|
||||
|
||||
def fetch_box(api_base: str, jwt: str, path: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
resp = requests.get(
|
||||
f"{api_base.rstrip('/')}{path}",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
timeout=20,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
return []
|
||||
data, _ = json_or_text(resp)
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
if isinstance(data.get("results"), list):
|
||||
return data["results"]
|
||||
if isinstance(data.get("data"), list):
|
||||
return data["data"]
|
||||
if isinstance(data.get("messages"), list):
|
||||
return data["messages"]
|
||||
return []
|
||||
|
||||
|
||||
def probe_all_boxes(api_base: str, jwt: str) -> List[Tuple[str, List[Dict[str, Any]]]]:
|
||||
probes = [
|
||||
("/api/mails", {"limit": 20, "offset": 0}),
|
||||
("/api/sendbox", {"limit": 20, "offset": 0}),
|
||||
("/api/mails", {"limit": 20, "offset": 0, "box": "trash"}),
|
||||
("/api/mails", {"limit": 20, "offset": 0, "folder": "trash"}),
|
||||
("/api/mails", {"limit": 20, "offset": 0, "deleted": "1"}),
|
||||
("/api/mails", {"limit": 20, "offset": 0, "status": "deleted"}),
|
||||
]
|
||||
out: List[Tuple[str, List[Dict[str, Any]]]] = []
|
||||
for path, params in probes:
|
||||
mails = fetch_box(api_base, jwt, path, params)
|
||||
out.append((f"{path}?{params}", mails))
|
||||
return out
|
||||
|
||||
|
||||
def get_detail(api_base: str, jwt: str, mail_id: Any) -> Dict[str, Any]:
|
||||
for url in [
|
||||
f"{api_base.rstrip('/')}/api/mail/{mail_id}",
|
||||
f"{api_base.rstrip('/')}/api/mails/{mail_id}",
|
||||
]:
|
||||
try:
|
||||
resp = requests.get(url, headers={"Authorization": f"Bearer {jwt}"}, timeout=20)
|
||||
if resp.status_code >= 400:
|
||||
continue
|
||||
data, _ = json_or_text(resp)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def flatten_mail_text(item: Dict[str, Any], detail: Dict[str, Any]) -> Tuple[str, str]:
|
||||
subject = str(item.get("subject") or detail.get("subject") or "")
|
||||
parts: List[str] = []
|
||||
for src in (item, detail):
|
||||
for k in ("text", "raw", "content", "intro", "body", "snippet"):
|
||||
v = src.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
parts.append(v)
|
||||
html_val = src.get("html")
|
||||
if isinstance(html_val, str):
|
||||
html_val = [html_val]
|
||||
if isinstance(html_val, list):
|
||||
for h in html_val:
|
||||
if isinstance(h, str):
|
||||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||||
return subject, "\n".join(parts)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--api-base", required=True)
|
||||
ap.add_argument("--address", default="")
|
||||
ap.add_argument("--credential", default="")
|
||||
ap.add_argument("--auth-mode", default="none", choices=["none", "bearer", "x-api-key", "x-admin-auth"])
|
||||
ap.add_argument("--api-key", default="")
|
||||
ap.add_argument("--create-path", default="/api/new_address")
|
||||
ap.add_argument("--domain", default="")
|
||||
ap.add_argument("--name", default="")
|
||||
ap.add_argument("--timeout", type=int, default=180)
|
||||
ap.add_argument("--interval", type=int, default=3)
|
||||
args = ap.parse_args()
|
||||
|
||||
address = args.address.strip()
|
||||
credential = args.credential.strip()
|
||||
if not credential:
|
||||
address, credential = create_address(
|
||||
args.api_base,
|
||||
auth_mode=args.auth_mode,
|
||||
api_key=args.api_key,
|
||||
create_path=args.create_path,
|
||||
domain=args.domain,
|
||||
name=args.name,
|
||||
)
|
||||
print(f"[NEW] address={address}")
|
||||
print(f"[NEW] credential(jwt)={credential}")
|
||||
else:
|
||||
print(f"[USE] address={address or '(unknown, from credential)'}")
|
||||
|
||||
deadline = time.time() + max(args.timeout, 1)
|
||||
seen_ids = set()
|
||||
while time.time() < deadline:
|
||||
boxes = probe_all_boxes(args.api_base, credential)
|
||||
total = 0
|
||||
for name, mails in boxes:
|
||||
if mails:
|
||||
print(f"[BOX] {name} -> {len(mails)}")
|
||||
total += len(mails)
|
||||
for m in mails:
|
||||
mail_id = m.get("id") or m.get("mail_id")
|
||||
if not mail_id or mail_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(mail_id)
|
||||
detail = get_detail(args.api_base, credential, mail_id)
|
||||
subj, text = flatten_mail_text(m, detail)
|
||||
code = extract_code(text, subj)
|
||||
print(f"[MAIL] id={mail_id} subject={subj!r} code={code!r}")
|
||||
if code:
|
||||
print(f"[FOUND] {code}")
|
||||
return
|
||||
if total == 0:
|
||||
print("[INFO] no mails yet")
|
||||
time.sleep(max(args.interval, 1))
|
||||
print("[TIMEOUT] no code found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,61 +0,0 @@
|
||||
// ============================================================
|
||||
// Cloudflare Email Worker — 把收到的邮件 POST 到你的 webhook
|
||||
// 用于 grok-free-register 的「自建邮箱模式」(EMAIL_MODE=custom)
|
||||
//
|
||||
// 链路: 发件方 → Cloudflare Email Routing → 本 Worker → 你的 webhook(email_server.py)
|
||||
//
|
||||
// ⚠️ 重要(踩过的坑):env.WEBHOOK_URL 必须是【域名】,不能用【裸 IP】!
|
||||
// Cloudflare Workers 的 fetch 不允许直接请求裸 IP,会返回
|
||||
// "error code: 1003 (Direct IP access not allowed)" 并被静默吞掉
|
||||
// —— Worker 看起来成功、你的服务器却收不到任何请求。
|
||||
// 做法:给服务器 IP 加一条 DNS A 记录(如 hook.example.com,灰云/DNS-only),
|
||||
// WEBHOOK_URL 填 http://hook.example.com:8080/webhook。
|
||||
//
|
||||
// 部署:
|
||||
// npm create cloudflare@latest cf-mail-webhook
|
||||
// cd cf-mail-webhook && npm i postal-mime
|
||||
// # 用本文件替换 src/index.js
|
||||
// npx wrangler secret put WEBHOOK_URL # 输入 http://hook.example.com:8080/webhook
|
||||
// npx wrangler secret put WEBHOOK_TOKEN # 可选,鉴权用
|
||||
// npx wrangler deploy
|
||||
// 然后在 Cloudflare 后台:Email → Email Routing → Routing rules →
|
||||
// Catch-all → 动作选「Send to a Worker」→ 选本 Worker。
|
||||
// (子域名收信需在 Email Routing 单独启用该子域并配它自己的 catch-all。)
|
||||
// ============================================================
|
||||
import PostalMime from "postal-mime";
|
||||
|
||||
function trim(s, max = 20000) {
|
||||
if (!s) return "";
|
||||
return s.length > max ? s.slice(0, max) + "\n...[truncated]" : s;
|
||||
}
|
||||
|
||||
export default {
|
||||
async email(message, env, ctx) {
|
||||
if (!env.WEBHOOK_URL) {
|
||||
console.error("WEBHOOK_URL 未配置 (npx wrangler secret put WEBHOOK_URL)");
|
||||
return;
|
||||
}
|
||||
const parsed = await PostalMime.parse(message.raw);
|
||||
const payload = {
|
||||
from: message.from,
|
||||
to: message.to,
|
||||
subject: parsed.subject || message.headers.get("subject") || "",
|
||||
text: trim(parsed.text || ""),
|
||||
html: trim(parsed.html || ""),
|
||||
};
|
||||
|
||||
const headers = { "content-type": "application/json" };
|
||||
if (env.WEBHOOK_TOKEN) headers["x-webhook-token"] = env.WEBHOOK_TOKEN;
|
||||
|
||||
const res = await fetch(env.WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
// 日志只保留状态码,避免把 webhook 地址或上游响应写入 Worker 日志。
|
||||
console.log(`webhook delivery -> ${res.status}`);
|
||||
if (!res.ok) {
|
||||
console.error(`webhook delivery failed: ${res.status}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"duckmail_api_key": "",
|
||||
"cloudflare_api_base": "https://mail.kanxue.workers.dev",
|
||||
"cloudflare_api_key": "dhsqj.12489",
|
||||
"cloudflare_auth_mode": "x-admin-auth",
|
||||
"cloudflare_path_domains": "/api/domains",
|
||||
"cloudflare_path_accounts": "/admin/new_address",
|
||||
"cloudflare_path_token": "/api/token",
|
||||
"cloudflare_path_messages": "/api/mails",
|
||||
"proxy": "",
|
||||
"proxy_pool_enabled": false,
|
||||
"proxy_managed": false,
|
||||
"proxy_db": "./proxy_alive.json",
|
||||
"proxy_pool": [],
|
||||
"proxy_pool_file": "./proxies.txt",
|
||||
"proxy_pool_mode": "weighted",
|
||||
"proxy_rotate_each_account": true,
|
||||
"proxy_mail_direct": true,
|
||||
"proxy_source_pick_k": 3,
|
||||
"proxy_harvest_per_source": 150,
|
||||
"proxy_harvest_total": 400,
|
||||
"proxy_probe_workers": 40,
|
||||
"proxy_probe_timeout": 12,
|
||||
"proxy_max_alive": 200,
|
||||
"proxy_min_alive": 15,
|
||||
"proxy_harvest_interval_sec": 600,
|
||||
"proxy_check_interval_sec": 300,
|
||||
"proxy_source_disable_after": 3,
|
||||
"enable_nsfw": true,
|
||||
"register_count": 3,
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
||||
"cpa_export_enabled": true,
|
||||
"cpa_auth_dir": "./cpa_auths",
|
||||
"cpa_base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"cpa_push_enabled": false,
|
||||
"cpa_remote_base": "https://cpa.nopj.cn",
|
||||
"cpa_remote_secret": "DNznuRVoBhDT2SpSAyWw",
|
||||
"cpa_remote_verify_tls": true,
|
||||
"cpa_push_proxy": "",
|
||||
"cpa_push_required": false,
|
||||
"cpa_prefer_sso_oauth": true,
|
||||
"cpa_require_referrer": true,
|
||||
"cpa_allow_device_fallback": false,
|
||||
"mint_proxy": "",
|
||||
"mint_timeout_sec": 300,
|
||||
"mint_required": false,
|
||||
"email_provider": "bblbb",
|
||||
"bblbb_api_base": "https://mail.bblbb.com",
|
||||
"bblbb_api_key": "89cb0a30c899f9882e98890cd8cd9c6a",
|
||||
"bblbb_domain": "mail.bblbb.com",
|
||||
"bblbb_poll_interval": 2.0,
|
||||
"defaultDomains": "mail.bblbb.com",
|
||||
"cf_worker_domains": "",
|
||||
"gmail_worker_enabled": false,
|
||||
"gmail_worker_url": "https://grok-xai-mail-codes.nopjcn.workers.dev",
|
||||
"gmail_worker_api_key": "ZhaoChao19950510",
|
||||
"gmail_worker_timeout_sec": 20,
|
||||
"gmail_worker_poll_interval": 0.4,
|
||||
"gmail_imap_fallback": false,
|
||||
"hide_window": false,
|
||||
"block_media_fonts": false
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""CPA (CLIProxyAPI) xai auth 组装 / 写盘 / 推送远端。"""
|
||||
|
||||
from .client import CpaPushError, push_auth_file
|
||||
from .push_queue import (
|
||||
PENDING_NAME,
|
||||
flush_push_pending,
|
||||
load_pending,
|
||||
push_with_queue,
|
||||
record_push_failure,
|
||||
remove_push_pending,
|
||||
)
|
||||
from .schema import (
|
||||
API_BASE_URL,
|
||||
CLI_BASE_URL,
|
||||
CLIENT_ID,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_HEADERS,
|
||||
GROK_TOKEN_UA,
|
||||
GROK_VERSION,
|
||||
REDIRECT_URI,
|
||||
TOKEN_ENDPOINT,
|
||||
build_cpa_xai_auth,
|
||||
credential_file_name,
|
||||
expired_from_access_token,
|
||||
)
|
||||
from .writer import write_cpa_xai_auth
|
||||
|
||||
__all__ = [
|
||||
"API_BASE_URL",
|
||||
"CLI_BASE_URL",
|
||||
"CLIENT_ID",
|
||||
"DEFAULT_BASE_URL",
|
||||
"DEFAULT_HEADERS",
|
||||
"GROK_TOKEN_UA",
|
||||
"GROK_VERSION",
|
||||
"PENDING_NAME",
|
||||
"REDIRECT_URI",
|
||||
"TOKEN_ENDPOINT",
|
||||
"CpaPushError",
|
||||
"build_cpa_xai_auth",
|
||||
"credential_file_name",
|
||||
"expired_from_access_token",
|
||||
"flush_push_pending",
|
||||
"load_pending",
|
||||
"push_auth_file",
|
||||
"push_with_queue",
|
||||
"record_push_failure",
|
||||
"remove_push_pending",
|
||||
"write_cpa_xai_auth",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""把 CPA xai auth JSON 推送到远端 CLIProxyAPI 导入。
|
||||
|
||||
对齐 router-for-me/CLIProxyAPI 管理 API:
|
||||
POST {base}/v0/management/auth-files?name=xai-<email>.json
|
||||
X-Management-Key: <secret-key> (管理员密钥)
|
||||
body = 原始 auth JSON
|
||||
成功 -> 200 {"status":"ok"}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
UPLOAD_PATH = "/v0/management/auth-files"
|
||||
|
||||
|
||||
class CpaPushError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _opener(proxy: str | None, verify_tls: bool) -> urllib.request.OpenerDirector:
|
||||
handlers: list[Any] = []
|
||||
if proxy:
|
||||
handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
|
||||
else:
|
||||
# 远端管理口通常在内网/直连,显式禁用系统代理,避免误走 mint 代理
|
||||
handlers.append(urllib.request.ProxyHandler({}))
|
||||
ctx = None
|
||||
if not verify_tls:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
if ctx is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
||||
return urllib.request.build_opener(*handlers)
|
||||
|
||||
|
||||
def push_auth_file(
|
||||
*,
|
||||
remote_base: str,
|
||||
secret: str,
|
||||
filename: str,
|
||||
payload: dict | bytes | str,
|
||||
proxy: str | None = None,
|
||||
verify_tls: bool = True,
|
||||
timeout: float = 30.0,
|
||||
) -> tuple[bool, int, str]:
|
||||
"""把一个 auth 文件推送到远端 CLIProxyAPI。返回 (ok, status, text)。"""
|
||||
base = (remote_base or "").strip().rstrip("/")
|
||||
if not base:
|
||||
raise CpaPushError("cpa_remote_base 未配置")
|
||||
if not secret:
|
||||
raise CpaPushError("cpa_remote_secret 未配置")
|
||||
if "://" not in base:
|
||||
base = "http://" + base
|
||||
if not filename.endswith(".json"):
|
||||
filename += ".json"
|
||||
|
||||
if isinstance(payload, dict):
|
||||
body = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
elif isinstance(payload, str):
|
||||
body = payload.encode("utf-8")
|
||||
else:
|
||||
body = payload
|
||||
|
||||
url = f"{base}{UPLOAD_PATH}?name={urllib.parse.quote(filename)}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"X-Management-Key": secret,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "grok-reg-cpa-push/1.0",
|
||||
},
|
||||
)
|
||||
opener = _opener(proxy, verify_tls)
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
status = int(getattr(resp, "status", 200) or 200)
|
||||
text = resp.read().decode("utf-8", errors="replace")
|
||||
return (200 <= status < 300), status, text
|
||||
except urllib.error.HTTPError as e:
|
||||
text = e.read().decode("utf-8", errors="replace")
|
||||
return False, int(e.code), text
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise CpaPushError(str(e)) from e
|
||||
@@ -0,0 +1,279 @@
|
||||
"""CPA 远端推送失败队列。
|
||||
|
||||
本地 auth 已写成功但 POST /v0/management/auth-files 失败时,把文件名记入
|
||||
`cpa_push_pending.txt`;下次开启推送时先重试队列里的文件,成功则剔除。
|
||||
|
||||
行格式(兼容只写文件名):
|
||||
xai-email@x.com.json----reason----unix_ts
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .client import CpaPushError, push_auth_file
|
||||
|
||||
PENDING_NAME = "cpa_push_pending.txt"
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def pending_path(out_dir: str | Path) -> Path:
|
||||
return Path(out_dir).expanduser().resolve() / PENDING_NAME
|
||||
|
||||
|
||||
def _normalize_filename(name: str) -> str:
|
||||
name = (name or "").strip().replace("\\", "/").split("/")[-1]
|
||||
if name and not name.endswith(".json"):
|
||||
name += ".json"
|
||||
return name
|
||||
|
||||
|
||||
def _parse_line(line: str) -> tuple[str, str, int] | None:
|
||||
line = (line or "").strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
parts = line.split("----")
|
||||
filename = _normalize_filename(parts[0])
|
||||
if not filename:
|
||||
return None
|
||||
reason = parts[1].strip() if len(parts) > 1 else ""
|
||||
ts = 0
|
||||
if len(parts) > 2:
|
||||
try:
|
||||
ts = int(parts[2].strip())
|
||||
except ValueError:
|
||||
ts = 0
|
||||
return filename, reason, ts
|
||||
|
||||
|
||||
def load_pending(out_dir: str | Path) -> list[tuple[str, str, int]]:
|
||||
"""按文件名去重,保留最后一次记录。"""
|
||||
path = pending_path(out_dir)
|
||||
if not path.is_file():
|
||||
return []
|
||||
ordered: dict[str, tuple[str, str, int]] = {}
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return []
|
||||
for line in text.splitlines():
|
||||
row = _parse_line(line)
|
||||
if row:
|
||||
ordered[row[0]] = row
|
||||
return list(ordered.values())
|
||||
|
||||
|
||||
def _rewrite(out_dir: str | Path, rows: list[tuple[str, str, int]]) -> None:
|
||||
out = Path(out_dir).expanduser().resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
path = pending_path(out)
|
||||
lines = [
|
||||
f"{fn}----{reason}----{ts or int(time.time())}\n"
|
||||
for fn, reason, ts in rows
|
||||
if fn
|
||||
]
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text("".join(lines), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def record_push_failure(
|
||||
out_dir: str | Path,
|
||||
filename: str,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""推送失败:加入待重试队列(同名覆盖为最新原因)。"""
|
||||
fn = _normalize_filename(filename)
|
||||
if not fn:
|
||||
return
|
||||
reason = (reason or "").replace("\n", " ").replace("\r", " ").strip()[:300]
|
||||
now = int(time.time())
|
||||
with _lock:
|
||||
rows = {r[0]: r for r in load_pending(out_dir)}
|
||||
rows[fn] = (fn, reason, now)
|
||||
_rewrite(out_dir, list(rows.values()))
|
||||
|
||||
|
||||
def remove_push_pending(out_dir: str | Path, filename: str) -> None:
|
||||
"""推送成功:从队列移除。"""
|
||||
fn = _normalize_filename(filename)
|
||||
if not fn:
|
||||
return
|
||||
with _lock:
|
||||
rows = [r for r in load_pending(out_dir) if r[0] != fn]
|
||||
path = pending_path(out_dir)
|
||||
if not rows:
|
||||
if path.is_file():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
_rewrite(out_dir, [])
|
||||
return
|
||||
_rewrite(out_dir, rows)
|
||||
|
||||
|
||||
def push_local_auth_file(
|
||||
out_dir: str | Path,
|
||||
filename: str,
|
||||
*,
|
||||
remote_base: str,
|
||||
secret: str,
|
||||
proxy: str | None = None,
|
||||
verify_tls: bool = True,
|
||||
) -> tuple[bool, int, str]:
|
||||
"""读取本地 xai-*.json 并推送。文件缺失返回 (False, 0, 'missing')."""
|
||||
fn = _normalize_filename(filename)
|
||||
path = Path(out_dir).expanduser().resolve() / fn
|
||||
if not path.is_file():
|
||||
return False, 0, "missing local file"
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, 0, f"read/json: {exc}"
|
||||
return push_auth_file(
|
||||
remote_base=remote_base,
|
||||
secret=secret,
|
||||
filename=fn,
|
||||
payload=payload,
|
||||
proxy=proxy,
|
||||
verify_tls=verify_tls,
|
||||
)
|
||||
|
||||
|
||||
def flush_push_pending(
|
||||
out_dir: str | Path,
|
||||
*,
|
||||
remote_base: str,
|
||||
secret: str,
|
||||
proxy: str | None = None,
|
||||
verify_tls: bool = True,
|
||||
log: Callable[[str], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""重试队列中所有仍存在的本地 auth 文件。成功剔除,失败保留。"""
|
||||
log = log or (lambda m: None)
|
||||
remote_base = (remote_base or "").strip()
|
||||
secret = (secret or "").strip()
|
||||
if not remote_base or not secret:
|
||||
return {"ok": 0, "fail": 0, "missing": 0, "skipped": True, "reason": "no remote config"}
|
||||
|
||||
with _lock:
|
||||
pending = load_pending(out_dir)
|
||||
|
||||
if not pending:
|
||||
return {"ok": 0, "fail": 0, "missing": 0, "total": 0}
|
||||
|
||||
log(f"[cpa] 重试待推送队列 count={len(pending)}")
|
||||
ok_n = fail_n = missing_n = 0
|
||||
|
||||
for filename, old_reason, _ts in pending:
|
||||
try:
|
||||
ok, status, text = push_local_auth_file(
|
||||
out_dir,
|
||||
filename,
|
||||
remote_base=remote_base,
|
||||
secret=secret,
|
||||
proxy=proxy,
|
||||
verify_tls=verify_tls,
|
||||
)
|
||||
except CpaPushError as exc:
|
||||
ok, status, text = False, -1, str(exc)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
ok, status, text = False, -1, str(exc)
|
||||
|
||||
if status == 0 and text.startswith("missing"):
|
||||
missing_n += 1
|
||||
remove_push_pending(out_dir, filename)
|
||||
log(f"[cpa] 待推送文件已不存在,移出队列: {filename}")
|
||||
continue
|
||||
|
||||
if ok:
|
||||
ok_n += 1
|
||||
remove_push_pending(out_dir, filename)
|
||||
log(f"[cpa] 队列重推成功: {filename} (HTTP {status})")
|
||||
else:
|
||||
fail_n += 1
|
||||
reason = f"HTTP {status}: {text[:200]}" if status > 0 else text[:200]
|
||||
record_push_failure(out_dir, filename, reason or old_reason)
|
||||
log(f"[!] [cpa] 队列重推失败: {filename} {reason}")
|
||||
|
||||
return {
|
||||
"ok": ok_n,
|
||||
"fail": fail_n,
|
||||
"missing": missing_n,
|
||||
"total": len(pending),
|
||||
"remaining": len(load_pending(out_dir)),
|
||||
}
|
||||
|
||||
|
||||
def push_with_queue(
|
||||
out_dir: str | Path,
|
||||
filename: str,
|
||||
payload: dict | bytes | str | None,
|
||||
*,
|
||||
remote_base: str,
|
||||
secret: str,
|
||||
proxy: str | None = None,
|
||||
verify_tls: bool = True,
|
||||
flush_first: bool = True,
|
||||
log: Callable[[str], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""推送当前文件;可选先 flush 队列。失败记入 pending,成功移出。
|
||||
|
||||
返回 {pushed, push_status?, push_error?, flush?}。
|
||||
"""
|
||||
log = log or (lambda m: None)
|
||||
result: dict[str, Any] = {"pushed": False}
|
||||
remote_base = (remote_base or "").strip()
|
||||
secret = (secret or "").strip()
|
||||
if not remote_base or not secret:
|
||||
result["push_error"] = "cpa_remote_base/secret 未配置"
|
||||
return result
|
||||
|
||||
if flush_first:
|
||||
result["flush"] = flush_push_pending(
|
||||
out_dir,
|
||||
remote_base=remote_base,
|
||||
secret=secret,
|
||||
proxy=proxy,
|
||||
verify_tls=verify_tls,
|
||||
log=log,
|
||||
)
|
||||
|
||||
fn = _normalize_filename(filename)
|
||||
try:
|
||||
if payload is None:
|
||||
ok, status, text = push_local_auth_file(
|
||||
out_dir,
|
||||
fn,
|
||||
remote_base=remote_base,
|
||||
secret=secret,
|
||||
proxy=proxy,
|
||||
verify_tls=verify_tls,
|
||||
)
|
||||
else:
|
||||
ok, status, text = push_auth_file(
|
||||
remote_base=remote_base,
|
||||
secret=secret,
|
||||
filename=fn,
|
||||
payload=payload,
|
||||
proxy=proxy,
|
||||
verify_tls=verify_tls,
|
||||
)
|
||||
result["push_status"] = status
|
||||
if ok:
|
||||
result["pushed"] = True
|
||||
remove_push_pending(out_dir, fn)
|
||||
log(f"[Debug] 已推送远端 (HTTP {status}): {fn}")
|
||||
else:
|
||||
result["push_error"] = text[:300]
|
||||
record_push_failure(out_dir, fn, f"HTTP {status}: {text[:200]}")
|
||||
log(f"[!] [cpa] 推送远端失败 HTTP {status}: {text[:200]}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["push_error"] = str(exc)
|
||||
record_push_failure(out_dir, fn, str(exc))
|
||||
log(f"[!] [cpa] 推送远端异常: {exc}")
|
||||
return result
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
"""CPA (CLIProxyAPI) xAI auth JSON 组装,对齐 router-for-me/CLIProxyAPI
|
||||
internal/auth/xai/token.go 的 TokenStorage 结构。
|
||||
|
||||
生成的 xai-<email>.json 可被 CLIProxyAPI 直接加载;CLIProxyAPI 请求 grok 时会
|
||||
自带 x-grok-client-version 头(xai_executor.go 硬编码 0.2.93),免费 Build 账号
|
||||
不会 426。
|
||||
|
||||
2026-07:AccessToken 必须同时满足:
|
||||
1) claim referrer=grok-build
|
||||
2) scope 含 grok-cli:access
|
||||
否则 cli-chat-proxy 会返回:
|
||||
WKE=unauthorized:grok-cli-token-auth-required
|
||||
铸造请走 oidc_mint.oauth_code(SSO→Authorization Code + PKCE)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
# 对齐 CLIProxyAPI internal/auth/xai/types.go
|
||||
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
ISSUER = "https://auth.x.ai"
|
||||
TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token"
|
||||
REDIRECT_URI = "http://127.0.0.1:56121/callback"
|
||||
# 免费 Grok 4.5 (Build) 走 cli-chat-proxy;付费 API 用 https://api.x.ai/v1
|
||||
CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1"
|
||||
API_BASE_URL = "https://api.x.ai/v1"
|
||||
DEFAULT_BASE_URL = CLI_BASE_URL
|
||||
|
||||
GROK_VERSION = "0.2.93"
|
||||
GROK_TOKEN_UA = (
|
||||
f"grok-pager/{GROK_VERSION} grok-shell/{GROK_VERSION} (linux; x86_64)"
|
||||
)
|
||||
DEFAULT_HEADERS = {
|
||||
"x-grok-client-version": GROK_VERSION,
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"User-Agent": GROK_TOKEN_UA,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_file_segment(value: str) -> str:
|
||||
"""对齐 CPA CredentialFileName 的清洗规则。"""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
out: list[str] = []
|
||||
for ch in value:
|
||||
if (
|
||||
("a" <= ch <= "z")
|
||||
or ("A" <= ch <= "Z")
|
||||
or ("0" <= ch <= "9")
|
||||
or ch in {"@", ".", "_", "-"}
|
||||
):
|
||||
out.append(ch)
|
||||
else:
|
||||
out.append("-")
|
||||
return "".join(out).strip("-")
|
||||
|
||||
|
||||
def credential_file_name(email: str = "", sub: str = "") -> str:
|
||||
"""返回 CPA 认证文件名:xai-<email>.json。"""
|
||||
email_s = _sanitize_file_segment(email)
|
||||
if email_s:
|
||||
return f"xai-{email_s}.json"
|
||||
sub_s = _sanitize_file_segment(sub)
|
||||
if sub_s:
|
||||
return f"xai-{sub_s}.json"
|
||||
ts = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
|
||||
return f"xai-{ts}.json"
|
||||
|
||||
|
||||
def _jwt_payload(token: str) -> dict[str, Any]:
|
||||
parts = (token or "").split(".")
|
||||
if len(parts) < 2:
|
||||
raise ValueError("not a JWT")
|
||||
seg = parts[1]
|
||||
seg += "=" * (-len(seg) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(seg))
|
||||
|
||||
|
||||
def expired_from_access_token(access_token: str) -> tuple[str, int, str, str]:
|
||||
"""从 access_token 解析 (expired_rfc3339, expires_in, sub, referrer)。"""
|
||||
pl = _jwt_payload(access_token)
|
||||
exp = int(pl["exp"])
|
||||
iat = int(pl["iat"]) if pl.get("iat") is not None else exp - 21600
|
||||
expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
sub = str(pl.get("sub") or pl.get("principal_id") or "").strip()
|
||||
referrer = str(pl.get("referrer") or "").strip()
|
||||
return expired, max(exp - iat, 0), sub, referrer
|
||||
|
||||
|
||||
def build_cpa_xai_auth(
|
||||
*,
|
||||
email: str,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
sub: str | None = None,
|
||||
id_token: str | None = None,
|
||||
expires_in: int | None = None,
|
||||
expired: str | None = None,
|
||||
last_refresh: str | None = None,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
token_endpoint: str = TOKEN_ENDPOINT,
|
||||
redirect_uri: str = REDIRECT_URI,
|
||||
sso: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
disabled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""组装一个 CLIProxyAPI 可导入的 xai auth 对象(TokenStorage 字段)。"""
|
||||
access_token = (access_token or "").strip()
|
||||
refresh_token = (refresh_token or "").strip()
|
||||
if not access_token:
|
||||
raise ValueError("access_token is required")
|
||||
if not refresh_token:
|
||||
raise ValueError("refresh_token is required (CPA 无法在缺 refresh_token 时续期)")
|
||||
|
||||
referrer = ""
|
||||
try:
|
||||
exp_s, exp_in, sub_jwt, referrer = expired_from_access_token(access_token)
|
||||
except Exception:
|
||||
exp_s, exp_in, sub_jwt, referrer = "", 21600, "", ""
|
||||
|
||||
if not expired:
|
||||
expired = exp_s
|
||||
if expires_in is None:
|
||||
expires_in = exp_in or 21600
|
||||
if not sub:
|
||||
sub = sub_jwt
|
||||
if not last_refresh:
|
||||
last_refresh = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# 从 id_token 补 email
|
||||
email = (email or "").strip()
|
||||
if not email and id_token:
|
||||
try:
|
||||
idp = _jwt_payload(id_token)
|
||||
email = str(idp.get("email") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
||||
if not re.search(r"/v1$", base_url) and base_url.endswith("cli-chat-proxy.grok.com"):
|
||||
base_url = base_url + "/v1"
|
||||
|
||||
hdrs = dict(DEFAULT_HEADERS)
|
||||
if headers:
|
||||
hdrs.update({str(k): str(v) for k, v in headers.items() if k and v is not None})
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int(expires_in),
|
||||
"expired": expired,
|
||||
"last_refresh": last_refresh,
|
||||
"email": email,
|
||||
"sub": (sub or "").strip(),
|
||||
"base_url": base_url,
|
||||
"token_endpoint": token_endpoint,
|
||||
"redirect_uri": redirect_uri,
|
||||
"disabled": bool(disabled),
|
||||
"headers": hdrs,
|
||||
}
|
||||
if id_token:
|
||||
payload["id_token"] = id_token.strip()
|
||||
if sso:
|
||||
payload["sso"] = str(sso).strip()
|
||||
if referrer:
|
||||
payload["referrer"] = referrer
|
||||
return payload
|
||||
@@ -0,0 +1,56 @@
|
||||
"""原子写 CPA xAI auth 文件(mode 0600)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .schema import credential_file_name
|
||||
|
||||
|
||||
def write_cpa_xai_auth(
|
||||
auth_dir: str | Path,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
filename: str | None = None,
|
||||
) -> Path:
|
||||
"""把 payload 原子写到 auth_dir/xai-<email>.json,返回最终路径。"""
|
||||
auth_dir = Path(auth_dir).expanduser().resolve()
|
||||
auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not filename:
|
||||
filename = credential_file_name(
|
||||
str(payload.get("email") or ""),
|
||||
str(payload.get("sub") or ""),
|
||||
)
|
||||
if not filename.endswith(".json"):
|
||||
filename = filename + ".json"
|
||||
|
||||
dest = auth_dir / filename
|
||||
data = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".xai-", suffix=".tmp", dir=str(auth_dir))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
try:
|
||||
os.chmod(tmp_name, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp_name, dest)
|
||||
try:
|
||||
os.chmod(dest, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
return dest
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6IjI0ZmZmOGE3LTE4MTktNDVhZS1iMWJmLTUxZTM0MWE0ODNhMSIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDAxMzAzOSwiaWF0IjoxNzgzOTkxNDM5LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIGdyb2stY2xpOmFjY2VzcyBhcGk6YWNjZXNzIGNvbnZlcnNhdGlvbnM6cmVhZCBjb252ZXJzYXRpb25zOndyaXRlIiwicHJpbmNpcGFsX3R5cGUiOiJVc2VyIiwicHJpbmNpcGFsX2lkIjoiMjRmZmY4YTctMTgxOS00NWFlLWIxYmYtNTFlMzQxYTQ4M2ExIiwiY2xpZW50X2lkIjoiYjFhMDA0OTItMDczYS00N2VhLTgxNmYtNGMzMjkyNjRhODI4IiwianRpIjoiYjdhMDZiYjQtZDhmNS00NTFkLWI3N2EtY2QxMTJjZWY0ZDE0IiwidGVhbV9pZCI6IjVlMjgzNTMwLWNhZDQtNDA2Yi05NmM1LWNjYjBlNjk4YjEyYyIsInJlZmVycmVyIjoiZ3Jvay1idWlsZCJ9.457bCCmBAahahh4Xjn3YcO1H-f9wXlILRsN3O7oY1oZs6cNn5FcQMyz2dZ0l9kmL-H8darChP4q5yJAtG0w9bw",
|
||||
"refresh_token": "jdfL30bDUemZotG5ylxFroPJY44YHr9UnCtpYykRZZgIYepu4grJNpxLt_jTRpSprbIqi30IhMhqstrR4Ug1cw",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 21600,
|
||||
"expired": "2026-07-14T07:10:39Z",
|
||||
"last_refresh": "2026-07-14T01:10:36Z",
|
||||
"email": "2in9y6cab4@mail.bblbb.com",
|
||||
"sub": "24fff8a7-1819-45ae-b1bf-51e341a483a1",
|
||||
"base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"disabled": false,
|
||||
"headers": {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)"
|
||||
},
|
||||
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6IjI0ZmZmOGE3LTE4MTktNDVhZS1iMWJmLTUxZTM0MWE0ODNhMSIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDAxMzAzOSwiaWF0IjoxNzgzOTkxNDM5LCJub25jZSI6ImllM2hOX1BfS29OVzltb1NIR0JWUFEiLCJnaXZlbl9uYW1lIjoiRWxpbyIsImZhbWlseV9uYW1lIjoiVGFuZyIsImVtYWlsIjoiMmluOXk2Y2FiNEBtYWlsLmJibGJiLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlfQ.C7RA-MkPJXPhyLumYnOdmHt6hQW7jEUlEY89Mbm2B8o6sZT_vLs-iQAPT2GGwpg3jJNbh2fYt2jL40ivWy8qxQ",
|
||||
"sso": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiNjRiNDZjNzctNTZkNi00YWI0LWIxYjctZjMxYjJkZjIxYTRlIn0.z3U3mI-1nIJPcNtEgy6nkrt4E-hT-7xrMqAFik30Gd4",
|
||||
"referrer": "grok-build"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6IjkxOWQ4ZTE2LTIxMTAtNDVhMS1iNGU3LTdiMjI0ZjllN2FmZCIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDAxMjk4OCwiaWF0IjoxNzgzOTkxMzg4LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIGdyb2stY2xpOmFjY2VzcyBhcGk6YWNjZXNzIGNvbnZlcnNhdGlvbnM6cmVhZCBjb252ZXJzYXRpb25zOndyaXRlIiwicHJpbmNpcGFsX3R5cGUiOiJVc2VyIiwicHJpbmNpcGFsX2lkIjoiOTE5ZDhlMTYtMjExMC00NWExLWI0ZTctN2IyMjRmOWU3YWZkIiwiY2xpZW50X2lkIjoiYjFhMDA0OTItMDczYS00N2VhLTgxNmYtNGMzMjkyNjRhODI4IiwianRpIjoiOWRiNTAwY2EtNmZlNi00NDdmLTg3ZjEtZjNmOWY4OWU3MjQ0IiwidGVhbV9pZCI6ImI4ZTBjMjJlLTBiNTYtNDM4Mi1iOTViLWQ5MDllMGJmOGU4YyIsInJlZmVycmVyIjoiZ3Jvay1idWlsZCJ9.Irtv-dCIb8lz6bvY_EemIfZ-xpBvgw0p-GnofAv02fWQQGb9soxwy7AfHLLEyv-dREN9h-4jJFRiczzkB5DPzA",
|
||||
"refresh_token": "2PRpUeQa7x7nAahvZPUff93CKIXE9g7F2pT6dWGYfhKDL6lZo0Hg1wdnWvPT6UEi2BxM8VFP3KzX3METMMjRdw",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 21600,
|
||||
"expired": "2026-07-14T07:09:48Z",
|
||||
"last_refresh": "2026-07-14T01:09:45Z",
|
||||
"email": "8qdlz5qphj@mail.bblbb.com",
|
||||
"sub": "919d8e16-2110-45a1-b4e7-7b224f9e7afd",
|
||||
"base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"disabled": false,
|
||||
"headers": {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)"
|
||||
},
|
||||
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6IjkxOWQ4ZTE2LTIxMTAtNDVhMS1iNGU3LTdiMjI0ZjllN2FmZCIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDAxMjk4OCwiaWF0IjoxNzgzOTkxMzg4LCJub25jZSI6IjNFWVROT3AzODBidWtDaFgzOTRsbHciLCJnaXZlbl9uYW1lIjoiSGVucnkiLCJmYW1pbHlfbmFtZSI6IkhvdSIsImVtYWlsIjoiOHFkbHo1cXBoakBtYWlsLmJibGJiLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlfQ.A0w-SiuiHdx_39OWKqP-kYwX1FdHNilQvDx4RV02cdhWFK58w4U6gWzhFryB4DXefUWbYog-L07Bv_ncYyOFUw",
|
||||
"sso": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiZWU4YjZhODQtMTIxZi00MDNmLTk5OWYtZThmMzRhMTMwYTJmIn0.KFIY6xP2NyrNHvMcg5Vgl2lL-qlqfZojwcBvWC_1c0U",
|
||||
"referrer": "grok-build"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6IjVlM2Y0NTIzLWY3MTYtNDBkMC1hN2UyLTI2MmI5NjNkMmQxYSIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDAxMzAwOSwiaWF0IjoxNzgzOTkxNDA5LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIGdyb2stY2xpOmFjY2VzcyBhcGk6YWNjZXNzIGNvbnZlcnNhdGlvbnM6cmVhZCBjb252ZXJzYXRpb25zOndyaXRlIiwicHJpbmNpcGFsX3R5cGUiOiJVc2VyIiwicHJpbmNpcGFsX2lkIjoiNWUzZjQ1MjMtZjcxNi00MGQwLWE3ZTItMjYyYjk2M2QyZDFhIiwiY2xpZW50X2lkIjoiYjFhMDA0OTItMDczYS00N2VhLTgxNmYtNGMzMjkyNjRhODI4IiwianRpIjoiZDU1MTg2MzItYjI3NC00MjNmLTk2NGMtNjhhMzM0YjA5NjgzIiwidGVhbV9pZCI6Ijk2NTM2NjhiLTYwNjItNGZhOC04ZmRmLTYxNjU1ZWFmZjhlZCIsInJlZmVycmVyIjoiZ3Jvay1idWlsZCJ9.adab8ouBU0TjXPzSwsZkmaONoW-n4ISc47fo4_aRerNWi-mAx-ykH0iCE4o90KhGg4ajdJyUy3cjII8Elix8Pw",
|
||||
"refresh_token": "DdXwfCsfy8vHu2x6XJmdsyUm-L_l-LFqYy3wculIOgGKPeqvNzdI7p82ThroS1kx05lmCbaW4WsWjCQnAHGjcQ",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 21600,
|
||||
"expired": "2026-07-14T07:10:09Z",
|
||||
"last_refresh": "2026-07-14T01:10:07Z",
|
||||
"email": "fe89ggxrpc@mail.bblbb.com",
|
||||
"sub": "5e3f4523-f716-40d0-a7e2-262b963d2d1a",
|
||||
"base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"disabled": false,
|
||||
"headers": {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)"
|
||||
},
|
||||
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6IjVlM2Y0NTIzLWY3MTYtNDBkMC1hN2UyLTI2MmI5NjNkMmQxYSIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDAxMzAwOSwiaWF0IjoxNzgzOTkxNDA5LCJub25jZSI6ImRWOXNzTHVaeFBvNDJUdFBwMGpQM2ciLCJnaXZlbl9uYW1lIjoiQWFyb24iLCJmYW1pbHlfbmFtZSI6Ild1IiwiZW1haWwiOiJmZTg5Z2d4cnBjQG1haWwuYmJsYmIuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9.kQJv3eY7Cmh6df3pzsCKulD-q8yyarhmPyzY34N2P3vNiyoyqJIbgJ0-lrbVSQrzgnbhrYK5qhBDoYG8Dqwk4Q",
|
||||
"sso": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiZTNmMjQ3ZWQtYmM4Yy00NWU5LWI5MzktZDkwMDNlZGMxZDcwIn0.uYTGsoBd1UldH13OTBq7409KRGF0DhdUQubk98juI_Q",
|
||||
"referrer": "grok-build"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6Ijc3ZTI4MTVlLTE1MjQtNDM0MS1iYWE4LWM5NGVjZjc3MzAzYSIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDEwOTU5MywiaWF0IjoxNzg0MDg3OTkzLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIGdyb2stY2xpOmFjY2VzcyBhcGk6YWNjZXNzIGNvbnZlcnNhdGlvbnM6cmVhZCBjb252ZXJzYXRpb25zOndyaXRlIiwicHJpbmNpcGFsX3R5cGUiOiJVc2VyIiwicHJpbmNpcGFsX2lkIjoiNzdlMjgxNWUtMTUyNC00MzQxLWJhYTgtYzk0ZWNmNzczMDNhIiwiY2xpZW50X2lkIjoiYjFhMDA0OTItMDczYS00N2VhLTgxNmYtNGMzMjkyNjRhODI4IiwianRpIjoiZDQ5YWQyMzEtOGRkNy00Zjk1LWE3ZjAtYWQ2N2VjZTNhYWRmIiwidGVhbV9pZCI6Ijc5OWY1OWM1LTJhZGMtNDllYi1iMTQ3LTVjNTY1NzJjMzE5MCIsInJlZmVycmVyIjoiZ3Jvay1idWlsZCJ9.LHoRB8Ze9wPEIbP1OYllw5cmDvMVLbYh7D2dL2AgQ3YPV0V7zpuw7gzQXYAOxbP7UPeuIQQ97qGOfXgJEW--yA",
|
||||
"refresh_token": "wXTgQRJDSMcyGmC8-krwGpHjkdsDzKHN2KnFwpvtdfnli_jBOqlvDnTSIrl2j90boNMVIO4Dg7UAdgGoTmE7ig",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 21600,
|
||||
"expired": "2026-07-15T09:59:53Z",
|
||||
"last_refresh": "2026-07-15T03:59:53Z",
|
||||
"email": "helenramos281@mail.bblbb.com",
|
||||
"sub": "77e2815e-1524-4341-baa8-c94ecf77303a",
|
||||
"base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"disabled": false,
|
||||
"headers": {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)"
|
||||
},
|
||||
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6Ijc3ZTI4MTVlLTE1MjQtNDM0MS1iYWE4LWM5NGVjZjc3MzAzYSIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDEwOTU5MywiaWF0IjoxNzg0MDg3OTkzLCJub25jZSI6IkU3dGRabjVEQmRNZmdNc2tQRF9NelEiLCJnaXZlbl9uYW1lIjoiS2FpIiwiZmFtaWx5X25hbWUiOiJIYW4iLCJlbWFpbCI6ImhlbGVucmFtb3MyODFAbWFpbC5iYmxiYi5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0.Ok3MSOzASage-BhrkdDfVvttZYFat-yBmbQbsth0ykj-jRD07iyjw5snRfX8__2A3gkZM_pLdkSUAfgEkFJTjw",
|
||||
"sso": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiMWU1OTMzYWEtMTJiYi00YjU5LTg1ZDktOTRiZmFhNDliYzU2In0.gnekLoMUj2B5DscbjE5hyC9FoLqkYjjXP9X3usyyqDQ",
|
||||
"referrer": "grok-build"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6IjlmZDQwMjg4LTg2YzgtNGM5YS1iNjA4LTkyOTMzMDllOTNjYiIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDAxMjk5NCwiaWF0IjoxNzgzOTkxMzk0LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIGdyb2stY2xpOmFjY2VzcyBhcGk6YWNjZXNzIGNvbnZlcnNhdGlvbnM6cmVhZCBjb252ZXJzYXRpb25zOndyaXRlIiwicHJpbmNpcGFsX3R5cGUiOiJVc2VyIiwicHJpbmNpcGFsX2lkIjoiOWZkNDAyODgtODZjOC00YzlhLWI2MDgtOTI5MzMwOWU5M2NiIiwiY2xpZW50X2lkIjoiYjFhMDA0OTItMDczYS00N2VhLTgxNmYtNGMzMjkyNjRhODI4IiwianRpIjoiNTBkNzllYzItMmRiNS00ZDE1LTg5ZDctMGE0M2EyNGY2NmIyIiwidGVhbV9pZCI6IjU5M2JkZDJmLTJmNDUtNDdmMC1hY2NmLWI0MTMxZDJkMzEwOSIsInJlZmVycmVyIjoiZ3Jvay1idWlsZCJ9.eCALwMjfDFppKIRDs17v1lddeMUfimojeVTi6oNMRvXowhm6PpdkcOV1_i1q0RVzHNU4FgIBkCfq2fkzBstsIQ",
|
||||
"refresh_token": "2LZN-30dFJf2O21flkqqKID32bVIov6ySxwhMgkwneVOXBlB_6T_RpsnzijP7l6ScakK-1nj5Puc3HhZgfYANg",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 21600,
|
||||
"expired": "2026-07-14T07:09:54Z",
|
||||
"last_refresh": "2026-07-14T01:09:51Z",
|
||||
"email": "kwlu5unggx@mail.bblbb.com",
|
||||
"sub": "9fd40288-86c8-4c9a-b608-9293309e93cb",
|
||||
"base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"disabled": false,
|
||||
"headers": {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)"
|
||||
},
|
||||
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6IjlmZDQwMjg4LTg2YzgtNGM5YS1iNjA4LTkyOTMzMDllOTNjYiIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDAxMjk5NCwiaWF0IjoxNzgzOTkxMzk0LCJub25jZSI6InNnZGdnUzBDSVFnbndmV1R3dDY4elEiLCJnaXZlbl9uYW1lIjoiSmFzb24iLCJmYW1pbHlfbmFtZSI6Ikd1byIsImVtYWlsIjoia3dsdTV1bmdneEBtYWlsLmJibGJiLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlfQ.tzuYusokaGNSGkG94qmOe9Av1Gpa2ssM7VR0OOv_8o5hrNIFMIvZbYab2OWsGXem7aLueC1n7T6mDR2a3ree0A",
|
||||
"sso": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiNjhhNDVmODgtYWI4MS00Y2MxLTljNDMtOGYzMzdkMTY2ZjhhIn0.ncyCG6YbgAWFUYXNTGRjC0EMYwsnjikzBM4moAbFew0",
|
||||
"referrer": "grok-build"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6ImJiYzI0ODVlLTcxM2UtNDU4Ny1iZTI3LTZkZTViZWNmZjhkNCIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDIxMTcyNywiaWF0IjoxNzg0MTkwMTI3LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIGdyb2stY2xpOmFjY2VzcyBhcGk6YWNjZXNzIGNvbnZlcnNhdGlvbnM6cmVhZCBjb252ZXJzYXRpb25zOndyaXRlIiwicHJpbmNpcGFsX3R5cGUiOiJVc2VyIiwicHJpbmNpcGFsX2lkIjoiYmJjMjQ4NWUtNzEzZS00NTg3LWJlMjctNmRlNWJlY2ZmOGQ0IiwiY2xpZW50X2lkIjoiYjFhMDA0OTItMDczYS00N2VhLTgxNmYtNGMzMjkyNjRhODI4IiwianRpIjoiZTQzNjI0ZmMtMDJmNC00MjhmLWFhMmEtNGM5MTAxMDk2MDY3IiwidGVhbV9pZCI6IjkzZGRiMzg3LWMyMDktNDEwZi1iMDJhLTA5NDZhNWM4ODkwNSIsInJlZmVycmVyIjoiZ3Jvay1idWlsZCJ9.DM0FgR-cvN82OkOpza-vvdj6Y7F-DDqcIgALepE3XR_GFKNKlblf6oa6ZGFLg6EkNELsSGNb5bmka_CDx06Mcg",
|
||||
"refresh_token": "YCyRNl-D1-OAzfoVs9sAKPEBBLm5kHJvdI9SEx18W8tZmXKHGcQ9yp_N2q4hNqq_dQH4Emy3EPbV3ikEBBuOAA",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 21600,
|
||||
"expired": "2026-07-16T14:22:07Z",
|
||||
"last_refresh": "2026-07-16T08:22:07Z",
|
||||
"email": "zoieroberts1869@mail.bblbb.com",
|
||||
"sub": "bbc2485e-713e-4587-be27-6de5becff8d4",
|
||||
"base_url": "https://cli-chat-proxy.grok.com/v1",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"disabled": false,
|
||||
"headers": {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)"
|
||||
},
|
||||
"id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Im9hdXRoMi1wcm9kdWN0aW9uLTIwMjYtMDItMTkifQ.eyJpc3MiOiJodHRwczovL2F1dGgueC5haSIsInN1YiI6ImJiYzI0ODVlLTcxM2UtNDU4Ny1iZTI3LTZkZTViZWNmZjhkNCIsImF1ZCI6ImIxYTAwNDkyLTA3M2EtNDdlYS04MTZmLTRjMzI5MjY0YTgyOCIsImV4cCI6MTc4NDIxMTcyNywiaWF0IjoxNzg0MTkwMTI3LCJub25jZSI6Imoxb0JzWUkzR19WUXRseUxWVTBteVEiLCJnaXZlbl9uYW1lIjoiQ2FsZWIiLCJmYW1pbHlfbmFtZSI6IkxpdSIsImVtYWlsIjoiem9pZXJvYmVydHMxODY5QG1haWwuYmJsYmIuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9.y38jxu1nWyO0Yzr4lcxVAqVbVc37880Jz0UGlIoQ8P5HWdD1b-hFleiGJmGbqayQ9AhpoKJMcoWrWIYFGMLc2g",
|
||||
"sso": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX2lkIjoiNjRkOWNjNTMtM2Q0YS00MzNlLThhMTctMTIxYmUyODNmZTU4In0.ra0SYJyU146-eMqSQKcG9J63YDVip_tXOxk0SRXp_54",
|
||||
"referrer": "grok-build"
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
"""注册成功钩子:SSO → OAuth Authorization Code(PKCE, referrer=grok-build)
|
||||
→ 写出 CPA (CLIProxyAPI) 的 xai-<email>.json → 推送到远端 CLIProxyAPI 导入。
|
||||
|
||||
- 本地写盘目录:config['cpa_auth_dir'](默认 ./cpa_auths)
|
||||
- 远端推送:POST config['cpa_remote_base'] + /v0/management/auth-files?name=...
|
||||
认证 X-Management-Key: config['cpa_remote_secret']
|
||||
|
||||
2026-07 起:设备码铸造的 token 缺 referrer=grok-build,cli-chat-proxy 不可用。
|
||||
默认优先走 SSO cookie 的授权码流程;仅在无 sso 且允许时才回退设备码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
_REG_DIR = Path(__file__).resolve().parent
|
||||
_DEFAULT_OUT = _REG_DIR / "cpa_auths"
|
||||
|
||||
|
||||
def _resolve_out_dir(cfg: dict) -> Path:
|
||||
raw = str(cfg.get("cpa_auth_dir") or _DEFAULT_OUT).strip()
|
||||
p = Path(raw).expanduser()
|
||||
if not p.is_absolute():
|
||||
p = (_REG_DIR / p).resolve()
|
||||
return p
|
||||
|
||||
|
||||
def _record_failure(out_dir: Path, email: str, reason: str) -> None:
|
||||
try:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(out_dir / "cpa_auth_failed.txt", "a", encoding="utf-8") as f:
|
||||
f.write(f"{email}----{reason}----{int(time.time())}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_proxy(cfg: dict) -> str | None:
|
||||
from oidc_mint import resolve_proxy, set_runtime_proxy
|
||||
|
||||
# 1) 显式 mint_proxy / proxy
|
||||
proxy = (cfg.get("mint_proxy") or cfg.get("proxy") or "").strip()
|
||||
# 2) 注册机代理池线程绑定(与浏览器同出口)
|
||||
if not proxy:
|
||||
try:
|
||||
import proxy_pool
|
||||
proxy = (proxy_pool.get_thread_proxy() or "").strip()
|
||||
except Exception:
|
||||
proxy = ""
|
||||
# 3) 环境变量
|
||||
if not proxy:
|
||||
proxy = (
|
||||
os.environ.get("https_proxy")
|
||||
or os.environ.get("HTTPS_PROXY")
|
||||
or os.environ.get("http_proxy")
|
||||
or ""
|
||||
).strip()
|
||||
resolved = resolve_proxy(proxy or None)
|
||||
set_runtime_proxy(resolved or None)
|
||||
return resolved or None
|
||||
|
||||
|
||||
def _proxy_label(proxy: str | None) -> str:
|
||||
try:
|
||||
from oidc_mint.proxyutil import proxy_log_label
|
||||
|
||||
return proxy_log_label(proxy or "") or "(direct)"
|
||||
except Exception:
|
||||
return proxy or "(direct)"
|
||||
|
||||
|
||||
def _port_open(host: str, port: int, timeout: float = 0.25) -> bool:
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _local_proxy_candidates(cfg: dict) -> list[str]:
|
||||
"""直连被 CF 拦时尝试的本机/配置代理列表(去重)。"""
|
||||
cands: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(raw: str | None) -> None:
|
||||
p = (raw or "").strip()
|
||||
if not p or p in seen:
|
||||
return
|
||||
seen.add(p)
|
||||
cands.append(p)
|
||||
|
||||
for key in ("mint_proxy", "proxy"):
|
||||
_add(str(cfg.get(key) or ""))
|
||||
raw_list = cfg.get("proxy_pool") or []
|
||||
if isinstance(raw_list, str):
|
||||
for line in raw_list.replace(",", "\n").splitlines():
|
||||
_add(line)
|
||||
elif isinstance(raw_list, (list, tuple)):
|
||||
for x in raw_list:
|
||||
_add(str(x))
|
||||
try:
|
||||
import proxy_pool
|
||||
|
||||
for p in proxy_pool.pool_snapshot()[:8]:
|
||||
_add(p)
|
||||
except Exception:
|
||||
pass
|
||||
# 仅探测本机已监听的常见代理端口,避免空转超时
|
||||
for port in (7890, 7897, 10809, 10808, 7891, 20171, 6152, 1080):
|
||||
if _port_open("127.0.0.1", port):
|
||||
_add(f"http://127.0.0.1:{port}")
|
||||
return cands
|
||||
|
||||
|
||||
def _is_cf_mint_error(exc: BaseException | str) -> bool:
|
||||
try:
|
||||
from oidc_mint.oauth_code import is_cloudflare_block
|
||||
|
||||
return is_cloudflare_block(exc=exc)
|
||||
except Exception:
|
||||
text = str(exc or "").lower()
|
||||
return "403" in text and (
|
||||
"cloudflare" in text or "<!doctype" in text or "oldie" in text
|
||||
)
|
||||
|
||||
|
||||
def _mint_tokens(
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
sso: str,
|
||||
page: Any | None,
|
||||
cfg: dict,
|
||||
log: Callable[[str], None],
|
||||
proxy: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""优先 SSO 授权码;可选回退设备码。"""
|
||||
from oidc_mint import set_runtime_proxy
|
||||
from oidc_mint.oauth_code import OAuthCodeError, mint_from_sso, normalize_sso_cookie
|
||||
|
||||
sso_token = normalize_sso_cookie(sso or "")
|
||||
prefer_sso = bool(cfg.get("cpa_prefer_sso_oauth", True))
|
||||
allow_device = bool(cfg.get("cpa_allow_device_fallback", False))
|
||||
timeout = float(cfg.get("mint_timeout_sec", 300) or 300)
|
||||
max_proxy_tries = int(cfg.get("mint_proxy_retries", 4) or 4)
|
||||
|
||||
if prefer_sso and sso_token:
|
||||
log("[cpa] 使用 SSO→OAuth(PKCE, referrer=grok-build)")
|
||||
tried: set[str] = set()
|
||||
proxies_to_try: list[str | None] = [proxy]
|
||||
last_exc: Exception | None = None
|
||||
|
||||
def _expand_proxy_candidates() -> None:
|
||||
for p in _local_proxy_candidates(cfg):
|
||||
if p and p not in tried and p not in {
|
||||
x for x in proxies_to_try if x
|
||||
}:
|
||||
proxies_to_try.append(p)
|
||||
|
||||
# 直连时先挂上本机已开端口,减少首次 403 后的空等
|
||||
if not proxy:
|
||||
_expand_proxy_candidates()
|
||||
|
||||
idx = 0
|
||||
while idx < len(proxies_to_try) and idx < max(1, max_proxy_tries):
|
||||
use_proxy = proxies_to_try[idx]
|
||||
label = _proxy_label(use_proxy)
|
||||
if idx == 0:
|
||||
log(f"[cpa] mint 出口={label}")
|
||||
else:
|
||||
log(f"[cpa] CF/403 换出口重试 ({idx + 1}/{max_proxy_tries}): {label}")
|
||||
set_runtime_proxy(use_proxy)
|
||||
tried.add(use_proxy or "")
|
||||
try:
|
||||
return mint_from_sso(
|
||||
sso_token,
|
||||
proxy=use_proxy,
|
||||
log=lambda m: log(f"[Debug] {m}"),
|
||||
require_referrer=bool(cfg.get("cpa_require_referrer", True)),
|
||||
)
|
||||
except OAuthCodeError as exc:
|
||||
last_exc = exc
|
||||
log(f"[!] SSO→OAuth 失败: {exc}")
|
||||
if _is_cf_mint_error(exc):
|
||||
if use_proxy:
|
||||
try:
|
||||
import proxy_pool
|
||||
|
||||
proxy_pool.report_failure(
|
||||
use_proxy, reason=f"mint CF: {str(exc)[:120]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_expand_proxy_candidates()
|
||||
idx += 1
|
||||
continue
|
||||
if not allow_device:
|
||||
raise
|
||||
log("[cpa] 回退设备码铸造(可能缺 referrer)")
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
log(f"[!] SSO→OAuth 异常: {exc}")
|
||||
if _is_cf_mint_error(exc):
|
||||
_expand_proxy_candidates()
|
||||
idx += 1
|
||||
continue
|
||||
if not allow_device:
|
||||
raise
|
||||
log("[cpa] 回退设备码铸造(可能缺 referrer)")
|
||||
break
|
||||
# 成功已 return;失败已 continue/break
|
||||
idx += 1 # pragma: no cover
|
||||
else:
|
||||
if last_exc is not None and not allow_device:
|
||||
raise last_exc
|
||||
if last_exc is not None and not allow_device:
|
||||
raise last_exc
|
||||
|
||||
if not allow_device and not sso_token:
|
||||
raise RuntimeError("无 sso cookie,且已禁用设备码回退;无法铸造带 referrer 的 token")
|
||||
|
||||
# 设备码回退(旧路径,通常无 referrer)
|
||||
from oidc_mint import mint_with_browser
|
||||
|
||||
if page is None and not (email and password):
|
||||
raise RuntimeError("设备码回退需要 page 或 email/password")
|
||||
|
||||
log("[cpa] 使用设备码铸造(兼容路径)")
|
||||
tokens = mint_with_browser(
|
||||
email=email,
|
||||
password=password,
|
||||
page=page,
|
||||
proxy=proxy,
|
||||
browser_timeout_sec=timeout,
|
||||
force_standalone=(page is None),
|
||||
cookies=None,
|
||||
poll_log=lambda m: log(f"[Debug] {m}"),
|
||||
)
|
||||
return tokens
|
||||
|
||||
|
||||
# ── 主入口 ──
|
||||
|
||||
def export_cpa_for_account(
|
||||
email: str,
|
||||
password: str = "",
|
||||
*,
|
||||
page: Any | None = None,
|
||||
sso: str = "",
|
||||
config: dict | None = None,
|
||||
log_callback: Callable[[str], None] | None = None,
|
||||
) -> dict:
|
||||
"""铸造 OIDC → 写本地 xai-<email>.json → 推送远端 CLIProxyAPI。
|
||||
|
||||
优先使用 sso cookie 走 Authorization Code + referrer=grok-build。
|
||||
返回 {ok, email, path, pushed, push_status?, error?}。
|
||||
"""
|
||||
cfg = config or {}
|
||||
log = log_callback or (lambda m: print(m, flush=True))
|
||||
|
||||
if not cfg.get("cpa_export_enabled", True):
|
||||
log("[cpa] 已关闭导出,跳过")
|
||||
return {"ok": False, "skipped": True, "reason": "disabled"}
|
||||
email = (email or "").strip()
|
||||
if not email:
|
||||
return {"ok": False, "error": "缺少 email", "email": email}
|
||||
|
||||
import cpa
|
||||
from oidc_mint.oauth_code import normalize_sso_cookie
|
||||
|
||||
out_dir = _resolve_out_dir(cfg)
|
||||
proxy = _resolve_proxy(cfg)
|
||||
base_url = cfg.get("cpa_base_url") or cpa.CLI_BASE_URL
|
||||
sso_token = normalize_sso_cookie(sso or "")
|
||||
|
||||
try:
|
||||
tokens = _mint_tokens(
|
||||
email=email,
|
||||
password=password or "",
|
||||
sso=sso_token,
|
||||
page=page,
|
||||
cfg=cfg,
|
||||
log=log,
|
||||
proxy=proxy,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"[!] 铸造失败: {exc}")
|
||||
_record_failure(out_dir, email, str(exc))
|
||||
if cfg.get("mint_required", False):
|
||||
raise
|
||||
return {"ok": False, "error": str(exc), "email": email}
|
||||
|
||||
try:
|
||||
payload = cpa.build_cpa_xai_auth(
|
||||
email=email,
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
id_token=tokens.get("id_token"),
|
||||
expires_in=tokens.get("expires_in"),
|
||||
base_url=base_url,
|
||||
sso=tokens.get("sso") or sso_token or None,
|
||||
)
|
||||
path = cpa.write_cpa_xai_auth(out_dir, payload)
|
||||
filename = Path(path).name
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"[!] 写本地文件失败: {exc}")
|
||||
_record_failure(out_dir, email, f"write: {exc}")
|
||||
if cfg.get("mint_required", False):
|
||||
raise
|
||||
return {"ok": False, "error": str(exc), "email": email}
|
||||
|
||||
ref = payload.get("referrer") or tokens.get("referrer") or ""
|
||||
log(f"[Debug] 已写本地: {path} referrer={ref or '(empty)'}")
|
||||
result: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"email": email,
|
||||
"path": str(path),
|
||||
"pushed": False,
|
||||
"referrer": ref,
|
||||
}
|
||||
|
||||
# 推送远端 CLIProxyAPI(失败记入 cpa_push_pending.txt,下次推送时一并重试)
|
||||
if cfg.get("cpa_push_enabled", False):
|
||||
remote_base = str(cfg.get("cpa_remote_base") or "").strip()
|
||||
secret = str(cfg.get("cpa_remote_secret") or "").strip()
|
||||
if not remote_base or not secret:
|
||||
log("[!] 推送已开启但未配置 cpa_remote_base/cpa_remote_secret,跳过推送")
|
||||
cpa.record_push_failure(out_dir, filename, "remote not configured")
|
||||
else:
|
||||
push_proxy = str(cfg.get("cpa_push_proxy") or "").strip() or None
|
||||
verify_tls = bool(cfg.get("cpa_remote_verify_tls", True))
|
||||
push_res = cpa.push_with_queue(
|
||||
out_dir,
|
||||
filename,
|
||||
payload,
|
||||
remote_base=remote_base,
|
||||
secret=secret,
|
||||
proxy=push_proxy,
|
||||
verify_tls=verify_tls,
|
||||
flush_first=True,
|
||||
log=log,
|
||||
)
|
||||
result["pushed"] = bool(push_res.get("pushed"))
|
||||
if "push_status" in push_res:
|
||||
result["push_status"] = push_res["push_status"]
|
||||
if push_res.get("push_error"):
|
||||
result["push_error"] = push_res["push_error"]
|
||||
if push_res.get("flush"):
|
||||
result["push_flush"] = push_res["flush"]
|
||||
if not result["pushed"] and cfg.get("cpa_push_required", False):
|
||||
result["ok"] = False
|
||||
result["error"] = (
|
||||
f"push: {result.get('push_error') or result.get('push_status')}"
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -1,180 +0,0 @@
|
||||
# CSP 架构说明
|
||||
|
||||
本文档记录当前运行时架构。README 面向使用者;本文面向维护者,重点是并发边界、资源生命周期和测试不变量。
|
||||
|
||||
## 目标
|
||||
|
||||
运行时采用 CSP 风格的异步流水线:
|
||||
|
||||
- `S_Worker` 生产 `T`。
|
||||
- `P_Worker` 发起外部请求,等待并生产 `Q`。
|
||||
- `C_Worker` 原子获取一组 `T + Q` 并执行最终消费。
|
||||
|
||||
架构目标是资源所有权闭合、背压边界清晰、取消语义可测试。当前设计不包含中心调度器、动态角色选择或运行时动态并发分配。
|
||||
|
||||
## 核心组件
|
||||
|
||||
`Physical_Sem` 限制本地浏览器重操作并发。`P_Worker` 发出请求后,在等待 `Q` 返回期间不得持有该许可。
|
||||
|
||||
`T_Slot_Sem` 限制已入库 `T` 的容量。`T` 生成成功后才允许获取 slot。
|
||||
|
||||
`Q_Slot_Sem` 限制已返回并入库 `Q` 的容量。`Q` 真正返回前不得获取 slot。
|
||||
|
||||
`Q_Pending_Sem` 限制已发出但尚未终态的外部 `Q` 请求数量。
|
||||
|
||||
`Inventory` 是唯一库存门面。worker 不直接访问底层队列,只能调用:
|
||||
|
||||
```text
|
||||
put_t(env)
|
||||
put_q(env)
|
||||
claim_pair()
|
||||
```
|
||||
|
||||
`ResourceEnvelope` 把资源实体和库存 slot 绑定在一起。slot 只能释放一次。
|
||||
|
||||
`PairLease` 是 `claim_pair()` 返回的异步上下文管理器。pair 一旦 claim 成功,两个 envelope 的所有权转移给 lease,直到 consumer 退出上下文。
|
||||
|
||||
`AdmissionGate` 是局部生产准入门控,只根据库存深度和静态水位决定是否允许继续生产。它不选择 worker 角色,不搬运资源,不调整并发容量。
|
||||
|
||||
## Worker 流程
|
||||
|
||||
### S_Worker
|
||||
|
||||
1. 等待 `AdmissionGate` 允许生产 `T`。
|
||||
2. 获取 `Physical_Sem`。
|
||||
3. 生产 `T`。
|
||||
4. 释放 `Physical_Sem`。
|
||||
5. 调用 `ResourceEnvelope.create_with_slot(...)`,创建 envelope 并获取 `T_Slot_Sem`。
|
||||
6. 调用 `Inventory.put_t(...)`,把所有权转移给 `Inventory`。
|
||||
7. 所有权转移前如果异常或取消,释放已获取的 slot。
|
||||
|
||||
slot 获取和 envelope 创建必须绑定,避免取消落在“已获取 slot、尚未创建 envelope”的窗口。
|
||||
|
||||
### P_Worker
|
||||
|
||||
1. 等待 `AdmissionGate` 允许生产 `Q`。
|
||||
2. 获取 `Q_Pending_Sem`。
|
||||
3. 获取 `Physical_Sem`。
|
||||
4. 创建请求并发送。
|
||||
5. 释放 `Physical_Sem`。
|
||||
6. 在不持有 `Physical_Sem` 的情况下等待 `Q` 返回。
|
||||
7. `Q` 返回后调用 `ResourceEnvelope.create_with_slot(...)`,创建 envelope 并获取 `Q_Slot_Sem`。
|
||||
8. 调用 `Inventory.put_q(...)`,把所有权转移给 `Inventory`。
|
||||
9. 请求进入终态后释放 `Q_Pending_Sem`。
|
||||
|
||||
`Q_Pending_Sem` 表达外部在途上限;`Q_Slot_Sem` 只表达已返回库存容量。两者不能合并。
|
||||
|
||||
### C_Worker
|
||||
|
||||
1. 进入 `async with inventory.claim_pair() as pair`。
|
||||
2. 获取 `Physical_Sem`。
|
||||
3. 消费 pair。
|
||||
4. 释放 `Physical_Sem`。
|
||||
5. 退出 `PairLease`,释放两个库存 slot。
|
||||
|
||||
`C_Worker` 不允许先取单边资源再等待另一边。对 consumer 来说,pair claim 必须是原子的。
|
||||
|
||||
## Inventory 语义
|
||||
|
||||
第一版 `Inventory` 使用一把 lock 和一个 condition。lock 保护等待、复查、过期清理和弹出操作。
|
||||
|
||||
必须保持以下语义:
|
||||
|
||||
- 等待中的 consumer 不移除资源。
|
||||
- claim 成功时同时移除一个有效 `T` 和一个有效 `Q`。
|
||||
- 等待 pair 时被取消,不影响库存。
|
||||
- claim 成功后被取消,由 `PairLease` 核销两个 envelope。
|
||||
- worker 永远不能直接操作底层 `T` / `Q` 队列。
|
||||
|
||||
只要锁内逻辑保持很小,除 lazy expiry cleanup 外基本是 O(1),单锁在当前版本可以接受。只有 profile 证明锁竞争成为真实瓶颈时,才考虑拆锁或分片。
|
||||
|
||||
## 过期模型
|
||||
|
||||
`ResourceEnvelope` 可以携带 `created_at` 和 `expires_at`。`Inventory` 在配对前可以丢弃已过期资源。
|
||||
|
||||
当前采用 lazy cleanup:
|
||||
|
||||
- `put_t`、`put_q`、`claim_pair` 被触发时顺手清理。
|
||||
- 清理会释放它看到的过期 envelope 对应 slot。
|
||||
- 系统完全静默时不会主动扫库。
|
||||
- 单边长期故障和静默停摆由监控暴露,不靠后台 sweeper 修复。
|
||||
|
||||
当前不做 worker 级回队。消费失败后,已 claim 的 `T` 和 `Q` 都由 `PairLease` 核销。
|
||||
|
||||
## 容量策略
|
||||
|
||||
容量边界由 Semaphore 表达。启动期容量优先级:
|
||||
|
||||
```text
|
||||
显式 PHYSICAL_CAP > CAPACITY_PROFILE > CPU/内存自动派生
|
||||
```
|
||||
|
||||
`CAPACITY_PROFILE` 只在启动时读取,是静态 profile,不是运行时调度器。
|
||||
|
||||
默认 worker 数量由容量派生:
|
||||
|
||||
```text
|
||||
S_WORKERS = Physical_Sem + 2
|
||||
P_WORKERS = Q_Pending_Sem + 2
|
||||
C_WORKERS = Physical_Sem + 2
|
||||
```
|
||||
|
||||
worker 数量不是主要调参入口。主要并发边界是各类容量许可,而不是 coroutine 循环数量。
|
||||
|
||||
## 当前不做
|
||||
|
||||
当前版本明确不包含:
|
||||
|
||||
- 中心调度器;
|
||||
- 运行时角色选择;
|
||||
- 动态打分;
|
||||
- 动态并发控制;
|
||||
- worker 级回队;
|
||||
- 高价值资源抢救策略;
|
||||
- 后台过期清扫;
|
||||
- 自动切换高风险浏览器模式。
|
||||
|
||||
这些方向可以单独实验,但不能混入基础所有权模型。
|
||||
|
||||
## 必须保持的不变量
|
||||
|
||||
实现和测试必须维持以下不变量:
|
||||
|
||||
- 每个已获取的库存 slot 最终释放一次且只释放一次。
|
||||
- 每个已准入的 pending 请求最终释放一次 `Q_Pending_Sem`。
|
||||
- `P_Worker` 等待 `Q` 返回时不持有 `Physical_Sem`。
|
||||
- `Q_Slot_Sem` 只在 `Q` 返回后获取。
|
||||
- `C_Worker` 只能通过 `Inventory.claim_pair()` 获取 `T` 和 `Q`。
|
||||
- `claim_pair()` 要么返回一个受 `PairLease` 保护的完整 pair,要么不返回资源。
|
||||
- 等待 pair 时取消,不移除库存资源。
|
||||
- claim pair 后取消,两个 envelope 由 `PairLease` 释放。
|
||||
- 触发清理时,过期资源不能被配对。
|
||||
- 监控只读,不修改 Semaphore、队列或 worker 状态。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
.venv/bin/pip install -r tests/requirements.txt
|
||||
```
|
||||
|
||||
快速检查:
|
||||
|
||||
```bash
|
||||
python3 -m unittest tests.test_admission_gate tests.test_register_runtime_unittest tests.test_inventory_unittest tests.test_runtime_log_analyzer -v
|
||||
```
|
||||
|
||||
完整测试:
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests -q
|
||||
```
|
||||
|
||||
重点测试文件:
|
||||
|
||||
- `tests.test_inventory_unittest`:库存、过期和 lease 行为。
|
||||
- `tests.test_register_runtime_unittest`:worker 运行语义和监控行。
|
||||
- `tests.test_admission_gate`:局部门控水位。
|
||||
- `tests.test_runtime_log_analyzer`:日志解析兼容性。
|
||||
- `tests/test_cancel.py`:取消边界。
|
||||
- `tests/test_property.py`:随机化不变量检查。
|
||||
- `tests/test_stress.py`:更高并发 fake-service 压测。
|
||||
@@ -1,67 +0,0 @@
|
||||
# 本地认证服务
|
||||
|
||||
认证服务把已有的 SSO 会话转换为 CPA 可直接读取的 OAuth 凭据。注册与认证可以在同一台机器,也可以分开运行。
|
||||
|
||||
## 默认同机运行
|
||||
|
||||
同一个项目目录已经或正在运行注册服务时,直接启动认证:
|
||||
|
||||
```bash
|
||||
bash auth-service.sh
|
||||
```
|
||||
|
||||
未配置 SSH 主机时,认证服务自动读取本项目 `keys/` 中的完整会话与历史账号。注册可以继续追加,认证服务只安装经过校验的完整快照。
|
||||
|
||||
## 配置远端同步
|
||||
|
||||
先把无密码导出器放到服务器项目目录:
|
||||
|
||||
```bash
|
||||
scp scripts/export_registered_sessions.py user@server.example:/opt/grok-free-register/scripts/
|
||||
```
|
||||
|
||||
在本地终端设置连接信息:
|
||||
|
||||
可以直接 `export`,也可以把 `.env.example` 复制为 `.env` 后填写;认证入口会自动读取 `.env`。
|
||||
|
||||
```bash
|
||||
export XAI_AUTH_SERVICE_SSH_HOST=user@server.example
|
||||
export XAI_AUTH_SERVICE_SSH_IDENTITY=/path/to/key.pem
|
||||
export XAI_AUTH_SERVICE_REMOTE_ROOT=/opt/grok-free-register
|
||||
```
|
||||
|
||||
使用 `ssh-agent` 时可省略 `XAI_AUTH_SERVICE_SSH_IDENTITY`。
|
||||
|
||||
设置了 `XAI_AUTH_SERVICE_SSH_HOST` 后,默认的 `auto` 模式会选择 SSH。需要明确覆盖时使用:
|
||||
|
||||
```bash
|
||||
export XAI_AUTH_SERVICE_SOURCE=local # 强制读取同机注册结果
|
||||
export XAI_AUTH_SERVICE_SOURCE=ssh # 强制使用 SSH,必须配置主机
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
bash auth-service.sh
|
||||
```
|
||||
|
||||
首次运行会自动安装项目依赖。该命令在当前终端持续运行并直接接受控制命令;输入 `q` 或按 `Ctrl-C` 停止,再次执行同一命令即可重启。不需要额外的会话管理工具。
|
||||
|
||||
普通模式只在来源连接、发现新账号、任务开始、认证结果、限流和控制状态变化时输出。查看队列、重试、节拍和冷却探针时使用:
|
||||
|
||||
```bash
|
||||
bash auth-service.sh --debug
|
||||
```
|
||||
|
||||
运行中终端底部会保持 `认证> ` 输入行。日志更新不会清掉尚未提交的内容;直接输入命令并回车:
|
||||
|
||||
```text
|
||||
s 查看状态
|
||||
take N 取用 N 个凭据
|
||||
p 暂停
|
||||
r 恢复
|
||||
c 取消当前任务
|
||||
q 安全退出
|
||||
```
|
||||
|
||||
快照默认每 30 秒更新一次;内容无变化时终端保持安静。有效快照和已生成凭据会在重启后继续使用。
|
||||
@@ -1,19 +0,0 @@
|
||||
# 凭据库存与取用
|
||||
|
||||
认证成功的凭据保存在本地认证目录,并在 SQLite 库存中维护三种状态:
|
||||
|
||||
- `available`:已经认证、尚未取用;
|
||||
- `claiming`:正在移动到取用批次;
|
||||
- `claimed`:已经取用。
|
||||
|
||||
运行认证服务后输入:
|
||||
|
||||
```text
|
||||
take 100
|
||||
```
|
||||
|
||||
服务会选择最新的 100 个可用凭据,移动到 `claimed/<batch-id>/`,再把对应库存标记为 `claimed`。认证 ledger 中的 `imported` 记录仍然保留,所以这些账号不会被重新认证。
|
||||
|
||||
每条库存记录预留 `note` 字段,默认为空。取用操作不要求填写用途;以后需要备注时可直接更新该字段。
|
||||
|
||||
若文件移动中断,服务下次启动会恢复 `claiming` 状态。库存不足或凭据文件缺失时,操作失败但认证服务继续运行。
|
||||
@@ -1,54 +0,0 @@
|
||||
# 注册教程
|
||||
|
||||
## 开始运行
|
||||
|
||||
```bash
|
||||
git clone <your-fork-or-mirror>/grok-free-register.git
|
||||
cd grok-free-register
|
||||
bash start.sh
|
||||
```
|
||||
|
||||
首次运行会安装 Python、CloakBrowser Chromium 及其系统依赖,然后引导选择邮箱模式。以后再次执行 `bash start.sh` 会直接使用已有配置。
|
||||
|
||||
普通模式只显示服务启动、任务开始、注册成功或失败、本次运行平均速率、累计数量和限流状态。查看完整并发、库存和阶段耗时时使用:
|
||||
|
||||
```bash
|
||||
bash start.sh --debug
|
||||
```
|
||||
|
||||
常用参数:
|
||||
|
||||
```bash
|
||||
bash start.sh --target 100
|
||||
bash start.sh --max-mem 6G
|
||||
bash start.sh --reconfig
|
||||
```
|
||||
|
||||
未设置 `--target` 时服务持续运行,按 `Ctrl-C` 安全停止。
|
||||
再次执行 `bash start.sh` 即可重启。程序直接使用当前终端,不需要额外的会话管理工具。
|
||||
|
||||
## 配置邮箱
|
||||
|
||||
临时邮箱无需额外配置:
|
||||
|
||||
```env
|
||||
EMAIL_MODE=tempmail
|
||||
```
|
||||
|
||||
自建邮箱需要可接收邮件的域名和本项目的收信服务:
|
||||
|
||||
```env
|
||||
EMAIL_MODE=custom
|
||||
EMAIL_DOMAIN=example.com
|
||||
EMAIL_API=http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
自建模式还需运行:
|
||||
|
||||
```bash
|
||||
bash start.sh --email-service
|
||||
```
|
||||
|
||||
性能参数默认会根据 CPU 和可用内存估算。除非正在压测,否则保持 `.env.example` 中的默认值即可。
|
||||
|
||||
成功结果写入 `keys/accounts.txt`、`keys/grok.txt` 和 `keys/auth-sessions.jsonl`;这些文件默认不提交到 Git。
|
||||
@@ -1,38 +0,0 @@
|
||||
# 运行状态与排障
|
||||
|
||||
## 普通模式
|
||||
|
||||
普通模式的每一行都对应一次状态变化:
|
||||
|
||||
- `[→]`:新任务开始;
|
||||
- `[✓]`:任务成功或来源连接完成;
|
||||
- `[✗]`:当前任务失败或被跳过;
|
||||
- `[⏸]`:进入限流冷却或服务暂停;
|
||||
- `[▶]`:限流解除或服务恢复;
|
||||
- `[!]`:来源、配置或流水线出现需要关注的问题。
|
||||
|
||||
认证端输入 `s` 可查看运行状态、待处理数量、当前阶段、本次运行平均速率、累计成功、可用和已取用凭据,以及是否处于限流。
|
||||
|
||||
## Debug 模式
|
||||
|
||||
注册端:
|
||||
|
||||
```bash
|
||||
bash start.sh --debug
|
||||
```
|
||||
|
||||
认证端:
|
||||
|
||||
```bash
|
||||
bash auth-service.sh --debug
|
||||
```
|
||||
|
||||
注册 Debug 面板包含 T/Q 库存、物理并发、S/P/C 阶段耗时和 token 求解时间。认证 Debug 状态包含 source/prepared/completion 队列、重试、授权节拍、冷却、单次探针和近五分钟滚动速率。
|
||||
|
||||
## 常见状态
|
||||
|
||||
限流后不会持续重试。认证端默认等待 60 秒,再只放行一个恢复探针;探针仍被限流时重新等待。注册端同样通过全局冷却闸门阻止并发任务漏过等待周期。
|
||||
|
||||
远端来源暂时断开时,本地认证服务继续使用上一份完整有效快照。恢复连接后会自动同步,不需要重启。
|
||||
|
||||
配置错误会指出缺少或非法的配置名,不输出 traceback。按提示检查 [注册配置](registration.md#配置邮箱) 或 [认证同步配置](auth-service.md#配置远端同步)。
|
||||
@@ -1 +0,0 @@
|
||||
"""Registration service package."""
|
||||
@@ -1,395 +0,0 @@
|
||||
"""Cloudflare clearance prewarm via external FlareSolverr.
|
||||
|
||||
This module does NOT run FlareSolverr. It only calls an already-deployed
|
||||
FlareSolverr instance (default http://127.0.0.1:8191).
|
||||
|
||||
Proxy roles (do not mix):
|
||||
- REGISTER_PROXY / HTTP_PROXY / HTTPS_PROXY
|
||||
→ grok-free-register Playwright + httpx egress (this process)
|
||||
- CLEARANCE_PROXY
|
||||
→ optional proxy *inside* FlareSolverr's browser (must be reachable
|
||||
from the FS *container*, e.g. http://privoxy:8118)
|
||||
- FLARESOLVERR_URL
|
||||
→ host-side URL to reach FS (http://127.0.0.1:8191)
|
||||
|
||||
Clearance cookies are only valid on the same egress path they were minted on.
|
||||
If REGISTER_PROXY points at host Privoxy→WARP, set CLEARANCE_PROXY to the
|
||||
docker-internal privoxy URL so FS solves on the same WARP exit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# CF-fronted x.ai family roots (no path suffix; path pages share host clearance)
|
||||
DEFAULT_CLEARANCE_URLS = (
|
||||
"https://accounts.x.ai",
|
||||
"https://x.ai",
|
||||
"https://status.x.ai",
|
||||
"https://console.x.ai",
|
||||
"https://auth.x.ai",
|
||||
)
|
||||
|
||||
_lock = threading.RLock()
|
||||
_cache: dict[str, Any] = {
|
||||
"cookies": [], # Playwright cookie dicts
|
||||
"user_agent": "",
|
||||
"fetched_at": 0.0,
|
||||
"hosts": [],
|
||||
"last_error": "",
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = str(os.environ.get(name, "")).strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
if raw in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if raw in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(str(os.environ.get(name, "")).strip() or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def clearance_enabled() -> bool:
|
||||
return _env_bool("CLEARANCE_ENABLED", False)
|
||||
|
||||
|
||||
def flaresolverr_url() -> str:
|
||||
return (os.environ.get("FLARESOLVERR_URL") or "http://127.0.0.1:8191").strip().rstrip("/")
|
||||
|
||||
|
||||
def clearance_timeout_sec() -> int:
|
||||
return max(10, _env_int("CLEARANCE_TIMEOUT_SEC", 60))
|
||||
|
||||
|
||||
def clearance_refresh_sec() -> int:
|
||||
return max(60, _env_int("CLEARANCE_REFRESH_SEC", 3000))
|
||||
|
||||
|
||||
def clearance_urls() -> list[str]:
|
||||
raw = (os.environ.get("CLEARANCE_URLS") or "").strip()
|
||||
if not raw:
|
||||
return list(DEFAULT_CLEARANCE_URLS)
|
||||
urls = []
|
||||
for part in raw.split(","):
|
||||
item = part.strip()
|
||||
if not item:
|
||||
continue
|
||||
if "://" not in item:
|
||||
item = "https://" + item
|
||||
# strip path/query — host-level prewarm only
|
||||
parsed = urlparse(item)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
urls.append(f"{parsed.scheme}://{parsed.netloc}")
|
||||
else:
|
||||
urls.append(item.rstrip("/"))
|
||||
# de-dupe preserve order
|
||||
seen = set()
|
||||
out = []
|
||||
for u in urls:
|
||||
if u not in seen:
|
||||
seen.add(u)
|
||||
out.append(u)
|
||||
return out or list(DEFAULT_CLEARANCE_URLS)
|
||||
|
||||
|
||||
def register_proxy_url() -> str:
|
||||
"""Egress proxy for *this* process (Playwright / httpx)."""
|
||||
for key in ("REGISTER_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"):
|
||||
value = (os.environ.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def clearance_proxy_url() -> str:
|
||||
"""Proxy for FlareSolverr browser (container-reachable). Empty = FS direct egress."""
|
||||
explicit = (os.environ.get("CLEARANCE_PROXY") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
# Do NOT auto-forward host REGISTER_PROXY into FS — 127.0.0.1 inside
|
||||
# the container is not the host. Operator must set CLEARANCE_PROXY when
|
||||
# minting on WARP (e.g. http://privoxy:8118).
|
||||
return ""
|
||||
|
||||
|
||||
def playwright_proxy_settings() -> dict[str, str] | None:
|
||||
"""Playwright launch/context proxy dict, or None for direct."""
|
||||
url = register_proxy_url()
|
||||
if not url:
|
||||
return None
|
||||
return {"server": url}
|
||||
|
||||
|
||||
def httpx_proxy_mounts() -> str | None:
|
||||
"""Single proxy URL for httpx.AsyncClient(proxy=...), or None."""
|
||||
return register_proxy_url() or None
|
||||
|
||||
|
||||
def _host_from_url(url: str) -> str:
|
||||
try:
|
||||
return (urlparse(url).hostname or "").lower()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _fs_post(payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
endpoint = f"{flaresolverr_url()}/v1"
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
|
||||
def _cookie_to_playwright(item: dict[str, Any], fallback_host: str) -> dict[str, Any] | None:
|
||||
name = str(item.get("name") or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
value = str(item.get("value") or "")
|
||||
domain = str(item.get("domain") or "").strip()
|
||||
if not domain:
|
||||
domain = fallback_host
|
||||
if domain and not domain.startswith(".") and domain.count(".") >= 1:
|
||||
# keep host-only cookies host-only; CF often uses .x.ai
|
||||
pass
|
||||
path = str(item.get("path") or "/") or "/"
|
||||
cookie: dict[str, Any] = {
|
||||
"name": name,
|
||||
"value": value,
|
||||
"domain": domain,
|
||||
"path": path,
|
||||
}
|
||||
if "httpOnly" in item:
|
||||
cookie["httpOnly"] = bool(item.get("httpOnly"))
|
||||
if "secure" in item:
|
||||
cookie["secure"] = bool(item.get("secure"))
|
||||
else:
|
||||
cookie["secure"] = True
|
||||
same_site = item.get("sameSite") or item.get("same_site")
|
||||
if same_site:
|
||||
# Playwright: Strict | Lax | None
|
||||
normalized = str(same_site).capitalize()
|
||||
if normalized.lower() == "none":
|
||||
normalized = "None"
|
||||
if normalized in {"Strict", "Lax", "None"}:
|
||||
cookie["sameSite"] = normalized
|
||||
expires = item.get("expires")
|
||||
if expires is not None:
|
||||
try:
|
||||
exp = float(expires)
|
||||
# FS may return ms or session -1
|
||||
if exp > 0:
|
||||
if exp > 1e12:
|
||||
exp = exp / 1000.0
|
||||
cookie["expires"] = exp
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return cookie
|
||||
|
||||
|
||||
def _merge_cookies(existing: list[dict[str, Any]], incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
index: dict[tuple[str, str, str], int] = {}
|
||||
merged: list[dict[str, Any]] = []
|
||||
for cookie in existing:
|
||||
key = (
|
||||
str(cookie.get("name") or ""),
|
||||
str(cookie.get("domain") or ""),
|
||||
str(cookie.get("path") or "/"),
|
||||
)
|
||||
index[key] = len(merged)
|
||||
merged.append(cookie)
|
||||
for cookie in incoming:
|
||||
key = (
|
||||
str(cookie.get("name") or ""),
|
||||
str(cookie.get("domain") or ""),
|
||||
str(cookie.get("path") or "/"),
|
||||
)
|
||||
if key in index:
|
||||
merged[index[key]] = cookie
|
||||
else:
|
||||
index[key] = len(merged)
|
||||
merged.append(cookie)
|
||||
return merged
|
||||
|
||||
|
||||
def prewarm_clearance(*, force: bool = False) -> dict[str, Any]:
|
||||
"""Hit FlareSolverr for each CLEARANCE_URLS host; cache Playwright cookies.
|
||||
|
||||
Safe to call when disabled — returns status without network.
|
||||
"""
|
||||
if not clearance_enabled():
|
||||
return {
|
||||
"enabled": False,
|
||||
"ok": True,
|
||||
"skipped": True,
|
||||
"message": "CLEARANCE_ENABLED=0",
|
||||
"cookies": 0,
|
||||
}
|
||||
|
||||
with _lock:
|
||||
age = time.time() - float(_cache.get("fetched_at") or 0)
|
||||
if (
|
||||
not force
|
||||
and _cache.get("cookies")
|
||||
and age < clearance_refresh_sec()
|
||||
):
|
||||
return {
|
||||
"enabled": True,
|
||||
"ok": True,
|
||||
"cached": True,
|
||||
"age_sec": round(age, 1),
|
||||
"cookies": len(_cache["cookies"]),
|
||||
"hosts": list(_cache.get("hosts") or []),
|
||||
"user_agent": _cache.get("user_agent") or "",
|
||||
}
|
||||
|
||||
urls = clearance_urls()
|
||||
timeout = float(clearance_timeout_sec())
|
||||
fs_proxy = clearance_proxy_url()
|
||||
max_timeout_ms = int(timeout * 1000)
|
||||
merged: list[dict[str, Any]] = []
|
||||
user_agent = ""
|
||||
hosts_ok: list[str] = []
|
||||
errors: list[str] = []
|
||||
per_host: list[dict[str, Any]] = []
|
||||
|
||||
for url in urls:
|
||||
host = _host_from_url(url)
|
||||
payload: dict[str, Any] = {
|
||||
"cmd": "request.get",
|
||||
"url": url,
|
||||
"maxTimeout": max_timeout_ms,
|
||||
}
|
||||
if fs_proxy:
|
||||
payload["proxy"] = {"url": fs_proxy}
|
||||
t0 = time.time()
|
||||
try:
|
||||
data = _fs_post(payload, timeout=timeout + 15)
|
||||
elapsed = round(time.time() - t0, 2)
|
||||
if str(data.get("status") or "").lower() != "ok":
|
||||
errors.append(f"{host}: status={data.get('status')} msg={data.get('message')}")
|
||||
per_host.append({"host": host, "ok": False, "elapsed": elapsed, "error": data.get("message")})
|
||||
continue
|
||||
solution = data.get("solution") if isinstance(data.get("solution"), dict) else {}
|
||||
raw_cookies = solution.get("cookies") or []
|
||||
pw_cookies = []
|
||||
if isinstance(raw_cookies, list):
|
||||
for item in raw_cookies:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
converted = _cookie_to_playwright(item, host)
|
||||
if converted:
|
||||
pw_cookies.append(converted)
|
||||
merged = _merge_cookies(merged, pw_cookies)
|
||||
ua = str(solution.get("userAgent") or "").strip()
|
||||
if ua:
|
||||
user_agent = ua
|
||||
has_cf = any(c.get("name") == "cf_clearance" for c in pw_cookies)
|
||||
hosts_ok.append(host)
|
||||
per_host.append(
|
||||
{
|
||||
"host": host,
|
||||
"ok": True,
|
||||
"elapsed": elapsed,
|
||||
"http": solution.get("status"),
|
||||
"cookies": len(pw_cookies),
|
||||
"cf_clearance": has_cf,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surface to operator log
|
||||
elapsed = round(time.time() - t0, 2)
|
||||
errors.append(f"{host}: {exc}")
|
||||
per_host.append({"host": host, "ok": False, "elapsed": elapsed, "error": str(exc)})
|
||||
|
||||
with _lock:
|
||||
if merged:
|
||||
_cache["cookies"] = merged
|
||||
_cache["user_agent"] = user_agent
|
||||
_cache["fetched_at"] = time.time()
|
||||
_cache["hosts"] = hosts_ok
|
||||
_cache["last_error"] = "; ".join(errors)
|
||||
elif errors:
|
||||
_cache["last_error"] = "; ".join(errors)
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"ok": bool(merged) or not errors,
|
||||
"cached": False,
|
||||
"cookies": len(merged),
|
||||
"hosts": hosts_ok,
|
||||
"user_agent": user_agent,
|
||||
"errors": errors,
|
||||
"detail": per_host,
|
||||
"flaresolverr": flaresolverr_url(),
|
||||
"clearance_proxy": fs_proxy or "(direct)",
|
||||
"register_proxy": register_proxy_url() or "(direct)",
|
||||
}
|
||||
|
||||
|
||||
def cached_playwright_cookies() -> list[dict[str, Any]]:
|
||||
with _lock:
|
||||
return [dict(c) for c in (_cache.get("cookies") or [])]
|
||||
|
||||
|
||||
def cached_user_agent() -> str:
|
||||
with _lock:
|
||||
return str(_cache.get("user_agent") or "")
|
||||
|
||||
|
||||
async def apply_clearance_to_context(context) -> int:
|
||||
"""Inject cached CF cookies into a Playwright BrowserContext. Returns count."""
|
||||
cookies = cached_playwright_cookies()
|
||||
if not cookies or context is None:
|
||||
return 0
|
||||
try:
|
||||
await context.add_cookies(cookies)
|
||||
return len(cookies)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def format_prewarm_log(result: dict[str, Any]) -> str:
|
||||
if result.get("skipped"):
|
||||
return "[clearance] 未启用 (CLEARANCE_ENABLED=0)"
|
||||
if result.get("cached"):
|
||||
return (
|
||||
f"[clearance] 复用缓存 cookies={result.get('cookies')} "
|
||||
f"age={result.get('age_sec')}s hosts={','.join(result.get('hosts') or [])}"
|
||||
)
|
||||
detail = result.get("detail") or []
|
||||
parts = []
|
||||
for row in detail:
|
||||
host = row.get("host")
|
||||
if row.get("ok"):
|
||||
cf = "cf+" if row.get("cf_clearance") else "cf-"
|
||||
parts.append(f"{host}:{row.get('elapsed')}s/{cf}")
|
||||
else:
|
||||
parts.append(f"{host}:FAIL")
|
||||
err = result.get("errors") or []
|
||||
suffix = f" errors={len(err)}" if err else ""
|
||||
return (
|
||||
f"[clearance] 预热完成 cookies={result.get('cookies')} "
|
||||
f"fs={result.get('flaresolverr')} reg_proxy={result.get('register_proxy')} "
|
||||
f"fs_proxy={result.get('clearance_proxy')} | " + " ".join(parts) + suffix
|
||||
)
|
||||
Whitespace-only changes.
@@ -1,129 +0,0 @@
|
||||
"""Local admission gates for CSP workers.
|
||||
|
||||
AdmissionGate is not a scheduler: it never chooses a role and never moves
|
||||
resources. It only lets producers reserve local production intent against
|
||||
inventory watermarks, so fixed S/P workers can self-throttle without a central
|
||||
dispatcher.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
|
||||
class _TProductionLease:
|
||||
__slots__ = ("_gate", "_released")
|
||||
|
||||
def __init__(self, gate):
|
||||
self._gate = gate
|
||||
self._released = False
|
||||
|
||||
async def release(self):
|
||||
if self._released:
|
||||
return
|
||||
self._released = True
|
||||
async with self._gate._cond:
|
||||
self._gate.t_in_progress -= 1
|
||||
self._gate._cond.notify_all()
|
||||
|
||||
|
||||
class _QBatchLease:
|
||||
__slots__ = ("_gate", "count", "_remaining")
|
||||
|
||||
def __init__(self, gate, count):
|
||||
self._gate = gate
|
||||
self.count = count
|
||||
self._remaining = count
|
||||
|
||||
async def release_one(self):
|
||||
if self._remaining <= 0:
|
||||
return
|
||||
async with self._gate._cond:
|
||||
if self._remaining <= 0:
|
||||
return
|
||||
self._remaining -= 1
|
||||
self._gate.q_inflight -= 1
|
||||
self._gate._cond.notify_all()
|
||||
|
||||
async def release_all(self):
|
||||
if self._remaining <= 0:
|
||||
return
|
||||
async with self._gate._cond:
|
||||
if self._remaining <= 0:
|
||||
return
|
||||
self._gate.q_inflight -= self._remaining
|
||||
self._remaining = 0
|
||||
self._gate._cond.notify_all()
|
||||
|
||||
|
||||
class AdmissionGate:
|
||||
"""Watermark-based producer admission for T and Q.
|
||||
|
||||
T production uses high/low hysteresis because local T generation can be
|
||||
heavy and should not keep filling an already sufficient inventory.
|
||||
|
||||
Q production reserves a batch by current demand:
|
||||
q_high - q_depth - q_inflight
|
||||
capped by the worker's max batch. Each reservation is released when the
|
||||
corresponding pending Q requests have reached terminal state.
|
||||
"""
|
||||
|
||||
def __init__(self, inventory, *, t_low, t_high, q_low, q_high):
|
||||
if t_low < 0 or q_low < 0:
|
||||
raise ValueError("low watermarks must be non-negative")
|
||||
if t_high <= 0 or q_high <= 0:
|
||||
raise ValueError("high watermarks must be positive")
|
||||
if t_low > t_high:
|
||||
raise ValueError("t_low must be <= t_high")
|
||||
if q_low > q_high:
|
||||
raise ValueError("q_low must be <= q_high")
|
||||
|
||||
self.inventory = inventory
|
||||
self.t_low = t_low
|
||||
self.t_high = t_high
|
||||
self.q_low = q_low
|
||||
self.q_high = q_high
|
||||
self.t_in_progress = 0
|
||||
self.q_inflight = 0
|
||||
self._t_paused = False
|
||||
self._q_paused = False
|
||||
self._cond = asyncio.Condition()
|
||||
|
||||
async def acquire_t_production(self):
|
||||
"""Reserve one T production attempt."""
|
||||
async with self._cond:
|
||||
while True:
|
||||
total = self.inventory.t_depth + self.t_in_progress
|
||||
if total >= self.t_high:
|
||||
self._t_paused = True
|
||||
if self._t_paused and total <= self.t_low:
|
||||
self._t_paused = False
|
||||
if not self._t_paused and total < self.t_high:
|
||||
self.t_in_progress += 1
|
||||
self._cond.notify_all()
|
||||
return _TProductionLease(self)
|
||||
await self._cond.wait()
|
||||
|
||||
async def acquire_q_batch(self, *, max_batch):
|
||||
"""Reserve up to max_batch pending Q requests based on current demand."""
|
||||
if max_batch <= 0:
|
||||
raise ValueError("max_batch must be positive")
|
||||
|
||||
async with self._cond:
|
||||
while True:
|
||||
total = self.inventory.q_depth + self.q_inflight
|
||||
if total >= self.q_high:
|
||||
self._q_paused = True
|
||||
if self._q_paused and total <= self.q_low:
|
||||
self._q_paused = False
|
||||
demand = self.q_high - total
|
||||
if not self._q_paused and demand > 0:
|
||||
count = min(max_batch, demand)
|
||||
self.q_inflight += count
|
||||
if self.inventory.q_depth + self.q_inflight >= self.q_high:
|
||||
self._q_paused = True
|
||||
self._cond.notify_all()
|
||||
return _QBatchLease(self, count)
|
||||
await self._cond.wait()
|
||||
|
||||
async def notify_changed(self):
|
||||
"""Wake waiters after inventory depth changes."""
|
||||
async with self._cond:
|
||||
self._cond.notify_all()
|
||||
@@ -1,61 +0,0 @@
|
||||
"""
|
||||
ResourceEnvelope — T/Q 资源实体与库存 slot 的生命周期绑定
|
||||
|
||||
slot 获取与 envelope 创建必须通过 create_with_slot() 完成。
|
||||
release_slot_once() 幂等:重复调用只释放一次。
|
||||
"""
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
|
||||
class ResourceEnvelope:
|
||||
"""绑定一个 T/Q 资源值与其对应的库存 slot。"""
|
||||
|
||||
__slots__ = ('kind', 'value', 'slot_sem', '_released', 'created_at', 'expires_at', 'meta')
|
||||
|
||||
def __init__(self, kind, value, slot_sem, *, created_at, expires_at=None, meta=None):
|
||||
self.kind = kind # 'T' or 'Q'
|
||||
self.value = value # T: turnstile token str; Q: {'email','password','code'}
|
||||
self.slot_sem = slot_sem # T_Slot_Sem or Q_Slot_Sem
|
||||
self._released = False
|
||||
self.created_at = created_at
|
||||
self.expires_at = expires_at
|
||||
self.meta = meta or {}
|
||||
|
||||
@classmethod
|
||||
async def create_with_slot(cls, kind, value, slot_sem, *, expires_at=None, meta=None):
|
||||
"""先获取库存 slot,再构造 envelope。slot 获取失败则抛异常,不创建 envelope。"""
|
||||
await slot_sem.acquire()
|
||||
try:
|
||||
return cls(
|
||||
kind, value, slot_sem,
|
||||
created_at=time.time(),
|
||||
expires_at=expires_at,
|
||||
meta=meta,
|
||||
)
|
||||
except BaseException:
|
||||
slot_sem.release()
|
||||
raise
|
||||
|
||||
def release_slot_once(self, reason=''):
|
||||
"""幂等释放 slot。重复调用安全。"""
|
||||
if not self._released:
|
||||
self._released = True
|
||||
self.slot_sem.release()
|
||||
|
||||
def discard(self, reason=''):
|
||||
"""资源未被消费,释放库存占用。"""
|
||||
self.release_slot_once(reason=f'discard:{reason}')
|
||||
|
||||
def is_expired(self, now=None):
|
||||
"""检查资源是否过期。"""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return (now or time.time()) > self.expires_at
|
||||
|
||||
@property
|
||||
def released(self):
|
||||
return self._released
|
||||
|
||||
def __repr__(self):
|
||||
return f'Envelope({self.kind}, released={self._released})'
|
||||
@@ -1,169 +0,0 @@
|
||||
"""
|
||||
Inventory — 唯一库存门面 + PairLease
|
||||
|
||||
put_t / put_q: 所有权从 Worker 转移到 Inventory
|
||||
claim_pair(): 等待完整 T/Q pair,通过 PairLease 原子转移所有权
|
||||
内部单锁 + Condition,不做分片,不做无锁。
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from .envelope import ResourceEnvelope
|
||||
|
||||
|
||||
class PairLease:
|
||||
"""claim_pair 成功后的 pair 所有权对象。
|
||||
|
||||
用法:
|
||||
async with inventory.claim_pair() as pair:
|
||||
t_val, q_val = pair.t.value, pair.q.value
|
||||
...
|
||||
离开上下文时无论成功/失败/取消,都核销 T/Q 并释放 slot。
|
||||
"""
|
||||
|
||||
__slots__ = ('t', 'q', '_inventory')
|
||||
|
||||
def __init__(self, t_env, q_env, inventory):
|
||||
self.t = t_env
|
||||
self.q = q_env
|
||||
self._inventory = inventory
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
# 第一版策略: 无论结果如何,均核销 T/Q
|
||||
self.t.release_slot_once(reason='pair_settle')
|
||||
self.q.release_slot_once(reason='pair_settle')
|
||||
# 通知可能在等待的 claim_pair
|
||||
async with self._inventory._lock:
|
||||
self._inventory._cond.notify_all()
|
||||
return False
|
||||
|
||||
|
||||
class Inventory:
|
||||
"""T/Q 库存门面。
|
||||
|
||||
内部维护两个 deque 和一个 Lock+Condition。
|
||||
put_t/put_q 在锁内 O(1) 操作;claim_pair 在锁外等待 Condition。
|
||||
"""
|
||||
|
||||
def __init__(self, metrics=None):
|
||||
self._t_buf = deque()
|
||||
self._q_buf = deque()
|
||||
self._lock = asyncio.Lock()
|
||||
self._cond = asyncio.Condition(self._lock)
|
||||
self._metrics = metrics
|
||||
|
||||
# ── 入库 ──
|
||||
|
||||
async def put_t(self, env: ResourceEnvelope):
|
||||
"""将 T envelope 入库。调用前 env 属于 Worker,成功后属于 Inventory。"""
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
if env.is_expired(now):
|
||||
env.discard(reason='expired_on_put')
|
||||
if self._metrics:
|
||||
self._metrics.t_expired += 1
|
||||
return
|
||||
self._cleanup_expired(now)
|
||||
self._t_buf.append(env)
|
||||
if self._metrics:
|
||||
self._metrics.t_admitted += 1
|
||||
self._cond.notify_all()
|
||||
|
||||
async def put_q(self, env: ResourceEnvelope):
|
||||
"""将 Q envelope 入库。调用前 env 属于 Worker,成功后属于 Inventory。"""
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
if env.is_expired(now):
|
||||
env.discard(reason='expired_on_put')
|
||||
if self._metrics:
|
||||
self._metrics.q_expired += 1
|
||||
return
|
||||
self._cleanup_expired(now)
|
||||
self._q_buf.append(env)
|
||||
if self._metrics:
|
||||
self._metrics.q_admitted += 1
|
||||
self._cond.notify_all()
|
||||
|
||||
# ── Claim ──
|
||||
|
||||
def claim_pair(self):
|
||||
"""返回 PairLease 的 async context manager。
|
||||
|
||||
等待期间可取消,取消不弹出资源。成功后 T/Q 原子弹出并转移给 PairLease。
|
||||
"""
|
||||
return _PairClaim(self, self._metrics)
|
||||
|
||||
# ── 内部 ──
|
||||
|
||||
def _try_pop_pair(self):
|
||||
"""在锁内尝试弹出一对未过期的 T/Q。返回 (t_env, q_env) 或 None。"""
|
||||
now = time.time()
|
||||
# 清理过期
|
||||
self._cleanup_expired(now)
|
||||
if self._t_buf and self._q_buf:
|
||||
t_env = self._t_buf.popleft()
|
||||
q_env = self._q_buf.popleft()
|
||||
return t_env, q_env
|
||||
return None
|
||||
|
||||
def _cleanup_expired(self, now):
|
||||
"""lazy cleanup: 清理已过期的 T/Q 并释放 slot。锁内调用。"""
|
||||
self._cleanup_buf(self._t_buf, now, 't_expired')
|
||||
self._cleanup_buf(self._q_buf, now, 'q_expired')
|
||||
|
||||
def _cleanup_buf(self, buf, now, metric_name):
|
||||
"""扫描完整库存,保留未过期实体的原始 FIFO 顺序。"""
|
||||
kept = deque()
|
||||
while buf:
|
||||
env = buf.popleft()
|
||||
if env.is_expired(now):
|
||||
env.discard(reason='expired_in_inventory')
|
||||
if self._metrics:
|
||||
setattr(self._metrics, metric_name, getattr(self._metrics, metric_name) + 1)
|
||||
else:
|
||||
kept.append(env)
|
||||
buf.extend(kept)
|
||||
|
||||
@property
|
||||
def t_depth(self):
|
||||
return len(self._t_buf)
|
||||
|
||||
@property
|
||||
def q_depth(self):
|
||||
return len(self._q_buf)
|
||||
|
||||
|
||||
class _PairClaim:
|
||||
"""claim_pair() 返回的 async context manager。"""
|
||||
|
||||
__slots__ = ('_inventory', '_pair', '_metrics')
|
||||
|
||||
def __init__(self, inventory, metrics):
|
||||
self._inventory = inventory
|
||||
self._pair = None
|
||||
self._metrics = metrics
|
||||
|
||||
async def __aenter__(self):
|
||||
inv = self._inventory
|
||||
async with inv._lock:
|
||||
# 循环等待直到有 pair 或被取消
|
||||
while True:
|
||||
pair = inv._try_pop_pair()
|
||||
if pair is not None:
|
||||
t_env, q_env = pair
|
||||
self._pair = PairLease(t_env, q_env, inv)
|
||||
if self._metrics:
|
||||
self._metrics.pair_claimed += 1
|
||||
return self._pair
|
||||
# 没有 pair,等待 notify
|
||||
await inv._cond.wait()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
if self._pair is not None:
|
||||
# PairLease.__aexit__ 处理核销
|
||||
return await self._pair.__aexit__(exc_type, exc, tb)
|
||||
return False
|
||||
@@ -1,215 +0,0 @@
|
||||
"""
|
||||
Observer — 只读观测层
|
||||
|
||||
只记录指标、生成日志。不修改 Semaphore,不调度 Worker,不释放资源。
|
||||
"""
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
|
||||
class Metrics:
|
||||
"""全局指标收集器。所有字段只在 asyncio 单线程中写入,无需加锁。"""
|
||||
|
||||
__slots__ = (
|
||||
'start_time',
|
||||
't_produced', 't_admitted', 't_claimed', 't_expired', 't_discarded',
|
||||
't_solve_count', 't_solve_seconds', 't_solve_failed',
|
||||
'solver_goto_seconds', 'solver_inject_seconds', 'solver_initial_seconds',
|
||||
'solver_click_seconds', 'solver_wait_seconds', 'solver_reused_count',
|
||||
'solver_visible_frame_count',
|
||||
's_physical_count', 's_physical_wait_seconds', 's_physical_hold_seconds',
|
||||
'p_physical_count', 'p_physical_wait_seconds', 'p_physical_hold_seconds',
|
||||
'c_physical_count', 'c_physical_wait_seconds', 'c_physical_hold_seconds',
|
||||
'p_email_create_count', 'p_email_create_seconds',
|
||||
'p_page_prepare_count', 'p_page_prepare_seconds',
|
||||
'p_send_count', 'p_send_seconds',
|
||||
'c_page_acquire_count', 'c_page_acquire_seconds',
|
||||
'c_verify_count', 'c_verify_seconds',
|
||||
'c_register_count', 'c_register_seconds',
|
||||
'c_hot_page_hits', 'c_hot_page_misses',
|
||||
'q_sent', 'q_returned', 'q_admitted', 'q_claimed', 'q_expired', 'q_discarded',
|
||||
'q_send_batches', 'q_send_batch_items',
|
||||
'pair_claimed', 'pair_consumed_ok', 'pair_consumed_fail',
|
||||
'success_count',
|
||||
'registration_starts',
|
||||
'_clock', 'started_monotonic', 'recent_success_times',
|
||||
)
|
||||
|
||||
def __init__(self, clock=time.monotonic):
|
||||
self._clock = clock
|
||||
self.started_monotonic = clock()
|
||||
self.recent_success_times = deque()
|
||||
self.start_time = time.time()
|
||||
# T 生命周期
|
||||
self.t_produced = 0
|
||||
self.t_admitted = 0
|
||||
self.t_claimed = 0
|
||||
self.t_expired = 0
|
||||
self.t_discarded = 0
|
||||
self.t_solve_count = 0
|
||||
self.t_solve_seconds = 0.0
|
||||
self.t_solve_failed = 0
|
||||
self.solver_goto_seconds = 0.0
|
||||
self.solver_inject_seconds = 0.0
|
||||
self.solver_initial_seconds = 0.0
|
||||
self.solver_click_seconds = 0.0
|
||||
self.solver_wait_seconds = 0.0
|
||||
self.solver_reused_count = 0
|
||||
self.solver_visible_frame_count = 0
|
||||
self.s_physical_count = 0
|
||||
self.s_physical_wait_seconds = 0.0
|
||||
self.s_physical_hold_seconds = 0.0
|
||||
self.p_physical_count = 0
|
||||
self.p_physical_wait_seconds = 0.0
|
||||
self.p_physical_hold_seconds = 0.0
|
||||
self.c_physical_count = 0
|
||||
self.c_physical_wait_seconds = 0.0
|
||||
self.c_physical_hold_seconds = 0.0
|
||||
self.p_email_create_count = 0
|
||||
self.p_email_create_seconds = 0.0
|
||||
self.p_page_prepare_count = 0
|
||||
self.p_page_prepare_seconds = 0.0
|
||||
self.p_send_count = 0
|
||||
self.p_send_seconds = 0.0
|
||||
self.c_page_acquire_count = 0
|
||||
self.c_page_acquire_seconds = 0.0
|
||||
self.c_verify_count = 0
|
||||
self.c_verify_seconds = 0.0
|
||||
self.c_register_count = 0
|
||||
self.c_register_seconds = 0.0
|
||||
self.c_hot_page_hits = 0
|
||||
self.c_hot_page_misses = 0
|
||||
# Q 生命周期
|
||||
self.q_sent = 0
|
||||
self.q_returned = 0
|
||||
self.q_admitted = 0
|
||||
self.q_claimed = 0
|
||||
self.q_expired = 0
|
||||
self.q_discarded = 0
|
||||
self.q_send_batches = 0
|
||||
self.q_send_batch_items = 0
|
||||
# Pair
|
||||
self.pair_claimed = 0
|
||||
self.pair_consumed_ok = 0
|
||||
self.pair_consumed_fail = 0
|
||||
# 成功数
|
||||
self.success_count = 0
|
||||
self.registration_starts = 0
|
||||
|
||||
def next_registration_task(self):
|
||||
self.registration_starts += 1
|
||||
return self.registration_starts
|
||||
|
||||
def record_success(self):
|
||||
self.success_count += 1
|
||||
self.recent_success_times.append(self._clock())
|
||||
|
||||
def five_minute_success_rate(self):
|
||||
now = self._clock()
|
||||
cutoff = now - 300.0
|
||||
while self.recent_success_times and self.recent_success_times[0] < cutoff:
|
||||
self.recent_success_times.popleft()
|
||||
if not self.recent_success_times:
|
||||
return None if self.success_count == 0 else 0.0
|
||||
elapsed = max(1.0, min(300.0, now - self.started_monotonic))
|
||||
return len(self.recent_success_times) * 60.0 / elapsed
|
||||
|
||||
def runtime_average_success_rate(self):
|
||||
if self.success_count == 0:
|
||||
return None
|
||||
elapsed = max(1.0, self._clock() - self.started_monotonic)
|
||||
return self.success_count * 60.0 / elapsed
|
||||
|
||||
def snapshot(self, inventory, sems):
|
||||
"""生成一行监控日志。"""
|
||||
elapsed = time.time() - self.start_time
|
||||
rate = self.success_count / (elapsed / 60) if elapsed > 60 else 0
|
||||
p_batch_avg = (
|
||||
self.q_send_batch_items / self.q_send_batches
|
||||
if self.q_send_batches else 0
|
||||
)
|
||||
t_solve_avg = (
|
||||
self.t_solve_seconds / self.t_solve_count
|
||||
if self.t_solve_count else 0
|
||||
)
|
||||
solver_goto_avg = (
|
||||
self.solver_goto_seconds / self.t_solve_count
|
||||
if self.t_solve_count else 0
|
||||
)
|
||||
solver_inject_avg = (
|
||||
self.solver_inject_seconds / self.t_solve_count
|
||||
if self.t_solve_count else 0
|
||||
)
|
||||
solver_initial_avg = (
|
||||
self.solver_initial_seconds / self.t_solve_count
|
||||
if self.t_solve_count else 0
|
||||
)
|
||||
solver_click_avg = (
|
||||
self.solver_click_seconds / self.t_solve_count
|
||||
if self.t_solve_count else 0
|
||||
)
|
||||
solver_wait_avg = (
|
||||
self.solver_wait_seconds / self.t_solve_count
|
||||
if self.t_solve_count else 0
|
||||
)
|
||||
solver_reuse_ratio = (
|
||||
self.solver_reused_count / self.t_solve_count
|
||||
if self.t_solve_count else 0
|
||||
)
|
||||
solver_visible_ratio = (
|
||||
self.solver_visible_frame_count / self.t_solve_count
|
||||
if self.t_solve_count else 0
|
||||
)
|
||||
s_phys_wait, s_phys_hold = self._avg_pair(
|
||||
self.s_physical_wait_seconds, self.s_physical_hold_seconds, self.s_physical_count
|
||||
)
|
||||
p_phys_wait, p_phys_hold = self._avg_pair(
|
||||
self.p_physical_wait_seconds, self.p_physical_hold_seconds, self.p_physical_count
|
||||
)
|
||||
c_phys_wait, c_phys_hold = self._avg_pair(
|
||||
self.c_physical_wait_seconds, self.c_physical_hold_seconds, self.c_physical_count
|
||||
)
|
||||
p_email_create = self._avg(self.p_email_create_seconds, self.p_email_create_count)
|
||||
p_page_prepare = self._avg(self.p_page_prepare_seconds, self.p_page_prepare_count)
|
||||
p_send_stage = self._avg(self.p_send_seconds, self.p_send_count)
|
||||
c_page_acquire = self._avg(self.c_page_acquire_seconds, self.c_page_acquire_count)
|
||||
c_verify = self._avg(self.c_verify_seconds, self.c_verify_count)
|
||||
c_register = self._avg(self.c_register_seconds, self.c_register_count)
|
||||
p_send_sem = sems.get("p_send")
|
||||
admission = sems.get("admission")
|
||||
p_send_part = f' p_send:{p_send_sem._value}' if p_send_sem is not None else ''
|
||||
admission_part = (
|
||||
f' t_prog:{admission.t_in_progress} q_inflight:{admission.q_inflight}'
|
||||
if admission is not None else ''
|
||||
)
|
||||
return (
|
||||
f'[*] T:{inventory.t_depth} Q:{inventory.q_depth} '
|
||||
f'phys:{sems["physical"]._value}{p_send_part} t_slot:{sems["t_slot"]._value} '
|
||||
f'q_slot:{sems["q_slot"]._value} q_pend:{sems["q_pending"]._value} '
|
||||
f'p_batch:{p_batch_avg:.1f}{admission_part} '
|
||||
f's_phys:{s_phys_wait:.2f}/{s_phys_hold:.2f} '
|
||||
f'p_phys:{p_phys_wait:.2f}/{p_phys_hold:.2f} '
|
||||
f'c_phys:{c_phys_wait:.2f}/{c_phys_hold:.2f} '
|
||||
f'p_stage:{p_email_create:.2f}/{p_page_prepare:.2f}/{p_send_stage:.2f} '
|
||||
f'c_stage:{c_page_acquire:.2f}/{c_verify:.2f}/{c_register:.2f} '
|
||||
f'c_hot:{self.c_hot_page_hits}/{self.c_hot_page_misses} '
|
||||
f't_solve_avg:{t_solve_avg:.1f} t_solve_fail:{self.t_solve_failed} '
|
||||
f'solver_goto:{solver_goto_avg:.2f} solver_inject:{solver_inject_avg:.2f} '
|
||||
f'solver_initial:{solver_initial_avg:.2f} solver_click:{solver_click_avg:.2f} '
|
||||
f'solver_wait:{solver_wait_avg:.2f} solver_reuse:{solver_reuse_ratio:.2f} '
|
||||
f'solver_visible:{solver_visible_ratio:.2f} '
|
||||
f't_prod:{self.t_produced} t_adm:{self.t_admitted} t_exp:{self.t_expired} '
|
||||
f'q_sent:{self.q_sent} q_ret:{self.q_returned} q_adm:{self.q_admitted} q_exp:{self.q_expired} '
|
||||
f'pair:{self.pair_claimed} ok:{self.pair_consumed_ok} fail:{self.pair_consumed_fail} '
|
||||
f'rate:{rate:.1f}/min #{self.success_count}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _avg(total, count):
|
||||
return total / count if count else 0
|
||||
|
||||
@staticmethod
|
||||
def _avg_pair(wait_total, hold_total, count):
|
||||
if not count:
|
||||
return 0, 0
|
||||
return wait_total / count, hold_total / count
|
||||
@@ -1,171 +0,0 @@
|
||||
"""
|
||||
邮件接收 API 服务器
|
||||
====================
|
||||
接收 Cloudflare Email Routing 转发的邮件,供注册服务查询。
|
||||
|
||||
端点:
|
||||
POST /webhook — Cloudflare 转发邮件到这里
|
||||
GET /check/<email> — 查询某邮箱的验证码
|
||||
GET /domains — 返回可用域名
|
||||
GET /health — 健康检查
|
||||
|
||||
用法:
|
||||
bash start.sh --email-service
|
||||
EMAIL_DOMAIN=your.domain bash start.sh --email-service --port 8080
|
||||
"""
|
||||
import os, re, json, time, sys
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
from http.server import HTTPServer, ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from threading import Lock
|
||||
|
||||
# 配置
|
||||
DEFAULT_DOMAIN = os.environ.get("EMAIL_DOMAIN", "")
|
||||
DEFAULT_PORT = 8080
|
||||
|
||||
# 存储
|
||||
emails = {} # {email_address: [{"code": "ABC123", "time": timestamp, "raw": "..."}]}
|
||||
emails_lock = Lock()
|
||||
|
||||
# 清理过期邮件(5 分钟)
|
||||
def cleanup_old():
|
||||
now = time.time()
|
||||
with emails_lock:
|
||||
for addr in list(emails.keys()):
|
||||
emails[addr] = [e for e in emails[addr] if now - e['time'] < 300]
|
||||
if not emails[addr]:
|
||||
del emails[addr]
|
||||
|
||||
|
||||
def extract_code(text):
|
||||
"""从邮件内容提取验证码(ABC-DEF 或 ABCDEF 格式)"""
|
||||
# 格式1: ABC-DEF
|
||||
m = re.search(r'>([A-Z0-9]{3}-[A-Z0-9]{3})<', text)
|
||||
if m:
|
||||
return m.group(1).replace('-', '')
|
||||
# 格式2: 直接 6 位
|
||||
m = re.search(r'>([A-Z0-9]{6})<', text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# 格式3: 正文中的 6 位
|
||||
m = re.search(r'\b([A-Z0-9]{3}-?[A-Z0-9]{3})\b', text)
|
||||
if m:
|
||||
return m.group(1).replace('-', '')
|
||||
return None
|
||||
|
||||
|
||||
class EmailHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if self.path == '/webhook':
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(content_length).decode('utf-8')
|
||||
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except:
|
||||
data = {}
|
||||
|
||||
# Cloudflare Email Routing 格式
|
||||
to_addr = data.get('to', data.get('recipient', ''))
|
||||
from_addr = data.get('from', data.get('sender', ''))
|
||||
subject = data.get('subject', '')
|
||||
text = data.get('text', '')
|
||||
html = data.get('html', '')
|
||||
|
||||
# 提取验证码
|
||||
content = f"{subject}\n{text}\n{html}"
|
||||
code = extract_code(content)
|
||||
|
||||
if to_addr and code:
|
||||
with emails_lock:
|
||||
if to_addr not in emails:
|
||||
emails[to_addr] = []
|
||||
emails[to_addr].append({
|
||||
'code': code,
|
||||
'time': time.time(),
|
||||
'from': from_addr,
|
||||
'subject': subject
|
||||
})
|
||||
print(f'[+] {to_addr} code={code}', flush=True)
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"ok": True, "code": code}).encode())
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
if path == '/health':
|
||||
self._json({"status": "ok", "emails": len(emails)})
|
||||
|
||||
elif path == '/domains':
|
||||
self._json({"domains": [DEFAULT_DOMAIN]})
|
||||
|
||||
elif path.startswith('/check/'):
|
||||
addr = path[7:] # 去掉 /check/
|
||||
cleanup_old()
|
||||
with emails_lock:
|
||||
items = emails.get(addr, [])
|
||||
if items:
|
||||
# 返回最新的验证码
|
||||
latest = items[-1]
|
||||
self._json({"code": latest['code'], "from": latest['from']})
|
||||
else:
|
||||
self._json({"code": None})
|
||||
|
||||
elif path == '/list':
|
||||
# 列出所有有邮件的地址(调试用)
|
||||
cleanup_old()
|
||||
with emails_lock:
|
||||
result = {addr: len(msgs) for addr, msgs in emails.items()}
|
||||
self._json(result)
|
||||
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def _json(self, data):
|
||||
body = json.dumps(data).encode()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[HTTP] {args[0] if args else format}", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
global DEFAULT_DOMAIN
|
||||
port = DEFAULT_PORT
|
||||
domain = DEFAULT_DOMAIN
|
||||
|
||||
for i, arg in enumerate(sys.argv[1:]):
|
||||
if arg == '--port' and i + 2 <= len(sys.argv):
|
||||
port = int(sys.argv[i + 2])
|
||||
if arg == '--domain' and i + 2 <= len(sys.argv):
|
||||
domain = sys.argv[i + 2]
|
||||
|
||||
DEFAULT_DOMAIN = domain
|
||||
|
||||
print(f"[*] Email server starting on :{port}", flush=True)
|
||||
print(f"[*] Domain: {domain}", flush=True)
|
||||
print(f"[*] Webhook: http://0.0.0.0:{port}/webhook", flush=True)
|
||||
print(f"[*] Check: http://localhost:{port}/check/<email>", flush=True)
|
||||
|
||||
server = ThreadingHTTPServer(('0.0.0.0', port), EmailHandler)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,944 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
acpa_watchdog.py — 自动 CPA 凭据整备驻守进程
|
||||
|
||||
职责:
|
||||
1. 盯 xai_enroller (auth-service.sh) 的出货目录:
|
||||
$XAI_ENROLLER_LOCAL_AUTH_DIR/authenticated/xai-*.json
|
||||
默认 ~/Downloads/grok-free-register-auth/authenticated/
|
||||
xai_enroller 产出的 CPA 文件 base_url=api.x.ai/v1 (付费口, 免费号必 402)
|
||||
+ headers=None (缺 grok-cli 身份头) + email=None。
|
||||
2. 每个新号原样不动地「整备」一份到 PROJECT/keys/cpa_ready/xai-*.json:
|
||||
- base_url → https://cli-chat-proxy.grok.com/v1 (免费 Grok 4.5 口)
|
||||
- headers → grok-cli 身份头(x-grok-client-version/identifier 等)
|
||||
- email → 从 id_token /access_token JWT 里挖(便于辨识, 不影响通信用)
|
||||
- 权限 0600 (合规: 含 refresh_token)
|
||||
3. 探活: 打 cli-chat-proxy /v1/responses model=grok-4.5
|
||||
请求对齐 new-api grok-cli 完整指纹(session 族头 + input 数组 + max_output_tokens 极小)。
|
||||
首探 ≤MAX_PROBES(默认 1,省 free 2M 额度);200 → alive。
|
||||
新号落盘后 ACPA_PROBE_WARMUP_SEC(默认 3s) 再探,避免 mint 后瞬时 permission-denied。
|
||||
单次探针内 403 当场短重试 ACPA_403_IMMEDIATE_RETRIES×SLEEP(默认 2×4s)。
|
||||
仍 403 → chat_denied:不丢,RETEST_AFTER_SEC(默认 180s) 后回测 1 次;
|
||||
回测活 → alive;仍 403 → chat_dead 丢。
|
||||
429 free-usage-exhausted / rate_limited / 401 → 立刻丢。
|
||||
alive 永不复探。
|
||||
4. 状态: keys/cpa_ready/_state.tsv
|
||||
name\tsub\temail\tstatus\tts\tprobes
|
||||
每轮 prune:json 在 cpa_ready/_discarded 都不存在的行直接从 TSV 删掉
|
||||
(删库跑路后不会残留幽灵 alive;盘上有 json 但无 state 会重新进探活)。
|
||||
|
||||
只读 AUTH_DIR, 只写 keys/cpa_ready/。源 CPA 文件不动; 不重铸 device flow;
|
||||
不做 HTTP 灌库 (入库步骤留给灌库脚本, 此处只把号整备到可直接喂 cliproxy / new-api)。
|
||||
|
||||
CLI:
|
||||
python3 acpa_watchdog.py # 常驻轮询 (默认 2s), Ctrl-C 退
|
||||
python3 acpa_watchdog.py --once # 单次扫一轮现存号就退 (CLI 自检)
|
||||
python3 acpa_watchdog.py --interval 4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# paths — portable: follow project root of this script; AUTH overridable via env
|
||||
# ---------------------------------------------------------------------------
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent # .../grok-free-register
|
||||
# Align with xai_enroller.service DEFAULT_LOCAL_AUTH_DIR
|
||||
# (~/Downloads/grok-free-register-auth/authenticated by default)
|
||||
_AUTH_BASE = Path(
|
||||
os.environ.get(
|
||||
"XAI_ENROLLER_LOCAL_AUTH_DIR",
|
||||
os.environ.get(
|
||||
"XAI_AUTH_SERVICE_LOCAL_DIR",
|
||||
str(Path.home() / "Downloads" / "grok-free-register-auth"),
|
||||
),
|
||||
)
|
||||
).expanduser()
|
||||
AUTH_DIR = _AUTH_BASE / "authenticated"
|
||||
OUT_DIR = PROJECT_ROOT / "keys" / "cpa_ready"
|
||||
DISCARD_DIR = OUT_DIR / "_discarded"
|
||||
STATE_FILE = OUT_DIR / "_state.tsv"
|
||||
|
||||
# grok-cli 静态身份头(对齐 new-api common.GrokCLI* / SetupRequestHeader)
|
||||
# session/req/agent 等 per-request 头只在探针里生成,不写进落盘 json。
|
||||
CLIPROXY_BASE_URL = "https://cli-chat-proxy.grok.com/v1"
|
||||
CLIPROXY_HEADERS = {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-shell",
|
||||
"x-compaction-at": "400000",
|
||||
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
|
||||
}
|
||||
|
||||
RUN = True
|
||||
|
||||
|
||||
def _sig(signum, _frame):
|
||||
global RUN
|
||||
RUN = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, _sig)
|
||||
signal.signal(signal.SIGTERM, _sig)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def log(msg: str) -> None:
|
||||
print(f"[acpa {time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
|
||||
def b64url_decode(seg: str) -> bytes:
|
||||
seg += "=" * (-len(seg) % 4)
|
||||
return base64.urlsafe_b64decode(seg)
|
||||
|
||||
|
||||
def jwt_payload(token: str) -> dict:
|
||||
if not token or token.count(".") != 2:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(b64url_decode(token.split(".")[1]))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def load_source(path: Path) -> dict | None:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
log(f" ✗ 读 {path.name} 失败: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def ensure_dir(p: Path) -> None:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(p, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def fallback_email(src: dict) -> str:
|
||||
"""xai_enroller 产的 file email 字段是 None。从 JWT 里挖一个标识。"""
|
||||
for key in ("id_token", "access_token"):
|
||||
pl = jwt_payload(src.get(key) or "")
|
||||
for f in ("email", "preferred_username", "sub"):
|
||||
v = pl.get(f)
|
||||
if v:
|
||||
return str(v)
|
||||
return ""
|
||||
|
||||
|
||||
def finalize(src: dict) -> tuple[str, dict]:
|
||||
"""把 xai_enroller 产的 raw CPA 整备成 cli-chat-proxy 可用格式。返回 (sub, dst)。"""
|
||||
dst = copy.deepcopy(src)
|
||||
dst["base_url"] = CLIPROXY_BASE_URL
|
||||
dst["headers"] = dict(CLIPROXY_HEADERS)
|
||||
# token_endpoint 不变 (auth.x.ai/oauth2/token, refresh 用)
|
||||
dst.setdefault("auth_kind", "oauth")
|
||||
dst.setdefault("type", "xai")
|
||||
if not dst.get("email"):
|
||||
dst["email"] = fallback_email(src)
|
||||
sub = dst.get("sub") or jwt_payload(dst.get("access_token", "")).get("sub", "")
|
||||
dst["sub"] = sub
|
||||
return sub, dst
|
||||
|
||||
|
||||
def write_out(name: str, entry: dict) -> Path:
|
||||
ensure_dir(OUT_DIR)
|
||||
target = OUT_DIR / name
|
||||
tmp = target.with_suffix(target.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(entry, separators=(",", ":"), ensure_ascii=False),
|
||||
encoding="utf-8")
|
||||
os.replace(tmp, target)
|
||||
os.chmod(target, 0o600)
|
||||
return target
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# probe liveness — 使用项目自带 .venv(已装 curl_cffi)
|
||||
# 完整 grok-cli 指纹;默认每号只首探 1 次(free ~2M,禁止连 ping)
|
||||
# ---------------------------------------------------------------------------
|
||||
_VENV_PY = PROJECT_ROOT / ".venv" / "bin" / "python"
|
||||
_PROBE_SRC = r'''
|
||||
import json, sys, uuid
|
||||
try:
|
||||
from curl_cffi import requests
|
||||
except Exception as e:
|
||||
print("NO_CURL_CFFI", str(e)); sys.exit(3)
|
||||
|
||||
fp = sys.argv[1]
|
||||
d = json.load(open(fp))
|
||||
at = d.get("access_token") or ""
|
||||
if not at:
|
||||
print(json.dumps({"status": "token_dead", "http": 401, "snippet": "no access_token"}))
|
||||
sys.exit(0)
|
||||
|
||||
# 对齐 new-api-fusion relay/channel/grok_cli SetupRequestHeader + common.GrokCLI*
|
||||
sid = str(uuid.uuid4())
|
||||
rid = str(uuid.uuid4())
|
||||
hdrs = {
|
||||
"Authorization": "Bearer " + at,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
"x-grok-client-identifier": "grok-shell",
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-compaction-at": "400000",
|
||||
"Connection": "Keep-Alive",
|
||||
"x-grok-session-id": sid,
|
||||
"x-grok-conv-id": sid,
|
||||
"x-grok-req-id": rid,
|
||||
"x-grok-turn-idx": "1",
|
||||
"x-grok-agent-id": "agent-" + rid[:8],
|
||||
"x-grok-model-override": "grok-4.5",
|
||||
}
|
||||
email = d.get("email") or ""
|
||||
sub = d.get("sub") or ""
|
||||
if email:
|
||||
hdrs["x-email"] = str(email)
|
||||
if sub:
|
||||
hdrs["x-userid"] = str(sub)
|
||||
|
||||
base = (d.get("base_url") or "https://cli-chat-proxy.grok.com/v1").rstrip("/")
|
||||
# 兼容 base 已带 /v1 或只有 host
|
||||
if base.endswith("/responses"):
|
||||
url = base
|
||||
else:
|
||||
url = base + "/responses"
|
||||
|
||||
# input 必须是 items 数组;裸字符串会被上游拒 / 误 403
|
||||
# max_output_tokens 压到极小,free 号额度只花探针这一枪
|
||||
body = {
|
||||
"model": "grok-4.5",
|
||||
"store": False,
|
||||
"stream": False,
|
||||
"max_output_tokens": 16,
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "ok"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
try:
|
||||
r = requests.post(url, json=body, headers=hdrs, impersonate="chrome", timeout=45)
|
||||
txt = r.text or ""
|
||||
code = r.status_code
|
||||
except Exception as e:
|
||||
print("EXC", str(e)[:200]); sys.exit(2)
|
||||
|
||||
status = "unknown"
|
||||
low = txt.lower()
|
||||
if code == 200 and ("output" in txt or "completed" in low or '"status"' in low):
|
||||
# 200 即视为通路可用;不要求模型真回 "ok"(省解码/推理)
|
||||
status = "alive"
|
||||
elif (
|
||||
code == 429
|
||||
or "free-usage-exhausted" in low
|
||||
or "used all the include" in low
|
||||
or "rate limit" in low
|
||||
or "rate_limit" in low
|
||||
):
|
||||
status = "free_exhausted" if ("free-usage" in low or "used all" in low) else "rate_limited"
|
||||
elif code == 402 or "personal-team-blocked" in txt or "spending-limit" in txt:
|
||||
status = "no_quota_paid_end"
|
||||
elif code == 403 or "permission-denied" in txt or "chat endpoint is denied" in low:
|
||||
status = "chat_denied"
|
||||
elif code == 401:
|
||||
status = "token_dead"
|
||||
elif code == 422:
|
||||
# body 形态问题:标 unknown 便于发现探针回归,不直接当号死
|
||||
status = "unknown"
|
||||
elif "forbidden" in low:
|
||||
status = "chat_denied"
|
||||
print(json.dumps({"status": status, "http": code, "snippet": txt[:200]}))
|
||||
sys.exit(0)
|
||||
'''
|
||||
|
||||
# 省额度:默认首探 1 次;403 当场短重试后再标 soft,默认 3 分钟后延迟回测 1 次。
|
||||
# alive 永不复探。429 free-usage-exhausted / token_dead 等真坏号立刻丢。
|
||||
MAX_PROBES = int(
|
||||
os.environ.get(
|
||||
"ACPA_MAX_PROBES",
|
||||
os.environ.get("ACPA_PROBE_RETRIES", "1"),
|
||||
)
|
||||
)
|
||||
PROBE_RETRY_SLEEP = float(os.environ.get("ACPA_PROBE_RETRY_SLEEP", "1.0"))
|
||||
# 新号 finalize 后稍等再首探:上游常对刚 mint 的 access 回瞬时 permission-denied
|
||||
PROBE_WARMUP_SEC = float(os.environ.get("ACPA_PROBE_WARMUP_SEC", "3.0"))
|
||||
# 单次 probe_one 内 403 当场短重试(不算额外 MAX_PROBES 预算)
|
||||
IMMEDIATE_403_RETRIES = int(os.environ.get("ACPA_403_IMMEDIATE_RETRIES", "2"))
|
||||
IMMEDIATE_403_SLEEP = float(os.environ.get("ACPA_403_IMMEDIATE_SLEEP", "4.0"))
|
||||
RETEST_AFTER_SEC = int(os.environ.get("ACPA_RETEST_AFTER_SEC", "180")) # 3 分钟
|
||||
RETEST_MAX = int(os.environ.get("ACPA_RETEST_MAX", "1")) # 403 延迟回测次数
|
||||
|
||||
# 终态:不再探(alive 保留;下列坏号 discard)
|
||||
TERMINAL_OK = frozenset({"alive"})
|
||||
TERMINAL_BAD = frozenset({
|
||||
"free_exhausted", # 429 免费额度耗尽 → 丢
|
||||
"rate_limited", # 其它 429 → 丢
|
||||
"token_dead", # 401
|
||||
"no_quota_paid_end",
|
||||
"chat_dead", # 5 分钟回测后仍 403 → 真坏,丢
|
||||
"discarded",
|
||||
})
|
||||
TERMINAL_STATUSES = TERMINAL_OK | TERMINAL_BAD
|
||||
|
||||
# 仅这些立刻/确认后丢出 cpa_ready(403 首探不在此列)
|
||||
DISCARD_STATUSES = frozenset({
|
||||
"free_exhausted",
|
||||
"rate_limited",
|
||||
"token_dead",
|
||||
"no_quota_paid_end",
|
||||
"chat_dead",
|
||||
})
|
||||
|
||||
# 等 5 分钟回测的软状态(保留 json,不进 acc,不丢)
|
||||
SOFT_RETEST_STATUSES = frozenset({
|
||||
"chat_denied",
|
||||
"forbidden_domain_ban", # 历史误标,按 403 软状态回测
|
||||
"probe_cap", # 历史:首探 2 次 403 被钉死,捞回可回测
|
||||
})
|
||||
|
||||
|
||||
def probe_one(path: Path) -> dict:
|
||||
"""用子进程跑探活(隔离 curl_cffi 环境)。返回 {status, http, snippet}."""
|
||||
import subprocess
|
||||
cp = subprocess.run(
|
||||
[str(_VENV_PY), "-c", _PROBE_SRC, str(path)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
out = cp.stdout.strip()
|
||||
if out.startswith("alive") or out.lstrip().startswith("{"):
|
||||
try:
|
||||
idx = out.find("{")
|
||||
if idx >= 0:
|
||||
return json.loads(out[idx:])
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "probe_err", "http": -1, "snippet": (out or cp.stderr[:200])[:200]}
|
||||
|
||||
|
||||
def probe_one_soft(path: Path) -> dict:
|
||||
"""
|
||||
一次「逻辑探针」:底层请求 + 403/瞬时错误当场短重试。
|
||||
仍计为 probe_budgeted 的 1 次 used(不因短重试烧额外额度记账)。
|
||||
"""
|
||||
last = {"status": "probe_err", "http": -1, "snippet": ""}
|
||||
attempts = 1 + max(0, IMMEDIATE_403_RETRIES)
|
||||
for i in range(attempts):
|
||||
last = probe_one(path)
|
||||
st = last.get("status")
|
||||
if st in (
|
||||
"alive", "free_exhausted", "rate_limited",
|
||||
"no_quota_paid_end", "token_dead",
|
||||
):
|
||||
return last
|
||||
if st in ("chat_denied", "forbidden_domain_ban", "probe_err", "unknown"):
|
||||
if i + 1 < attempts:
|
||||
time.sleep(IMMEDIATE_403_SLEEP)
|
||||
continue
|
||||
break
|
||||
if last.get("status") == "forbidden_domain_ban":
|
||||
last = {
|
||||
"status": "chat_denied",
|
||||
"http": last.get("http", 403),
|
||||
"snippet": last.get("snippet", "")[:200],
|
||||
}
|
||||
return last
|
||||
|
||||
|
||||
def probe_budgeted(path: Path, budget: int) -> tuple[dict, int]:
|
||||
"""
|
||||
在 budget 次内探活。返回 (最后结果, 实际探活次数)。
|
||||
alive / 额度终态立刻停;403 可在预算内连探;最终 403 保持 chat_denied(不改 probe_cap)。
|
||||
"""
|
||||
budget = max(0, int(budget))
|
||||
if budget <= 0:
|
||||
return {"status": "chat_denied", "http": -1, "snippet": "no probe budget"}, 0
|
||||
last = {"status": "probe_err", "http": -1, "snippet": ""}
|
||||
used = 0
|
||||
for i in range(budget):
|
||||
last = probe_one_soft(path)
|
||||
used += 1
|
||||
st = last.get("status")
|
||||
if st in (
|
||||
"alive", "free_exhausted", "rate_limited",
|
||||
"no_quota_paid_end", "token_dead",
|
||||
):
|
||||
return last, used
|
||||
if i + 1 < budget and st in (
|
||||
"chat_denied", "forbidden_domain_ban", "probe_err", "unknown",
|
||||
):
|
||||
time.sleep(PROBE_RETRY_SLEEP)
|
||||
continue
|
||||
break
|
||||
# 首探/回测结束仍是 403 → 保持 chat_denied,交给延迟回测或 chat_dead
|
||||
if last.get("status") in ("forbidden_domain_ban",):
|
||||
last = {
|
||||
"status": "chat_denied",
|
||||
"http": last.get("http", 403),
|
||||
"snippet": last.get("snippet", "")[:200],
|
||||
}
|
||||
return last, used
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# state — name \t sub \t email \t status \t ts [\t probes]
|
||||
# ---------------------------------------------------------------------------
|
||||
def load_state() -> dict:
|
||||
"""{name: {sub, email, status, ts, probes}}"""
|
||||
if not STATE_FILE.exists():
|
||||
return {}
|
||||
st = {}
|
||||
for ln in STATE_FILE.read_text(encoding="utf-8").splitlines():
|
||||
parts = ln.split("\t")
|
||||
if len(parts) >= 5:
|
||||
name, sub, email, status, ts = parts[:5]
|
||||
probes = 0
|
||||
if len(parts) >= 6:
|
||||
try:
|
||||
probes = int(parts[5] or 0)
|
||||
except ValueError:
|
||||
probes = 0
|
||||
# 历史行没有 probes:已有终态按已探满处理,避免重启后重烧额度
|
||||
if probes <= 0 and status in TERMINAL_STATUSES | {
|
||||
"chat_denied", "forbidden_domain_ban", "unknown", "probe_err",
|
||||
}:
|
||||
probes = MAX_PROBES if status != "alive" else 1
|
||||
if probes <= 0 and status == "alive":
|
||||
probes = 1
|
||||
st[name] = {
|
||||
"sub": sub, "email": email, "status": status,
|
||||
"ts": ts, "probes": probes,
|
||||
}
|
||||
return st
|
||||
|
||||
|
||||
def write_state(st: dict) -> None:
|
||||
ensure_dir(OUT_DIR)
|
||||
lines = []
|
||||
for name, v in sorted(st.items()):
|
||||
lines.append("\t".join([
|
||||
name, v.get("sub", ""), v.get("email", ""),
|
||||
v.get("status", ""), str(v.get("ts", "")),
|
||||
str(int(v.get("probes") or 0)),
|
||||
]))
|
||||
tmp = STATE_FILE.with_suffix(".tsv.tmp")
|
||||
tmp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
||||
os.replace(tmp, STATE_FILE)
|
||||
os.chmod(STATE_FILE, 0o600)
|
||||
|
||||
|
||||
def json_exists_for(name: str) -> bool:
|
||||
"""cpa_ready 主目录或 _discarded 里是否还有该号 json。"""
|
||||
return (OUT_DIR / name).exists() or (DISCARD_DIR / name).exists()
|
||||
|
||||
|
||||
def prune_orphan_state(st: dict) -> int:
|
||||
"""
|
||||
删库跑路 / 手工清 json 后:TSV 里既无 cpa_ready 也无 _discarded 文件的行直接 drop。
|
||||
避免幽灵 alive 把 sync/账本钉死。返回删除行数。
|
||||
"""
|
||||
if not st:
|
||||
return 0
|
||||
drop = [name for name in list(st.keys()) if not json_exists_for(name)]
|
||||
if not drop:
|
||||
return 0
|
||||
for name in drop:
|
||||
del st[name]
|
||||
write_state(st)
|
||||
log(f" 🧹 prune orphan state {len(drop)} 行(盘上无 json)")
|
||||
return len(drop)
|
||||
|
||||
|
||||
def should_discard(status: str, probes: int = 0) -> bool:
|
||||
"""仅真坏号丢:429 额度耗尽 / 401 / 回测后仍 403(chat_dead)。首探 403 不丢。"""
|
||||
return status in DISCARD_STATUSES
|
||||
|
||||
|
||||
def discard_bad(name: str, st: dict, *, reason: str = "") -> bool:
|
||||
"""
|
||||
坏号移出 keys/cpa_ready/ → keys/cpa_ready/_discarded/
|
||||
state 标记 discarded,sync_acc 只认 alive 故 acc.md 自动剔除。
|
||||
返回是否执行了丢弃。
|
||||
"""
|
||||
entry = st.get(name) or {}
|
||||
status = entry.get("status", reason or "bad")
|
||||
probes = _probes(entry) if entry else MAX_PROBES
|
||||
if status == "discarded":
|
||||
# 已丢过:确保 json 不在主目录
|
||||
p = OUT_DIR / name
|
||||
if p.exists():
|
||||
ensure_dir(DISCARD_DIR)
|
||||
dest = DISCARD_DIR / name
|
||||
try:
|
||||
os.replace(p, dest)
|
||||
except OSError:
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
if not should_discard(status, probes) and not reason:
|
||||
return False
|
||||
|
||||
ensure_dir(DISCARD_DIR)
|
||||
src = OUT_DIR / name
|
||||
moved = False
|
||||
if src.exists():
|
||||
dest = DISCARD_DIR / name
|
||||
try:
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
os.replace(src, dest)
|
||||
os.chmod(dest, 0o600)
|
||||
moved = True
|
||||
except OSError as exc:
|
||||
log(f" ✗ 丢弃移动失败 {name}: {exc}")
|
||||
try:
|
||||
src.unlink()
|
||||
moved = True
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
st[name] = {
|
||||
"sub": entry.get("sub", ""),
|
||||
"email": entry.get("email", ""),
|
||||
"status": "discarded",
|
||||
"ts": int(time.time()),
|
||||
"probes": probes if probes else MAX_PROBES,
|
||||
}
|
||||
# 旁路账本:便于扫为何丢
|
||||
try:
|
||||
ledger = DISCARD_DIR / "_discarded.tsv"
|
||||
with ledger.open("a", encoding="utf-8") as fh:
|
||||
fh.write(
|
||||
f"{int(time.time())}\t{name}\t{entry.get('email','')}\t"
|
||||
f"{status}\t{reason or status}\n"
|
||||
)
|
||||
os.chmod(ledger, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
log(
|
||||
f" 🗑 discard {name} [{status}]"
|
||||
f"{' → _discarded/' if moved else ' (no json)'}"
|
||||
f" email={entry.get('email','')}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def sweep_discard(st: dict) -> int:
|
||||
"""扫 state + cpa_ready,丢掉所有真坏号 json。返回丢弃个数。"""
|
||||
n = 0
|
||||
for name in list(st.keys()):
|
||||
if not RUN:
|
||||
break
|
||||
v = st[name]
|
||||
if v.get("status") == "discarded":
|
||||
discard_bad(name, st) # 清残留 json
|
||||
continue
|
||||
# 软 403 已用完延迟回测次数 → 升格 chat_dead 再丢
|
||||
if v.get("status") in SOFT_RETEST_STATUSES:
|
||||
if _retests_done(v) >= RETEST_MAX and _age_sec(v) >= RETEST_AFTER_SEC:
|
||||
v["status"] = "chat_dead"
|
||||
st[name] = v
|
||||
if should_discard(v.get("status", ""), _probes(v)):
|
||||
if discard_bad(name, st):
|
||||
n += 1
|
||||
write_state(st)
|
||||
return n
|
||||
|
||||
|
||||
def rescue_soft_discards(st: dict) -> int:
|
||||
"""
|
||||
把误丢的 403/probe_cap 从 _discarded 捞回主目录,标 chat_denied,
|
||||
立刻可做一次延迟回测(ts 回拨到已到期)。
|
||||
账本原因为 soft,或 state 已 discarded 但 json 仍在 _discarded 且无硬坏标记。
|
||||
"""
|
||||
ensure_dir(DISCARD_DIR)
|
||||
ensure_dir(OUT_DIR)
|
||||
soft_reasons = SOFT_RETEST_STATUSES | {"probe_cap", "chat_denied", "forbidden_domain_ban"}
|
||||
soft_names: set[str] = set()
|
||||
ledger = DISCARD_DIR / "_discarded.tsv"
|
||||
if ledger.exists():
|
||||
for ln in ledger.read_text(encoding="utf-8").splitlines():
|
||||
parts = ln.split("\t")
|
||||
if len(parts) >= 5:
|
||||
name, prev_st, reason = parts[1], parts[3], parts[4]
|
||||
if prev_st in soft_reasons or reason in soft_reasons:
|
||||
soft_names.add(name)
|
||||
elif len(parts) >= 4:
|
||||
name, prev_st = parts[1], parts[3]
|
||||
if prev_st in soft_reasons:
|
||||
soft_names.add(name)
|
||||
|
||||
n = 0
|
||||
for p in sorted(DISCARD_DIR.glob("xai-*.json")):
|
||||
if not RUN:
|
||||
break
|
||||
name = p.name
|
||||
# 账本标 soft,或 state 缺/已 discarded 时默认按 soft 捞(旧误丢)
|
||||
entry = st.get(name) or {}
|
||||
prev = entry.get("status", "discarded")
|
||||
if name not in soft_names and prev not in ("discarded", "") and prev not in soft_reasons:
|
||||
continue
|
||||
if name not in soft_names and prev == "discarded":
|
||||
# 无账本线索:仍捞(用户确认 403 常误杀);真 429 已不在 soft 集合时靠账本
|
||||
soft_names.add(name)
|
||||
if name not in soft_names:
|
||||
continue
|
||||
dest = OUT_DIR / name
|
||||
try:
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
os.replace(p, dest)
|
||||
os.chmod(dest, 0o600)
|
||||
except OSError as exc:
|
||||
log(f" ✗ rescue 移动失败 {name}: {exc}")
|
||||
continue
|
||||
# ts 回拨:立刻进入可回测窗口(不额外空等 RETEST_AFTER_SEC)
|
||||
st[name] = {
|
||||
"sub": entry.get("sub", ""),
|
||||
"email": entry.get("email", ""),
|
||||
"status": "chat_denied",
|
||||
"ts": int(time.time()) - RETEST_AFTER_SEC,
|
||||
"probes": MAX_PROBES, # 留给 1 次回测额度(与当前 MAX_PROBES 对齐)
|
||||
}
|
||||
n += 1
|
||||
log(f" ↩ rescue {name} → chat_denied (retest due) probes={MAX_PROBES}")
|
||||
if n:
|
||||
write_state(st)
|
||||
return n
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main loop
|
||||
# ---------------------------------------------------------------------------
|
||||
def _probes(entry: dict) -> int:
|
||||
try:
|
||||
return int(entry.get("probes") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _age_sec(entry: dict) -> int:
|
||||
try:
|
||||
return max(0, int(time.time()) - int(entry.get("ts") or 0))
|
||||
except (TypeError, ValueError):
|
||||
return RETEST_AFTER_SEC
|
||||
|
||||
|
||||
def _retests_done(entry: dict) -> int:
|
||||
"""首探预算用 MAX_PROBES;超出部分算延迟回测次数。"""
|
||||
return max(0, _probes(entry) - MAX_PROBES)
|
||||
|
||||
|
||||
def should_probe(name: str, st: dict) -> bool:
|
||||
"""
|
||||
新号:首探(≤MAX_PROBES 次当场连探)。
|
||||
403 软状态:满首探后等 RETEST_AFTER_SEC,再给 RETEST_MAX 次回测。
|
||||
429/真坏/终态:不探。
|
||||
"""
|
||||
if name not in st:
|
||||
return True
|
||||
cur = st[name].get("status", "")
|
||||
if cur == "discarded" or cur in TERMINAL_STATUSES:
|
||||
return False
|
||||
probes = _probes(st[name])
|
||||
|
||||
if cur in SOFT_RETEST_STATUSES:
|
||||
if _retests_done(st[name]) >= RETEST_MAX:
|
||||
return False
|
||||
# 首探未打满(异常中断)→ 先补完首探,不算延迟回测
|
||||
if probes < MAX_PROBES:
|
||||
return True
|
||||
return _age_sec(st[name]) >= RETEST_AFTER_SEC
|
||||
|
||||
# 新号 / pending / 临时错误:未满首探预算可探
|
||||
if probes < MAX_PROBES and cur in ("", "pending", "probe_err", "unknown"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def process_file(name: str, st: dict, *, force_probe: bool = False) -> None:
|
||||
src_path = AUTH_DIR / name
|
||||
# 回测/整备也可只读 cpa_ready 已有 json(rescue 后 AUTH 可能仍在)
|
||||
out_existing = OUT_DIR / name
|
||||
if not src_path.exists() and not out_existing.exists():
|
||||
return
|
||||
|
||||
src = None
|
||||
if src_path.exists():
|
||||
src = load_source(src_path)
|
||||
if (not src or not src.get("access_token")) and out_existing.exists():
|
||||
src = load_source(out_existing)
|
||||
if not src:
|
||||
return
|
||||
if not src.get("access_token"):
|
||||
log(f" ✗ {name} 无 access_token, 跳过")
|
||||
return
|
||||
|
||||
if name in st and st[name].get("status") == "discarded":
|
||||
return
|
||||
|
||||
sub, entry = finalize(src)
|
||||
out_path = write_out(name, entry)
|
||||
|
||||
already = _probes(st[name]) if name in st else 0
|
||||
prev_status = st[name].get("status", "") if name in st else ""
|
||||
is_soft_retest = (
|
||||
prev_status in SOFT_RETEST_STATUSES
|
||||
and already >= MAX_PROBES
|
||||
and _retests_done(st.get(name, {})) < RETEST_MAX
|
||||
)
|
||||
|
||||
if not force_probe and not should_probe(name, st):
|
||||
if name in st and should_discard(st[name].get("status", ""), already):
|
||||
discard_bad(name, st)
|
||||
write_state(st)
|
||||
return
|
||||
# 软状态未到点:静默整备
|
||||
if prev_status in SOFT_RETEST_STATUSES:
|
||||
left = max(0, RETEST_AFTER_SEC - _age_sec(st[name]))
|
||||
if already >= MAX_PROBES and _retests_done(st[name]) < RETEST_MAX:
|
||||
log(
|
||||
f" • {name} 403 待回测 "
|
||||
f"({left}s / retest={_retests_done(st[name])}/{RETEST_MAX})"
|
||||
)
|
||||
return
|
||||
log(
|
||||
f" • {name} 整备覆盖 "
|
||||
f"({prev_status or '-'}/probes={already}) → {out_path.name}"
|
||||
)
|
||||
return
|
||||
|
||||
if force_probe and already >= MAX_PROBES + RETEST_MAX and not os.environ.get("ACPA_FORCE_IGNORE_CAP"):
|
||||
log(f" • {name} 已 probes={already},跳过强制重探(省额度)")
|
||||
if should_discard(st.get(name, {}).get("status", ""), already):
|
||||
discard_bad(name, st)
|
||||
write_state(st)
|
||||
return
|
||||
|
||||
if is_soft_retest:
|
||||
budget = 1
|
||||
phase = f"403回测×1 (after {RETEST_AFTER_SEC}s, done={_retests_done(st[name])}/{RETEST_MAX})"
|
||||
else:
|
||||
budget = max(0, MAX_PROBES - already)
|
||||
phase = f"首探×≤{budget} (used={already}/{MAX_PROBES})"
|
||||
|
||||
# 新号首探前 warmup:刚 mint 的 access 常被上游瞬时 403
|
||||
if not is_soft_retest and PROBE_WARMUP_SEC > 0:
|
||||
time.sleep(PROBE_WARMUP_SEC)
|
||||
|
||||
# 新号首探前 warmup:刚 mint 的 access 常被上游瞬时 403
|
||||
if not is_soft_retest and PROBE_WARMUP_SEC > 0:
|
||||
time.sleep(PROBE_WARMUP_SEC)
|
||||
|
||||
log(f" ▸ {name} 整备完成, {phase} ...")
|
||||
r, used = probe_budgeted(out_path, budget)
|
||||
status = r.get("status", "probe_err")
|
||||
snip = r.get("snippet", "")
|
||||
new_probes = already + used
|
||||
|
||||
# 延迟回测仍 403 → 真坏 chat_dead
|
||||
if is_soft_retest and status in (
|
||||
"chat_denied", "forbidden_domain_ban", "probe_cap", "probe_err", "unknown",
|
||||
):
|
||||
status = "chat_dead"
|
||||
log(
|
||||
f" {name}: [chat_dead] 回测仍 403/失败 http={r.get('http')} "
|
||||
f"probes={new_probes} {snip[:70]}"
|
||||
)
|
||||
else:
|
||||
log(
|
||||
f" {name}: [{status}] http={r.get('http')} "
|
||||
f"probes={new_probes} {snip[:70]}"
|
||||
)
|
||||
|
||||
st[name] = {
|
||||
"sub": sub,
|
||||
"email": entry.get("email", ""),
|
||||
"status": status,
|
||||
"ts": int(time.time()),
|
||||
"probes": new_probes,
|
||||
}
|
||||
if should_discard(status, new_probes):
|
||||
discard_bad(name, st)
|
||||
write_state(st)
|
||||
|
||||
|
||||
def scan_once(st: dict) -> int:
|
||||
"""扫一轮:新号首探;403 到期回测;429 等真坏丢弃;alive 只刷整备。"""
|
||||
if not AUTH_DIR.exists():
|
||||
log(f"⚠ 出货目录不存在: {AUTH_DIR} (auth-service.sh 启动后才会建)")
|
||||
# 仍处理 cpa_ready 里待回测/rescue 的号
|
||||
pruned = prune_orphan_state(st)
|
||||
if pruned:
|
||||
log(f"本轮 prune 幽灵 state {pruned} 行")
|
||||
discarded = sweep_discard(st)
|
||||
if discarded:
|
||||
log(f"本轮丢弃坏号 {discarded} 个 → {DISCARD_DIR.name}/")
|
||||
|
||||
names: set[str] = set()
|
||||
if AUTH_DIR.exists():
|
||||
names.update(p.name for p in AUTH_DIR.glob("xai-*.json"))
|
||||
# cpa_ready 里所有现存 json 都纳入扫描:
|
||||
# - soft 到期回测
|
||||
# - 无 state(删 TSV 后残留 / 手工丢入)→ 当新号首探
|
||||
# - alive 走 should_probe=False 分支只刷整备
|
||||
if OUT_DIR.exists():
|
||||
names.update(p.name for p in OUT_DIR.glob("xai-*.json"))
|
||||
|
||||
n = 0
|
||||
for name in sorted(names):
|
||||
if not RUN:
|
||||
break
|
||||
if name in st and st[name].get("status") == "discarded":
|
||||
continue
|
||||
if name in st and not should_probe(name, st):
|
||||
if should_discard(st[name].get("status", ""), _probes(st[name])):
|
||||
discard_bad(name, st)
|
||||
write_state(st)
|
||||
continue
|
||||
src_path = AUTH_DIR / name if (AUTH_DIR / name).exists() else OUT_DIR / name
|
||||
src = load_source(src_path) if src_path.exists() else None
|
||||
if src and src.get("access_token"):
|
||||
_, entry = finalize(src)
|
||||
write_out(name, entry)
|
||||
continue
|
||||
process_file(name, st)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="auto CPA 整备驻守")
|
||||
ap.add_argument("--interval", type=float, default=2.0)
|
||||
ap.add_argument("--once", action="store_true", help="扫一轮就退")
|
||||
ap.add_argument(
|
||||
"--reprobe-soft",
|
||||
action="store_true",
|
||||
help="立刻对 chat_denied/probe_cap 等软 403 做回测(仍 1 次/号,受 RETEST_MAX)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--no-rescue",
|
||||
action="store_true",
|
||||
help="启动时不从 _discarded 捞回误丢的 403",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
ensure_dir(OUT_DIR)
|
||||
ensure_dir(DISCARD_DIR)
|
||||
st = load_state()
|
||||
n_prune = prune_orphan_state(st)
|
||||
|
||||
# 历史 probe_cap 并入 soft,可走延迟回测
|
||||
for name, v in st.items():
|
||||
if v.get("status") == "probe_cap":
|
||||
v["status"] = "chat_denied"
|
||||
|
||||
soft_n = sum(1 for v in st.values() if v.get("status") in SOFT_RETEST_STATUSES)
|
||||
bad_n = sum(
|
||||
1 for v in st.values()
|
||||
if v.get("status") != "discarded"
|
||||
and should_discard(v.get("status", ""), _probes(v))
|
||||
)
|
||||
log(
|
||||
f"启动 | 出货={AUTH_DIR} | 落盘={OUT_DIR} | state={len(st)} 条 "
|
||||
f"(启动 prune {n_prune}) | 首探≤{MAX_PROBES}/号 "
|
||||
f"| warmup={PROBE_WARMUP_SEC}s 403即重试={IMMEDIATE_403_RETRIES}×{IMMEDIATE_403_SLEEP}s "
|
||||
f"| 403回测 after={RETEST_AFTER_SEC}s ×{RETEST_MAX} "
|
||||
f"| soft_403={soft_n} | pending_discard={bad_n}"
|
||||
)
|
||||
|
||||
if not args.no_rescue:
|
||||
n_res = rescue_soft_discards(st)
|
||||
if n_res:
|
||||
log(f"启动捞回误丢 403 {n_res} 个(将回测)")
|
||||
|
||||
n0 = sweep_discard(st)
|
||||
if n0:
|
||||
log(f"启动丢弃真坏号 {n0} 个 → {DISCARD_DIR}/")
|
||||
|
||||
if st and not args.reprobe_soft:
|
||||
log(
|
||||
f"(state 已有: 刷 alive 整备;403 满 {RETEST_AFTER_SEC}s 回测×{RETEST_MAX};"
|
||||
f"429 free_exhausted 立刻丢)"
|
||||
)
|
||||
for name in list(st.keys()):
|
||||
if not RUN:
|
||||
break
|
||||
if st[name].get("status") != "alive":
|
||||
continue
|
||||
src_path = AUTH_DIR / name
|
||||
if src_path.exists():
|
||||
src = load_source(src_path)
|
||||
if src and src.get("access_token"):
|
||||
_, entry = finalize(src)
|
||||
write_out(name, entry)
|
||||
|
||||
if args.reprobe_soft:
|
||||
# soft 立刻回测一次:拨到期 + 把 probes 收成 MAX_PROBES,
|
||||
# 避免旧版 probes=2 在新默认 MAX_PROBES=1 下被算成 retest 已用尽。
|
||||
# 只动 soft;alive 绝不复探(省 free 额度)。
|
||||
now = int(time.time())
|
||||
targets = []
|
||||
for name, v in st.items():
|
||||
if v.get("status") in SOFT_RETEST_STATUSES:
|
||||
v["ts"] = now - RETEST_AFTER_SEC
|
||||
v["probes"] = MAX_PROBES
|
||||
targets.append(name)
|
||||
write_state(st)
|
||||
log(f"--reprobe-soft: {len(targets)} 个 403 软状态立刻回测×1(alive 不动)")
|
||||
for name in targets:
|
||||
if not RUN:
|
||||
break
|
||||
process_file(name, st, force_probe=True)
|
||||
write_state(st)
|
||||
alive = sum(1 for v in st.values() if v.get("status") == "alive")
|
||||
dead = sum(
|
||||
1 for v in st.values()
|
||||
if v.get("status") in DISCARD_STATUSES or v.get("status") == "discarded"
|
||||
)
|
||||
soft = sum(1 for v in st.values() if v.get("status") in SOFT_RETEST_STATUSES)
|
||||
log(f"回测结束 | alive={alive} soft_403={soft} dead/discard={dead} state={len(st)}")
|
||||
return 0
|
||||
|
||||
while RUN:
|
||||
try:
|
||||
n = scan_once(st)
|
||||
if n:
|
||||
log(f"本轮处理 {n} 个号(新号/回测)")
|
||||
except Exception as exc:
|
||||
log(f"轮询异常: {type(exc).__name__} {exc}")
|
||||
if args.once:
|
||||
break
|
||||
slept = 0.0
|
||||
while slept < args.interval and RUN:
|
||||
time.sleep(0.25)
|
||||
slept += 0.25
|
||||
|
||||
log(f"退出 | state={len(st)} 条 写 {STATE_FILE}")
|
||||
write_state(st)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
sync_acc.py — 把 keys/cpa_ready/ 里「探活通过」号的 access_token 同步进 keys/acc.md
|
||||
|
||||
默认只入库 acpa_watchdog 记为 alive 的号(读 keys/cpa_ready/_state.tsv)。
|
||||
403 forbidden / token_dead / probe_err 等不进 acc.md,避免坏号灌库。
|
||||
|
||||
用法:
|
||||
python3 sync_acc.py # 常驻轮询(默认 5s), Ctrl-C 退
|
||||
python3 sync_acc.py --once # 同步一次就退
|
||||
python3 sync_acc.py --all # 忽略探活状态,全量抽 token(排障用)
|
||||
python3 sync_acc.py --interval 10
|
||||
|
||||
acc.md 每轮全量重写(去重),0600。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT = Path(__file__).resolve().parent.parent # grok-free-register/
|
||||
SRC_DIR = PROJECT / "keys" / "cpa_ready"
|
||||
STATE_FILE = SRC_DIR / "_state.tsv"
|
||||
OUT_FILE = PROJECT / "keys" / "acc.md"
|
||||
|
||||
# 默认可入库的探活状态(discarded/坏号不在 cpa_ready 主目录,也不在此集合)
|
||||
ALIVE_STATUSES = frozenset({"alive"})
|
||||
SKIP_STATUSES = frozenset({
|
||||
"discarded", "free_exhausted", "rate_limited", "token_dead",
|
||||
"no_quota_paid_end", "probe_cap", "chat_denied", "chat_dead",
|
||||
"forbidden_domain_ban", "probe_err", "unknown",
|
||||
})
|
||||
|
||||
RUN = True
|
||||
|
||||
|
||||
def _sig(_s, _f):
|
||||
global RUN
|
||||
RUN = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, _sig)
|
||||
signal.signal(signal.SIGTERM, _sig)
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[sync_acc {time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
|
||||
def load_alive_names() -> set[str] | None:
|
||||
"""
|
||||
读 _state.tsv → 状态为 alive 且盘上仍有 json 的文件名集合。
|
||||
无 state 文件返回 None。幽灵 alive(TSV 有、json 无)不计。
|
||||
"""
|
||||
if not STATE_FILE.exists():
|
||||
return None
|
||||
alive: set[str] = set()
|
||||
for ln in STATE_FILE.read_text(encoding="utf-8").splitlines():
|
||||
parts = ln.split("\t")
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
name, status = parts[0], parts[3]
|
||||
if status in ALIVE_STATUSES:
|
||||
# 只认盘上真实存在的号,避免删库后 state 幽灵把计数钉死
|
||||
if (SRC_DIR / name).exists():
|
||||
alive.add(name)
|
||||
# discarded / 坏状态明确不进(双保险;主目录 json 也应已被 watchdog 挪走)
|
||||
elif status in SKIP_STATUSES:
|
||||
continue
|
||||
return alive
|
||||
|
||||
|
||||
def collect(*, require_alive: bool) -> tuple[list[str], dict[str, int]]:
|
||||
"""
|
||||
返回 (去重 token 列表, 计数)。
|
||||
require_alive=True 时只收 _state.tsv 标为 alive 且文件仍在的 xai-*.json。
|
||||
"""
|
||||
files = sorted(glob.glob(str(SRC_DIR / "xai-*.json")))
|
||||
alive_names = load_alive_names() if require_alive else None
|
||||
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
stats = {
|
||||
"files": len(files),
|
||||
"alive_state": len(alive_names) if alive_names is not None else -1,
|
||||
"skipped_not_alive": 0,
|
||||
"no_tok": 0,
|
||||
"bad": 0,
|
||||
"no_state_skip_all": 0,
|
||||
}
|
||||
|
||||
if require_alive and alive_names is None:
|
||||
# 有 cpa json 但还没 state:宁可不灌,避免 403 进库
|
||||
if files:
|
||||
log(f"⚠ 无 {STATE_FILE.name},拒绝全量入库(等 watchdog 探活)")
|
||||
stats["no_state_skip_all"] = len(files)
|
||||
return [], stats
|
||||
|
||||
for f in files:
|
||||
name = Path(f).name
|
||||
if require_alive and alive_names is not None and name not in alive_names:
|
||||
stats["skipped_not_alive"] += 1
|
||||
continue
|
||||
try:
|
||||
d = json.loads(Path(f).read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
stats["bad"] += 1
|
||||
continue
|
||||
tok = d.get("access_token")
|
||||
if not isinstance(tok, str) or not tok:
|
||||
stats["no_tok"] += 1
|
||||
continue
|
||||
if tok in seen:
|
||||
continue
|
||||
seen.add(tok)
|
||||
out.append(tok)
|
||||
return out, stats
|
||||
|
||||
|
||||
def write_acc(tokens: list[str]) -> None:
|
||||
OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 固定同目录临时文件,避免 with_suffix 边界情况 + 保证 replace 原子
|
||||
tmp = OUT_FILE.parent / (OUT_FILE.name + ".tmp")
|
||||
tmp.write_text("\n".join(tokens) + ("\n" if tokens else ""), encoding="utf-8")
|
||||
os.replace(tmp, OUT_FILE)
|
||||
try:
|
||||
os.chmod(OUT_FILE, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def sync_once(*, require_alive: bool) -> int:
|
||||
toks, st = collect(require_alive=require_alive)
|
||||
write_acc(toks)
|
||||
mode = "alive-only" if require_alive else "all"
|
||||
log(
|
||||
f"sync → {OUT_FILE.name}: {len(toks)} tokens [{mode}] "
|
||||
f"(files={st['files']} state_alive={st['alive_state']} "
|
||||
f"skip_dead={st['skipped_not_alive']} no_tok={st['no_tok']} bad={st['bad']})"
|
||||
)
|
||||
return len(toks)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="cpa_ready access_token → keys/acc.md (default: alive only)"
|
||||
)
|
||||
ap.add_argument("--interval", type=float, default=5.0)
|
||||
ap.add_argument("--once", action="store_true", help="刷一次就退")
|
||||
ap.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="忽略探活,全量抽 token(排障;会把 403 也写入 acc.md)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
require_alive = not args.all
|
||||
|
||||
if not SRC_DIR.exists():
|
||||
log(f"⚠ 目录不存在: {SRC_DIR} (watchdog 整备后才会建)")
|
||||
if args.once:
|
||||
return 0
|
||||
|
||||
sync_once(require_alive=require_alive)
|
||||
if args.once:
|
||||
return 0
|
||||
|
||||
log(f"常驻轮询 interval={args.interval}s alive_only={require_alive} (Ctrl-C 退)")
|
||||
while RUN:
|
||||
slept = 0.0
|
||||
while slept < args.interval and RUN:
|
||||
time.sleep(0.25)
|
||||
slept += 0.25
|
||||
if not RUN:
|
||||
break
|
||||
sync_once(require_alive=require_alive)
|
||||
|
||||
log("退出")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,985 @@
|
||||
ldnhvgcuog@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibGRuaHZnY3VvZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjEzNjB9.EPc5qD1ldeMnsgozH_6Q6ZKfRQMt3FUP-iuG6JKM_5w
|
||||
0ut3371zzx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHV0MzM3MXp6eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjEzNjZ9.T9vVnU_hpVPnPJqReyms75emvYJ0w3zY3PN0j5wDQk4
|
||||
24m63z630y@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMjRtNjN6NjMweUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjEzNzB9.FTNTLjgBIrz8Vs84knhmntqzIvcn1M8AYagEcaZccWQ
|
||||
maxdobzdo8@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibWF4ZG9iemRvOEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjEzNzh9.zrJEBZRLpqfkIs6syFsWj4MRTOGhap-c0uoHpEqHz8g
|
||||
ovplrqmm8a@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib3ZwbHJxbW04YUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjEzODV9._sHMMLDb8kJF_CGVZk-nf3cFpYUUHGeS1EW2rbP-3lQ
|
||||
t52djkzaix@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDUyZGpremFpeEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MTB9.DjGHI0aa8n9JBRBFgHN3vfJ0tr-wz_QUfvsv4GQyfZU
|
||||
47rqyszuxf@zhangyunuo.net cf_worker
|
||||
xpjt7028xd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieHBqdDcwMjh4ZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MTF9.nxkQbYeRO-xNgd6NvNQUYzWA_E_ZDfMvHv0x0sn4Qxw
|
||||
7h7rugu538@zhangyunuo.net cf_worker
|
||||
zdkbp65awf@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiemRrYnA2NWF3ZkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MTJ9.vsWUktW7qVi5Dcy6L4wsz_jgKmOWrS1Zn3o7D39r23o
|
||||
ff3hm4tn68@zhangyunuo.net cf_worker
|
||||
yhm3o9cg9j@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieWhtM285Y2c5akBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MTd9.rJAoA5zKPTV4uOPwTRLoJrykmPqhCSu0LyX2_VqxHQw
|
||||
z2co9oecfd@zhangyunuo.net cf_worker
|
||||
r4z3zzcnt0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjR6M3p6Y250MEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MTl9.kWaiXa_9MA1wwMOv9wMZTLPX_9JLnXnoRRCu6oVB12g
|
||||
torp0cjv4s@zhangyunuo.net cf_worker
|
||||
rogjfax8al@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicm9namZheDhhbEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MjN9.Zkq6cj1yTjeEivgOGVOXbRVL1n2nMx3xftD4YxRNR6g
|
||||
mru98jnol6@zhangyunuo.net cf_worker
|
||||
e33fwviqrh@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTMzZnd2aXFyaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MjR9.8kYpB_tJQYHsHCRTfQz5Ei1pSoB1trDY6AAbOUvmgt0
|
||||
karxzmtmt3@zhangyunuo.net cf_worker
|
||||
faiymol7uk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmFpeW1vbDd1a0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MzB9.QRPK494puuMWoUuaxlwao6o1pAkq_pCRmQ91v3av-Qo
|
||||
estvdafbw0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXN0dmRhZmJ3MEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MzF9.43B_ghCOzj0ie13wc2lXEzsvhCav6D1jICw4yCs-VQs
|
||||
bv8pw4rwnq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnY4cHc0cnducUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MzJ9.q98MvPOfm5jCPEl9nJOJmYjA4sildtVeor8OXh8yl2A
|
||||
nb2rgintjk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmIycmdpbnRqa0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MzN9.4XPelAD2mvSe1MXAAg-i5FwyBdN3Z1k2pOZ5shpqipg
|
||||
6wcib1d19x@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNndjaWIxZDE5eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MzR9.Ajqap74T0Je-WM4a5WgighLO-4jyg8qAyDB-30kJMGU
|
||||
ymdri8j8y6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieW1kcmk4ajh5NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MzV9.0AvD_6v5BqBxPswkgMtqiEkQfoMVXKSqm15kicyzlp8
|
||||
jlrtj0krf4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiamxydGowa3JmNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0MzZ9.z9VRFoi8gs-bgxVQQqO2pKy4Zn4hTMhZpoa9taHhnoA
|
||||
azh24dkd9w@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXpoMjRka2Q5d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0Mzd9.j0vOvXsSLZXyd_yPylQmu9i2EtnGiQw9ij4xD3lSPwY
|
||||
2bzfrxlnn9@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmJ6ZnJ4bG5uOUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0Mzh9.0MU4ezfYMqaIcM-DxEbYXLBL8gym_zx3rIo3mAYV0p8
|
||||
8fpn0gjacl@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOGZwbjBnamFjbEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0Mzl9.QBg7wzI_0nX-6ig7E1XrxvLxpCluDX-a27JeilVozII
|
||||
mn8sf1iyye@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibW44c2YxaXl5ZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NDF9.XIMTAcuI-UwllhO2GYEtVzJd4Si4VGsaC-D5UOit_X0
|
||||
nsajm34em7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibnNham0zNGVtN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NDJ9.mUT01EhGdF_dVYgOtyrnh4In1EzM4KzSMBHSkMmWCmM
|
||||
frdco6skje@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZnJkY282c2tqZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NDN9.zobjJXY_d_JCPFQIy-eb52v9QhzGguIrX1R0-ZbBQmw
|
||||
fia5t2n0og@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmlhNXQybjBvZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NDR9.nqIP0k_8wJuI1E21O0klfOxg67JNx9hJ9iO4hTk8CIY
|
||||
5u1b4dyaml@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXUxYjRkeWFtbEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NDV9.M_W69l7jQ5fwrGv7u4eQf8XOQ0LEH2OKEWou0F9jSl8
|
||||
x3zg9qlu95@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieDN6ZzlxbHU5NUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NDZ9.h8wzag58Ty4RZsKFZ5zdNlXCSR_hCc2droSsi8mtDlw
|
||||
rb27ceq176@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmIyN2NlcTE3NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NDd9.mrjXNgg2a99dekG3db7Uev4owwz_9ZwKTXGQKjOxVmg
|
||||
e9z947etdd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTl6OTQ3ZXRkZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NDl9.eiwPbPNCeAUcv4bkvklFQuLrSZ1VEYD8CfEg6Vjzjfw
|
||||
8ujfsqp8zz@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOHVqZnNxcDh6ekBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NTB9.L3VGu-jDeSUtzfAhbwNH-9HpIVnEDcc55nPzX_1Ozn8
|
||||
6ya56uc9cm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNnlhNTZ1YzljbUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NTF9.3c53t8VOWzIIc38b6soyweQdws9Z27NlLi6NymTaHQ4
|
||||
43wbsq0jyv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNDN3YnNxMGp5dkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NTJ9.X3Q32mkSdREUg3ITk7gpXbLsB_u1v3NrQM2P5sMdj5U
|
||||
ixe1hjo645@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaXhlMWhqbzY0NUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NTR9.MQk-u6mO2xe8_dEmpxrvMYucmO-w-UG6TS2e7JHgJOA
|
||||
r62zi7flqw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjYyemk3Zmxxd0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NTZ9.uH2fZM0cCSFWIY5tel-4YrQQ3vJpe7_uCSPR9sTXmNc
|
||||
2gh9chzsqo@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmdoOWNoenNxb0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NTh9.Fo2xzWQNDppoJPShgG7_x2vYgJ26mBJG6ufscGc7dH4
|
||||
nl45jetu8c@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmw0NWpldHU4Y0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NTl9.iFcgTVWr0gg2ubs2hGEqtMa509EORUgxn1SOvlptVrM
|
||||
e2dsv3te98@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTJkc3YzdGU5OEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NjB9.E4p5KaSKuZBLFWxF976bO2b4uKuuknrh-XNOJasaeuA
|
||||
f7bo80h3lm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZjdibzgwaDNsbUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NjF9.9gWWn45EN8vPNkYL5sr1tO3VgTXhJ_drX7knYAvJiG4
|
||||
vi6utfhjzl@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmk2dXRmaGp6bEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NjJ9.DdkE4aT-JWriRc2PlH6MgEIT6Cer5tduZqaGKjaUxno
|
||||
u7ahgimo1x@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidTdhaGdpbW8xeEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0NjV9.6rF2rI7JFsgsnNLfECwAZ8S0mtSYXPam8MNE4-Zm5Ck
|
||||
1smfftlfge@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXNtZmZ0bGZnZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0Njd9.68hn-vZ7SqVGH2jbzA5V9atZcgGgBUvu6dkhkaN96jU
|
||||
2bkus632ss@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmJrdXM2MzJzc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE0OTB9.D1iJsPVQWepKhLZQ4KIT71V6GklyuhSfUX4hEAwy9ZU
|
||||
om6aktj95t@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib202YWt0ajk1dEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MDJ9.2i7NhBmVxIvBBNIrfZdw1wd8CruDw2b4hjLLioSdqvQ
|
||||
5uyyzhwpa7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXV5eXpod3BhN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MDV9.wvk-Ltwu51TLTDTr_YZkJsPJ7U0jFBxfkJQzwHCIfkw
|
||||
iekcdguksg@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaWVrY2RndWtzZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MTF9.c-ddDRqHCGABExwq80SUNua0hiIEdhjCFn14tE8-qaQ
|
||||
ki7rmpy5rr@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2k3cm1weTVyckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MTR9.0Qj3vWKH4aiZWBOVcGM17TtZfe5xPKB5W3ZpzmkEFiE
|
||||
64uqnvty4h@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjR1cW52dHk0aEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MTV9.jFXvpJtSRU-20t8P9PcId0u6HiE2fc_Ipl4v8T707L8
|
||||
m76yyidh09@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibTc2eXlpZGgwOUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MTZ9.PI2vRJTYfr35AcWFEMzAG-cox6pYDZTbr43bt19X2LM
|
||||
wme8fqq49h@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid21lOGZxcTQ5aEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MTd9.iOsOn9-CbkltTSEp1uCYT-31n7kAzGKs23J3eniJJ90
|
||||
xt7h8fdfo4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieHQ3aDhmZGZvNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MjB9.xsaSGUMo4LTRzkKt0_bK7lAhO3vVh2eN4LU0gwL5aH0
|
||||
qbvdik1f87@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWJ2ZGlrMWY4N0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MjV9.6ViTUcdj7v4vhLLJL9hgSfRhJggK_KcH3Ljf85oU_zk
|
||||
alti58yyxr@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYWx0aTU4eXl4ckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MjZ9.tuYf7O7nRg2tfQhBVyg7Z5BedcMinKxBU9xceKRpHaY
|
||||
47e5iz65ux@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNDdlNWl6NjV1eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1Mjd9.Wfa_TVkmDRKgAyzC1VCbQ5sZPfUS0G-yzS5goih6bWc
|
||||
ru8v66xqs0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicnU4djY2eHFzMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MzR9.MuV7IsJk9cSv8M9w0jWwDsB_AnAaUU51SyBOO8MmaJ4
|
||||
5u9u075j24@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXU5dTA3NWoyNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1MzV9.Y1UbXfIeFk3iydkMhN1nZ7WpgEv-H8P00LMjaqUAeoU
|
||||
iaqi5a76lv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaWFxaTVhNzZsdkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1Mzd9.l0eteh0zwoCKMyDnuLga8vV3-aENGhAH4pFhkdD5rHU
|
||||
zbb7rtf06m@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiemJiN3J0ZjA2bUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1Mzl9.Sf1d6DLt0mpGYZBYt2giM-P5ZslimdQBsJ12GJ2yop8
|
||||
pmj0js92e2@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicG1qMGpzOTJlMkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NDF9.-__vTgChcoawWz1U3-BDTUuBmdfZs1KWYpGCnHqQAgM
|
||||
cfw9jwecej@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY2Z3OWp3ZWNlakBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NDJ9.SF19vMBZHWnaE5wmT6-MlPQphaMwxQ7q5EIIS-CsTPE
|
||||
ml653m1rb2@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibWw2NTNtMXJiMkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NDN9.a2zxv9JwqfaXdKODx91ntqMRjc67FucqTo5j2pN5-04
|
||||
klikj19mxc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2xpa2oxOW14Y0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NDR9.FdQNq-LKDTE6LvZ-1zhFibe3YJypMSldyoDjmr_UWXE
|
||||
wc28fzk8ng@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid2MyOGZ6azhuZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NDh9.bmXnNQ-CWU84DQbK0PqV2N2xICLb_CLzd3Vpwbh8hOU
|
||||
hthyr9q85y@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaHRoeXI5cTg1eUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NTV9.BHWAemSyqM0QXYBz6B7b9xGg33Fh7l-pJI6t2Nd_qtU
|
||||
nhy17oent3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmh5MTdvZW50M0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NTd9.xwcXXkKgbmUvRgPKbsjr7oIjTBVwtFKUHJ25x_TSTT4
|
||||
bqr2gbdjvv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnFyMmdiZGp2dkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NTl9.1noFzx43FXVR0-3Aly6-s_mKo3bgrOKU-B1V-UkO660
|
||||
ddgksbo2ls@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGRna3NibzJsc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NjF9.4Xr10btvw6kxm_fDIc5gNHS2FsIngagHykSQRQMUDPU
|
||||
xjdivn2agy@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGpkaXZuMmFneUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NjJ9.C08F61iubN5GB-yaLEJeBOAxJRighujuo3972bCOMQo
|
||||
gfi6njvtwo@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ2ZpNm5qdnR3b0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NjN9.--blNQgO7F09FzMN0yzGcXeASU0Iu0k36KgQGC76AWk
|
||||
sy5x822egd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic3k1eDgyMmVnZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NjR9.QKyCdO3vL-DLuD2G-gK83GcGShj91K9C59QzVvRUmhU
|
||||
cho1hamp8b@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY2hvMWhhbXA4YkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1Njd9.ImPai2ysx-MupxZHKnfRkRYYJkD_1QKWiIpECE7qDMY
|
||||
onirftm0o0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib25pcmZ0bTBvMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1NzR9.ANwEqUbkEO-OinY1jO18z-lIMk4QBEM1cqNzLV-sLPI
|
||||
8vgju5yqnx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOHZnanU1eXFueEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1Nzh9.CwwOzZ6UsMFe5MhYf8Nenayzt0g5slmTQknt8tNd6BI
|
||||
ud320uvcxm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidWQzMjB1dmN4bUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1Nzl9.ujo3DSIQUPWVVLa0ywa0m3SP8Te5i3MyWkbz_wBiSvg
|
||||
c6c1g7xjh1@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzZjMWc3eGpoMUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1ODJ9.LCZaAOJvUXbz5wdRUwxN0tJ7yq0fGGZhU_s5Lo8nWo0
|
||||
7yyvx78keu@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN3l5dng3OGtldUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1ODR9.1BUd4usQ6kNp3gOWCw_5bpMXsqUUPiM3FnlKgNxRPzg
|
||||
wgrqb0zstc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid2dycWIwenN0Y0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1ODZ9.JCkVqnT4OmhX4S58bsGr91HWCZP7wqwim1Ox62H7N60
|
||||
zbxx4t6g26@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiemJ4eDR0NmcyNkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1OTB9.lSP-cnIyDBJ3vOz4E16lVN7eG7LPlhxFop7CcVkyJ3g
|
||||
5y79xdu4sf@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXk3OXhkdTRzZkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1OTF9.nGGv7VRR49rQKckdqOj-k3sHGRR5CDA_twXD4kibaco
|
||||
js2b73lbr0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoianMyYjczbGJyMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1OTZ9.jFuvaUslnRVU3tAY6HMcWtd1w48HnuhOFKsvXApmQfc
|
||||
czs2rujod6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3pzMnJ1am9kNkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1OTd9.h1WdB8SBz6oOI5yvvJL7nI2dkx91pZu3xxb2kx-M3lE
|
||||
9ick8id78p@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOWljazhpZDc4cEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE1OTl9.e6LUK8Wdf11f-wXFrUCBWSS-j0KSYIhEkN2eGh3zxXQ
|
||||
hj9f0pxd82@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGo5ZjBweGQ4MkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MDF9.gYPcUL7R53BhnLRKcHDusVn4clSPEI9jcUVfKDgVqqg
|
||||
3dbefcbaqu@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2RiZWZjYmFxdUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MDN9._mroVXFRGqb3vjfzWV2zo-x5IIyCRjHha-ToPq8-MAg
|
||||
fajeqvknjd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmFqZXF2a25qZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MDR9.ss2B553TYfO3xumu3X2djC_7sKz_3d1j39PF8kKw5XU
|
||||
52gkj8w50h@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNTJna2o4dzUwaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MDh9.KL6fr2rTpr63_5V3xrFb6pD7Mx4NMCwwO75Mawu4p14
|
||||
c2ukvkcisq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzJ1a3ZrY2lzcUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MTB9.UKDk2vRolEsAjobFiufY7NsQjGBhIJLNsEWd3ja3DZk
|
||||
tzypshl8ok@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHp5cHNobDhva0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MTZ9.fnjeG72hD-5c3yzD93oxxuDsgX6OGzoDs6NRWgFdQAo
|
||||
l8rp3o8mlc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibDhycDNvOG1sY0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MTd9.pQb0AWj4Gr36ycMTpEnDGDUUgnfpUYSmKbF2cThbCOQ
|
||||
ozavfle6h7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib3phdmZsZTZoN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MTl9.XcuDwiZ3pdbsP9TjfEwM-VoyNNd9w4jkqK8vGguJP2g
|
||||
dy4c6mxd0e@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZHk0YzZteGQwZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MjF9.OCrPuCns-VCU9V457K3l9KT_OduJKtxbYDO2B2oEad4
|
||||
hhvvpzb0c6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGh2dnB6YjBjNkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MjZ9.SP4UvwanBk5hJtcyq881jFnR7sb1gjNhE9o48ejClf0
|
||||
fepe325s0n@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmVwZTMyNXMwbkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2Mjh9.FtBzDHeTwnMskJbygBMPRC3iWnSU8rth6EtqKV1z3P8
|
||||
ne35v91s3u@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmUzNXY5MXMzdUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MzF9.9nWW2f2W2TSbWHX4VCoQ6u-IXVTlM0MgoVd3n5xDO_w
|
||||
z4cn8gl3sw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiejRjbjhnbDNzd0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MzJ9.1a0xhratK0eQA0C7WVhOQTueODVnHbsEefpnx8klApI
|
||||
cph0o1vom2@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3BoMG8xdm9tMkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MzV9.ZwFJLCQQZG307Ua4W9L5nzt1LmIbJPDzlBr2fcpz4pY
|
||||
3g4odg6a21@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2c0b2RnNmEyMUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2MzZ9.E5RoS3NrauR-anrDrybKRhSlQaIw8xPPW0nXbWRBRNU
|
||||
rk6wcbuym4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicms2d2NidXltNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2Mzd9.G8ss9egBKbVZtshH2JupOIYN8T_b6BSV2M7dcyVotSU
|
||||
u566vx8zki@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidTU2NnZ4OHpraUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2Mzl9.kOhK6Uns7Nfshh2ECpi0RMv3PtqjT63Fdc_znhTyWSU
|
||||
cnva1cvjgc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY252YTFjdmpnY0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2NDB9.fbf-LsQFo5c-aXFJPE0hL0cJrcWBnGGwuz30err62yQ
|
||||
g7n5kzxqym@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZzduNWt6eHF5bUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2NDd9._5xDOU265pg-ooaTPb8tvmCdQhtJ9r0wvaVth-njNCg
|
||||
l7cz5dnj15@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibDdjejVkbmoxNUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2NTB9.aHv0Z-g7UKoCIO-IeYIwS3Hpm37o3mcfjE0btD2g2gk
|
||||
5256jzk04t@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNTI1Nmp6azA0dEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2NTF9.voVSj_n50Hbvk3ppS7W2YvnN6AYysVIrm_FhLF3qZt8
|
||||
2f3pk5hnqx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmYzcGs1aG5xeEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2NTJ9.uUxXNUz09xV-VxcuLu-3SdhezqdoBlhZeeNdu7zMBdk
|
||||
tdels042e3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidGRlbHMwNDJlM0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2NTN9.1bqy0monTTmqCd8uaOh-NFaNfSbvtKJUzdaRlwUDXCg
|
||||
utxrhrgcma@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidXR4cmhyZ2NtYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2NTR9.kjEmV-9LgH1X6hA4JNfdCYXsNcOZ14FSZ1VFCKy-BNc
|
||||
7rkepl07xx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN3JrZXBsMDd4eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2Njl9.sLYmmUJM0sk3dX0lYpXLMV3MhXOGqrYgbAFqcjeNYa0
|
||||
e88whl7e8o@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTg4d2hsN2U4b0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2NzB9.9CZIOD_ghxOszWm0ZQt5z2WCFB8p0jQtLWdc-wYtXpQ
|
||||
jj9swnz4vn@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiamo5c3duejR2bkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2NzF9.TPpL6QT7igytsiyiPCGjdGtF2aBGyhmTjb0UWH6H6wY
|
||||
j325nd577p@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiajMyNW5kNTc3cEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2ODF9.6iJT-1YVTggP3eCY-xzjFiG56jxhJub9VrbZbDdMeBw
|
||||
qi0efvgzse@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWkwZWZ2Z3pzZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2ODN9.Fg5BrTZ7LGSbHk6NYeToKnwOpt9g5cAPRsaA77p5Vi0
|
||||
63bjqws32a@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjNianF3czMyYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2OTR9.mfbOTP8jVSY9p6ZwGxHHrd9Ll02zFD0R3HTCvgafa6M
|
||||
yljw2cof4k@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieWxqdzJjb2Y0a0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE2OTd9.3Ar0188RrkgKohSLZja0Yl1DZ8zX11tN6T9Zrji38Tk
|
||||
8jxx5ti6zo@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOGp4eDV0aTZ6b0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3MDV9.4jwQg454iDyLRpZBrbfRl66u_36oogeyOMyiExUzsz4
|
||||
muq9sbojoa@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibXVxOXNib2pvYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3MDZ9.lPFELSURA3q9GftM7evhg9vfICPvEOBi8nXee3x7RcY
|
||||
og74oonsm4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib2c3NG9vbnNtNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3MTR9.3pA0AAFQj7mPAhfcBtWDlKMDAJqeU-mXC7QKDrtNiyw
|
||||
dl5od7l2je@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGw1b2Q3bDJqZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3MTV9.rFFHC-Q7F2IhiIu-nkNUOLharZ41utdZMaWWAfO0HO8
|
||||
xlcgnspuii@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGxjZ25zcHVpaUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3MjF9.RK-M8AptRqfgIaVP5jR2Y4fNGMHTR6CSZT78QEVPinA
|
||||
cicbhhacyb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY2ljYmhoYWN5YkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3MjJ9.kLxcjMr--ntKEc8z0mchuZSvxD9q3XEHm6A0cRqpkgc
|
||||
hr1s9ndx6c@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaHIxczluZHg2Y0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3MjR9.8IUBu4hEs1lBsaHb3Zoobd1-JHlJx-2j2G5zjVh1SX4
|
||||
a94w6e1tft@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYTk0dzZlMXRmdEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3Mjd9.i8USUMLhjQyk-TceAzWMNvg_zkUagYP5-YGwk25LW-E
|
||||
sj5ix8t2qp@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic2o1aXg4dDJxcEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3Mjh9.f-2FYBebyMqb-PN6rD0HFUS50L7HEJG-1p7U8k0n4bA
|
||||
k13oh7mype@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiazEzb2g3bXlwZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3MzR9.Oj8fInzo-wCqYa1XmcfIva7TJ3O6V51rkfoUCqZISMQ
|
||||
53uvnq9ot4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNTN1dm5xOW90NEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3MzV9.NPHLSJSqAnwStQwe0OcrG7QjdRCqv3t4diWmV7Eddlo
|
||||
9siz1a6w9q@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXNpejFhNnc5cUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3Mzh9.wBj7xp1D2tweaRYDsjVtASZNgQHLuS5pZ-Gjp5f1fns
|
||||
60c3bel0of@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjBjM2JlbDBvZkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NDB9.OV6bybXn1ICtfbirH7SGENX9GyLgW4ILgQ55uo_Hjkk
|
||||
fk9342dhhb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZms5MzQyZGhoYkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NDJ9.2FqLI-siCwhJOAJduSK7dO3XFs415cHKIvbjd0lwjNY
|
||||
6y1m6p9ejg@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNnkxbTZwOWVqZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NDN9.o2zocllMYSCIEgBRhAgdI87pDXnd-vK0zmBQkxN6VEs
|
||||
9reee0ai8h@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXJlZWUwYWk4aEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NDV9.BujVoAFrd2eyU7vlZn9DV12H3_HzgsD0z1aMdb6rJnU
|
||||
k51heiyx4b@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiazUxaGVpeXg0YkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NDd9.pm37qyf64BxTiohe88Xbb1s6G6wmfcNbILaTqmxhqys
|
||||
p2mduw32zz@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDJtZHV3MzJ6ekBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NDl9.gk6xmU5l8gSHRu18w5rne5ogkzS92ByWj_mVWVEFTk4
|
||||
0j2ld2ajp9@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMGoybGQyYWpwOUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NTF9.sLMj-HSipxT4p41UPobSKJpljdx7Z8hW0TYZKu-YVRA
|
||||
wsljbjjqqa@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid3NsamJqanFxYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NTR9.3Qh6duEvTZDNoUig0zgs8VWRYoK5XOt3ZzC-YTB_eLE
|
||||
q08qn1kpmz@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicTA4cW4xa3BtekBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NTN9.8uwytLJn79WJCFTjBFFHlyfhRL454e07tdWztAR0OcI
|
||||
xe5dz2gsly@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGU1ZHoyZ3NseUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NTh9.NZ2vKlgMMJ29Gd0x4RtmgPXW6TUM9Z54l24OMOpXdRg
|
||||
975h5exlck@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOTc1aDVleGxja0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NjB9.aIqj5Yo2IwVJrMLj8maCrGiVAOy3L1cFt0p1y7XAn84
|
||||
xep62luxj1@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGVwNjJsdXhqMUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NjF9.zbEq1DgXOvdYs_qSaaCi0MeQpkFpEOT1J1H7LNkO6zM
|
||||
bywnuxdlst@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnl3bnV4ZGxzdEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NjV9.lF3hmx9VRfC1RcWXoWunTAa6zlxbRInl4nOA8hOr-Dc
|
||||
o5ghn97cez@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibzVnaG45N2NlekBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NzB9.ARC2PGUvtnQitMmc1rsHjveFbjn-TbYPPNdL5Y4ulTE
|
||||
f2fiohvtqg@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZjJmaW9odnRxZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NzJ9.EAPPKZR6Dhx5AraFjiZfObInJYPW8A0wz0l8IUAxj_M
|
||||
ypg51d45r0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieXBnNTFkNDVyMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3NzZ9.iFPGh6mF5IqwxVl2GrZLRUh74WUNAvjqWT9Q0VtoMJw
|
||||
omogi0azbx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib21vZ2kwYXpieEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3ODB9.MfqeEvmVruzJ3YRb0R5kaSbqNdlrl5m90wHD3955lT8
|
||||
kduly97uxq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2R1bHk5N3V4cUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3ODN9.zoeVHjw7jBsUe8ht13EQFzgriPCx_rSaHlySnr7J77I
|
||||
a72504smkj@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYTcyNTA0c21rakBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3ODh9.4vKuZgyOEuHYvvxcNIADzeE1olN0xDs2KH9Agt2deR8
|
||||
s4n7ekbaku@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiczRuN2VrYmFrdUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3OTF9.e7-_aJ1sLzJDY1e1GJu26Gzm0MEWuFpkZobPBfhEYT8
|
||||
6331ne6rbc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjMzMW5lNnJiY0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE3OTV9.6wE-D-k3Rl3nl0aUAT0HkULk4qro2YIEIOT_CgO9bdE
|
||||
6042etmmm8@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjA0MmV0bW1tOEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4MDZ9.vUHU2C1sW4nMjPzT8_YXgcxMIago8XHyqksmEwz0Vp0
|
||||
lihplift4a@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibGlocGxpZnQ0YUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4MDl9.wjsFWDVhX-JdJ58hDN5fFKx3qa4fMXlQ3JEPHej8nyc
|
||||
1b54x8ixhg@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMWI1NHg4aXhoZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4MTJ9.Sn9pGJCB8VoVzrKvau7Di0e90H83wSHWCCtBgYbkN5w
|
||||
9v4ceax6zm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXY0Y2VheDZ6bUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4MTd9.TQfBLJdKtfggbCBY7riQdlFNN6BXWO1vgBLU5CqxU5U
|
||||
6dbesrztrw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmRiZXNyenRyd0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4MjZ9.xeJXERiHolLE98az3bAePat9o_perbmRC1NsTXRVIBc
|
||||
6kqcobepww@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmtxY29iZXB3d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4Mjd9.NvF44zEZOoRQ5hRFWnGe5X_8oV_hN0wSpc_5AvDumXI
|
||||
eejz72mw2o@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZWVqejcybXcyb0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4Mjh9.glh0j4VBeNRDhJ80muJGCDQqykNt4AuxaKAb4svLVug
|
||||
o95qnz8bdb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibzk1cW56OGJkYkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4Mzd9.tnNELpAYQcHWNCEi2eBrmZtLrlQJUEDOkYHzYV2qTZk
|
||||
q2nqpgidjv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicTJucXBnaWRqdkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4Mzh9.0KpPMr6AVt-Bjj55soRQXPcZDevrjK1xH_t_NKwK7ps
|
||||
8lnrhvxe3a@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOGxucmh2eGUzYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4NDF9.22h6bq1nykzLAUCwOBvCbCgGXa7rJCQRWXY7L7OPfxI
|
||||
t1b5dkoy8p@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDFiNWRrb3k4cEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4NDh9.EjSIUv6SSaU8tKQcrG9dsdKVKZmt7uexQno-vkqMDHs
|
||||
3x9cnfep9r@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3g5Y25mZXA5ckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4NDl9.83-V_raU06YnJejPdXcyahWs5OgbmTNISUwseXbIh0Q
|
||||
bu06oq50vn@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnUwNm9xNTB2bkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4NTB9.K31cOn0pjgf0ZmMadCIutepUuAKWii1TaNuv5T7-as0
|
||||
kcp3zw9281@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2NwM3p3OTI4MUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4NTd9.Qxm9UNyfzPcmnzoZ2_Au3gT9tJYWr9Wgj2OW1QVM74c
|
||||
fjfnkokfmh@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmpmbmtva2ZtaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4NTl9.MPkv4U5YlWw8i9GQeQrbbhmCiirSyH5d3F487jbe0Lg
|
||||
8x4pwvn3zr@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOHg0cHd2bjN6ckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjE4NjF9.qNQa8XEPIlasTQukpngV5Zdmw6bacIw-NEHw-cC0OXo
|
||||
8l8swz3y43@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOGw4c3d6M3k0M0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjV9.o4HAKQWO3u22iFzBmSQSnl0r-CAgFjd8kU2yDM4CoWI
|
||||
j4948gddgu@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiajQ5NDhnZGRndUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjZ9.w3jpdp12FPd9xBoYKR9QV89Y6ftVuDZ4OUQ5H6Jb1BE
|
||||
wnjhqq4yjk@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid25qaHFxNHlqa0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjd9.I5FZPwBJfqev8BhsFFOtecVrk2WbtmedtNMbiVloql4
|
||||
4fqwg1izka@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNGZxd2cxaXprYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjh9.HtjG1fGcc5V9GhyvRNT0L7G_bBWKSx7l1iOSNZNfh3Q
|
||||
zopf9zbn5n@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiem9wZjl6Ym41bkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjl9.Kv0lzn-7r6eIWNNr5lDvF4Af2YsgG4VG8pOS3BdUTu8
|
||||
9vyw4fh3s7@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXZ5dzRmaDNzN0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwfQ.rAKOyCQZjS9HluHvFCDFSTgbuH3cbJPE0P_pZNxkCQE
|
||||
607jtxa2km@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjA3anR4YTJrbUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExfQ.ppR8X9tIbYmEB2f7UMFQl88swyT7M6W_Nu-4V6ru2nI
|
||||
fa6fm36e36@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmE2Zm0zNmUzNkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyfQ.sphd99woQ8WW68kzxYE3nqqFeesJ7rtX_mINWnChOt4
|
||||
269ea5fk1w@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMjY5ZWE1Zmsxd0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzfQ.xAIH6Pl4iyizlABDN7SsCvar2dGMb6YWMgnd2YO1ing
|
||||
sx6opy8477@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic3g2b3B5ODQ3N0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0fQ.E1u90gQQPG8mMeW3pEgtzhqoF01N4yhs0MDBRAR49cg
|
||||
s0203468yb@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiczAyMDM0Njh5YkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1fQ.tOvw4T54FpbkoQrijB3akTy_s__59SLQKzmH85QGB5E
|
||||
cejj4b5umv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY2VqajRiNXVtdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2fQ.XY3NYw1qyITXoVytC0H4j0DxeJIWoC4VNJQ4K204fqw
|
||||
hman3w0xtc@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaG1hbjN3MHh0Y0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3fQ._QOTiL1gwleougVQP1gTwaeHxjjzHwz2fKA1AK22uAs
|
||||
vyyooowki2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidnl5b29vd2tpMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4fQ.WH6JEr1DI55aVjkD9HH6UDCPFfrt5KSzkCjPCDN69to
|
||||
m2ehspyzqz@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibTJlaHNweXpxekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE5fQ.orwqqjCEJXLV-errAxBhRhArSnYU-QoMnegrg6MMWlU
|
||||
vvr2xta0lj@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidnZyMnh0YTBsakB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIwfQ.NSHYjnANkIMTS51uM-gfO12ShkkgEVdrExLbnWyF-TY
|
||||
jq4h1a5win@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoianE0aDFhNXdpbkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIxfQ.xBSKp9uTl7dQbEQomhlMThiEus5mBMT4OD4Qt8Cgf0s
|
||||
fwys646fez@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZnd5czY0NmZlekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIyfQ.Kc_y9RR9af-aJBI8TUsmOSNUjYG2pGhIIij9xmR2gi4
|
||||
jmnprnb412@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiam1ucHJuYjQxMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIzfQ.oiGjkdgfrFLg_NmWohtX-KRNJpjmFGcM7CIlNX7Lkz0
|
||||
3wyomp4e0c@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3d5b21wNGUwY0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI0fQ.pa_-zTfjsLyCcGXxf5Pm7ljbn2mLNs3FXfkhFmmXKB8
|
||||
g8dw6bsk4o@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZzhkdzZic2s0b0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI1fQ.6vAcgOZvoG_J1Bh3_GXDqI8EkZlerEHqPf6D4UHVXIE
|
||||
km2t4snyev@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia20ydDRzbnlldkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI2fQ.6FyURs1QR7fIZKf8Jza3z8dbohYiqeZE2lGt6ZRMsJI
|
||||
dlak40yeom@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGxhazQweWVvbUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI3fQ._PrKl6QnvZM93WidSdeXFRWr5YPdd4pdmlZXtFeF_Qo
|
||||
9awtkcd170@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOWF3dGtjZDE3MEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI4fQ.7xvuuGVSpeFNTLsfFzHkEbkkq5xI_9C5OSqhtNoo4sA
|
||||
mjtq31nz3j@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibWp0cTMxbnozakB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI5fQ.-iL5cvqOYGPz-IIKnypH1FwtVYGR3kYtgjtwhHXyCok
|
||||
4ipwudjft6@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNGlwd3VkamZ0NkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMwfQ.pu8_4MtqN7ja3hjECo9LIqDCtYnmZudOIFuq1QThwBU
|
||||
rbtu7yne4l@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmJ0dTd5bmU0bEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMxfQ.TTNI3VUlalG4-gCF3cEFR_3LKEbobzwbk2IEe9EnQDA
|
||||
5s4hgc76u3@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXM0aGdjNzZ1M0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMyfQ.oQ7vppgiDhF5CyvZ8Z6rvYKZow6lU0n8BY_-Jmyz3Ik
|
||||
r2lqoyazqv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjJscW95YXpxdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMzfQ.84fcYvBJpevfqISdCvJEeXnJPLzPdnspAGFtP_lA8Z4
|
||||
qqv0e4ngv7@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicXF2MGU0bmd2N0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM0fQ.ZFTbQPVdmw1d1CPBH2P6LAYefkGqjE6B9KoIyTneIJY
|
||||
0uwkdgxsqu@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHV3a2RneHNxdUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM1fQ.rn891NKOYY7iMannKdegxyeL0uFyz-fSXgXV4WiEg_g
|
||||
6w6s46fvn0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNnc2czQ2ZnZuMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM2fQ.aSoalQyoZTndSjkN4WRJk1XwAOnxQ_2cEQKePn0bg40
|
||||
qqhtinu1th@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicXFodGludTF0aEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM3fQ.lh9QMnSO937M1bBvAVlId67w1IY5mS8w4e4bxHXWuVQ
|
||||
l2qqhiuuck@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibDJxcWhpdXVja0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM4fQ._GV3-s9F0m-M9Msv_la3zgXj142UIxzPF8R6O-P58GA
|
||||
eydevflqqo@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXlkZXZmbHFxb0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM5fQ.puMfDpTgrZz2cRPrwy4DjYlJGtPUODJO0J8BYzzLYy8
|
||||
83wrza8mkj@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiODN3cnphOG1rakB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQwfQ.HMsuzmfCc53YE4MmZmPtCTivwqOLxtnluMKhR--o_bw
|
||||
kicrvzcekv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2ljcnZ6Y2VrdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQxfQ.Tzvwk-l9awuTlK8TO9bO0iFIqmYJ3IgxsAt5dsFYZOY
|
||||
dwvng6tfwx@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZHd2bmc2dGZ3eEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQyfQ.R4grMixwMmV9chJFSwyiCVjWoPLcmTykBkHAMPM0woE
|
||||
mty06t01m0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibXR5MDZ0MDFtMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQzfQ.tN6msxJk2hxBtC7zGKj2BOkw8cUvEn7T0_wWQUE1umk
|
||||
dk8mozqovr@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGs4bW96cW92ckB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ0fQ.5imiWGvqUPo4PhU-Qbks5ncgFUeksY60icTe3LoEAz4
|
||||
y1f8rg29z2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieTFmOHJnMjl6MkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ1fQ.upRokltWYw-for4uMpyYn5V4uo4QVoLukKh4q-uMEbY
|
||||
l8qfh1h1iv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibDhxZmgxaDFpdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ2fQ.3iiPvow6m61C4RAY2wWBMZrkxucTcird-llt_JpxnpY
|
||||
7yalfcvmgy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN3lhbGZjdm1neUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ3fQ.Pv8xjaRE0wujp0Z8fhsSpEbmVLy_QcIjlPhsJU7hmt8
|
||||
bkv5nse8cm@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYmt2NW5zZThjbUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ4fQ.UwvActJ2MUWTHphAZDnPlW2kVB8Pq2drlOMpMRzpU_8
|
||||
v5estgy2e6@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidjVlc3RneTJlNkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ5fQ.1It_UV1WzZPS5SE5JgGH93najNsSZah7uXcdPGMNyrw
|
||||
sxlekvdyk8@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic3hsZWt2ZHlrOEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUwfQ.4qGQnhHTBHiBuYTdjtb5KPNi0NIQVV29KyekbnTtyD8
|
||||
9kdfn77ae5@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOWtkZm43N2FlNUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUxfQ.22d5xeYHK4uXOUS1uYrNFwKL03gxVAlptC3vurHXSOI
|
||||
jel3ufpqs1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiamVsM3VmcHFzMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUyfQ.EVOdje-P8ELvVslo3DctlTB_OCbCl9kEdO-1WjNnDLA
|
||||
ehuq2z68tg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZWh1cTJ6Njh0Z0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUzfQ.mGJo_R_8NzA7IxZYBOxaQC88UrRnF0kP3PSe1H0pGnI
|
||||
sppe0x500w@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic3BwZTB4NTAwd0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU0fQ.8zbqlfeS2hRASGeMSXuaBEl9RIl_f0lDn_5yYWQ23Yg
|
||||
lsbr1j6f4u@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHNicjFqNmY0dUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU1fQ.JRHOslr9wMHAkdfjKoer_oG4rp7rrfE2IKXQqWRjsdM
|
||||
r24wvfdore@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjI0d3ZmZG9yZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU2fQ.cmGsQVOYQHlhl5vGLR9m2jPWHrTC_EElnoGt_JNvsyY
|
||||
qchmmlhgf1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWNobW1saGdmMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU3fQ.P5cCH0mvDDLxwEYhEbdmkr8BxfOvfs5HIhIKWMi4zUs
|
||||
8w16x0hlu2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOHcxNngwaGx1MkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU4fQ.8sxhIcwAVOtuU7kCgwd6OVjbrADvTbhXwD2uj-LjKxc
|
||||
wfx35bxg0y@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid2Z4MzVieGcweUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU5fQ.lUkoKVNSVqSyESO-hkSfnnm6nL5SH8MA0O3U32ZyMhM
|
||||
m9xsazszep@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibTl4c2F6c3plcEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYwfQ.A6R9ELIiLp9PjALZiPBsLmlNLGrsgBCq4laenjB5v9Q
|
||||
dadqfcez9v@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGFkcWZjZXo5dkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYxfQ.-Vde2WifBdb9HgqcWMK-TLSS-sUikthxqz61XENYvuM
|
||||
qhmomxwjk1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWhtb214d2prMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYyfQ.R-bEGikZGWk92yO8bI6YisMhC73LjGVZN7X8dC3WO2E
|
||||
b2zxnf27u8@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYjJ6eG5mMjd1OEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYzfQ.y2F2vAzsfLXWHFGXIynLKs6IMfd1HKonOnFvmsEkqVw
|
||||
bw1blewoqi@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYncxYmxld29xaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY0fQ.5G0_EU__qBSblBhIVk2mwnyOlhN8rA7G1zZ4da0kzZQ
|
||||
dyki4j4kya@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZHlraTRqNGt5YUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY1fQ.OZm5tYXe5ZLB1tnaEZFQinZIu2qy_LCbJzbUNSR4Knk
|
||||
70nto0p439@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNzBudG8wcDQzOUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY2fQ.lL2opwQLRrtLoTs1Vh8Dd6fJnl15IFmXOdv9wP_rMHk
|
||||
xc1m4yzxrt@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGMxbTR5enhydEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY3fQ.PE1Xapa86tdODuWKKdkumNhNXCDyiF0Mx8d9iAlDecY
|
||||
ncwm8iff6o@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmN3bThpZmY2b0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY4fQ.h7Scd9LW7EeAVBr3UNQrjmjT0En9lY9bYtIfqfgqEdI
|
||||
a9knqbgfbn@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYTlrbnFiZ2ZibkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY5fQ.p7wbi7vmZYSqbdlAN-z3U6pgKO94bt7ViqovVzImsWA
|
||||
0uicow3wob@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHVpY293M3dvYkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjcwfQ.Crwl-ycJuw7CWZWqcku-sxvhlPEBdUWqTjlVz7_siNY
|
||||
gmo5ea242r@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ21vNWVhMjQyckB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjcxfQ.iiakpVN3wv2ciKS0lMuTAN0MXNYk79JJgNa9QZki0vw
|
||||
7z0vik8o13@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN3owdmlrOG8xM0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjcyfQ.m-NKJoyGSw0XVArYVn4SInvBDf7099hireUCuIaA1Gc
|
||||
i2e9hhze8k@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaTJlOWhoemU4a0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjczfQ.wguUmyBb9cD-xiFYupHxIA6vm44kjbJgBnFRMqr7SH8
|
||||
14v3iqwtb1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTR2M2lxd3RiMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc0fQ.yyghTGPF4vRAzbxZs8QVYuaEWggCx70TKU7URvz_vG0
|
||||
6vmx9qojn9@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNnZteDlxb2puOUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc1fQ.wU7hbewHEvXI3GpWhSwBDg7d5_nggD7KqXGGiHbmuFU
|
||||
ps1ttpfsg8@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicHMxdHRwZnNnOEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc2fQ.UcD772RCCL_n8A7exvBAusso99YBYCVwgUVQvQJeySE
|
||||
zeiyvr3ya8@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiemVpeXZyM3lhOEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc3fQ.jggcScQrrZP8D-PUTkyFE_yq7JKfjrZKIws5CnQd8_4
|
||||
1xcjtmqod0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXhjanRtcW9kMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc4fQ.9DdV9sm7NA8OuuwEi9-admHH1De4FPWb-Fq4hJ4eAfQ
|
||||
08jjit220r@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMDhqaml0MjIwckB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc5fQ.t1zXwX0NYNCtVYoBvfsFx8khPXn57e5ppwxQAWxH5Qk
|
||||
ju1978ur3r@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoianUxOTc4dXIzckB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgwfQ.kWs4r9KRcB4fAw-5oGlh9wLTKDZAoMb1Hz8ElEVqAeE
|
||||
qguuop12jf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWd1dW9wMTJqZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgxfQ.lOb1iIc7jxMsPo5BN5MNcgCnIExA1QkGmNZ1zKBnw1M
|
||||
6hoxgazm1a@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmhveGdhem0xYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgyfQ.hMejSvG8fIuuOnHo5MQ2kpBAvAKhu3c_pChYbiuWAFE
|
||||
dyipx2z6rv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZHlpcHgyejZydkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgzfQ.0mDqnMGunQ7iv_mZcCgN1XgZToQoQDNsbQXA16nObGM
|
||||
q64674g0oc@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicTY0Njc0ZzBvY0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg0fQ.slqb1fZlghGpLoOfv0oYH5byt7ux39kn1lKqCIYpwYo
|
||||
5oxi5vp6cf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNW94aTV2cDZjZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg1fQ.sh1EXLsTb1CNv-oMpeN5yDB-wHc0fXTwSFjeNm1Fdcs
|
||||
o2i002mred@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibzJpMDAybXJlZEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg2fQ.kStKxyLrk3zlmZNWRs1sMotyHIHa1x28Xs_u4m1Zbk8
|
||||
rkz3248ln7@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmt6MzI0OGxuN0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg3fQ.-vNsdC33qRNR9LeG6F7l8eJxo5uRiqiNIVPhkg177Ik
|
||||
hic9orq6zs@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGljOW9ycTZ6c0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg4fQ.9ohLix8wzOsAG30XirKbnmDZ6vwOxfI9SEo4Jw6vl00
|
||||
p0ol4om3m9@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDBvbDRvbTNtOUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg5fQ.uVFZmSIwrPmQVwvgSGNjvgsOI-j79wTv2p8XLzN6-p8
|
||||
aihl32j3dd@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYWlobDMyajNkZEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkwfQ.0xN00stBnNXrXh9lO2xgpei6ErwtJA1V3m27inuZvK0
|
||||
cyzkvew0vu@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3l6a3ZldzB2dUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkxfQ.i53bXVjUwTrWKVJlD2jqfgyw7h29pA-SLwpU9EH3JyA
|
||||
9wdbo6l5ms@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXdkYm82bDVtc0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkyfQ.gURoLuDPbb_2WajTON0laRdoih12Vgjik4h_lbXWE54
|
||||
yq9ltaotgi@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieXE5bHRhb3RnaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkzfQ.mY6Bh7F6uZdFFhCB5yKcw9717t9SpDHwccYS2Z4uH6c
|
||||
unevs1bbmk@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidW5ldnMxYmJta0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk0fQ.CAkwedUhxNhcThKsbZt2nJfJ-90mzUbKHUFfnXmf6TY
|
||||
t7r8sgpbft@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDdyOHNncGJmdEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk1fQ.eTxzH-JaSMKH2tvIC1md4rLWybM3ps7Zl93pFSphOHw
|
||||
00benuc3v0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMDBiZW51YzN2MEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk2fQ.3R9llObCvkh4_FrklptPMufTlou1T9-wAq0PxJvD00s
|
||||
flnkhfldtv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmxua2hmbGR0dkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk3fQ.vQ9vAWBQvLS_oeNQS09KGOuwta1T1_57JKzBFAUqEBM
|
||||
5ywvx0rpek@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXl3dngwcnBla0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk4fQ.q6mmPBbYo1x2lJL9LKesIUy3TYWntfvtt3xasibexLc
|
||||
63qntra1u1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjNxbnRyYTF1MUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk5fQ.pYST-90VpBXx9AXYJWoWT0_63FA5OAPAfhUkOIczWFc
|
||||
w4wa5rm6hu@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidzR3YTVybTZodUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMH0.5c5ahQ2q-FAWbL1Ny1eFuGbqPimSXS32759bgbNlb8c
|
||||
j9v82f8ppo@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiajl2ODJmOHBwb0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMn0.6-of5Bm6bg-E4bgXhkLjOJtwcJs7XpzMVfngWCXK7QI
|
||||
aqvs8amjk0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXF2czhhbWprMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExNX0.DnM0C9WXz8dnBcrujVNkPfEl_kv-jYRab6WJsCZglPs
|
||||
i41rgfgvz7@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaTQxcmdmZ3Z6N0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyMH0.GxJZtlDevjYIHOg-xkeBkpf5gkglU5BoCheahXh6pNY
|
||||
fmu93d172k@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZm11OTNkMTcya0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyNX0.bp6qArAxJ-1851Y6BdvInG1G5P93g4iEAG_66nSeq-M
|
||||
7dr7ypwlst@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN2RyN3lwd2xzdEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzN30.s19cHfgk5pbNak4x29KkxSFrKdqiJGn_fupZe8yJQmc
|
||||
u572xz7j8e@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidTU3Mnh6N2o4ZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzOH0.tvxvSUxuOrmlyF8QjTrvf2gCJVKCOE7sZE42yk--csM
|
||||
2pvm4igxcq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnB2bTRpZ3hjcUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NH0.M0l2br71dOGivN0hln10FZU0C_soPT0qGdv3YTzCiNo
|
||||
atou74mhyk@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXRvdTc0bWh5a0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NX0.hoRTuZVCtYTcEiARdO2YIK3Vf2yTqrdd1pV2hC1k5tA
|
||||
om1e0lmbya@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib20xZTBsbWJ5YUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1MX0.WoMYDBR6SPjeO60iJkorKi7y-uLeeV9Dfnlw9GAsEqA
|
||||
7lfbhjn3dx@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN2xmYmhqbjNkeEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1NH0.0YYqgm0GX_WBiGmor-WgEeF6Ppd7gWUr5xdYbbaA2VQ
|
||||
fhzh8ir864@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmh6aDhpcjg2NEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1OX0.sUnf9V_qTcnzLnw2tGKkUZhRc_rle13jgO2fMscmpqY
|
||||
32nzg6ntz0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMzJuemc2bnR6MEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2MX0.5QOCgH9mRTSFdvw1sv9swAobG_7gEgrq0i6O3oelztQ
|
||||
vrefxp7lzk@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidnJlZnhwN2x6a0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2NX0.wb7vERCGfRC3gDQfmxkgkmchBWM-VghdYxUc5uFEB2A
|
||||
18rz4os9y6@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMThyejRvczl5NkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2OH0.U0WYHYc57ed43VXgoIWM9RwlM0Q4HYwBC3Wj_ngzABY
|
||||
mnug78nz97@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibW51Zzc4bno5N0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3MH0.gKDb4BtJv0emJSBYJ-4pplbAdex7jJCwKMGsnXsadms
|
||||
zoe8g2ioc4@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiem9lOGcyaW9jNEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3N30.wSq_Hc7pp_2b1TUEQC27GtZ9GVfJ57RHOyiNsv3I6ks
|
||||
cgzz319vwe@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY2d6ejMxOXZ3ZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3OH0.A-ae6Cw0EXVD1N_0g6LhiGdxsMHjeRUWF3tc60kodYA
|
||||
yba8p8e4eq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieWJhOHA4ZTRlcUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4MX0.QKBKK1qal5s-jZtPJRN4zar_YyfWLBMK-PKUjgay-50
|
||||
xfa5x37phm@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGZhNXgzN3BobUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4OH0.RorSHxuYOE6y-vTZLx6npMQQKQg3xtPRzelREEy4rH0
|
||||
uta2dcly0s@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidXRhMmRjbHkwc0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE5MH0.WQDoZSITxjGR4Pnr6XLi70QndGIZRRAtJ7rbToGtwfQ
|
||||
ng165t7yrp@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmcxNjV0N3lycEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE5Nn0.aYVvpbBAHE8PyvbdzlEmc51qVSoQ00N5F1JhI1_2Kjo
|
||||
3ye7brxqy2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3llN2JyeHF5MkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE5N30.OhwWlqdDVYmBuEbfoLHx-6z_zr-3gpQBVSR1tWWTkeo
|
||||
8rl1t7h5gt@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOHJsMXQ3aDVndEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE5OH0.G-g12jgm2HanVB4nAiKKB9bLjBa4gm_7xIe3QD2DeiQ
|
||||
n8oggzf2dt@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibjhvZ2d6ZjJkdEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIwNn0.GcEmyf7DwwPv5n7IhOWR4j2_XVD5vkIY2YXMIRDrFEY
|
||||
t3yvxx7dbr@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDN5dnh4N2RickB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIwOH0.lX1nyAgRsbkPFSdaCgZUyQEgCZ_E6vx2EYHwRO6U1LA
|
||||
2ts9njjr2m@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnRzOW5qanIybUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIxNX0.cgYaH4duCpvE_5HmPHcn91ti02t2p9we_BfLArb_V8s
|
||||
u66flmpoo8@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidTY2ZmxtcG9vOEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIxOX0.Diuv2Vkd7pBEsgg2aoiH11cSaVnbe3tiyuuOqG900po
|
||||
3zwdnyukt9@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3p3ZG55dWt0OUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIyMH0.Z8wUdk5Asw-ZgclCP0VxWwTrNReKZmUhwCXuZfUlm1g
|
||||
92dj67v6hl@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOTJkajY3djZobEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIyMn0.vgZsGdkEi_FYOB6kauVcaoxlRg-fNLBaqQihWwzg_KA
|
||||
noy2f19fy9@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibm95MmYxOWZ5OUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIyNH0.dosfFFUiSQRfkyd-pmM-XQlOa2TcNgLtT7_A7AJynRw
|
||||
jx2r9xmdsc@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiangycjl4bWRzY0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIyNn0.m1iWIEZa5R1GxsoFqk29QgSHRvuiKJaNGIi4dCJr0MQ
|
||||
wqpu1zn6zv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid3FwdTF6bjZ6dkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIyOX0.LnCQe3xLJBvYDL1ZibL4LxG2qKG-kSJX_-bSuhr4Z-M
|
||||
akzubd72wk@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYWt6dWJkNzJ3a0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIzMn0.K6YFTmWnc-fOIzgcwu1ogaEit07XGC0tkVrveMLjXZs
|
||||
gbd0h988wy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ2JkMGg5ODh3eUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIzNX0.v0AT6aO7-IRjJdqxaMFHdJGpI6jy3J4yzmwf41i--04
|
||||
l8dvj4tzz3@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibDhkdmo0dHp6M0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIzNn0.9fbzIhs4aujp9HRdLYsx6yGi_YfWqrK_Vd0a4GyAkMQ
|
||||
arqq4nzhwt@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXJxcTRuemh3dEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIzN30.TovrJ_k0vF7XV3p3X_PrOOqCQXeufsgMtamQWuzqz-o
|
||||
rc0lbuf15u@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmMwbGJ1ZjE1dUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIzOH0.o2d2Q-w9KeiU9zUXEq7OGzeHNaszhthjJaUpBDX4e9U
|
||||
0uzc8499m9@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHV6Yzg0OTltOUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjIzOX0.eqSDlXYcAjrEQ7_uKonmXZXLJulgAlCREKuvZvuLN_g
|
||||
nvvbe771va@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibnZ2YmU3NzF2YUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI0MH0.u19RNXjqieHgWJtoLWZiH0ax0-R6Pcdjx0nmst70kZg
|
||||
uf9dgvjyl1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidWY5ZGd2anlsMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI0MX0.re0QPKEauV9yHEIGVVivf92CkSJjalSB8Apl0j0h8EI
|
||||
9vvfwfqf8o@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXZ2ZndmcWY4b0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI0Mn0.j_3BWjaN2A4qqCl-5mo0hfZhd3R4t-KUx8_e761JtxU
|
||||
izlyb66i14@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaXpseWI2NmkxNEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI0NH0.P6ezZsCkBYJ5Tb3Zm7fgoJRejfqYnhB5xE8DPFNTmdc
|
||||
zwylurt9zt@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiend5bHVydDl6dEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI1NX0.CYxtyFAJtelq9vY3CVBHwHoCIv8gzz9O3r1UICDK4-g
|
||||
1oct8uh5lg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMW9jdDh1aDVsZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI1Nn0.lHw_33QkKorwdyqY-gkH2ugztZl-2Bo1Idef1JeKWZk
|
||||
gq69updwdl@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ3E2OXVwZHdkbEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI1N30.SaCHXXWUhMAAR-yvqqg29ELndsgIxNvgjo9B4TOV-B8
|
||||
aea504svaq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYWVhNTA0c3ZhcUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI2NH0.7u3KzuQfYVI90ZsijMOoqtJBXz5x77F_SJsU6epmNF0
|
||||
w9ouzl9e5y@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidzlvdXpsOWU1eUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI2NX0.Von7SkTDSTtyuJHZtnRr6Zn3nmxu5DuJfrhMnQRf4ro
|
||||
qcyby4prpv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWN5Ynk0cHJwdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI2N30.VJhKGviefh1ph0X8nw1LO2FqS_nOJXqKiLObc1yXaZk
|
||||
ifdt41tniw@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaWZkdDQxdG5pd0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI3NH0.o16TmCKsalZ4F58TmhdSnpORGhJZbXbTqXU5am3QXRo
|
||||
gg793igpg9@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ2c3OTNpZ3BnOUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI3OX0.SGx-J89dlntVIuHOLMFw4osujzExnwRK24v1UGZfCzI
|
||||
0m4iax1bge@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMG00aWF4MWJnZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI4Mn0.NU9XI2xbp1VaW512AJiDeVZjS-nKL9AUA8pu0-peSwo
|
||||
8aaj5z1y12@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOGFhajV6MXkxMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI4N30.zCzcIDkAtNr7mpZpLIXgvPM5vaQlYQSvNmoVieTZ0Zw
|
||||
5djr5p7ivr@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNWRqcjVwN2l2ckB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI4OX0.46oD1k71KvBXaEpwYjOioznP-biJ_EMwc2-peT7yrvQ
|
||||
hb9tyjy5w6@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGI5dHlqeTV3NkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI5MX0.d584RDi4D9l1KeVgCHvJE8jo5QJ5AmUk6i-lL91PkiI
|
||||
ik6o87cyri@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaWs2bzg3Y3lyaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI5M30.9dqElO2hdzQ132_5OIaVdv3MVRbHF1TbponNKzNgbB8
|
||||
zlnol5zb8c@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiemxub2w1emI4Y0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjI5OH0.9AHdN4-2z59ZdxdJQkJm-P9c7HzjnCljJvJaO_o9UlY
|
||||
qdzqox8f2p@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWR6cW94OGYycEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMwNH0.3DzzAnmz5OKN8Vm6yUHMAsjkUFH9cCHD0FGxi0z-WP0
|
||||
834jlxmfe1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiODM0amx4bWZlMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMwN30.2-N_jfsjAg9a_SCHrrEoC0mzF6izl7NfE51pEuhcS68
|
||||
7csb0on7jx@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN2NzYjBvbjdqeEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMxM30.yTVfGu-hgdIQhIOjqew7GzG__ebrnuf8A2694fgtTPo
|
||||
hxmj8i7h3g@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaHhtajhpN2gzZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMxOH0.3EN4F6KkUiEioDfwzVUI6xZ8fAjS744WlVF6G2f1sh0
|
||||
4dry38j8jw@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNGRyeTM4ajhqd0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMyMH0.5zfAZRecx1mwKWgS6XSL89d17upnGDR7qVS6FWo_d0w
|
||||
czfh8jhbhr@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3pmaDhqaGJockB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMyM30.yyjQ4nUATHZbvXxs3xbIxkIFcQXO3_X3cfPvor6po_w
|
||||
x7shf6y6dj@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieDdzaGY2eTZkakB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMyN30.Pf--Fx8BLXMcBMTOhxYc7fyF5SD8D5MBjpV2AqPWC7A
|
||||
1crwjb88ls@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMWNyd2piODhsc0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMzMX0.f9hMwDj_Wld9P_OV73hYlFWJoGO9P83XBHqVs6IvonI
|
||||
ddhhv04qtw@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGRoaHYwNHF0d0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjMzOH0.tarKt6jmMdrwQOjE-Gl16wa_OH30aVFlF0QcXBHEy8M
|
||||
jumxzgcls0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoianVteHpnY2xzMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM0MH0.u37Z7sw2u6L-1GKJU8jy4yHKOjwdIE47DHiytwB3xnQ
|
||||
p2uywrf5uc@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDJ1eXdyZjV1Y0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM0M30.g1jjVDZiaUhZ_c72wZhg2VwuUUTEfIiCwH9uz4tjUJ0
|
||||
fg4bv7zopy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmc0YnY3em9weUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM1MH0.6npy51IkxqIqxiZ_JfGXItyVvigBehJhOPsrWmeccLE
|
||||
9msh4lzvaz@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOW1zaDRsenZhekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM1NH0.KgNXbKd8qzgmWs3XXDOs5SUgehjdftMSAGDsiw2CKL0
|
||||
h14bxiv9hh@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaDE0YnhpdjloaEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM2MH0.b-ZkD1OThqFNIw5vMKu40Z4XRILhWnHTVZ9FsKGXZOQ
|
||||
zrm248l4zf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoienJtMjQ4bDR6ZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM2NH0.Ed8anUEvNO1N3b0eMUdAJBbvWLxuTnFbcqCvjOiUhZE
|
||||
yr7hdjap0z@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieXI3aGRqYXAwekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM2OX0.GYE34S-mHyNNsJtPfZnI21WygYQp9DsJEFQngB2o85g
|
||||
vxruc3dn1x@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidnhydWMzZG4xeEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM3NX0.1_Az56rBrjv_jkQ2crXREZgOag8vTaEAzXdoRUlno-M
|
||||
a1jgu1kao6@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYTFqZ3Uxa2FvNkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM3OX0.K6gISqGoyuOZx6Hu3DavWfXHMy824dTI-IphRxp86Wc
|
||||
ttj9vrsj4j@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHRqOXZyc2o0akB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM4MH0.GBqtOMIL5V8oXDSOijlO02qfRhJgXdscCmuWn3jXT-s
|
||||
642zsfolme@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjQyenNmb2xtZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM5MH0.9XN935DtHD9Nl6rTcneiHVK7ILUIHhlPdNlanw6ocmg
|
||||
g8tjgxunzy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZzh0amd4dW56eUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM5M30.JjRY9RcQAHTPb4NiHd970_Wb6jeaFARjmgGdI9WK_HQ
|
||||
xz1nyvr8ll@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieHoxbnl2cjhsbEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM5NX0.J9FzkKy7NB9zq8rKgBvXKNOAyBYRUgUJL8s4iZL8Jbk
|
||||
tkr5s62wzn@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidGtyNXM2Mnd6bkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjM5OX0.8EHW91ONiicZAcflJONEjDmSOBaLtxjjYwOfGSBiT3I
|
||||
f2t1dntr1y@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZjJ0MWRudHIxeUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQwMH0.FErJZEscnshAuNkI3b6bUf1RxY5ch0g7K9mFyoTp-jQ
|
||||
jdxohlw31s@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiamR4b2hsdzMxc0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQwNH0.cdSSD56PhZ42IbYdGHbnkgRVtnrO-JiBQ32-joRVWC4
|
||||
fuqw0ysorv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZnVxdzB5c29ydkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQxMH0.b17NN70KEvZkCcHFnQDalCFVyTAd14UwGmjX8KsnpDM
|
||||
p962zz7elu@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDk2Mnp6N2VsdUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQxNX0.2JecMlpP6wtQqY2nNtXD_6OVpDCfA-NHYncDilMXRn4
|
||||
kadp4868a1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2FkcDQ4NjhhMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQxN30.eelB4ohUYFqAN2sg9x4Ug89cEIDTUUQMSEH6BS2tZw0
|
||||
isfkweohkw@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaXNma3dlb2hrd0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQyNH0.0vmCQETiSbptZ9sVwqbBTGZqnsgGaaLM39UtQE3K5TU
|
||||
gg94oc5c03@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ2c5NG9jNWMwM0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQyNn0.7N3QrlEFrC3S_a_TtaCn12obSXC8Hs2cT9zillqYqNo
|
||||
3wbo1gfckg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3dibzFnZmNrZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQzNX0.mQGSfoNukxbuedHWxiePLI_BBAEAuSn6KSXeQL4vGmc
|
||||
j7itvcaovf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiajdpdHZjYW92ZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQzOH0.v_0z_jxFiYILGcC1y9NGLewEGoLX3hlTaWjWt2a98vI
|
||||
4qxu6p1dcg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNHF4dTZwMWRjZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ0M30.9HrSm-GAXHrNajFCBsByUFvMUkfTxAW7JKrfleX2gR4
|
||||
cnxfxolccv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY254ZnhvbGNjdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ1Mn0.JmM4ZGIs1YNiVLv-E_AllspKFJc_ovGOBkqGYNBqyW0
|
||||
f7l5wkl0sd@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZjdsNXdrbDBzZEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ2Mn0.3U7JuFLwRdpkIR5kUsz4y3_5Y8gHf8kuqP3uq17V8gw
|
||||
w25dnfcal7@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidzI1ZG5mY2FsN0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ2NH0.WyehZmN_Bf6TrLfd6U_Oq2kgp-P9Qt_Jrzv9DPEUcWE
|
||||
sra8qqilga@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic3JhOHFxaWxnYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ3N30.oYFUeGSDlllDnEUrIAhGn7Hd7cDh9EMao2Emy2BobwI
|
||||
v81rw4own0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidjgxcnc0b3duMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ3OX0.pY1oroiC-hqEHETTrUqIh6ZKthI9rKHoAKpW4gaAR_U
|
||||
d8len0bbcy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZDhsZW4wYmJjeUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ4MX0.9qkRfn9qnUE8zYWB4FcbHENDWWmYcvuKiIRShy_0_d4
|
||||
aj01z8yabn@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYWowMXo4eWFibkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ4OH0.sDBgRaCm7mRlBdRK2qw8eaOmqEiUWedRUTfsHKDP774
|
||||
dtqa62h2td@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZHRxYTYyaDJ0ZEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ5MX0.8hH9vZcVDCPneszHZa5oQi3l9ZlVPlK3QX-E9U1m5qY
|
||||
e9m3lszp60@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTltM2xzenA2MEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ5Mn0.zZ0-d6ETghZbG7m2duMJl67eZEsxBJOfpdH3k_lX-L8
|
||||
mdyyujhpsg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibWR5eXVqaHBzZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjQ5OX0.6ixqiBsBrA7mnAY9stUPFAKhYRq2XlRI3m9EpSQHmPA
|
||||
8lxtghisfx@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOGx4dGdoaXNmeEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUwMX0.QQM7bsysWP8-XhHE8ogoYPrXU-UJkeGeERCF04_m2U4
|
||||
9jmotxy2kh@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOWptb3R4eTJraEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUxMX0.jf8VoPvtQdB7Ts5UX1dcfjFnsD0X5WO2DnStMpSSG6U
|
||||
5q0yisvpnj@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXEweWlzdnBuakB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUxMn0.QV4LoYdNcZHJNE6QKTbmOP6SqoKis5s3dOenGbVuHqI
|
||||
evf8wdvi9o@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXZmOHdkdmk5b0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUxNX0.Cocrj_Bk5L0oVZBg1sXjjcDWPEc21pjWfjUPVkfnTFg
|
||||
auo68wuyfg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXVvNjh3dXlmZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUyMX0.L6V5VvuuJpzurrA0en0T-Yje_tp_2ooTr9HGFNX0Hnc
|
||||
xdqdiimg5f@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGRxZGlpbWc1ZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUyM30.-5oYnGP4XfjUD76ThQYCdfP6tEyNdSyqCun9QMgJ8uk
|
||||
e3uloxgq99@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTN1bG94Z3E5OUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjUzM30.WJPQkccm3dk1Mi4SuPyjq1m6Xf-tSMOTj-IEa3Ny0jY
|
||||
s0dwb8itwi@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiczBkd2I4aXR3aUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU0MX0.wWnXSH5iSAZmOKnjYAbA6OmKDR-4y4TQRdP4gBS0Xxk
|
||||
edoewzi3j1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZWRvZXd6aTNqMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU0M30.pIDnp_Bylm2KGfliwgQ4Wci3C1GXRlBoaU4tQyIyt8I
|
||||
4co53h3o86@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNGNvNTNoM284NkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU1MH0.ennpqUMkHzAsCRaIM701obvhOyRJ8pEyrEwRUv6PtBI
|
||||
y0wu1pah9w@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieTB3dTFwYWg5d0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU1M30.94nrKy0Rs0SHoPIUQzgwJw0MvZzHhTio73tSredIIzE
|
||||
x5q3ohp4b1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieDVxM29ocDRiMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU2M30.KrGdmLUKh9_3YXtsDOcnMF56iiIE2wxBuMZT404SOgg
|
||||
avpcw17jz6@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXZwY3cxN2p6NkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU2N30.cUUTha3yyCYnDxJrAMP58iSOKJWVyjSfv79xalGzyvQ
|
||||
geex7jiofq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ2VleDdqaW9mcUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU2OX0.6Ywx0wGz4-oC1mzkxJTuXM_an8PJiyMGVGOUzTQNHUI
|
||||
6crfx5elnz@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmNyZng1ZWxuekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU3Nn0.VXSaKst7H-DRKUqYeqmIlJrxPgQuRN4_MYv7WLgFag8
|
||||
2sarpqokoy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnNhcnBxb2tveUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU4OH0.nanGtyJ50qBFBSvI1qlug40z6sOpF6Bip4jfuzLBahQ
|
||||
4qwgi9jhhy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNHF3Z2k5amhoeUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjU5M30.fvXFI6NiFaWEYIuChjX-cN2dA_ZUFZPLn_EsKqFuh0o
|
||||
1wsu2wei0e@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXdzdTJ3ZWkwZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYwMn0._Ybj6xwngLcB3I5OX64YuSpAz_DH4IcqOTK5z2WXcK4
|
||||
i2yszoe7j1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaTJ5c3pvZTdqMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYwNH0.UgfIe-fDhpHzBDJREy8S00OX6NJeaPhEVgzv4hGf07g
|
||||
pp846oa63l@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicHA4NDZvYTYzbEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYwNn0.wmgizFYAoeQ7oVbPwVkI7NAUbC0IMllIB3M1oczXeVw
|
||||
ox2n8hi7wy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib3gybjhoaTd3eUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYxNX0.N3KQG765ZG5v8oIE9dhRSNY4c1xTcl2d89qAbCAqYyc
|
||||
a4kjmflu1a@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYTRram1mbHUxYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYyMX0.wyavbjke5vNocbdg8zvm4k2oNn5j9racQqxxedVKOUg
|
||||
0pqagtv2e3@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHBxYWd0djJlM0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYyOH0.D90J7_z1_sBLYbYfjWrzaGHeEJ2FyyfyQaF3ahSmCxw
|
||||
wvypbv9ult@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid3Z5cGJ2OXVsdEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYzMX0.raP6yv4pSSQU-dt5MRotolb-3t_vo_CYy9LtRtBEwLI
|
||||
m6eg4t7s6a@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibTZlZzR0N3M2YUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjYzNX0.90_UadYxK4uSoz7kf5pS98Gsn9RSngeqq7a8JuLqIm8
|
||||
4389cfj60g@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNDM4OWNmajYwZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY0Mn0.bIb0Zr-KKibJbH0NvNKu3mOjHc8wg9KEmyddEOut0sA
|
||||
ns0j2fxfdy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibnMwajJmeGZkeUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY0N30.D-3WtZQGayI4D-IJGj7OiOK7Du0ceJKxDHEhvNx7lsk
|
||||
9jngrbp8ok@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOWpuZ3JicDhva0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY0OX0.ZsJGYTNEFb-jt8vSLrHe6f1trAJHNMuPq9wi3ZXP2Ls
|
||||
u3r1flai61@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidTNyMWZsYWk2MUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY1NH0.ko3VxbeFJcL5r2dwD_tSnlaOTKdnAtN54XtHVQQpWBI
|
||||
8o5y0se83z@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOG81eTBzZTgzekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY1NX0.f5-5JRpoJ6dDiJoWt4nunvAyQABj8dOa1yfQbd0reMU
|
||||
hd4kiic7jy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGQ0a2lpYzdqeUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY2MH0.eLyM-1sLrNTTIKeiuTCtKvhTyaGy9fmNqFWSLHz3Cos
|
||||
37kk44klxo@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMzdrazQ0a2x4b0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY2NH0.kAb8wpLdXYlD73oJmwvRP2z8q-oEVpMExbHcaPofOUY
|
||||
eofki9t279@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZW9ma2k5dDI3OUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY2OX0.xX3S7I9WDHyYAw5jsSxsCoY3mnUYo9CfZaZN1DJncDs
|
||||
cup7uz2jbb@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3VwN3V6MmpiYkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY3MX0.3KWaPkaA8XcUXJju-PCjIYAwU445PKB4Yi_dnmfQydo
|
||||
14d9gqtuyp@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTRkOWdxdHV5cEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY3M30.MYtxT0VAXXuDAbkfOk3xCWXEetjE2BU0QsgUMmKnafw
|
||||
qobn6pkyd3@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicW9ibjZwa3lkM0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY3OX0.E8W-A4gRZnmubm3vNdvl61CamqiokH3oK0HHi4yr2wM
|
||||
16b860h0b5@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTZiODYwaDBiNUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY4MH0.3xIhdrqzXHRdmxiU1oBNjX-IIhJmUG4WukdAMmxIjCM
|
||||
5p7rzotbs0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXA3cnpvdGJzMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY4OX0.bIWXSL8AIRYYOlXTuFF9iEFmBB3LsSWITLv10nhF8cI
|
||||
m3vw493d9l@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibTN2dzQ5M2Q5bEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY5MX0.TIoe-Q7HrrViT1cww7Drxk_E94AfZneurI7Xv_VHI_Y
|
||||
bwezq8s3zy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYndlenE4czN6eUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY5Nn0.mEZvgqHgC3kkQYtpAGhJBqXsY8NjJlT7dRMFtkGd5g0
|
||||
upmaqdreoh@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidXBtYXFkcmVvaEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjY5OH0.N1gOzl2CxJnqrMUkX-LvlKXxFw-48i8HPRezPu8L-iU
|
||||
hdkv74u7af@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGRrdjc0dTdhZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjcwM30.EMMUvJhonsUvGky9wkCDkbat7QXpKd_nv9VMK_hCuaQ
|
||||
rdjx5zjh06@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmRqeDV6amgwNkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjcwOH0.5tfxT09vMmWPCJa7GuPnT7NE-6MHcDp_0wXHW8_9ww0
|
||||
klpd65ltkf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2xwZDY1bHRrZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjcwOX0.jTRilNS0GHo8PaQT849kkjDSztNFPoSMUQ_UFikdQFw
|
||||
hqz2cxesb6@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaHF6MmN4ZXNiNkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjcxOX0.av4t7tXPHsTJqGfNLtQCvRDKis5G9w_MstxyLQf6KpI
|
||||
mmq2lile1d@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibW1xMmxpbGUxZEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjcyMX0.reXioR7lzwWFJ9UdEobTIt8wgUKxFZBWFZFxsA4VxIk
|
||||
lxlksdyrif@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHhsa3NkeXJpZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjcyMn0.leiDAnfKzxDrFU8MCC1zN8SGqBzvCSmmuKvYGh6VumQ
|
||||
id65dcz8ri@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaWQ2NWRjejhyaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjczMX0.DKIyF78vekuYJ-oraS20566kypRHMF1iEcC7dQ3KAfU
|
||||
duakwon2sx@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZHVha3dvbjJzeEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjczMn0.VARUPknPgyoTEASzmzH_-0Kw5-cmN-5HwIt1fIayulU
|
||||
f5r5za0ucb@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZjVyNXphMHVjYkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc0MH0.Cz9mxdIt51QbTe5ATwGZwI18Hib-kNnt--ITyXxT40g
|
||||
0qd3oivm7g@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHFkM29pdm03Z0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc0MX0.OVJ3n1Tl35IkXhZIiYmaY8PW_eUNdOQeG77LOWozU0A
|
||||
6f3vqtgbvy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmYzdnF0Z2J2eUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc0M30.v5bZbKhxV9qGbkQDjwxloeVvo44YLn4Up2jFnHO-wWM
|
||||
k8vvvunyeo@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiazh2dnZ1bnllb0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc0OX0.Zzc1Djvcmbn0B2opeeZubWdPz8Ua2IyGKk0rxDp6qRs
|
||||
j9vnhcquxq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiajl2bmhjcXV4cUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc1Mn0.3PVz_Jpmo755XabJ_jTS7AQNiyR0-_JesqszQoyeTnw
|
||||
fl1kyuhx3p@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmwxa3l1aHgzcEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc1OH0.b2gisBpiQ39eMp4B-izHhapYvOc4KAtLHBoOSJ4wjrA
|
||||
3dua6ifk6c@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2R1YTZpZms2Y0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc2MH0.bVxCPcZ42loTEjBP6d9N6ciSReLLLUtUbOVaJC4cXOE
|
||||
x1u61wrpee@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieDF1NjF3cnBlZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc2OX0.FgLMEnzdeKHtbge403w0NCtbcnCeOVmr91YeHo6eIxs
|
||||
hxtl820pm0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaHh0bDgyMHBtMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc3MH0.ZYeWEMC2uYW74gp9TRUVbDdfA9lI_spKj7QtLY2z7Rw
|
||||
55pm4g9rhi@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNTVwbTRnOXJoaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc3MX0.m8WYC8F0yQdGfw2Gv7YZKsw6PJ8i7oereJMD6C72TAQ
|
||||
kekkicu60d@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2Vra2ljdTYwZEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc4NH0.VvTjBuPx2sdlyQEXvIVhOnw77SnuurcIPgQMvSpwvCU
|
||||
tyexyexh2y@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHlleHlleGgyeUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc4NX0.9tajItQ5RI2kZLQmgbzY2jLTa-IONpXasAvzdLd9E4Y
|
||||
e0s2mzq8jf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTBzMm16cThqZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc4Nn0.81BnRRaDI6Sn1CTvNZRQhd_sw-V-V88U4iHk00J4ka4
|
||||
109wk6q5ja@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTA5d2s2cTVqYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjc4OX0.eDnANIRyic4xNGANYavv7-gNQ8VeE-6hkOkhJoLug58
|
||||
ogczzvul5x@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib2djenp2dWw1eEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgwMn0.L0kwM5JDvtTmTwyTVm4f8-9U18TTZ0TfTLuo4AntV0Q
|
||||
2gsx4l18gf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmdzeDRsMThnZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgwNH0.6SZ78-iCMMkIYG3btKiLVqiF7KtEkBB9HhmUbPMxdcI
|
||||
3z1i7mlf3h@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3oxaTdtbGYzaEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgwNn0.pnQ_TUh4-2k1uEQhZOsN9A1vpR1Eto4Tz_hN_wwYy14
|
||||
32np650ks7@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMzJucDY1MGtzN0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgxNn0.hBgGFG1mtqUiK3tkSO_EDpQKwbCXhGv26DpnVUm_Qmc
|
||||
61vvwlzg9f@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjF2dndsemc5ZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgxN30.2SGjaXQHULmHxCiUucuj_MdrMyGssQan0csUYFkev_w
|
||||
2xejk4lxs7@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnhlams0bHhzN0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgyMX0.jd1TAd0Kh4s0bDoiT1w9UarA0lP-EuKzMKtzCCl1baU
|
||||
jc9oheeyvp@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiamM5b2hlZXl2cEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgyNn0.gQah1WmliMwmJ-8Ia7qBc4hZgM3YebpUq3nGQUO1faY
|
||||
0xxkhh5if6@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHh4a2hoNWlmNkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgyOX0.mKfsenrHSGt6SQR0H9fpNkC8xP6MlJYgjRz9ggCFKPg
|
||||
v0t5ywrg87@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidjB0NXl3cmc4N0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgzNH0.e62d0WZF6h0htXvnTyxnrA4U-9nuGuNAd7jwLD0V9Rc
|
||||
188ax8kz1x@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTg4YXg4a3oxeEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjgzNn0.-fkbeVJ16NpLNW9NoaWX5qDPMT0CeDTz3r_grtjjRLo
|
||||
638attic8i@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjM4YXR0aWM4aUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg0N30.GzpQ4xX_hkbyXovrKPp-y54dzFsGGFeOhJL7YXIMKjI
|
||||
l7qwqln6iz@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibDdxd3FsbjZpekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg0OH0.4ol8TS2xj5I_NRS2CfA0EePXNH7tCTh7OC3Awm_UE1k
|
||||
h2fq9t9xwg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaDJmcTl0OXh3Z0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg1M30.b8czCWYwmAWBQibYxgFvrVXTfHiL2kO3XXZJx2lBFrA
|
||||
53w7pxoiah@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNTN3N3B4b2lhaEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg2MH0.dhRJaoKHMLnu6YDjMu0e7C1N4SaUWvy1ig00YnLAsUg
|
||||
3swcb6iaxg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3N3Y2I2aWF4Z0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg2N30.4oyO385bmJzob7EkntJdzHz2UkGEADn-8Da03JXfUBM
|
||||
tl4pp9wuf1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidGw0cHA5d3VmMUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg3Nn0.4_alPlUJ1knLJxkBN0EI53UmfsESzviOzzbMGMV3GOc
|
||||
j4jyjf9m3f@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiajRqeWpmOW0zZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg4MX0.rRZ6Zt3Gr44LvawOuoWCQgyee0iRRBK1XcuOpklsw6Y
|
||||
u38fj3rcre@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidTM4ZmozcmNyZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg4Nn0.mRGolHtx49aKPIcZxHzQaJ6IRuKN4te_NbDLhPu9s8o
|
||||
aylo8fwzfz@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXlsbzhmd3pmekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg5MX0.WmmsZ0mn_ve3XbHem-Uyd2gpc5ptaT_UetVdRcjLSXM
|
||||
ltbxvyk00a@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHRieHZ5azAwYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjg5NH0.1U3PiEB7YJZgPWRCyS0wM9FWOaVnHubGo2LAN_Nyq6g
|
||||
2z35vrdybe@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnozNXZyZHliZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkwMH0.oVL8Ctqu4p-Dwn_OQH1IDQX1StpMm2DPCQwxscfueJ0
|
||||
05dqyknrym@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMDVkcXlrbnJ5bUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkwNX0.GFDRZofVGwwb6zWaQo2L_pee0BjvnUSLvFKPvNl6J6U
|
||||
f0xi00091o@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZjB4aTAwMDkxb0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkxNH0.Q5S6Vk-BtKcM0MZpoBsFxrKD3tbysVjhOZ8DMhBJXfw
|
||||
ka5bdfl45r@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2E1YmRmbDQ1ckB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkxNX0.d3G-9bvH7s6lh960t6MmCxh4GNAUvk1Tpusuucer--8
|
||||
vky5it7hbd@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmt5NWl0N2hiZEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkyMX0.8Q9eMSPidrBJN0QuVuCYTmCr5mAZwZ1bLYWulFElH1U
|
||||
jtgy38nk29@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoianRneTM4bmsyOUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkzMX0.SmcfmtWHOE1qF6hJmcGhh9_6bbJmHMZc9bFn1_228Dc
|
||||
0qvcbynnin@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHF2Y2J5bm5pbkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkzM30.uJD2kZVg1qXLiJ8WzcItTS4wHqBInWd94CEfTwoZJno
|
||||
bz0abvq8ag@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnowYWJ2cThhZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjkzN30.C-WvD7XNzGTumJAOjtiSqEIaAEsfDFNgEPKTzjUfy8Q
|
||||
41wblsohi8@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNDF3Ymxzb2hpOEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk0Mn0.zi8QjuiBZASPQFlKWraT9HZm5nDZFbmprks6oM_c1Gc
|
||||
6idfz3w9ru@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmlkZnozdzlydUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk0NX0.-pelGZwdtPhZpqEYXwz1TlinVgf589X7WbISrInbtAk
|
||||
pc0mevujj5@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicGMwbWV2dWpqNUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk0Nn0.0_RiwstwTCpwC2yr36CtBCPlZnNX62mCetV_IUzQkjg
|
||||
3oh3o742wx@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM29oM283NDJ3eEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk1M30.2XUZXW5Ao3cTX5H13VWbHEQiGZ8CJcYbnIdy76eCH98
|
||||
kx17opinzo@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia3gxN29waW56b0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk2Mn0.7DAVlxyiko-xYhRvDsvTD36mTb2lAl4tv4OlxkOLNuc
|
||||
vpebko915f@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidnBlYmtvOTE1ZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk3NH0.OFnAIOnhLpOZMJL6Ul5DljIXPW9kTOJmFMkFFZ6m39c
|
||||
fdgxy8jd5j@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmRneHk4amQ1akB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk3OX0.UfJ38yTrh2fmEWSITX-okVnKQr7nfKN_kkAgkwWUoe0
|
||||
uluk2zu2gj@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidWx1azJ6dTJnakB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk4Nn0.tuYRQs_Sn4Bbc50LgexSTF09uyU9oncB2pRxXEU-Z6Y
|
||||
cxbo868vci@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3hibzg2OHZjaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk4N30.BSMUMFuEjUm33oLqd2xgVxTz8OQ7tX9JP3kebMgaN4c
|
||||
nzsxzny5wv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibnpzeHpueTV3dkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjk5OH0.hdnpWQMhwTc7Zrn8Nl6DbRnkm-cNUb8X1RdsYnlmNgo
|
||||
y0gsz731fe@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieTBnc3o3MzFmZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMDZ9.bTZ7_vB3QrzdiaWfUjQ6TQ7qHKHGEoCHhaDntmkAQpY
|
||||
3s9y7xtus4@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3M5eTd4dHVzNEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMTB9.Ixemir5yz82Qmgoax2Zxfx3AYzXmxt_ZHpNYgASIU6g
|
||||
7tzudckqku@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN3R6dWRja3FrdUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMTZ9.kwFeF8TNL7FlS-O4uXAMWwQMqm64VwJqxTYlpmzPG50
|
||||
25jcneexoh@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMjVqY25lZXhvaEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMTl9.nZrGeat5z-a_LHo9spwO3iyRGkeGvz-7DrVInZ7I59E
|
||||
ni1kk9e9dy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmkxa2s5ZTlkeUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMjR9.t3K7fGLE0kslRV5_yZ30Lgndxet6Au6X3ERm2vnHIMQ
|
||||
kko0f7nd9a@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2tvMGY3bmQ5YUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMjd9.Nz-o37TIlnRdxE9zZlBTrax_waeS8fMSn0oOcwAjnFo
|
||||
lrx2yli9dq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHJ4MnlsaTlkcUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMjl9.EQZKpYry6f2A4upcHFnMHjPFcIeAnrQCBt-MJVceJzo
|
||||
u4lul4qzgc@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidTRsdWw0cXpnY0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMzN9.-ZXj1yJF4NEQ3M2KUXvXbKSMZkBx1a-Ovt5g0qrPq30
|
||||
b744isofid@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYjc0NGlzb2ZpZEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwMzh9.gH-jE9623wHkpqfwaCN7l9-lSVBeY-BsdF1w13E_j-4
|
||||
t5x5sfz0u5@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDV4NXNmejB1NUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwNDF9.33A2G3RwPjLNWc23ywZbFpXdmyXdjgO3dIiopqg0cDo
|
||||
xqdghq60jq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieHFkZ2hxNjBqcUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwNDJ9.bVVpD3v3y-oiTeN67ztadxXdi1WWAPwUgVcFWG9ZjRY
|
||||
gt2y4di6dp@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ3QyeTRkaTZkcEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwNDd9.mBnPCwXygjANHTMGEza4NeBn-1PpFOQw9qmoBz60nC8
|
||||
78sezomopi@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNzhzZXpvbW9waUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwNDh9.1GyLJdvsdrZnUlHyBcgAdBk4M-rF-LmplUkrHkFrSUg
|
||||
0g9qqipu0s@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMGc5cXFpcHUwc0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwNjJ9.E2mxaseq_b0X74jCYpf_ZqGFdvpCElA1UMNjXmEAq5s
|
||||
2ccsg6acsy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmNjc2c2YWNzeUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwNjN9.BxnyxpuFl9hsBiUABzmUm-ohlRMq8XkqELFTWufR9NQ
|
||||
ct2zc9yl1s@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3QyemM5eWwxc0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwNjh9.T8EH3R_Ama1Ur0MUvKWgCsFE8S5NUSqFCmeuRivNPAs
|
||||
46669d3irg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNDY2NjlkM2lyZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwNzB9.Kg1HL74FHbDmM2NV5-bj81-vcDWeZZaHOxMUsS2k3RA
|
||||
3wzankp5dw@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3d6YW5rcDVkd0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwNzF9.ap3C6dduL1nPeE6RrJIwM7m2aT69Q5-o6la7JbBhxd4
|
||||
44367712ar@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNDQzNjc3MTJhckB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwODB9.3gveUvpGqLcXMZ-NOWZ0M0uAi4NblabVnjpTvlfAXRA
|
||||
yk3pwy8m9s@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieWszcHd5OG05c0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwODR9.PJvTKAIlinuI7Y0q8HDp8Gxh4k17Qm_KPckhthM30DE
|
||||
2xx2wbi1sl@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnh4MndiaTFzbEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwODh9.BV35iHk-GFHKS64_GjL6jTCnCUIlJL-Q_E9rtJ4DhCM
|
||||
9nuh7b9vrf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOW51aDdiOXZyZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEwODl9.Gc9idEIjSURynsoWFzOhDnGno1dT-ro8DgWwns9BLZA
|
||||
8ryx736drj@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOHJ5eDczNmRyakB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMDB9.tKNcGwLMJD90k-W7dCMtF-ooIWVWgs27JJZg7_Vw74s
|
||||
fe40df68o2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmU0MGRmNjhvMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMDF9.wGSiLVoa2C590ELQM5aYVVTXeVhUyRk2__EOLEQ8nvA
|
||||
pait07auio@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicGFpdDA3YXVpb0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMTF9.oUsYld0S6uBS8Eqgk-EsNFALJavOitNMFTkGp0earYI
|
||||
o2rmyrnkxw@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibzJybXlybmt4d0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMTJ9.0TcLdRitOYzsrur7XV4vrDQxtJ9ls11E-Ga2AXRJduo
|
||||
9sh25vb1mn@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXNoMjV2YjFtbkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMTN9.HN5O_fuHms0J1jb6dKAsi6gQHZake9949JbVjNqZK74
|
||||
5mes6x0vcv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNW1lczZ4MHZjdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMjF9.uUn0KtHaPzr8N34tGATUPW9ozsV3Uld_Nz5hUi5GqI4
|
||||
c2fk4kbpbc@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzJmazRrYnBiY0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMjJ9.lzZYhUYoB21TADW5kUHteu4iAO1cOt2fptj6QrMXvtk
|
||||
7h06lo3494@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN2gwNmxvMzQ5NEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMzF9.KAL255Ku3zH-SnSNyzG2YNuTCgWD-alpAt3JC_LvtCI
|
||||
smat388wi2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic21hdDM4OHdpMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExMzN9.WukTF301tabE_TVTDaqCkensqr3BowP9XjEPTJ2_6SA
|
||||
t64ctsetck@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDY0Y3RzZXRja0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExNDF9.mJiDIo8GiH25vi7dmCFxvcOOW-oqfRRra9cSu4LqeuM
|
||||
4a3k5wr8qj@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNGEzazV3cjhxakB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExNDJ9.K7gmWYusmCwTb0FSHZKnqul6MG6BFYYRiaolAKjPU1k
|
||||
1bgqbed07x@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMWJncWJlZDA3eEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExNDh9.IOqczDO46fSI2GrLUUgsJM3-kRohdipEhhZM2czq9cA
|
||||
02lz9mxdzh@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMDJsejlteGR6aEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExNzF9.XIhUHkBy0RVkYZi91VRsGQHtE4YqV6GwB7RFnnfvgEM
|
||||
3kngte2g7d@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2tuZ3RlMmc3ZEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExNzJ9.abnmxZsdBHZ7ViiXyX9CkWB7ofWXfo1zFOGLij8cZ14
|
||||
ioti6jatj0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaW90aTZqYXRqMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExNzR9.aVHRePme1Eks2FUuvKe-XQO2_Sk_mpqwrXXlPa5BGFY
|
||||
ezerppz6dt@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXplcnBwejZkdEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExODd9.8gkc0UmMc84g99qJaam3d_TfzH-v_1BRVRQShf7XvBY
|
||||
lgz1289q2x@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibGd6MTI4OXEyeEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjExOTd9.wQ5Ywb-nnNkX54w5kEeoUitZfuXYnciNLLUyz5RZ624
|
||||
w4ls63gvn8@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidzRsczYzZ3ZuOEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyMDh9.aJJFjEQ7kOdU6dV1BN7lmGRKRV0nybT_G3FkOdngtT0
|
||||
xwtcfg2hwz@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieHd0Y2ZnMmh3ekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyMTJ9.7sgWzuzzGRKNxGd-qkGiNp7Q_Z5MN3_TpBIZRPRHrx8
|
||||
bqgr7r8ma4@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnFncjdyOG1hNEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyMTZ9.lwPa5pg4zn0kDyWGg0cg1NR2f_TvyLWpEH2ZzO7pMYI
|
||||
c4p39tm0ba@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzRwMzl0bTBiYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyMjN9.wAQksmfLwmn55uzeIqkzO9I_3kiVgZWdTn1WXj-rJOc
|
||||
vwj1cj3v3p@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidndqMWNqM3YzcEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyMjV9.iqTzgYiqZTVe5LXvUI0r9L_uFLhW3Yw8tOy4EujzDEU
|
||||
gwnv1a16du@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ3dudjFhMTZkdUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyMjh9.lmQeixbxuFqlmv3o0u7HlWO-r9qEvopFXjh-G5N7sGE
|
||||
qhmufixryq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWhtdWZpeHJ5cUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyNDF9.zPEKNz-8OKNCfFxFZmkucNpdliBDMgqjeZYMuf8y7vw
|
||||
y9pi8cy5ur@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieTlwaThjeTV1ckB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyNDN9.8XwBguvFb4n5X3171N71jUhsFVzBypBi5tL-xiKX_GI
|
||||
voociop33f@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidm9vY2lvcDMzZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyNTF9.OdYiaWst8KLvFvUepm_SmhRYo6P-RjUr4pn5w2oVIV4
|
||||
m6wotlaheb@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibTZ3b3RsYWhlYkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyNTZ9.PSAclG-AmvaaJZpBWVUGlAMXBrDW5zbhizt1_iHVKp8
|
||||
exyw0oy4u0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXh5dzBveTR1MEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyNjV9.KABU3nd2vaTjL-dgmjLoEY4ZEISKeyJORzKpvTd2Weg
|
||||
t1h0822heo@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDFoMDgyMmhlb0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyNzN9.v9zpQKSqaGdDApRD77OqEjS589o2AnLs7gFcn48vjEE
|
||||
y7zom24xhe@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieTd6b20yNHhoZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyNzl9.j4Y50sdbCaUPfLIGH4RhVxNGQT6L6qZjxKROnjOUbaw
|
||||
3dpgyeliaf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2RwZ3llbGlhZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyODR9.Ai8giG-c-wHOgBQPc2IBEIQMAOV5ZqHheMU_CJLVd7I
|
||||
r68pcqdnuq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjY4cGNxZG51cUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyODd9.j3-j8Ejdt0tMg7b2gqOUo22xUplep1ZsmXmp2ywnTWI
|
||||
qe5g2v4xyf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWU1ZzJ2NHh5ZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEyOTl9.Nrw6wZbUh4hut-VScwSh2PLKo7pQDd8T_WEAYBjcXQg
|
||||
s81jiwhnvz@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiczgxaml3aG52ekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzMDR9.pgIzpQCWBGlKr0cN-v9sdeMDPNonFh1tygusgbmGPa0
|
||||
0kvi6kd2m8@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMGt2aTZrZDJtOEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzMTh9.iAaHmeM8o395oQbJWIzVtyA7G0vGrxdWBMQvLlzI4tA
|
||||
1wc53pn1t1@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXdjNTNwbjF0MUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzMTl9.4itZz1Aqvu_nZ9LhNeM63Lm5Tr7ezwv4KevVtq9fw4M
|
||||
p66qywlvhn@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDY2cXl3bHZobkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzMjF9.aqGxC_z4FH6ERmRP-icJxkrtBz6KTIM3hiC-Z1zm3VU
|
||||
okep6ofyun@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib2tlcDZvZnl1bkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzMzN9.QLVosS_IQz8qESEps2Q3oxijNrXB2ceZQxiFGQylm6w
|
||||
kj2lltqjfc@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2oybGx0cWpmY0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzMzh9.Tr_CyLzFN-Klde7KHy8kxRhg408j96A3-7Srp9lt7Tw
|
||||
vcf1bjx71q@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmNmMWJqeDcxcUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzMzl9.erqF0G7v2BijrKE8sIJ04LKnDzRR6V5LsO2AYBprr3s
|
||||
vhknpjkxvz@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmhrbnBqa3h2ekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzNTR9.iFwp2VfMPnmxUI_JslbMeSLUccDak1oybT9rHdLuGBI
|
||||
c26sihr43a@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzI2c2locjQzYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzNTV9.QqtoqViD2OLVxcaVxsSUptryUZnFo4Bbz0OQz68UbbM
|
||||
t589jhq5mm@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDU4OWpocTVtbUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzNjl9.5xctayYAkExdD7vQZVUWVsduyqleqGOpddeeMKQ0gcs
|
||||
6b0xyj7g2i@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmIweHlqN2cyaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzNzJ9.V3_1I_vbCywyjkq71e_jDsXwtkwFnfsuQhSJMAHqjOs
|
||||
2g7esuaiyq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmc3ZXN1YWl5cUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzODZ9.0jo9a6DJTDPv7h0wKU_LsNHRMNqfr04M_HN2Suyh8-k
|
||||
tr197ifofp@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHIxOTdpZm9mcEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzOTV9.s5wI_X3qr8I-odjDIQvE3R6YIZoEetJhRQ3sRLRfUvM
|
||||
5v0l6k9k3z@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXYwbDZrOWszekB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjEzOTZ9.e5Rr4Gjwb5CuMNpi_SDIUQeBoaZgUwON1o14kbA3Ns4
|
||||
4y0h0p2eg2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNHkwaDBwMmVnMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0MDZ9.ASbmxmnR8C0RzdWYSEP2kGTHcryuCCGUpoWnidzU0-w
|
||||
4jc4ryj1rg@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNGpjNHJ5ajFyZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0MTJ9.haAhWKK5M7Cb0abvyMqzF8vRWifzVAHpY2y5eu8n9aA
|
||||
1ia6fin6iv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMWlhNmZpbjZpdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0MTN9.UnyfDOiMzvHcOKRYV5vwiyr1bq6vqA5fkWIYWbdvuEY
|
||||
gkjdt4hkr8@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ2tqZHQ0aGtyOEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0MjB9.cXaEmnr_9sc0KIGYp-gAKfCPZXiSniQ4EZppWzM-XTE
|
||||
t5r5ca6d4t@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDVyNWNhNmQ0dEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0Mjd9.5Ye2XYIaqr3KqRMvbkB8M4-M9MTbu5DUIBahkcxEerM
|
||||
2bw1cvri32@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmJ3MWN2cmkzMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0MzF9.Ct2Qzt72FL4C_47IyIaS9qLJDy-kV3ngLfEykPnImh8
|
||||
pxnr0ju6sa@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicHhucjBqdTZzYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0MzV9.WZswamvaTEecf2AUb7luifCHYqN4BQKgz-i5p6UfDwk
|
||||
p876ij3uwl@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDg3NmlqM3V3bEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NDF9.HgVQw3VJiSXuJXBfcS6lL4dzBTPt0Ym4ShmsbYNZ1bo
|
||||
3r6shdeyxj@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3I2c2hkZXl4akB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NDZ9.5dhxk-EymdHhb_KlCAYKoTzbBCnvfyh1eqZOlRpA48o
|
||||
qx4rmgwod6@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicXg0cm1nd29kNkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NDd9.S3kpMtVfmUYV7TSRMAgxmkUkveblOBsSr5z6tmtLD0U
|
||||
tr4o1s9oxr@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHI0bzFzOW94ckB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NDl9.n9ej8bUm2RL9EK4QsJbnIhRAfHz-rLHqV9x31Hp0aLQ
|
||||
qa200jvb0a@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWEyMDBqdmIwYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NTJ9.Ord8bQ5C4PRV10o_rqzIVtZD0bywOCIJdFis156U6ig
|
||||
x4wb742862@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieDR3Yjc0Mjg2MkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NTh9.eOhxHNvCRkAzgEvel-UxgKfAFLjB6lGuptBhPL6dul4
|
||||
uzuki25oni@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidXp1a2kyNW9uaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NTl9.Cn8K3xtvQ0esV1_7JRmQ1cegRTPFP3IAsF0V6gc_pwg
|
||||
n3k6ufnjik@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibjNrNnVmbmppa0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NzF9.J83YFwqXmzF3vfgf05gmopEdmzIuSzLWBY6s2y_t6Hc
|
||||
hjldifkp2k@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGpsZGlma3Aya0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0NzV9.ZDe-IrljgWeRrbIqd0Ee-uji5gkJSDdibAwRUMEzov4
|
||||
gt0059vdrq@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ3QwMDU5dmRycUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0Nzh9.n1c9DS8RtwNNJ4W0hHEf1MuWR1ph_eOku0BOGDnlkEU
|
||||
664bpmbqbi@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjY0YnBtYnFiaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0Nzl9.RDVeLtbu6-KVEB7vMTwVpG7Bo1gou-ZBGq4Sgl-Rth4
|
||||
v0nxyfko57@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidjBueHlma281N0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0OTJ9.nI6keoUKFVeTa7Twl55TgrXOhPrCsYXDHJh_eIgu5_c
|
||||
c9ads6rsxf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzlhZHM2cnN4ZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE0OTN9.8oUbIEfFLFmW7wxh5pjLGH2jZSQ7CNv3W3s-4LLHYD4
|
||||
c9uzp1rufi@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzl1enAxcnVmaUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1MDF9.Oaw2Pp2FlifiFV8wbVTa9NipHY03o42-yzkZS7hIOQ4
|
||||
9zvtp7i2af@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXp2dHA3aTJhZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1MDJ9.lnpGRN37cARwFF0Mv_uXFO1-yhgk3FP5Nhh2Vy6qJlA
|
||||
vkf7pvh9nk@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmtmN3B2aDlua0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1MDd9.Vm70uVXBUrsWLaoZAtxOUGp8pWIpMCYg7y--Yg4U9n0
|
||||
bx7eu0ty10@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYng3ZXUwdHkxMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1MTh9.QxGuLUYBGadfGtHki4nl1-tpWv9960ySnv8GeptKXpI
|
||||
h4g7fjucke@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaDRnN2ZqdWNrZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1MjB9.skLIT6mxKKNIFM9ywI6lZ5CAo3hm9aZ5ciJ0rx2LJzY
|
||||
qg5om1m9ka@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWc1b20xbTlrYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1MjF9.heHpYtWrtruuMUn5qhpH1qCMFDWFW5oj3tecokw6d1w
|
||||
parlnpil1q@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicGFybG5waWwxcUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1MzN9.pw85Z5eZiIfNNLEVYBT-1Bk_HZcIdkQ0HcDoB8VQ0tA
|
||||
fx27mr51wf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZngyN21yNTF3ZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1ODJ9.gW8y7JmJdUvWtr1NpB8ux-QN72z_fbPFZ9U1EQd41OI
|
||||
678ry7242o@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjc4cnk3MjQyb0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1ODN9.4r7hkyu9w6Oxk0oeYfH2DJoLUZClYFp9owXCiUomPxI
|
||||
9ttnz84pap@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXR0bno4NHBhcEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1ODd9.2Uww9hXTjL0_LixhRX0s3w-SU1AWQrCckHasNcXaIfA
|
||||
qxf7pw3htn@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicXhmN3B3M2h0bkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE1OTV9.72Ys4ydAeLcQGVOrvCid2Du_4eaDNy8TFg5p-PoHEI4
|
||||
fx1zebeio5@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZngxemViZWlvNUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2MDF9.7qXdcn0fIkpJdA__pRx4p0617NHvJpXWeIA0NAN71AU
|
||||
68a58bn00e@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjhhNThibjAwZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2MTB9.4-7c5CrjeH5Fz-QehHFUsnrQePPC-2xW0oshg7zJRr4
|
||||
1wt4dmyki7@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXd0NGRteWtpN0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2MTJ9.iMeAwW4BKqBTzsFZ-X0SlXFfdAeFsNILkNRFV0RplMA
|
||||
3t4hcah8ew@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3Q0aGNhaDhld0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2MjB9.6mdOMDomdF7ke4cC8TVxVsuFXQyn4yMj931rkePZ4wE
|
||||
wejl427k93@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid2VqbDQyN2s5M0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2Mjd9.C69wqMJGPjcMRSq6XFRbDgHUEOS3W-cpybaHUnl3bvw
|
||||
01128kdlr4@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMDExMjhrZGxyNEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2Mjl9.w7ui-CzAFxBjf57IDWHIYZHcjwLZW1BT28ySL9zKzUo
|
||||
96dmcwg72t@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOTZkbWN3ZzcydEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2MzN9.rwLcc1hSVtXCozYkJsFDnwBJfHAVDSZn29BoqO5mKDA
|
||||
qefqnf26gf@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWVmcW5mMjZnZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2Mzl9.m4p6MgW0FhwKxgtmaKk3HyzH07sf-Smzg6QzRqG_TQ8
|
||||
r42vxcfman@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjQydnhjZm1hbkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2NDF9.OpAsMoDZ741DbeU9DzOqXzVsL42fiCf1dUoz_qc9J1g
|
||||
hks7iwu3jv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGtzN2l3dTNqdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2NDd9.1hyvWb34VIHVdwnYl5dIOpfCDcU-JdEs3ZjPo-zZrFM
|
||||
f1kl5sqyc2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZjFrbDVzcXljMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2NTV9.rJkw7s09YkguD6XIcxySTe1kjtfFpSp6k7DAjmo8iEk
|
||||
6krdiqofub@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmtyZGlxb2Z1YkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2NTd9.KqdL7lujl4DcGq-fZq2hMWAcG1aeLa5z2obnXbhnYdg
|
||||
2sikbpy0dl@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnNpa2JweTBkbEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2NjJ9.Gmc1Ps17RlNZOJJli0tMcgR6y7ifbegD6ZcdNlGBUc0
|
||||
tfawm63lup@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidGZhd202M2x1cEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2NzN9.0wGT1vaNxg6o0GxMu2MG2IGRcyhrCLJAjXmr12eqCXM
|
||||
c93i1l3o5t@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzkzaTFsM281dEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2Nzh9.yqr8GCZmKt6x9Kpji9WpC-3Mmz4Y9AqL2SLi7PJHnpQ
|
||||
dasaggfxv4@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGFzYWdnZnh2NEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2ODJ9.28sFtyC--sXm_p4s9f66i3YMXZvOQ3CfZx8stBWG9S0
|
||||
io88zyc61u@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaW84OHp5YzYxdUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2ODV9.rm1tQUURzZmdsnpoWhJNL63R7RZ_K0YmxSGNiu_IBac
|
||||
lttznxzu1l@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHR0em54enUxbEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2OTV9.aJ-8fGAbbspwM09e-ZPRSfz08tguBWmcp0IONfpq534
|
||||
erwrvk9p33@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXJ3cnZrOXAzM0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE2OTZ9.Nr4pedCM-bjlQPk5T41p9ZxMgSmBSv-ufZFTwbMil7Q
|
||||
q5q1ok8xp2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicTVxMW9rOHhwMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3MDZ9.AVY0HYDbZGnUiJFC2EI-wxKhwN2ZlPPUhwX5OUkNX5c
|
||||
qyyk88ocs9@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicXl5azg4b2NzOUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3MTV9.xbosuuFPEjeOC8k1cY7w00xcalP0ycRaUXIpuK_XKSw
|
||||
yhd9npwgnv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieWhkOW5wd2dudkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3MjB9.S4tIxqJSNkAXKOkbIS4R-LxIBe6KKC82NWqVV0YnE_c
|
||||
bvl338jjst@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnZsMzM4ampzdEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3MjZ9.wuhMfhIuZiS2nCf-k8D2Ax0mbp5D8cHG9crRiaPLah0
|
||||
vdoq5kwdse@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmRvcTVrd2RzZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3MzJ9.f3XNmdtLH9EgM7gAc-jrzaqk_N90ECwHS0ZjSynDOLM
|
||||
1auam8qzdl@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMWF1YW04cXpkbEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3NDB9.vxwHJPZ2GL0jMPOIA0jEyT2DKhkYTsjjZo80LNx1isY
|
||||
4favg5grq2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNGZhdmc1Z3JxMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3NTh9.aVD4kgcUZ24jgHjAOyXJQxi5qsrFqmaioCOOODhlCxQ
|
||||
9odrt3qpsy@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOW9kcnQzcXBzeUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3NjJ9.KGCYrW1S6q6GZnh495JVqPvESBLxAMAxWCIsIABKIJk
|
||||
el9bjpi9qa@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZWw5YmpwaTlxYUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3NzB9.8VBpxfaJ6DEkuKR7tEhLEWDlD7scFnCi2_mOBZd2yCY
|
||||
knnrczh8k0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia25ucmN6aDhrMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3Nzl9.4hA5WP46vFz9MJuupBkDF9elS-GZJtWe-sEuPq_1qos
|
||||
180wp3hz9q@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTgwd3AzaHo5cUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3ODh9.xnUy1O9x6PC7SDRTZi3dJb18B_jmq5d04DObhbfRATA
|
||||
2vf2zrab8a@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnZmMnpyYWI4YUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE3OTF9.V34dYM8Qo9mq5IoTZnLEe-saBImSOMH-YFAy3a0VOmo
|
||||
wktablimh0@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid2t0YWJsaW1oMEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4MDd9.4-9I_m3SXqPCCABVPbTv9HeervE2hQZYBv8uMt7ihVM
|
||||
skgsb3ihze@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic2tnc2IzaWh6ZUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4MTF9.kPPLUOfTU5DitPsrixKLi6cHc0KBOaZx-f4K49lVp-w
|
||||
8qs0vzgz3g@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOHFzMHZ6Z3ozZ0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4MTZ9.voZe956WkS3AK_4x4nhvTPlcuRED6AI9t6DxSIJ0Ps4
|
||||
x7jw5j7gi3@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieDdqdzVqN2dpM0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4MjB9.BTLr0gxYW--LejfeLQorEhPlBTCYl1cJ9XMWijdsyq4
|
||||
6wri5ilmfl@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNndyaTVpbG1mbEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4Mzd9._5g99Yps3ICFE9YCdWl5hRgt8dr0Fo61VkcqUL0C0Tw
|
||||
8ez82dizrl@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOGV6ODJkaXpybEB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4NDN9.EwgiAWYzIvZqvKq4zbrlZlZgO7QXbmTwG85y_dxLvEw
|
||||
1cvafz9z9m@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMWN2YWZ6OXo5bUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4NTd9._I8h2PjRzvHvfnLGINd8NiUCRF3KsukP0oRHz3WOWEY
|
||||
cyzuwkb889@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3l6dXdrYjg4OUB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4NjF9.hMT1JIFN-MCv9z5MbRXOWZSLSoP-IwDB6pmvvsxphp8
|
||||
7ie27477n2@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN2llMjc0NzduMkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4NzF9.zcEvh2NuK7XM9LOfw2f78N8CWRel-Yw_O2GkW3O_X5g
|
||||
6jslhwlf9f@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmpzbGh3bGY5ZkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4Nzh9.pAHHwq3Dpszz6qOJKZ4e6shhD47D-Bumm97UyEdLeLA
|
||||
n4qbgy36sv@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibjRxYmd5MzZzdkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4Nzl9.amc10pUTpOh2NxbyQShOYcWHqv1hTkSSYA09JBNBYHk
|
||||
1rt5memnev@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXJ0NW1lbW5ldkB6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE4OTl9.DS-DS4TS5FwGr7_Wvl6Ljqd_XY4s6AZqAHxN0WwpDsM
|
||||
n49tedrcko@zhangyunuo.net eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibjQ5dGVkcmNrb0B6aGFuZ3l1bnVvLm5ldCIsImFkZHJlc3NfaWQiOjE5MDF9.Foh0IknAuv9hmTh2i57plJazKZ0mB02VvUCn-ntvj2I
|
||||
gqciq6pxcj@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ3FjaXE2cHhjakBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ1NTl9.H7gs1Fd09bcbQP9PwN9hCthrC4sGZAGLGFtkrMr6epY
|
||||
euiwx6cf8q@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXVpd3g2Y2Y4cUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ1NjR9.tdO77LcdS322OjND7bCCeKBj8d6dUHjWk0gX1RiiilM
|
||||
ivo217hkq2@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaXZvMjE3aGtxMkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ1NjV9.hmIX1qeqvZugMtbc_CSBOdNnB34zMyqGsue6Ta68Lo0
|
||||
fe9qz7ngks@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmU5cXo3bmdrc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ1NzB9.i5Ekmxw4FiNHUUGHwA2WjVPT8SKktdB8pki-Cbz-epI
|
||||
txgnjbxuhy@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHhnbmpieHVoeUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ1NzR9.zU9SrGn2B89VTLAGPKbYN5_ihB6Ue6p-UeZajuBrI4I
|
||||
qi4c1mq8sr@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWk0YzFtcThzckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ1ODd9.sqz6dAKuPvzNAM-fgsSY15KAv2rAnbNzBxPHmQCCGlg
|
||||
hjapyitrr3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGphcHlpdHJyM0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ1OTF9.6BFpdcFgxCj39VXKpnqyWadmzezYzKN1EE5mUe__OmM
|
||||
myytb5linq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibXl5dGI1bGlucUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ1OTl9.R6B5ssMAyQCUC_T71BVBO16wkld71NHS3mfGQRlbX1A
|
||||
a3n6sy4i1t@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYTNuNnN5NGkxdEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2MDB9.Vrl2oGefgbMa7E405IZsuKruw8lAMhPLk2CcIUkDd50
|
||||
chzvcmcbi6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY2h6dmNtY2JpNkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2MDJ9.LF0iY8_uX6qk1Ma13UcJELc-4pduhqRG1yFf6GzeEzs
|
||||
jsnht245qc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoianNuaHQyNDVxY0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2MTN9.Uth7HVf4w_Fy_E16bTBq4Y5KH-biF_UxzrmY_46cJes
|
||||
ecer63750p@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZWNlcjYzNzUwcEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2MTR9.9lQmFU3jsc7oY3-Dg7QC4f9v-szqE0VrNXjf5FPWzVg
|
||||
bw5hi4cw56@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnc1aGk0Y3c1NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2Mjl9.ldtls7ieyEDVKT6B28abeyCNuL2UDJXKEywZlGL48fk
|
||||
4okymeals6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNG9reW1lYWxzNkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2MzJ9.uMc2-PsKTE1kOPcJjtHQQnz_sllfY-CJfZgvi6uwOfA
|
||||
c46n11e4m1@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzQ2bjExZTRtMUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2Mzh9.pIFORoVhZe_XTfYs7WJgnvOyxRMZC-kFvwIXxv61TMc
|
||||
p5qb0yv1di@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDVxYjB5djFkaUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2NDN9.f2zjummrhgP9bQSyxRzpGrb5dRBXi07_EGT6waqJB6M
|
||||
r3fbe66pk0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjNmYmU2NnBrMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2NDZ9.96XLDo6p1HrqmXvST1__kOt-I6dx4hOw2ZeWQ4LC9rs
|
||||
z8b8nv6otd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiejhiOG52Nm90ZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2NTd9._YaAdeWnozymKfXIbpqQR7cWGmNVL7J6IwxSOwQ-sAE
|
||||
xv37yn9gvw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieHYzN3luOWd2d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2NjB9.9zoDKuK_n79cUKIr-yYJgO--Xj0ieolXQASGsgbyG00
|
||||
1yfo55kvpx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXlmbzU1a3ZweEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2NjN9.eHS1jyl8AtiA6_S63O8V0p5NH5DSOwA22ies-awz8Ms
|
||||
2y5rt8vysy@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnk1cnQ4dnlzeUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2NzF9.vdGVzM_rWOTeCHSFsljYw9In_lVE6rwtTzKApNHXgG8
|
||||
4u8bdjjq87@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNHU4YmRqanE4N0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2NzJ9.TVQZzywJCoNRoVyif2Wlq32nR7elNEFUQXf0odTunMw
|
||||
1zbwlum0yx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXpid2x1bTB5eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2ODB9.Gi_jVIuiJKfkMIiZ7HL5EmE2WEaCUx-UkHJe8fEcWfg
|
||||
63ymdex4yc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjN5bWRleDR5Y0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ2OTF9.muK3yGNdVfYaLCgB7CI3JBHW8gX2iTKPj4RWmn0SOc0
|
||||
7wgmdg2k5v@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN3dnbWRnMms1dkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3MDR9.lsUOOZ5pQQ85k24NTOROrhifwHSt807DLpBmsMBUkwY
|
||||
487ssvp5zl@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNDg3c3N2cDV6bEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3MDh9.XRGcybP36dlKsTX_KEcrvaQm1TI9hHx6-zrG3NgID7c
|
||||
2eyvibv3hy@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmV5dmlidjNoeUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3MTR9.oWXAOvjRNf2QSBZD2fnTxuRYpdNNiK5pfNldXf1TJcM
|
||||
deqfi8xjep@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGVxZmk4eGplcEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3MTh9.iaJNJ8e9OXjVy7N3S3riFW_DsoYFH0FbRGCkLBTZvQk
|
||||
pl7g743q8f@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicGw3Zzc0M3E4ZkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3MTl9.FqYkXpLAVfeRUNwMGQo2pnA42AG9_pRqytcfIv97f7E
|
||||
69gy9ra14y@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjlneTlyYTE0eUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3Mzd9.rqS3hM0DUrETTjQJvYMdNkIU8NS3IJB3cDZngHYZAVo
|
||||
0b61md3hoy@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMGI2MW1kM2hveUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3NDF9.K4oS3swxTt6sk9s9cyyYsEvkSHaUComHFjMj7bzHQaA
|
||||
dfpkzo85ns@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGZwa3pvODVuc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3NDN9.cSW34g-9GP4dnY1RuLrtpU7UD4AqtWg7GNfjzPvWsuM
|
||||
zxc1e60xrs@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoienhjMWU2MHhyc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3NTF9.65rrJeVkiajgqDqpvUwcymog6cLEC9cy30NoqWEgo2A
|
||||
7bj50re9c9@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN2JqNTByZTljOUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3NjF9.sSaUpkWliH2yQzZz6FXUriPGGC2ikz5Um2ky-0QTTlA
|
||||
yc0gvwzu6p@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieWMwZ3Z3enU2cEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3Njh9.pBoPOYjoABvXw44Ew5iaoKKgzS-_SV8h88e0s-IkRcE
|
||||
rgyqnezl3x@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmd5cW5lemwzeEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3NzN9.dx9MduMwdupXFOmUdp69sUKT5WSn_tHJGRKhUXDz3Pw
|
||||
xgi89g7cv5@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGdpODlnN2N2NUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3Nzh9.nKcE1lzuMCJyX5wrC_pf6Q620Ov8yv3_-aY0d15m-7Q
|
||||
2x1n7vrf3s@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMngxbjd2cmYzc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3Nzl9.hNUuEFutpRhkVZaMjK-k-bwH1VpAeyLX2UvWR7rQ7lg
|
||||
qzvirpwi6h@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicXp2aXJwd2k2aEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3ODF9.8PeO50jz3j6Rw3vD9A1-qu95EY6vaid1GXkG0E1giy8
|
||||
pnjqbpw3uw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicG5qcWJwdzN1d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3ODR9.OOs67bEIw-TgZLGl6bZnrpR1QheJyKnl-U6MAGa5Wgc
|
||||
uh0yo1vs6b@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidWgweW8xdnM2YkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3OTB9.1mY-QZN7NymSFIHlIS_Am8lzdRw0fFCIpfQGZTL3k7k
|
||||
dax7xi0i9f@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGF4N3hpMGk5ZkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3OTN9.1q8YZ5YV_WixdCEp492KJZ29v3Eki_b2HW8qlZqNTMY
|
||||
zcuanvehgt@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiemN1YW52ZWhndEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ3OTl9.cta5w5HXJtRH6WvgYeiAaGnC8JXOpWL-LoT7uYBrWcE
|
||||
jfammvye2k@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiamZhbW12eWUya0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4MDh9.pmgm57TUE6WrzdlPQs1X1rarXuYDzgGZ5g1nJdSmCBw
|
||||
vqcwig1i03@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidnFjd2lnMWkwM0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4MTd9.wRAYTkvBlQrXxAJNnElf7WKEAmuuKRUGldm1-N4sU04
|
||||
r99kfxd4ch@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjk5a2Z4ZDRjaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4MjR9.y1YvoVQAQ7LFEK5AaGpswUfW2XPz-r2utn6RlsEZIJ0
|
||||
ilqzknt063@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaWxxemtudDA2M0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4MjZ9.HwgX_1T_TBdU1_6xBHU3g3Ob8zSke2vK-QKGP096hkE
|
||||
1cx9361e5d@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMWN4OTM2MWU1ZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4MzR9.ibBrrhaMZODoiuyg1YTMjPxntS6sGrQhl0ES-yIHDLo
|
||||
ndm8p9l0ul@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmRtOHA5bDB1bEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4NDF9.YL3kX_Lz9uSAd6S50-VwjxYVaZVo0fHs_wzJ8R75XUY
|
||||
x5is55l094@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieDVpczU1bDA5NEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4NDJ9.Z4_DCVUfJ7104aU7HA_xASz4kirGPIndetott_0I-jU
|
||||
aqlx3hp8ll@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXFseDNocDhsbEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4NDV9.9rK4K9YEyRSNQiZseujjUQBsHlHjDKfWvp-hBFXk8IM
|
||||
825k3w21u1@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiODI1azN3MjF1MUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4NDh9.GM-OyZhLwbzsRxn8tfrTDXbPLetBGS26GChfpdlggkE
|
||||
x2j3pugcnu@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieDJqM3B1Z2NudUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4NDl9.xeB3DZrRkiADby0xqqCI7MnTWm2xxGfq1elJewvzf0o
|
||||
2sldkwjs1k@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnNsZGt3anMxa0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4NTF9.3079v-Hv-hoKdjD4SwRmAYTgmLObnuiveIApUq5pO0E
|
||||
o3qedsutcm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibzNxZWRzdXRjbUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4NTN9.VRaOIB-AbSMZTbnbS2nuOUj6psYY4-aAOgAtJgBNZ80
|
||||
nv88ztossq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibnY4OHp0b3NzcUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4NjB9.VO7N6ibMr73_O6jUGeMG76Ncvw6T7oFtScrPvRseLZ8
|
||||
m9du4to2i2@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibTlkdTR0bzJpMkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4Njd9.46Uj0FqIPsUBGFllISz9SiKvGVYfLoNZNqVKHby1Hdo
|
||||
valw70o2c4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmFsdzcwbzJjNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4NzR9.uYU0lXADoPFizhjYXYT4SPX9GV2z4yu9HRdU2hrGNks
|
||||
uoql2tgek2@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidW9xbDJ0Z2VrMkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4ODF9.BPJ0qgO8nPxpqRGnLTjWvB5-2evI0nTmx14qkS8_glI
|
||||
3jugss0mzu@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2p1Z3NzMG16dUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4OTB9.MHPzWMaC0TR3gHRA9u0PCwuMhFa28Fxs1jgU9moZZ2Q
|
||||
4kdh0fom9e@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNGtkaDBmb205ZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ4OTJ9.znPb_oS7aNeJFLMle3hirS8qRPJKyZnkwFIwsLgjs_E
|
||||
14atoros20@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTRhdG9yb3MyMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5MDF9.bst8TFvZS2igq-sYcsW7DlB825BIQMI3xf58fr0-s-U
|
||||
y37xhycl6l@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieTM3eGh5Y2w2bEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5MDZ9.UTjRTJ0OYMo_7Mn8xUjBRVDFX5ZV7C8NmBYVsrc5jP0
|
||||
hg2ph7q0gt@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGcycGg3cTBndEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5MTF9.zTlnsoLjk6zm9wWngjB1aJXxYMrabUMa70UvqqdM73Q
|
||||
fn53kxxobx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZm41M2t4eG9ieEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5MTR9.ehbCSbHIrH1D2lqfvlSpZh90B5y4kyk8r8dUwEyX5a0
|
||||
lsm6rqoq52@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHNtNnJxb3E1MkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5MTl9.PdlKiXpiRQX5LeugqOENtpOwsZ-nrZUM_gXPWoL407o
|
||||
vftfhxo6lo@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmZ0Zmh4bzZsb0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5MzJ9.ddszBLbBy66w0_Uuo4k1auMueXzMGPXJeBaNycxwbog
|
||||
cuvunakr32@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3V2dW5ha3IzMkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5Mzd9.PI9byvHRjNJBTZaGHMwB0VC75bSMfr2Hn-6I6cLnFRE
|
||||
yuhp68fj6x@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieXVocDY4Zmo2eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5NDV9.FqVX-Kb96oIXwoT2z4MmzvMpHrHAMQvl-0AX4BszlKA
|
||||
makbu3i9kr@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibWFrYnUzaTlrckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5NDl9.YzYMC5WvRA5maQHRSrOVbMJW2hvAulJu0hMDYdhTSDo
|
||||
cjkir3nsoh@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY2praXIzbnNvaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5NjB9.ddCctmSyoLOWz1SVdbeAWvxUTxitnokfKVxlexs8wUU
|
||||
uii3gj2anw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidWlpM2dqMmFud0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5NjR9.pSHcGG7LsMpiO3EPLwfXmU3p3_UAew39DqKVREoeuts
|
||||
cajzy6ozge@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY2Fqenk2b3pnZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5NzN9.8LRAnW7gEmxM08s1RAy5FjxUjoQYhZgheeTBwTBos00
|
||||
q3xf8xpwe8@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicTN4Zjh4cHdlOEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5ODF9.s5l1mVFptuiClRojODS3mgXZVO5oGsfIDk8OujwZUfo
|
||||
xpnilnv0ov@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieHBuaWxudjBvdkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5ODd9.FpWy1D9i6PQcK5S-XNopxa_dame_BD2h2W7sWkND27c
|
||||
758xvxyswd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNzU4eHZ4eXN3ZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5OTB9.C6wQ31NHwjRo6_jMZ3C24Wgi9I8LHLOFaj7WRXGkT60
|
||||
6h4gof3ype@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmg0Z29mM3lwZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5OTd9.U0IQyzZXwfRPYF0uj6gYrrxYJ-y_CZfNxmuU4Dbogg8
|
||||
t2e99mrnzr@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDJlOTltcm56ckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjQ5OTh9._gxEDwy2z7-snvQQ2mJMu_zoviDfrmj8hrnATalybBc
|
||||
0qsj36min9@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHFzajM2bWluOUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwMDJ9.gOFDhqXjLEbg45Pq1iW3rtfQ3M-16KwiDr8NgAAs_jA
|
||||
rnowr6xdsw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicm5vd3I2eGRzd0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwMDl9.Qs9T6YKWj9SKTCcxrSCOZtjebN_b0U6OJv9vVCLxlaw
|
||||
b0d69cmtxv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYjBkNjljbXR4dkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwMTZ9.yv5bvEb6c3NhcqUzI0qv73op1remxY3xZ0i-b3lvdM0
|
||||
geedkny475@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ2VlZGtueTQ3NUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwMTh9.-omYuXz_0H305HIFZU16WcHMjnahjPvaA4QdxnCLkHo
|
||||
6tg1ljx7pw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNnRnMWxqeDdwd0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwMTl9.HEtNKiAZNzzZzZKs5cLmgG26BVinwRcwmUNQw_NeLWI
|
||||
q8v76kyz4j@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicTh2NzZreXo0akBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwMzJ9.8Ze4IFIrLjZasLo15e5VZ7jw8i06h_28zu-b2H4SpLw
|
||||
jwpqyevmi0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiandwcXlldm1pMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwNDB9.PEpoKjWh3KQzCEjUOZA3yKIG5ATH-lkQpvUXVidbdD0
|
||||
vzatrvugc8@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidnphdHJ2dWdjOEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwNDJ9.Hw9RTuBCb2Vwi-cRt2Z1NCCPrP1lP7wnpkToOBkoED4
|
||||
nzho4luyvw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibnpobzRsdXl2d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwNDd9.onLI_BI6MRdqoStNgRUFrXedcKd7mBxT-qh_aCysu8k
|
||||
1asouvcccq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMWFzb3V2Y2NjcUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwNTV9.FwG1IdzMvVcqwyN3_wcM_2WIy63Or5N890wFvT7Brlc
|
||||
0x440gk4zp@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHg0NDBnazR6cEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwNTd9.YJdq1ldZGTU8uJ6aW8iTKy_VJxvJ7TXNz2Fr_cTDlEg
|
||||
j1b1rmnqd6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiajFiMXJtbnFkNkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwNjB9.5NvZZ8ARQb43uU8byJSUnV3dClImdqD1-RZ7buPss2Y
|
||||
euim1f7zep@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXVpbTFmN3plcEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwNjN9.roiSTUJmzWlB_WpZPmbgiphPACUiR27KI-j34VmqY4Q
|
||||
wbp3ekm3uf@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid2JwM2VrbTN1ZkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwNjR9._mSHljs6LVOKEU0nKA5EpoMIiv_05R4Gl16Su3QS6-U
|
||||
zezyv26qmp@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiemV6eXYyNnFtcEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUwNjd9.JDQL3uPoLeU_cFo3HcTR2cOvlz9jdwSu5aPhktxrJxQ
|
||||
r4m6hfk7et@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjRtNmhmazdldEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxNzh9.MhhF1hnuVM0RHJfRUKYOZ0cwq7GuPLOTK3L4EPLqKZM
|
||||
3kqcus0zsd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2txY3VzMHpzZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxNzl9.XNX9z8iZQOZlod8Y5y-8GTzhMemt4nv4Kd9vraLt_S8
|
||||
v0k98besgh@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidjBrOThiZXNnaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxODB9.FsqZ2pB4G6q0G4F0Nhja0c7eYuZGDcvmsZ1SDQTeK2s
|
||||
yx0smn5pgk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieXgwc21uNXBna0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxODF9.KrnH5I6WkdxBDqw1Sj-pNVki39pVBYM6LRwiHw9yMt8
|
||||
yz0wpc51xq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieXowd3BjNTF4cUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxODJ9.RvXKArOus6f71l7NkteiZKyOvyag62Ea72IklNj3m3k
|
||||
hw3hz2exew@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaHczaHoyZXhld0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxODN9.XVbkMr7in1WRLlOSRo-V9cchbUqR1992mv10KjW_PGs
|
||||
hbqdou2mb4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGJxZG91Mm1iNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxODR9.FC1WPpTtxl0nG8C1RlkC8f1wXYDmjqCmQc0CjMZ7aSg
|
||||
7tciyjszqs@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN3RjaXlqc3pxc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxODV9.koKsVNrL0d53nk5EQTkuxY0KHzf8fesp8o8_FVzeFWk
|
||||
zng44io4rb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiem5nNDRpbzRyYkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxODZ9.Ba1fI8cwLc8E7OYRrJCbx10Zb6nCgiREymiP9jhv9pM
|
||||
rdxubo90ah@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmR4dWJvOTBhaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxODd9.LmUjOa2GQFsm7VoGB0luyfEeI0ELC9QhZ3Uo2vm5j9U
|
||||
co7gjrw0gl@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY283Z2pydzBnbEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxODl9.zlwOPNBvx4DfVzoJ8irl36c7V589ihEK7aygmzp9VKE
|
||||
c3z9kdwex0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzN6OWtkd2V4MEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxOTB9.ivq1JoDfn5VVARV-qD2UdEytYacqPxV5u82Fm-eJ63U
|
||||
e0beo5xg85@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTBiZW81eGc4NUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxOTR9.qpLYqf8ShZfEnEPAd96nJ0fHqppaf69BDZZvj50Cl1o
|
||||
3ap7erzkzq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2FwN2Vyemt6cUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxOTV9.pFUC3_SSnBkJ9I8nhUCa7ww_FmI5bk5CfBiOF3_gBpE
|
||||
npw2i7wctv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibnB3Mmk3d2N0dkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxOTZ9.Or0IynOvBa2je9jXZL6Hq3xzIDSt5O9FFAcZ2TRLXEk
|
||||
w1tbj1ts9w@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidzF0YmoxdHM5d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxOTd9.XkdZD0WHpLtEMf5Vul_7r9sSwvFcxiBNBbTkj5_4Msc
|
||||
6unp4qgm74@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNnVucDRxZ203NEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUxOTl9.fEgs8q0N_g-U3OzoH7JgPdW0040-fmwXNLNAvZW3hmk
|
||||
tmml1klvfu@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidG1tbDFrbHZmdUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMDJ9.w3Qk7ajQsSt8oaQzaxbm5SGCp51x04CGnSvrKCQ9v3U
|
||||
cw6sqqtyjl@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY3c2c3FxdHlqbEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMDN9.BzqRkAf7YHDIDrYP4hgLMXC5rqTpBU-viaXcSdtYjr8
|
||||
1bhzo9zsqm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMWJoem85enNxbUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMDR9.DhNog6E2pk3wEphKCmfSiPyzJSjULTKBT8aaOs6P-T8
|
||||
e6wqx40pih@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTZ3cXg0MHBpaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMDV9.q-3OjbfOj3VDn9sNZFJbJgaI191DKHgmpoZVV9XWbLY
|
||||
eg4tkh5om8@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZWc0dGtoNW9tOEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMDZ9.tlJ3UAxgMffS0EOB2o7uyYsldynt_CkaCIg5brG6BZ0
|
||||
3vrpwoczhw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM3ZycHdvY3pod0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMDd9.q3BMUOC7lUXPVWQl-xNPkSGjZ3vf60RCRnVnzzb1QFU
|
||||
rb70m5ur2r@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmI3MG01dXIyckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMDh9.KB1stfreGKy2sTgD1SZHTnjYFRQL4LbJ_L6cStxexpg
|
||||
hlp4a39st4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaGxwNGEzOXN0NEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMDl9.D9ptAozBeyXEbj6wQtP-bJ9U1Agh809aFXu3ewoX-eE
|
||||
58ox5gpkra@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNThveDVncGtyYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTB9.esgKRlw2dteyZWn4SOwyCAMTpnEPlS22sWVGz68QlCw
|
||||
kmt72oglir@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia210NzJvZ2xpckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTF9.edf-W-eOHZT3mu2EZ3CsJr4twOWUuEMPzubzaHLsS5U
|
||||
bpti8mv0kc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnB0aThtdjBrY0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTJ9.Iu7W3S2ENDYiLfzuinM_tVPHLKrcq-inHqpzFKOUC2Y
|
||||
tscce4uhqj@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHNjY2U0dWhxakBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTN9.-BH2RDj5dIp-2cNUZcXLEYULvFySmW6jPDzASJHKiPk
|
||||
iddl624ufc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaWRkbDYyNHVmY0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTR9.2m4ZRzmVUjVHja6Ll-lwXuCIBWdU6kDM_ULSXe1d8nk
|
||||
bf93carf05@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYmY5M2NhcmYwNUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTV9.RnCztJ4pIL_kdUDICmH7XuD0o9V13QcNLrrvyIScuBw
|
||||
pxl6cs0dnk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicHhsNmNzMGRua0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTZ9.IZYiSH3ln5DqmdSV6-WKOV3JL7pytAnsCrh1rLqmDF0
|
||||
ys12pids2i@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieXMxMnBpZHMyaUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTd9.CRXx36ARXa3cO30CLekt3qjV36mzo-Eqnu4sSFIax0s
|
||||
k7vl5frula@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiazd2bDVmcnVsYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTh9.7DXACikWJ8zBo_WxCbl0rKDsB9Hm3bqHVxyftlCG8is
|
||||
3li8o5o3qo@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2xpOG81bzNxb0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMTl9.0a2jszpYIxb0c6Kvxd_dOiptOFmg1R9mCg0VZ-8Xi34
|
||||
ofm9yg5q2m@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib2ZtOXlnNXEybUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMjB9.zDTxhwyTVB5incWCsWy-lDq_r8r2cuKgdaHfzB7dXSc
|
||||
n76pl1qtxa@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibjc2cGwxcXR4YUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMjF9.VHnN-pFCDFiNvDopirCM7iK7RVfxKM3sl8fSvij_Hqs
|
||||
vu298kzba7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidnUyOThremJhN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMjJ9.bV8Jqx1xsFBCAlwafsLIabDwE0ssvjewSejkt7EPd9A
|
||||
403xwa34w6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNDAzeHdhMzR3NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMjN9.DmZcLm0geujToYwtMzJr5_T-Qv6vywm-JgGIFDHAgfc
|
||||
lamseksh56@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibGFtc2Vrc2g1NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMjR9.COUVDeQ-TfzlK-Upd3jNJwKwruxrIGl2r9NGAvK2rI0
|
||||
usekubgyxr@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidXNla3ViZ3l4ckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMjV9._1Q4I9Qc3-532H4L8zvCEcBbP4-l6SiGeGIwPqoX3AI
|
||||
tp78n9o69w@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHA3OG45bzY5d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMjZ9.sMo3UzrZ1go1DtLbfrXkXyDKR-MPhfzPdcOeZeuZ_YE
|
||||
rp7h6xe95n@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicnA3aDZ4ZTk1bkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMjh9.FbjECeZLAiix2qnyucgcLXfUpl7wmRIGTjvn_huVtzM
|
||||
kus1vd96ot@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia3VzMXZkOTZvdEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMjl9.3sVwYFbUvHEBQ1D4rYx49JL3nSy2U5E5bG-wkuDtQxw
|
||||
qjf0y94jm9@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWpmMHk5NGptOUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMzB9.8ryKjA0AXA0XZMMiWcztnBfE5p2ueEItTZMzNa5y1LE
|
||||
p0r4xmr342@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDByNHhtcjM0MkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMzF9.74WrNq3WgreNM4P9K9ZkVuK7WCrjNVaOeTU041dLMaY
|
||||
irqvl0mjm7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaXJxdmwwbWptN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMzJ9.iFe246_6IXPuZmAvfW2PHYztuMUf1IN6k8SqAYg1OP0
|
||||
g2xs8tjg5t@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZzJ4czh0amc1dEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMzN9.U589Xwrr-YAK1N26jYrIgZB5Fl6W-h_arGfY-MpcO18
|
||||
sdnj87t4m4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic2Ruajg3dDRtNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyMzR9.0vm-83y7ouYXDInmXDh2ujIpucIm1tWhlgCk0TttfE0
|
||||
dlz7tir1gs@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGx6N3RpcjFnc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNDB9.K8o6L6r1U_MjgwDgekTlNt3VCpIQo4mVirh4Gxi529o
|
||||
35xcp5jk25@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMzV4Y3A1amsyNUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNDN9.sW8ZQEe1jgkD4a4AIqwoGGvj1XgBQ-4e1BkAkakf3kQ
|
||||
zyqmlv2yjp@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoienlxbWx2MnlqcEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNDZ9.qFfFcCioQV2zT1QI9QmNvHcW7m_S-KclngOeSuo1izQ
|
||||
129a7zb311@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTI5YTd6YjMxMUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNDd9.c5eWzXBAqIQIVcSV_5znvflHGPWgaeIVi3MYWOXx2Hg
|
||||
30k5it7z0d@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMzBrNWl0N3owZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNDl9.8t4PyyREXn16RAtndbATc8qX_I7MthnGm-TkxgTJdJM
|
||||
xfx6xm37cv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGZ4NnhtMzdjdkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNTB9.xOTwIH3ycAbEQ-nMK4_lpDWh2ocDiJUZnupOyJx9czY
|
||||
j17vvibp00@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiajE3dnZpYnAwMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNTJ9.7Tv5sXvPyUPIEINVxD-mhlGvY0rPfPLjCk3RIXqWf3w
|
||||
z6u0b94cew@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiejZ1MGI5NGNld0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNTN9.kSheDCgmVPAvpi4_veydzBBQRmpq3ph9WljkKW60yBA
|
||||
erf376do66@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXJmMzc2ZG82NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNTR9.OXVpC5eeMjcp4lA5taCXQWLShKb2XF6ZcbkIr7vhAKE
|
||||
11pkmv62vi@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTFwa212NjJ2aUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNTh9.0-EPu-u9e43XvqvmvIPE9L7zEzVvScOqwwX9XEUSuto
|
||||
0y6he876oj@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHk2aGU4NzZvakBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNTl9.aE2YMZOU4921q6VASMq7D-iVOUUTB1mXDcZcemD1h7s
|
||||
tdejz59lg0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidGRlano1OWxnMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNjB9.mGxG2H6GLY0QATWIhqBPNOZlY0rwamwKRpW-m6pFrC0
|
||||
pu3n6m65ga@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicHUzbjZtNjVnYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNjF9.7Ocg7LK1B1J805amzbIP3tTfr7y9GmE-fH1pGu_Ukd8
|
||||
jh4nwem1tl@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiamg0bndlbTF0bEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNjd9.8iWUXn81sbWGJ3F99rMLhDjXh2uZOMQuXudEMnuQMUs
|
||||
yn0re0syeg@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieW4wcmUwc3llZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNjh9.SrUYo-dILoDYFa3hLa2BxMW3ie-DlDZNJHu8wsdJ12U
|
||||
tfbvoliz0c@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidGZidm9saXowY0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNzF9.jfk8LnkFCvrcVjEVL8P8Ifbm3c_l4owQ0kTUd6e0TNE
|
||||
qgtgfrvufi@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWd0Z2ZydnVmaUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNzJ9.P_u2s-bPaQjsjKfvijZHmlTGS6uN736cErXQe037Ij4
|
||||
1zwudadsn3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXp3dWRhZHNuM0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNzN9.1qt3Nq-rzkdRdu3T3G7H7bLnmmv95qLeftYHXRWuwQU
|
||||
xlcll5ymn9@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGxjbGw1eW1uOUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNzR9.FKT6g_KsCa4kTOMlT1u1glKH-GthHS5_1Tncs2Hy6dg
|
||||
kpvwp045lo@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia3B2d3AwNDVsb0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNzV9.vim5hcB2WMWF1kfpjtGZF27cfBAY138pQoY4J0aEMPs
|
||||
scn05rsjqq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic2NuMDVyc2pxcUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyNzd9.Rp-EQ9YSNtgmWtIJUDkyRQT-nnwcuTiiuuGLSvSxKeo
|
||||
gberxtalwm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ2Jlcnh0YWx3bUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyODF9.MakMsNStS21FHz40H3SDEGxXFI-MJqSdGyQLnbNOS8o
|
||||
ey7pkbb8c6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXk3cGtiYjhjNkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyODR9.OMe4MHqSwQF4HNz-TRgFFtGs_OIIh4PBjzxajk2TTAM
|
||||
7ek03tlcts@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN2VrMDN0bGN0c0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyODV9.QdWguBIuWY1SHQYSbanHxrb74hL_zHZfP9ak28Q3PME
|
||||
h162rp6yur@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaDE2MnJwNnl1ckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyODd9.-KBQhxGDkIXn0dY5pr-zptRopWSPnCv_dbP1MRMAet8
|
||||
ofcmquhn5t@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib2ZjbXF1aG41dEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyODh9.frezQ3lEtMMv5tEHxOCiiJ_D01rkd6DyCs_jDu1CBQU
|
||||
5jjvh7bz1a@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNWpqdmg3YnoxYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyODl9.M4DHT_YJMs0uC3x8YE97-WSOhnMWn-dR3LfUN1cOChY
|
||||
av6g6uya8x@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXY2ZzZ1eWE4eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyOTB9.7jHatnaabIOZdMfxqQC7-638F2xtfeLW80sTsUCYuqs
|
||||
z0gqtaz517@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiejBncXRhejUxN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyOTJ9.k60xS5hxFKOm8rFOKxual4eOGQo0i4xWpJ_R623Ad9A
|
||||
ewy9rpfbdv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXd5OXJwZmJkdkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUyOTd9.U3WNsL5AK2A9jYdc7Finful_fPtr8CSNXZ3vuJMRLws
|
||||
2vhblj8etp@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnZoYmxqOGV0cEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMDJ9.7cdLsAaU5dAoiGY0jCuPIvreA1_fstRIKKLNwdmPV3A
|
||||
oxgfn4ak17@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib3hnZm40YWsxN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMDF9.epF7aIfEnjEvPWUrs8V1gWzv9QRQnaVgUMJQXRIptFM
|
||||
se697xwuig@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic2U2OTd4d3VpZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMTR9.izpn4Ni0TVDCSVJ01NI0JM8napz2GcerYaBFWrL7uuU
|
||||
uy8t6bcvbe@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidXk4dDZiY3ZiZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMTZ9.wIfm8kme9G8R04QNBJ4uN12K569QdbRep6wgReSnIY4
|
||||
zbq7kykifw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiemJxN2t5a2lmd0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMTd9.rFPkalNZYNAv6t708QvCvKmCVXF2EO26bUOKsSXc0bc
|
||||
gu55l3t2rl@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ3U1NWwzdDJybEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMTh9.AMOMxHB7Xfd3E6vQn4huhLBrVp4AheX03bb3q8sWNqo
|
||||
6vbwdslbgj@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNnZid2RzbGJnakBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMTl9.w5NBZ6pjCezCdUE9ihkojG51LduEU5pBciSa6ScvNAI
|
||||
fluzprnfri@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmx1enBybmZyaUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMjB9.RTC0CDc4Y3f-d5kqKd-xEAWuN0juGghW6qmaKB-Y7Xo
|
||||
xk5fvdv8sg@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGs1ZnZkdjhzZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMjJ9.m3DAof9BC1KIngUtBF_o90_gHvUr8rAV-A4lS9Bj61A
|
||||
b85k0sv1tb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYjg1azBzdjF0YkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMjN9.XqzR0RZtvS6stwKfaWLrdoGbzygY0S8oWxaQioCxP54
|
||||
lzkrwschrd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHprcndzY2hyZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMjZ9.Mb3VDxUdDDktVR33WcOeZl1JrP5ygT_JgsfUHfbjLlw
|
||||
ofrfyf7vqs@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib2ZyZnlmN3Zxc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMjd9.bRDrO8LdBX7OyDZd_8PyUuIzlmiWRzRHLmN7EsYPm8w
|
||||
xfin5z5r49@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieGZpbjV6NXI0OUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMjl9.PymA70pfQWNEv4F7xhSlGg8Sz6uCSpOABMuLL36UPV0
|
||||
r36w3vn3zi@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjM2dzN2bjN6aUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMzV9.W6yKqEj0VHcYWOXjRe1ARpHI5r_HrWTEghgF62rAsvs
|
||||
wzcdakvasg@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid3pjZGFrdmFzZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMzd9.UpAaryaXYvWIjVlgHyYjUlXLMzeu3qzv9yl3EwZLIJk
|
||||
7lv2cszn6e@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN2x2MmNzem42ZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMzh9.aD1W60XNVsjHazn-bS8Ll44nrbzhF9tZTaq-AChOlRk
|
||||
bj5nrxt1w4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYmo1bnJ4dDF3NEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzMzl9.97rn0w8fHk6vvhguTAk-302dUl5_yDfjsAy2XHEBay8
|
||||
a15mpddifw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYTE1bXBkZGlmd0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNDZ9.cwLOcUBAMPtn-_KoLG5I8MLZ-AilZKmBmmWjcOokOpo
|
||||
k0btlduzio@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiazBidGxkdXppb0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNDd9.n4rlNOtlZPuHILGKBJ6sVb6uv5hMQ8B3XuUFvUmjPxA
|
||||
6cu86yne2p@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmN1ODZ5bmUycEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNDh9.r7LKwrrHz_PUO-ZSzUqu5o8Ribs2boMnj5omU_QK31E
|
||||
uiu195olxv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidWl1MTk1b2x4dkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNTB9.B54JbG0An2lqiYZ4ZhyGh3rlwaF0PFOVGzCX-WXROco
|
||||
m7lyhsrvyb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibTdseWhzcnZ5YkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNTN9.LJutt3_mG0xyRq1YfP01st4Po9tbxlAJ6G-sMXTDK1E
|
||||
6pjen4dcv5@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNnBqZW40ZGN2NUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNTd9.F8o8WbAMeDqwGbucfVZ6q0EMogEl4rTFqhRbVcWOQYg
|
||||
u79onaf82t@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidTc5b25hZjgydEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNTl9.Bvjb2vxzSPBPdRJhrWwjqgat-ieRUjzJy0RShJCrMWc
|
||||
r4uucqo0ju@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjR1dWNxbzBqdUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNjN9.XH-7SG1iBzb2ML5tLm_7J0TbpMyS8ZOtOOO7ygYp2lk
|
||||
oj97ls0dbj@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib2o5N2xzMGRiakBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNjR9.8sSCBXm4F7-29yPJPkVwjhigIOeTBDy-ZLk6EUlZixw
|
||||
zzz7d6c4j1@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoienp6N2Q2YzRqMUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNjV9.JT2NIlUpAa8ek7QSgquiiqoccv9cYpEJIlmYpzeo15M
|
||||
4nz30ivs5n@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNG56MzBpdnM1bkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNzF9.E-kRGtjenJqGNbulJ31d7oTu1j4YW60IIaMSeSJic1s
|
||||
z1g4uemikp@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiejFnNHVlbWlrcEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNzJ9.vwoLNv0bGWKDq1XHqxryZDF6yuqjEuHUClTBSw8K744
|
||||
mj7d4rba54@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibWo3ZDRyYmE1NEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNzN9.zmTpsb5yf_pbKtvxvJbyDDgMzbH53g6-zFahXPwDmEo
|
||||
pjp27ydd1g@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicGpwMjd5ZGQxZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzNzd9.A59kCbGVqPqq0Lz4tzzqmmoysVNyeQmWETHprPiFzkM
|
||||
g3bbfltevv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZzNiYmZsdGV2dkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzODJ9.B9NaRsvczYkDzP8YvPRkR3yWlzWdTnoFAD8BXf-ourI
|
||||
c10itrftiy@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYzEwaXRyZnRpeUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzODR9.Bhv13RpvkIkyAOCwkuiVVF2HwcPzYiSYkN39yWYBhBw
|
||||
54uzattcvd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNTR1emF0dGN2ZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzODN9.j5pd17hL-D52VDswdTjCDHPuE0u9b54J1MOVMM5Nd18
|
||||
dj4bkzdejk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZGo0Ymt6ZGVqa0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzODV9.tCks0z88inbHbSzvylDQhLxGralD8X7H46Kf_PmQ1pI
|
||||
1yu3ospoho@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXl1M29zcG9ob0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzODh9.WlMA9srVpYWK7nab8GVDlL3NTWVntrrrvWj7u2whG00
|
||||
ngl7xuswik@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmdsN3h1c3dpa0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzODl9.ZgzuiGIuhr6cY_EbnmBss6Vw-oOiXck3fGxvmOy3Cf0
|
||||
90zy02v9zg@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOTB6eTAydjl6Z0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzOTF9.fOYj2MkdAYzIVorXZvgeS95eJOKcM5SEKh2nTccdN4o
|
||||
t83tk77c9a@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDgzdGs3N2M5YUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzOTJ9.pk7cAhVYQ7Rr6XdyfgAtN9ik8nuDQRbB4zwAdqk3LuA
|
||||
2v6xb4gj4r@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnY2eGI0Z2o0ckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzOTR9.7ulZC2I0kbAhheJfERlUpweXoYkujKF4GnRiJgFqYeg
|
||||
0matjbfqiz@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMG1hdGpiZnFpekBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjUzOTV9.b2DCEJbEaH7wAXa3qLcGgxmOx8nl4-Fpo1qbrqUXUbs
|
||||
rjarivzqqy@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmphcml2enFxeUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MDB9.0eor34f6Rpou2Af83nZUZworOPAI9EwqEgpUnuakkQs
|
||||
0x2rzu741g@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMHgycnp1NzQxZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MDF9.fwYjUQutS-430NV1OvFNCZJFvvMHD5UIXkWIZ3k3uyI
|
||||
cjxb1y6d7x@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY2p4YjF5NmQ3eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MDJ9.sgLwRfA-4119FOYGjIW9hJSace6kUY7AqeQqngi37nE
|
||||
4udyo1x2kr@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNHVkeW8xeDJrckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MDR9.CfTD79_7Dps6pGN1AZzNJXxP_ARv9EeLnn4j263KzSU
|
||||
ege22jfy44@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZWdlMjJqZnk0NEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MDZ9.PbM_mpJbN-H7FmB1FmSHDiazr0TvGJyjrY5XQ7-X9Os
|
||||
nciriwbt0s@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmNpcml3YnQwc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MDd9.XFTMtEIt33E1hhyG_5F41CRf0iJrbaAKtAwHorN3IBA
|
||||
tvho0uy0zq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHZobzB1eTB6cUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MDh9.DEEywVZQ7fRoN6IeRP3qcJxA8T8tw0sFilXnU5OYPls
|
||||
0asngfsfrp@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMGFzbmdmc2ZycEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MTB9.j9vYsPinT50rWxkW5V3CVaRM3JKkg5lM3k3NYk9FEx4
|
||||
v4lvo6bltd@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidjRsdm82Ymx0ZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MTN9.gDmR3A47NfUYqwCkRSopJNPuGbdQfxea8eDY5eQCi9A
|
||||
jb30peglgn@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiamIzMHBlZ2xnbkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MTV9.zydhComdFOY2zdqo_ZpHYfB_ogY4hEoCKdFVfvO_Ctw
|
||||
wkz11rmrpw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid2t6MTFybXJwd0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MTd9.JAjz60tP-Mo5H4sN3ltLr6Br-LyDa45Um9wpfIIYKJ8
|
||||
9o4dt8woif@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOW80ZHQ4d29pZkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MTh9.-uNL43AtVb2mVCvihDh1hKiM-M8VruOKQNIArQGo_EE
|
||||
126k9tvkl0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTI2azl0dmtsMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MTl9.o1qZ5nD0Re_x4QNf4_fumYoMUblqG0VvEvXoyIHEZ4s
|
||||
28n0f1qbl3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMjhuMGYxcWJsM0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MjJ9.nJXb4lucs-4Tdlqbk9jHQ85Dfutf9F1rB896Fy6zMM8
|
||||
thgbsyi6oy@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidGhnYnN5aTZveUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MjN9.Sgpn2BcXYqLkoB8zZ_aAloHh5WZOuZmrhETGS9dlhus
|
||||
0kck4x4oyv@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMGtjazR4NG95dkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MjZ9.Yp1_O6FtLtY2HHZTMHY5or21-XcCl5S0bO-UElkpsL4
|
||||
8lpmhmole7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOGxwbWhtb2xlN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0Mjh9.ShVJZGOtAoOL4QRmEfY6cGVN8g9IxoV_0P8Cj82Jf6M
|
||||
wh0u2hlt2j@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid2gwdTJobHQyakBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0Mjl9.Uk-KfKkOmucABVRMhj9xWqd3Bp377qAUXrNCzk1s_VE
|
||||
jdi27hss15@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiamRpMjdoc3MxNUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MzF9.HaW4qOy40tt-wAKGo1D4c60Q3A6-DRH7wCx3_ekEC0s
|
||||
be0yr591ax@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYmUweXI1OTFheEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MzJ9.k-97KM7l1ZsTAELOwAa7XUqxN_hWncZ66M8M4INPk7c
|
||||
kja1o7vydb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2phMW83dnlkYkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU0MzR9.wcGikzyDhWihQ8PjYOXwV9ISDJGJE4reuFmiYq4av8c
|
||||
coy092wwkk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiY295MDkyd3dra0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3MjV9.kHMkrYBIZssl7fGEYgol-f6zroQFD0k09mz3wXXS_KU
|
||||
h022x44e66@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaDAyMng0NGU2NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3Mzd9.-HslG8_wE9YbLnxH_alLaBXNTHiI9RTy3miaQZ2M-VU
|
||||
04t5bii6nc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMDR0NWJpaTZuY0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3Mzh9.lT_wGvW7ovey2vrCLAcErXHnQonrDftC4niOH91LBxs
|
||||
3a4kjd9dr7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2E0a2pkOWRyN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3NTB9.gIAGpJKc71PTnod0MMyv1r3bGU-ubwnb6eTisBcj-jM
|
||||
4om36btou9@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNG9tMzZidG91OUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3NTN9.ESr0KVUPQq3ptMKTTE9CNkFROU0zKFvofweE57adQXg
|
||||
sycxwhlu92@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic3ljeHdobHU5MkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3NjJ9.20m0HaxckIAcS4CAddiXsJewLMa4cn_i_2CazWPchcY
|
||||
pqabrlfbyw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicHFhYnJsZmJ5d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3NzN9.HgLzZIQ74qdoTYnG2FrNXxGU9V5lbikWMYOKjmlBTZc
|
||||
5lizp793ha@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNWxpenA3OTNoYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3ODN9.JQ1SLn-C0u9QXwNILz1ST2es_XROxtkmpXx_ihcXE9k
|
||||
6ovqa3l5ld@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNm92cWEzbDVsZEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3ODd9.GyR1WvKx6ISi-X7uXZ9sA_iZg2NfJcMsmz9dXd1CJZ8
|
||||
k15qo9f4gm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiazE1cW85ZjRnbUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU3OTN9.Koo9hGfsW6v2xQG6NYX27z-HYfuschX0wuFlqyw_atA
|
||||
e2exde8ft3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZTJleGRlOGZ0M0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4MDF9.UwELdqnigU6kuKaMvPpIpS6cjBXG3KJPPx26kahys0c
|
||||
h2vzb0oim7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaDJ2emIwb2ltN0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4MDl9.QNbblz28nTN04JokmtOYDTNphXoX_5OqQr_xAScdAoY
|
||||
ly82c7u4qc@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHk4MmM3dTRxY0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4MTd9.E42Tdpi6SCtRAnOd3CYPpCN4Tp0xT-0CiPSw8sdSloA
|
||||
9xhon9klxz@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXhob245a2x4ekBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4MjZ9.PnuZx6yyWj89tLJskrcHInlTwRZoaxcfbOu-ACqhe4Y
|
||||
b98on8xwzs@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYjk4b244eHd6c0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4MzF9.VKItKQvzmHYYz6FIehjQ0r8nmE2DR9v2Xd-gvuOd4Ww
|
||||
9cquvwb51f@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOWNxdXZ3YjUxZkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4MzJ9.hk4xrm_LOUSpCo4sDHmOn-B0imM2i374tIbLYbM-jg8
|
||||
dvpap7epwx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZHZwYXA3ZXB3eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4MzV9.iEWo1SuyiIgpmdKPtmOJec8w5dg3chD4auoeukYVtJ4
|
||||
bl1cqmq1dq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYmwxY3FtcTFkcUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4NDJ9.goEaMZ6Zg-A2fXFoXpATuKnCIOQ_GDjub4EMDETRE_A
|
||||
lsq7tjr5ss@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHNxN3RqcjVzc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4NTJ9.60H8svT8fpqIIq5UElndhV4pMVk6lrlgLSptJe73aw4
|
||||
2qdkhoy29e@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMnFka2hveTI5ZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4NTR9.TzRpxKYdnc4hO-ptkN1ofWoVIiJJYDrDZfYYrk5hrHc
|
||||
sdfdwkqve5@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic2RmZHdrcXZlNUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4NjF9.PwBHG-L0uDpBuaHAVyirXjzaDPcf456bysf8OrhIs20
|
||||
0927j6g01u@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMDkyN2o2ZzAxdUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4Njd9.rH6WVvec8ldSTZZ-9glsFX7n8oKGKhdcX3bvq0qZ8EA
|
||||
6h9nk3k7o3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmg5bmszazdvM0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4ODN9.8h58jxR4VEzYzsEEwNyHAwW7GymezOcDoWVRwb5xGog
|
||||
p8gbk20e8g@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDhnYmsyMGU4Z0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4ODR9.c8j0LwaghukwnZvJnth_DrQMGLP1BUWc7p_Q3dlaVnU
|
||||
dvdgb3efwj@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZHZkZ2IzZWZ3akBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4OTB9.8x6XJxe0WY6Zs420_Jtm88iNaQBqUJesJThxDyBqHEw
|
||||
iz1395vw9r@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaXoxMzk1dnc5ckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU4OTd9.6zCE8S4Pnm88_wfKU7XNTY850i9maxvrQPQG-d5CTkg
|
||||
4gsn98eyzs@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNGdzbjk4ZXl6c0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5MTF9.zMMfehhgXbPsw3M7TbLUprkh3NbMs3qaU8FnDH7ClJI
|
||||
jrhjxnld2k@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoianJoanhubGQya0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5MTJ9.URsxn2ga3dEOesWKw6uva2oQIrc0wfWfanup6Na3luw
|
||||
bioiv6imle@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYmlvaXY2aW1sZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5MTN9.vwrReo29ErSnyILj_4sJ-nZmHQ9zHKC15oy4T0mp0aA
|
||||
lknlu9vi05@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibGtubHU5dmkwNUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5MjZ9.bI0fuy8NBVmJg03mxZ4_CvHMZ6uuKaP7fLNBe1UtuLY
|
||||
35rngwryng@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMzVybmd3cnluZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5Mjl9.NHZEEIoI3IZHzz8pHFKATnVWmevLCT8xwksrFKyJNuU
|
||||
r7sev6yzk3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicjdzZXY2eXprM0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5Mzh9.T6k20xSc-4QEiPQPKER4zvJVpzhKJ7QR9ft6jEo0aDA
|
||||
l032kppy96@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibDAzMmtwcHk5NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5Mzl9.XJ5GI_V7tz0K84esUoKSfFNnIsRlrB-vXBshNzkpMZc
|
||||
0nhjru7gp8@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMG5oanJ1N2dwOEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5NTZ9.wcf2y2xcoDEhg8Y-aDKrwucEa18Zuyh2JdRWqIQ7FXc
|
||||
f9bf38aapq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZjliZjM4YWFwcUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5NjF9.Bn2OeVg_4EiAyv0kmWmQckthqZHP2gJyWB4ywyY3vS8
|
||||
p5yvrpt73e@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDV5dnJwdDczZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5NjR9.KweY-SNDHfDnEdXfFn2l9O_xJp1_l2CXr3rgveiOtsQ
|
||||
i07sjziyti@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaTA3c2p6aXl0aUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5NjZ9.1i0QVzMClYV_RI-qYkDGZM5fDKV1fhvsUPK3YRCQVIM
|
||||
2hyuei0vms@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmh5dWVpMHZtc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5NzF9.3KaixVhberb0QLr8k58VZL64ad_aQVnUTUIn4xnaFWA
|
||||
vdh7xmnea0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmRoN3htbmVhMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5ODR9.5c6kqtjEUCQAhFn4-nG7Z7t_szs0td99M51IVHBEp6Q
|
||||
ncq3ugh9dh@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibmNxM3VnaDlkaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5ODd9.SHLbSFDYJ0_UOkGbX0AvSUO4vhSQMEDbJWFHS3epX7o
|
||||
wglirqpn26@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoid2dsaXJxcG4yNkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5OTJ9.a0t5-TqPHwWjd86bZiHI-69_p8xnDVS1w2IdD1JAO-Q
|
||||
5tlssmg3rh@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNXRsc3NtZzNyaEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjU5OTh9.diDHzmuKg2gzDLS7MoKqe_iB02Ui2Yqh8_nA7u5jMWo
|
||||
fhjj0zybc8@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmhqajB6eWJjOEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwMDF9.6lnPspnzRt76KBBFbbEkgcF36MytLpqoMsLEg-4wtYk
|
||||
pgxsxheds4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicGd4c3hoZWRzNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwMDh9.GSXQYJuZmuCFHUBMyckWeuYruyQVRxujRa6cD9KbQso
|
||||
upg5xpef5u@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidXBnNXhwZWY1dUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwMTF9.dL7Kz0fP3k9L7sZ8QQ4iJX_nWQqEhzuI1ufGCIsrjfg
|
||||
w9lk40x3ga@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidzlsazQweDNnYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwMTl9.Su5QNwpNNFFyQhbVOXAS8IweWvNKV0AZHPpUHKVK0Mc
|
||||
lv2rlz54ks@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHYycmx6NTRrc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwMjB9.672VM5tdVt4QTStjlripYgiEO0gt9DIhQXSLTGJxBIE
|
||||
j9a6dmtd1f@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiajlhNmRtdGQxZkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwMzR9.vBBWloLwVdd4bVvg8NHoY4RRdBwQDFwARNKOOFFLfRA
|
||||
lov73vfeww@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibG92NzN2ZmV3d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwMzV9.PMXwUM4lteW7BKj_ncM0SuUx4-6_Gj-I8_z6oD9I3rU
|
||||
8floa9n98y@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOGZsb2E5bjk4eUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwMzl9.pwhnVn8vwUk-pGuXaQk9D_35h2exuhMjTN6XvaODhIM
|
||||
ijqmxx4q68@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaWpxbXh4NHE2OEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwNDF9.6G8fRAKWZ_3sYTG-Cmc2drIQX9vM2k6qq0f1b-Vy0C0
|
||||
ot37lyk711@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib3QzN2x5azcxMUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwNTF9.JwVZp5Uw9OWagzOQzNEou_Kki4oUuiDbT8-jhEzbcm8
|
||||
4q4r7w5rze@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNHE0cjd3NXJ6ZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwNTZ9.0h-CnC8zHfi5kvSlb486VpCXFHplRSY_yQcoqxBKJE8
|
||||
nrxdi6oh43@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibnJ4ZGk2b2g0M0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwNTd9.hQsRnenmSqvrg-KPIPunZbvEBsfpeVgHtgDHHXYwUfA
|
||||
h7m5k9zaa1@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaDdtNWs5emFhMUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwNjV9.HLRgZFHKmnJf-61NL4EkXLVHOAHbL3_DPZ5V-u681IQ
|
||||
8u77gnrvnk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOHU3N2ducnZua0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwNjh9.3B1nNBt1ntkH31W_plPOq2rDcpSTiQukDFP7prYztbc
|
||||
9p4r6noxqg@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXA0cjZub3hxZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwNzJ9.wvwNYUoRZontV1tNrhg2FrSIw3BANXAu8VNvzqQttzA
|
||||
t8722nhqma@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidDg3MjJuaHFtYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwNzh9.xJw6oe7hDJCTTM2PnjVAvUjYinKVYtQdjDgeT1IbLSI
|
||||
rsqiqtcxg1@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicnNxaXF0Y3hnMUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwODB9.7OrOCLBBL7efLfk-lVtdt6USD5poPVnv_tVkrKN45t0
|
||||
axk6884y8m@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXhrNjg4NHk4bUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwODJ9.hBSuGlrtUTfGgjfixo_irF2xdhIJrtRgfI9mGLDv_uo
|
||||
peondgd6t9@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicGVvbmRnZDZ0OUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwOTB9.7lwaDQoxu57DvC5ZClKhaxKPHomJ1-JTm3r2O0B7d3M
|
||||
vb3xp7mn9x@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidmIzeHA3bW45eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwOTJ9.cY9NcyKmBXxRydH_1sr8HMiIk_icQKb7HNpaDiTPXRY
|
||||
9l1i3zhho8@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOWwxaTN6aGhvOEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwOTR9.5ZywV_Pfq3SSO0FmjmBDyJTDzw4t0mKS5uCX5BMJ5u4
|
||||
ako626vgu7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYWtvNjI2dmd1N0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYwOTd9.2DjppUSxXG6ZCewhyhBWzHl2w9s4AGPsPMSjwN-Btpw
|
||||
ig5tlqo4h0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaWc1dGxxbzRoMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxMDR9.p_zedgaJe-QRj5KVRr7_VeYFSaWGgC45632QAlztpBg
|
||||
plwp3tsr54@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicGx3cDN0c3I1NEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxMDh9.NOtM82beq5hByq1bZoYgLNxTdkizZkFxgT97Yjp2FJI
|
||||
tu10au0jvm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidHUxMGF1MGp2bUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxMTN9.jTGkE-lm783HEsweVdl899nk-C42Zq9ayLLYyZg7Q_A
|
||||
hzh1hlmq3y@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaHpoMWhsbXEzeUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxMTR9.MruimPVo-vNygpyt6xfJn4VzE3aR1g6EwHAPjNQvrAs
|
||||
o3jejpipgb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibzNqZWpwaXBnYkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxMjF9.wS4HWAJK2eHFNBo2E3Oh7eL5cA4eUMfUeiXE8956zck
|
||||
2g4n3qqs9a@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmc0bjNxcXM5YUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxMjN9.d93vVi_ViG1gDvD4XIZ4Zswrbd6En3QfEdg-5M1CAdo
|
||||
npjqviae4j@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibnBqcXZpYWU0akBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxMjZ9.KpU2V_vnsFcw4PhxFzRFe0tqhSCuq2W3CmXi1gEe1lM
|
||||
703goiyq7k@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNzAzZ29peXE3a0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxMzV9.V2zNCkN1ERYyWc7NBBqwbkoEgvT2ffxEo29rjbtWS8Y
|
||||
65mwafmovx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjVtd2FmbW92eEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxMzZ9.lic9H08lp_My-m9cQEVbi4mwkYNwGhWs4JL4UkMpY4E
|
||||
2fbymhkens@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmZieW1oa2Vuc0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxNDZ9.XS7jn87Ns7sET6TnEUt2Hj7kSb4iP_stZSgN2mH3K14
|
||||
h1ijikun3m@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaDFpamlrdW4zbUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxNTB9.IERbZSB-GCvrRmMTdWgXs-zTueb6j8GQH0OYiz2ghbU
|
||||
5ns4igavnk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNW5zNGlnYXZua0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxNTF9.pBY2OZVOoy_FJmfa0q6jn3J2Sl-mFNpzCOBMV5XyEvc
|
||||
srqj7143rp@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic3JxajcxNDNycEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxNjJ9.PfLW832bdkKF1D7ObIORe5cfWcV6801PONCt1IvKb5E
|
||||
i9u935oty2@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaTl1OTM1b3R5MkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxNjZ9.kkAfCmojJ5z4-yefAkU4Q2proxYNj5nipyJxcu1jGvw
|
||||
ltqt36dm2e@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibHRxdDM2ZG0yZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxNzN9.EJL4lD8PMoH3TgK3xfnmk_jhueQCRaF1ZDOdqxTyhPk
|
||||
axpe5gfdpm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYXhwZTVnZmRwbUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxODB9.5YygMicR8z4Zlqpmzil25rm6OVs4CJZo7cGdLVCS8MQ
|
||||
hq5d0jxb9o@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaHE1ZDBqeGI5b0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxODd9.V3u23xFBxUw0Q9QsSG7kCXvw_i68k955i7-sOLQuamQ
|
||||
ezcvnio00t@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXpjdm5pbzAwdEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYxOTd9.65t7kRmmitI6g4UrIDcSedjCgquWrHOWSp1hJ9XvzL4
|
||||
9zjos8ifao@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOXpqb3M4aWZhb0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyMDB9.-ENfG5jPQO71K2HpD3KueK9CCD1h_kIIuFetvWvEmp4
|
||||
ez12h0urbj@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXoxMmgwdXJiakBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyMTB9._ltlOhpFFQjeBxMS12o39t0l0jSPvp5IxywRib1-jKI
|
||||
p4cnq1n267@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDRjbnExbjI2N0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyMjB9.6hjv9pnuQUnuTk4DRWgUonlHXDurpttbGNHrPlefDwY
|
||||
3hmt6d41f2@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2htdDZkNDFmMkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyMjZ9.C-qYVFGpRutF_tbPOO0vQzEcnmIDU_su2KWa9qZrmbE
|
||||
gs7umwefsk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ3M3dW13ZWZza0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyMzZ9.BWdcGwYu7IcsD3oKxUw4eKbyX16Xomoqyx7bU4gstBQ
|
||||
sls4iydjce@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoic2xzNGl5ZGpjZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyNTd9.slormkQ7f5ApSrJmVur7ZbWlIgTgdlo3qN3UjlV5Hig
|
||||
gyfe5frsst@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZ3lmZTVmcnNzdEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyNjJ9.8m5APGXAE_G-dP3v66E10QBF1MxJ56-dEQqu8yN3BWM
|
||||
3dh3oieth3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2RoM29pZXRoM0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyNjl9.BwDCIqCOz2far-3nZU_DHoRK2VNL-mCg93e79HsiNhk
|
||||
bat79v3o6s@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYmF0Nzl2M282c0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyNzN9.nkpqRf44AN5yllWRMu0IIDwq-7ia002IP1oO3cWXBnQ
|
||||
76691rbpw1@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNzY2OTFyYnB3MUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYyNzZ9.mD5i4hDY12qWPXFCM7lyrzmFAH9tsdUFJvGWtIorU9s
|
||||
khhwkvgvzb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoia2hod2t2Z3Z6YkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzMDB9.7b9PK9DoyXxYEdPSHOuROtlV_jYb00vHu2f_g2pPdkM
|
||||
onxe1lg6ho@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoib254ZTFsZzZob0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzMDF9.gva3u3pKpirgg9d3pKdB4NLvoleK7-2T3SuqujrXDb4
|
||||
rrh04ewypq@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicnJoMDRld3lwcUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzMTJ9.FCfr_CXeMsX8ZHO0Q-E45n1lXNPuhvaavkUA3i-T4yI
|
||||
a30vwedzn4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYTMwdndlZHpuNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzMTN9.5xg9zoW2mpKcLg6odh0mmkqAAp_c2115rRzmOwX_h8k
|
||||
rfycfs500g@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicmZ5Y2ZzNTAwZ0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzMTR9.RpQUo5eKSh8VMv8NvDTDl5VTuT08qsSugOHrRPkxuyg
|
||||
pffr0q4ey6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicGZmcjBxNGV5NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzMjN9.4KS82RaeAgqkZm8v-AwOJriP16GvaUfaFdTwRiF10Zs
|
||||
ai905n4apl@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYWk5MDVuNGFwbEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzMjV9.IrhhJ5-LxEXazMZX9UegYjhCJO1vAVacuRygL4GPya8
|
||||
2itx7v0wtn@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMml0eDd2MHd0bkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzMzV9.vcniZ2z_NeY4ol2Kdw0HGHF9N3Se4Eg1ED5B2MLjLkg
|
||||
05noan0oh2@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMDVub2FuMG9oMkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzMzZ9.u5cV5MMIw0qDC1y8f4T0V3qLNdK31jzwDg3vsC3ivfc
|
||||
3hadpyerv8@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiM2hhZHB5ZXJ2OEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzNDN9.IKAFty0b7OrB7eGJtEJB-CmD0kuAeXyX1oR-Yx9qs9g
|
||||
2hlubx8eq3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMmhsdWJ4OGVxM0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzNDd9._ARiB5k2KAIXg-pqfvViCLmHYMoYYhZelnM-2CeHkSQ
|
||||
587vajn0o0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNTg3dmFqbjBvMEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzNTJ9.d9xdSLHB-EgD0GUD5EiZifvVAAuw8XT42CypbxjczJk
|
||||
k0ygu0017l@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiazB5Z3UwMDE3bEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzNTN9.iIezWdgE4obDvEGcv2lfWSuKjvoQaIlZ1EwMyKEEAkk
|
||||
qbuczhjdau@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicWJ1Y3poamRhdUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzNTl9.WYuWV4-Vcttjjz_4k_yO6_Emhq5J9NjzC7BV3JKY2aw
|
||||
fmeob6w1qx@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZm1lb2I2dzFxeEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzNjN9._i_BnVioGGNP8wlQ_u9w3BW915D8ZbIE85LTC2mr6xM
|
||||
93331nptxr@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiOTMzMzFucHR4ckBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzNzB9.Jj-Rct0s42056UiRdGE8UEsxP1y9jFq-0YYuAGfU3ME
|
||||
bu2jlps02m@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYnUyamxwczAybUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzNzV9.iwIy21Nv1l8HxR30mxF2VarDLqj7lwtvbVkzOTXqi1g
|
||||
1wl1rxphe9@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMXdsMXJ4cGhlOUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzODJ9.o6ui82D_Kaig6MUEWbZY-2kOn35nj0mWtG0qcNkBjd4
|
||||
21hicok1ox@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMjFoaWNvazFveEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzODN9.-P-QJVq_2P8N79yCm-aJ_GdJsNjNtleCcehart6Dsa4
|
||||
erf91vdecn@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXJmOTF2ZGVjbkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzODd9.DYqzzHtQUSjDHQbti_yzVl4gpw_r3ZrHmxCV1JPAPgo
|
||||
o6o3td4biw@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibzZvM3RkNGJpd0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzOTd9.pL_UfGQf9a1EX-BKUdJH8PlqsPDOvVsY2ebXZRh6Ny0
|
||||
7tpc6wecvm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiN3RwYzZ3ZWN2bUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjYzOTh9.3HAbnP36AUqZNG50YT8U5lBPuULTPaHRuWwzimTspbc
|
||||
ez0940igg4@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZXowOTQwaWdnNEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY0NDl9.JKygAc7T395cA7iH9TqmjAWP8RqsFeyB5pTBoxe3ERI
|
||||
6ekao1somm@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmVrYW8xc29tbUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY1NTN9.yA-mLuTsXX0vEHsghGgNRbAFjxLv-P9-XDuf0mCUm9g
|
||||
liornmwtge@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibGlvcm5td3RnZUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY1NTV9.s2Xmhb-D48LFUUKjTs5PW4F28XbTjGSN7VHq_BtXuA0
|
||||
l7jjdoo83n@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibDdqamRvbzgzbkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY1NjN9.JZlwgbSrnzNkWAXWAVHXzUAABi3km2DQlNevjCIen88
|
||||
w2p3dxyfz3@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoidzJwM2R4eWZ6M0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2MjR9.QB3M_ssG4n0SviPfviIp7l6IyBuOYS1_kpyQBjKTAFQ
|
||||
h9v2z5c5x0@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaDl2Mno1YzV4MEBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2MjV9.LYYlo0fQdJPWN8G01Gfv9ta91V_p9aCHaSZ9IpUd_Lc
|
||||
xw4a8g8xy6@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoieHc0YThnOHh5NkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2MjZ9.6P-MYahUYMAcYCmI8o1rSZn08ZNq64_lgUf1jWW5KE4
|
||||
i5inivpafb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiaTVpbml2cGFmYkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2Mjh9.P5eerFxEoIE39apKEJ3Pl3C0YXg4o5abUU6G6acIO2s
|
||||
l9dsdx1yla@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoibDlkc2R4MXlsYUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2MzB9.xgDxJ0rq-zXOvUWyIEz9Fe1UPPGy4c3OV10OtufRDkI
|
||||
qnp2m6wu6w@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicW5wMm02d3U2d0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2Mzd9.5h13RNyKPh3NOY2319eC6cp8q8R_iPbxXDo8mWUTjUA
|
||||
64g5gp38co@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNjRnNWdwMzhjb0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2NDF9.3UxuwMBxcPlr9lqhXAxLplQlXT3y9AZP3DsPj_ErQgM
|
||||
6gwsas4av7@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiNmd3c2FzNGF2N0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2NDV9.KuXBZrMObGllmlQClH_frKlGMeFPC8yp27eWgo1XRkw
|
||||
03cliyo5hn@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMDNjbGl5bzVobkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2NDh9.uhjQcIiEDNOi51XfvuiIO9vnJ4Rjtrtwf4quPmM8kC0
|
||||
p1hjlnb5vy@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoicDFoamxuYjV2eUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2ODh9.Y-974AgfmburV6zBC5GL08CuKXylwcVSQXQqTwvcTLQ
|
||||
fcqbhgxp4y@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZmNxYmhneHA0eUBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY2ODl9.64Q8CqvQNHlvwISJZlrQmuMWSs_AruZPIdwGDjLXRsg
|
||||
d9ws1v42qk@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiZDl3czF2NDJxa0BiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY3MDR9.8bRIgXhYW8EM6yl1o2LgZW25lNIRmUgVoS58vtXTwS0
|
||||
17avby89cb@baoxia.top eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiMTdhdmJ5ODljYkBiYW94aWEudG9wIiwiYWRkcmVzc19pZCI6MjY3MDl9.9-QW9CcKKUPMCn33NTcWpX16s0KI3q19F-9zLUpIA0k
|
||||
kwlu5unggx@mail.bblbb.com bblbb:kwlu5unggx
|
||||
fe89ggxrpc@mail.bblbb.com bblbb:fe89ggxrpc
|
||||
8qdlz5qphj@mail.bblbb.com bblbb:8qdlz5qphj
|
||||
t0rpdza1s0@mail.bblbb.com bblbb:t0rpdza1s0
|
||||
w5ayintm8r@mail.bblbb.com bblbb:w5ayintm8r
|
||||
2in9y6cab4@mail.bblbb.com bblbb:2in9y6cab4
|
||||
5fbu5nq3wj@mail.bblbb.com bblbb:5fbu5nq3wj
|
||||
cynthia464@mail.bblbb.com bblbb:cynthia464
|
||||
meganmartinez956@mail.bblbb.com bblbb:meganmartinez956
|
||||
helenramos281@mail.bblbb.com bblbb:helenramos281
|
||||
zoieroberts1869@mail.bblbb.com bblbb:zoieroberts1869
|
||||
@@ -0,0 +1,40 @@
|
||||
"""OIDC 铸造包:为免费 Grok Build 账号铸造 access/refresh/id token。
|
||||
|
||||
优先路径(2026-07 起):
|
||||
- oauth_code:SSO cookie → Authorization Code + PKCE(referrer=grok-build)
|
||||
|
||||
兼容路径:
|
||||
- oauth_device + browser_confirm:设备码授权(通常无 referrer,可能不可用)
|
||||
|
||||
输出成什么格式(CPA / 其它)由上层的 cpa_export 负责,这里不掺和。
|
||||
"""
|
||||
|
||||
from .browser_confirm import mint_with_browser, shutdown_mint_browsers
|
||||
from .oauth_code import (
|
||||
CLIENT_ID as CODE_CLIENT_ID,
|
||||
GROK_REFERRER,
|
||||
OAuthCodeError,
|
||||
SCOPE as CODE_SCOPE,
|
||||
mint_from_sso,
|
||||
normalize_sso_cookie,
|
||||
sso_to_token,
|
||||
)
|
||||
from .oauth_device import CLIENT_ID, SCOPE, OAuthDeviceError
|
||||
from .proxyutil import resolve_proxy, set_runtime_proxy
|
||||
|
||||
__all__ = [
|
||||
"mint_with_browser",
|
||||
"shutdown_mint_browsers",
|
||||
"mint_from_sso",
|
||||
"sso_to_token",
|
||||
"normalize_sso_cookie",
|
||||
"CLIENT_ID",
|
||||
"SCOPE",
|
||||
"CODE_CLIENT_ID",
|
||||
"CODE_SCOPE",
|
||||
"GROK_REFERRER",
|
||||
"OAuthDeviceError",
|
||||
"OAuthCodeError",
|
||||
"resolve_proxy",
|
||||
"set_runtime_proxy",
|
||||
]
|
||||
@@ -0,0 +1,937 @@
|
||||
"""Approve xAI device-code in Chromium (DrissionPage).
|
||||
|
||||
Paths resolve relative to the grok_reg project root (parent of oidc_mint).
|
||||
|
||||
Proven flow (2026-07-10, free account):
|
||||
1. Open verification_uri_complete (user_code prefilled)
|
||||
2. Click 继续 on device page
|
||||
3. Cookie banner: 全部允许 (optional)
|
||||
4. 使用邮箱登录 → fill email → 下一步
|
||||
5. Wait cf-turnstile-response → fill password → REAL click 登录
|
||||
6. May land /account redirect or device page → 继续
|
||||
7. Consent page /oauth2/device/consent → REAL click exact 允许
|
||||
(by_js click causes Invalid action / empty form action)
|
||||
8. /oauth2/device/done "设备已授权" + token poll SUCCESS
|
||||
|
||||
Hard rules:
|
||||
- Token poll is source of truth
|
||||
- Button match is EXACT text only (允许 ≠ 全部允许)
|
||||
- Consent Allow MUST be a real click, not by_js
|
||||
- Prefer headed browser + register turnstilePatch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
def _noop_log(_: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class BrowserConfirmError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _sleep(sec: float) -> None:
|
||||
time.sleep(sec)
|
||||
|
||||
|
||||
def create_standalone_page(
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
headless: bool = False,
|
||||
log: LogFn | None = None,
|
||||
) -> tuple[Any, Any]:
|
||||
log = log or _noop_log
|
||||
try:
|
||||
from DrissionPage import Chromium, ChromiumOptions
|
||||
except ImportError as e:
|
||||
raise BrowserConfirmError(
|
||||
"DrissionPage not installed; run inside grok_reg uv env or pip install DrissionPage"
|
||||
) from e
|
||||
|
||||
opts = None
|
||||
# Project root = parent of this package (./oidc_mint → ../)
|
||||
_pkg_root = Path(__file__).resolve().parents[1]
|
||||
try:
|
||||
reg_file = _pkg_root / "grok_register_ttk.py"
|
||||
if reg_file.is_file():
|
||||
reg_dir = str(_pkg_root)
|
||||
if reg_dir not in sys.path:
|
||||
sys.path.insert(0, reg_dir)
|
||||
try:
|
||||
from grok_register_ttk import create_browser_options # type: ignore
|
||||
|
||||
opts = create_browser_options()
|
||||
log("using register create_browser_options (turnstilePatch)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"register browser options unavailable: {e}")
|
||||
opts = None
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"register options probe failed: {e}")
|
||||
opts = None
|
||||
|
||||
if opts is None:
|
||||
opts = ChromiumOptions()
|
||||
opts.auto_port()
|
||||
opts.set_timeouts(base=2)
|
||||
for flag in (
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
"--disable-background-networking",
|
||||
"--window-size=1280,900",
|
||||
):
|
||||
opts.set_argument(flag)
|
||||
ext = str(_pkg_root / "turnstilePatch")
|
||||
if os.path.isdir(ext):
|
||||
try:
|
||||
opts.add_extension(ext)
|
||||
log(f"added extension {ext}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"extension add failed: {e}")
|
||||
|
||||
if headless:
|
||||
try:
|
||||
opts.headless(True)
|
||||
except Exception:
|
||||
opts.set_argument("--headless=new")
|
||||
log("headless=True (may hit Cloudflare / break real clicks)")
|
||||
else:
|
||||
try:
|
||||
opts.headless(False)
|
||||
except Exception:
|
||||
pass
|
||||
log(f"headed browser DISPLAY={os.environ.get('DISPLAY', '')!r}")
|
||||
|
||||
for cand in (
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
):
|
||||
if os.path.isfile(cand):
|
||||
try:
|
||||
opts.set_browser_path(cand)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
from .proxyutil import proxy_for_chromium, proxy_log_label, resolve_proxy
|
||||
|
||||
# explicit / runtime config first; env only as fallback
|
||||
proxy = resolve_proxy(proxy)
|
||||
chrome_proxy = proxy_for_chromium(proxy)
|
||||
if chrome_proxy:
|
||||
opts.set_argument(f"--proxy-server={chrome_proxy}")
|
||||
log(f"browser proxy={proxy_log_label(proxy)} (chromium {chrome_proxy})")
|
||||
else:
|
||||
log("browser proxy=(none)")
|
||||
|
||||
browser = Chromium(opts)
|
||||
page = browser.latest_tab
|
||||
log("standalone chromium started")
|
||||
return browser, page
|
||||
|
||||
|
||||
def close_standalone(browser: Any) -> None:
|
||||
try:
|
||||
browser.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── mint browser reuse (per-thread) ──
|
||||
_mint_tls = threading.local()
|
||||
|
||||
|
||||
def _mint_tls_get() -> dict[str, Any]:
|
||||
d = getattr(_mint_tls, "state", None)
|
||||
if d is None:
|
||||
d = {"browser": None, "page": None, "served": 0, "proxy": None, "headless": None}
|
||||
_mint_tls.state = d
|
||||
return d
|
||||
|
||||
|
||||
def clear_page_session(page: Any, browser: Any | None = None, log: LogFn | None = None) -> None:
|
||||
"""Blank page + wipe storage/cookies for reuse between mint jobs."""
|
||||
log = log or _noop_log
|
||||
try:
|
||||
if page is not None:
|
||||
try:
|
||||
page.get("about:blank")
|
||||
except Exception:
|
||||
pass
|
||||
for js in (
|
||||
"try{localStorage.clear()}catch(e){}",
|
||||
"try{sessionStorage.clear()}catch(e){}",
|
||||
):
|
||||
try:
|
||||
page.run_js(js)
|
||||
except Exception:
|
||||
pass
|
||||
for target in (page, browser):
|
||||
if target is None:
|
||||
continue
|
||||
try:
|
||||
target.set.cookies.clear() # type: ignore[attr-defined]
|
||||
log("mint session cookies cleared")
|
||||
break
|
||||
except Exception:
|
||||
try:
|
||||
# older API
|
||||
cks = target.cookies()
|
||||
if isinstance(cks, list):
|
||||
for c in cks:
|
||||
try:
|
||||
target.set.cookies.remove(c) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
log(f"clear_page_session: {e}")
|
||||
|
||||
|
||||
def normalize_cookies(cookies: Any) -> list[dict[str, Any]]:
|
||||
"""Normalize DrissionPage / browser cookie list to settable dicts.
|
||||
|
||||
Also clones SSO-like cookies onto accounts.x.ai / auth.x.ai domains so
|
||||
device-auth can skip secondary login when possible.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
if not cookies:
|
||||
return out
|
||||
if isinstance(cookies, dict):
|
||||
for k, v in cookies.items():
|
||||
if k and v is not None:
|
||||
out.append({"name": str(k), "value": str(v), "domain": ".x.ai", "path": "/"})
|
||||
cookies = out
|
||||
out = []
|
||||
if not isinstance(cookies, (list, tuple)):
|
||||
return out
|
||||
for c in cookies:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
name = c.get("name") or c.get("Name")
|
||||
value = c.get("value") or c.get("Value")
|
||||
if not name or value is None:
|
||||
continue
|
||||
domain = str(c.get("domain") or c.get("Domain") or ".x.ai")
|
||||
path = str(c.get("path") or c.get("Path") or "/")
|
||||
item = {
|
||||
"name": str(name),
|
||||
"value": str(value),
|
||||
"domain": domain,
|
||||
"path": path,
|
||||
}
|
||||
for src, dst in (
|
||||
("expiry", "expiry"),
|
||||
("expires", "expiry"),
|
||||
("secure", "secure"),
|
||||
("httpOnly", "httpOnly"),
|
||||
("sameSite", "sameSite"),
|
||||
):
|
||||
if src in c and c[src] is not None:
|
||||
item[dst] = c[src]
|
||||
out.append(item)
|
||||
|
||||
# Expand SSO cookies to xAI account hosts (register browser is often on grok.com)
|
||||
sso_names = {"sso", "sso-rw", "cf_clearance", "sso_jwt", "__cf_bm"}
|
||||
extras: list[dict[str, Any]] = []
|
||||
seen = {(i["name"], i["domain"], i["path"]) for i in out}
|
||||
for item in list(out):
|
||||
n = item["name"]
|
||||
if n not in sso_names and not n.startswith("sso"):
|
||||
continue
|
||||
for dom in (".x.ai", "accounts.x.ai", ".accounts.x.ai", "auth.x.ai", ".auth.x.ai"):
|
||||
key = (n, dom, item["path"])
|
||||
if key in seen:
|
||||
continue
|
||||
clone = dict(item)
|
||||
clone["domain"] = dom
|
||||
extras.append(clone)
|
||||
seen.add(key)
|
||||
out.extend(extras)
|
||||
return out
|
||||
|
||||
|
||||
def inject_cookies(page: Any, cookies: Any, log: LogFn | None = None) -> int:
|
||||
"""Inject cookies into page/browser. Returns count attempted."""
|
||||
log = log or _noop_log
|
||||
items = normalize_cookies(cookies)
|
||||
if not items or page is None:
|
||||
return 0
|
||||
for url in (
|
||||
"https://accounts.x.ai/",
|
||||
"https://auth.x.ai/",
|
||||
"https://grok.com/",
|
||||
):
|
||||
try:
|
||||
page.get(url)
|
||||
_sleep(0.4)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
n = 0
|
||||
for target_name, target in (("page", page), ("browser", getattr(page, "browser", None))):
|
||||
if target is None:
|
||||
continue
|
||||
try:
|
||||
target.set.cookies(items) # type: ignore[attr-defined]
|
||||
n = len(items)
|
||||
log(f"injected cookies bulk via {target_name}={n}")
|
||||
break
|
||||
except Exception as e:
|
||||
log(f"bulk set via {target_name} failed: {e}")
|
||||
|
||||
if n == 0:
|
||||
for item in items:
|
||||
ok = False
|
||||
for target in (page, getattr(page, "browser", None)):
|
||||
if target is None:
|
||||
continue
|
||||
try:
|
||||
target.set.cookies(item) # type: ignore[attr-defined]
|
||||
ok = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if ok:
|
||||
n += 1
|
||||
log(f"injected cookies one-by-one={n}/{len(items)}")
|
||||
|
||||
# JS document.cookie for non-httpOnly SSO cookies (best effort)
|
||||
try:
|
||||
js_items = [
|
||||
c
|
||||
for c in items
|
||||
if (not c.get("httpOnly")) and c.get("name") in {"sso", "sso-rw", "cf_clearance"}
|
||||
]
|
||||
if js_items:
|
||||
page.get("https://accounts.x.ai/")
|
||||
for c in js_items:
|
||||
name = str(c["name"])
|
||||
val = str(c["value"])
|
||||
# avoid quote breakage
|
||||
if "'" in name or "'" in val:
|
||||
continue
|
||||
page.run_js(
|
||||
"document.cookie='"
|
||||
+ name
|
||||
+ "="
|
||||
+ val
|
||||
+ "; path=/; domain=.x.ai; Secure; SameSite=None'"
|
||||
)
|
||||
log(f"js cookie fallback applied={len(js_items)}")
|
||||
except Exception as e:
|
||||
log(f"js cookie fallback: {e}")
|
||||
|
||||
return n
|
||||
|
||||
|
||||
def acquire_mint_browser(
|
||||
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
headless: bool = False,
|
||||
reuse: bool = True,
|
||||
recycle_every: int = 15,
|
||||
log: LogFn | None = None,
|
||||
) -> tuple[Any, Any, bool]:
|
||||
"""Return (browser, page, owned). owned=True means caller must close if not reusing.
|
||||
|
||||
When reuse=True, browser is kept in thread-local and cleared between jobs.
|
||||
"""
|
||||
log = log or _noop_log
|
||||
st = _mint_tls_get()
|
||||
if reuse and st.get("browser") is not None:
|
||||
# recycle if proxy/headless changed or served enough
|
||||
need_recycle = (
|
||||
st.get("proxy") != (proxy or None)
|
||||
or st.get("headless") != headless
|
||||
or (recycle_every > 0 and int(st.get("served") or 0) >= recycle_every)
|
||||
)
|
||||
if not need_recycle:
|
||||
page = st.get("page")
|
||||
browser = st.get("browser")
|
||||
clear_page_session(page, browser, log=log)
|
||||
log(f"mint browser reused served={st.get('served')}")
|
||||
return browser, page, False
|
||||
log("mint browser recycle (proxy/headless/served threshold)")
|
||||
try:
|
||||
close_standalone(st.get("browser"))
|
||||
except Exception:
|
||||
pass
|
||||
st["browser"] = None
|
||||
st["page"] = None
|
||||
st["served"] = 0
|
||||
|
||||
browser, page = create_standalone_page(proxy=proxy, headless=headless, log=log)
|
||||
if reuse:
|
||||
st["browser"] = browser
|
||||
st["page"] = page
|
||||
st["proxy"] = proxy or None
|
||||
st["headless"] = headless
|
||||
st["served"] = 0
|
||||
return browser, page, False
|
||||
return browser, page, True
|
||||
|
||||
|
||||
def release_mint_browser(
|
||||
*,
|
||||
owned: bool,
|
||||
success: bool = True,
|
||||
force_quit: bool = False,
|
||||
log: LogFn | None = None,
|
||||
) -> None:
|
||||
log = log or _noop_log
|
||||
st = _mint_tls_get()
|
||||
if force_quit or owned:
|
||||
browser = st.get("browser") if not owned else None
|
||||
# if owned, caller passes via closing create path — handle both
|
||||
if owned:
|
||||
# owned browser not in tls
|
||||
return
|
||||
if browser is not None:
|
||||
close_standalone(browser)
|
||||
st["browser"] = None
|
||||
st["page"] = None
|
||||
st["served"] = 0
|
||||
log("mint browser quit")
|
||||
return
|
||||
if success:
|
||||
st["served"] = int(st.get("served") or 0) + 1
|
||||
else:
|
||||
# fail: drop browser to avoid dirty state
|
||||
if st.get("browser") is not None:
|
||||
close_standalone(st.get("browser"))
|
||||
st["browser"] = None
|
||||
st["page"] = None
|
||||
st["served"] = 0
|
||||
log("mint browser dropped after failure")
|
||||
|
||||
|
||||
def shutdown_mint_browsers() -> None:
|
||||
st = getattr(_mint_tls, "state", None)
|
||||
if not st:
|
||||
return
|
||||
if st.get("browser") is not None:
|
||||
close_standalone(st.get("browser"))
|
||||
st["browser"] = None
|
||||
st["page"] = None
|
||||
st["served"] = 0
|
||||
|
||||
|
||||
def _page_url(page: Any) -> str:
|
||||
try:
|
||||
return page.url or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _visible_text(page: Any) -> str:
|
||||
try:
|
||||
t = page.run_js(
|
||||
"return (document.body && (document.body.innerText || document.body.textContent)) || '';"
|
||||
)
|
||||
if isinstance(t, str) and t.strip():
|
||||
return t
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
raw = getattr(page, "raw_text", None)
|
||||
if callable(raw):
|
||||
t = raw()
|
||||
if isinstance(t, str) and t.strip():
|
||||
return t
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", (s or "").strip())
|
||||
|
||||
|
||||
def _find_button_exact(page: Any, label: str) -> Any | None:
|
||||
try:
|
||||
for el in page.eles("tag:button") or []:
|
||||
try:
|
||||
if _norm(el.text or "") == label:
|
||||
return el
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return page.ele(f"xpath://button[normalize-space(.)='{label}']", timeout=0.3)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _click_exact(
|
||||
page: Any,
|
||||
labels: list[str],
|
||||
log: LogFn,
|
||||
*,
|
||||
real: bool = False,
|
||||
) -> str | None:
|
||||
"""Click button by EXACT visible text. real=True uses physical click (needed for consent)."""
|
||||
for label in labels:
|
||||
el = _find_button_exact(page, label)
|
||||
if not el:
|
||||
continue
|
||||
try:
|
||||
if real:
|
||||
try:
|
||||
el.scroll.to_see()
|
||||
except Exception:
|
||||
pass
|
||||
el.click()
|
||||
log(f"clicked REAL exact {label!r}")
|
||||
else:
|
||||
el.click(by_js=True)
|
||||
log(f"clicked JS exact {label!r}")
|
||||
return label
|
||||
except Exception as e:
|
||||
log(f"click {label!r} failed: {e}")
|
||||
if real:
|
||||
try:
|
||||
el.click(by_js=True)
|
||||
log(f"clicked JS fallback exact {label!r}")
|
||||
return label
|
||||
except Exception as e2:
|
||||
log(f"js fallback {label!r} failed: {e2}")
|
||||
return None
|
||||
|
||||
|
||||
def _wait_turnstile(page: Any, log: LogFn, timeout: float = 45.0) -> bool:
|
||||
"""Wait/click Cloudflare Turnstile on the mint browser page."""
|
||||
deadline = time.time() + timeout
|
||||
clicked = False
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
el = page.ele("css:input[name='cf-turnstile-response']", timeout=0.3)
|
||||
if el is not None:
|
||||
v = (el.attr("value") or "").strip()
|
||||
if len(v) > 20:
|
||||
log(f"turnstile ready len={len(v)}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Mimic register-machine: shadow-root checkbox click
|
||||
try:
|
||||
challenge_input = page.ele("@name=cf-turnstile-response", timeout=0.2)
|
||||
if challenge_input is not None:
|
||||
wrapper = challenge_input.parent()
|
||||
iframe = None
|
||||
try:
|
||||
iframe = wrapper.shadow_root.ele("tag:iframe")
|
||||
except Exception:
|
||||
iframe = None
|
||||
if iframe is not None:
|
||||
try:
|
||||
iframe.run_js(
|
||||
"""
|
||||
window.dtp = 1;
|
||||
function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
|
||||
let sx = getRandomInt(800, 1200);
|
||||
let sy = getRandomInt(400, 700);
|
||||
Object.defineProperty(MouseEvent.prototype, 'screenX', { value: sx });
|
||||
Object.defineProperty(MouseEvent.prototype, 'screenY', { value: sy });
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
body_sr = iframe.ele("tag:body").shadow_root
|
||||
btn = body_sr.ele("tag:input")
|
||||
if btn is not None:
|
||||
btn.click()
|
||||
if not clicked:
|
||||
log("clicked turnstile shadow checkbox")
|
||||
clicked = True
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not clicked:
|
||||
try:
|
||||
page.run_js(
|
||||
"""
|
||||
const nodes = Array.from(document.querySelectorAll('div,span,iframe')).filter((n) => {
|
||||
const txt = (n.className || '') + ' ' + (n.id || '') + ' ' + (n.getAttribute?.('src') || '');
|
||||
return String(txt).toLowerCase().includes('turnstile');
|
||||
});
|
||||
if (nodes.length && typeof nodes[0].click === 'function') nodes[0].click();
|
||||
"""
|
||||
)
|
||||
clicked = True
|
||||
log("clicked turnstile container via JS")
|
||||
except Exception:
|
||||
pass
|
||||
_sleep(0.9)
|
||||
log("turnstile not ready")
|
||||
return False
|
||||
|
||||
|
||||
def _fill(page, selector, value, log, label=""):
|
||||
"""找到 selector 输入框,清空并填入 value;成功返回 True。"""
|
||||
try:
|
||||
el = page.ele(selector, timeout=3)
|
||||
except Exception:
|
||||
el = None
|
||||
if not el:
|
||||
return False
|
||||
try:
|
||||
try:
|
||||
el.clear()
|
||||
except Exception:
|
||||
pass
|
||||
el.input(value)
|
||||
if label:
|
||||
log(f"filled {label}")
|
||||
return True
|
||||
except Exception as exc:
|
||||
log(f"fill {label} failed: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def approve_device_code(
|
||||
page: Any,
|
||||
*,
|
||||
verification_uri_complete: str,
|
||||
email: str,
|
||||
password: str,
|
||||
user_code: str = "",
|
||||
timeout_sec: float = 240.0,
|
||||
stop_event: threading.Event | None = None,
|
||||
log: LogFn | None = None,
|
||||
) -> None:
|
||||
log = log or _noop_log
|
||||
if page is None:
|
||||
raise BrowserConfirmError("page is None")
|
||||
email = (email or "").strip()
|
||||
password = password or ""
|
||||
if not email or not password:
|
||||
raise BrowserConfirmError("email/password required")
|
||||
|
||||
if not user_code and "user_code=" in (verification_uri_complete or ""):
|
||||
try:
|
||||
user_code = verification_uri_complete.split("user_code=", 1)[1].split("&", 1)[0]
|
||||
except Exception:
|
||||
user_code = ""
|
||||
|
||||
log(f"open device url: {verification_uri_complete}")
|
||||
try:
|
||||
page.get(verification_uri_complete, timeout=60)
|
||||
except TypeError:
|
||||
page.get(verification_uri_complete)
|
||||
_sleep(2.0)
|
||||
|
||||
deadline = time.time() + timeout_sec
|
||||
phase = "device"
|
||||
login_attempts = 0
|
||||
last_url = ""
|
||||
|
||||
while time.time() < deadline:
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
log("stop_event set — leave browser loop")
|
||||
return
|
||||
|
||||
url = _page_url(page)
|
||||
text = _visible_text(page)
|
||||
if url != last_url:
|
||||
log(f"url: {url[:180]}")
|
||||
last_url = url
|
||||
snip = _norm(text)[:160]
|
||||
if snip:
|
||||
log(f"visible: {snip}")
|
||||
|
||||
# Done page
|
||||
if "device/done" in url or "设备已授权" in text or "device authorized" in text.lower():
|
||||
log("device done page — waiting for token poll")
|
||||
_sleep(1.5)
|
||||
continue
|
||||
|
||||
if "Invalid action" in text:
|
||||
log("Invalid action — reopen device uri")
|
||||
page.get(verification_uri_complete)
|
||||
_sleep(2.0)
|
||||
phase = "device"
|
||||
continue
|
||||
|
||||
# Consent page — REAL click exact 允许
|
||||
if "/consent" in url or "授权 Grok Build" in text or "Authorize Grok Build" in text:
|
||||
phase = "consent"
|
||||
# Prefer real click; React needs it to set form action=allow
|
||||
if _click_exact(page, ["允许", "Allow", "Authorize", "Approve"], log, real=True):
|
||||
_sleep(2.5)
|
||||
continue
|
||||
# last resort: set action and submit
|
||||
try:
|
||||
page.run_js(
|
||||
"""
|
||||
const f=document.querySelector('form');
|
||||
if(!f) return;
|
||||
let a=f.querySelector('input[name=action]');
|
||||
if(!a){a=document.createElement('input');a.type='hidden';a.name='action';f.appendChild(a);}
|
||||
a.value='allow';
|
||||
const btn=[...f.querySelectorAll('button')].find(b=>((b.innerText||'').trim())==='允许'||(b.innerText||'').trim()==='Allow');
|
||||
if(btn) btn.click(); else f.submit();
|
||||
"""
|
||||
)
|
||||
log("consent form submit via JS fallback")
|
||||
_sleep(2.5)
|
||||
except Exception as e:
|
||||
log(f"consent fallback failed: {e}")
|
||||
continue
|
||||
|
||||
# Device code entry
|
||||
if page.ele("css:input[name='user_code']", timeout=0.3) and "consent" not in url:
|
||||
phase = "device"
|
||||
if user_code:
|
||||
try:
|
||||
uc = page.ele("css:input[name='user_code']")
|
||||
cur = (uc.value or "") if uc else ""
|
||||
if user_code.replace("-", "") not in cur.replace("-", ""):
|
||||
uc.clear()
|
||||
uc.input(user_code)
|
||||
log("filled user_code")
|
||||
except Exception:
|
||||
pass
|
||||
if _click_exact(page, ["继续", "Continue"], log, real=False):
|
||||
_sleep(2.0)
|
||||
continue
|
||||
try:
|
||||
el = page.ele("css:button[type='submit']", timeout=0.5)
|
||||
if el:
|
||||
el.click(by_js=True)
|
||||
log("clicked device submit")
|
||||
_sleep(2.0)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Account redirect
|
||||
if "正在重定向" in text or ("/account" in url and "sign-in" not in url):
|
||||
if _click_exact(page, ["继续", "Continue"], log, real=False):
|
||||
_sleep(2.0)
|
||||
continue
|
||||
|
||||
# Cookie banner (exact labels only)
|
||||
if "全部允许" in text or "隐私偏好" in text:
|
||||
_click_exact(page, ["全部允许", "全部拒绝"], log, real=False)
|
||||
_sleep(0.5)
|
||||
|
||||
# Sign-in chooser
|
||||
if "使用邮箱登录" in text or "Continue with email" in text:
|
||||
if _click_exact(page, ["使用邮箱登录", "Continue with email", "Sign in with email"], log, real=False):
|
||||
_sleep(1.5)
|
||||
phase = "email"
|
||||
continue
|
||||
|
||||
# Email only step
|
||||
if page.ele("css:input[type='email']", timeout=0.3) and not page.ele(
|
||||
"css:input[type='password']", timeout=0.2
|
||||
):
|
||||
phase = "email"
|
||||
_fill(page, "css:input[type='email']", email, log, "email")
|
||||
if _click_exact(page, ["下一步", "Next", "Continue", "继续"], log, real=False):
|
||||
_sleep(1.8)
|
||||
continue
|
||||
|
||||
# Password login
|
||||
if page.ele("css:input[type='password']", timeout=0.3):
|
||||
phase = "password"
|
||||
if login_attempts >= 5:
|
||||
_sleep(1.0)
|
||||
continue
|
||||
login_attempts += 1
|
||||
log(f"login attempt {login_attempts}")
|
||||
_fill(page, "css:input[type='email']", email, log, "email")
|
||||
_wait_turnstile(page, log, 25)
|
||||
_fill(page, "css:input[type='password']", password, log, "password")
|
||||
_wait_turnstile(page, log, 12)
|
||||
# REAL click login helps form submit
|
||||
if not _click_exact(page, ["登录", "Sign in", "Log in"], log, real=True):
|
||||
try:
|
||||
el = page.ele("css:button[type='submit']", timeout=0.5) or page.ele(
|
||||
"css:button[data-testid='sign-in-submit']", timeout=0.5
|
||||
)
|
||||
if el:
|
||||
el.click()
|
||||
log("clicked login submit real")
|
||||
except Exception as e:
|
||||
log(f"login submit fail: {e}")
|
||||
# wait navigation
|
||||
for _ in range(24):
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
return
|
||||
_sleep(0.5)
|
||||
if not page.ele("css:input[type='password']", timeout=0.2):
|
||||
break
|
||||
if "sign-in" not in _page_url(page):
|
||||
break
|
||||
continue
|
||||
|
||||
_sleep(1.0)
|
||||
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
log("browser finished via stop_event")
|
||||
return
|
||||
log(f"browser loop ended phase={phase} login_attempts={login_attempts}")
|
||||
|
||||
|
||||
def mint_with_browser(
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
page: Any | None = None,
|
||||
proxy: str | None = None,
|
||||
headless: bool = False,
|
||||
browser_timeout_sec: float = 240.0,
|
||||
poll_log: LogFn | None = None,
|
||||
cancel: Callable[[], bool] | None = None,
|
||||
force_standalone: bool = True,
|
||||
cookies: Any | None = None,
|
||||
reuse_browser: bool = True,
|
||||
recycle_every: int = 15,
|
||||
) -> dict[str, Any]:
|
||||
"""Request device code, approve in browser, poll tokens.
|
||||
|
||||
force_standalone=True (default): do not reuse the *register* tab.
|
||||
Mint workers may still reuse their *own* Chromium via reuse_browser.
|
||||
cookies: optional register-browser cookie list to skip re-login.
|
||||
"""
|
||||
from .oauth_device import OAuthDeviceError, poll_device_token, request_device_code
|
||||
from .proxyutil import proxy_log_label, resolve_proxy, set_runtime_proxy
|
||||
|
||||
log = poll_log or _noop_log
|
||||
own_browser = None
|
||||
owned = False
|
||||
work_page = None if force_standalone else page
|
||||
resolved = resolve_proxy(proxy)
|
||||
set_runtime_proxy(resolved or None)
|
||||
success = False
|
||||
try:
|
||||
last_err: BaseException | None = None
|
||||
sess = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
sess = request_device_code(proxy=resolved or None)
|
||||
last_err = None
|
||||
break
|
||||
except BaseException as e: # noqa: BLE001
|
||||
last_err = e
|
||||
log(f"request_device_code attempt {attempt}/3 failed: {e}")
|
||||
_sleep(1.5 * attempt)
|
||||
if sess is None:
|
||||
raise last_err or RuntimeError("request_device_code failed")
|
||||
log(
|
||||
f"device user_code={sess.user_code} expires_in={sess.expires_in} "
|
||||
f"proxy={proxy_log_label(resolved) or '(none)'}"
|
||||
)
|
||||
|
||||
if work_page is None:
|
||||
own_browser, work_page, owned = acquire_mint_browser(
|
||||
proxy=resolved or None,
|
||||
headless=headless,
|
||||
reuse=reuse_browser,
|
||||
recycle_every=recycle_every,
|
||||
log=log,
|
||||
)
|
||||
if owned:
|
||||
# non-reuse path: track for finally close
|
||||
pass
|
||||
|
||||
# Cookie inject before opening device URL (skip secondary login when possible)
|
||||
if cookies:
|
||||
n = inject_cookies(work_page, cookies, log=log)
|
||||
log(f"cookie inject count={n}")
|
||||
try:
|
||||
work_page.get("https://accounts.x.ai/")
|
||||
_sleep(1.0)
|
||||
url = _page_url(work_page)
|
||||
text = _visible_text(work_page)
|
||||
snip = _norm(text)[:120]
|
||||
log(f"post-inject session url={url[:120]} visible={snip}")
|
||||
except Exception as e:
|
||||
log(f"post-inject check: {e}")
|
||||
|
||||
stop_event = threading.Event()
|
||||
token_box: dict[str, Any] = {}
|
||||
err_box: dict[str, BaseException] = {}
|
||||
|
||||
def _poll() -> None:
|
||||
try:
|
||||
time.sleep(2)
|
||||
tr = poll_device_token(
|
||||
sess.device_code,
|
||||
interval=max(sess.interval, 5),
|
||||
expires_in=min(sess.expires_in, int(browser_timeout_sec) + 60),
|
||||
log=log,
|
||||
cancel=cancel,
|
||||
proxy=resolved or None,
|
||||
)
|
||||
token_box["token"] = tr
|
||||
stop_event.set()
|
||||
log("token poll SUCCESS — stop_event set")
|
||||
except BaseException as e: # noqa: BLE001
|
||||
err_box["err"] = e
|
||||
stop_event.set()
|
||||
|
||||
t = threading.Thread(target=_poll, name="oauth-poll", daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
approve_device_code(
|
||||
work_page,
|
||||
verification_uri_complete=sess.verification_uri_complete,
|
||||
email=email,
|
||||
password=password,
|
||||
user_code=sess.user_code,
|
||||
timeout_sec=browser_timeout_sec,
|
||||
stop_event=stop_event,
|
||||
log=log,
|
||||
)
|
||||
except BrowserConfirmError as e:
|
||||
log(f"browser confirm warning: {e}")
|
||||
|
||||
t.join(timeout=max(browser_timeout_sec, 60) + 30)
|
||||
if "token" in token_box:
|
||||
tr = token_box["token"]
|
||||
success = True
|
||||
return {
|
||||
"access_token": tr.access_token,
|
||||
"refresh_token": tr.refresh_token,
|
||||
"id_token": tr.id_token,
|
||||
"token_type": tr.token_type,
|
||||
"expires_in": tr.expires_in,
|
||||
"user_code": sess.user_code,
|
||||
}
|
||||
if "err" in err_box:
|
||||
raise err_box["err"]
|
||||
raise OAuthDeviceError("token poll thread ended without result")
|
||||
finally:
|
||||
if own_browser is not None:
|
||||
if owned:
|
||||
close_standalone(own_browser)
|
||||
else:
|
||||
release_mint_browser(owned=False, success=success, log=log)
|
||||
@@ -0,0 +1,718 @@
|
||||
"""xAI OAuth Authorization Code + PKCE (SSO cookie → CPA token).
|
||||
|
||||
对齐最新可用流程:authorize / consent 必须带 referrer=grok-build,
|
||||
否则 access_token JWT 缺少 referrer 字段,cli-chat-proxy / grok-build 不可用。
|
||||
|
||||
参考实现:sso -> oauth2/authorize(referrer=grok-build) -> consent page
|
||||
-> POST auth.x.ai/oauth2/authorize (form action=allow)
|
||||
-> oauth2/token (authorization_code + PKCE)
|
||||
|
||||
2026-07-16:accounts.x.ai consent 页 Next.js Server Action 频繁轮换,
|
||||
硬编码 Next-Action 会 404 "Server action not found"。
|
||||
官方 HTML form 的 action 指向 auth.x.ai/oauth2/authorize(非 consent URL),
|
||||
form-urlencoded + action=allow 可稳定拿到 code。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
|
||||
|
||||
from .proxyutil import resolve_proxy
|
||||
|
||||
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
ISSUER = "https://auth.x.ai"
|
||||
TOKEN_URL = f"{ISSUER}/oauth2/token"
|
||||
AUTHORIZE_URL = f"{ISSUER}/oauth2/authorize"
|
||||
# 官方 CLI 0.2.101:loopback redirect,运行时随机端口(RFC 8252 端口无关)
|
||||
# 兼容保留旧固定端口常量,仅作 fallback
|
||||
REDIRECT_URI_LEGACY = "http://127.0.0.1:56121/callback"
|
||||
REDIRECT_URI = REDIRECT_URI_LEGACY
|
||||
# cli-chat-proxy 要求 access_token.scope 含 grok-cli:access,否则:
|
||||
# WKE=unauthorized:grok-cli-token-auth-required
|
||||
# 2026-07 一度误删该 scope(对齐 discovery),导致铸造成功但调用 403。
|
||||
SCOPE = (
|
||||
"openid profile email offline_access "
|
||||
"grok-cli:access api:access conversations:read conversations:write"
|
||||
)
|
||||
GROK_CLI_SCOPE = "grok-cli:access"
|
||||
GROK_REFERRER = "grok-build"
|
||||
# 对齐官方 stable CLI(2026-07-14 二进制:0.2.101)
|
||||
GROK_VERSION = "0.2.101"
|
||||
GROK_TOKEN_UA = f"xai-grok-build/{GROK_VERSION}"
|
||||
GROK_CLIENT_SURFACE = "grok-build"
|
||||
# 旧 Next.js Server Action id(已失效,仅作 fallback 尝试)
|
||||
NEXT_ACTION_ID = "4005315a1d7e426de592990bb54bb37471f39dd6d2"
|
||||
BROWSER_UA = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
def _noop_log(_: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class OAuthCodeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthCodeFlow:
|
||||
state: str
|
||||
nonce: str
|
||||
code_verifier: str
|
||||
code_challenge: str
|
||||
redirect_uri: str = REDIRECT_URI_LEGACY
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenResult:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
id_token: str | None
|
||||
token_type: str
|
||||
expires_in: int
|
||||
raw: dict[str, Any]
|
||||
referrer: str = ""
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _new_loopback_redirect_uri() -> str:
|
||||
"""官方 CLI:http://127.0.0.1:<ephemeral>/callback。"""
|
||||
# 高位端口,避免与常见本机服务冲突
|
||||
port = 49152 + secrets.randbelow(16383)
|
||||
return f"http://127.0.0.1:{port}/callback"
|
||||
|
||||
|
||||
def new_auth_code_flow() -> AuthCodeFlow:
|
||||
verifier = _b64url(secrets.token_bytes(32))
|
||||
state = _b64url(secrets.token_bytes(16))
|
||||
nonce = _b64url(secrets.token_bytes(16))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return AuthCodeFlow(
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_verifier=verifier,
|
||||
code_challenge=challenge,
|
||||
redirect_uri=_new_loopback_redirect_uri(),
|
||||
)
|
||||
|
||||
|
||||
def normalize_sso_cookie(raw: str) -> str:
|
||||
token = (raw or "").strip()
|
||||
if token.lower().startswith("sso="):
|
||||
token = token[4:].strip()
|
||||
return token
|
||||
|
||||
|
||||
def jwt_payload(token: str) -> dict[str, Any]:
|
||||
parts = (token or "").split(".")
|
||||
if len(parts) < 2:
|
||||
raise ValueError("invalid JWT")
|
||||
seg = parts[1]
|
||||
seg += "=" * (-len(seg) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(seg.encode("ascii")))
|
||||
|
||||
|
||||
def _short(value: str, limit: int = 240) -> str:
|
||||
value = value or ""
|
||||
return value if len(value) <= limit else value[:limit]
|
||||
|
||||
|
||||
def _is_curl_tls_broken(exc: BaseException | str) -> bool:
|
||||
"""识别 curl_cffi / libcurl OpenSSL 损坏类错误(常见于部分 Windows 环境)。"""
|
||||
text = str(exc or "").lower()
|
||||
needles = (
|
||||
"openssl_internal:invalid library",
|
||||
"tls connect error",
|
||||
"curl: (35)",
|
||||
"failed to perform, curl: (35)",
|
||||
"ssl_error_syscall",
|
||||
"ssl connect error",
|
||||
"wrong version number",
|
||||
)
|
||||
return any(n in text for n in needles)
|
||||
|
||||
|
||||
def is_cloudflare_block(
|
||||
status: int | None = None,
|
||||
body: str = "",
|
||||
exc: BaseException | str | None = None,
|
||||
) -> bool:
|
||||
"""识别 auth.x.ai 被 Cloudflare 拦(常见直连 403 挑战页)。"""
|
||||
text = f"{body or ''} {exc or ''}".lower()
|
||||
if status == 403 and (
|
||||
"<!doctype html" in text
|
||||
or "cloudflare" in text
|
||||
or "cf-ray" in text
|
||||
or "attention required" in text
|
||||
or "just a moment" in text
|
||||
or "enable javascript" in text
|
||||
or "oldie" in text
|
||||
):
|
||||
return True
|
||||
if "authorize http 403" in text and (
|
||||
"<!doctype" in text or "oldie" in text or "cloudflare" in text
|
||||
):
|
||||
return True
|
||||
return "cloudflare" in text and ("403" in text or "blocked" in text)
|
||||
|
||||
|
||||
def _cf_block_hint(proxy_label: str = "") -> str:
|
||||
via = proxy_label or "(direct)"
|
||||
return (
|
||||
f"Cloudflare 拦截 auth.x.ai(出口={via})。"
|
||||
"直连大陆/机房 IP 几乎必 403;请配置 mint_proxy / proxy,"
|
||||
"或开启 proxy_pool_enabled 并保证代理能访问 auth.x.ai。"
|
||||
)
|
||||
|
||||
|
||||
def _make_std_session(proxy: str | None = None):
|
||||
try:
|
||||
import requests as std_requests
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise OAuthCodeError(
|
||||
"需要 curl_cffi 或 requests 才能执行 SSO→OAuth 转换"
|
||||
) from e
|
||||
resolved = resolve_proxy(proxy)
|
||||
proxies = {"http": resolved, "https": resolved} if resolved else None
|
||||
sess = std_requests.Session()
|
||||
if proxies:
|
||||
sess.proxies.update(proxies)
|
||||
try:
|
||||
sess._cpa_http_backend = "requests" # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
return sess
|
||||
|
||||
|
||||
def _make_curl_session(proxy: str | None = None):
|
||||
from curl_cffi import requests as crequests
|
||||
|
||||
resolved = resolve_proxy(proxy)
|
||||
proxies = {"http": resolved, "https": resolved} if resolved else None
|
||||
last_err: Exception | None = None
|
||||
for impersonate in ("chrome131", "chrome124", "chrome120", "chrome110", None):
|
||||
try:
|
||||
if impersonate:
|
||||
sess = crequests.Session(impersonate=impersonate, proxies=proxies)
|
||||
else:
|
||||
sess = crequests.Session(proxies=proxies)
|
||||
try:
|
||||
sess._cpa_http_backend = f"curl_cffi:{impersonate or 'default'}" # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
return sess
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = exc
|
||||
continue
|
||||
if last_err:
|
||||
raise last_err
|
||||
raise OAuthCodeError("curl_cffi Session 创建失败")
|
||||
|
||||
|
||||
def _make_session(proxy: str | None = None, *, prefer: str = "curl"):
|
||||
"""优先 curl_cffi(Chrome TLS);prefer=requests 时直接标准库。"""
|
||||
prefer = (prefer or "curl").strip().lower()
|
||||
errors: list[str] = []
|
||||
if prefer != "requests":
|
||||
try:
|
||||
return _make_curl_session(proxy)
|
||||
except ImportError:
|
||||
errors.append("curl_cffi 未安装")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"curl_cffi: {exc}")
|
||||
try:
|
||||
return _make_std_session(proxy)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"requests: {exc}")
|
||||
raise OAuthCodeError(
|
||||
"无法创建 HTTP Session: " + " | ".join(errors)
|
||||
) from exc
|
||||
|
||||
|
||||
def _set_sso_cookies(session: Any, sso: str) -> None:
|
||||
sso = normalize_sso_cookie(sso)
|
||||
if not sso:
|
||||
raise OAuthCodeError("sso cookie 为空")
|
||||
# curl_cffi / requests cookie jar
|
||||
for domain in ("accounts.x.ai", "auth.x.ai", ".x.ai"):
|
||||
for name in ("sso", "sso-rw"):
|
||||
try:
|
||||
session.cookies.set(name, sso, domain=domain, path="/")
|
||||
except Exception:
|
||||
try:
|
||||
session.cookies.set(name, sso)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _browser_headers(method: str, url: str, next_action: str = "") -> dict[str, str]:
|
||||
headers = {
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Sec-CH-UA": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"',
|
||||
"Sec-CH-UA-Mobile": "?0",
|
||||
"Sec-CH-UA-Platform": '"Linux"',
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
if method.upper() == "POST":
|
||||
headers.update(
|
||||
{
|
||||
"Accept": "text/x-component",
|
||||
"Content-Type": "text/plain;charset=UTF-8",
|
||||
"Origin": "https://accounts.x.ai",
|
||||
"Referer": url,
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
}
|
||||
)
|
||||
if next_action:
|
||||
headers["Next-Action"] = next_action
|
||||
else:
|
||||
headers.update(
|
||||
{
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"application/json;q=0.8,*/*;q=0.7"
|
||||
),
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
}
|
||||
)
|
||||
return headers
|
||||
|
||||
|
||||
def _token_headers() -> dict[str, str]:
|
||||
# 对齐官方 0.2.101:小写 x-grok-* + surface=grok-build
|
||||
return {
|
||||
"User-Agent": GROK_TOKEN_UA,
|
||||
"Accept": "*/*",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"x-grok-client-version": GROK_VERSION,
|
||||
"x-grok-client-surface": GROK_CLIENT_SURFACE,
|
||||
# 兼容旧中间件/代理仍读 Pascal 头
|
||||
"X-Grok-Client-Version": GROK_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def _final_url(resp: Any) -> str:
|
||||
try:
|
||||
return str(getattr(resp, "url", "") or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def open_authorize_page(session: Any, flow: AuthCodeFlow) -> str:
|
||||
redirect_uri = flow.redirect_uri or _new_loopback_redirect_uri()
|
||||
flow.redirect_uri = redirect_uri
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": SCOPE,
|
||||
"code_challenge": flow.code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": flow.state,
|
||||
"nonce": flow.nonce,
|
||||
"referrer": GROK_REFERRER,
|
||||
}
|
||||
url = f"{AUTHORIZE_URL}?{urlencode(params)}"
|
||||
resp = session.get(
|
||||
url,
|
||||
headers=_browser_headers("GET", url),
|
||||
allow_redirects=True,
|
||||
timeout=30,
|
||||
)
|
||||
body = resp.text or ""
|
||||
final = _final_url(resp)
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
if is_cloudflare_block(resp.status_code, body):
|
||||
raise OAuthCodeError(
|
||||
f"authorize HTTP {resp.status_code}: Cloudflare 拦截 — {_short(body, 80)}"
|
||||
)
|
||||
raise OAuthCodeError(
|
||||
f"authorize HTTP {resp.status_code}: {_short(body)}"
|
||||
)
|
||||
if "sign-in" in final or "sign-up" in final:
|
||||
raise OAuthCodeError("sso 无效(authorize 跳转登录页)")
|
||||
if "/oauth2/consent" not in final:
|
||||
# 少数情况 consent 在 body 的 redirect 里
|
||||
m = re.search(r'https?://[^"\']+/oauth2/consent[^"\']*', body)
|
||||
if m:
|
||||
final = m.group(0)
|
||||
else:
|
||||
raise OAuthCodeError(f"authorize 未进入 consent: {final or _short(body)}")
|
||||
return final
|
||||
|
||||
|
||||
def parse_consent_code(body: str) -> str:
|
||||
"""从 Next.js RSC / text-x-component 响应中解析 code。"""
|
||||
text = body or ""
|
||||
# 1) 逐行 JSON(Go 实现路径)
|
||||
for line in text.splitlines():
|
||||
idx = line.find("{")
|
||||
if idx < 0:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line[idx:])
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(obj, dict) and obj.get("code"):
|
||||
if obj.get("success") is False:
|
||||
raise OAuthCodeError(
|
||||
f"consent 失败: {obj.get('error') or obj.get('action')}"
|
||||
)
|
||||
return str(obj["code"]).strip()
|
||||
if isinstance(obj, list):
|
||||
for item in obj:
|
||||
if isinstance(item, dict) and item.get("code"):
|
||||
return str(item["code"]).strip()
|
||||
|
||||
# 2) 宽松正则
|
||||
m = re.search(r'"code"\s*:\s*"([A-Za-z0-9._~\-]+)"', text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
|
||||
# 3) redirect 里带 code=
|
||||
m = re.search(r"[?&]code=([A-Za-z0-9._~\-]+)", text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
|
||||
raise OAuthCodeError(f"consent 响应缺少 code: {_short(text, 300)}")
|
||||
|
||||
|
||||
def _code_from_location(url: str) -> str:
|
||||
if not url or "code=" not in url:
|
||||
return ""
|
||||
qs = parse_qs(urlparse(url).query)
|
||||
return str((qs.get("code") or [""])[0] or "").strip()
|
||||
|
||||
|
||||
def _approve_via_form_post(
|
||||
session: Any, consent_url: str, flow: AuthCodeFlow
|
||||
) -> str:
|
||||
"""POST form to auth.x.ai/oauth2/authorize (matches consent page HTML)."""
|
||||
redirect_uri = flow.redirect_uri or REDIRECT_URI_LEGACY
|
||||
fields = {
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": SCOPE,
|
||||
"state": flow.state,
|
||||
"code_challenge": flow.code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"nonce": flow.nonce,
|
||||
"principal_type": "User",
|
||||
"principal_id": "",
|
||||
"referrer": GROK_REFERRER,
|
||||
"action": "allow",
|
||||
}
|
||||
headers = {
|
||||
"User-Agent": BROWSER_UA,
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"*/*;q=0.8"
|
||||
),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Origin": "https://accounts.x.ai",
|
||||
"Referer": consent_url,
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
# 不自动 follow 到 127.0.0.1 loopback(本机无 listener)
|
||||
resp = session.post(
|
||||
AUTHORIZE_URL,
|
||||
data=urlencode(fields),
|
||||
headers=headers,
|
||||
allow_redirects=False,
|
||||
timeout=30,
|
||||
)
|
||||
loc = (
|
||||
resp.headers.get("Location")
|
||||
or resp.headers.get("location")
|
||||
or ""
|
||||
)
|
||||
code = _code_from_location(loc)
|
||||
if code:
|
||||
return code
|
||||
|
||||
# 少数库会吞 Location 到 resp.url / 已 follow
|
||||
final = _final_url(resp)
|
||||
code = _code_from_location(final)
|
||||
if code:
|
||||
return code
|
||||
|
||||
text = resp.text or ""
|
||||
if 200 <= resp.status_code < 300:
|
||||
try:
|
||||
return parse_consent_code(text)
|
||||
except OAuthCodeError:
|
||||
pass
|
||||
raise OAuthCodeError(
|
||||
f"consent form POST HTTP {resp.status_code}: "
|
||||
f"loc={_short(loc, 120)} body={_short(text, 200)}"
|
||||
)
|
||||
|
||||
|
||||
def _approve_via_next_action(
|
||||
session: Any, consent_url: str, flow: AuthCodeFlow
|
||||
) -> str:
|
||||
"""旧路径:Next.js Server Action POST consent URL(action id 常失效)。"""
|
||||
redirect_uri = flow.redirect_uri or REDIRECT_URI_LEGACY
|
||||
payload = [
|
||||
{
|
||||
"action": "allow",
|
||||
"clientId": CLIENT_ID,
|
||||
"redirectUri": redirect_uri,
|
||||
"scope": SCOPE,
|
||||
"state": flow.state,
|
||||
"codeChallenge": flow.code_challenge,
|
||||
"codeChallengeMethod": "S256",
|
||||
"nonce": flow.nonce,
|
||||
"principalType": "User",
|
||||
"principalId": "",
|
||||
"referrer": GROK_REFERRER,
|
||||
}
|
||||
]
|
||||
body = json.dumps(payload, separators=(",", ":"))
|
||||
resp = session.post(
|
||||
consent_url,
|
||||
data=body.encode("utf-8"),
|
||||
headers=_browser_headers("POST", consent_url, NEXT_ACTION_ID),
|
||||
allow_redirects=False,
|
||||
timeout=30,
|
||||
)
|
||||
text = resp.text or ""
|
||||
loc = (
|
||||
resp.headers.get("Location")
|
||||
or resp.headers.get("location")
|
||||
or ""
|
||||
)
|
||||
code = _code_from_location(loc) or _code_from_location(_final_url(resp))
|
||||
if code:
|
||||
return code
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
raise OAuthCodeError(
|
||||
f"consent Next-Action HTTP {resp.status_code}: {_short(text, 300)}"
|
||||
)
|
||||
return parse_consent_code(text)
|
||||
|
||||
|
||||
def approve_authorization(session: Any, consent_url: str, flow: AuthCodeFlow) -> str:
|
||||
"""Approve consent and return authorization code.
|
||||
|
||||
优先:HTML form POST → https://auth.x.ai/oauth2/authorize
|
||||
回退:旧 Next-Action POST consent URL(易 404)。
|
||||
"""
|
||||
try:
|
||||
return _approve_via_form_post(session, consent_url, flow)
|
||||
except OAuthCodeError as form_exc:
|
||||
try:
|
||||
return _approve_via_next_action(session, consent_url, flow)
|
||||
except OAuthCodeError as next_exc:
|
||||
raise OAuthCodeError(
|
||||
f"consent allow failed: form={form_exc}; next_action={next_exc}"
|
||||
) from next_exc
|
||||
|
||||
|
||||
def exchange_auth_code(session: Any, code: str, flow: AuthCodeFlow) -> TokenResult:
|
||||
redirect_uri = flow.redirect_uri or REDIRECT_URI_LEGACY
|
||||
form = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": CLIENT_ID,
|
||||
"code_verifier": flow.code_verifier,
|
||||
}
|
||||
resp = session.post(
|
||||
TOKEN_URL,
|
||||
data=urlencode(form),
|
||||
headers=_token_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
text = resp.text or ""
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
raise OAuthCodeError(f"token HTTP {resp.status_code}: {_short(text, 300)}")
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
raise OAuthCodeError(f"token 响应非 JSON: {_short(text)}") from e
|
||||
if not isinstance(data, dict) or not data.get("access_token"):
|
||||
raise OAuthCodeError(f"token 响应缺少 access_token: {data!r}")
|
||||
|
||||
access = str(data["access_token"]).strip()
|
||||
refresh = str(data.get("refresh_token") or "").strip()
|
||||
if not refresh:
|
||||
raise OAuthCodeError("token 响应缺少 refresh_token")
|
||||
|
||||
referrer = ""
|
||||
try:
|
||||
pl = jwt_payload(access)
|
||||
referrer = str(pl.get("referrer") or "")
|
||||
except Exception:
|
||||
pl = {}
|
||||
|
||||
expires_in = int(data.get("expires_in") or 21600)
|
||||
return TokenResult(
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
id_token=(str(data["id_token"]).strip() if data.get("id_token") else None),
|
||||
token_type=str(data.get("token_type") or "Bearer"),
|
||||
expires_in=expires_in,
|
||||
raw=data,
|
||||
referrer=referrer,
|
||||
)
|
||||
|
||||
|
||||
def _run_sso_flow(
|
||||
sso: str,
|
||||
*,
|
||||
session: Any,
|
||||
log: LogFn,
|
||||
require_referrer: bool,
|
||||
) -> TokenResult:
|
||||
flow = new_auth_code_flow()
|
||||
backend = getattr(session, "_cpa_http_backend", "unknown")
|
||||
log(
|
||||
f"Authorization Code Flow ver={GROK_VERSION} ua={GROK_TOKEN_UA} "
|
||||
f"referrer={GROK_REFERRER} redirect={flow.redirect_uri} http={backend}"
|
||||
)
|
||||
_set_sso_cookies(session, sso)
|
||||
consent_url = open_authorize_page(session, flow)
|
||||
log(f"authorize -> consent: {_short(consent_url, 120)}")
|
||||
code = approve_authorization(session, consent_url, flow)
|
||||
log("consent allow ok")
|
||||
token = exchange_auth_code(session, code, flow)
|
||||
if token.referrer != GROK_REFERRER:
|
||||
msg = f"access_token 未包含预期 referrer(got={token.referrer!r})"
|
||||
if require_referrer:
|
||||
raise OAuthCodeError(msg)
|
||||
log(f"WARN {msg}")
|
||||
else:
|
||||
log("access_token referrer=grok-build ok")
|
||||
|
||||
# cli-chat-proxy 的 Grok CLI gate:缺 grok-cli:access 会直接 403
|
||||
scope_text = ""
|
||||
try:
|
||||
scope_text = str(jwt_payload(token.access_token).get("scope") or "")
|
||||
except Exception:
|
||||
scope_text = ""
|
||||
scopes = set(scope_text.split())
|
||||
if GROK_CLI_SCOPE not in scopes:
|
||||
msg = (
|
||||
f"access_token 缺少 {GROK_CLI_SCOPE} "
|
||||
f"(scope={scope_text or '(empty)'});"
|
||||
"cli-chat-proxy 会返回 grok-cli-token-auth-required"
|
||||
)
|
||||
if require_referrer:
|
||||
raise OAuthCodeError(msg)
|
||||
log(f"WARN {msg}")
|
||||
else:
|
||||
log(f"access_token scope 含 {GROK_CLI_SCOPE} ok")
|
||||
|
||||
log(f"token ok expires_in={token.expires_in} refresh=yes")
|
||||
return token
|
||||
|
||||
|
||||
def sso_to_token(
|
||||
sso_cookie: str,
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
log: LogFn | None = None,
|
||||
require_referrer: bool = True,
|
||||
) -> TokenResult:
|
||||
"""SSO cookie → 带 referrer=grok-build 的 OAuth token。"""
|
||||
from .proxyutil import proxy_log_label, resolve_proxy
|
||||
|
||||
log = log or _noop_log
|
||||
sso = normalize_sso_cookie(sso_cookie)
|
||||
if not sso:
|
||||
raise OAuthCodeError("sso cookie 为空")
|
||||
|
||||
resolved = resolve_proxy(proxy)
|
||||
log(f"mint 出口={proxy_log_label(resolved) or '(direct)'}")
|
||||
|
||||
# 先 curl_cffi;若遇到 OpenSSL invalid library / curl(35),自动回退 std requests
|
||||
session = _make_session(proxy, prefer="curl")
|
||||
try:
|
||||
return _run_sso_flow(
|
||||
sso, session=session, log=log, require_referrer=require_referrer
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
backend = str(getattr(session, "_cpa_http_backend", "") or "")
|
||||
can_fallback = _is_curl_tls_broken(exc) or (
|
||||
backend.startswith("curl_cffi") and "curl: (35)" in str(exc).lower()
|
||||
)
|
||||
if not can_fallback:
|
||||
if is_cloudflare_block(exc=exc):
|
||||
raise OAuthCodeError(_cf_block_hint(proxy_log_label(resolved))) from exc
|
||||
raise
|
||||
log(f"curl TLS 异常,回退标准 requests: {_short(str(exc), 160)}")
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
pass
|
||||
session = _make_session(proxy, prefer="requests")
|
||||
try:
|
||||
return _run_sso_flow(
|
||||
sso, session=session, log=log, require_referrer=require_referrer
|
||||
)
|
||||
except Exception as exc2: # noqa: BLE001
|
||||
if is_cloudflare_block(exc=exc2):
|
||||
raise OAuthCodeError(_cf_block_hint(proxy_log_label(resolved))) from exc2
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
pass
|
||||
session = None # type: ignore[assignment]
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def mint_from_sso(
|
||||
sso_cookie: str,
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
log: LogFn | None = None,
|
||||
require_referrer: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""上层统一返回 dict,兼容 cpa_export。"""
|
||||
tr = sso_to_token(
|
||||
sso_cookie,
|
||||
proxy=proxy,
|
||||
log=log,
|
||||
require_referrer=require_referrer,
|
||||
)
|
||||
return {
|
||||
"access_token": tr.access_token,
|
||||
"refresh_token": tr.refresh_token,
|
||||
"id_token": tr.id_token,
|
||||
"token_type": tr.token_type,
|
||||
"expires_in": tr.expires_in,
|
||||
"referrer": tr.referrer,
|
||||
"sso": normalize_sso_cookie(sso_cookie),
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"""xAI OAuth device-code grant (Grok CLI).
|
||||
|
||||
Endpoints from https://auth.x.ai/.well-known/openid-configuration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from .proxyutil import resolve_proxy
|
||||
|
||||
# xAI OAuth client id (matches CLIProxyAPI default)
|
||||
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
ISSUER = "https://auth.x.ai"
|
||||
DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code"
|
||||
TOKEN_URL = "https://auth.x.ai/oauth2/token"
|
||||
# cli-chat-proxy 要求 token scope 含 grok-cli:access(否则 grok-cli-token-auth-required)
|
||||
SCOPE = (
|
||||
"openid profile email offline_access "
|
||||
"grok-cli:access api:access conversations:read conversations:write"
|
||||
)
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
def _noop_log(_: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _proxy_handler(proxy: str | None = None) -> urllib.request.ProxyHandler | None:
|
||||
p = resolve_proxy(proxy)
|
||||
if not p:
|
||||
return None
|
||||
return urllib.request.ProxyHandler({"http": p, "https": p})
|
||||
|
||||
|
||||
def _opener(proxy: str | None = None) -> urllib.request.OpenerDirector:
|
||||
handlers: list[Any] = []
|
||||
ph = _proxy_handler(proxy)
|
||||
if ph is not None:
|
||||
handlers.append(ph)
|
||||
return urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener()
|
||||
|
||||
|
||||
def _post_form(
|
||||
url: str,
|
||||
form: dict[str, str],
|
||||
timeout: float = 30.0,
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
) -> tuple[int, dict[str, Any] | str]:
|
||||
data = urllib.parse.urlencode(form).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "grok-reg-oidc-minter/1.0",
|
||||
},
|
||||
)
|
||||
opener = _opener(proxy)
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
status = getattr(resp, "status", 200) or 200
|
||||
try:
|
||||
return int(status), json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return int(status), body
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return int(e.code), json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return int(e.code), body
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceCodeSession:
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenResult:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
id_token: str | None
|
||||
token_type: str
|
||||
expires_in: int
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
class OAuthDeviceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def request_device_code(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
scope: str = SCOPE,
|
||||
timeout: float = 30.0,
|
||||
proxy: str | None = None,
|
||||
) -> DeviceCodeSession:
|
||||
status, body = _post_form(
|
||||
DEVICE_CODE_URL,
|
||||
{"client_id": client_id, "scope": scope},
|
||||
timeout=timeout,
|
||||
proxy=proxy,
|
||||
)
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
raise OAuthDeviceError(f"device code request failed HTTP {status}: {body!r}")
|
||||
device_code = str(body.get("device_code") or "").strip()
|
||||
user_code = str(body.get("user_code") or "").strip()
|
||||
if not device_code or not user_code:
|
||||
raise OAuthDeviceError(f"device code response missing fields: {body}")
|
||||
vuri = str(body.get("verification_uri") or "https://accounts.x.ai/oauth2/device").strip()
|
||||
vcomplete = str(
|
||||
body.get("verification_uri_complete") or f"{vuri}?user_code={user_code}"
|
||||
).strip()
|
||||
expires_in = int(body.get("expires_in") or 1800)
|
||||
interval = max(int(body.get("interval") or 5), 1)
|
||||
return DeviceCodeSession(
|
||||
device_code=device_code,
|
||||
user_code=user_code,
|
||||
verification_uri=vuri,
|
||||
verification_uri_complete=vcomplete,
|
||||
expires_in=expires_in,
|
||||
interval=interval,
|
||||
raw=body,
|
||||
)
|
||||
|
||||
|
||||
def poll_device_token(
|
||||
device_code: str,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
interval: int = 5,
|
||||
expires_in: int = 1800,
|
||||
timeout: float = 30.0,
|
||||
log: LogFn | None = None,
|
||||
cancel: Callable[[], bool] | None = None,
|
||||
proxy: str | None = None,
|
||||
) -> TokenResult:
|
||||
"""Poll token endpoint until authorized or expired."""
|
||||
log = log or _noop_log
|
||||
deadline = time.time() + max(expires_in - 5, 30)
|
||||
sleep_for = max(interval, 1)
|
||||
while time.time() < deadline:
|
||||
if cancel and cancel():
|
||||
raise OAuthDeviceError("cancelled")
|
||||
status, body = _post_form(
|
||||
TOKEN_URL,
|
||||
{
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"device_code": device_code,
|
||||
"client_id": client_id,
|
||||
},
|
||||
timeout=timeout,
|
||||
proxy=proxy,
|
||||
)
|
||||
if status == 200 and isinstance(body, dict) and body.get("access_token"):
|
||||
access = str(body["access_token"]).strip()
|
||||
refresh = str(body.get("refresh_token") or "").strip()
|
||||
if not refresh:
|
||||
raise OAuthDeviceError("token response missing refresh_token")
|
||||
return TokenResult(
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
id_token=(str(body["id_token"]).strip() if body.get("id_token") else None),
|
||||
token_type=str(body.get("token_type") or "Bearer"),
|
||||
expires_in=int(body.get("expires_in") or 21600),
|
||||
raw=body,
|
||||
)
|
||||
err = ""
|
||||
desc = ""
|
||||
if isinstance(body, dict):
|
||||
err = str(body.get("error") or "")
|
||||
desc = str(body.get("error_description") or "")
|
||||
if err in ("authorization_pending", "slow_down"):
|
||||
if err == "slow_down":
|
||||
sleep_for = min(sleep_for + 5, 30)
|
||||
log(f"oauth poll: {err} (sleep {sleep_for}s)")
|
||||
time.sleep(sleep_for)
|
||||
continue
|
||||
if err in ("expired_token", "access_denied"):
|
||||
raise OAuthDeviceError(f"device auth failed: {err}: {desc}")
|
||||
if status == 400 and err:
|
||||
raise OAuthDeviceError(f"device auth token error: {err}: {desc or body}")
|
||||
log(f"oauth poll unexpected HTTP {status}: {body!r}")
|
||||
time.sleep(sleep_for)
|
||||
raise OAuthDeviceError("device auth timed out waiting for user approval")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Resolve outbound proxy for OIDC mint HTTP + browser.
|
||||
|
||||
Priority (highest first):
|
||||
1. explicit argument
|
||||
2. thread-local runtime pin (set_runtime_proxy)
|
||||
3. environment https_proxy / HTTPS_PROXY / http_proxy / HTTP_PROXY
|
||||
|
||||
Thread-local pin avoids cross-talk when multiple mint workers run with
|
||||
different proxies in the same process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_thread = threading.local()
|
||||
|
||||
|
||||
def set_runtime_proxy(proxy: str | None) -> None:
|
||||
"""Pin proxy for the *current thread*. Empty clears pin."""
|
||||
p = (proxy or "").strip()
|
||||
_thread.proxy = p or None
|
||||
|
||||
|
||||
def get_runtime_proxy() -> str | None:
|
||||
return getattr(_thread, "proxy", None)
|
||||
|
||||
|
||||
def resolve_proxy(explicit: str | None = None) -> str:
|
||||
for cand in (
|
||||
(explicit or "").strip(),
|
||||
(get_runtime_proxy() or "").strip(),
|
||||
(os.environ.get("https_proxy") or "").strip(),
|
||||
(os.environ.get("HTTPS_PROXY") or "").strip(),
|
||||
(os.environ.get("http_proxy") or "").strip(),
|
||||
(os.environ.get("HTTP_PROXY") or "").strip(),
|
||||
):
|
||||
if cand:
|
||||
return cand
|
||||
return ""
|
||||
|
||||
|
||||
def proxy_for_chromium(proxy: str) -> str:
|
||||
"""Chromium --proxy-server cannot embed user:pass; host:port only."""
|
||||
p = (proxy or "").strip()
|
||||
if not p:
|
||||
return ""
|
||||
u = urlparse(p if "://" in p else f"http://{p}")
|
||||
host = u.hostname or ""
|
||||
if not host:
|
||||
return ""
|
||||
port = u.port or (443 if (u.scheme or "http") == "https" else 80)
|
||||
scheme = u.scheme or "http"
|
||||
return f"{scheme}://{host}:{port}"
|
||||
|
||||
|
||||
def proxy_log_label(proxy: str) -> str:
|
||||
"""Redact userinfo for logs."""
|
||||
p = (proxy or "").strip()
|
||||
if not p:
|
||||
return ""
|
||||
try:
|
||||
u = urlparse(p if "://" in p else f"http://{p}")
|
||||
host = u.hostname or "?"
|
||||
port = u.port or ""
|
||||
auth = "user:***@" if u.username else ""
|
||||
return f"{u.scheme or 'http'}://{auth}{host}{(':' + str(port)) if port else ''}"
|
||||
except Exception:
|
||||
return "(proxy)"
|
||||
@@ -0,0 +1,2 @@
|
||||
# auto-exported by proxy_manager — only accounts.x.ai-validated proxies
|
||||
# updated 2026-07-12 23:14:13
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": 1,
|
||||
"proxies": {},
|
||||
"sources": {
|
||||
"proxyscrape_http": {
|
||||
"enabled": true,
|
||||
"last_ok": 1783869240.43135,
|
||||
"last_run": 1783869253.1493964,
|
||||
"fail_streak": 1,
|
||||
"ok_runs": 1,
|
||||
"last_harvested": 40,
|
||||
"last_kept": 0,
|
||||
"last_fail_reason": "harvested but 0 passed accounts.x.ai",
|
||||
"fail_runs": 1
|
||||
},
|
||||
"monosans_http": {
|
||||
"enabled": true,
|
||||
"last_ok": 1783869241.4878805,
|
||||
"last_run": 1783869253.15142,
|
||||
"fail_streak": 1,
|
||||
"ok_runs": 1,
|
||||
"last_harvested": 40,
|
||||
"last_kept": 0,
|
||||
"last_fail_reason": "harvested but 0 passed accounts.x.ai",
|
||||
"fail_runs": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""代理生命周期管理:
|
||||
|
||||
1. 加权拉取公开源
|
||||
2. 测通 https://accounts.x.ai/ 才入库
|
||||
3. 库存定期巡检,失败删除
|
||||
4. 使用中失败 / 打不开注册页 / 被 x.ai 屏蔽 → 立刻删除
|
||||
5. 源连续无产出或拉失败 → 降权/禁用
|
||||
|
||||
CLI:
|
||||
python proxy_manager.py harvest
|
||||
python proxy_manager.py check
|
||||
python proxy_manager.py loop
|
||||
python proxy_manager.py status
|
||||
python proxy_manager.py pick
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from proxy_sources import DEFAULT_SOURCES, harvest # noqa: E402
|
||||
from proxy_store import ProxyStore, normalize_proxy, proxy_log_label # noqa: E402
|
||||
|
||||
try:
|
||||
from curl_cffi import requests as _req
|
||||
except Exception: # pragma: no cover
|
||||
import requests as _req # type: ignore
|
||||
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
XAI_PROBE_URL = "https://accounts.x.ai/sign-up?redirect=grok-com"
|
||||
XAI_HOST = "accounts.x.ai"
|
||||
|
||||
# 命中这些说明代理通了 x.ai 注册页(或至少没被墙/封到无法用)
|
||||
_OK_MARKERS = (
|
||||
"x.ai",
|
||||
"sign-up",
|
||||
"signup",
|
||||
"sign up",
|
||||
"email",
|
||||
"cloudflare",
|
||||
"turnstile",
|
||||
"cf-turnstile",
|
||||
"使用邮箱",
|
||||
"create",
|
||||
"accounts.x.ai",
|
||||
)
|
||||
# 明确坏标记
|
||||
_BAD_MARKERS = (
|
||||
"blocked due to abusive traffic",
|
||||
"access denied",
|
||||
"attention required",
|
||||
"just a moment", # 纯 CF 墙页且无后续时也算可疑,但需配合 status
|
||||
"error code: 5",
|
||||
)
|
||||
|
||||
|
||||
def _log_print(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
p = _ROOT / "config.json"
|
||||
if not p.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def probe_accounts_xai(
|
||||
proxy: str,
|
||||
*,
|
||||
timeout: float = 12.0,
|
||||
) -> tuple[bool, str, float]:
|
||||
"""测代理是否能访问 accounts.x.ai。
|
||||
|
||||
返回 (ok, detail, latency_ms)
|
||||
"""
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return False, "empty", 0.0
|
||||
t0 = time.time()
|
||||
try:
|
||||
r = _req.get(
|
||||
XAI_PROBE_URL,
|
||||
proxies={"http": p, "https": p},
|
||||
timeout=timeout,
|
||||
impersonate="chrome120",
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/138.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
lat = (time.time() - t0) * 1000
|
||||
status = int(getattr(r, "status_code", 0) or 0)
|
||||
text = (r.text or "")[:8000]
|
||||
low = text.lower()
|
||||
final_url = str(getattr(r, "url", "") or "")
|
||||
|
||||
if status in (403, 429, 503):
|
||||
if any(m in low for m in ("abusive traffic", "access denied", "blocked")):
|
||||
return False, f"blocked status={status}", lat
|
||||
if status >= 500:
|
||||
return False, f"status={status}", lat
|
||||
if status == 0:
|
||||
return False, "no status", lat
|
||||
|
||||
# 明确坏
|
||||
for m in _BAD_MARKERS:
|
||||
if m in low and "sign" not in low and "email" not in low:
|
||||
# CF interstitial 单独处理:有时还能过,不算立刻死
|
||||
if m == "just a moment":
|
||||
break
|
||||
return False, f"bad marker:{m}", lat
|
||||
|
||||
# 成功条件:200/403 但页面像 x.ai 注册/登录相关
|
||||
if status in (200, 301, 302, 303, 307, 308, 403, 401):
|
||||
if XAI_HOST in final_url or any(m in low for m in _OK_MARKERS):
|
||||
# 再加一刀:能拿到 HTML 长度
|
||||
if len(text) < 80 and status != 200:
|
||||
return False, f"short body status={status}", lat
|
||||
return True, f"ok status={status} len={len(text)}", lat
|
||||
return False, f"status={status} no markers", lat
|
||||
except Exception as exc:
|
||||
lat = (time.time() - t0) * 1000
|
||||
return False, f"{type(exc).__name__}: {exc}"[:160], lat
|
||||
|
||||
|
||||
def probe_batch(
|
||||
proxies: list[str],
|
||||
*,
|
||||
workers: int = 40,
|
||||
timeout: float = 12.0,
|
||||
log: LogFn | None = None,
|
||||
) -> list[tuple[str, bool, str, float]]:
|
||||
log = log or _log_print
|
||||
results: list[tuple[str, bool, str, float]] = []
|
||||
total = len(proxies)
|
||||
done = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def one(p: str):
|
||||
ok, detail, lat = probe_accounts_xai(p, timeout=timeout)
|
||||
return p, ok, detail, lat
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
|
||||
futs = [ex.submit(one, p) for p in proxies]
|
||||
for fu in as_completed(futs):
|
||||
item = fu.result()
|
||||
results.append(item)
|
||||
with lock:
|
||||
done += 1
|
||||
if done % 25 == 0 or done == total:
|
||||
ok_n = sum(1 for _, o, _, _ in results if o)
|
||||
log(f"[proxy-probe] progress {done}/{total} ok={ok_n}")
|
||||
return results
|
||||
|
||||
|
||||
class ProxyManager:
|
||||
def __init__(self, config: dict | None = None, log: LogFn | None = None):
|
||||
self.cfg = config if config is not None else load_config()
|
||||
self.log = log or _log_print
|
||||
db = self.cfg.get("proxy_db") or self.cfg.get("proxy_alive_db") or "./proxy_alive.json"
|
||||
self.store = ProxyStore(db)
|
||||
self.sources = list(DEFAULT_SOURCES)
|
||||
# 允许 config 覆盖源权重
|
||||
overrides = self.cfg.get("proxy_source_weights") or {}
|
||||
if isinstance(overrides, dict):
|
||||
for s in self.sources:
|
||||
if s.id in overrides:
|
||||
try:
|
||||
s.weight = int(overrides[s.id])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── harvest + validate + insert ──
|
||||
|
||||
def harvest_and_store(self) -> dict:
|
||||
pick_k = int(self.cfg.get("proxy_source_pick_k", 3) or 3)
|
||||
per_limit = int(self.cfg.get("proxy_harvest_per_source", 150) or 150)
|
||||
total_limit = int(self.cfg.get("proxy_harvest_total", 400) or 400)
|
||||
workers = int(self.cfg.get("proxy_probe_workers", 40) or 40)
|
||||
timeout = float(self.cfg.get("proxy_probe_timeout", 12) or 12)
|
||||
max_keep = int(self.cfg.get("proxy_max_alive", 200) or 200)
|
||||
|
||||
self.log(
|
||||
f"[proxy] harvest start pick_k={pick_k} per={per_limit} total={total_limit}"
|
||||
)
|
||||
candidates, by_source = harvest(
|
||||
self.sources,
|
||||
pick_k=pick_k,
|
||||
per_source_limit=per_limit,
|
||||
total_limit=total_limit,
|
||||
store=self.store,
|
||||
log=self.log,
|
||||
)
|
||||
# 去掉已在库的
|
||||
existing = set(self.store.proxy_urls(alive_only=False))
|
||||
fresh = [p for p in candidates if p not in existing]
|
||||
self.log(f"[proxy] candidates={len(candidates)} fresh={len(fresh)} existing={len(existing)}")
|
||||
if not fresh:
|
||||
return {"harvested": len(candidates), "tested": 0, "kept": 0, "by_source": {}}
|
||||
|
||||
results = probe_batch(fresh, workers=workers, timeout=timeout, log=self.log)
|
||||
kept = 0
|
||||
kept_by_src: dict[str, int] = {sid: 0 for sid in by_source}
|
||||
# reverse map proxy -> source
|
||||
src_of: dict[str, str] = {}
|
||||
for sid, items in by_source.items():
|
||||
for p in items:
|
||||
src_of.setdefault(p, sid)
|
||||
|
||||
for p, ok, detail, lat in results:
|
||||
if not ok:
|
||||
continue
|
||||
sid = src_of.get(p, "")
|
||||
self.store.upsert(p, source_id=sid, latency_ms=lat, note=detail)
|
||||
kept += 1
|
||||
if sid:
|
||||
kept_by_src[sid] = kept_by_src.get(sid, 0) + 1
|
||||
|
||||
# 回写各源 kept,并惩罚 0 kept 的源
|
||||
for sid, items in by_source.items():
|
||||
k = kept_by_src.get(sid, 0)
|
||||
if items:
|
||||
if k > 0:
|
||||
self.store.source_ok(sid, harvested=len(items), kept=k)
|
||||
else:
|
||||
# 有采集但测 accounts.x.ai 全挂 → 记失败(连续则禁用)
|
||||
disabled = self.store.source_fail(
|
||||
sid,
|
||||
reason="harvested but 0 passed accounts.x.ai",
|
||||
disable_after=int(self.cfg.get("proxy_source_disable_after", 3) or 3),
|
||||
)
|
||||
self.log(
|
||||
f"[proxy] 源 {sid} 本轮 0 入库"
|
||||
+ (" → 已禁用" if disabled else "")
|
||||
)
|
||||
|
||||
# 超容量:按 last_ok 淘汰旧的
|
||||
alive = self.store.list_proxies(alive_only=True)
|
||||
if max_keep > 0 and len(alive) > max_keep:
|
||||
alive.sort(key=lambda x: float(x.get("last_ok") or 0), reverse=True)
|
||||
for item in alive[max_keep:]:
|
||||
self.store.remove(item["proxy"])
|
||||
self.log(f"[proxy] trim alive to {max_keep}")
|
||||
|
||||
# 同步 proxies.txt 方便人工看
|
||||
self.export_txt()
|
||||
st = self.store.stats()
|
||||
self.log(f"[proxy] harvest done kept={kept} alive={st['alive']} db={st['db']}")
|
||||
return {
|
||||
"harvested": len(candidates),
|
||||
"tested": len(fresh),
|
||||
"kept": kept,
|
||||
"alive": st["alive"],
|
||||
"by_source": kept_by_src,
|
||||
}
|
||||
|
||||
def export_txt(self) -> Path:
|
||||
path = _ROOT / str(self.cfg.get("proxy_pool_file") or "proxies.txt")
|
||||
if not path.is_absolute():
|
||||
path = (_ROOT / path).resolve()
|
||||
lines = [
|
||||
"# auto-exported by proxy_manager — only accounts.x.ai-validated proxies",
|
||||
f"# updated {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
]
|
||||
for item in sorted(
|
||||
self.store.list_proxies(alive_only=True),
|
||||
key=lambda x: float(x.get("latency_ms") or 99999),
|
||||
):
|
||||
lines.append(item["proxy"])
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
# ── recheck inventory ──
|
||||
|
||||
def recheck_inventory(self) -> dict:
|
||||
items = self.store.list_proxies(alive_only=True)
|
||||
if not items:
|
||||
self.log("[proxy] recheck: empty inventory")
|
||||
return {"checked": 0, "removed": 0, "ok": 0}
|
||||
workers = int(self.cfg.get("proxy_probe_workers", 40) or 40)
|
||||
timeout = float(self.cfg.get("proxy_probe_timeout", 12) or 12)
|
||||
proxies = [x["proxy"] for x in items]
|
||||
self.log(f"[proxy] recheck {len(proxies)} alive proxies")
|
||||
results = probe_batch(proxies, workers=workers, timeout=timeout, log=self.log)
|
||||
removed = 0
|
||||
ok_n = 0
|
||||
for p, ok, detail, lat in results:
|
||||
if ok:
|
||||
self.store.mark_ok(p, latency_ms=lat)
|
||||
ok_n += 1
|
||||
else:
|
||||
if self.store.mark_fail(p, reason=detail, remove_immediately=True):
|
||||
removed += 1
|
||||
self.log(f"[proxy] drop {proxy_log_label(p)} ({detail})")
|
||||
self.export_txt()
|
||||
self.log(f"[proxy] recheck done ok={ok_n} removed={removed}")
|
||||
return {"checked": len(proxies), "removed": removed, "ok": ok_n}
|
||||
|
||||
# ── runtime hooks for register ──
|
||||
|
||||
def acquire_for_use(self) -> str:
|
||||
mode = str(self.cfg.get("proxy_pool_mode") or "weighted")
|
||||
p = self.store.pick(mode=mode)
|
||||
if p:
|
||||
self.store.touch_use(p)
|
||||
self.log(f"[proxy] acquire {proxy_log_label(p)} (alive={self.store.count()})")
|
||||
return p
|
||||
|
||||
def report_success(self, proxy: str) -> None:
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return
|
||||
self.store.mark_ok(p)
|
||||
|
||||
def report_failure(self, proxy: str, reason: str = "") -> None:
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return
|
||||
reason = (reason or "").strip()
|
||||
# 识别 x.ai 屏蔽 / 注册页缺失
|
||||
low = reason.lower()
|
||||
hard = any(
|
||||
k in low
|
||||
for k in (
|
||||
"abusive traffic",
|
||||
"access denied",
|
||||
"blocked",
|
||||
"403",
|
||||
"未找到",
|
||||
"使用邮箱注册",
|
||||
"turnstile",
|
||||
"timeout",
|
||||
"连接已断开",
|
||||
"page disconnected",
|
||||
"err_proxy",
|
||||
"proxy",
|
||||
"tunnel",
|
||||
)
|
||||
)
|
||||
removed = self.store.mark_fail(
|
||||
p,
|
||||
reason=reason,
|
||||
remove_immediately=True if hard or True else False,
|
||||
)
|
||||
if removed:
|
||||
self.log(f"[proxy] use-fail drop {proxy_log_label(p)} ({reason[:120]})")
|
||||
# 若该源近期大量 use-fail,记源失败
|
||||
# 简化:从 meta 取 source_id
|
||||
# 已删除,无法取;跳过
|
||||
self.export_txt()
|
||||
|
||||
def status(self) -> dict:
|
||||
st = self.store.stats()
|
||||
st["sources_cfg"] = [
|
||||
{
|
||||
"id": s.id,
|
||||
"weight": s.weight,
|
||||
"enabled_store": self.store.source_enabled(s.id, default=True),
|
||||
"eff_weight": None,
|
||||
}
|
||||
for s in self.sources
|
||||
]
|
||||
from proxy_sources import effective_weight
|
||||
|
||||
for row in st["sources_cfg"]:
|
||||
src = next(x for x in self.sources if x.id == row["id"])
|
||||
row["eff_weight"] = round(
|
||||
effective_weight(src, self.store.source_meta(src.id)), 2
|
||||
)
|
||||
return st
|
||||
|
||||
def loop_forever(self) -> None:
|
||||
harvest_every = int(self.cfg.get("proxy_harvest_interval_sec", 600) or 600)
|
||||
check_every = int(self.cfg.get("proxy_check_interval_sec", 300) or 300)
|
||||
min_alive = int(self.cfg.get("proxy_min_alive", 20) or 20)
|
||||
self.log(
|
||||
f"[proxy] loop start harvest_every={harvest_every}s "
|
||||
f"check_every={check_every}s min_alive={min_alive}"
|
||||
)
|
||||
last_harvest = 0.0
|
||||
last_check = 0.0
|
||||
# 启动先 harvest 一轮
|
||||
try:
|
||||
self.harvest_and_store()
|
||||
last_harvest = time.time()
|
||||
except Exception as exc:
|
||||
self.log(f"[proxy] initial harvest error: {exc}")
|
||||
while True:
|
||||
now = time.time()
|
||||
try:
|
||||
if self.store.count() < min_alive or now - last_harvest >= harvest_every:
|
||||
self.harvest_and_store()
|
||||
last_harvest = time.time()
|
||||
if now - last_check >= check_every:
|
||||
self.recheck_inventory()
|
||||
last_check = time.time()
|
||||
except KeyboardInterrupt:
|
||||
self.log("[proxy] loop stopped")
|
||||
return
|
||||
except Exception as exc:
|
||||
self.log(f"[proxy] loop error: {exc}")
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
# ── 给注册机用的薄封装 ──
|
||||
|
||||
_manager_singleton: ProxyManager | None = None
|
||||
_manager_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_manager(config: dict | None = None, log: LogFn | None = None) -> ProxyManager:
|
||||
global _manager_singleton
|
||||
with _manager_lock:
|
||||
if _manager_singleton is None:
|
||||
_manager_singleton = ProxyManager(config=config, log=log)
|
||||
elif config is not None:
|
||||
_manager_singleton.cfg = config
|
||||
if log is not None:
|
||||
_manager_singleton.log = log
|
||||
return _manager_singleton
|
||||
|
||||
|
||||
def is_xai_block_reason(msg: str) -> bool:
|
||||
low = (msg or "").lower()
|
||||
keys = (
|
||||
"abusive traffic",
|
||||
"blocked due to",
|
||||
"access denied",
|
||||
"err_proxy",
|
||||
"proxy connection",
|
||||
"tunnel connection",
|
||||
"未找到「使用邮箱注册」",
|
||||
"未找到\"使用邮箱注册\"",
|
||||
"与页面的连接已断开",
|
||||
"page disconnected",
|
||||
)
|
||||
return any(k in low for k in keys)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="xAI 可用代理池管理")
|
||||
parser.add_argument(
|
||||
"cmd",
|
||||
choices=["harvest", "check", "loop", "status", "pick", "export"],
|
||||
help="harvest=采集入库 | check=巡检 | loop=常驻 | status | pick | export",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
cfg = load_config()
|
||||
mgr = ProxyManager(config=cfg)
|
||||
if args.cmd == "harvest":
|
||||
r = mgr.harvest_and_store()
|
||||
print(json.dumps(r, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.cmd == "check":
|
||||
r = mgr.recheck_inventory()
|
||||
print(json.dumps(r, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.cmd == "loop":
|
||||
mgr.loop_forever()
|
||||
return 0
|
||||
if args.cmd == "status":
|
||||
print(json.dumps(mgr.status(), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.cmd == "pick":
|
||||
p = mgr.acquire_for_use()
|
||||
print(p or "")
|
||||
return 0 if p else 1
|
||||
if args.cmd == "export":
|
||||
path = mgr.export_txt()
|
||||
print(path)
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""注册机代理池:解析 / 轮换 / 线程绑定 / 对接 proxy_manager 库存。
|
||||
|
||||
配置(config.json)示例:
|
||||
"proxy_pool_enabled": true,
|
||||
"proxy_managed": true, # True=用 proxy_alive.json(accounts.x.ai 测通过的)
|
||||
"proxy_pool_file": "./proxies.txt",
|
||||
"proxy_pool_mode": "weighted", # weighted | round_robin | random
|
||||
"proxy_rotate_each_account": true,
|
||||
"proxy_mail_direct": true
|
||||
|
||||
线程绑定:
|
||||
set_thread_proxy(url) / get_thread_proxy()
|
||||
浏览器、HTTP(xAI)、CPA mint 共用同一线程代理,避免出口不一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_lock = threading.Lock()
|
||||
_thread = threading.local()
|
||||
_pool: list[str] = []
|
||||
_index = 0
|
||||
_mode = "weighted"
|
||||
_enabled = False
|
||||
_rotate_each = True
|
||||
_mail_direct = True
|
||||
_managed = False
|
||||
_loaded = False
|
||||
_cfg: dict = {}
|
||||
|
||||
|
||||
def proxy_log_label(proxy: str | None) -> str:
|
||||
p = (proxy or "").strip()
|
||||
if not p:
|
||||
return "(direct)"
|
||||
try:
|
||||
u = urlparse(p if "://" in p else f"http://{p}")
|
||||
host = u.hostname or "?"
|
||||
port = f":{u.port}" if u.port else ""
|
||||
auth = "user:***@" if u.username else ""
|
||||
return f"{u.scheme or 'http'}://{auth}{host}{port}"
|
||||
except Exception:
|
||||
return "(proxy)"
|
||||
|
||||
|
||||
def normalize_proxy(raw: str | None) -> str:
|
||||
p = (raw or "").strip()
|
||||
if not p or p.lower() in ("none", "null", "direct", "off", "-"):
|
||||
return ""
|
||||
if "://" not in p:
|
||||
if p.count(":") == 3:
|
||||
host, port, user, pwd = p.split(":", 3)
|
||||
p = f"http://{user}:{pwd}@{host}:{port}"
|
||||
else:
|
||||
p = f"http://{p}"
|
||||
return p
|
||||
|
||||
|
||||
def proxy_for_chromium(proxy: str | None) -> str:
|
||||
"""Chromium --proxy-server:scheme://host:port(不能带 user:pass)。"""
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return ""
|
||||
u = urlparse(p)
|
||||
host = u.hostname or ""
|
||||
if not host:
|
||||
return ""
|
||||
scheme = (u.scheme or "http").lower()
|
||||
if scheme in ("socks5h",):
|
||||
scheme = "socks5"
|
||||
port = u.port or (443 if scheme == "https" else 80)
|
||||
return f"{scheme}://{host}:{port}"
|
||||
|
||||
|
||||
def proxy_has_auth(proxy: str | None) -> bool:
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return False
|
||||
u = urlparse(p)
|
||||
return bool(u.username)
|
||||
|
||||
|
||||
def _parse_lines(text: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for line in (text or "").splitlines():
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
if "://" not in s and s.count(":") == 3:
|
||||
host, port, user, pwd = s.split(":", 3)
|
||||
s = f"http://{user}:{pwd}@{host}:{port}"
|
||||
p = normalize_proxy(s)
|
||||
if p and p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _load_file(path: str) -> list[str]:
|
||||
raw = (path or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
p = Path(raw).expanduser()
|
||||
if not p.is_absolute():
|
||||
p = (Path(__file__).resolve().parent / p).resolve()
|
||||
if not p.is_file():
|
||||
return []
|
||||
try:
|
||||
return _parse_lines(p.read_text(encoding="utf-8", errors="replace"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _load_managed_pool(cfg: dict) -> list[str]:
|
||||
"""从 proxy_alive.json 读测通过的库存。"""
|
||||
try:
|
||||
from proxy_store import ProxyStore
|
||||
|
||||
db = cfg.get("proxy_db") or cfg.get("proxy_alive_db") or "./proxy_alive.json"
|
||||
store = ProxyStore(db)
|
||||
return store.proxy_urls(alive_only=True)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def configure_from_config(cfg: dict | None) -> list[str]:
|
||||
"""从 config 装载池;返回当前池列表。"""
|
||||
global _pool, _index, _mode, _enabled, _rotate_each, _mail_direct, _managed, _loaded, _cfg
|
||||
cfg = cfg or {}
|
||||
_cfg = dict(cfg)
|
||||
items: list[str] = []
|
||||
managed = bool(cfg.get("proxy_managed", True))
|
||||
|
||||
if managed and bool(cfg.get("proxy_pool_enabled", False)):
|
||||
items.extend(_load_managed_pool(cfg))
|
||||
|
||||
# 静态列表 / 文件兜底
|
||||
raw_list = cfg.get("proxy_pool") or []
|
||||
if isinstance(raw_list, str):
|
||||
items.extend(_parse_lines(raw_list.replace(",", "\n")))
|
||||
elif isinstance(raw_list, (list, tuple)):
|
||||
for x in raw_list:
|
||||
items.extend(_parse_lines(str(x)))
|
||||
items.extend(_load_file(str(cfg.get("proxy_pool_file") or "")))
|
||||
|
||||
seen: set[str] = set()
|
||||
pool: list[str] = []
|
||||
for p in items:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
pool.append(p)
|
||||
|
||||
enabled = bool(cfg.get("proxy_pool_enabled", bool(pool)))
|
||||
if not pool:
|
||||
single = normalize_proxy(cfg.get("proxy") or "")
|
||||
if single:
|
||||
pool = [single]
|
||||
enabled = bool(cfg.get("proxy_pool_enabled", True)) if single else False
|
||||
managed = False
|
||||
|
||||
mode = str(cfg.get("proxy_pool_mode") or "weighted").strip().lower()
|
||||
if mode not in ("round_robin", "random", "weighted"):
|
||||
mode = "weighted"
|
||||
|
||||
with _lock:
|
||||
_pool = pool
|
||||
_index = 0
|
||||
_mode = mode
|
||||
_enabled = enabled and bool(pool)
|
||||
_rotate_each = bool(cfg.get("proxy_rotate_each_account", True))
|
||||
_mail_direct = bool(cfg.get("proxy_mail_direct", True))
|
||||
_managed = managed and enabled
|
||||
_loaded = True
|
||||
return list(pool)
|
||||
|
||||
|
||||
def reload_from_store() -> int:
|
||||
"""热更新:从 proxy_alive.json 重载(harvest 后调用)。"""
|
||||
global _pool, _enabled
|
||||
if not _cfg:
|
||||
return 0
|
||||
items = _load_managed_pool(_cfg)
|
||||
# 合并静态文件
|
||||
items.extend(_load_file(str(_cfg.get("proxy_pool_file") or "")))
|
||||
seen: set[str] = set()
|
||||
pool = []
|
||||
for p in items:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
pool.append(p)
|
||||
with _lock:
|
||||
_pool = pool
|
||||
_enabled = bool(_cfg.get("proxy_pool_enabled", False)) and bool(pool)
|
||||
return len(pool)
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
return bool(_enabled and _pool)
|
||||
|
||||
|
||||
def is_managed() -> bool:
|
||||
return bool(_managed)
|
||||
|
||||
|
||||
def mail_direct() -> bool:
|
||||
return bool(_mail_direct)
|
||||
|
||||
|
||||
def rotate_each_account() -> bool:
|
||||
return bool(_rotate_each)
|
||||
|
||||
|
||||
def pool_size() -> int:
|
||||
return len(_pool)
|
||||
|
||||
|
||||
def pool_snapshot() -> list[str]:
|
||||
with _lock:
|
||||
return list(_pool)
|
||||
|
||||
|
||||
def set_thread_proxy(proxy: str | None) -> str:
|
||||
p = normalize_proxy(proxy)
|
||||
_thread.proxy = p or None
|
||||
return p
|
||||
|
||||
|
||||
def get_thread_proxy() -> str:
|
||||
return normalize_proxy(getattr(_thread, "proxy", None) or "")
|
||||
|
||||
|
||||
def clear_thread_proxy() -> None:
|
||||
_thread.proxy = None
|
||||
|
||||
|
||||
def acquire(force: bool = False) -> str:
|
||||
"""取下一个代理并绑定当前线程。force=True 强制轮换。"""
|
||||
global _index
|
||||
# managed + weighted:优先走 ProxyStore.pick
|
||||
if _managed and (_mode == "weighted" or force):
|
||||
try:
|
||||
from proxy_manager import get_manager
|
||||
|
||||
mgr = get_manager(_cfg)
|
||||
p = mgr.acquire_for_use()
|
||||
if p:
|
||||
set_thread_proxy(p)
|
||||
# 同步内存池(可能被 drop 过)
|
||||
reload_from_store()
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _lock:
|
||||
if not _enabled or not _pool:
|
||||
p = ""
|
||||
elif _mode == "random":
|
||||
p = random.choice(_pool)
|
||||
else:
|
||||
p = _pool[_index % len(_pool)]
|
||||
_index += 1
|
||||
if not force and not p:
|
||||
cur = get_thread_proxy()
|
||||
if cur:
|
||||
return cur
|
||||
set_thread_proxy(p)
|
||||
return p
|
||||
|
||||
|
||||
def report_success(proxy: str | None = None) -> None:
|
||||
p = normalize_proxy(proxy or get_thread_proxy())
|
||||
if not p or not _managed:
|
||||
return
|
||||
try:
|
||||
from proxy_manager import get_manager
|
||||
|
||||
get_manager(_cfg).report_success(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def report_failure(proxy: str | None = None, reason: str = "") -> None:
|
||||
"""使用失败:从库存删除并热更新内存池。"""
|
||||
p = normalize_proxy(proxy or get_thread_proxy())
|
||||
if not p:
|
||||
return
|
||||
# 内存池先踢
|
||||
with _lock:
|
||||
if p in _pool:
|
||||
_pool[:] = [x for x in _pool if x != p]
|
||||
if not _managed:
|
||||
return
|
||||
try:
|
||||
from proxy_manager import get_manager
|
||||
|
||||
get_manager(_cfg).report_failure(p, reason=reason)
|
||||
reload_from_store()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def resolve_for_http(explicit: str | None = None) -> str:
|
||||
"""HTTP 请求用代理优先级:explicit > 线程绑定 > 池下一枚(仅当启用且线程空) > 空。"""
|
||||
if explicit is not None:
|
||||
return normalize_proxy(explicit)
|
||||
cur = get_thread_proxy()
|
||||
if cur:
|
||||
return cur
|
||||
if is_enabled():
|
||||
return acquire(force=True)
|
||||
return ""
|
||||
|
||||
|
||||
def proxies_dict(proxy: str | None = None) -> dict:
|
||||
p = normalize_proxy(proxy if proxy is not None else resolve_for_http())
|
||||
if not p:
|
||||
return {}
|
||||
return {"http": p, "https": p}
|
||||
|
||||
|
||||
def status_line() -> str:
|
||||
if not is_enabled():
|
||||
cur = get_thread_proxy()
|
||||
if cur:
|
||||
return f"单代理 {proxy_log_label(cur)}"
|
||||
return "直连(未配置代理池)"
|
||||
kind = "managed(x.ai测通)" if _managed else "static"
|
||||
return (
|
||||
f"代理池 {pool_size()} 个 [{kind}] | mode={_mode} | "
|
||||
f"每账号轮换={'开' if _rotate_each else '关'} | "
|
||||
f"邮件直连={'开' if _mail_direct else '关'}"
|
||||
)
|
||||
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""公开免费代理源(国外可访问)+ 加权分流采集。
|
||||
|
||||
设计:
|
||||
- 每个源有 weight;采集时按权重随机/轮询分流
|
||||
- 源连续拉不到可用代理 / 被 x.ai 相关链路废掉 → 降权或禁用
|
||||
- 采集结果只返回候选 URL,是否入库由 proxy_manager 测 accounts.x.ai 决定
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
from curl_cffi import requests as _req
|
||||
except Exception: # pragma: no cover
|
||||
import requests as _req # type: ignore
|
||||
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
def _noop(msg: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxySource:
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
weight: int = 10
|
||||
protocol: str = "http" # 结果默认 scheme
|
||||
kind: str = "txt" # txt | json_geonode | json_list
|
||||
enabled: bool = True
|
||||
# 运行时统计(内存;持久化在 ProxyStore.sources)
|
||||
fail_streak: int = 0
|
||||
last_ok: float = 0.0
|
||||
last_fail: float = 0.0
|
||||
last_reason: str = ""
|
||||
harvested_total: int = 0
|
||||
kept_total: int = 0
|
||||
|
||||
|
||||
# 公开源(无需 key)。权重仅作初始值,运行中会按成功率动态调。
|
||||
DEFAULT_SOURCES: list[ProxySource] = [
|
||||
ProxySource(
|
||||
id="proxyscrape_http",
|
||||
name="ProxyScrape HTTP API",
|
||||
url="https://api.proxyscrape.com/v2/?request=displayproxies&protocol=http&timeout=8000&country=all&ssl=all&anonymity=all",
|
||||
weight=30,
|
||||
protocol="http",
|
||||
kind="txt",
|
||||
),
|
||||
ProxySource(
|
||||
id="proxyscrape_socks5",
|
||||
name="ProxyScrape SOCKS5 API",
|
||||
url="https://api.proxyscrape.com/v2/?request=displayproxies&protocol=socks5&timeout=8000&country=all",
|
||||
weight=15,
|
||||
protocol="socks5",
|
||||
kind="txt",
|
||||
),
|
||||
ProxySource(
|
||||
id="databay_http",
|
||||
name="Databay free-proxy-list HTTP",
|
||||
url="https://cdn.jsdelivr.net/gh/databay-labs/free-proxy-list@master/http.txt",
|
||||
weight=25,
|
||||
protocol="http",
|
||||
kind="txt",
|
||||
),
|
||||
ProxySource(
|
||||
id="databay_socks5",
|
||||
name="Databay free-proxy-list SOCKS5",
|
||||
url="https://cdn.jsdelivr.net/gh/databay-labs/free-proxy-list@master/socks5.txt",
|
||||
weight=12,
|
||||
protocol="socks5",
|
||||
kind="txt",
|
||||
),
|
||||
ProxySource(
|
||||
id="monosans_http",
|
||||
name="monosans/proxy-list HTTP",
|
||||
url="https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt",
|
||||
weight=18,
|
||||
protocol="http",
|
||||
kind="txt",
|
||||
),
|
||||
ProxySource(
|
||||
id="monosans_socks5",
|
||||
name="monosans/proxy-list SOCKS5",
|
||||
url="https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/socks5.txt",
|
||||
weight=10,
|
||||
protocol="socks5",
|
||||
kind="txt",
|
||||
),
|
||||
ProxySource(
|
||||
id="speedx_http",
|
||||
name="TheSpeedX PROXY-List HTTP",
|
||||
url="https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt",
|
||||
weight=12,
|
||||
protocol="http",
|
||||
kind="txt",
|
||||
),
|
||||
ProxySource(
|
||||
id="openproxylist_http",
|
||||
name="openproxylist HTTP",
|
||||
url="https://api.openproxylist.xyz/http.txt",
|
||||
weight=10,
|
||||
protocol="http",
|
||||
kind="txt",
|
||||
),
|
||||
ProxySource(
|
||||
id="geonode_http",
|
||||
name="Geonode free proxy API",
|
||||
url="https://proxylist.geonode.com/api/proxy-list?limit=200&page=1&sort_by=lastChecked&sort_type=desc&protocols=http%2Chttps",
|
||||
weight=8,
|
||||
protocol="http",
|
||||
kind="json_geonode",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
_HOSTPORT_RE = re.compile(r"^[\w\.\-]+:\d{2,5}$")
|
||||
|
||||
|
||||
def _normalize_line(line: str, default_scheme: str = "http") -> str:
|
||||
s = (line or "").strip()
|
||||
if not s or s.startswith("#") or s.startswith("<"):
|
||||
return ""
|
||||
s = s.split()[0].strip(",")
|
||||
if "://" in s:
|
||||
u = urlparse(s)
|
||||
if not u.hostname or not u.port:
|
||||
return ""
|
||||
scheme = (u.scheme or default_scheme).lower()
|
||||
if scheme == "https":
|
||||
scheme = "http"
|
||||
if scheme == "socks5h":
|
||||
scheme = "socks5"
|
||||
return f"{scheme}://{u.hostname}:{u.port}"
|
||||
if not _HOSTPORT_RE.match(s):
|
||||
return ""
|
||||
scheme = (default_scheme or "http").lower()
|
||||
if scheme == "https":
|
||||
scheme = "http"
|
||||
return f"{scheme}://{s}"
|
||||
|
||||
|
||||
def parse_txt(body: str, default_scheme: str = "http") -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for line in (body or "").splitlines():
|
||||
p = _normalize_line(line, default_scheme)
|
||||
if p and p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def parse_geonode_json(data, default_scheme: str = "http") -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
rows = []
|
||||
if isinstance(data, dict):
|
||||
rows = data.get("data") or data.get("proxies") or []
|
||||
elif isinstance(data, list):
|
||||
rows = data
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
ip = str(row.get("ip") or row.get("host") or "").strip()
|
||||
port = row.get("port")
|
||||
if not ip or not port:
|
||||
continue
|
||||
protocols = row.get("protocols") or row.get("protocol") or [default_scheme]
|
||||
if isinstance(protocols, str):
|
||||
protocols = [protocols]
|
||||
scheme = "http"
|
||||
for pr in protocols:
|
||||
pr = str(pr).lower()
|
||||
if pr in ("socks5", "socks4"):
|
||||
scheme = pr
|
||||
break
|
||||
if pr in ("http", "https"):
|
||||
scheme = "http"
|
||||
p = f"{scheme}://{ip}:{port}"
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_source(
|
||||
source: ProxySource,
|
||||
*,
|
||||
timeout: float = 20.0,
|
||||
log: LogFn | None = None,
|
||||
) -> list[str]:
|
||||
log = log or _noop
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = _req.get(
|
||||
source.url,
|
||||
timeout=timeout,
|
||||
impersonate="chrome120",
|
||||
headers={"User-Agent": "Mozilla/5.0 (compatible; grok-register-proxy/1.0)"},
|
||||
)
|
||||
status = getattr(resp, "status_code", 0)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"HTTP {status}")
|
||||
if source.kind == "json_geonode":
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"json parse: {exc}") from exc
|
||||
items = parse_geonode_json(data, source.protocol)
|
||||
else:
|
||||
text = resp.text or ""
|
||||
if text.lstrip().startswith("<") or "error code" in text[:80].lower():
|
||||
raise RuntimeError(f"bad body: {text[:80]!r}")
|
||||
items = parse_txt(text, source.protocol)
|
||||
elapsed = time.time() - t0
|
||||
log(f"[proxy-src] {source.id} ok n={len(items)} {elapsed:.1f}s")
|
||||
return items
|
||||
except Exception as exc:
|
||||
log(f"[proxy-src] {source.id} fail: {exc}")
|
||||
raise
|
||||
|
||||
|
||||
def effective_weight(source: ProxySource, store_meta: dict | None = None) -> float:
|
||||
"""运行权重 = 配置 weight × 健康系数。"""
|
||||
if not source.enabled:
|
||||
return 0.0
|
||||
meta = store_meta or {}
|
||||
if meta.get("enabled") is False:
|
||||
return 0.0
|
||||
base = max(0, int(source.weight))
|
||||
fail = int(meta.get("fail_streak") or source.fail_streak or 0)
|
||||
ok_runs = int(meta.get("ok_runs") or 0)
|
||||
kept = int(meta.get("last_kept") or 0)
|
||||
# 连续失败指数衰减;有产出加成
|
||||
health = 1.0 / (1.0 + fail * 1.5)
|
||||
if kept > 0:
|
||||
health *= 1.0 + min(1.0, kept / 20.0)
|
||||
if ok_runs > 0 and fail == 0:
|
||||
health *= 1.15
|
||||
return max(0.0, base * health)
|
||||
|
||||
|
||||
def pick_sources(
|
||||
sources: list[ProxySource],
|
||||
*,
|
||||
k: int = 3,
|
||||
store=None,
|
||||
) -> list[ProxySource]:
|
||||
"""按权重不放回抽样最多 k 个源。"""
|
||||
candidates = []
|
||||
weights = []
|
||||
for s in sources:
|
||||
meta = store.source_meta(s.id) if store is not None else {}
|
||||
if not store.source_enabled(s.id, default=s.enabled) if store is not None else s.enabled:
|
||||
continue
|
||||
w = effective_weight(s, meta)
|
||||
if w <= 0:
|
||||
continue
|
||||
candidates.append(s)
|
||||
weights.append(w)
|
||||
if not candidates:
|
||||
return []
|
||||
k = max(1, min(k, len(candidates)))
|
||||
picked: list[ProxySource] = []
|
||||
pool = list(zip(candidates, weights))
|
||||
for _ in range(k):
|
||||
if not pool:
|
||||
break
|
||||
cs, ws = zip(*pool)
|
||||
choice = random.choices(list(cs), weights=list(ws), k=1)[0]
|
||||
picked.append(choice)
|
||||
pool = [(c, w) for c, w in pool if c.id != choice.id]
|
||||
return picked
|
||||
|
||||
|
||||
def harvest(
|
||||
sources: list[ProxySource] | None = None,
|
||||
*,
|
||||
pick_k: int = 3,
|
||||
per_source_limit: int = 200,
|
||||
total_limit: int = 600,
|
||||
timeout: float = 20.0,
|
||||
store=None,
|
||||
log: LogFn | None = None,
|
||||
) -> tuple[list[str], dict[str, list[str]]]:
|
||||
"""从加权选中的源采集候选代理。
|
||||
|
||||
返回 (all_candidates, by_source)。
|
||||
"""
|
||||
log = log or _noop
|
||||
sources = list(sources or DEFAULT_SOURCES)
|
||||
chosen = pick_sources(sources, k=pick_k, store=store)
|
||||
if not chosen:
|
||||
# 全部被禁用时尝试强制取 weight 最高的 2 个做恢复
|
||||
ranked = sorted(sources, key=lambda s: s.weight, reverse=True)[:2]
|
||||
chosen = ranked
|
||||
log("[proxy-src] 所有源被禁用/权重为0,强制尝试恢复 top weight 源")
|
||||
|
||||
by_source: dict[str, list[str]] = {}
|
||||
all_seen: set[str] = set()
|
||||
all_list: list[str] = []
|
||||
|
||||
for src in chosen:
|
||||
try:
|
||||
items = fetch_source(src, timeout=timeout, log=log)
|
||||
if per_source_limit > 0:
|
||||
random.shuffle(items)
|
||||
items = items[:per_source_limit]
|
||||
by_source[src.id] = items
|
||||
for p in items:
|
||||
if p not in all_seen:
|
||||
all_seen.add(p)
|
||||
all_list.append(p)
|
||||
if store is not None:
|
||||
# 先记 harvest;kept 在测完后由 manager 写
|
||||
store.source_ok(src.id, harvested=len(items), kept=0)
|
||||
except Exception as exc:
|
||||
by_source[src.id] = []
|
||||
if store is not None:
|
||||
disabled = store.source_fail(src.id, reason=str(exc), disable_after=3)
|
||||
if disabled:
|
||||
log(f"[proxy-src] 源已禁用: {src.id} ({exc})")
|
||||
continue
|
||||
if total_limit and len(all_list) >= total_limit:
|
||||
break
|
||||
|
||||
if total_limit and len(all_list) > total_limit:
|
||||
random.shuffle(all_list)
|
||||
all_list = all_list[:total_limit]
|
||||
log(f"[proxy-src] harvest done sources={len(chosen)} candidates={len(all_list)}")
|
||||
return all_list, by_source
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""可用代理持久化库存(JSON)。
|
||||
|
||||
只存「测通过 accounts.x.ai」的代理;失效/使用失败立即删。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
_DEFAULT_DB = _ROOT / "proxy_alive.json"
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
def normalize_proxy(raw: str | None) -> str:
|
||||
p = (raw or "").strip()
|
||||
if not p or p.lower() in ("none", "null", "direct", "off", "-"):
|
||||
return ""
|
||||
if "://" not in p:
|
||||
# host:port:user:pass
|
||||
if p.count(":") == 3:
|
||||
host, port, user, pwd = p.split(":", 3)
|
||||
p = f"http://{user}:{pwd}@{host}:{port}"
|
||||
else:
|
||||
p = f"http://{p}"
|
||||
return p
|
||||
|
||||
|
||||
def proxy_log_label(proxy: str | None) -> str:
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return "(direct)"
|
||||
try:
|
||||
u = urlparse(p)
|
||||
host = u.hostname or "?"
|
||||
port = f":{u.port}" if u.port else ""
|
||||
auth = "user:***@" if u.username else ""
|
||||
return f"{u.scheme or 'http'}://{auth}{host}{port}"
|
||||
except Exception:
|
||||
return "(proxy)"
|
||||
|
||||
|
||||
class ProxyStore:
|
||||
def __init__(self, path: str | Path | None = None):
|
||||
self.path = Path(path).expanduser() if path else _DEFAULT_DB
|
||||
if not self.path.is_absolute():
|
||||
self.path = (_ROOT / self.path).resolve()
|
||||
self._data: dict[str, Any] = {"version": 1, "proxies": {}, "sources": {}}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
with _lock:
|
||||
if self.path.is_file():
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
self._data = raw
|
||||
self._data.setdefault("proxies", {})
|
||||
self._data.setdefault("sources", {})
|
||||
except Exception:
|
||||
self._data = {"version": 1, "proxies": {}, "sources": {}}
|
||||
else:
|
||||
self._data = {"version": 1, "proxies": {}, "sources": {}}
|
||||
|
||||
def save(self) -> None:
|
||||
with _lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(self._data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(self.path)
|
||||
|
||||
# ── proxies ──
|
||||
|
||||
def list_proxies(self, *, alive_only: bool = True) -> list[dict]:
|
||||
with _lock:
|
||||
out = []
|
||||
for p, meta in (self._data.get("proxies") or {}).items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
if alive_only and not meta.get("alive", True):
|
||||
continue
|
||||
item = dict(meta)
|
||||
item["proxy"] = p
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
def proxy_urls(self, *, alive_only: bool = True) -> list[str]:
|
||||
return [x["proxy"] for x in self.list_proxies(alive_only=alive_only)]
|
||||
|
||||
def count(self, *, alive_only: bool = True) -> int:
|
||||
return len(self.list_proxies(alive_only=alive_only))
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
proxy: str,
|
||||
*,
|
||||
source_id: str = "",
|
||||
latency_ms: float | None = None,
|
||||
exit_ip: str = "",
|
||||
note: str = "",
|
||||
) -> str:
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return ""
|
||||
with _lock:
|
||||
cur = self._data["proxies"].get(p) or {}
|
||||
now = _now()
|
||||
meta = {
|
||||
"alive": True,
|
||||
"source_id": source_id or cur.get("source_id") or "",
|
||||
"first_seen": cur.get("first_seen") or now,
|
||||
"last_ok": now,
|
||||
"last_check": now,
|
||||
"ok_count": int(cur.get("ok_count") or 0) + 1,
|
||||
"fail_count": 0,
|
||||
"use_count": int(cur.get("use_count") or 0),
|
||||
"use_fail": int(cur.get("use_fail") or 0),
|
||||
"latency_ms": latency_ms if latency_ms is not None else cur.get("latency_ms"),
|
||||
"exit_ip": exit_ip or cur.get("exit_ip") or "",
|
||||
"note": note or cur.get("note") or "",
|
||||
}
|
||||
self._data["proxies"][p] = meta
|
||||
self.save()
|
||||
return p
|
||||
|
||||
def touch_use(self, proxy: str) -> None:
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return
|
||||
with _lock:
|
||||
meta = self._data["proxies"].get(p)
|
||||
if not meta:
|
||||
return
|
||||
meta["use_count"] = int(meta.get("use_count") or 0) + 1
|
||||
meta["last_use"] = _now()
|
||||
self.save()
|
||||
|
||||
def mark_ok(self, proxy: str, *, latency_ms: float | None = None, exit_ip: str = "") -> None:
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return
|
||||
with _lock:
|
||||
meta = self._data["proxies"].get(p)
|
||||
if not meta:
|
||||
return
|
||||
meta["alive"] = True
|
||||
meta["last_ok"] = _now()
|
||||
meta["last_check"] = _now()
|
||||
meta["ok_count"] = int(meta.get("ok_count") or 0) + 1
|
||||
meta["fail_count"] = 0
|
||||
if latency_ms is not None:
|
||||
meta["latency_ms"] = latency_ms
|
||||
if exit_ip:
|
||||
meta["exit_ip"] = exit_ip
|
||||
self.save()
|
||||
|
||||
def mark_fail(
|
||||
self,
|
||||
proxy: str,
|
||||
*,
|
||||
reason: str = "",
|
||||
remove_immediately: bool = True,
|
||||
max_fail: int = 1,
|
||||
) -> bool:
|
||||
"""返回 True 表示已删除。"""
|
||||
p = normalize_proxy(proxy)
|
||||
if not p:
|
||||
return False
|
||||
with _lock:
|
||||
meta = self._data["proxies"].get(p)
|
||||
if not meta:
|
||||
return False
|
||||
meta["fail_count"] = int(meta.get("fail_count") or 0) + 1
|
||||
meta["use_fail"] = int(meta.get("use_fail") or 0) + 1
|
||||
meta["last_check"] = _now()
|
||||
meta["last_fail_reason"] = (reason or "")[:200]
|
||||
if remove_immediately or meta["fail_count"] >= max_fail:
|
||||
self._data["proxies"].pop(p, None)
|
||||
self.save()
|
||||
return True
|
||||
meta["alive"] = False
|
||||
self.save()
|
||||
return False
|
||||
|
||||
def remove(self, proxy: str) -> bool:
|
||||
p = normalize_proxy(proxy)
|
||||
with _lock:
|
||||
if p in self._data["proxies"]:
|
||||
self._data["proxies"].pop(p, None)
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def pick(self, mode: str = "weighted") -> str:
|
||||
"""从库存挑一个。weighted=按成功率/延迟加权;random/round_robin 简化为随机。"""
|
||||
items = self.list_proxies(alive_only=True)
|
||||
if not items:
|
||||
return ""
|
||||
if mode in ("random", "round_robin"):
|
||||
return random.choice(items)["proxy"]
|
||||
weights = []
|
||||
for it in items:
|
||||
ok = max(1, int(it.get("ok_count") or 1))
|
||||
fail = int(it.get("use_fail") or 0)
|
||||
lat = float(it.get("latency_ms") or 3000.0)
|
||||
# 越快越稳权重越高
|
||||
w = ok / (1 + fail) * (1.0 / max(0.2, lat / 1000.0))
|
||||
weights.append(max(0.01, w))
|
||||
return random.choices(items, weights=weights, k=1)[0]["proxy"]
|
||||
|
||||
# ── sources health ──
|
||||
|
||||
def source_meta(self, source_id: str) -> dict:
|
||||
with _lock:
|
||||
return dict((self._data.get("sources") or {}).get(source_id) or {})
|
||||
|
||||
def source_ok(self, source_id: str, *, harvested: int = 0, kept: int = 0) -> None:
|
||||
if not source_id:
|
||||
return
|
||||
with _lock:
|
||||
cur = self._data["sources"].setdefault(source_id, {})
|
||||
cur["enabled"] = True
|
||||
cur["last_ok"] = _now()
|
||||
cur["last_run"] = _now()
|
||||
cur["fail_streak"] = 0
|
||||
cur["ok_runs"] = int(cur.get("ok_runs") or 0) + 1
|
||||
cur["last_harvested"] = harvested
|
||||
cur["last_kept"] = kept
|
||||
self.save()
|
||||
|
||||
def source_fail(self, source_id: str, *, reason: str = "", disable_after: int = 3) -> bool:
|
||||
"""源连续失败 disable_after 次后禁用。返回是否已禁用。"""
|
||||
if not source_id:
|
||||
return False
|
||||
with _lock:
|
||||
cur = self._data["sources"].setdefault(source_id, {})
|
||||
cur["last_run"] = _now()
|
||||
cur["last_fail_reason"] = (reason or "")[:200]
|
||||
cur["fail_streak"] = int(cur.get("fail_streak") or 0) + 1
|
||||
cur["fail_runs"] = int(cur.get("fail_runs") or 0) + 1
|
||||
disabled = False
|
||||
if cur["fail_streak"] >= max(1, disable_after):
|
||||
cur["enabled"] = False
|
||||
cur["disabled_at"] = _now()
|
||||
disabled = True
|
||||
self.save()
|
||||
return disabled
|
||||
|
||||
def source_enabled(self, source_id: str, default: bool = True) -> bool:
|
||||
with _lock:
|
||||
cur = (self._data.get("sources") or {}).get(source_id) or {}
|
||||
if "enabled" not in cur:
|
||||
return default
|
||||
return bool(cur.get("enabled"))
|
||||
|
||||
def reenable_source(self, source_id: str) -> None:
|
||||
with _lock:
|
||||
cur = self._data["sources"].setdefault(source_id, {})
|
||||
cur["enabled"] = True
|
||||
cur["fail_streak"] = 0
|
||||
cur["reenabled_at"] = _now()
|
||||
self.save()
|
||||
|
||||
def stats(self) -> dict:
|
||||
items = self.list_proxies(alive_only=False)
|
||||
alive = [x for x in items if x.get("alive", True)]
|
||||
sources = self._data.get("sources") or {}
|
||||
return {
|
||||
"total": len(items),
|
||||
"alive": len(alive),
|
||||
"sources": {
|
||||
sid: {
|
||||
"enabled": meta.get("enabled", True),
|
||||
"fail_streak": meta.get("fail_streak", 0),
|
||||
"last_kept": meta.get("last_kept", 0),
|
||||
}
|
||||
for sid, meta in sources.items()
|
||||
},
|
||||
"db": str(self.path),
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""批量重铸 CPA auth:补回 grok-cli:access scope。
|
||||
|
||||
只覆盖写回,不删除原文件。默认仅处理缺 grok-cli:access 的 xai-*.json。
|
||||
|
||||
用法:
|
||||
python remint_cli_scope.py --auth-dir /opt/cli-proxy-api/auths --dry-run
|
||||
python remint_cli_scope.py --auth-dir /opt/cli-proxy-api/auths --limit 5
|
||||
python remint_cli_scope.py --auth-dir /opt/cli-proxy-api/auths --concurrency 6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
|
||||
def _jwt_payload(token: str) -> dict[str, Any]:
|
||||
parts = (token or "").split(".")
|
||||
if len(parts) < 2:
|
||||
raise ValueError("invalid JWT")
|
||||
seg = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(seg.encode("ascii")))
|
||||
|
||||
|
||||
def _scope_set(token: str) -> set[str]:
|
||||
try:
|
||||
return set(str(_jwt_payload(token).get("scope") or "").split())
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _needs_remint(data: dict[str, Any], *, only_missing_cli: bool) -> tuple[bool, str]:
|
||||
at = str(data.get("access_token") or "").strip()
|
||||
if not at:
|
||||
return False, "no_access_token"
|
||||
scopes = _scope_set(at)
|
||||
has_cli = "grok-cli:access" in scopes
|
||||
if only_missing_cli and has_cli:
|
||||
return False, "already_has_cli_scope"
|
||||
sso = str(data.get("sso") or "").strip()
|
||||
if not sso:
|
||||
return False, "no_sso"
|
||||
return True, "missing_cli_scope" if not has_cli else "force"
|
||||
|
||||
|
||||
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
|
||||
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}.{threading.get_ident()}")
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def remint_one(
|
||||
path: Path,
|
||||
*,
|
||||
proxy: str | None,
|
||||
dry_run: bool,
|
||||
only_missing_cli: bool,
|
||||
) -> dict[str, Any]:
|
||||
import cpa
|
||||
from oidc_mint.oauth_code import GROK_CLI_SCOPE, mint_from_sso, normalize_sso_cookie
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "path": str(path), "error": f"read: {exc}"}
|
||||
|
||||
need, reason = _needs_remint(data, only_missing_cli=only_missing_cli)
|
||||
email = str(data.get("email") or "").strip()
|
||||
if not need:
|
||||
return {
|
||||
"ok": True,
|
||||
"skipped": True,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
sso = normalize_sso_cookie(str(data.get("sso") or ""))
|
||||
if dry_run:
|
||||
return {
|
||||
"ok": True,
|
||||
"dry_run": True,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
try:
|
||||
tokens = mint_from_sso(
|
||||
sso,
|
||||
proxy=proxy,
|
||||
require_referrer=True,
|
||||
log=lambda m: None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"ok": False,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"error": f"mint: {exc}",
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
access = str(tokens.get("access_token") or "").strip()
|
||||
refresh = str(tokens.get("refresh_token") or "").strip()
|
||||
if GROK_CLI_SCOPE not in _scope_set(access):
|
||||
return {
|
||||
"ok": False,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"error": f"minted token still missing {GROK_CLI_SCOPE}",
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
try:
|
||||
payload = cpa.build_cpa_xai_auth(
|
||||
email=email or str(data.get("email") or ""),
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
id_token=tokens.get("id_token") or data.get("id_token"),
|
||||
expires_in=tokens.get("expires_in"),
|
||||
base_url=data.get("base_url") or cpa.CLI_BASE_URL,
|
||||
sso=sso,
|
||||
headers=data.get("headers") if isinstance(data.get("headers"), dict) else None,
|
||||
)
|
||||
# 保留原 disabled 状态,避免重铸把人工禁用号重新启用
|
||||
if "disabled" in data:
|
||||
payload["disabled"] = bool(data.get("disabled"))
|
||||
_atomic_write(path, payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"ok": False,
|
||||
"path": str(path),
|
||||
"email": email,
|
||||
"error": f"write: {exc}",
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"path": str(path),
|
||||
"email": email or payload.get("email"),
|
||||
"reason": reason,
|
||||
"referrer": payload.get("referrer"),
|
||||
"scope_ok": True,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Remint CPA auths missing grok-cli:access")
|
||||
p.add_argument(
|
||||
"--auth-dir",
|
||||
default=os.environ.get("CPA_AUTH_DIR", str(_ROOT / "cpa_auths")),
|
||||
help="CPA auth 目录(默认 ./cpa_auths 或 $CPA_AUTH_DIR)",
|
||||
)
|
||||
p.add_argument("--proxy", default=os.environ.get("https_proxy") or os.environ.get("http_proxy") or "")
|
||||
p.add_argument("--concurrency", type=int, default=4)
|
||||
p.add_argument("--limit", type=int, default=0, help="最多处理 N 个需重铸文件(0=不限)")
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
p.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="强制全部重铸(默认只处理缺 grok-cli:access 的)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--log-file",
|
||||
default="",
|
||||
help="结果日志路径(默认 auth-dir/remint_cli_scope.log)",
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
auth_dir = Path(args.auth_dir).expanduser().resolve()
|
||||
if not auth_dir.is_dir():
|
||||
print(f"[!] auth-dir 不存在: {auth_dir}", flush=True)
|
||||
return 2
|
||||
|
||||
proxy = (args.proxy or "").strip() or None
|
||||
only_missing_cli = not args.all
|
||||
files = sorted(auth_dir.glob("xai-*.json"))
|
||||
targets: list[Path] = []
|
||||
skipped = 0
|
||||
for path in files:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
||||
need, _ = _needs_remint(data, only_missing_cli=only_missing_cli)
|
||||
if need:
|
||||
targets.append(path)
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception:
|
||||
skipped += 1
|
||||
if args.limit and len(targets) >= args.limit:
|
||||
break
|
||||
|
||||
log_path = Path(args.log_file).expanduser() if args.log_file else (auth_dir / "remint_cli_scope.log")
|
||||
print(
|
||||
f"[*] auth-dir={auth_dir} total={len(files)} targets={len(targets)} "
|
||||
f"skipped_scan={skipped} concurrency={args.concurrency} "
|
||||
f"proxy={proxy or '(direct)'} dry_run={args.dry_run}",
|
||||
flush=True,
|
||||
)
|
||||
if not targets:
|
||||
print("[*] 无需重铸", flush=True)
|
||||
return 0
|
||||
|
||||
ok_n = 0
|
||||
fail_n = 0
|
||||
skip_n = 0
|
||||
lock = threading.Lock()
|
||||
started = time.time()
|
||||
|
||||
def _handle(path: Path) -> dict[str, Any]:
|
||||
return remint_one(
|
||||
path,
|
||||
proxy=proxy,
|
||||
dry_run=args.dry_run,
|
||||
only_missing_cli=only_missing_cli,
|
||||
)
|
||||
|
||||
def _record(res: dict[str, Any]) -> None:
|
||||
nonlocal ok_n, fail_n, skip_n
|
||||
line = json.dumps(res, ensure_ascii=False)
|
||||
with lock:
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
if res.get("skipped") or res.get("dry_run"):
|
||||
skip_n += 1
|
||||
tag = "SKIP" if res.get("skipped") else "DRY"
|
||||
elif res.get("ok"):
|
||||
ok_n += 1
|
||||
tag = "OK"
|
||||
else:
|
||||
fail_n += 1
|
||||
tag = "FAIL"
|
||||
email = res.get("email") or Path(str(res.get("path") or "")).name
|
||||
extra = res.get("error") or res.get("reason") or ""
|
||||
print(f"[{tag}] {email} {extra}", flush=True)
|
||||
|
||||
# 清空/追加日志头
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "start",
|
||||
"ts": int(time.time()),
|
||||
"targets": len(targets),
|
||||
"dry_run": bool(args.dry_run),
|
||||
"proxy": proxy or "",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
if args.concurrency <= 1:
|
||||
for path in targets:
|
||||
_record(_handle(path))
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as ex:
|
||||
futs = [ex.submit(_handle, path) for path in targets]
|
||||
for fut in as_completed(futs):
|
||||
try:
|
||||
_record(fut.result())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_record({"ok": False, "error": f"worker: {exc}"})
|
||||
|
||||
elapsed = time.time() - started
|
||||
print(
|
||||
f"[*] done ok={ok_n} fail={fail_n} skip/dry={skip_n} "
|
||||
f"elapsed={elapsed:.1f}s log={log_path}",
|
||||
flush=True,
|
||||
)
|
||||
return 0 if fail_n == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+18
-7
@@ -1,7 +1,18 @@
|
||||
cloakbrowser>=0.3.0
|
||||
requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
httpx>=0.28
|
||||
playwright>=1.55
|
||||
curl_cffi>=0.7.0
|
||||
curl_cffi>=0.7.0
|
||||
certifi==2026.4.22
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.7
|
||||
cssselect==1.3.0
|
||||
curl_cffi==0.13.0
|
||||
DataRecorder==3.6.2
|
||||
DownloadKit==2.0.7
|
||||
DrissionPage==4.1.1.3
|
||||
filelock==3.19.1
|
||||
idna==3.13
|
||||
lxml==6.1.0
|
||||
psutil==7.2.2
|
||||
pycparser==2.23
|
||||
requests==2.32.5
|
||||
requests-file==3.0.1
|
||||
tldextract==5.3.0
|
||||
urllib3==2.6.3
|
||||
websocket-client==1.9.0
|
||||
@@ -1,222 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键清零注册 → 认证 → CPA 整条流水线的运行时数据,方便重开。
|
||||
#
|
||||
# 你只清 keys/acc.md / accounts.txt 不够:
|
||||
# acpa_watchdog 会从 ~/Downloads/grok-free-register-auth/authenticated/
|
||||
# 把旧号再整备进 keys/cpa_ready/,sync_acc 再写回 acc.md。
|
||||
#
|
||||
# 用法(本文件在项目根):
|
||||
# bash reset_pipeline.sh # dry-run,只列将删内容
|
||||
# bash reset_pipeline.sh --yes # 真正删除
|
||||
# bash reset_pipeline.sh --yes --keep-register
|
||||
# # 只清认证/CPA,保留 keys 里新注册的 SSO 源
|
||||
# bash reset_pipeline.sh --yes --keep-cpa
|
||||
# # 只清认证账本/源快照,保留 cpa_ready(一般不需要)
|
||||
#
|
||||
# 建议先停 auth-service / register,再跑本脚本。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
KEYS="$ROOT/keys"
|
||||
AUTH_DIR="${XAI_AUTH_SERVICE_LOCAL_DIR:-$HOME/Downloads/grok-free-register-auth}"
|
||||
|
||||
YES=0
|
||||
KEEP_REGISTER=0
|
||||
KEEP_CPA=0
|
||||
KEEP_AUTH_SALT=1
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--yes|-y) YES=1 ;;
|
||||
--keep-register) KEEP_REGISTER=1 ;;
|
||||
--keep-cpa) KEEP_CPA=1 ;;
|
||||
--wipe-salt) KEEP_AUTH_SALT=0 ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 运行时产物清单(脚本本身永不删)
|
||||
REGISTER_FILES=(
|
||||
"$KEYS/accounts.txt"
|
||||
"$KEYS/grok.txt"
|
||||
"$KEYS/auth-sessions.jsonl"
|
||||
"$KEYS/acc.md"
|
||||
)
|
||||
CPA_GLOBS=(
|
||||
"$KEYS/cpa_ready/xai-"*.json
|
||||
"$KEYS/cpa_ready/_state.tsv"
|
||||
)
|
||||
AUTH_PATHS=(
|
||||
"$AUTH_DIR/authenticated"
|
||||
"$AUTH_DIR/claimed"
|
||||
"$AUTH_DIR/enrollment-ledger.db"
|
||||
"$AUTH_DIR/source-snapshot.jsonl"
|
||||
)
|
||||
AUTH_SALT="$AUTH_DIR/.ledger-salt"
|
||||
|
||||
count_matches() {
|
||||
local n=0
|
||||
local p
|
||||
for p in "$@"; do
|
||||
# shellcheck disable=SC2086
|
||||
if compgen -G "$p" > /dev/null 2>&1; then
|
||||
while IFS= read -r -d '' f; do
|
||||
n=$((n + 1))
|
||||
done < <(find $p -type f -print0 2>/dev/null || true)
|
||||
# also count plain files matched by glob/file
|
||||
if [[ -f "$p" ]]; then
|
||||
n=$((n + 1))
|
||||
fi
|
||||
elif [[ -f "$p" ]]; then
|
||||
n=$((n + 1))
|
||||
elif [[ -d "$p" ]]; then
|
||||
while IFS= read -r -d '' f; do
|
||||
n=$((n + 1))
|
||||
done < <(find "$p" -type f -print0 2>/dev/null || true)
|
||||
fi
|
||||
done
|
||||
echo "$n"
|
||||
}
|
||||
|
||||
size_of() {
|
||||
local p="$1"
|
||||
if [[ -e "$p" ]]; then
|
||||
du -sh "$p" 2>/dev/null | awk '{print $1}'
|
||||
else
|
||||
echo "-"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== grok-free-register 流水线清零 ==="
|
||||
echo "ROOT = $ROOT"
|
||||
echo "KEYS = $KEYS"
|
||||
echo "AUTH_DIR = $AUTH_DIR"
|
||||
echo
|
||||
|
||||
# 检测可能还在跑的服务
|
||||
if pgrep -af 'xai_enroller|acpa_watchdog|sync_acc|grok_register.register' 2>/dev/null | grep -v "reset_pipeline\|pgrep\|grep" >/dev/null; then
|
||||
echo "[!] 检测到相关进程仍在运行:"
|
||||
pgrep -af 'xai_enroller|acpa_watchdog|sync_acc|grok_register.register' 2>/dev/null | grep -v "reset_pipeline\|pgrep" || true
|
||||
echo " 建议先 Ctrl-C 停掉 auth-service / register,再 --yes"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "-- 将处理的目标 --"
|
||||
if [[ "$KEEP_REGISTER" -eq 0 ]]; then
|
||||
for f in "${REGISTER_FILES[@]}"; do
|
||||
if [[ -e "$f" ]]; then
|
||||
echo " [register] $(size_of "$f") $f"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo " [register] 跳过 (--keep-register)"
|
||||
fi
|
||||
|
||||
if [[ "$KEEP_CPA" -eq 0 ]]; then
|
||||
cpa_n=$(find "$KEYS/cpa_ready" -name 'xai-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo " [cpa] ${cpa_n} 个 xai-*.json @ $KEYS/cpa_ready/"
|
||||
[[ -f "$KEYS/cpa_ready/_state.tsv" ]] && echo " [cpa] _state.tsv"
|
||||
else
|
||||
echo " [cpa] 跳过 (--keep-cpa)"
|
||||
fi
|
||||
|
||||
auth_n=$(find "$AUTH_DIR/authenticated" -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo " [auth] authenticated/ ${auth_n} 文件 ($(size_of "$AUTH_DIR/authenticated"))"
|
||||
[[ -d "$AUTH_DIR/claimed" ]] && echo " [auth] claimed/ $(find "$AUTH_DIR/claimed" -type f 2>/dev/null | wc -l | tr -d ' ') 文件"
|
||||
[[ -f "$AUTH_DIR/enrollment-ledger.db" ]] && echo " [auth] enrollment-ledger.db $(size_of "$AUTH_DIR/enrollment-ledger.db")"
|
||||
[[ -f "$AUTH_DIR/source-snapshot.jsonl" ]] && echo " [auth] source-snapshot.jsonl $(size_of "$AUTH_DIR/source-snapshot.jsonl") lines=$(wc -l < "$AUTH_DIR/source-snapshot.jsonl" 2>/dev/null | tr -d ' ')"
|
||||
if [[ "$KEEP_AUTH_SALT" -eq 0 && -f "$AUTH_SALT" ]]; then
|
||||
echo " [auth] .ledger-salt (将删,指纹会重算)"
|
||||
else
|
||||
echo " [auth] .ledger-salt 保留"
|
||||
fi
|
||||
echo
|
||||
|
||||
if [[ "$YES" -ne 1 ]]; then
|
||||
echo "dry-run 模式,未删除任何东西。"
|
||||
echo "确认后执行: bash reset_pipeline.sh --yes"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rm_file() {
|
||||
local f="$1"
|
||||
if [[ -f "$f" || -L "$f" ]]; then
|
||||
rm -f -- "$f"
|
||||
echo " removed file $f"
|
||||
fi
|
||||
}
|
||||
|
||||
wipe_dir_contents() {
|
||||
local d="$1"
|
||||
if [[ -d "$d" ]]; then
|
||||
# 只清内容,保留目录本身
|
||||
find "$d" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||
echo " wiped dir $d/"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "-- 执行删除 --"
|
||||
|
||||
if [[ "$KEEP_REGISTER" -eq 0 ]]; then
|
||||
for f in "${REGISTER_FILES[@]}"; do
|
||||
rm_file "$f"
|
||||
done
|
||||
# 重建空占位,避免下游 open 报错
|
||||
: > "$KEYS/accounts.txt"
|
||||
: > "$KEYS/grok.txt"
|
||||
: > "$KEYS/auth-sessions.jsonl"
|
||||
: > "$KEYS/acc.md"
|
||||
chmod 600 "$KEYS/acc.md" "$KEYS/auth-sessions.jsonl" 2>/dev/null || true
|
||||
echo " recreated empty accounts.txt / grok.txt / auth-sessions.jsonl / acc.md"
|
||||
fi
|
||||
|
||||
if [[ "$KEEP_CPA" -eq 0 ]]; then
|
||||
mkdir -p "$KEYS/cpa_ready"
|
||||
find "$KEYS/cpa_ready" -type f \( -name 'xai-*.json' -o -name '_state.tsv' \) -delete 2>/dev/null || true
|
||||
echo " wiped $KEYS/cpa_ready/xai-*.json + _state.tsv"
|
||||
fi
|
||||
|
||||
# 认证侧:这是旧号回灌的真正源头
|
||||
mkdir -p "$AUTH_DIR/authenticated" "$AUTH_DIR/claimed"
|
||||
wipe_dir_contents "$AUTH_DIR/authenticated"
|
||||
wipe_dir_contents "$AUTH_DIR/claimed"
|
||||
rm_file "$AUTH_DIR/enrollment-ledger.db"
|
||||
rm_file "$AUTH_DIR/source-snapshot.jsonl"
|
||||
# 偶发的 sqlite 旁路文件
|
||||
rm_file "$AUTH_DIR/enrollment-ledger.db-wal"
|
||||
rm_file "$AUTH_DIR/enrollment-ledger.db-shm"
|
||||
rm_file "$AUTH_DIR/enrollment-ledger.db-journal"
|
||||
|
||||
if [[ "$KEEP_AUTH_SALT" -eq 0 ]]; then
|
||||
rm_file "$AUTH_SALT"
|
||||
fi
|
||||
|
||||
# 兜底:项目内若误放了同名目录也清
|
||||
for extra in "$KEYS/authenticated" "$KEYS/claimed"; do
|
||||
if [[ -d "$extra" ]]; then
|
||||
wipe_dir_contents "$extra"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "-- 清零后状态 --"
|
||||
echo " accounts.txt lines = $(wc -l < "$KEYS/accounts.txt" 2>/dev/null | tr -d ' ')"
|
||||
echo " auth-sessions = $(wc -l < "$KEYS/auth-sessions.jsonl" 2>/dev/null | tr -d ' ')"
|
||||
echo " acc.md tokens = $(wc -l < "$KEYS/acc.md" 2>/dev/null | tr -d ' ')"
|
||||
echo " cpa_ready json = $(find "$KEYS/cpa_ready" -name 'xai-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
echo " authenticated json = $(find "$AUTH_DIR/authenticated" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
echo " ledger.db = $([[ -f "$AUTH_DIR/enrollment-ledger.db" ]] && echo present || echo gone)"
|
||||
echo " snapshot = $([[ -f "$AUTH_DIR/source-snapshot.jsonl" ]] && echo present || echo gone)"
|
||||
echo
|
||||
echo "完成。重开顺序建议:"
|
||||
echo " 1) bash start.sh # 注册出 SSO → keys/auth-sessions.jsonl + accounts.txt"
|
||||
echo " 2) bash auth-service.sh # device-flow → AUTH_DIR/authenticated + acpa→cpa_ready + sync_acc"
|
||||
echo " 若只想清 CPA/认证、保留刚注的 SSO: 下次加 --keep-register"
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 快速清历史归档(不碰现网号池)。
|
||||
#
|
||||
# 只删这些「历史」:
|
||||
# keys/cpa_ready/cpa_ready_*.zip 导出包
|
||||
# keys/cpa_ready/_discarded/** 已丢弃号
|
||||
# keys/*.bak* / keys/*~ / keys/__pycache__
|
||||
# keys/async_auth.log / logs/*
|
||||
# keys/212.zip 等杂包
|
||||
#
|
||||
# 默认保留:
|
||||
# keys/cpa_ready/xai-*.json
|
||||
# keys/cpa_ready/_state.tsv
|
||||
# keys/acc.md / accounts.txt / auth-sessions.jsonl
|
||||
# AUTH_DIR/authenticated 现网出货
|
||||
#
|
||||
# 用法(项目根):
|
||||
# bash scripts/clean_history.sh # dry-run
|
||||
# bash scripts/clean_history.sh --yes # 真删
|
||||
# bash scripts/clean_history.sh --yes --deep # 再清 source-snapshot / 旧 .bak 脚本
|
||||
#
|
||||
# 全量清零号池请用: bash reset_pipeline.sh --yes
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
KEYS="$ROOT/keys"
|
||||
CPA="$KEYS/cpa_ready"
|
||||
AUTH_DIR="${XAI_AUTH_SERVICE_LOCAL_DIR:-${XAI_ENROLLER_LOCAL_AUTH_DIR:-$HOME/Downloads/grok-free-register-auth}}"
|
||||
LOGS="$ROOT/logs"
|
||||
|
||||
YES=0
|
||||
DEEP=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--yes|-y) YES=1 ;;
|
||||
--deep) DEEP=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
size_of() {
|
||||
local p="$1"
|
||||
if [[ -e "$p" ]]; then
|
||||
du -sh "$p" 2>/dev/null | awk '{print $1}'
|
||||
else
|
||||
echo "-"
|
||||
fi
|
||||
}
|
||||
|
||||
list_targets() {
|
||||
# zips / archives under cpa_ready and keys
|
||||
find "$CPA" -maxdepth 1 -type f \( -name 'cpa_ready_*.zip' -o -name '*.zip' -o -name '*.tar' -o -name '*.tar.gz' -o -name '*.tgz' \) 2>/dev/null || true
|
||||
find "$KEYS" -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar' -o -name '*.tar.gz' -o -name '*.tgz' \) 2>/dev/null || true
|
||||
# discarded
|
||||
if [[ -d "$CPA/_discarded" ]]; then
|
||||
find "$CPA/_discarded" -mindepth 1 2>/dev/null || true
|
||||
fi
|
||||
# logs / pyc / bak
|
||||
find "$KEYS" -maxdepth 1 -type f \( -name '*.log' -o -name '*.bak' -o -name '*.bak.*' -o -name '*~' \) 2>/dev/null || true
|
||||
find "$KEYS" -maxdepth 1 -type d -name '__pycache__' 2>/dev/null || true
|
||||
if [[ -d "$LOGS" ]]; then
|
||||
find "$LOGS" -type f 2>/dev/null || true
|
||||
fi
|
||||
if [[ "$DEEP" -eq 1 ]]; then
|
||||
# deep: old snapshot growth + script backups; still keep live pool
|
||||
[[ -f "$AUTH_DIR/source-snapshot.jsonl" ]] && echo "$AUTH_DIR/source-snapshot.jsonl"
|
||||
find "$KEYS" -maxdepth 1 -type f -name 'acpa_watchdog.py.bak*' 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
mapfile -t TARGETS < <(list_targets | awk 'NF' | sort -u)
|
||||
|
||||
echo "=== grok-free-register 快速清历史 ==="
|
||||
echo "ROOT = $ROOT"
|
||||
echo "KEYS = $KEYS"
|
||||
echo "AUTH_DIR = $AUTH_DIR"
|
||||
echo "MODE = $([[ $YES -eq 1 ]] && echo APPLY || echo dry-run)$([[ $DEEP -eq 1 ]] && echo ' +deep' || true)"
|
||||
echo
|
||||
|
||||
if [[ "${#TARGETS[@]}" -eq 0 ]]; then
|
||||
echo "没有可清的历史文件。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "-- 将处理 --"
|
||||
total=0
|
||||
for f in "${TARGETS[@]}"; do
|
||||
if [[ -e "$f" ]]; then
|
||||
echo " $(size_of "$f") $f"
|
||||
total=$((total + 1))
|
||||
fi
|
||||
done
|
||||
echo " 共 $total 项"
|
||||
echo
|
||||
|
||||
# live pool summary (never touched)
|
||||
echo "-- 现网号池(保留)--"
|
||||
echo " cpa xai-*.json = $(find "$CPA" -maxdepth 1 -name 'xai-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
echo " acc.md lines = $(wc -l < "$KEYS/acc.md" 2>/dev/null | tr -d ' ' || echo 0)"
|
||||
echo " sessions = $(wc -l < "$KEYS/auth-sessions.jsonl" 2>/dev/null | tr -d ' ' || echo 0)"
|
||||
echo " authenticated = $(find "$AUTH_DIR/authenticated" -type f 2>/dev/null | wc -l | tr -d ' ' || echo 0)"
|
||||
echo
|
||||
|
||||
if [[ "$YES" -ne 1 ]]; then
|
||||
echo "dry-run。确认后: bash scripts/clean_history.sh --yes"
|
||||
echo "更深一层(含 snapshot): bash scripts/clean_history.sh --yes --deep"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "-- 执行删除 --"
|
||||
for f in "${TARGETS[@]}"; do
|
||||
if [[ -d "$f" ]]; then
|
||||
rm -rf -- "$f"
|
||||
echo " removed dir $f"
|
||||
elif [[ -e "$f" ]]; then
|
||||
rm -f -- "$f"
|
||||
echo " removed file $f"
|
||||
fi
|
||||
done
|
||||
|
||||
# keep empty discarded dir
|
||||
mkdir -p "$CPA/_discarded" 2>/dev/null || true
|
||||
mkdir -p "$LOGS" 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "-- 清后 --"
|
||||
echo " cpa zip left = $(find "$CPA" -maxdepth 1 -name '*.zip' -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
echo " discarded files= $(find "$CPA/_discarded" -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
echo " cpa xai-*.json = $(find "$CPA" -maxdepth 1 -name 'xai-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
echo " keys size = $(size_of "$KEYS")"
|
||||
echo "完成。"
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ensure_runtime() {
|
||||
local lock_dir=".setup.lock"
|
||||
local acquired=0
|
||||
local attempt
|
||||
|
||||
for attempt in {1..300}; do
|
||||
if mkdir "$lock_dir" 2>/dev/null; then
|
||||
acquired=1
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
if [ "$acquired" -ne 1 ]; then
|
||||
echo "[!] 另一个安装进程长时间未结束,请稍后重试。" >&2
|
||||
return 1
|
||||
fi
|
||||
trap 'rmdir .setup.lock 2>/dev/null || true' EXIT INT TERM
|
||||
|
||||
if [ ! -d .venv ]; then
|
||||
echo "[*] 首次运行,安装依赖..."
|
||||
if ! bash setup.sh; then
|
||||
rmdir "$lock_dir"
|
||||
trap - EXIT INT TERM
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
rmdir "$lock_dir"
|
||||
trap - EXIT INT TERM
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""通过 SSH 导出不含密码的注册会话;兼容历史裸 SSO 记录。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
COOKIE_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"value",
|
||||
"url",
|
||||
"domain",
|
||||
"path",
|
||||
"expires",
|
||||
"httpOnly",
|
||||
"secure",
|
||||
"sameSite",
|
||||
}
|
||||
)
|
||||
MAX_RECORD_BYTES = 256 * 1024
|
||||
LEGACY_COOKIE_SCOPE = {
|
||||
"name": "sso",
|
||||
"domain": "accounts.x.ai",
|
||||
"path": "/",
|
||||
"secure": True,
|
||||
"httpOnly": True,
|
||||
"sameSite": "Lax",
|
||||
}
|
||||
|
||||
|
||||
def _decode_json_line(raw, label):
|
||||
if len(raw) > MAX_RECORD_BYTES:
|
||||
raise ValueError(f"invalid {label} record")
|
||||
try:
|
||||
document = json.loads(raw.decode("utf-8"))
|
||||
email = document["email"]
|
||||
cookies = document["cookies"]
|
||||
except (UnicodeDecodeError, TypeError, ValueError, KeyError) as exc:
|
||||
raise ValueError(f"invalid {label} record") from exc
|
||||
if not isinstance(email, str) or not email or not isinstance(cookies, list) or not cookies:
|
||||
raise ValueError(f"invalid {label} record")
|
||||
try:
|
||||
email.encode("utf-8")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ValueError(f"invalid {label} record") from exc
|
||||
normalized = []
|
||||
for cookie in cookies:
|
||||
if not isinstance(cookie, dict):
|
||||
raise ValueError(f"invalid {label} record")
|
||||
filtered = {key: cookie[key] for key in COOKIE_FIELDS if key in cookie}
|
||||
if not all(
|
||||
isinstance(filtered.get(key), str) and filtered[key]
|
||||
for key in ("name", "value")
|
||||
):
|
||||
raise ValueError(f"invalid {label} record")
|
||||
scope = filtered.get("domain") or filtered.get("url")
|
||||
if not isinstance(scope, str) or not scope:
|
||||
raise ValueError(f"invalid {label} record")
|
||||
try:
|
||||
filtered["name"].encode("utf-8")
|
||||
filtered["value"].encode("utf-8")
|
||||
scope.encode("utf-8")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ValueError(f"invalid {label} record") from exc
|
||||
normalized.append(filtered)
|
||||
return {"email": email, "cookies": normalized}
|
||||
|
||||
|
||||
def _complete_lines(data):
|
||||
"""Return newline-terminated records and the unconsumed trailing bytes."""
|
||||
parts = data.split(b"\n")
|
||||
return parts[:-1], parts[-1]
|
||||
|
||||
|
||||
def _read_complete_file(path):
|
||||
if not path.exists():
|
||||
return []
|
||||
lines, _incomplete = _complete_lines(path.read_bytes())
|
||||
return [line for line in lines if line]
|
||||
|
||||
|
||||
def load_snapshots(path, *, raw_lines=None):
|
||||
snapshots = {}
|
||||
scopes = {}
|
||||
lines = _read_complete_file(path) if raw_lines is None else raw_lines
|
||||
for line in lines:
|
||||
document = _decode_json_line(line, "session")
|
||||
email = document["email"]
|
||||
cookies = document["cookies"]
|
||||
if email in snapshots:
|
||||
continue
|
||||
snapshots[email] = {"email": email, "cookies": cookies}
|
||||
for cookie in cookies:
|
||||
name = cookie.get("name")
|
||||
domain = cookie.get("domain")
|
||||
if name in {"sso", "sso-rw"} and domain:
|
||||
scopes[(name, domain)] = {
|
||||
"name": name,
|
||||
"domain": domain,
|
||||
"path": cookie.get("path", "/"),
|
||||
"secure": bool(cookie.get("secure", True)),
|
||||
"httpOnly": bool(cookie.get("httpOnly", True)),
|
||||
"sameSite": cookie.get("sameSite", "Lax"),
|
||||
}
|
||||
return snapshots, scopes
|
||||
|
||||
|
||||
def export_sessions(sessions_path, accounts_path, *, session_lines=None):
|
||||
snapshots, scopes = load_snapshots(sessions_path, raw_lines=session_lines)
|
||||
for document in snapshots.values():
|
||||
yield document
|
||||
if not accounts_path.exists():
|
||||
return
|
||||
legacy_scopes = list(scopes.values()) or [LEGACY_COOKIE_SCOPE]
|
||||
for raw in _read_complete_file(accounts_path):
|
||||
try:
|
||||
email, _password, sso = raw.decode("utf-8").rsplit(":", 2)
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise ValueError("invalid account record") from exc
|
||||
if not email or not sso or email in snapshots:
|
||||
continue
|
||||
cookies = [{**scope, "value": sso} for scope in legacy_scopes]
|
||||
yield {"email": email, "cookies": cookies}
|
||||
|
||||
|
||||
def _write_document(document):
|
||||
payload = json.dumps(document, separators=(",", ":"))
|
||||
if len(payload.encode("utf-8")) > MAX_RECORD_BYTES:
|
||||
raise ValueError("invalid session record")
|
||||
print(payload, flush=False)
|
||||
|
||||
|
||||
def export_and_follow(sessions_path, accounts_path, *, poll_seconds=0.25):
|
||||
"""Emit a complete snapshot, then losslessly follow the same JSONL fd."""
|
||||
sessions_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not sessions_path.exists():
|
||||
fd = os.open(sessions_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
os.close(fd)
|
||||
os.chmod(sessions_path, 0o600)
|
||||
stream = sessions_path.open("rb")
|
||||
|
||||
with stream:
|
||||
opened = os.fstat(stream.fileno())
|
||||
initial_lines, pending = _complete_lines(stream.read())
|
||||
if len(pending) > MAX_RECORD_BYTES:
|
||||
raise ValueError("invalid session record")
|
||||
for document in export_sessions(
|
||||
sessions_path,
|
||||
accounts_path,
|
||||
session_lines=[line for line in initial_lines if line],
|
||||
):
|
||||
_write_document(document)
|
||||
sys.stdout.flush()
|
||||
|
||||
while True:
|
||||
chunk = stream.read()
|
||||
if chunk:
|
||||
complete, pending = _complete_lines(pending + chunk)
|
||||
if len(pending) > MAX_RECORD_BYTES:
|
||||
raise ValueError("invalid session record")
|
||||
for raw in complete:
|
||||
if raw:
|
||||
_write_document(_decode_json_line(raw, "session"))
|
||||
sys.stdout.flush()
|
||||
continue
|
||||
|
||||
try:
|
||||
current = sessions_path.stat()
|
||||
except FileNotFoundError:
|
||||
return 3
|
||||
if (
|
||||
current.st_dev != opened.st_dev
|
||||
or current.st_ino != opened.st_ino
|
||||
or current.st_size < stream.tell()
|
||||
):
|
||||
return 3
|
||||
time.sleep(poll_seconds)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--follow", action="store_true")
|
||||
parser.add_argument("sessions_path", type=Path)
|
||||
parser.add_argument("accounts_path", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.follow:
|
||||
raise SystemExit(export_and_follow(args.sessions_path, args.accounts_path))
|
||||
for document in export_sessions(args.sessions_path, args.accounts_path):
|
||||
_write_document(document)
|
||||
except ValueError:
|
||||
raise SystemExit(4) from None
|
||||
except BrokenPipeError:
|
||||
try:
|
||||
sys.stdout.close()
|
||||
finally:
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本机注册 → 推 SSO 源到远端 auth 机(local 模式吃 keys/)
|
||||
#
|
||||
# 动态公网 IP 不适合「远端 SSH 拉本机」;改由本机主动推:
|
||||
# keys/auth-sessions.jsonl
|
||||
# keys/accounts.txt
|
||||
# → AUTH_HOST:REMOTE_ROOT/keys/
|
||||
#
|
||||
# 用法(本机项目根或任意 cwd):
|
||||
# bash scripts/push_keys_to_auth.sh # 推一次
|
||||
# bash scripts/push_keys_to_auth.sh --watch # 常驻,文件变了再推
|
||||
# bash scripts/push_keys_to_auth.sh --interval 15
|
||||
#
|
||||
# 环境变量(可写本机 .env,本脚本会 source):
|
||||
# AUTH_SSH_HOST=user@auth-server.example
|
||||
# AUTH_REMOTE_ROOT=/opt/grok-free-register
|
||||
# AUTH_SSH_IDENTITY= # 可选 -i 路径
|
||||
# AUTH_PUSH_INTERVAL=20 # --watch 轮询秒
|
||||
#
|
||||
# 无内置默认主机/路径:必须显式设置 AUTH_SSH_HOST 与 AUTH_REMOTE_ROOT。
|
||||
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# 调用方已 export 的 AUTH_* 优先(.env 不得盖掉),方便并行推多台 auth
|
||||
_PRE_AUTH_SSH_HOST="${AUTH_SSH_HOST-}"
|
||||
_PRE_AUTH_REMOTE_ROOT="${AUTH_REMOTE_ROOT-}"
|
||||
_PRE_AUTH_SSH_IDENTITY="${AUTH_SSH_IDENTITY-}"
|
||||
_PRE_AUTH_PUSH_INTERVAL="${AUTH_PUSH_INTERVAL-}"
|
||||
|
||||
if [[ -f "$ROOT/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "$ROOT/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
AUTH_SSH_HOST="${_PRE_AUTH_SSH_HOST:-${AUTH_SSH_HOST:-}}"
|
||||
AUTH_REMOTE_ROOT="${_PRE_AUTH_REMOTE_ROOT:-${AUTH_REMOTE_ROOT:-}}"
|
||||
AUTH_SSH_IDENTITY="${_PRE_AUTH_SSH_IDENTITY:-${AUTH_SSH_IDENTITY:-}}"
|
||||
AUTH_PUSH_INTERVAL="${_PRE_AUTH_PUSH_INTERVAL:-${AUTH_PUSH_INTERVAL:-20}}"
|
||||
|
||||
if [[ -z "$AUTH_SSH_HOST" || -z "$AUTH_REMOTE_ROOT" ]]; then
|
||||
echo "push_keys_to_auth: set AUTH_SSH_HOST and AUTH_REMOTE_ROOT (env or .env)" >&2
|
||||
echo " e.g. AUTH_SSH_HOST=user@auth-server.example AUTH_REMOTE_ROOT=/opt/grok-free-register" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
WATCH=0
|
||||
INTERVAL="$AUTH_PUSH_INTERVAL"
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--watch) WATCH=1 ;;
|
||||
--interval)
|
||||
# next arg handled below if present as --interval=N form preferred
|
||||
;;
|
||||
--interval=*) INTERVAL="${a#--interval=}" ;;
|
||||
-h|--help)
|
||||
sed -n '2,22p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
# support: --interval 15
|
||||
prev=""
|
||||
for a in "$@"; do
|
||||
if [[ "$prev" == "--interval" ]]; then
|
||||
INTERVAL="$a"
|
||||
fi
|
||||
prev="$a"
|
||||
done
|
||||
|
||||
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=15 -o ServerAliveInterval=15)
|
||||
if [[ -n "$AUTH_SSH_IDENTITY" ]]; then
|
||||
SSH_OPTS+=(-i "$AUTH_SSH_IDENTITY")
|
||||
fi
|
||||
|
||||
KEYS_SRC="$ROOT/keys"
|
||||
REMOTE_KEYS="${AUTH_REMOTE_ROOT}/keys"
|
||||
# 只推 auth 源,不推 cpa_ready/acc(远端自己整备)
|
||||
FILES=(auth-sessions.jsonl accounts.txt)
|
||||
|
||||
log() { printf '[push_keys %s] %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
|
||||
fingerprint() {
|
||||
local f
|
||||
for f in "${FILES[@]}"; do
|
||||
if [[ -f "$KEYS_SRC/$f" ]]; then
|
||||
# size + mtime + sha256 head — cheap change detect
|
||||
stat -c '%s %Y' "$KEYS_SRC/$f" 2>/dev/null || stat -f '%z %m' "$KEYS_SRC/$f"
|
||||
sha256sum "$KEYS_SRC/$f" 2>/dev/null | awk '{print $1}'
|
||||
else
|
||||
echo "missing:$f"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
push_once() {
|
||||
local missing=0 f
|
||||
for f in "${FILES[@]}"; do
|
||||
if [[ ! -f "$KEYS_SRC/$f" ]]; then
|
||||
log "⚠ 缺 $KEYS_SRC/$f"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
if [[ "$missing" -eq 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 远端原子替换:先推 .push.tmp 再 mv
|
||||
ssh "${SSH_OPTS[@]}" "$AUTH_SSH_HOST" "mkdir -p $(printf %q "$REMOTE_KEYS") && chmod 700 $(printf %q "$REMOTE_KEYS") 2>/dev/null || true"
|
||||
|
||||
local remote_tmp remote_final
|
||||
for f in "${FILES[@]}"; do
|
||||
remote_tmp="${REMOTE_KEYS}/.${f}.push.tmp"
|
||||
remote_final="${REMOTE_KEYS}/${f}"
|
||||
# rsync over ssh when available; fallback scp
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -az -e "ssh ${SSH_OPTS[*]}" \
|
||||
"$KEYS_SRC/$f" \
|
||||
"${AUTH_SSH_HOST}:${remote_tmp}"
|
||||
else
|
||||
scp "${SSH_OPTS[@]}" "$KEYS_SRC/$f" "${AUTH_SSH_HOST}:${remote_tmp}"
|
||||
fi
|
||||
ssh "${SSH_OPTS[@]}" "$AUTH_SSH_HOST" \
|
||||
"chmod 600 $(printf %q "$remote_tmp") && mv -f $(printf %q "$remote_tmp") $(printf %q "$remote_final")"
|
||||
done
|
||||
|
||||
local n_sess n_acc
|
||||
n_sess=$(wc -l < "$KEYS_SRC/auth-sessions.jsonl" | tr -d ' ')
|
||||
n_acc=$(wc -l < "$KEYS_SRC/accounts.txt" | tr -d ' ')
|
||||
log "→ ${AUTH_SSH_HOST}:${REMOTE_KEYS}/ sessions=${n_sess} accounts=${n_acc}"
|
||||
}
|
||||
|
||||
LAST_FP=""
|
||||
if [[ "$WATCH" -eq 0 ]]; then
|
||||
push_once
|
||||
exit $?
|
||||
fi
|
||||
|
||||
log "watch host=${AUTH_SSH_HOST} root=${AUTH_REMOTE_ROOT} interval=${INTERVAL}s"
|
||||
while true; do
|
||||
FP="$(fingerprint)"
|
||||
if [[ "$FP" != "$LAST_FP" ]]; then
|
||||
if push_once; then
|
||||
LAST_FP="$FP"
|
||||
else
|
||||
log "推送失败,${INTERVAL}s 后重试"
|
||||
fi
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Grok Free Register — 一键安装脚本
|
||||
# 用法: bash setup.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Grok Free Register 安装 ==="
|
||||
|
||||
# 检测系统
|
||||
if [ -f /etc/debian_version ]; then
|
||||
echo "[1/4] 安装系统依赖 (Debian/Ubuntu)..."
|
||||
sudo apt update -qq
|
||||
sudo apt install -y -qq \
|
||||
python3 python3-pip python3-venv \
|
||||
libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 \
|
||||
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
|
||||
libcairo2 libasound2t64 libnspr4 libnss3 libxshmfence1 \
|
||||
2>/dev/null || true
|
||||
# 兼容旧版 Ubuntu
|
||||
sudo apt install -y -qq libatk1.0-0 libatk-bridge2.0-0 libcups2 libasound2 2>/dev/null || true
|
||||
elif [ -f /etc/redhat-release ]; then
|
||||
echo "[1/4] 安装系统依赖 (RHEL/CentOS)..."
|
||||
sudo yum install -y -q \
|
||||
python3 python3-pip \
|
||||
atk cups-libs libdrm libXcomposite libXdamage libXfixes libXrandr \
|
||||
mesa-libgbm pango cairo alsa-lib nspr nss libxshmfence \
|
||||
2>/dev/null || true
|
||||
else
|
||||
echo "[1/4] 未知系统,跳过系统依赖(如 Chrome 启动失败请手动安装)"
|
||||
fi
|
||||
|
||||
# Python 虚拟环境
|
||||
echo "[2/4] 创建 Python 环境..."
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -q --upgrade pip
|
||||
.venv/bin/pip install -q -r requirements.txt
|
||||
|
||||
# 本项目直接由 Playwright 启动二进制,因此显式准备 CloakBrowser Chromium。
|
||||
echo "[3/4] 下载 CloakBrowser Chromium..."
|
||||
.venv/bin/python -m cloakbrowser install
|
||||
|
||||
# 创建输出目录
|
||||
mkdir -p keys
|
||||
|
||||
echo "[4/4] 安装完成!"
|
||||
echo ""
|
||||
echo "运行注册服务: bash start.sh"
|
||||
echo "运行自建邮箱服务: bash start.sh --email-service"
|
||||
echo "运行认证服务: bash auth-service.sh"
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""批量 SSO → CPA OAuth(referrer=grok-build)。
|
||||
|
||||
用法:
|
||||
python sso_to_cpa.py --sso accounts.txt --out-dir ./cpa_auths
|
||||
python sso_to_cpa.py --sso-cookie "eyJ..." --email a@b.com
|
||||
python sso_to_cpa.py --sso accounts.txt --concurrency 4 --proxy http://127.0.0.1:7897
|
||||
|
||||
输入行格式:
|
||||
<sso_jwt>
|
||||
邮箱----密码----sso_jwt
|
||||
邮箱----sso_jwt
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
cfg_path = _ROOT / "config.json"
|
||||
if not cfg_path.is_file():
|
||||
return {}
|
||||
try:
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_line(line: str) -> tuple[str, str]:
|
||||
line = (line or "").strip()
|
||||
if not line or line.startswith("#"):
|
||||
return "", ""
|
||||
email = ""
|
||||
sso = line
|
||||
if "----" in line:
|
||||
parts = [p.strip() for p in line.split("----")]
|
||||
if parts:
|
||||
email = parts[0]
|
||||
sso = parts[-1]
|
||||
return email, sso
|
||||
|
||||
|
||||
def load_records(sso_file: str, sso_cookie: str, email: str) -> list[dict]:
|
||||
from oidc_mint.oauth_code import normalize_sso_cookie
|
||||
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
if sso_cookie.strip():
|
||||
sso = normalize_sso_cookie(sso_cookie)
|
||||
if sso and sso not in seen:
|
||||
seen.add(sso)
|
||||
out.append({"email": (email or "").strip(), "sso": sso})
|
||||
return out
|
||||
|
||||
if not sso_file:
|
||||
return out
|
||||
path = Path(sso_file)
|
||||
data = path.read_text(encoding="utf-8", errors="replace")
|
||||
for line in data.splitlines():
|
||||
em, sso_raw = _parse_line(line)
|
||||
sso = normalize_sso_cookie(sso_raw)
|
||||
if not sso or sso in seen:
|
||||
continue
|
||||
seen.add(sso)
|
||||
out.append({"email": em, "sso": sso})
|
||||
return out
|
||||
|
||||
|
||||
def convert_one(
|
||||
rec: dict,
|
||||
*,
|
||||
out_dir: Path,
|
||||
proxy: str | None,
|
||||
push: bool,
|
||||
cfg: dict,
|
||||
lock: threading.Lock,
|
||||
index: int,
|
||||
total: int,
|
||||
) -> dict:
|
||||
from oidc_mint.oauth_code import OAuthCodeError, mint_from_sso
|
||||
import cpa
|
||||
|
||||
email = (rec.get("email") or "").strip()
|
||||
sso = rec["sso"]
|
||||
tag = email or sso[:16]
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[{index}/{total}] {msg}", flush=True)
|
||||
|
||||
# 预检查已存在
|
||||
if email:
|
||||
pre = out_dir / cpa.credential_file_name(email)
|
||||
if pre.is_file():
|
||||
log(f"skip exists {pre.name}")
|
||||
return {"ok": True, "skipped": True, "email": email, "path": str(pre)}
|
||||
|
||||
log(f"converting {tag}")
|
||||
try:
|
||||
tokens = mint_from_sso(
|
||||
sso,
|
||||
proxy=proxy,
|
||||
log=lambda m: log(f" {m}"),
|
||||
require_referrer=True,
|
||||
)
|
||||
except OAuthCodeError as exc:
|
||||
log(f"FAIL {tag}: {exc}")
|
||||
return {"ok": False, "email": email, "error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"FAIL {tag}: {exc}")
|
||||
return {"ok": False, "email": email, "error": str(exc)}
|
||||
|
||||
# email 可从 id_token 补
|
||||
if not email and tokens.get("id_token"):
|
||||
try:
|
||||
from cpa.schema import _jwt_payload
|
||||
|
||||
email = str(_jwt_payload(tokens["id_token"]).get("email") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
payload = cpa.build_cpa_xai_auth(
|
||||
email=email,
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens["refresh_token"],
|
||||
id_token=tokens.get("id_token"),
|
||||
expires_in=tokens.get("expires_in"),
|
||||
base_url=cfg.get("cpa_base_url") or cpa.CLI_BASE_URL,
|
||||
sso=sso,
|
||||
)
|
||||
with lock:
|
||||
path = cpa.write_cpa_xai_auth(out_dir, payload)
|
||||
log(f"OK {path.name} referrer={payload.get('referrer')!r}")
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"email": email or payload.get("email"),
|
||||
"path": str(path),
|
||||
"referrer": payload.get("referrer"),
|
||||
"pushed": False,
|
||||
}
|
||||
|
||||
if push:
|
||||
remote_base = str(cfg.get("cpa_remote_base") or "").strip()
|
||||
secret = str(cfg.get("cpa_remote_secret") or "").strip()
|
||||
if remote_base and secret:
|
||||
# 仅首条在 convert 前统一 flush;这里 flush_first=False 避免并发重复重试
|
||||
push_res = cpa.push_with_queue(
|
||||
out_dir,
|
||||
Path(path).name,
|
||||
payload,
|
||||
remote_base=remote_base,
|
||||
secret=secret,
|
||||
proxy=str(cfg.get("cpa_push_proxy") or "").strip() or None,
|
||||
verify_tls=bool(cfg.get("cpa_remote_verify_tls", True)),
|
||||
flush_first=False,
|
||||
log=lambda m: log(m.replace("[Debug] ", "").replace("[!] ", "")),
|
||||
)
|
||||
result["pushed"] = bool(push_res.get("pushed"))
|
||||
if "push_status" in push_res:
|
||||
result["push_status"] = push_res["push_status"]
|
||||
if push_res.get("push_error"):
|
||||
result["push_error"] = push_res["push_error"]
|
||||
else:
|
||||
cpa.record_push_failure(out_dir, Path(path).name, "remote not configured")
|
||||
log("push skipped: remote not configured (queued)")
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
cfg = _load_config()
|
||||
default_proxy = (
|
||||
cfg.get("mint_proxy")
|
||||
or cfg.get("proxy")
|
||||
or os.environ.get("https_proxy")
|
||||
or os.environ.get("http_proxy")
|
||||
or "http://127.0.0.1:7897"
|
||||
)
|
||||
default_out = cfg.get("cpa_auth_dir") or "./cpa_auths"
|
||||
|
||||
p = argparse.ArgumentParser(description="SSO cookie → CPA xai auth (referrer=grok-build)")
|
||||
p.add_argument("--sso", default="", help="sso 列表文件")
|
||||
p.add_argument("--sso-cookie", default="", help="单个 sso cookie")
|
||||
p.add_argument("--email", default="", help="单 cookie 时的 email")
|
||||
p.add_argument("--out-dir", default=default_out, help="输出目录")
|
||||
p.add_argument("--proxy", default=default_proxy, help="HTTP 代理")
|
||||
p.add_argument("--concurrency", type=int, default=1, help="并发数")
|
||||
p.add_argument("--delay", type=float, default=0, help="串行时每个间隔秒")
|
||||
p.add_argument("--push", action="store_true", help="推送到 config 里的远端 CPA")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
records = load_records(args.sso, args.sso_cookie, args.email)
|
||||
if not records:
|
||||
print("需要 --sso 或 --sso-cookie", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
if not out_dir.is_absolute():
|
||||
out_dir = (_ROOT / out_dir).resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
proxy = (args.proxy or "").strip() or None
|
||||
lock = threading.Lock()
|
||||
total = len(records)
|
||||
ok = fail = skipped = 0
|
||||
do_push = args.push or bool(cfg.get("cpa_push_enabled"))
|
||||
|
||||
print(
|
||||
f"开始转换 count={total} concurrency={args.concurrency} out={out_dir} proxy={proxy}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# 开启推送时先重试历史失败队列,再处理本批
|
||||
if do_push:
|
||||
import cpa
|
||||
|
||||
remote_base = str(cfg.get("cpa_remote_base") or "").strip()
|
||||
secret = str(cfg.get("cpa_remote_secret") or "").strip()
|
||||
if remote_base and secret:
|
||||
flush = cpa.flush_push_pending(
|
||||
out_dir,
|
||||
remote_base=remote_base,
|
||||
secret=secret,
|
||||
proxy=str(cfg.get("cpa_push_proxy") or "").strip() or None,
|
||||
verify_tls=bool(cfg.get("cpa_remote_verify_tls", True)),
|
||||
log=lambda m: print(m, flush=True),
|
||||
)
|
||||
if flush.get("total"):
|
||||
print(
|
||||
f"队列重推 ok={flush.get('ok')} fail={flush.get('fail')} "
|
||||
f"missing={flush.get('missing')} remaining={flush.get('remaining')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if args.concurrency <= 1:
|
||||
for i, rec in enumerate(records, 1):
|
||||
r = convert_one(
|
||||
rec,
|
||||
out_dir=out_dir,
|
||||
proxy=proxy,
|
||||
push=do_push,
|
||||
cfg=cfg,
|
||||
lock=lock,
|
||||
index=i,
|
||||
total=total,
|
||||
)
|
||||
if r.get("skipped"):
|
||||
skipped += 1
|
||||
elif r.get("ok"):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
if args.delay > 0 and i < total:
|
||||
time.sleep(args.delay)
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as ex:
|
||||
futs = [
|
||||
ex.submit(
|
||||
convert_one,
|
||||
rec,
|
||||
out_dir=out_dir,
|
||||
proxy=proxy,
|
||||
push=do_push,
|
||||
cfg=cfg,
|
||||
lock=lock,
|
||||
index=i,
|
||||
total=total,
|
||||
)
|
||||
for i, rec in enumerate(records, 1)
|
||||
]
|
||||
for fut in as_completed(futs):
|
||||
r = fut.result()
|
||||
if r.get("skipped"):
|
||||
skipped += 1
|
||||
elif r.get("ok"):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
|
||||
print(f"完成 success={ok} failed={fail} skipped={skipped} total={total}", flush=True)
|
||||
return 1 if fail else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 一键启动:自动装依赖 → 引导配置 → 运行
|
||||
# 用法:
|
||||
# bash start.sh # 首次会引导选模式,之后直接启动
|
||||
# bash start.sh --reconfig # 重新选择邮箱模式
|
||||
# bash start.sh --debug # 保留完整调试面板
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
. scripts/ensure_runtime.sh
|
||||
ensure_runtime
|
||||
|
||||
if [ "${1:-}" = "--email-service" ]; then
|
||||
shift
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
mkdir -p logs
|
||||
exec 8>logs/email-service.lock
|
||||
if ! flock -n 8; then
|
||||
echo "[!] 邮箱服务已经在运行。"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "[*] 启动邮箱服务... (Ctrl-C 停止)"
|
||||
exec .venv/bin/python -m grok_register.email_server "$@"
|
||||
fi
|
||||
|
||||
reconfig=0
|
||||
register_args=()
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--reconfig" ]; then
|
||||
reconfig=1
|
||||
else
|
||||
register_args+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
# 同一工作目录只允许一个注册进程,避免重复启动同时写账号和日志。
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
mkdir -p logs
|
||||
exec 9>logs/register.lock
|
||||
if ! flock -n 9; then
|
||||
echo "[!] 注册机已经在运行。"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1) 配置:无 .env 或显式 --reconfig 时进入引导
|
||||
if [ ! -f .env ] || [ "$reconfig" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "选择邮箱模式:"
|
||||
echo " [1] 免费临时邮箱 (默认 · 零配置 · 直接回车 · 多 provider 自动 fallback)"
|
||||
echo " [2] 自建域名邮箱 (需 Cloudflare Email Routing + 本地 webhook)"
|
||||
read -rp "输入 1 或 2 [1]: " mode || mode=1
|
||||
if [ "$mode" = "2" ]; then
|
||||
read -rp " 你的域名 (如 example.com): " domain
|
||||
read -rp " webhook 地址 [http://127.0.0.1:8080]: " api
|
||||
api=${api:-http://127.0.0.1:8080}
|
||||
cat > .env <<ENV
|
||||
EMAIL_MODE=custom
|
||||
EMAIL_DOMAIN=${domain}
|
||||
EMAIL_API=${api}
|
||||
# CSP 容量(可选,0=按 CPU/内存启动期静态派生)
|
||||
# PHYSICAL_CAP=0
|
||||
# PHYSICAL_PER_CPU=2
|
||||
# PHYSICAL_MEM_MB=512
|
||||
# MIN_FREE_MEM_MB=500
|
||||
ENV
|
||||
echo ""
|
||||
echo "[!] custom 模式还需在另一终端运行收信服务:"
|
||||
echo " bash start.sh --email-service"
|
||||
echo " 并按 README「自建邮箱模式」配置 Cloudflare Email Worker。"
|
||||
else
|
||||
echo "EMAIL_MODE=tempmail" > .env
|
||||
fi
|
||||
echo "[*] 已写入 .env"
|
||||
fi
|
||||
|
||||
# 2) 运行
|
||||
echo "[*] 启动注册服务... (Ctrl-C 停止)"
|
||||
exec .venv/bin/python -m grok_register.register "${register_args[@]}"
|
||||
Whitespace-only changes.
@@ -1,92 +0,0 @@
|
||||
"""
|
||||
共享 fixtures — 供四层测试共用
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from grok_register.core.inventory import Inventory
|
||||
from grok_register.core.observer import Metrics
|
||||
from tests.fakes import (
|
||||
FakeTurnstile, FakeEmailService, FakeRegisterAPI,
|
||||
Conservation, EventLog,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metrics():
|
||||
return Metrics()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inventory(metrics):
|
||||
return Inventory(metrics=metrics)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sems():
|
||||
"""标准 Semaphore 集合(小容量,便于测试)。"""
|
||||
return {
|
||||
'physical': asyncio.Semaphore(4),
|
||||
't_slot': asyncio.Semaphore(4),
|
||||
'q_slot': asyncio.Semaphore(4),
|
||||
'q_pending': asyncio.Semaphore(6),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def large_sems():
|
||||
"""大容量 Semaphore(压力测试用)。"""
|
||||
return {
|
||||
'physical': asyncio.Semaphore(32),
|
||||
't_slot': asyncio.Semaphore(64),
|
||||
'q_slot': asyncio.Semaphore(64),
|
||||
'q_pending': asyncio.Semaphore(48),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conservation(sems, inventory):
|
||||
return Conservation(
|
||||
sems['t_slot'], sems['q_slot'], sems['q_pending'], inventory
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_turnstile():
|
||||
return FakeTurnstile()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_email():
|
||||
return FakeEmailService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_register():
|
||||
return FakeRegisterAPI()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_log():
|
||||
return EventLog()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stop():
|
||||
return asyncio.Event()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_lock():
|
||||
return asyncio.Lock()
|
||||
|
||||
|
||||
# ── 跳过需要网络的测试(本地开发时) ──
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "slow: 压力测试(较慢)")
|
||||
config.addinivalue_line("markers", "needs_network: 需要外部网络")
|
||||
-317
@@ -1,317 +0,0 @@
|
||||
"""
|
||||
Fake 服务 + 测试工具
|
||||
|
||||
FakeTurnstile — 可控 T 生成器:可注入延迟、失败、取消点
|
||||
FakeEmailService — 可控 Q 提供者:可注入延迟、超时、验证码
|
||||
FakeConsumer — 可控 C 消费器:可注入延迟、失败
|
||||
Conservation — slot 守恒断言器
|
||||
CancelPoint — 在指定 await 边界注入取消
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
import dataclasses
|
||||
from typing import Optional, Callable, Any
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Fake T 生成器 (替代 solve_one_turnstile)
|
||||
# ═══════════════════════════════════════════
|
||||
class FakeTurnstile:
|
||||
"""可控 token 生成器。
|
||||
|
||||
config:
|
||||
delay: 生成延迟(秒)
|
||||
fail_rate: 0.0~1.0,随机失败概率
|
||||
token_seq: 预设 token 序列,None 则自动递增
|
||||
"""
|
||||
|
||||
def __init__(self, delay=0.01, fail_rate=0.0, token_seq=None):
|
||||
self.delay = delay
|
||||
self._fail_rate = fail_rate
|
||||
self._seq = token_seq or []
|
||||
self._idx = 0
|
||||
self._call_count = 0
|
||||
self._fail_count = 0
|
||||
self._cancel_at: Optional[int] = None # 第 N 次调用时注入取消
|
||||
|
||||
def set_cancel_at(self, n: int):
|
||||
"""第 n 次调用时注入 CancelledError。"""
|
||||
self._cancel_at = n
|
||||
|
||||
async def generate(self) -> Optional[str]:
|
||||
"""生成一个 token。返回 None 表示失败,raise CancelledError 表示取消。"""
|
||||
self._call_count += 1
|
||||
|
||||
if self._cancel_at is not None and self._call_count == self._cancel_at:
|
||||
raise asyncio.CancelledError(f'FakeTurnstile cancel at call {self._call_count}')
|
||||
|
||||
await asyncio.sleep(self.delay)
|
||||
|
||||
if self._fail_count < self._fail_rate * self._call_count:
|
||||
return None
|
||||
# 简单随机: fail_rate 作为概率
|
||||
import random
|
||||
if random.random() < self._fail_rate:
|
||||
self._fail_count += 1
|
||||
return None
|
||||
|
||||
if self._idx < len(self._seq):
|
||||
tok = self._seq[self._idx]
|
||||
self._idx += 1
|
||||
return tok
|
||||
self._idx += 1
|
||||
return f'fake_token_{self._idx}'
|
||||
|
||||
@property
|
||||
def call_count(self):
|
||||
return self._call_count
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Fake 邮箱服务 (替代 create_email + poll_code)
|
||||
# ═══════════════════════════════════════════
|
||||
@dataclasses.dataclass
|
||||
class FakeEmailResult:
|
||||
handle: str
|
||||
email: str
|
||||
password: str
|
||||
code: Optional[str] = None # None = 轮询超时
|
||||
create_delay: float = 0.01
|
||||
poll_delay: float = 0.01
|
||||
|
||||
|
||||
class FakeEmailService:
|
||||
"""可控邮箱 + 验证码服务。
|
||||
|
||||
results: 预设结果队列,每次 create+poll 消费一个。
|
||||
"""
|
||||
|
||||
def __init__(self, results=None):
|
||||
self._results = list(results or [])
|
||||
self._idx = 0
|
||||
self._create_count = 0
|
||||
self._poll_count = 0
|
||||
self._cancel_create_at: Optional[int] = None
|
||||
self._cancel_poll_at: Optional[int] = None
|
||||
|
||||
def set_cancel_create_at(self, n: int):
|
||||
self._cancel_create_at = n
|
||||
|
||||
def set_cancel_poll_at(self, n: int):
|
||||
self._cancel_poll_at = n
|
||||
|
||||
async def create_email(self):
|
||||
"""创建邮箱。返回 (handle, email, password)。"""
|
||||
self._create_count += 1
|
||||
if self._cancel_create_at and self._create_count == self._cancel_create_at:
|
||||
raise asyncio.CancelledError(f'FakeEmail.create cancel at {self._create_count}')
|
||||
|
||||
if self._idx < len(self._results):
|
||||
r = self._results[self._idx]
|
||||
await asyncio.sleep(r.create_delay)
|
||||
return r.handle, r.email, r.password
|
||||
self._idx += 1
|
||||
n = self._idx
|
||||
await asyncio.sleep(0.01)
|
||||
return f'handle_{n}', f'user_{n}@test.com', f'pass_{n}'
|
||||
|
||||
async def poll_code(self, handle: str, max_wait=90):
|
||||
"""轮询验证码。返回 code 或 None。"""
|
||||
self._poll_count += 1
|
||||
if self._cancel_poll_at and self._poll_count == self._cancel_poll_at:
|
||||
raise asyncio.CancelledError(f'FakeEmail.poll cancel at {self._poll_count}')
|
||||
|
||||
# 找匹配的预设结果
|
||||
for r in self._results:
|
||||
if r.handle == handle:
|
||||
await asyncio.sleep(r.poll_delay)
|
||||
return r.code
|
||||
await asyncio.sleep(0.01)
|
||||
return '000000'
|
||||
|
||||
@property
|
||||
def create_count(self):
|
||||
return self._create_count
|
||||
|
||||
@property
|
||||
def poll_count(self):
|
||||
return self._poll_count
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Fake 注册 API (替代 grpc_verify + server_action_register)
|
||||
# ═══════════════════════════════════════════
|
||||
class FakeRegisterAPI:
|
||||
"""可控注册 API。"""
|
||||
|
||||
def __init__(self, success_rate=1.0, delay=0.01):
|
||||
self.success_rate = success_rate
|
||||
self.delay = delay
|
||||
self._register_count = 0
|
||||
self._cancel_at: Optional[int] = None
|
||||
self._fail_at: Optional[int] = None
|
||||
|
||||
def set_cancel_at(self, n: int):
|
||||
self._cancel_at = n
|
||||
|
||||
def set_fail_at(self, n: int):
|
||||
self._fail_at = n
|
||||
|
||||
async def register(self, email, password, code, token) -> Optional[str]:
|
||||
"""执行注册。返回 SSO 或 None。"""
|
||||
self._register_count += 1
|
||||
if self._cancel_at and self._register_count == self._cancel_at:
|
||||
raise asyncio.CancelledError(f'FakeRegister cancel at {self._register_count}')
|
||||
if self._fail_at and self._register_count == self._fail_at:
|
||||
return None
|
||||
await asyncio.sleep(self.delay)
|
||||
import random
|
||||
if random.random() < self.success_rate:
|
||||
return f'sso_{email}_{int(time.time())}'
|
||||
return None
|
||||
|
||||
@property
|
||||
def register_count(self):
|
||||
return self._register_count
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Slot 守恒断言器
|
||||
# ═══════════════════════════════════════════
|
||||
class Conservation:
|
||||
"""检查 T/Q slot 守恒不变量。
|
||||
|
||||
守恒公式:
|
||||
slot_total = free_slots + inventory_depth + pairleased_held + worker_held
|
||||
"""
|
||||
|
||||
def __init__(self, t_slot_sem, q_slot_sem, q_pending_sem, inventory):
|
||||
self.t_slot_sem = t_slot_sem
|
||||
self.q_slot_sem = q_slot_sem
|
||||
self.q_pending_sem = q_pending_sem
|
||||
self.inventory = inventory
|
||||
# 追踪 Worker 持有的未发布资源
|
||||
self._worker_held_t = 0
|
||||
self._worker_held_q = 0
|
||||
|
||||
def worker_acquire_t(self):
|
||||
self._worker_held_t += 1
|
||||
|
||||
def worker_release_t(self):
|
||||
self._worker_held_t -= 1
|
||||
|
||||
def worker_acquire_q(self):
|
||||
self._worker_held_q += 1
|
||||
|
||||
def worker_release_q(self):
|
||||
self._worker_held_q -= 1
|
||||
|
||||
def check_t_conservation(self, t_slot_total: int, pairleased_t: int = 0):
|
||||
"""检查 T slot 守恒。
|
||||
|
||||
Args:
|
||||
t_slot_total: T_Slot_Sem 初始容量
|
||||
pairleased_t: 当前 PairLease 持有的 T 数量
|
||||
"""
|
||||
free = self.t_slot_sem._value
|
||||
inv = self.inventory.t_depth
|
||||
held = self._worker_held_t
|
||||
total = free + inv + pairleased_t + held
|
||||
assert total == t_slot_total, (
|
||||
f'T slot 守恒违反: free({free}) + inv({inv}) + pairleased({pairleased_t}) '
|
||||
f'+ worker_held({held}) = {total} != total({t_slot_total})'
|
||||
)
|
||||
|
||||
def check_q_conservation(self, q_slot_total: int, pairleased_q: int = 0):
|
||||
"""检查 Q slot 守恒。"""
|
||||
free = self.q_slot_sem._value
|
||||
inv = self.inventory.q_depth
|
||||
held = self._worker_held_q
|
||||
total = free + inv + pairleased_q + held
|
||||
assert total == q_slot_total, (
|
||||
f'Q slot 守恒违反: free({free}) + inv({inv}) + pairleased({pairleased_q}) '
|
||||
f'+ worker_held({held}) = {total} != total({q_slot_total})'
|
||||
)
|
||||
|
||||
def check_pending_conservation(self, pending_total: int, in_flight: int = 0):
|
||||
"""检查 Q pending 守恒。"""
|
||||
free = self.q_pending_sem._value
|
||||
total = free + in_flight
|
||||
assert total == pending_total, (
|
||||
f'Q pending 守恒违反: free({free}) + in_flight({in_flight}) = {total} '
|
||||
f'!= total({pending_total})'
|
||||
)
|
||||
|
||||
def check_all(self, t_total, q_total, pend_total, pairleased_t=0, pairleased_q=0, in_flight=0):
|
||||
"""一次性检查所有守恒。"""
|
||||
self.check_t_conservation(t_total, pairleased_t)
|
||||
self.check_q_conservation(q_total, pairleased_q)
|
||||
self.check_pending_conservation(pend_total, in_flight)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 取消注入器
|
||||
# ═══════════════════════════════════════════
|
||||
class CancelInjector:
|
||||
"""在指定 await 边界前后注入取消。"""
|
||||
|
||||
def __init__(self):
|
||||
self._points: dict[str, asyncio.Event] = {}
|
||||
self._cancel_task: Optional[asyncio.Task] = None
|
||||
|
||||
def register(self, name: str):
|
||||
"""注册一个取消点。"""
|
||||
self._points[name] = asyncio.Event()
|
||||
|
||||
async def wait_at(self, name: str):
|
||||
"""Worker 在此挂起,等待外部触发取消。"""
|
||||
if name in self._points:
|
||||
await self._points[name].wait()
|
||||
|
||||
def trigger(self, name: str, task: asyncio.Task):
|
||||
"""触发指定取消点,取消目标 task。"""
|
||||
if name in self._points:
|
||||
self._points[name].set()
|
||||
task.cancel()
|
||||
|
||||
async def cancel_after(self, delay: float, task: asyncio.Task):
|
||||
"""延迟后取消目标 task。"""
|
||||
await asyncio.sleep(delay)
|
||||
task.cancel()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 事件记录器(用于性质测试)
|
||||
# ═══════════════════════════════════════════
|
||||
@dataclasses.dataclass
|
||||
class Event:
|
||||
ts: float
|
||||
kind: str # 't_produced', 't_admitted', 't_claimed', 'q_sent', 'q_returned',
|
||||
# 'q_admitted', 'q_claimed', 'pair_claimed', 'pair_ok', 'pair_fail',
|
||||
# 't_expired', 'q_expired', 't_discarded', 'q_discarded',
|
||||
# 'sem_t_free', 'sem_q_free', 'sem_pend_free', 'inv_t', 'inv_q'
|
||||
detail: Any = None
|
||||
|
||||
|
||||
class EventLog:
|
||||
"""记录所有事件,用于事后分析。"""
|
||||
|
||||
def __init__(self):
|
||||
self.events: list[Event] = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def record(self, kind: str, detail=None):
|
||||
async with self._lock:
|
||||
self.events.append(Event(ts=time.time(), kind=kind, detail=detail))
|
||||
|
||||
def count(self, kind: str) -> int:
|
||||
return sum(1 for e in self.events if e.kind == kind)
|
||||
|
||||
def filter(self, kind: str) -> list[Event]:
|
||||
return [e for e in self.events if e.kind == kind]
|
||||
|
||||
def dump(self) -> str:
|
||||
lines = []
|
||||
for e in self.events:
|
||||
lines.append(f'{e.ts:.3f} {e.kind} {e.detail or ""}')
|
||||
return '\n'.join(lines)
|
||||
@@ -1,2 +0,0 @@
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.24.0
|
||||
@@ -1,82 +0,0 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from grok_register.core.admission import AdmissionGate
|
||||
|
||||
|
||||
class FakeInventory:
|
||||
def __init__(self):
|
||||
self.t_depth = 0
|
||||
self.q_depth = 0
|
||||
|
||||
|
||||
class AdmissionGateTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_t_production_blocks_at_high_water_and_wakes_at_low_water(self):
|
||||
inventory = FakeInventory()
|
||||
gate = AdmissionGate(inventory, t_low=1, t_high=3, q_low=1, q_high=3)
|
||||
|
||||
inventory.t_depth = 3
|
||||
blocked = asyncio.create_task(gate.acquire_t_production())
|
||||
await asyncio.sleep(0.02)
|
||||
self.assertFalse(blocked.done())
|
||||
|
||||
inventory.t_depth = 1
|
||||
await gate.notify_changed()
|
||||
lease = await asyncio.wait_for(blocked, timeout=1)
|
||||
|
||||
self.assertEqual(gate.t_in_progress, 1)
|
||||
await lease.release()
|
||||
self.assertEqual(gate.t_in_progress, 0)
|
||||
|
||||
async def test_q_batch_reserves_only_current_demand(self):
|
||||
inventory = FakeInventory()
|
||||
gate = AdmissionGate(inventory, t_low=1, t_high=3, q_low=2, q_high=5)
|
||||
|
||||
lease = await gate.acquire_q_batch(max_batch=4)
|
||||
|
||||
self.assertEqual(lease.count, 4)
|
||||
self.assertEqual(gate.q_inflight, 4)
|
||||
|
||||
second = await gate.acquire_q_batch(max_batch=4)
|
||||
self.assertEqual(second.count, 1)
|
||||
self.assertEqual(gate.q_inflight, 5)
|
||||
|
||||
blocked = asyncio.create_task(gate.acquire_q_batch(max_batch=4))
|
||||
await asyncio.sleep(0.02)
|
||||
self.assertFalse(blocked.done())
|
||||
|
||||
await lease.release_all()
|
||||
await second.release_all()
|
||||
inventory.q_depth = 1
|
||||
await gate.notify_changed()
|
||||
third = await asyncio.wait_for(blocked, timeout=1)
|
||||
self.assertEqual(third.count, 4)
|
||||
await third.release_all()
|
||||
|
||||
async def test_q_batch_waits_for_low_water_after_reaching_high_water(self):
|
||||
inventory = FakeInventory()
|
||||
gate = AdmissionGate(inventory, t_low=1, t_high=3, q_low=2, q_high=5)
|
||||
|
||||
lease = await gate.acquire_q_batch(max_batch=5)
|
||||
self.assertEqual(lease.count, 5)
|
||||
self.assertEqual(gate.q_inflight, 5)
|
||||
|
||||
await lease.release_one()
|
||||
self.assertEqual(gate.q_inflight, 4)
|
||||
|
||||
blocked = asyncio.create_task(gate.acquire_q_batch(max_batch=5))
|
||||
await asyncio.sleep(0.02)
|
||||
self.assertFalse(blocked.done())
|
||||
|
||||
await lease.release_one()
|
||||
await lease.release_one()
|
||||
await lease.release_one()
|
||||
|
||||
resumed = await asyncio.wait_for(blocked, timeout=1)
|
||||
self.assertEqual(resumed.count, 4)
|
||||
await lease.release_all()
|
||||
await resumed.release_all()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,475 +0,0 @@
|
||||
"""
|
||||
Layer 2: 取消注入测试
|
||||
|
||||
在每个 await 边界前后强制取消 Worker,验证:
|
||||
- S 取消后 Physical_Sem 和 T_Slot_Sem 不泄漏
|
||||
- P 取消后 Physical_Sem、Q_Pending_Sem、Q_Slot_Sem 不泄漏
|
||||
- C 取消后 Physical_Sem 不泄漏,已 claim 的 T/Q 被核销
|
||||
- 库存守恒: 取消不导致 slot 无界堆积或耗尽
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from grok_register.core.envelope import ResourceEnvelope
|
||||
from grok_register.core.inventory import Inventory
|
||||
from grok_register.core.observer import Metrics
|
||||
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI, Conservation
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# S_Worker 取消测试
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def _s_worker_impl(browser, inventory, physical_sem, t_slot_sem, metrics, stop):
|
||||
"""S_Worker 简化实现,用于测试。"""
|
||||
while not stop.is_set():
|
||||
await physical_sem.acquire()
|
||||
try:
|
||||
token = await browser.generate()
|
||||
finally:
|
||||
physical_sem.release()
|
||||
|
||||
if token is None:
|
||||
metrics.t_discarded += 1
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
|
||||
metrics.t_produced += 1
|
||||
now = time.time()
|
||||
try:
|
||||
t_env = await ResourceEnvelope.create_with_slot(
|
||||
'T', token, t_slot_sem, expires_at=now + 300
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
metrics.t_discarded += 1
|
||||
raise
|
||||
|
||||
try:
|
||||
await inventory.put_t(t_env)
|
||||
except Exception:
|
||||
t_env.discard(reason='put_failed')
|
||||
metrics.t_discarded += 1
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
|
||||
class TestSCancel:
|
||||
"""S_Worker 取消语义测试。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_generate_no_leak(self):
|
||||
"""S 在 generate() 期间被取消,sem 不泄漏。"""
|
||||
ft = FakeTurnstile(delay=0.5) # 慢生成
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(2)
|
||||
t_slot = asyncio.Semaphore(4)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
|
||||
task = asyncio.create_task(
|
||||
_s_worker_impl(ft, inv, phys, t_slot, metrics, stop)
|
||||
)
|
||||
await asyncio.sleep(0.05) # 等它进入 generate
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Physical_Sem 应完全释放
|
||||
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
|
||||
# T_Slot_Sem 不变(没生成成功)
|
||||
assert t_slot._value == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_put_t_no_leak(self):
|
||||
"""S 在 put_t 期间被取消(极端: put 内部取消),envelope 被 discard。"""
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(2)
|
||||
t_slot = asyncio.Semaphore(4)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
|
||||
# 快速生成
|
||||
ft = FakeTurnstile(delay=0.001)
|
||||
|
||||
task = asyncio.create_task(
|
||||
_s_worker_impl(ft, inv, phys, t_slot, metrics, stop)
|
||||
)
|
||||
# 在 put_t 之前的极短窗口取消
|
||||
await asyncio.sleep(0.002)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert phys._value == 2
|
||||
# t_slot 可能是 4 或 3,但不应是负数
|
||||
assert t_slot._value >= 0
|
||||
assert t_slot._value <= 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s_repeated_cancel_no_accumulation(self):
|
||||
"""反复取消 S 多次,sem 计数不累积。"""
|
||||
ft = FakeTurnstile(delay=0.01)
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(2)
|
||||
t_slot = asyncio.Semaphore(4)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
|
||||
for _ in range(20):
|
||||
task = asyncio.create_task(
|
||||
_s_worker_impl(ft, inv, phys, t_slot, metrics, stop)
|
||||
)
|
||||
await asyncio.sleep(0.02)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
|
||||
# 守恒: free + inventory == total (成功入库的 envelope 占着 slot)
|
||||
assert t_slot._value + inv.t_depth == 4, f'T_Slot 守恒违反: free={t_slot._value} inv={inv.t_depth}'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# P_Worker 取消测试
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def _p_worker_impl(email_svc, inventory, physical_sem, q_pending_sem,
|
||||
q_slot_sem, metrics, stop):
|
||||
"""P_Worker 简化实现,用于测试。"""
|
||||
loop = asyncio.get_event_loop()
|
||||
while not stop.is_set():
|
||||
await q_pending_sem.acquire()
|
||||
_pending_released = False
|
||||
try:
|
||||
await physical_sem.acquire()
|
||||
try:
|
||||
handle, email, password = await email_svc.create_email()
|
||||
sent = True # fake 总是成功
|
||||
finally:
|
||||
physical_sem.release()
|
||||
|
||||
if not sent:
|
||||
q_pending_sem.release()
|
||||
_pending_released = True
|
||||
continue
|
||||
|
||||
metrics.q_sent += 1
|
||||
|
||||
try:
|
||||
code = await asyncio.wait_for(
|
||||
email_svc.poll_code(handle), timeout=95
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
code = None
|
||||
|
||||
if code is None:
|
||||
metrics.q_discarded += 1
|
||||
q_pending_sem.release()
|
||||
_pending_released = True
|
||||
continue
|
||||
|
||||
metrics.q_returned += 1
|
||||
now = time.time()
|
||||
q_env = await ResourceEnvelope.create_with_slot(
|
||||
'Q', {'email': email, 'password': password, 'code': code},
|
||||
q_slot_sem, expires_at=now + 120,
|
||||
)
|
||||
|
||||
try:
|
||||
await inventory.put_q(q_env)
|
||||
except Exception:
|
||||
q_env.discard(reason='put_failed')
|
||||
metrics.q_discarded += 1
|
||||
|
||||
except asyncio.CancelledError:
|
||||
if not _pending_released:
|
||||
q_pending_sem.release()
|
||||
_pending_released = True
|
||||
raise
|
||||
except Exception:
|
||||
metrics.q_discarded += 1
|
||||
finally:
|
||||
if not _pending_released:
|
||||
q_pending_sem.release()
|
||||
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
|
||||
class TestPCancel:
|
||||
"""P_Worker 取消语义测试。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_create_email(self):
|
||||
"""P 在 create_email 期间被取消,所有 sem 正确释放。"""
|
||||
es = FakeEmailService()
|
||||
es.set_cancel_create_at(1)
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(2)
|
||||
q_pend = asyncio.Semaphore(4)
|
||||
q_slot = asyncio.Semaphore(4)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
|
||||
task = asyncio.create_task(
|
||||
_p_worker_impl(es, inv, phys, q_pend, q_slot, metrics, stop)
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
|
||||
assert q_pend._value == 4, f'Q_Pending 泄漏: {q_pend._value}'
|
||||
assert q_slot._value == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_poll_code(self):
|
||||
"""P 在 poll_code 期间被取消,不持有 Physical_Sem。"""
|
||||
es = FakeEmailService()
|
||||
es.set_cancel_poll_at(1)
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(2)
|
||||
q_pend = asyncio.Semaphore(4)
|
||||
q_slot = asyncio.Semaphore(4)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
|
||||
task = asyncio.create_task(
|
||||
_p_worker_impl(es, inv, phys, q_pend, q_slot, metrics, stop)
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# P 等 Q 时不持有 Physical_Sem
|
||||
assert phys._value == 2, f'Physical_Sem 应完全释放: {phys._value}'
|
||||
assert q_pend._value == 4, f'Q_Pending 泄漏: {q_pend._value}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p_repeated_cancel_no_accumulation(self):
|
||||
"""反复取消 P 多次,sem 计数不累积。"""
|
||||
es = FakeEmailService()
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(2)
|
||||
q_pend = asyncio.Semaphore(4)
|
||||
q_slot = asyncio.Semaphore(4)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
|
||||
for _ in range(20):
|
||||
task = asyncio.create_task(
|
||||
_p_worker_impl(es, inv, phys, q_pend, q_slot, metrics, stop)
|
||||
)
|
||||
await asyncio.sleep(0.03)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
|
||||
assert q_pend._value == 4, f'Q_Pending 泄漏: {q_pend._value}'
|
||||
# 守恒: free + inventory == total
|
||||
assert q_slot._value + inv.q_depth == 4, f'Q_Slot 守恒违反: free={q_slot._value} inv={inv.q_depth}'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# C_Worker 取消测试
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def _c_worker_impl(register_api, browser_unused, inventory, physical_sem,
|
||||
metrics, stop, file_lock):
|
||||
"""C_Worker 简化实现,用于测试。"""
|
||||
while not stop.is_set():
|
||||
try:
|
||||
async with inventory.claim_pair() as pair:
|
||||
t_val = pair.t.value
|
||||
q_val = pair.q.value
|
||||
await physical_sem.acquire()
|
||||
try:
|
||||
sso = await register_api.register(
|
||||
q_val['email'], q_val['password'], q_val['code'], t_val
|
||||
)
|
||||
if sso:
|
||||
metrics.pair_consumed_ok += 1
|
||||
metrics.success_count += 1
|
||||
else:
|
||||
metrics.pair_consumed_fail += 1
|
||||
finally:
|
||||
physical_sem.release()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
metrics.pair_consumed_fail += 1
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
|
||||
class TestCCancel:
|
||||
"""C_Worker 取消语义测试。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_register_pair_settled(self):
|
||||
"""C 在 register 期间被取消,pair 已 claim,必须核销。"""
|
||||
fra = FakeRegisterAPI(delay=0.5)
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(2)
|
||||
t_slot = asyncio.Semaphore(4)
|
||||
q_slot = asyncio.Semaphore(4)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
fl = asyncio.Lock()
|
||||
|
||||
# 用带 metrics 的 inventory
|
||||
inv_with_metrics = Inventory(metrics=Metrics())
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_slot)
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'email': 'a@b.com', 'password': 'p', 'code': '123'}, q_slot)
|
||||
await inv_with_metrics.put_t(t_env)
|
||||
await inv_with_metrics.put_q(q_env)
|
||||
|
||||
task = asyncio.create_task(
|
||||
_c_worker_impl(fra, None, inv_with_metrics, phys, metrics, stop, fl)
|
||||
)
|
||||
await asyncio.sleep(0.1) # 等它 claim 进入 register
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# pair 已核销: slot 应释放
|
||||
assert t_slot._value == 4, f'T slot 未核销: {t_slot._value}'
|
||||
assert q_slot._value == 4, f'Q slot 未核销: {q_slot._value}'
|
||||
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_claim_wait_no_pop(self):
|
||||
"""C 在 claim_pair 等待时取消,不弹出资源。"""
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(2)
|
||||
t_slot = asyncio.Semaphore(4)
|
||||
q_slot = asyncio.Semaphore(4)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
fl = asyncio.Lock()
|
||||
|
||||
fra = FakeRegisterAPI()
|
||||
|
||||
# 放一个 T 但不放 Q,让 C 等待
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_slot)
|
||||
await inv.put_t(t_env)
|
||||
|
||||
task = asyncio.create_task(
|
||||
_c_worker_impl(fra, None, inv, phys, metrics, stop, fl)
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# T 仍在 inventory 中
|
||||
assert inv.t_depth == 1, 'claim 等待时取消不应弹出 T'
|
||||
assert phys._value == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_c_repeated_cancel_no_accumulation(self):
|
||||
"""反复取消 C 多次,sem 计数不累积。"""
|
||||
fra = FakeRegisterAPI(delay=0.01)
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(2)
|
||||
t_slot = asyncio.Semaphore(10)
|
||||
q_slot = asyncio.Semaphore(10)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
fl = asyncio.Lock()
|
||||
|
||||
for _ in range(10):
|
||||
# 每次放一对
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', f'tok_{_}', t_slot)
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'email': f'e@{_}', 'password': 'p', 'code': '123'}, q_slot)
|
||||
await inv.put_t(t_env)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
task = asyncio.create_task(
|
||||
_c_worker_impl(fra, None, inv, phys, metrics, stop, fl)
|
||||
)
|
||||
await asyncio.sleep(0.03)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
|
||||
# 所有 slot 应已核销
|
||||
assert t_slot._value == 10, f'T_Slot 泄漏: {t_slot._value}'
|
||||
assert q_slot._value == 10, f'Q_Slot 泄漏: {q_slot._value}'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 端到端取消: S+P+C 同时运行时取消
|
||||
# ═══════════════════════════════════════════
|
||||
class TestE2ECancel:
|
||||
"""端到端取消测试: S/P/C 同时运行时取消,验证全局守恒。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_workers_sem_reset(self):
|
||||
"""同时启动 S/P/C,然后全部取消,所有 sem 恢复初始值。"""
|
||||
ft = FakeTurnstile(delay=0.05)
|
||||
es = FakeEmailService()
|
||||
fra = FakeRegisterAPI(delay=0.05)
|
||||
|
||||
inv = Inventory()
|
||||
phys = asyncio.Semaphore(4)
|
||||
t_slot = asyncio.Semaphore(8)
|
||||
q_slot = asyncio.Semaphore(8)
|
||||
q_pend = asyncio.Semaphore(12)
|
||||
metrics = Metrics()
|
||||
stop = asyncio.Event()
|
||||
fl = asyncio.Lock()
|
||||
|
||||
tasks = []
|
||||
for _ in range(3):
|
||||
tasks.append(asyncio.create_task(
|
||||
_s_worker_impl(ft, inv, phys, t_slot, metrics, stop)
|
||||
))
|
||||
tasks.append(asyncio.create_task(
|
||||
_p_worker_impl(es, inv, phys, q_pend, q_slot, metrics, stop)
|
||||
))
|
||||
tasks.append(asyncio.create_task(
|
||||
_c_worker_impl(fra, None, inv, phys, metrics, stop, fl)
|
||||
))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
stop.set()
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 全部停止后,守恒检查: free + inventory == total
|
||||
assert phys._value >= 0 and phys._value <= 4
|
||||
assert t_slot._value + inv.t_depth == 8, f'T 守恒违反: free={t_slot._value} inv={inv.t_depth}'
|
||||
assert q_slot._value + inv.q_depth == 8, f'Q 守恒违反: free={q_slot._value} inv={inv.q_depth}'
|
||||
assert q_pend._value >= 0 and q_pend._value <= 12
|
||||
|
||||
# 守恒检查
|
||||
cons = Conservation(t_slot, q_slot, q_pend, inv)
|
||||
cons.check_t_conservation(8)
|
||||
cons.check_q_conservation(8)
|
||||
cons.check_pending_conservation(12)
|
||||
@@ -1,491 +0,0 @@
|
||||
"""
|
||||
Layer 1: 单元测试
|
||||
|
||||
测 ResourceEnvelope、PairLease、Inventory 的局部语义:
|
||||
- release_slot_once() 幂等
|
||||
- create_with_slot() slot 获取失败不创建 envelope
|
||||
- is_expired() 正确判断
|
||||
- put_t/put_q 成功前所有权属于 Worker,成功后属于 Inventory
|
||||
- claim_pair 等待时取消不弹资源
|
||||
- claim_pair 成功后 pair 属于 PairLease
|
||||
- PairLease 退出时核销 T/Q
|
||||
- 过期资源不会交付给 C
|
||||
- lazy cleanup 只在事件触发时发生
|
||||
- discard() 幂等释放
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from grok_register.core.envelope import ResourceEnvelope
|
||||
from grok_register.core.inventory import Inventory, PairLease
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# ResourceEnvelope 测试
|
||||
# ═══════════════════════════════════════════
|
||||
class TestResourceEnvelope:
|
||||
"""ResourceEnvelope 语义测试。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_slot_acquires_sem(self):
|
||||
"""create_with_slot 成功后 semaphore 值减 1。"""
|
||||
sem = asyncio.Semaphore(3)
|
||||
env = await ResourceEnvelope.create_with_slot('T', 'tok1', sem)
|
||||
assert sem._value == 2
|
||||
assert env.value == 'tok1'
|
||||
assert env.kind == 'T'
|
||||
assert not env.released
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_slot_fails_no_envelope(self):
|
||||
"""slot 获取失败(取消)时不创建 envelope,sem 不变。"""
|
||||
sem = asyncio.Semaphore(0)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
# 手动取消
|
||||
task = asyncio.current_task()
|
||||
task.cancel()
|
||||
try:
|
||||
await ResourceEnvelope.create_with_slot('T', 'tok1', sem)
|
||||
except asyncio.CancelledError:
|
||||
# sem 不应改变(因为 acquire 被取消了)
|
||||
raise
|
||||
# sem 仍为 0
|
||||
assert sem._value == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_slot_once_idempotent(self):
|
||||
"""release_slot_once() 幂等:重复调用只释放一次。"""
|
||||
sem = asyncio.Semaphore(3)
|
||||
env = await ResourceEnvelope.create_with_slot('T', 'tok1', sem)
|
||||
assert sem._value == 2
|
||||
|
||||
env.release_slot_once(reason='test1')
|
||||
assert sem._value == 3
|
||||
assert env.released
|
||||
|
||||
# 第二次调用不应再次释放
|
||||
env.release_slot_once(reason='test2')
|
||||
assert sem._value == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_releases_slot(self):
|
||||
"""discard() 释放 slot。"""
|
||||
sem = asyncio.Semaphore(2)
|
||||
env = await ResourceEnvelope.create_with_slot('Q', {'code': '123'}, sem)
|
||||
assert sem._value == 1
|
||||
env.discard(reason='test')
|
||||
assert sem._value == 2
|
||||
assert env.released
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_idempotent(self):
|
||||
"""discard() 幂等。"""
|
||||
sem = asyncio.Semaphore(2)
|
||||
env = await ResourceEnvelope.create_with_slot('Q', 'val', sem)
|
||||
env.discard(reason='first')
|
||||
env.discard(reason='second')
|
||||
assert sem._value == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_expired_with_expires_at(self):
|
||||
"""有 expires_at 时正确判断过期。"""
|
||||
sem = asyncio.Semaphore(2)
|
||||
now = time.time()
|
||||
env = await ResourceEnvelope.create_with_slot('T', 'tok', sem, expires_at=now - 1)
|
||||
assert env.is_expired(now=now)
|
||||
|
||||
env2 = await ResourceEnvelope.create_with_slot('T', 'tok2', sem, expires_at=now + 100)
|
||||
assert not env2.is_expired(now=now)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_expired_without_expires_at(self):
|
||||
"""没有 expires_at 时永远不过期。"""
|
||||
sem = asyncio.Semaphore(2)
|
||||
env = await ResourceEnvelope.create_with_slot('T', 'tok', sem)
|
||||
assert not env.is_expired(now=time.time() + 99999)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_slot_meta(self):
|
||||
"""meta 字段正确传递。"""
|
||||
sem = asyncio.Semaphore(1)
|
||||
env = await ResourceEnvelope.create_with_slot('T', 'tok', sem, meta={'source': 'test'})
|
||||
assert env.meta == {'source': 'test'}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# Inventory 测试
|
||||
# ═══════════════════════════════════════════
|
||||
class TestInventory:
|
||||
"""Inventory 语义测试。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_t_then_claim(self):
|
||||
"""put_t 后 claim_pair 能拿到 pair。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
q_sem = asyncio.Semaphore(2)
|
||||
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'token1', t_sem)
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'abc'}, q_sem)
|
||||
|
||||
await inv.put_t(t_env)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
assert inv.t_depth == 1
|
||||
assert inv.q_depth == 1
|
||||
|
||||
# claim
|
||||
async with inv.claim_pair() as pair:
|
||||
assert pair.t.value == 'token1'
|
||||
assert pair.q.value == {'code': 'abc'}
|
||||
# claim 后 inventory 应为空
|
||||
assert inv.t_depth == 0
|
||||
assert inv.q_depth == 0
|
||||
|
||||
# PairLease 退出后 slot 应被释放
|
||||
assert t_sem._value == 2
|
||||
assert q_sem._value == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_waits_for_both(self):
|
||||
"""只有 T 没有 Q 时,claim_pair 应等待。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
q_sem = asyncio.Semaphore(2)
|
||||
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
|
||||
await inv.put_t(t_env)
|
||||
|
||||
claimed = False
|
||||
|
||||
async def claimer():
|
||||
nonlocal claimed
|
||||
async with inv.claim_pair() as pair:
|
||||
claimed = True
|
||||
|
||||
task = asyncio.create_task(claimer())
|
||||
await asyncio.sleep(0.1)
|
||||
assert not claimed, 'claim 不应在缺少 Q 时成功'
|
||||
|
||||
# 现在放入 Q
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
assert claimed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_cancel_no_pop(self):
|
||||
"""claim_pair 等待时取消,不应弹出任何资源。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
|
||||
await inv.put_t(t_env)
|
||||
assert inv.t_depth == 1
|
||||
|
||||
async def claimer():
|
||||
async with inv.claim_pair() as pair:
|
||||
pass # 不应到达
|
||||
|
||||
task = asyncio.create_task(claimer())
|
||||
await asyncio.sleep(0.1)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# T 仍在 inventory 中
|
||||
assert inv.t_depth == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pair_lease_settles_on_success(self):
|
||||
"""PairLease 成功退出时核销 T/Q 并释放 slot。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
q_sem = asyncio.Semaphore(2)
|
||||
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem)
|
||||
await inv.put_t(t_env)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
async with inv.claim_pair() as pair:
|
||||
# slot 还在 pair 中
|
||||
assert t_sem._value == 1
|
||||
assert q_sem._value == 1
|
||||
|
||||
# 退出后 slot 释放
|
||||
assert t_sem._value == 2
|
||||
assert q_sem._value == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pair_lease_settles_on_exception(self):
|
||||
"""PairLease 异常退出时也核销 T/Q。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
q_sem = asyncio.Semaphore(2)
|
||||
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem)
|
||||
await inv.put_t(t_env)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
async with inv.claim_pair() as pair:
|
||||
raise ValueError('boom')
|
||||
|
||||
# slot 仍被核销
|
||||
assert t_sem._value == 2
|
||||
assert q_sem._value == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pair_lease_settles_on_cancel(self):
|
||||
"""PairLease 取消退出时也核销 T/Q。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
q_sem = asyncio.Semaphore(2)
|
||||
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem)
|
||||
await inv.put_t(t_env)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
async def consumer():
|
||||
async with inv.claim_pair() as pair:
|
||||
# 在 pair 内部被取消
|
||||
await asyncio.sleep(999)
|
||||
|
||||
task = asyncio.create_task(consumer())
|
||||
await asyncio.sleep(0.1)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# slot 仍被核销
|
||||
assert t_sem._value == 2
|
||||
assert q_sem._value == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_t_not_delivered(self):
|
||||
"""过期的 T 不会交付给 C。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
q_sem = asyncio.Semaphore(2)
|
||||
|
||||
now = time.time()
|
||||
# T 已过期
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'expired_tok', t_sem, expires_at=now - 10)
|
||||
# Q 有效
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem, expires_at=now + 100)
|
||||
|
||||
await inv.put_t(t_env)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
# claim 应该把过期的 T 清理掉,然后等待新的 T
|
||||
claimed = False
|
||||
|
||||
async def claimer():
|
||||
nonlocal claimed
|
||||
async with inv.claim_pair() as pair:
|
||||
claimed = True
|
||||
|
||||
task = asyncio.create_task(claimer())
|
||||
await asyncio.sleep(0.2)
|
||||
assert not claimed, '过期 T 不应交付'
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 过期 T 被清理,slot 被释放
|
||||
assert t_sem._value == 2
|
||||
# Q 仍在 inventory 中
|
||||
assert inv.q_depth == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_q_not_delivered(self):
|
||||
"""过期的 Q 不会交付给 C。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
q_sem = asyncio.Semaphore(2)
|
||||
|
||||
now = time.time()
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem, expires_at=now + 100)
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem, expires_at=now - 10)
|
||||
|
||||
await inv.put_t(t_env)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
claimed = False
|
||||
|
||||
async def claimer():
|
||||
nonlocal claimed
|
||||
async with inv.claim_pair() as pair:
|
||||
claimed = True
|
||||
|
||||
task = asyncio.create_task(claimer())
|
||||
await asyncio.sleep(0.2)
|
||||
assert not claimed, '过期 Q 不应交付'
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert q_sem._value == 2
|
||||
assert inv.t_depth == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_cleanup_on_put(self):
|
||||
"""lazy cleanup: put 时清理已过期的库存。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(4)
|
||||
|
||||
now = time.time()
|
||||
# 放入 3 个过期的 T
|
||||
for i in range(3):
|
||||
env = await ResourceEnvelope.create_with_slot(
|
||||
'T', f'expired_{i}', t_sem, expires_at=now - 10
|
||||
)
|
||||
await inv.put_t(env)
|
||||
|
||||
# 由于 put 时 lazy cleanup 已清理过期项,库存应为空
|
||||
assert inv.t_depth == 0
|
||||
# slot 应已释放
|
||||
assert t_sem._value == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_cleanup_silent_until_next_event(self):
|
||||
"""lazy cleanup 不在静默期主动释放,下一次 put 才触发清理。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(4)
|
||||
|
||||
env = await ResourceEnvelope.create_with_slot(
|
||||
'T', 'will_expire', t_sem, expires_at=time.time() + 0.05
|
||||
)
|
||||
await inv.put_t(env)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
assert inv.t_depth == 1
|
||||
assert t_sem._value == 3
|
||||
|
||||
fresh = await ResourceEnvelope.create_with_slot(
|
||||
'T', 'fresh', t_sem, expires_at=time.time() + 100
|
||||
)
|
||||
await inv.put_t(fresh)
|
||||
|
||||
assert inv.t_depth == 1
|
||||
assert t_sem._value == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_cleanup_on_claim(self):
|
||||
"""lazy cleanup: claim_pair 时清理过期库存。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(4)
|
||||
q_sem = asyncio.Semaphore(4)
|
||||
|
||||
now = time.time()
|
||||
# 直接注入过期项到 inventory 内部(绕过 put 的清理)
|
||||
env = await ResourceEnvelope.create_with_slot('T', 'expired', t_sem, expires_at=now - 10)
|
||||
inv._t_buf.append(env)
|
||||
|
||||
# 有效的 Q
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem, expires_at=now + 100)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
# claim 时应清理过期的 T
|
||||
claimed = False
|
||||
async def claimer():
|
||||
nonlocal claimed
|
||||
async with inv.claim_pair() as pair:
|
||||
claimed = True
|
||||
|
||||
task = asyncio.create_task(claimer())
|
||||
await asyncio.sleep(0.2)
|
||||
assert not claimed # T 被清了,没有 pair
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert inv.t_depth == 0
|
||||
assert t_sem._value == 4 # slot 已释放
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fifo_order(self):
|
||||
"""Inventory 按 FIFO 顺序配对。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(10)
|
||||
q_sem = asyncio.Semaphore(10)
|
||||
|
||||
for i in range(3):
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', f'tok_{i}', t_sem)
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': f'c_{i}'}, q_sem)
|
||||
await inv.put_t(t_env)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
pairs = []
|
||||
for _ in range(3):
|
||||
async with inv.claim_pair() as pair:
|
||||
pairs.append((pair.t.value, pair.q.value['code']))
|
||||
|
||||
assert pairs == [('tok_0', 'c_0'), ('tok_1', 'c_1'), ('tok_2', 'c_2')]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_concurrent_claims(self):
|
||||
"""多个 C 同时 claim,不应重复消费同一个 T/Q。"""
|
||||
inv = Inventory()
|
||||
t_sem = asyncio.Semaphore(10)
|
||||
q_sem = asyncio.Semaphore(10)
|
||||
|
||||
n = 5
|
||||
for i in range(n):
|
||||
t_env = await ResourceEnvelope.create_with_slot('T', f'tok_{i}', t_sem)
|
||||
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': f'c_{i}'}, q_sem)
|
||||
await inv.put_t(t_env)
|
||||
await inv.put_q(q_env)
|
||||
|
||||
results = []
|
||||
|
||||
async def claimer(idx):
|
||||
async with inv.claim_pair() as pair:
|
||||
results.append((idx, pair.t.value))
|
||||
|
||||
tasks = [asyncio.create_task(claimer(i)) for i in range(n)]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# 每个 T 只被消费一次
|
||||
t_values = [r[1] for r in results]
|
||||
assert len(set(t_values)) == n, f'重复消费: {t_values}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_admitted(self):
|
||||
"""put 成功时 metrics.t_admitted / q_admitted 递增。"""
|
||||
from grok_register.core.observer import Metrics
|
||||
m = Metrics()
|
||||
inv = Inventory(metrics=m)
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
|
||||
env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
|
||||
await inv.put_t(env)
|
||||
assert m.t_admitted == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_expired(self):
|
||||
"""过期资源 put 时 metrics.t_expired 递增。"""
|
||||
from grok_register.core.observer import Metrics
|
||||
m = Metrics()
|
||||
inv = Inventory(metrics=m)
|
||||
t_sem = asyncio.Semaphore(2)
|
||||
|
||||
env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem, expires_at=time.time() - 10)
|
||||
await inv.put_t(env)
|
||||
assert m.t_expired == 1
|
||||
assert m.t_admitted == 0
|
||||
@@ -1,58 +0,0 @@
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from grok_register.core.envelope import ResourceEnvelope
|
||||
from grok_register.core.inventory import Inventory
|
||||
from grok_register.core.observer import Metrics
|
||||
|
||||
|
||||
class InventoryUnitTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create_with_slot_releases_slot_if_constructor_fails(self):
|
||||
class BrokenEnvelope(ResourceEnvelope):
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
sem = asyncio.Semaphore(1)
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
await BrokenEnvelope.create_with_slot("T", "tok", sem)
|
||||
|
||||
self.assertEqual(sem._value, 1)
|
||||
|
||||
async def test_lazy_cleanup_scans_past_fresh_head(self):
|
||||
metrics = Metrics()
|
||||
inventory = Inventory(metrics=metrics)
|
||||
t_sem = asyncio.Semaphore(3)
|
||||
q_sem = asyncio.Semaphore(1)
|
||||
now = time.time()
|
||||
|
||||
fresh_head = await ResourceEnvelope.create_with_slot(
|
||||
"T", "fresh_head", t_sem, expires_at=now + 100
|
||||
)
|
||||
stale_tail = await ResourceEnvelope.create_with_slot(
|
||||
"T", "stale_tail", t_sem, expires_at=now + 100
|
||||
)
|
||||
fresh_tail = await ResourceEnvelope.create_with_slot(
|
||||
"T", "fresh_tail", t_sem, expires_at=now + 100
|
||||
)
|
||||
|
||||
await inventory.put_t(fresh_head)
|
||||
await inventory.put_t(stale_tail)
|
||||
await inventory.put_t(fresh_tail)
|
||||
self.assertEqual(t_sem._value, 0)
|
||||
|
||||
stale_tail.expires_at = time.time() - 10
|
||||
|
||||
q_env = await ResourceEnvelope.create_with_slot(
|
||||
"Q", "q", q_sem, expires_at=now + 100
|
||||
)
|
||||
await inventory.put_q(q_env)
|
||||
|
||||
self.assertEqual(t_sem._value, 1)
|
||||
self.assertEqual(metrics.t_expired, 1)
|
||||
self.assertEqual(inventory.t_depth, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,482 +0,0 @@
|
||||
"""
|
||||
Layer 3: 性质测试 (Property Tests)
|
||||
|
||||
随机生成 S/P/C 成功、失败、取消、超时、过期事件,持续检查:
|
||||
- slot 守恒 (T/Q 总量恒定)
|
||||
- 无重复 claim (同一个 T/Q 不被两个 PairLease 拿到)
|
||||
- 无单边持有等待 (C 不先拿 T 再等 Q)
|
||||
- 过期资源不交付
|
||||
- 取消后不泄漏
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
import random
|
||||
import pytest
|
||||
|
||||
from grok_register.core.envelope import ResourceEnvelope
|
||||
from grok_register.core.inventory import Inventory
|
||||
from grok_register.core.observer import Metrics
|
||||
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI, Conservation, EventLog
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 随机性质测试引擎
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
class PropertyEngine:
|
||||
"""驱动 S/P/C 随机行为,持续检查不变量。"""
|
||||
|
||||
def __init__(self, t_slot_cap=6, q_slot_cap=6, q_pending_cap=8, phys_cap=4,
|
||||
t_max_age=2.0, q_max_age=2.0, seed=None):
|
||||
self.rng = random.Random(seed)
|
||||
self.t_slot_sem = asyncio.Semaphore(t_slot_cap)
|
||||
self.q_slot_sem = asyncio.Semaphore(q_slot_cap)
|
||||
self.q_pending_sem = asyncio.Semaphore(q_pending_cap)
|
||||
self.phys_sem = asyncio.Semaphore(phys_cap)
|
||||
self.t_slot_cap = t_slot_cap
|
||||
self.q_slot_cap = q_slot_cap
|
||||
self.q_pending_cap = q_pending_cap
|
||||
self.t_max_age = t_max_age
|
||||
self.q_max_age = q_max_age
|
||||
|
||||
self.metrics = Metrics()
|
||||
self.inventory = Inventory(metrics=self.metrics)
|
||||
self.conservation = Conservation(
|
||||
self.t_slot_sem, self.q_slot_sem, self.q_pending_sem, self.inventory
|
||||
)
|
||||
self.events = EventLog()
|
||||
|
||||
self._claimed_tokens = set() # 检测重复 claim
|
||||
self._pairleased_t = 0 # 当前 PairLease 持有的 T
|
||||
self._pairleased_q = 0 # 当前 PairLease 持有的 Q
|
||||
|
||||
def check_invariants_soft(self):
|
||||
"""运行时软检查: 非负、不超限。
|
||||
|
||||
注意: Worker 运行期间 slot 可能在 create_with_slot 和 put_t 之间的
|
||||
envelope 中(worker 持有但无法从外部追踪)。严格守恒检查用
|
||||
check_invariants_final() 在所有 Worker 停止后执行。
|
||||
"""
|
||||
assert self.t_slot_sem._value >= 0, 'T slot 为负'
|
||||
assert self.q_slot_sem._value >= 0, 'Q slot 为负'
|
||||
assert self.q_pending_sem._value >= 0, 'Q pending 为负'
|
||||
assert self.q_pending_sem._value <= self.q_pending_cap, 'Q pending 超限'
|
||||
assert self.phys_sem._value >= 0, 'Physical 为负'
|
||||
# inventory 深度不超过 slot 总量
|
||||
assert self.inventory.t_depth <= self.t_slot_cap, 'T inventory 超限'
|
||||
assert self.inventory.q_depth <= self.q_slot_cap, 'Q inventory 超限'
|
||||
|
||||
def check_invariants_final(self):
|
||||
"""最终严格守恒检查: 所有 Worker 停止后执行。
|
||||
|
||||
此时没有 Worker 持有中间状态的 slot,也没有 PairLease。
|
||||
free + inventory == total
|
||||
"""
|
||||
self.conservation.check_t_conservation(self.t_slot_cap)
|
||||
self.conservation.check_q_conservation(self.q_slot_cap)
|
||||
self.conservation.check_pending_conservation(self.q_pending_cap)
|
||||
|
||||
async def s_produce(self):
|
||||
"""S 产生一个 T。返回 True/False/None(cancelled)。"""
|
||||
# 模拟物理资源使用
|
||||
await self.phys_sem.acquire()
|
||||
delay = self.rng.uniform(0.001, 0.02)
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
finally:
|
||||
self.phys_sem.release()
|
||||
|
||||
# 模拟随机失败
|
||||
if self.rng.random() < 0.1:
|
||||
self.metrics.t_discarded += 1
|
||||
await self.events.record('t_discarded', 'gen_fail')
|
||||
return False
|
||||
|
||||
token = f'tok_{self.metrics.t_produced}'
|
||||
self.metrics.t_produced += 1
|
||||
await self.events.record('t_produced', token)
|
||||
|
||||
# 创建 envelope + 入库
|
||||
# 关键: create_with_slot 成功后 slot 在 envelope 中,
|
||||
# 必须追踪 worker_held 直到 put_t 成功或 discard。
|
||||
now = time.time()
|
||||
expire = now + self.t_max_age if self.rng.random() > 0.15 else now - 1
|
||||
t_env = None
|
||||
try:
|
||||
t_env = await ResourceEnvelope.create_with_slot(
|
||||
'T', token, self.t_slot_sem, expires_at=expire
|
||||
)
|
||||
# slot 已获取,标记 worker 持有
|
||||
self.conservation.worker_acquire_t()
|
||||
await self.inventory.put_t(t_env)
|
||||
# put 成功,所有权转移到 inventory
|
||||
self.conservation.worker_release_t()
|
||||
except Exception:
|
||||
if t_env is not None and not t_env.released:
|
||||
t_env.discard(reason='error_cleanup')
|
||||
self.conservation.worker_release_t()
|
||||
self.metrics.t_discarded += 1
|
||||
return False
|
||||
except asyncio.CancelledError:
|
||||
if t_env is not None and not t_env.released:
|
||||
t_env.discard(reason='cancel_cleanup')
|
||||
self.conservation.worker_release_t()
|
||||
self.metrics.t_discarded += 1
|
||||
raise
|
||||
|
||||
self.check_invariants_soft()
|
||||
return True
|
||||
|
||||
async def p_produce(self):
|
||||
"""P 产生一个 Q。返回 True/False/None(cancelled)。"""
|
||||
await self.q_pending_sem.acquire()
|
||||
_pend_released = False
|
||||
|
||||
try:
|
||||
# 创建邮箱 + 发请求(持有 Physical)
|
||||
await self.phys_sem.acquire()
|
||||
try:
|
||||
delay = self.rng.uniform(0.001, 0.02)
|
||||
await asyncio.sleep(delay)
|
||||
finally:
|
||||
self.phys_sem.release()
|
||||
|
||||
# 模拟随机失败
|
||||
if self.rng.random() < 0.1:
|
||||
self.metrics.q_discarded += 1
|
||||
self.q_pending_sem.release()
|
||||
_pend_released = True
|
||||
await self.events.record('q_discarded', 'send_fail')
|
||||
return False
|
||||
|
||||
self.metrics.q_sent += 1
|
||||
|
||||
# 等待 Q 返回(不持有 Physical)
|
||||
poll_delay = self.rng.uniform(0.001, 0.05)
|
||||
await asyncio.sleep(poll_delay)
|
||||
|
||||
# 模拟超时/失败
|
||||
if self.rng.random() < 0.1:
|
||||
self.metrics.q_discarded += 1
|
||||
self.q_pending_sem.release()
|
||||
_pend_released = True
|
||||
await self.events.record('q_discarded', 'poll_timeout')
|
||||
return False
|
||||
|
||||
code = f'{self.rng.randint(100000, 999999)}'
|
||||
self.metrics.q_returned += 1
|
||||
await self.events.record('q_returned', code)
|
||||
|
||||
# 创建 envelope + 入库
|
||||
now = time.time()
|
||||
expire = now + self.q_max_age if self.rng.random() > 0.15 else now - 1
|
||||
q_env = None
|
||||
try:
|
||||
q_env = await ResourceEnvelope.create_with_slot(
|
||||
'Q', {'email': f'e@{code}', 'password': 'p', 'code': code},
|
||||
self.q_slot_sem, expires_at=expire,
|
||||
)
|
||||
self.conservation.worker_acquire_q()
|
||||
await self.inventory.put_q(q_env)
|
||||
self.conservation.worker_release_q()
|
||||
except Exception:
|
||||
if q_env is not None and not q_env.released:
|
||||
q_env.discard(reason='error_cleanup')
|
||||
self.conservation.worker_release_q()
|
||||
self.metrics.q_discarded += 1
|
||||
except asyncio.CancelledError:
|
||||
if q_env is not None and not q_env.released:
|
||||
q_env.discard(reason='cancel_cleanup')
|
||||
self.conservation.worker_release_q()
|
||||
self.metrics.q_discarded += 1
|
||||
raise
|
||||
|
||||
except asyncio.CancelledError:
|
||||
if not _pend_released:
|
||||
self.q_pending_sem.release()
|
||||
_pend_released = True
|
||||
raise
|
||||
except Exception:
|
||||
self.metrics.q_discarded += 1
|
||||
finally:
|
||||
if not _pend_released:
|
||||
self.q_pending_sem.release()
|
||||
|
||||
self.check_invariants_soft()
|
||||
return True
|
||||
|
||||
async def c_consume(self):
|
||||
"""C 消费一个 pair。返回 True/False/None(cancelled)。"""
|
||||
try:
|
||||
async with self.inventory.claim_pair() as pair:
|
||||
t_val = pair.t.value
|
||||
q_val = pair.q.value
|
||||
|
||||
# 检测重复 claim
|
||||
assert t_val not in self._claimed_tokens, f'重复 claim: {t_val}'
|
||||
self._claimed_tokens.add(t_val)
|
||||
|
||||
# pairleased 计数 — 必须在 claim 后立即标记,
|
||||
# 否则在下一个 await 前被取消会导致守恒违反
|
||||
self._pairleased_t += 1
|
||||
self._pairleased_q += 1
|
||||
|
||||
# 消费(持有 Physical)
|
||||
await self.phys_sem.acquire()
|
||||
try:
|
||||
delay = self.rng.uniform(0.001, 0.02)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# 模拟随机失败
|
||||
if self.rng.random() < 0.15:
|
||||
self.metrics.pair_consumed_fail += 1
|
||||
await self.events.record('pair_fail', t_val)
|
||||
else:
|
||||
self.metrics.pair_consumed_ok += 1
|
||||
self.metrics.success_count += 1
|
||||
await self.events.record('pair_ok', t_val)
|
||||
finally:
|
||||
self.phys_sem.release()
|
||||
|
||||
# PairLease 退出后(正常路径)
|
||||
self._pairleased_t -= 1
|
||||
self._pairleased_q -= 1
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# PairLease.__aexit__ 已核销 slot。
|
||||
# 如果 _pairleased 已 +1,则 -1;否则不操作。
|
||||
if self._pairleased_t > 0:
|
||||
self._pairleased_t -= 1
|
||||
if self._pairleased_q > 0:
|
||||
self._pairleased_q -= 1
|
||||
raise
|
||||
except Exception:
|
||||
self.metrics.pair_consumed_fail += 1
|
||||
if self._pairleased_t > 0:
|
||||
self._pairleased_t -= 1
|
||||
if self._pairleased_q > 0:
|
||||
self._pairleased_q -= 1
|
||||
|
||||
self.check_invariants_soft()
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestProperty:
|
||||
"""性质测试: 随机事件流 + 持续不变量检查。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_random_events_10s(self):
|
||||
"""随机 S/P/C 事件流 10 秒,持续检查不变量。"""
|
||||
engine = PropertyEngine(seed=42)
|
||||
stop = asyncio.Event()
|
||||
errors = []
|
||||
|
||||
async def s_loop():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await engine.s_produce()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except AssertionError as e:
|
||||
errors.append(f'S: {e}')
|
||||
break
|
||||
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
|
||||
|
||||
async def p_loop():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await engine.p_produce()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except AssertionError as e:
|
||||
errors.append(f'P: {e}')
|
||||
break
|
||||
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
|
||||
|
||||
async def c_loop():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await engine.c_consume()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except AssertionError as e:
|
||||
errors.append(f'C: {e}')
|
||||
break
|
||||
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
|
||||
|
||||
tasks = []
|
||||
for _ in range(2):
|
||||
tasks.append(asyncio.create_task(s_loop()))
|
||||
tasks.append(asyncio.create_task(p_loop()))
|
||||
tasks.append(asyncio.create_task(c_loop()))
|
||||
|
||||
await asyncio.sleep(10)
|
||||
stop.set()
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 最终不变量检查
|
||||
engine.check_invariants_final()
|
||||
|
||||
assert not errors, f'不变量违反:\n' + '\n'.join(errors)
|
||||
print(f'\n[Property] t_produced={engine.metrics.t_produced} '
|
||||
f'q_sent={engine.metrics.q_sent} q_returned={engine.metrics.q_returned} '
|
||||
f'pair_ok={engine.metrics.pair_consumed_ok} pair_fail={engine.metrics.pair_consumed_fail} '
|
||||
f'success={engine.metrics.success_count}')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_random_with_cancellations(self):
|
||||
"""随机事件 + 随机取消 Worker,不变量仍成立。"""
|
||||
engine = PropertyEngine(seed=123)
|
||||
stop = asyncio.Event()
|
||||
errors = []
|
||||
|
||||
async def s_loop():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await engine.s_produce()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except AssertionError as e:
|
||||
errors.append(f'S: {e}')
|
||||
break
|
||||
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
|
||||
|
||||
async def p_loop():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await engine.p_produce()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except AssertionError as e:
|
||||
errors.append(f'P: {e}')
|
||||
break
|
||||
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
|
||||
|
||||
async def c_loop():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await engine.c_consume()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except AssertionError as e:
|
||||
errors.append(f'C: {e}')
|
||||
break
|
||||
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
|
||||
|
||||
async def cancel_loop():
|
||||
"""随机取消并重启 Worker。"""
|
||||
while not stop.is_set():
|
||||
await asyncio.sleep(engine.rng.uniform(0.5, 2.0))
|
||||
if tasks:
|
||||
idx = engine.rng.randint(0, len(tasks) - 1)
|
||||
t = tasks[idx]
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
# 重启
|
||||
role = idx % 3
|
||||
if role == 0:
|
||||
tasks[idx] = asyncio.create_task(s_loop())
|
||||
elif role == 1:
|
||||
tasks[idx] = asyncio.create_task(p_loop())
|
||||
else:
|
||||
tasks[idx] = asyncio.create_task(c_loop())
|
||||
|
||||
tasks = []
|
||||
for _ in range(2):
|
||||
tasks.append(asyncio.create_task(s_loop()))
|
||||
tasks.append(asyncio.create_task(p_loop()))
|
||||
tasks.append(asyncio.create_task(c_loop()))
|
||||
tasks.append(asyncio.create_task(cancel_loop()))
|
||||
|
||||
await asyncio.sleep(10)
|
||||
stop.set()
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
engine.check_invariants_final()
|
||||
assert not errors, f'不变量违反:\n' + '\n'.join(errors)
|
||||
print(f'\n[Property+Cancel] t_produced={engine.metrics.t_produced} '
|
||||
f'pair_ok={engine.metrics.pair_consumed_ok} '
|
||||
f'success={engine.metrics.success_count}')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extreme_ttl_pressure(self):
|
||||
"""极端 TTL: 所有资源几乎立即过期,验证过期清理不破坏守恒。"""
|
||||
engine = PropertyEngine(
|
||||
t_slot_cap=4, q_slot_cap=4, q_pending_cap=6, phys_cap=3,
|
||||
t_max_age=0.05, q_max_age=0.05, # 50ms 过期
|
||||
seed=789,
|
||||
)
|
||||
stop = asyncio.Event()
|
||||
errors = []
|
||||
|
||||
async def s_loop():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await engine.s_produce()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except AssertionError as e:
|
||||
errors.append(f'S: {e}')
|
||||
break
|
||||
await asyncio.sleep(engine.rng.uniform(0.01, 0.05))
|
||||
|
||||
async def p_loop():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await engine.p_produce()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except AssertionError as e:
|
||||
errors.append(f'P: {e}')
|
||||
break
|
||||
await asyncio.sleep(engine.rng.uniform(0.01, 0.05))
|
||||
|
||||
async def c_loop():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await engine.c_consume()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except AssertionError as e:
|
||||
errors.append(f'C: {e}')
|
||||
break
|
||||
await asyncio.sleep(engine.rng.uniform(0.01, 0.05))
|
||||
|
||||
tasks = []
|
||||
for _ in range(2):
|
||||
tasks.append(asyncio.create_task(s_loop()))
|
||||
tasks.append(asyncio.create_task(p_loop()))
|
||||
tasks.append(asyncio.create_task(c_loop()))
|
||||
|
||||
await asyncio.sleep(5)
|
||||
stop.set()
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
engine.check_invariants_final()
|
||||
assert not errors, f'不变量违反:\n' + '\n'.join(errors)
|
||||
print(f'\n[TTL Pressure] t_produced={engine.metrics.t_produced} '
|
||||
f't_expired={engine.metrics.t_expired} q_expired={engine.metrics.q_expired} '
|
||||
f'pair_ok={engine.metrics.pair_consumed_ok}')
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,132 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from tools.runtime_log_analyzer import (
|
||||
parse_monitor_lines,
|
||||
parse_solver_timelines,
|
||||
summarize_monitor_rows,
|
||||
summarize_solver_timelines,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeLogAnalyzerTests(unittest.TestCase):
|
||||
def test_parses_csp_monitor_rows_and_recent_rates(self):
|
||||
text = "\n".join(
|
||||
[
|
||||
"[*] T:0 Q:3 phys:0 t_slot:3 q_slot:0 q_pend:0 "
|
||||
"t_prod:76 t_adm:76 t_exp:0 q_sent:87 q_ret:87 q_adm:79 q_exp:0 "
|
||||
"pair:76 ok:71 fail:0 rate:8.6/min #71",
|
||||
"[*] T:0 Q:3 phys:0 t_slot:3 q_slot:0 q_pend:0 "
|
||||
"t_prod:91 t_adm:91 t_exp:0 q_sent:101 q_ret:101 q_adm:94 q_exp:0 "
|
||||
"pair:91 ok:86 fail:0 rate:8.8/min #86",
|
||||
]
|
||||
)
|
||||
|
||||
rows = parse_monitor_lines(text)
|
||||
summary = summarize_monitor_rows(rows)
|
||||
|
||||
self.assertEqual(rows[0].kind, "csp")
|
||||
self.assertEqual(summary["last_ok"], 86)
|
||||
self.assertAlmostEqual(summary["last_cumulative_rate"], 8.8)
|
||||
self.assertAlmostEqual(summary["recent_ok_per_min"], 9.89, places=2)
|
||||
self.assertAlmostEqual(summary["recent_t_prod_per_min"], 9.89, places=2)
|
||||
self.assertEqual(summary["last_q_minus_t"], 3)
|
||||
self.assertEqual(summary["last_q_return_minus_t_prod"], 10)
|
||||
|
||||
def test_parses_state_machine_monitor_rows(self):
|
||||
text = "\n".join(
|
||||
[
|
||||
"[*] slots:7/8 act:7 cpu:83% avg:76%/85 mem:4841M "
|
||||
"T:4 Q:0 sent:0 got:0(0%) rate:0.0/min #0",
|
||||
"[*] slots:8/8 act:8 cpu:91% avg:88%/85 mem:5012M "
|
||||
"T:6 Q:5 sent:61 got:48(79%) rate:7.8/min #40",
|
||||
"[*] slots:6/8 act:6 cpu:100% avg:90%/85 mem:5351M "
|
||||
"T:1 Q:6 sent:68 got:68(100%) rate:8.8/min #59",
|
||||
]
|
||||
)
|
||||
|
||||
rows = parse_monitor_lines(text)
|
||||
summary = summarize_monitor_rows(rows)
|
||||
|
||||
self.assertEqual(rows[-1].kind, "state_machine")
|
||||
self.assertEqual(summary["last_ok"], 59)
|
||||
self.assertAlmostEqual(summary["last_cumulative_rate"], 8.8)
|
||||
self.assertEqual(summary["last_q_minus_t"], 5)
|
||||
self.assertEqual(summary["last_slots"], 6)
|
||||
|
||||
def test_parses_csp_v2_monitor_rows_with_admission_fields(self):
|
||||
text = (
|
||||
"[*] T:1 Q:2 phys:3 p_send:1 t_slot:4 q_slot:5 q_pend:6 "
|
||||
"p_batch:3.5 t_prog:1 q_inflight:4 "
|
||||
"s_phys:0.50/3.00 p_phys:0.20/1.40 c_phys:0.30/2.50 "
|
||||
"p_stage:0.50/0.80/0.40 c_stage:0.30/0.40/1.50 c_hot:4/1 "
|
||||
"solver_goto:0.10 solver_inject:0.20 solver_initial:0.50 "
|
||||
"solver_click:0.01 solver_wait:11.80 solver_reuse:0.75 solver_visible:0.00 "
|
||||
"t_prod:20 t_adm:18 t_exp:1 q_sent:24 q_ret:22 q_adm:20 q_exp:0 "
|
||||
"pair:17 ok:16 fail:1 rate:9.1/min #16"
|
||||
)
|
||||
|
||||
rows = parse_monitor_lines(text)
|
||||
summary = summarize_monitor_rows(rows)
|
||||
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].kind, "csp")
|
||||
self.assertEqual(summary["last_ok"], 16)
|
||||
self.assertEqual(summary["last_q_return_minus_t_prod"], 2)
|
||||
self.assertEqual(summary["last_solver_wait"], 11.8)
|
||||
self.assertEqual(summary["last_solver_reuse"], 0.75)
|
||||
self.assertEqual(summary["last_phys"], 3)
|
||||
self.assertEqual(summary["last_p_batch"], 3.5)
|
||||
self.assertEqual(summary["last_t_prog"], 1)
|
||||
self.assertEqual(summary["last_q_inflight"], 4)
|
||||
self.assertEqual(summary["last_s_phys_hold"], 3.0)
|
||||
self.assertEqual(summary["last_p_phys_wait"], 0.2)
|
||||
self.assertEqual(summary["last_c_phys_hold"], 2.5)
|
||||
self.assertEqual(summary["last_physical_hold_leader"], "s")
|
||||
self.assertEqual(summary["last_physical_wait_leader"], "s")
|
||||
self.assertEqual(summary["last_p_email_create"], 0.5)
|
||||
self.assertEqual(summary["last_p_page_prepare"], 0.8)
|
||||
self.assertEqual(summary["last_p_send"], 0.4)
|
||||
self.assertEqual(summary["last_c_page_acquire"], 0.3)
|
||||
self.assertEqual(summary["last_c_verify"], 0.4)
|
||||
self.assertEqual(summary["last_c_register"], 1.5)
|
||||
self.assertEqual(summary["last_c_hot_hits"], 4)
|
||||
self.assertEqual(summary["last_c_hot_misses"], 1)
|
||||
|
||||
def test_summarizes_solver_timeline_events(self):
|
||||
text = (
|
||||
'[solver_timeline] [{"t":0.0,"event":"page_trace_after_inject",'
|
||||
'"page_trace":{"created_at":0.0,"render_called_at":100.0,"render_returned_at":120.0,'
|
||||
'"token_written_at":null}},'
|
||||
'{"t":0.5,"event":"click_before","attempt":1,"token_len":0,'
|
||||
'"dom":{"widget":{"present":true,"visible":true},'
|
||||
'"turnstile_iframe_count":0,'
|
||||
'"element_at_center":{"tag":"DIV","is_iframe":false}}},'
|
||||
'{"t":3.0,"event":"click_after","attempt":1,"token_len":0,'
|
||||
'"click_call_ms":2500.0,'
|
||||
'"click_trace":{"mouse_move1_ms":500.0,"mouse_move2_ms":1500.0,'
|
||||
'"mouse_down_ms":300.0,"mouse_up_ms":100.0}},'
|
||||
'{"t":3.0,"event":"poll_start"},'
|
||||
'{"t":4.0,"event":"poll_done","ok":true,"token_len":730,'
|
||||
'"poll_attempts":2,"first_token_attempt":2,"poll_read_ms_avg":12.0,'
|
||||
'"poll_read_ms_max":20.0,'
|
||||
'"page_trace":{"created_at":0.0,"render_called_at":100.0,"render_returned_at":120.0,'
|
||||
'"token_written_at":3900.0}}]'
|
||||
)
|
||||
|
||||
timelines = parse_solver_timelines(text)
|
||||
summary = summarize_solver_timelines(timelines)
|
||||
|
||||
self.assertEqual(len(timelines), 1)
|
||||
self.assertEqual(summary["solver_timeline_count"], 1)
|
||||
self.assertEqual(summary["ok_count"], 1)
|
||||
self.assertEqual(summary["avg_click_call_ms"], 2500.0)
|
||||
self.assertEqual(summary["avg_click_mouse_move_ms"], 2000.0)
|
||||
self.assertEqual(summary["avg_render_to_token_ms"], 3800.0)
|
||||
self.assertEqual(summary["avg_token_write_to_poll_done_ms"], 100.0)
|
||||
self.assertEqual(summary["avg_poll_attempts"], 2.0)
|
||||
self.assertEqual(summary["center_iframe_hit_ratio"], 0.0)
|
||||
self.assertEqual(summary["turnstile_iframe_seen_ratio"], 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,64 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _entrypoint_workspace(tmp_path):
|
||||
for name in ("start.sh", "auth-service.sh", "setup.sh"):
|
||||
shutil.copy2(ROOT / name, tmp_path / name)
|
||||
scripts = tmp_path / "scripts"
|
||||
scripts.mkdir()
|
||||
shutil.copy2(ROOT / "scripts" / "ensure_runtime.sh", scripts / "ensure_runtime.sh")
|
||||
python = tmp_path / ".venv" / "bin" / "python"
|
||||
python.parent.mkdir(parents=True)
|
||||
python.write_text(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ENTRY_CAPTURE\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
python.chmod(0o755)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _run_entry(workspace, script, *args):
|
||||
capture = workspace / "capture.txt"
|
||||
environment = {**os.environ, "ENTRY_CAPTURE": str(capture)}
|
||||
completed = subprocess.run(
|
||||
["bash", script, *args],
|
||||
cwd=workspace,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return capture.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_start_dispatches_registration_and_email_as_independent_modules(tmp_path):
|
||||
workspace = _entrypoint_workspace(tmp_path)
|
||||
(workspace / ".env").write_text("EMAIL_MODE=tempmail\n", encoding="utf-8")
|
||||
|
||||
assert _run_entry(workspace, "start.sh", "--target", "3") == [
|
||||
"-m",
|
||||
"grok_register.register",
|
||||
"--target",
|
||||
"3",
|
||||
]
|
||||
assert _run_entry(workspace, "start.sh", "--email-service", "--port", "9090") == [
|
||||
"-m",
|
||||
"grok_register.email_server",
|
||||
"--port",
|
||||
"9090",
|
||||
]
|
||||
|
||||
|
||||
def test_auth_service_keeps_the_supported_python_module_internal(tmp_path):
|
||||
workspace = _entrypoint_workspace(tmp_path)
|
||||
assert _run_entry(workspace, "auth-service.sh", "--debug") == [
|
||||
"-m",
|
||||
"xai_enroller.service",
|
||||
"--debug",
|
||||
]
|
||||
@@ -1,411 +0,0 @@
|
||||
"""
|
||||
Layer 4: 压力测试
|
||||
|
||||
真实 asyncio 并发跑较长时间,检查:
|
||||
- pending 泄漏
|
||||
- 库存卡死
|
||||
- claim 饥饿
|
||||
- 等待时间周期性尖峰
|
||||
- slot 守恒在长时间运行后仍成立
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
import statistics
|
||||
import pytest
|
||||
|
||||
from grok_register.core.envelope import ResourceEnvelope
|
||||
from grok_register.core.inventory import Inventory
|
||||
from grok_register.core.observer import Metrics
|
||||
from tests.fakes import Conservation, EventLog
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# S/P/C 真实并发 Worker (使用 fake 服务)
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
async def stress_s_worker(wid, t_gen, inventory, phys, t_slot, metrics, stop):
|
||||
"""压力测试 S_Worker。"""
|
||||
while not stop.is_set():
|
||||
await phys.acquire()
|
||||
try:
|
||||
token = await t_gen.generate()
|
||||
finally:
|
||||
phys.release()
|
||||
|
||||
if token is None:
|
||||
metrics.t_discarded += 1
|
||||
continue
|
||||
|
||||
metrics.t_produced += 1
|
||||
now = time.time()
|
||||
try:
|
||||
t_env = await ResourceEnvelope.create_with_slot(
|
||||
'T', token, t_slot, expires_at=now + 60
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
metrics.t_discarded += 1
|
||||
raise
|
||||
|
||||
try:
|
||||
await inventory.put_t(t_env)
|
||||
except Exception:
|
||||
t_env.discard(reason='put_fail')
|
||||
metrics.t_discarded += 1
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
async def stress_p_worker(wid, email_svc, inventory, phys, q_pend, q_slot, metrics, stop):
|
||||
"""压力测试 P_Worker。"""
|
||||
loop = asyncio.get_event_loop()
|
||||
while not stop.is_set():
|
||||
await q_pend.acquire()
|
||||
_pend_released = False
|
||||
try:
|
||||
await phys.acquire()
|
||||
try:
|
||||
handle, email, password = await email_svc.create_email()
|
||||
sent = True
|
||||
finally:
|
||||
phys.release()
|
||||
|
||||
if not sent:
|
||||
q_pend.release()
|
||||
_pend_released = True
|
||||
continue
|
||||
|
||||
metrics.q_sent += 1
|
||||
|
||||
try:
|
||||
code = await asyncio.wait_for(email_svc.poll_code(handle), timeout=95)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
code = None
|
||||
|
||||
if code is None:
|
||||
metrics.q_discarded += 1
|
||||
q_pend.release()
|
||||
_pend_released = True
|
||||
continue
|
||||
|
||||
metrics.q_returned += 1
|
||||
now = time.time()
|
||||
q_env = await ResourceEnvelope.create_with_slot(
|
||||
'Q', {'email': email, 'password': password, 'code': code},
|
||||
q_slot, expires_at=now + 60,
|
||||
)
|
||||
|
||||
try:
|
||||
await inventory.put_q(q_env)
|
||||
except Exception:
|
||||
q_env.discard(reason='put_fail')
|
||||
metrics.q_discarded += 1
|
||||
|
||||
except asyncio.CancelledError:
|
||||
if not _pend_released:
|
||||
q_pend.release()
|
||||
_pend_released = True
|
||||
raise
|
||||
except Exception:
|
||||
metrics.q_discarded += 1
|
||||
finally:
|
||||
if not _pend_released:
|
||||
q_pend.release()
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
async def stress_c_worker(wid, register_api, inventory, phys, metrics, stop, file_lock):
|
||||
"""压力测试 C_Worker。"""
|
||||
claim_wait_times = []
|
||||
while not stop.is_set():
|
||||
t0 = time.time()
|
||||
try:
|
||||
async with inventory.claim_pair() as pair:
|
||||
claim_wait_times.append(time.time() - t0)
|
||||
t_val = pair.t.value
|
||||
q_val = pair.q.value
|
||||
|
||||
await phys.acquire()
|
||||
try:
|
||||
sso = await register_api.register(
|
||||
q_val['email'], q_val['password'], q_val['code'], t_val
|
||||
)
|
||||
if sso:
|
||||
metrics.pair_consumed_ok += 1
|
||||
metrics.success_count += 1
|
||||
else:
|
||||
metrics.pair_consumed_fail += 1
|
||||
finally:
|
||||
phys.release()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
metrics.pair_consumed_fail += 1
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
return claim_wait_times
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestStress:
|
||||
"""压力测试: 长时间真实并发。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_steady_state_30s(self):
|
||||
"""稳态运行 30 秒,检查守恒、无泄漏、无饥饿。"""
|
||||
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI
|
||||
|
||||
t_cap, q_cap, pend_cap, phys_cap = 16, 16, 24, 8
|
||||
t_slot_sem = asyncio.Semaphore(t_cap)
|
||||
q_slot_sem = asyncio.Semaphore(q_cap)
|
||||
q_pend_sem = asyncio.Semaphore(pend_cap)
|
||||
phys_sem = asyncio.Semaphore(phys_cap)
|
||||
|
||||
ft = FakeTurnstile(delay=0.005, fail_rate=0.05)
|
||||
es = FakeEmailService()
|
||||
fra = FakeRegisterAPI(success_rate=0.9, delay=0.005)
|
||||
|
||||
metrics = Metrics()
|
||||
inv = Inventory(metrics=metrics)
|
||||
cons = Conservation(t_slot_sem, q_slot_sem, q_pend_sem, inv)
|
||||
stop = asyncio.Event()
|
||||
fl = asyncio.Lock()
|
||||
|
||||
tasks = []
|
||||
all_claim_waits = []
|
||||
|
||||
# S workers
|
||||
for i in range(4):
|
||||
tasks.append(asyncio.create_task(
|
||||
stress_s_worker(i, ft, inv, phys_sem, t_slot_sem, metrics, stop)
|
||||
))
|
||||
# P workers
|
||||
for i in range(6):
|
||||
tasks.append(asyncio.create_task(
|
||||
stress_p_worker(i, es, inv, phys_sem, q_pend_sem, q_slot_sem, metrics, stop)
|
||||
))
|
||||
# C workers
|
||||
for i in range(4):
|
||||
tasks.append(asyncio.create_task(
|
||||
stress_c_worker(i, fra, inv, phys_sem, metrics, stop, fl)
|
||||
))
|
||||
|
||||
# 监控 + 守恒检查
|
||||
async def monitor():
|
||||
snapshots = []
|
||||
while not stop.is_set():
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
cons.check_t_conservation(t_cap)
|
||||
cons.check_q_conservation(q_cap)
|
||||
# Q_Pending: 只做软检查(非负、不超限),不做守恒检查
|
||||
# 因为 P worker 正常 acquire/release 期间 free 值会波动
|
||||
assert q_pend_sem._value >= 0, 'Q pending 为负'
|
||||
assert q_pend_sem._value <= pend_cap, 'Q pending 超限'
|
||||
except AssertionError as e:
|
||||
snapshots.append(f'INVARIANT VIOLATION: {e}')
|
||||
snapshots.append(
|
||||
f't={metrics.t_produced} q_sent={metrics.q_sent} q_ret={metrics.q_returned} '
|
||||
f'pair_ok={metrics.pair_consumed_ok} inv_t={inv.t_depth} inv_q={inv.q_depth} '
|
||||
f'phys={phys_sem._value} t_slot={t_slot_sem._value} q_slot={q_slot_sem._value}'
|
||||
)
|
||||
return snapshots
|
||||
|
||||
mon_task = asyncio.create_task(monitor())
|
||||
|
||||
await asyncio.sleep(30)
|
||||
stop.set()
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
snapshots = await mon_task
|
||||
|
||||
# 最终守恒检查(所有 Worker 已停止)
|
||||
cons.check_t_conservation(t_cap)
|
||||
cons.check_q_conservation(q_cap)
|
||||
# Q_Pending: 软检查
|
||||
assert q_pend_sem._value >= 0 and q_pend_sem._value <= pend_cap
|
||||
|
||||
# 检查是否有 INVARIANT VIOLATION
|
||||
violations = [s for s in snapshots if 'INVARIANT' in s]
|
||||
assert not violations, f'守恒违反:\n' + '\n'.join(violations)
|
||||
|
||||
print(f'\n[Stress 30s] t_produced={metrics.t_produced} '
|
||||
f'q_sent={metrics.q_sent} q_returned={metrics.q_returned} '
|
||||
f'pair_ok={metrics.pair_consumed_ok} pair_fail={metrics.pair_consumed_fail} '
|
||||
f'success={metrics.success_count}')
|
||||
print(f'Snapshots ({len(snapshots)}):')
|
||||
for s in snapshots[-5:]:
|
||||
print(f' {s}')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_claim_starvation(self):
|
||||
"""检查 C 不会永远等不到 pair (claim 饥饿)。"""
|
||||
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI
|
||||
|
||||
t_cap, q_cap, pend_cap, phys_cap = 4, 4, 6, 2
|
||||
t_slot_sem = asyncio.Semaphore(t_cap)
|
||||
q_slot_sem = asyncio.Semaphore(q_cap)
|
||||
q_pend_sem = asyncio.Semaphore(pend_cap)
|
||||
phys_sem = asyncio.Semaphore(phys_cap)
|
||||
|
||||
ft = FakeTurnstile(delay=0.002, fail_rate=0.0)
|
||||
es = FakeEmailService()
|
||||
fra = FakeRegisterAPI(success_rate=1.0, delay=0.002)
|
||||
|
||||
metrics = Metrics()
|
||||
inv = Inventory(metrics=metrics)
|
||||
stop = asyncio.Event()
|
||||
fl = asyncio.Lock()
|
||||
|
||||
claim_wait_samples = []
|
||||
last_success_time = time.time()
|
||||
|
||||
async def c_worker(wid):
|
||||
nonlocal last_success_time
|
||||
while not stop.is_set():
|
||||
t0 = time.time()
|
||||
try:
|
||||
async with inv.claim_pair() as pair:
|
||||
wait = time.time() - t0
|
||||
claim_wait_samples.append(wait)
|
||||
|
||||
await phys_sem.acquire()
|
||||
try:
|
||||
sso = await fra.register(
|
||||
pair.q.value['email'], pair.q.value['password'],
|
||||
pair.q.value['code'], pair.t.value
|
||||
)
|
||||
if sso:
|
||||
metrics.pair_consumed_ok += 1
|
||||
metrics.success_count += 1
|
||||
last_success_time = time.time()
|
||||
finally:
|
||||
phys_sem.release()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
metrics.pair_consumed_fail += 1
|
||||
await asyncio.sleep(0.005)
|
||||
|
||||
tasks = []
|
||||
for i in range(3):
|
||||
tasks.append(asyncio.create_task(stress_s_worker(i, ft, inv, phys_sem, t_slot_sem, metrics, stop)))
|
||||
for i in range(4):
|
||||
tasks.append(asyncio.create_task(stress_p_worker(i, es, inv, phys_sem, q_pend_sem, q_slot_sem, metrics, stop)))
|
||||
for i in range(3):
|
||||
tasks.append(asyncio.create_task(c_worker(i)))
|
||||
|
||||
await asyncio.sleep(15)
|
||||
stop.set()
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 检查: claim 等待时间不应有极端尖峰
|
||||
if claim_wait_samples:
|
||||
p50 = statistics.median(claim_wait_samples)
|
||||
p95 = sorted(claim_wait_samples)[int(len(claim_wait_samples) * 0.95)]
|
||||
p99 = sorted(claim_wait_samples)[int(len(claim_wait_samples) * 0.99)]
|
||||
print(f'\n[Starvation] claim_wait p50={p50:.3f}s p95={p95:.3f}s p99={p99:.3f}s')
|
||||
print(f' samples={len(claim_wait_samples)} pair_ok={metrics.pair_consumed_ok}')
|
||||
# p99 不应超过 5 秒(在 fake 服务下)
|
||||
assert p99 < 5.0, f'claim 饥饿: p99={p99:.3f}s 太高'
|
||||
|
||||
# 检查: 最近成功消费不应太久
|
||||
time_since_last = time.time() - last_success_time
|
||||
assert time_since_last < 5.0, f'太久没有成功消费: {time_since_last:.1f}s'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_high_cancellation_churn(self):
|
||||
"""高取消翻转: Worker 频繁取消重启,系统仍保持守恒。"""
|
||||
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI
|
||||
|
||||
t_cap, q_cap, pend_cap, phys_cap = 8, 8, 12, 4
|
||||
t_slot_sem = asyncio.Semaphore(t_cap)
|
||||
q_slot_sem = asyncio.Semaphore(q_cap)
|
||||
q_pend_sem = asyncio.Semaphore(pend_cap)
|
||||
phys_sem = asyncio.Semaphore(phys_cap)
|
||||
|
||||
ft = FakeTurnstile(delay=0.003, fail_rate=0.1)
|
||||
es = FakeEmailService()
|
||||
fra = FakeRegisterAPI(success_rate=0.85, delay=0.003)
|
||||
|
||||
metrics = Metrics()
|
||||
inv = Inventory(metrics=metrics)
|
||||
cons = Conservation(t_slot_sem, q_slot_sem, q_pend_sem, inv)
|
||||
stop = asyncio.Event()
|
||||
fl = asyncio.Lock()
|
||||
|
||||
tasks = []
|
||||
violations = []
|
||||
|
||||
def spawn_worker(kind, idx):
|
||||
if kind == 'S':
|
||||
return asyncio.create_task(stress_s_worker(idx, ft, inv, phys_sem, t_slot_sem, metrics, stop))
|
||||
elif kind == 'P':
|
||||
return asyncio.create_task(stress_p_worker(idx, es, inv, phys_sem, q_pend_sem, q_slot_sem, metrics, stop))
|
||||
else:
|
||||
return asyncio.create_task(stress_c_worker(idx, fra, inv, phys_sem, metrics, stop, fl))
|
||||
|
||||
# 初始 workers
|
||||
for i in range(3):
|
||||
tasks.append(spawn_worker('S', i))
|
||||
tasks.append(spawn_worker('P', i))
|
||||
tasks.append(spawn_worker('C', i))
|
||||
|
||||
async def churn():
|
||||
import random
|
||||
while not stop.is_set():
|
||||
await asyncio.sleep(0.3)
|
||||
# 随机取消一个,重启一个
|
||||
if tasks:
|
||||
idx = random.randint(0, len(tasks) - 1)
|
||||
t = tasks[idx]
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
tasks[idx] = spawn_worker(
|
||||
random.choice(['S', 'P', 'C']),
|
||||
random.randint(0, 9)
|
||||
)
|
||||
# 定期守恒检查(软检查)
|
||||
try:
|
||||
cons.check_t_conservation(t_cap)
|
||||
cons.check_q_conservation(q_cap)
|
||||
assert q_pend_sem._value >= 0 and q_pend_sem._value <= pend_cap
|
||||
except AssertionError as e:
|
||||
violations.append(str(e))
|
||||
|
||||
churn_task = asyncio.create_task(churn())
|
||||
tasks.append(churn_task)
|
||||
|
||||
await asyncio.sleep(15)
|
||||
stop.set()
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 最终守恒检查
|
||||
try:
|
||||
cons.check_t_conservation(t_cap)
|
||||
cons.check_q_conservation(q_cap)
|
||||
assert q_pend_sem._value >= 0 and q_pend_sem._value <= pend_cap
|
||||
except AssertionError as e:
|
||||
violations.append(f'FINAL: {e}')
|
||||
|
||||
assert not violations, f'守恒违反:\n' + '\n'.join(violations[:5])
|
||||
print(f'\n[High Churn] t_produced={metrics.t_produced} '
|
||||
f'pair_ok={metrics.pair_consumed_ok} success={metrics.success_count}')
|
||||
@@ -1,298 +0,0 @@
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
from xai_enroller.auth_pipeline import AuthPipeline
|
||||
from xai_enroller.ledger import Ledger
|
||||
from xai_enroller.models import (
|
||||
AuthorizationResult,
|
||||
AuthorizationStatus,
|
||||
DeviceFlow,
|
||||
JobStatus,
|
||||
OAuthCredential,
|
||||
PipelineState,
|
||||
SinkReceipt,
|
||||
SourceRecord,
|
||||
)
|
||||
|
||||
|
||||
class EmptySource:
|
||||
async def records(self):
|
||||
if False:
|
||||
yield None
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class NoopExecutor:
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def _pipeline(tmp_path):
|
||||
return AuthPipeline(
|
||||
source=EmptySource(),
|
||||
protocol=object(),
|
||||
executor=NoopExecutor(),
|
||||
sink=object(),
|
||||
ledger=Ledger(tmp_path / "ledger.db", b"salt"),
|
||||
)
|
||||
|
||||
|
||||
def test_cancel_active_is_idempotent_while_cleanup_is_in_progress(tmp_path):
|
||||
async def scenario():
|
||||
pipeline = _pipeline(tmp_path)
|
||||
|
||||
async def wait_forever():
|
||||
await asyncio.Future()
|
||||
|
||||
task = asyncio.create_task(wait_forever())
|
||||
pipeline._authorization_task = task
|
||||
pipeline._authorization_cancellable = True
|
||||
assert await pipeline.cancel_active()
|
||||
assert not await pipeline.cancel_active()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
class SingleSource:
|
||||
async def records(self):
|
||||
yield SourceRecord("stock", "opaque")
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class ImmediateProtocol:
|
||||
async def start_device_flow(self):
|
||||
return DeviceFlow(
|
||||
"device",
|
||||
"code",
|
||||
"https://accounts.x.ai/oauth2/device",
|
||||
600,
|
||||
0,
|
||||
)
|
||||
|
||||
async def poll_token(self, **_kwargs):
|
||||
return OAuthCredential(
|
||||
"access",
|
||||
"refresh",
|
||||
None,
|
||||
"Bearer",
|
||||
3600,
|
||||
"later",
|
||||
"now",
|
||||
"subject",
|
||||
"https://auth.x.ai/oauth2/token",
|
||||
)
|
||||
|
||||
|
||||
class ImmediateExecutor:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def start(self):
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async def confirm(self, *_args):
|
||||
self.calls += 1
|
||||
return AuthorizationResult(AuthorizationStatus.AUTHORIZED, "confirmed")
|
||||
|
||||
|
||||
class BlockingThreadSink:
|
||||
def __init__(self):
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.pipeline = None
|
||||
self.calls = 0
|
||||
|
||||
def _store(self):
|
||||
self.calls += 1
|
||||
self.entered.set()
|
||||
self.release.wait(2)
|
||||
return SinkReceipt("opaque")
|
||||
|
||||
async def store(self, _credential):
|
||||
receipt = await asyncio.to_thread(self._store)
|
||||
self.pipeline.request_stop()
|
||||
return receipt
|
||||
|
||||
|
||||
def test_sink_and_ledger_commit_cannot_be_cancelled_mid_commit(tmp_path):
|
||||
async def scenario():
|
||||
executor = ImmediateExecutor()
|
||||
sink = BlockingThreadSink()
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"salt")
|
||||
events = []
|
||||
pipeline = AuthPipeline(
|
||||
source=SingleSource(),
|
||||
protocol=ImmediateProtocol(),
|
||||
executor=executor,
|
||||
sink=sink,
|
||||
ledger=ledger,
|
||||
timeout=2,
|
||||
event_callback=lambda kind, data: events.append((kind, data)),
|
||||
)
|
||||
sink.pipeline = pipeline
|
||||
run = asyncio.create_task(pipeline.run())
|
||||
assert await asyncio.to_thread(sink.entered.wait, 1)
|
||||
assert not await pipeline.cancel_active()
|
||||
sink.release.set()
|
||||
await asyncio.wait_for(run, 2)
|
||||
assert executor.calls == 1
|
||||
assert sink.calls == 1
|
||||
assert ledger.aggregate_counts()["imported_unique"] == 1
|
||||
started = next(data for kind, data in events if kind == "authorization_started")
|
||||
imported = next(
|
||||
data
|
||||
for kind, data in events
|
||||
if kind == "result" and data["status"] == "imported"
|
||||
)
|
||||
assert imported["task_number"] == started["task_number"]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_pending_total_uses_current_snapshot_set_difference(tmp_path):
|
||||
class SnapshotSource(EmptySource):
|
||||
snapshot_fingerprints = frozenset({"a", "b", "c"})
|
||||
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"salt")
|
||||
imported = ledger.start_fingerprint("b")
|
||||
ledger.finish(imported, JobStatus.IMPORTED, "imported", "receipt-b")
|
||||
outside = ledger.start_fingerprint("outside")
|
||||
ledger.finish(outside, JobStatus.IMPORTED, "imported", "receipt-outside")
|
||||
pipeline = AuthPipeline(
|
||||
source=SnapshotSource(),
|
||||
protocol=object(),
|
||||
executor=NoopExecutor(),
|
||||
sink=object(),
|
||||
ledger=ledger,
|
||||
)
|
||||
|
||||
assert pipeline.status()["pending_total"] == 2
|
||||
assert pipeline.status()["five_minute_imports_per_minute"] is None
|
||||
assert pipeline.status()["lifetime_imports_per_minute"] is None
|
||||
|
||||
|
||||
def test_pending_total_is_unknown_before_first_valid_snapshot(tmp_path):
|
||||
pipeline = _pipeline(tmp_path)
|
||||
|
||||
assert pipeline.status()["pending_total"] is None
|
||||
|
||||
|
||||
def test_rate_limit_gate_grows_cooldown_on_consecutive_trips():
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
async def scenario():
|
||||
from xai_enroller.auth_pipeline import GlobalRateLimitGate
|
||||
|
||||
clock = Clock()
|
||||
gate = GlobalRateLimitGate(clock=clock, base_cooldown=60.0)
|
||||
assert await gate.rate_limited()
|
||||
assert gate.COOLDOWN_SECONDS == 60.0
|
||||
assert gate.snapshot()["cooldown_seconds"] == 60.0
|
||||
|
||||
resume = asyncio.Event()
|
||||
resume.set()
|
||||
clock.now = 60.0
|
||||
probe = await gate.wait_for_permission(resume)
|
||||
assert probe is not None
|
||||
assert await gate.rate_limited(probe)
|
||||
assert gate.COOLDOWN_SECONDS == 90.0
|
||||
assert gate.snapshot()["cooldown_remaining_seconds"] == 90.0
|
||||
|
||||
clock.now = 150.0
|
||||
probe = await gate.wait_for_permission(resume)
|
||||
elapsed = await gate.authorized(probe)
|
||||
assert elapsed == 150.0
|
||||
assert gate.COOLDOWN_SECONDS == 60.0
|
||||
assert gate.snapshot()["rate_limit_trips"] == 0
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_minimum_start_interval_raises_after_rate_limit_and_decays():
|
||||
from xai_enroller.auth_pipeline import MinimumStartInterval
|
||||
|
||||
def approx(value, rel=1e-6):
|
||||
class Approx:
|
||||
def __eq__(self, other):
|
||||
return abs(float(other) - float(value)) <= rel * max(1.0, abs(value))
|
||||
|
||||
def __repr__(self):
|
||||
return f"approx({value})"
|
||||
|
||||
return Approx()
|
||||
|
||||
interval = MinimumStartInterval(10.0)
|
||||
assert interval.seconds == 10.0
|
||||
raised = interval.note_rate_limited()
|
||||
assert raised == 18.0
|
||||
raised = interval.note_rate_limited()
|
||||
assert raised == approx(18.0 * 1.55)
|
||||
for _ in range(MinimumStartInterval.SUCCESS_STREAK_TO_DECAY):
|
||||
interval.note_success()
|
||||
assert interval.seconds < raised
|
||||
# Keep decaying until the base floor is restored.
|
||||
for _ in range(40):
|
||||
interval.note_success()
|
||||
assert interval.seconds == 10.0
|
||||
|
||||
|
||||
def test_queued_source_imported_while_paused_is_not_authorized(tmp_path):
|
||||
async def scenario():
|
||||
executor = ImmediateExecutor()
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"salt")
|
||||
pipeline = AuthPipeline(
|
||||
source=EmptySource(),
|
||||
protocol=ImmediateProtocol(),
|
||||
executor=executor,
|
||||
sink=object(),
|
||||
ledger=ledger,
|
||||
timeout=2,
|
||||
)
|
||||
source = SourceRecord("stock", "opaque")
|
||||
fingerprint = ledger.fingerprint(source.source_id)
|
||||
|
||||
assert await pipeline.admit(source)
|
||||
pipeline.pause()
|
||||
await pipeline.rate_gate.rate_limited()
|
||||
run = asyncio.create_task(pipeline.run())
|
||||
try:
|
||||
async with asyncio.timeout(1):
|
||||
while pipeline._authorization_task is None:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
external_job_id = ledger.start_fingerprint(fingerprint)
|
||||
ledger.finish(
|
||||
external_job_id,
|
||||
JobStatus.IMPORTED,
|
||||
"imported",
|
||||
"external-receipt",
|
||||
)
|
||||
pipeline.resume()
|
||||
|
||||
async with asyncio.timeout(1):
|
||||
while (
|
||||
executor.calls == 0
|
||||
and pipeline._states.get(fingerprint) is not PipelineState.IMPORTED
|
||||
):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert executor.calls == 0
|
||||
assert pipeline._states[fingerprint] is PipelineState.IMPORTED
|
||||
finally:
|
||||
pipeline.request_stop()
|
||||
await asyncio.wait_for(run, 2)
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -1,391 +0,0 @@
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from xai_enroller.models import JobStatus
|
||||
from xai_enroller.service import (
|
||||
AuthServiceSettings,
|
||||
AuthPipelineRunner,
|
||||
EventTerminal,
|
||||
InteractiveCommandPrompt,
|
||||
resolve_auth_log_mode,
|
||||
)
|
||||
from xai_enroller.ledger import Ledger
|
||||
from xai_enroller.inventory import InventoryError
|
||||
|
||||
|
||||
def test_auth_log_mode_prefers_cli_and_rejects_invalid_values():
|
||||
assert resolve_auth_log_mode([], {}) == "user"
|
||||
assert resolve_auth_log_mode(
|
||||
[], {"XAI_AUTH_SERVICE_LOG_MODE": "debug"}
|
||||
) == "debug"
|
||||
assert resolve_auth_log_mode(
|
||||
["--debug"], {"XAI_AUTH_SERVICE_LOG_MODE": "user"}
|
||||
) == "debug"
|
||||
with pytest.raises(ValueError, match="XAI_AUTH_SERVICE_LOG_MODE"):
|
||||
resolve_auth_log_mode([], {"XAI_AUTH_SERVICE_LOG_MODE": "verbose"})
|
||||
|
||||
|
||||
def test_user_terminal_reports_progress_without_internal_or_secret_fields():
|
||||
messages = []
|
||||
terminal = EventTerminal(mode="user", output=messages.append)
|
||||
terminal.emit(
|
||||
(
|
||||
"startup",
|
||||
{
|
||||
"available": 7,
|
||||
"claimed": 3,
|
||||
"destination": "authenticated/",
|
||||
"source_kind": "local",
|
||||
"ssh_host": "do-not-print.example",
|
||||
},
|
||||
)
|
||||
)
|
||||
terminal.emit(
|
||||
(
|
||||
"authorization_started",
|
||||
{
|
||||
"task_number": 3,
|
||||
"attempt_number": 1,
|
||||
"pending_total": 41,
|
||||
"source_queue": 64,
|
||||
"source_id": "secret@example.test",
|
||||
},
|
||||
)
|
||||
)
|
||||
terminal.emit(
|
||||
(
|
||||
"result",
|
||||
{
|
||||
"status": "imported",
|
||||
"reason": "imported",
|
||||
"task_number": 3,
|
||||
"five_minute_imports_per_minute": 4.25,
|
||||
"lifetime_imports_per_minute": 1.75,
|
||||
"imported_unique": 120,
|
||||
"available": 117,
|
||||
"source_id": "secret@example.test",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert messages == [
|
||||
"[✓] 本地认证服务已启动 | 来源 本机 | 输出 authenticated/ | 待处理 — | 可用 7",
|
||||
"[→] 开始认证 #3 | 待处理 41",
|
||||
"[✓] 认证成功 #3 | 运行平均 1.75/分 | 累计 120 | 可用 117",
|
||||
]
|
||||
rendered = "\n".join(messages)
|
||||
assert "source_queue" not in rendered
|
||||
assert "secret@example.test" not in rendered
|
||||
assert "do-not-print.example" not in rendered
|
||||
|
||||
|
||||
def test_user_status_distinguishes_unknown_runtime_rate_from_zero_rate():
|
||||
base = {
|
||||
"state": "running",
|
||||
"pending_total": None,
|
||||
"active_stage": "idle",
|
||||
"imported_unique": 0,
|
||||
"available": 0,
|
||||
"claimed": 0,
|
||||
"cooldown": False,
|
||||
}
|
||||
messages = []
|
||||
terminal = EventTerminal(mode="user", output=messages.append)
|
||||
terminal.emit(("status", {**base, "lifetime_imports_per_minute": None}))
|
||||
terminal.emit(("status", {**base, "lifetime_imports_per_minute": 0.0}))
|
||||
|
||||
assert "运行平均 —" in messages[0]
|
||||
assert "运行平均 0.00/分" in messages[1]
|
||||
assert "source_queue" not in messages[0]
|
||||
|
||||
|
||||
def test_user_terminal_omits_missing_task_number_before_authorization_starts():
|
||||
messages = []
|
||||
terminal = EventTerminal(mode="user", output=messages.append)
|
||||
|
||||
terminal.emit(
|
||||
(
|
||||
"result",
|
||||
{
|
||||
"status": "failed",
|
||||
"reason": "device_flow_failed",
|
||||
"task_number": None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert messages == ["[✗] 认证未完成 | 暂时失败,将自动重试"]
|
||||
assert "None" not in messages[0]
|
||||
|
||||
|
||||
def test_debug_terminal_keeps_aggregate_diagnostics_and_sanitizes_unknown_events():
|
||||
messages = []
|
||||
terminal = EventTerminal(mode="debug", output=messages.append)
|
||||
terminal.emit(
|
||||
(
|
||||
"status",
|
||||
{
|
||||
"state": "running",
|
||||
"source_queue": 2,
|
||||
"prepared_queue": 1,
|
||||
"completion_queue": 0,
|
||||
"active_stage": "authorization",
|
||||
"retry_waiting": 1,
|
||||
"next_retry_seconds": 5.0,
|
||||
"authorization_starts": 3,
|
||||
"cooldown": False,
|
||||
"cooldown_remaining_seconds": 0.0,
|
||||
"probe_in_flight": False,
|
||||
"min_authorization_interval_seconds": 10.0,
|
||||
"pacing_remaining_seconds": 2.0,
|
||||
"imported_unique": 2,
|
||||
"attempted_unique": 3,
|
||||
"rate_limited": 1,
|
||||
"five_minute_imports_per_minute": 2.0,
|
||||
"lifetime_imports_per_minute": 1.0,
|
||||
"available": 2,
|
||||
"claiming": 0,
|
||||
"claimed": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
terminal.emit(
|
||||
(
|
||||
"future_event",
|
||||
{"reason": "internal_error", "token": "do-not-print-token"},
|
||||
)
|
||||
)
|
||||
|
||||
assert "queues=2/1/0" in messages[0]
|
||||
assert messages[1] == "• debug event=future_event reason=internal_error"
|
||||
assert "do-not-print-token" not in "\n".join(messages)
|
||||
|
||||
|
||||
def test_terminal_output_failure_does_not_escape():
|
||||
def broken_output(_message):
|
||||
raise OSError("closed terminal")
|
||||
|
||||
terminal = EventTerminal(mode="user", output=broken_output)
|
||||
terminal.emit(("service_stopped", {}))
|
||||
|
||||
|
||||
def test_interactive_prompt_restores_partially_typed_take_command_after_events():
|
||||
output = io.StringIO()
|
||||
commands = []
|
||||
prompt = InteractiveCommandPrompt(
|
||||
output=output,
|
||||
interactive=True,
|
||||
prompt="认证> ",
|
||||
)
|
||||
prompt.start(commands.append)
|
||||
prompt.feed("take 12")
|
||||
|
||||
prompt.write_event("[↻] 发现新账号 3")
|
||||
|
||||
assert output.getvalue().endswith("[↻] 发现新账号 3\n认证> take 12")
|
||||
|
||||
prompt.feed("\r")
|
||||
|
||||
assert commands == ["take 12"]
|
||||
assert output.getvalue().endswith("\n认证> ")
|
||||
|
||||
|
||||
def test_auth_service_configuration_errors_are_actionable_without_traceback(tmp_path):
|
||||
environment = os.environ.copy()
|
||||
environment.pop("XAI_AUTH_SERVICE_SSH_HOST", None)
|
||||
environment.pop("XAI_AUTH_SERVICE_LOG_MODE", None)
|
||||
environment["XAI_AUTH_SERVICE_SOURCE"] = "ssh"
|
||||
missing = subprocess.run(
|
||||
[sys.executable, "-m", "xai_enroller.service"],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert missing.returncode == 2
|
||||
assert "XAI_AUTH_SERVICE_SSH_HOST" in missing.stderr
|
||||
assert "docs/guides/auth-service.md" in missing.stderr
|
||||
assert "Traceback" not in missing.stderr
|
||||
|
||||
environment["XAI_AUTH_SERVICE_LOG_MODE"] = "verbose"
|
||||
invalid = subprocess.run(
|
||||
[sys.executable, "-m", "xai_enroller.service"],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert invalid.returncode == 2
|
||||
assert "XAI_AUTH_SERVICE_LOG_MODE" in invalid.stderr
|
||||
assert "Traceback" not in invalid.stderr
|
||||
|
||||
environment["XAI_AUTH_SERVICE_LOG_MODE"] = "user"
|
||||
environment.pop("XAI_AUTH_SERVICE_SOURCE", None)
|
||||
environment["XAI_AUTH_SERVICE_SSH_HOST"] = "user@example.test"
|
||||
environment["XAI_ENROLLER_LOCAL_AUTH_DIR"] = str(tmp_path / "auth")
|
||||
environment["XAI_ENROLLER_TIMEOUT_SEC"] = "not-a-number"
|
||||
invalid_enroller_setting = subprocess.run(
|
||||
[sys.executable, "-m", "xai_enroller.service"],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert invalid_enroller_setting.returncode == 2
|
||||
assert "XAI_ENROLLER_TIMEOUT_SEC" in invalid_enroller_setting.stderr
|
||||
assert "docs/guides/auth-service.md" in invalid_enroller_setting.stderr
|
||||
assert "Traceback" not in invalid_enroller_setting.stderr
|
||||
|
||||
|
||||
def test_auth_service_startup_failure_is_sanitized_without_traceback(tmp_path):
|
||||
blocked_destination = tmp_path / "private-output-path"
|
||||
blocked_destination.write_text("not a directory", encoding="utf-8")
|
||||
environment = os.environ.copy()
|
||||
environment["XAI_AUTH_SERVICE_SSH_HOST"] = "user@example.test"
|
||||
environment["XAI_ENROLLER_LOCAL_AUTH_DIR"] = str(blocked_destination)
|
||||
|
||||
failed = subprocess.run(
|
||||
[sys.executable, "-m", "xai_enroller.service"],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert failed.returncode == 1
|
||||
assert "认证服务异常终止" in failed.stderr
|
||||
assert "bash auth-service.sh --debug" in failed.stderr
|
||||
assert "Traceback" not in failed.stderr
|
||||
assert str(blocked_destination) not in failed.stderr
|
||||
|
||||
|
||||
def test_ledger_recognizes_previously_imported_sources(tmp_path):
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"test-salt")
|
||||
imported = ledger.start("done@example.test")
|
||||
ledger.finish(imported, JobStatus.IMPORTED, "imported")
|
||||
failed = ledger.start("retry@example.test")
|
||||
ledger.finish(failed, JobStatus.SINK_FAILED, "sink_failed")
|
||||
|
||||
assert ledger.has_imported("done@example.test") is True
|
||||
assert ledger.has_imported("retry@example.test") is False
|
||||
|
||||
|
||||
def test_auth_service_settings_defaults_local_and_preserves_ssh_auto_detection(tmp_path):
|
||||
local = AuthServiceSettings.from_environ(
|
||||
{"XAI_AUTH_SERVICE_REGISTER_ROOT": str(tmp_path)}
|
||||
)
|
||||
remote = AuthServiceSettings.from_environ(
|
||||
{
|
||||
"XAI_AUTH_SERVICE_SSH_HOST": "ubuntu@example.test",
|
||||
"XAI_AUTH_SERVICE_SYNC_SEC": "45",
|
||||
}
|
||||
)
|
||||
|
||||
assert local.source_kind == "local"
|
||||
assert local.ssh_host is None
|
||||
assert local.register_root == str(tmp_path)
|
||||
assert remote.source_kind == "ssh"
|
||||
assert remote.ssh_host == "ubuntu@example.test"
|
||||
assert remote.sync_seconds == 45
|
||||
assert remote.remote_root == "/opt/grok-free-register"
|
||||
|
||||
|
||||
def test_auth_service_settings_explicit_source_overrides_auto_detection():
|
||||
local = AuthServiceSettings.from_environ(
|
||||
{
|
||||
"XAI_AUTH_SERVICE_SOURCE": "local",
|
||||
"XAI_AUTH_SERVICE_SSH_HOST": "ignored@example.test",
|
||||
}
|
||||
)
|
||||
assert local.source_kind == "local"
|
||||
|
||||
with pytest.raises(ValueError, match="XAI_AUTH_SERVICE_SSH_HOST"):
|
||||
AuthServiceSettings.from_environ({"XAI_AUTH_SERVICE_SOURCE": "ssh"})
|
||||
with pytest.raises(ValueError, match="XAI_AUTH_SERVICE_SOURCE"):
|
||||
AuthServiceSettings.from_environ({"XAI_AUTH_SERVICE_SOURCE": "unknown"})
|
||||
|
||||
|
||||
def test_pipeline_runner_takes_a_credential_batch_and_reports_inventory():
|
||||
class InventoryLedger:
|
||||
def inventory_counts(self):
|
||||
return {"available": 7, "claiming": 0, "claimed": 3}
|
||||
|
||||
class Pipeline:
|
||||
ledger = InventoryLedger()
|
||||
|
||||
def status(self):
|
||||
return {"state": "running"}
|
||||
|
||||
class Inventory:
|
||||
def take(self, count):
|
||||
assert count == 3
|
||||
return SimpleNamespace(
|
||||
batch_id="batch-1",
|
||||
directory=Path("/tmp/claimed/batch-1"),
|
||||
moved=3,
|
||||
note="",
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
events = []
|
||||
runner = AuthPipelineRunner(Pipeline(), events.append, inventory=Inventory())
|
||||
assert await runner.handle_command("take 3") is True
|
||||
assert await runner.handle_command("s") is True
|
||||
return events
|
||||
|
||||
assert asyncio.run(scenario()) == [
|
||||
(
|
||||
"inventory_taken",
|
||||
{
|
||||
"batch_id": "batch-1",
|
||||
"directory": "/tmp/claimed/batch-1",
|
||||
"moved": 3,
|
||||
"available": 7,
|
||||
"claiming": 0,
|
||||
"claimed": 3,
|
||||
},
|
||||
),
|
||||
(
|
||||
"status",
|
||||
{
|
||||
"state": "running",
|
||||
"available": 7,
|
||||
"claiming": 0,
|
||||
"claimed": 3,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_pipeline_runner_reports_inventory_failure_without_stopping():
|
||||
class Ledger:
|
||||
def inventory_counts(self):
|
||||
return {"available": 1, "claiming": 1, "claimed": 0}
|
||||
|
||||
class Pipeline:
|
||||
ledger = Ledger()
|
||||
|
||||
class Inventory:
|
||||
def take(self, _count):
|
||||
raise InventoryError("credential file is missing")
|
||||
|
||||
async def scenario():
|
||||
events = []
|
||||
runner = AuthPipelineRunner(Pipeline(), events.append, inventory=Inventory())
|
||||
assert await runner.handle_command("take 1") is True
|
||||
return events
|
||||
|
||||
assert asyncio.run(scenario()) == [
|
||||
(
|
||||
"inventory_error",
|
||||
{
|
||||
"reason": "credential file is missing",
|
||||
"available": 1,
|
||||
"claiming": 1,
|
||||
"claimed": 0,
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -1,58 +0,0 @@
|
||||
from xai_enroller.inventory import CredentialInventory
|
||||
from xai_enroller.ledger import Ledger
|
||||
from xai_enroller.models import JobStatus
|
||||
|
||||
|
||||
def import_credential(ledger, source, receipt):
|
||||
job_id = ledger.start(source)
|
||||
ledger.finish(job_id, JobStatus.IMPORTED, "imported", receipt)
|
||||
return job_id
|
||||
|
||||
|
||||
def test_take_moves_available_credentials_without_changing_imported_jobs(tmp_path):
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"salt")
|
||||
available = tmp_path / "authenticated"
|
||||
claimed = tmp_path / "claimed"
|
||||
available.mkdir()
|
||||
first_job = import_credential(ledger, "first", "xai-first")
|
||||
second_job = import_credential(ledger, "second", "xai-second")
|
||||
(available / "xai-first.json").write_text("{}\n", encoding="utf-8")
|
||||
(available / "xai-second.json").write_text("{}\n", encoding="utf-8")
|
||||
|
||||
batch = CredentialInventory(ledger, available, claimed).take(2)
|
||||
|
||||
assert batch.moved == 2
|
||||
assert batch.note == ""
|
||||
assert not list(available.glob("*.json"))
|
||||
assert sorted(path.name for path in batch.directory.glob("*.json")) == [
|
||||
"xai-first.json",
|
||||
"xai-second.json",
|
||||
]
|
||||
assert ledger.inventory_counts() == {
|
||||
"available": 0,
|
||||
"claiming": 0,
|
||||
"claimed": 2,
|
||||
}
|
||||
assert ledger.get(first_job)["status"] == JobStatus.IMPORTED.value
|
||||
assert ledger.get(second_job)["status"] == JobStatus.IMPORTED.value
|
||||
|
||||
|
||||
def test_recover_finishes_a_batch_interrupted_before_file_move(tmp_path):
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"salt")
|
||||
available = tmp_path / "authenticated"
|
||||
claimed = tmp_path / "claimed"
|
||||
available.mkdir()
|
||||
import_credential(ledger, "source", "xai-recover")
|
||||
(available / "xai-recover.json").write_text("{}\n", encoding="utf-8")
|
||||
assert ledger.claim_available(1, "batch-recover") == ["xai-recover"]
|
||||
|
||||
recovered = CredentialInventory(ledger, available, claimed).recover()
|
||||
|
||||
assert recovered == 1
|
||||
assert not (available / "xai-recover.json").exists()
|
||||
assert (claimed / "batch-recover" / "xai-recover.json").exists()
|
||||
assert ledger.inventory_counts() == {
|
||||
"available": 0,
|
||||
"claiming": 0,
|
||||
"claimed": 1,
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from xai_enroller.config import Settings
|
||||
from xai_enroller.sources import FileSourceAdapter, SQLiteSourceAdapter
|
||||
|
||||
|
||||
def base_env(tmp_path, **overrides):
|
||||
values = {
|
||||
"XAI_ENROLLER_SOURCE_KIND": "file",
|
||||
"XAI_ENROLLER_SOURCE_FILE": str(tmp_path / "sessions.tsv"),
|
||||
"XAI_ENROLLER_SOURCE_SALT": "test-salt",
|
||||
"XAI_ENROLLER_LEDGER_PATH": str(tmp_path / "ledger.db"),
|
||||
}
|
||||
values.update(overrides)
|
||||
return values
|
||||
|
||||
|
||||
def test_settings_rejects_public_http_cpa_sink(tmp_path):
|
||||
with pytest.raises(ValueError, match="HTTPS"):
|
||||
Settings.from_environ(
|
||||
base_env(
|
||||
tmp_path,
|
||||
XAI_ENROLLER_SINK="cpa",
|
||||
XAI_ENROLLER_CPA_BASE_URL="http://example.test",
|
||||
XAI_ENROLLER_CPA_MANAGEMENT_SECRET="secret",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_settings_defaults_are_bounded_and_redacted(tmp_path):
|
||||
settings = Settings.from_environ(base_env(tmp_path))
|
||||
assert settings.target == 1
|
||||
assert settings.concurrency == 1
|
||||
assert settings.retry_attempts == 0
|
||||
assert settings.executor == "http"
|
||||
assert settings.poll_interval == 5.0
|
||||
redacted = settings.redacted_dict()
|
||||
assert "test-salt" not in repr(redacted)
|
||||
assert "CPA_MANAGEMENT_SECRET" not in repr(redacted)
|
||||
assert "source_salt" not in redacted
|
||||
|
||||
|
||||
def test_settings_accepts_bounded_retry_attempts(tmp_path):
|
||||
settings = Settings.from_environ(
|
||||
base_env(tmp_path, XAI_ENROLLER_RETRY_ATTEMPTS="2")
|
||||
)
|
||||
|
||||
assert settings.retry_attempts == 2
|
||||
|
||||
|
||||
def test_settings_accepts_remote_source_without_a_local_source_file(tmp_path):
|
||||
settings = Settings.from_environ(
|
||||
base_env(
|
||||
tmp_path,
|
||||
XAI_ENROLLER_SOURCE_KIND="remote",
|
||||
XAI_ENROLLER_SOURCE_FILE="",
|
||||
)
|
||||
)
|
||||
|
||||
assert settings.source_kind == "remote"
|
||||
assert settings.source_file is None
|
||||
assert settings.source_db is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value", "message"),
|
||||
[
|
||||
("XAI_ENROLLER_SOURCE_KIND", "other", "source"),
|
||||
("XAI_ENROLLER_AUTH_EXECUTOR", "other", "executor"),
|
||||
("XAI_ENROLLER_CONCURRENCY", "0", "concurrency"),
|
||||
("XAI_ENROLLER_POLL_SEC", "0", "poll"),
|
||||
("XAI_ENROLLER_RETRY_ATTEMPTS", "-1", "retry"),
|
||||
("XAI_ENROLLER_RETRY_ATTEMPTS", "4", "retry"),
|
||||
("XAI_ENROLLER_TARGET", "0", "target"),
|
||||
("XAI_ENROLLER_TARGET", "101", "target"),
|
||||
],
|
||||
)
|
||||
def test_settings_reject_invalid_bounds(tmp_path, key, value, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
Settings.from_environ(base_env(tmp_path, **{key: value}))
|
||||
|
||||
|
||||
def test_file_source_requires_private_regular_file(tmp_path):
|
||||
path = tmp_path / "sessions.tsv"
|
||||
path.write_text("one\tsecret\n", encoding="utf-8")
|
||||
path.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
|
||||
with pytest.raises(ValueError, match="0600"):
|
||||
list(FileSourceAdapter(path).records())
|
||||
|
||||
|
||||
def test_file_source_suppresses_duplicates_without_leaking_tokens(tmp_path):
|
||||
path = tmp_path / "sessions.tsv"
|
||||
path.write_text("one\tsecret-a\none\tsecret-b\n", encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
records = list(FileSourceAdapter(path).records())
|
||||
assert len(records) == 1
|
||||
assert records[0].source_id == "one"
|
||||
assert "secret" not in repr(records)
|
||||
|
||||
|
||||
def test_sqlite_source_uses_fixed_read_only_query_and_hmac_ids(tmp_path):
|
||||
path = tmp_path / "accounts.db"
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute(
|
||||
"CREATE TABLE accounts (token TEXT, status TEXT, deleted_at TEXT, updated_at INTEGER)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO accounts VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
("old", "active", None, 1),
|
||||
("new", "active", None, 2),
|
||||
("deleted", "active", "2026-01-01", 3),
|
||||
("inactive", "inactive", None, 4),
|
||||
],
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
records = list(SQLiteSourceAdapter(path, b"salt").records())
|
||||
assert [record.sso_token for record in records] == ["new", "old"]
|
||||
assert records[0].source_id != records[1].source_id
|
||||
assert "new" not in repr(records)
|
||||
@@ -1,293 +0,0 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from xai_enroller.coordinator import EnrollmentCoordinator
|
||||
from xai_enroller.models import (
|
||||
AuthorizationResult,
|
||||
AuthorizationStatus,
|
||||
DeviceFlow,
|
||||
JobStatus,
|
||||
OAuthCredential,
|
||||
SinkReceipt,
|
||||
SourceRecord,
|
||||
)
|
||||
from xai_enroller.sources import FileSourceAdapter, SQLiteSourceAdapter
|
||||
|
||||
|
||||
class Source:
|
||||
async def records(self):
|
||||
for index in range(3):
|
||||
yield SourceRecord(f"source-{index}", f"token-{index}")
|
||||
|
||||
|
||||
class Protocol:
|
||||
def __init__(self):
|
||||
self.polls = 0
|
||||
|
||||
async def start_device_flow(self):
|
||||
return DeviceFlow("device", "code", "https://accounts.x.ai/device", 60, 0)
|
||||
|
||||
async def poll_token(self, **kwargs):
|
||||
self.polls += 1
|
||||
return OAuthCredential(
|
||||
"access",
|
||||
"refresh",
|
||||
None,
|
||||
"Bearer",
|
||||
3600,
|
||||
"2026-07-11T00:00:00Z",
|
||||
"2026-07-11T00:00:00Z",
|
||||
"subject",
|
||||
"https://auth.x.ai/oauth2/token",
|
||||
)
|
||||
|
||||
|
||||
class Executor:
|
||||
active = 0
|
||||
max_active = 0
|
||||
|
||||
async def confirm(self, source, flow):
|
||||
type(self).active += 1
|
||||
type(self).max_active = max(type(self).max_active, type(self).active)
|
||||
await asyncio.sleep(0)
|
||||
type(self).active -= 1
|
||||
if source.source_id == "source-1":
|
||||
return AuthorizationResult(AuthorizationStatus.NEEDS_BROWSER, "challenge")
|
||||
return AuthorizationResult(AuthorizationStatus.AUTHORIZED, "confirmed")
|
||||
|
||||
|
||||
class Sink:
|
||||
async def store(self, credential):
|
||||
if credential.subject == "subject":
|
||||
return SinkReceipt("receipt")
|
||||
|
||||
|
||||
class FlakyProtocol(Protocol):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls = 0
|
||||
|
||||
async def start_device_flow(self):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise OSError("temporary transport")
|
||||
return await super().start_device_flow()
|
||||
|
||||
|
||||
class InvalidProtocol(Protocol):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls = 0
|
||||
|
||||
async def start_device_flow(self):
|
||||
self.calls += 1
|
||||
raise ValueError("invalid device response")
|
||||
|
||||
|
||||
class FailingExecutor(Executor):
|
||||
async def confirm(self, source, flow):
|
||||
raise OSError("confirmation transport")
|
||||
|
||||
|
||||
class SlowDeviceFlowProtocol(Protocol):
|
||||
async def start_device_flow(self):
|
||||
await asyncio.sleep(0.04)
|
||||
return await super().start_device_flow()
|
||||
|
||||
|
||||
class SlowExecutor(Executor):
|
||||
async def confirm(self, source, flow):
|
||||
await asyncio.sleep(0.04)
|
||||
return await super().confirm(source, flow)
|
||||
|
||||
|
||||
def test_coordinator_limits_concurrency_and_maps_terminal_results(tmp_path):
|
||||
protocol = Protocol()
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=Source(),
|
||||
protocol=protocol,
|
||||
executor=Executor(),
|
||||
sink=Sink(),
|
||||
ledger_path=tmp_path / "ledger.db",
|
||||
ledger_salt=b"salt",
|
||||
concurrency=2,
|
||||
timeout=5,
|
||||
)
|
||||
results = asyncio.run(coordinator.run(target=3))
|
||||
assert [result.status for result in results] == [
|
||||
JobStatus.IMPORTED,
|
||||
JobStatus.NEEDS_BROWSER,
|
||||
JobStatus.IMPORTED,
|
||||
]
|
||||
assert Executor.max_active == 2
|
||||
assert protocol.polls == 2
|
||||
|
||||
|
||||
def test_coordinator_accepts_explicit_synced_records(tmp_path):
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=Source(),
|
||||
protocol=Protocol(),
|
||||
executor=Executor(),
|
||||
sink=Sink(),
|
||||
ledger_path=tmp_path / "ledger.db",
|
||||
ledger_salt=b"salt",
|
||||
concurrency=1,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
results = asyncio.run(
|
||||
coordinator.run_records([SourceRecord("synced-account", "synced-sso")])
|
||||
)
|
||||
|
||||
assert [result.source_id for result in results] == ["synced-account"]
|
||||
assert results[0].status is JobStatus.IMPORTED
|
||||
|
||||
|
||||
def test_coordinator_emits_device_flow_after_the_flow_is_created(tmp_path):
|
||||
events = []
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=Source(),
|
||||
protocol=Protocol(),
|
||||
executor=Executor(),
|
||||
sink=Sink(),
|
||||
ledger_path=tmp_path / "ledger.db",
|
||||
ledger_salt=b"salt",
|
||||
concurrency=1,
|
||||
timeout=5,
|
||||
event_callback=lambda kind, data: events.append((kind, data)),
|
||||
)
|
||||
|
||||
asyncio.run(coordinator.run_records([SourceRecord("synced-account", "synced-sso")]))
|
||||
|
||||
assert events == [
|
||||
(
|
||||
"device_flow",
|
||||
{
|
||||
"source_id": "synced-account",
|
||||
"user_code": "code",
|
||||
"verification_url": "https://accounts.x.ai/device",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_coordinator_without_sink_never_reports_imported(tmp_path):
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=Source(),
|
||||
protocol=Protocol(),
|
||||
executor=Executor(),
|
||||
sink=None,
|
||||
ledger_path=tmp_path / "ledger.db",
|
||||
ledger_salt=b"salt",
|
||||
concurrency=1,
|
||||
timeout=5,
|
||||
)
|
||||
results = asyncio.run(coordinator.run(target=1))
|
||||
assert results[0].status is JobStatus.SINK_FAILED
|
||||
|
||||
|
||||
def test_coordinator_retries_only_before_device_flow_creation(tmp_path):
|
||||
protocol = FlakyProtocol()
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=Source(),
|
||||
protocol=protocol,
|
||||
executor=Executor(),
|
||||
sink=Sink(),
|
||||
ledger_path=tmp_path / "ledger.db",
|
||||
ledger_salt=b"salt",
|
||||
concurrency=1,
|
||||
timeout=5,
|
||||
retry_attempts=1,
|
||||
)
|
||||
results = asyncio.run(coordinator.run(target=1))
|
||||
assert results[0].status is JobStatus.IMPORTED
|
||||
assert protocol.calls == 2
|
||||
|
||||
|
||||
def test_coordinator_does_not_retry_after_device_flow_creation(tmp_path):
|
||||
protocol = Protocol()
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=Source(),
|
||||
protocol=protocol,
|
||||
executor=FailingExecutor(),
|
||||
sink=Sink(),
|
||||
ledger_path=tmp_path / "ledger.db",
|
||||
ledger_salt=b"salt",
|
||||
concurrency=1,
|
||||
timeout=5,
|
||||
retry_attempts=3,
|
||||
)
|
||||
results = asyncio.run(coordinator.run(target=1))
|
||||
assert results[0].status is JobStatus.TRANSPORT_FAILED
|
||||
assert protocol.polls == 0
|
||||
|
||||
|
||||
def test_coordinator_does_not_retry_non_transport_device_flow_error(tmp_path):
|
||||
protocol = InvalidProtocol()
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=Source(),
|
||||
protocol=protocol,
|
||||
executor=Executor(),
|
||||
sink=Sink(),
|
||||
ledger_path=tmp_path / "ledger.db",
|
||||
ledger_salt=b"salt",
|
||||
concurrency=1,
|
||||
timeout=5,
|
||||
retry_attempts=3,
|
||||
)
|
||||
results = asyncio.run(coordinator.run(target=1))
|
||||
assert results[0].status is JobStatus.TRANSPORT_FAILED
|
||||
assert protocol.calls == 1
|
||||
|
||||
|
||||
def test_coordinator_applies_timeout_to_the_entire_attempt(tmp_path):
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=Source(),
|
||||
protocol=SlowDeviceFlowProtocol(),
|
||||
executor=SlowExecutor(),
|
||||
sink=Sink(),
|
||||
ledger_path=tmp_path / "ledger.db",
|
||||
ledger_salt=b"salt",
|
||||
concurrency=1,
|
||||
timeout=0.06,
|
||||
retry_attempts=3,
|
||||
)
|
||||
|
||||
results = asyncio.run(coordinator.run(target=1))
|
||||
|
||||
assert results[0].status is JobStatus.TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_kind", ["file", "sqlite"])
|
||||
def test_coordinator_consumes_real_source_adapters(tmp_path, source_kind):
|
||||
if source_kind == "file":
|
||||
source_path = tmp_path / "sessions.tsv"
|
||||
source_path.write_text("file-source\tfile-token\n", encoding="utf-8")
|
||||
source_path.chmod(0o600)
|
||||
source = FileSourceAdapter(source_path)
|
||||
else:
|
||||
source_path = tmp_path / "accounts.db"
|
||||
connection = sqlite3.connect(source_path)
|
||||
connection.execute(
|
||||
"CREATE TABLE accounts (token TEXT, status TEXT, deleted_at TEXT, updated_at INTEGER)"
|
||||
)
|
||||
connection.execute("INSERT INTO accounts VALUES (?, ?, ?, ?)", ("db-token", "active", None, 1))
|
||||
connection.commit()
|
||||
connection.close()
|
||||
source = SQLiteSourceAdapter(source_path, b"source-salt")
|
||||
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=source,
|
||||
protocol=Protocol(),
|
||||
executor=Executor(),
|
||||
sink=Sink(),
|
||||
ledger_path=tmp_path / f"{source_kind}-ledger.db",
|
||||
ledger_salt=b"ledger-salt",
|
||||
concurrency=1,
|
||||
timeout=5,
|
||||
)
|
||||
results = asyncio.run(coordinator.run(target=1))
|
||||
assert len(results) == 1
|
||||
assert results[0].status is JobStatus.IMPORTED
|
||||
@@ -1,411 +0,0 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from xai_enroller.executors import HTTPProbeExecutor, PlaywrightExecutor
|
||||
from xai_enroller.models import AuthorizationStatus, DeviceFlow, SourceRecord
|
||||
|
||||
|
||||
def test_http_probe_classifies_403_challenge_without_submission():
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(403, text="<html>challenge-platform</html>")
|
||||
|
||||
executor = HTTPProbeExecutor(
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False)
|
||||
)
|
||||
source = SourceRecord("source", "sso-token")
|
||||
flow = DeviceFlow("device", "ABCD", "https://accounts.x.ai/oauth2/device?user_code=ABCD", 60, 1)
|
||||
result = asyncio.run(executor.confirm(source, flow))
|
||||
assert result.status is AuthorizationStatus.NEEDS_BROWSER
|
||||
assert len(calls) == 1
|
||||
assert calls[0].method == "GET"
|
||||
assert "sso-token" not in repr(result)
|
||||
|
||||
|
||||
def test_http_probe_does_not_follow_redirects_or_submit_forms():
|
||||
def handler(request):
|
||||
return httpx.Response(302, headers={"location": "https://evil.example/"})
|
||||
|
||||
executor = HTTPProbeExecutor(
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False)
|
||||
)
|
||||
result = asyncio.run(
|
||||
executor.confirm(
|
||||
SourceRecord("source", "token"),
|
||||
DeviceFlow("device", "ABCD", "https://accounts.x.ai/oauth2/device", 60, 1),
|
||||
)
|
||||
)
|
||||
assert result.status is AuthorizationStatus.NEEDS_INTERACTION
|
||||
|
||||
|
||||
class FakeButton:
|
||||
def __init__(self, count):
|
||||
self._count = count
|
||||
|
||||
async def count(self):
|
||||
return self._count
|
||||
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
async def click(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakePage:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def goto(self, url, wait_until):
|
||||
self.url = url
|
||||
|
||||
def locator(self, selector):
|
||||
return self
|
||||
|
||||
async def inner_text(self):
|
||||
return "Authorize access"
|
||||
|
||||
def get_by_role(self, role, name):
|
||||
return FakeButton(1 if name == "Authorize" else 0)
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self):
|
||||
self.cookies = []
|
||||
self.page = FakePage()
|
||||
self.closed = False
|
||||
|
||||
async def add_cookies(self, cookies):
|
||||
self.cookies.extend(cookies)
|
||||
|
||||
async def new_page(self):
|
||||
return self.page
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeBrowser:
|
||||
def __init__(self):
|
||||
self.launches = 0
|
||||
self.contexts = []
|
||||
self.closed = False
|
||||
|
||||
async def new_context(self):
|
||||
context = FakeContext()
|
||||
self.contexts.append(context)
|
||||
return context
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeChromium:
|
||||
def __init__(self, browser):
|
||||
self.browser = browser
|
||||
self.launch_options = None
|
||||
|
||||
async def launch(self, **options):
|
||||
self.launch_options = options
|
||||
self.browser.launches += 1
|
||||
return self.browser
|
||||
|
||||
|
||||
class FakePlaywright:
|
||||
def __init__(self, browser):
|
||||
self.chromium = FakeChromium(browser)
|
||||
self.stopped = False
|
||||
|
||||
async def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
|
||||
class FakePlaywrightFactory:
|
||||
def __init__(self):
|
||||
self.browser = FakeBrowser()
|
||||
self.playwright = FakePlaywright(self.browser)
|
||||
|
||||
def __call__(self):
|
||||
factory = self
|
||||
|
||||
class Runner:
|
||||
async def start(self):
|
||||
return factory.playwright
|
||||
|
||||
return Runner()
|
||||
|
||||
|
||||
class FailingPageContext(FakeContext):
|
||||
async def new_page(self):
|
||||
raise RuntimeError("page creation failed")
|
||||
|
||||
|
||||
class FailingPageBrowser(FakeBrowser):
|
||||
async def new_context(self):
|
||||
context = FailingPageContext()
|
||||
self.contexts.append(context)
|
||||
return context
|
||||
|
||||
|
||||
def test_playwright_executor_isolates_context_and_scopes_exact_cookie():
|
||||
factory = FakePlaywrightFactory()
|
||||
executor = PlaywrightExecutor(playwright_factory=factory)
|
||||
source = SourceRecord("source", "secret-token")
|
||||
flow = DeviceFlow("device", "ABCD", "https://accounts.x.ai/oauth2/device", 60, 1)
|
||||
result = asyncio.run(executor.confirm(source, flow))
|
||||
asyncio.run(executor.close())
|
||||
assert result.status is AuthorizationStatus.AUTHORIZED
|
||||
assert factory.browser.launches == 1
|
||||
context = factory.browser.contexts[0]
|
||||
assert context.cookies == [{
|
||||
"name": "sso",
|
||||
"value": "secret-token",
|
||||
"domain": "accounts.x.ai",
|
||||
"path": "/",
|
||||
"secure": True,
|
||||
"httpOnly": True,
|
||||
}]
|
||||
assert context.page.closed
|
||||
assert context.closed
|
||||
assert factory.browser.closed
|
||||
|
||||
|
||||
def test_playwright_executor_closes_context_when_page_creation_fails():
|
||||
factory = FakePlaywrightFactory()
|
||||
factory.browser = FailingPageBrowser()
|
||||
factory.playwright = FakePlaywright(factory.browser)
|
||||
executor = PlaywrightExecutor(playwright_factory=factory)
|
||||
|
||||
result = asyncio.run(
|
||||
executor.confirm(
|
||||
SourceRecord("source", "secret-token"),
|
||||
DeviceFlow("device", "ABCD", "https://accounts.x.ai/oauth2/device", 60, 1),
|
||||
)
|
||||
)
|
||||
asyncio.run(executor.close())
|
||||
|
||||
assert result.status is AuthorizationStatus.NEEDS_INTERACTION
|
||||
assert factory.browser.contexts[0].closed
|
||||
|
||||
|
||||
def test_playwright_executor_launches_configured_fingerprint_browser():
|
||||
factory = FakePlaywrightFactory()
|
||||
executor = PlaywrightExecutor(
|
||||
playwright_factory=factory,
|
||||
executable_path="/opt/cloakbrowser/chrome",
|
||||
)
|
||||
|
||||
asyncio.run(executor.start())
|
||||
asyncio.run(executor.close())
|
||||
|
||||
assert factory.playwright.chromium.launch_options == {
|
||||
"headless": True,
|
||||
"executable_path": "/opt/cloakbrowser/chrome",
|
||||
}
|
||||
|
||||
|
||||
def test_playwright_executor_uses_fingerprint_browser_path_from_environment(monkeypatch):
|
||||
monkeypatch.setenv("XAI_ENROLLER_BROWSER_EXECUTABLE", "/opt/cloakbrowser/chrome")
|
||||
factory = FakePlaywrightFactory()
|
||||
executor = PlaywrightExecutor(playwright_factory=factory)
|
||||
|
||||
asyncio.run(executor.start())
|
||||
asyncio.run(executor.close())
|
||||
|
||||
assert factory.playwright.chromium.launch_options == {
|
||||
"headless": True,
|
||||
"executable_path": "/opt/cloakbrowser/chrome",
|
||||
}
|
||||
|
||||
|
||||
def test_playwright_executor_finds_macos_cloakbrowser_binary(monkeypatch):
|
||||
macos_binary = (
|
||||
"/Users/test/.cloakbrowser/chromium-145/Chromium.app/Contents/MacOS/Chromium"
|
||||
)
|
||||
monkeypatch.delenv("XAI_ENROLLER_BROWSER_EXECUTABLE", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"xai_enroller.executors.glob.glob",
|
||||
lambda pattern: [macos_binary] if "Chromium.app" in pattern else [],
|
||||
)
|
||||
|
||||
assert PlaywrightExecutor._find_executable_path() == macos_binary
|
||||
|
||||
|
||||
def test_playwright_attempt_cleanup_defers_repeated_cancellation():
|
||||
class Page:
|
||||
def __init__(self):
|
||||
self.entered = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
self.entered.set()
|
||||
await self.release.wait()
|
||||
self.closed = True
|
||||
|
||||
class Context:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
async def scenario():
|
||||
page = Page()
|
||||
context = Context()
|
||||
task = asyncio.create_task(
|
||||
PlaywrightExecutor._close_attempt_resources(page, context)
|
||||
)
|
||||
await page.entered.wait()
|
||||
task.cancel()
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
page.release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert page.closed
|
||||
assert context.closed
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_playwright_attempt_cleanup_has_a_true_wall_clock_deadline(monkeypatch):
|
||||
class Page:
|
||||
def __init__(self):
|
||||
self.entered = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
|
||||
async def close(self):
|
||||
self.entered.set()
|
||||
while not self.release.is_set():
|
||||
try:
|
||||
await self.release.wait()
|
||||
except asyncio.CancelledError:
|
||||
# Playwright transports can remain stuck while cancellation
|
||||
# propagates. A hard deadline must not await that forever.
|
||||
continue
|
||||
|
||||
async def scenario():
|
||||
monkeypatch.setattr(PlaywrightExecutor, "CLOSE_TIMEOUT_SECONDS", 0.02)
|
||||
page = Page()
|
||||
task = asyncio.create_task(
|
||||
PlaywrightExecutor._close_attempt_resources(page, None)
|
||||
)
|
||||
await page.entered.wait()
|
||||
done, _pending = await asyncio.wait({task}, timeout=0.10)
|
||||
completed_within_deadline = task in done
|
||||
|
||||
page.release.set()
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
assert completed_within_deadline
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_playwright_confirmation_has_hard_deadline_and_restarts_browser(monkeypatch):
|
||||
class StubbornGotoPage(FakePage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.entered = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
|
||||
async def goto(self, url, wait_until):
|
||||
self.url = url
|
||||
self.entered.set()
|
||||
while not self.release.is_set():
|
||||
try:
|
||||
await self.release.wait()
|
||||
except asyncio.CancelledError:
|
||||
# Model a wedged Playwright transport that does not finish
|
||||
# propagating task cancellation.
|
||||
continue
|
||||
|
||||
class StubbornGotoContext(FakeContext):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.page = StubbornGotoPage()
|
||||
|
||||
class StubbornGotoBrowser(FakeBrowser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.close_entered = asyncio.Event()
|
||||
self.close_release = asyncio.Event()
|
||||
|
||||
async def new_context(self):
|
||||
context = StubbornGotoContext()
|
||||
self.contexts.append(context)
|
||||
return context
|
||||
|
||||
async def close(self):
|
||||
self.close_entered.set()
|
||||
while not self.close_release.is_set():
|
||||
try:
|
||||
await self.close_release.wait()
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
self.closed = True
|
||||
|
||||
class RotatingPlaywrightFactory:
|
||||
def __init__(self):
|
||||
self.browsers = [StubbornGotoBrowser(), FakeBrowser()]
|
||||
self.playwrights = [FakePlaywright(browser) for browser in self.browsers]
|
||||
self.starts = 0
|
||||
|
||||
def __call__(self):
|
||||
factory = self
|
||||
|
||||
class Runner:
|
||||
async def start(self):
|
||||
playwright = factory.playwrights[factory.starts]
|
||||
factory.starts += 1
|
||||
return playwright
|
||||
|
||||
return Runner()
|
||||
|
||||
async def scenario():
|
||||
monkeypatch.setattr(PlaywrightExecutor, "ATTEMPT_TIMEOUT_SECONDS", 0.02)
|
||||
monkeypatch.setattr(PlaywrightExecutor, "CLOSE_TIMEOUT_SECONDS", 0.02)
|
||||
factory = RotatingPlaywrightFactory()
|
||||
executor = PlaywrightExecutor(playwright_factory=factory)
|
||||
source = SourceRecord("source", "secret-token")
|
||||
flow = DeviceFlow(
|
||||
"device", "ABCD", "https://accounts.x.ai/oauth2/device", 60, 1
|
||||
)
|
||||
|
||||
first = asyncio.create_task(executor.confirm(source, flow))
|
||||
while not factory.browsers[0].contexts:
|
||||
await asyncio.sleep(0)
|
||||
stuck_page = factory.browsers[0].contexts[0].page
|
||||
await stuck_page.entered.wait()
|
||||
done, _pending = await asyncio.wait({first}, timeout=0.10)
|
||||
completed_within_deadline = first in done
|
||||
|
||||
stuck_page.release.set()
|
||||
factory.browsers[0].close_release.set()
|
||||
first_result = await first
|
||||
await asyncio.sleep(0)
|
||||
second_result = await executor.confirm(source, flow)
|
||||
await executor.close()
|
||||
|
||||
assert completed_within_deadline
|
||||
assert first_result.status is AuthorizationStatus.NEEDS_INTERACTION
|
||||
assert first_result.reason_code == "confirmation_timeout"
|
||||
assert second_result.status is AuthorizationStatus.AUTHORIZED
|
||||
assert factory.starts == 2
|
||||
assert factory.browsers[0].close_entered.is_set()
|
||||
assert factory.browsers[0].closed
|
||||
assert factory.playwrights[0].stopped
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -1,188 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from xai_enroller.coordinator import EnrollmentCoordinator
|
||||
from xai_enroller.executors import PlaywrightExecutor
|
||||
from xai_enroller.models import SourceRecord
|
||||
from xai_enroller.protocol import XAIProfile, XAIProtocol
|
||||
from xai_enroller.sinks import CPAAuthFileSink
|
||||
|
||||
|
||||
class SingleSource:
|
||||
async def records(self):
|
||||
yield SourceRecord("source-1", "sso-secret")
|
||||
|
||||
|
||||
class FakeButton:
|
||||
async def count(self):
|
||||
return 1
|
||||
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
async def click(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakePage:
|
||||
async def goto(self, url, wait_until):
|
||||
self.url = url
|
||||
|
||||
def locator(self, selector):
|
||||
return self
|
||||
|
||||
async def inner_text(self):
|
||||
return "Authorize access"
|
||||
|
||||
def get_by_role(self, role, name):
|
||||
return FakeButton() if name == "Authorize" else type(
|
||||
"NoButton",
|
||||
(),
|
||||
{"count": staticmethod(lambda: asyncio.sleep(0, result=0))},
|
||||
)()
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self, browser):
|
||||
self.browser = browser
|
||||
self.cookies = []
|
||||
|
||||
async def add_cookies(self, cookies):
|
||||
self.cookies.extend(cookies)
|
||||
|
||||
async def new_page(self):
|
||||
return FakePage()
|
||||
|
||||
async def close(self):
|
||||
self.browser.live_contexts -= 1
|
||||
|
||||
|
||||
class FakeBrowser:
|
||||
def __init__(self):
|
||||
self.launches = 0
|
||||
self.live_contexts = 0
|
||||
self.max_live_contexts = 0
|
||||
|
||||
async def new_context(self):
|
||||
self.live_contexts += 1
|
||||
self.max_live_contexts = max(self.max_live_contexts, self.live_contexts)
|
||||
return FakeContext(self)
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeChromium:
|
||||
def __init__(self, browser):
|
||||
self.browser = browser
|
||||
|
||||
async def launch(self, **options):
|
||||
self.browser.launches += 1
|
||||
return self.browser
|
||||
|
||||
|
||||
class FakePlaywright:
|
||||
def __init__(self, browser):
|
||||
self.chromium = FakeChromium(browser)
|
||||
|
||||
async def stop(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakePlaywrightFactory:
|
||||
def __init__(self):
|
||||
self.browser = FakeBrowser()
|
||||
self.playwright = FakePlaywright(self.browser)
|
||||
|
||||
def __call__(self):
|
||||
factory = self
|
||||
|
||||
class Runner:
|
||||
async def start(self):
|
||||
return factory.playwright
|
||||
|
||||
return Runner()
|
||||
|
||||
|
||||
def test_adapter_boundary_hands_xai_credential_to_cpa_without_ledger_secrets(tmp_path):
|
||||
def xai_handler(request):
|
||||
if request.url.path == "/.well-known/openid-configuration":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
},
|
||||
)
|
||||
if request.url.path == "/oauth2/device/code":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"device_code": "device-secret",
|
||||
"user_code": "ABCD",
|
||||
"verification_uri": "https://accounts.x.ai/oauth2/device",
|
||||
"verification_uri_complete": (
|
||||
"https://accounts.x.ai/oauth2/device?user_code=ABCD"
|
||||
),
|
||||
"expires_in": 60,
|
||||
"interval": 0,
|
||||
},
|
||||
)
|
||||
if request.url.path == "/oauth2/token":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"access_token": "access-secret",
|
||||
"refresh_token": "refresh-secret",
|
||||
"id_token": "id-secret",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"sub": "subject-1",
|
||||
},
|
||||
)
|
||||
raise AssertionError(f"unexpected xAI request: {request.url}")
|
||||
|
||||
cpa_requests = []
|
||||
|
||||
def cpa_handler(request):
|
||||
cpa_requests.append(request)
|
||||
return httpx.Response(201)
|
||||
|
||||
xai_client = httpx.AsyncClient(transport=httpx.MockTransport(xai_handler))
|
||||
cpa_client = httpx.AsyncClient(transport=httpx.MockTransport(cpa_handler))
|
||||
factory = FakePlaywrightFactory()
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=SingleSource(),
|
||||
protocol=XAIProtocol(xai_client, XAIProfile.default()),
|
||||
executor=PlaywrightExecutor(playwright_factory=factory),
|
||||
sink=CPAAuthFileSink(
|
||||
"https://cpa.example",
|
||||
"management-secret",
|
||||
cpa_client,
|
||||
name_secret=b"name-secret",
|
||||
),
|
||||
ledger_path=tmp_path / "ledger.db",
|
||||
ledger_salt=b"ledger-salt",
|
||||
)
|
||||
|
||||
try:
|
||||
results = asyncio.run(coordinator.run(target=1))
|
||||
finally:
|
||||
asyncio.run(xai_client.aclose())
|
||||
asyncio.run(cpa_client.aclose())
|
||||
|
||||
assert results[0].status.value == "imported"
|
||||
assert factory.browser.launches == 1
|
||||
assert factory.browser.max_live_contexts == 1
|
||||
assert cpa_requests[0].url.path == "/v0/management/auth-files"
|
||||
assert cpa_requests[0].url.params["name"].startswith("xai-")
|
||||
assert json.loads(cpa_requests[0].content)["refresh_token"] == "refresh-secret"
|
||||
ledger_bytes = (tmp_path / "ledger.db").read_bytes()
|
||||
for secret in ("sso-secret", "device-secret", "access-secret", "refresh-secret", "id-secret"):
|
||||
assert secret.encode() not in ledger_bytes
|
||||
@@ -1,163 +0,0 @@
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from xai_enroller.ledger import Ledger
|
||||
from xai_enroller.models import JobStatus
|
||||
|
||||
|
||||
def test_ledger_persists_only_redacted_terminal_fields(tmp_path):
|
||||
path = tmp_path / "ledger.db"
|
||||
ledger = Ledger(path, b"salt")
|
||||
job_id = ledger.start("source", attempt=1)
|
||||
ledger.finish(job_id, JobStatus.SINK_FAILED, "sink_failed", "receipt")
|
||||
raw = path.read_bytes()
|
||||
for secret in [
|
||||
"sso-token",
|
||||
"device-code",
|
||||
"https://accounts.x.ai",
|
||||
"access-token",
|
||||
"refresh-token",
|
||||
"id-token",
|
||||
"person@example.com",
|
||||
]:
|
||||
assert secret.encode() not in raw
|
||||
row = ledger.get(job_id)
|
||||
assert row["status"] == "sink_failed"
|
||||
assert row["reason_code"] == "sink_failed"
|
||||
assert row["sink_receipt_fingerprint"] == "receipt"
|
||||
assert "source" not in repr(row["source_fingerprint"])
|
||||
|
||||
|
||||
def test_ledger_recovers_pending_jobs(tmp_path):
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"salt")
|
||||
job_id = ledger.start("source", attempt=1)
|
||||
ledger.recover_pending()
|
||||
assert ledger.get(job_id)["status"] == JobStatus.CANCELLED.value
|
||||
|
||||
|
||||
def test_ledger_backfills_imported_receipts_into_available_inventory(tmp_path):
|
||||
path = tmp_path / "ledger.db"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE jobs (
|
||||
job_id INTEGER PRIMARY KEY,
|
||||
source_fingerprint TEXT NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
reason_code TEXT,
|
||||
sink_receipt_fingerprint TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO jobs(source_fingerprint, attempt_number, status, started_at, "
|
||||
"finished_at, reason_code, sink_receipt_fingerprint) VALUES (?, 1, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
("source-old", "imported", "2026-01-01", "2026-01-02", "imported", "receipt-old"),
|
||||
("source-new", "imported", "2026-01-03", "2026-01-04", "imported", "receipt-new"),
|
||||
("source-failed", "sink_failed", "2026-01-05", "2026-01-06", "sink_failed", "receipt-failed"),
|
||||
],
|
||||
)
|
||||
|
||||
ledger = Ledger(path, b"salt")
|
||||
|
||||
assert ledger.inventory_counts() == {
|
||||
"available": 2,
|
||||
"claiming": 0,
|
||||
"claimed": 0,
|
||||
}
|
||||
assert ledger.claim_available(2, "batch-backfill") == [
|
||||
"receipt-new",
|
||||
"receipt-old",
|
||||
]
|
||||
|
||||
|
||||
def test_imported_finish_and_inventory_insert_are_atomic(tmp_path):
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"salt")
|
||||
job_id = ledger.start("source", attempt=1)
|
||||
with sqlite3.connect(ledger.path) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TRIGGER reject_inventory_insert
|
||||
BEFORE INSERT ON credential_inventory
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'inventory insert rejected');
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
with pytest.raises(sqlite3.IntegrityError, match="inventory insert rejected"):
|
||||
ledger.finish(job_id, JobStatus.IMPORTED, "imported", "receipt")
|
||||
|
||||
assert ledger.get(job_id)["status"] == "pending"
|
||||
assert ledger.inventory_counts()["available"] == 0
|
||||
|
||||
|
||||
def test_claim_available_uses_latest_finish_and_tracks_batch(tmp_path):
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"salt")
|
||||
jobs = []
|
||||
for source, receipt in [
|
||||
("source-a", "receipt-a"),
|
||||
("source-b", "receipt-b"),
|
||||
("source-c", "receipt-c"),
|
||||
]:
|
||||
job_id = ledger.start(source)
|
||||
ledger.finish(job_id, JobStatus.IMPORTED, "imported", receipt)
|
||||
jobs.append(job_id)
|
||||
with sqlite3.connect(ledger.path) as connection:
|
||||
connection.executemany(
|
||||
"UPDATE jobs SET finished_at=? WHERE job_id=?",
|
||||
[
|
||||
("2026-01-01T00:00:00+00:00", jobs[0]),
|
||||
("2026-01-03T00:00:00+00:00", jobs[1]),
|
||||
("2026-01-02T00:00:00+00:00", jobs[2]),
|
||||
],
|
||||
)
|
||||
|
||||
assert ledger.claim_available(2, "batch-1") == ["receipt-b", "receipt-c"]
|
||||
assert ledger.claim_available(2, "batch-2") == ["receipt-a"]
|
||||
assert ledger.pending_claims("batch-1") == [
|
||||
{
|
||||
"sink_receipt_fingerprint": "receipt-b",
|
||||
"batch_id": "batch-1",
|
||||
"note": "",
|
||||
},
|
||||
{
|
||||
"sink_receipt_fingerprint": "receipt-c",
|
||||
"batch_id": "batch-1",
|
||||
"note": "",
|
||||
},
|
||||
]
|
||||
assert ledger.inventory_counts() == {
|
||||
"available": 0,
|
||||
"claiming": 3,
|
||||
"claimed": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_claim_completion_and_recovery_are_batch_scoped(tmp_path):
|
||||
ledger = Ledger(tmp_path / "ledger.db", b"salt")
|
||||
for source, receipt in [
|
||||
("source-a", "receipt-a"),
|
||||
("source-b", "receipt-b"),
|
||||
("source-c", "receipt-c"),
|
||||
]:
|
||||
job_id = ledger.start(source)
|
||||
ledger.finish(job_id, JobStatus.IMPORTED, "imported", receipt)
|
||||
|
||||
assert ledger.claim_available(2, "batch-complete")
|
||||
assert ledger.claim_available(1, "batch-recover")
|
||||
assert ledger.mark_claimed("batch-complete", "delivered") == 2
|
||||
assert ledger.recover_claiming("batch-recover", note="worker_restart") == 1
|
||||
|
||||
assert ledger.pending_claims() == []
|
||||
assert ledger.inventory_counts() == {
|
||||
"available": 1,
|
||||
"claiming": 0,
|
||||
"claimed": 2,
|
||||
}
|
||||
assert ledger.claim_available(1, "batch-retry") == ["receipt-a"]
|
||||
@@ -1,245 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from xai_enroller.models import AuthorizationStatus
|
||||
from xai_enroller.protocol import XAIProtocol, XAIProfile
|
||||
|
||||
|
||||
def _unsigned_jwt(payload):
|
||||
encoded = base64.urlsafe_b64encode(
|
||||
json.dumps(payload, separators=(",", ":")).encode()
|
||||
).decode().rstrip("=")
|
||||
return f"header.{encoded}.signature"
|
||||
|
||||
|
||||
def test_default_profile_matches_observed_grok_cli_device_contract():
|
||||
profile = XAIProfile.default()
|
||||
|
||||
assert profile.client_id == "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
assert profile.scope == "openid profile email offline_access grok-cli:access api:access"
|
||||
assert profile.version == "grok-cli-device-v1"
|
||||
|
||||
|
||||
def test_device_flow_uses_discovered_endpoints_and_profile_scope():
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/.well-known/openid-configuration":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"device_code": "device",
|
||||
"user_code": "ABCD",
|
||||
"verification_uri": "https://accounts.x.ai/oauth2/device",
|
||||
"expires_in": 60,
|
||||
"interval": 1,
|
||||
},
|
||||
)
|
||||
|
||||
profile = XAIProfile(client_id="client", scope="openid offline_access")
|
||||
protocol = XAIProtocol(httpx.AsyncClient(transport=httpx.MockTransport(handler)), profile)
|
||||
|
||||
async def run():
|
||||
flow = await protocol.start_device_flow()
|
||||
return flow
|
||||
|
||||
flow = __import__("asyncio").run(run())
|
||||
assert flow.verification_url == "https://accounts.x.ai/oauth2/device?user_code=ABCD"
|
||||
assert dict(__import__("urllib.parse", fromlist=["parse_qsl"]).parse_qsl(requests[1].content.decode())) == {
|
||||
"client_id": "client",
|
||||
"scope": "openid offline_access",
|
||||
}
|
||||
|
||||
|
||||
def test_device_flow_uses_configured_poll_interval_when_issuer_omits_it():
|
||||
def handler(request):
|
||||
if request.url.path == "/.well-known/openid-configuration":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"device_code": "device",
|
||||
"user_code": "ABCD",
|
||||
"verification_uri": "https://accounts.x.ai/oauth2/device",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
protocol = XAIProtocol(
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
XAIProfile(client_id="client", scope="openid"),
|
||||
default_poll_interval=7,
|
||||
)
|
||||
|
||||
flow = __import__("asyncio").run(protocol.start_device_flow())
|
||||
|
||||
assert flow.interval == 7
|
||||
|
||||
|
||||
def test_device_flow_preserves_complete_verification_url():
|
||||
def handler(request):
|
||||
if request.url.path == "/.well-known/openid-configuration":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"device_code": "device",
|
||||
"user_code": "ABCD",
|
||||
"verification_uri": "https://accounts.x.ai/oauth2/device",
|
||||
"verification_uri_complete": "https://accounts.x.ai/oauth2/device?user_code=ABCD&state=issuer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
protocol = XAIProtocol(
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
XAIProfile(client_id="client", scope="openid"),
|
||||
)
|
||||
|
||||
flow = __import__("asyncio").run(protocol.start_device_flow())
|
||||
|
||||
assert flow.verification_url == (
|
||||
"https://accounts.x.ai/oauth2/device?user_code=ABCD&state=issuer"
|
||||
)
|
||||
|
||||
|
||||
def test_xai_url_allowlist_rejects_missing_hostname():
|
||||
assert XAIProtocol._allowed_url("https:///oauth2/device") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"discovery",
|
||||
[
|
||||
{
|
||||
"device_authorization_endpoint": "http://auth.x.ai/device",
|
||||
"token_endpoint": "https://auth.x.ai/token",
|
||||
},
|
||||
{
|
||||
"device_authorization_endpoint": "https://evil.example/device",
|
||||
"token_endpoint": "https://auth.x.ai/token",
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_discovery_rejects_non_xai_or_non_https(discovery):
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=discovery)
|
||||
|
||||
protocol = XAIProtocol(
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
XAIProfile(client_id="client", scope="openid"),
|
||||
)
|
||||
with pytest.raises(ValueError, match="allowlist"):
|
||||
__import__("asyncio").run(protocol.discover())
|
||||
|
||||
|
||||
def test_token_polling_maps_pending_slow_down_and_denial():
|
||||
responses = iter(
|
||||
[
|
||||
httpx.Response(400, json={"error": "authorization_pending"}),
|
||||
httpx.Response(400, json={"error": "slow_down"}),
|
||||
httpx.Response(400, json={"error": "access_denied"}),
|
||||
]
|
||||
)
|
||||
|
||||
def handler(request):
|
||||
return next(responses)
|
||||
|
||||
protocol = XAIProtocol(
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
XAIProfile(client_id="client", scope="openid"),
|
||||
sleep=lambda _: __import__("asyncio").sleep(0),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="oauth_denied"):
|
||||
__import__("asyncio").run(
|
||||
protocol.poll_token(
|
||||
endpoint="https://auth.x.ai/oauth2/token",
|
||||
flow=type("Flow", (), {"device_code": "device", "interval": 0})(),
|
||||
timeout=1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_token_response_requires_refresh_token_and_never_exposes_body():
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"access_token": "access", "token_type": "Bearer"})
|
||||
|
||||
protocol = XAIProtocol(
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
XAIProfile(client_id="client", scope="openid"),
|
||||
)
|
||||
with pytest.raises(RuntimeError) as error:
|
||||
__import__("asyncio").run(
|
||||
protocol.poll_token(
|
||||
endpoint="https://auth.x.ai/oauth2/token",
|
||||
flow=type("Flow", (), {"device_code": "device", "interval": 0})(),
|
||||
timeout=1,
|
||||
)
|
||||
)
|
||||
assert "access" not in str(error.value)
|
||||
|
||||
|
||||
def test_credential_subject_prefers_id_token_subject():
|
||||
credential = XAIProtocol._credential(
|
||||
{
|
||||
"access_token": _unsigned_jwt({"principal_id": "access-subject"}),
|
||||
"refresh_token": "refresh",
|
||||
"id_token": _unsigned_jwt(
|
||||
{"sub": "stable-subject", "email": "ignored@example.test"}
|
||||
),
|
||||
"sub": "response-subject",
|
||||
},
|
||||
"https://auth.x.ai/oauth2/token",
|
||||
)
|
||||
|
||||
assert credential.subject == "stable-subject"
|
||||
|
||||
|
||||
def test_credential_subject_falls_back_to_access_token_principal_id():
|
||||
credential = XAIProtocol._credential(
|
||||
{
|
||||
"access_token": _unsigned_jwt({"principal_id": "stable-principal"}),
|
||||
"refresh_token": "refresh",
|
||||
"id_token": "malformed",
|
||||
"sub": "response-subject",
|
||||
},
|
||||
"https://auth.x.ai/oauth2/token",
|
||||
)
|
||||
|
||||
assert credential.subject == "stable-principal"
|
||||
|
||||
|
||||
def test_credential_subject_ignores_email_and_falls_back_to_response_subject():
|
||||
credential = XAIProtocol._credential(
|
||||
{
|
||||
"access_token": "not-a-jwt",
|
||||
"refresh_token": "refresh",
|
||||
"id_token": _unsigned_jwt({"email": "ignored@example.test"}),
|
||||
"sub": "response-subject",
|
||||
},
|
||||
"https://auth.x.ai/oauth2/token",
|
||||
)
|
||||
|
||||
assert credential.subject == "response-subject"
|
||||
@@ -1,79 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from xai_enroller.models import OAuthCredential
|
||||
from xai_enroller.sinks import CPAAuthFileSink, SinkError
|
||||
|
||||
|
||||
def credential():
|
||||
return OAuthCredential(
|
||||
access_token="access",
|
||||
refresh_token="refresh",
|
||||
id_token="id-token",
|
||||
token_type="Bearer",
|
||||
expires_in=3600,
|
||||
expires_at="2026-07-11T00:00:00Z",
|
||||
last_refresh="2026-07-11T00:00:00Z",
|
||||
subject="subject",
|
||||
token_endpoint="https://auth.x.ai/oauth2/token",
|
||||
)
|
||||
|
||||
|
||||
def test_cpa_sink_builds_pinned_document_and_safe_name():
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(201)
|
||||
|
||||
sink = CPAAuthFileSink(
|
||||
"https://cpa.example",
|
||||
"management-secret",
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
name_secret=b"name-secret",
|
||||
)
|
||||
receipt = asyncio.run(sink.store(credential()))
|
||||
assert requests[0].url.path == "/v0/management/auth-files"
|
||||
assert requests[0].headers["authorization"] == "Bearer management-secret"
|
||||
assert requests[0].url.params["name"].startswith("xai-")
|
||||
assert requests[0].url.params["name"].endswith(".json")
|
||||
assert json.loads(requests[0].content) == {
|
||||
"type": "xai",
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"id_token": "id-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"expired": "2026-07-11T00:00:00Z",
|
||||
"last_refresh": "2026-07-11T00:00:00Z",
|
||||
"sub": "subject",
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"auth_kind": "oauth",
|
||||
}
|
||||
assert "access" not in repr(receipt)
|
||||
|
||||
|
||||
def test_cpa_sink_maps_non_2xx_without_retry_or_secret_leak():
|
||||
count = 0
|
||||
|
||||
def handler(request):
|
||||
nonlocal count
|
||||
count += 1
|
||||
return httpx.Response(500, text="access")
|
||||
|
||||
sink = CPAAuthFileSink(
|
||||
"https://cpa.example",
|
||||
"management-secret",
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||
name_secret=b"name-secret",
|
||||
)
|
||||
try:
|
||||
asyncio.run(sink.store(credential()))
|
||||
except SinkError as error:
|
||||
assert "access" not in str(error)
|
||||
else:
|
||||
raise AssertionError("expected SinkError")
|
||||
assert count == 1
|
||||
@@ -1,240 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from xai_enroller.models import SourceRecord
|
||||
from xai_enroller.remote_stream import (
|
||||
DiskSnapshotSource,
|
||||
LocalSnapshotSynchronizer,
|
||||
SSHSnapshotSynchronizer,
|
||||
)
|
||||
|
||||
|
||||
def _document(source_id):
|
||||
return json.dumps(
|
||||
{
|
||||
"email": source_id,
|
||||
"cookies": [
|
||||
{
|
||||
"name": "sso",
|
||||
"value": "opaque",
|
||||
"domain": "accounts.x.ai",
|
||||
"path": "/",
|
||||
}
|
||||
],
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self, lines):
|
||||
self._lines = iter(lines)
|
||||
|
||||
async def readline(self):
|
||||
return next(self._lines, b"")
|
||||
|
||||
async def read(self, _size=-1):
|
||||
return b""
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self, lines, returncode=0):
|
||||
self.stdout = FakeStream(lines)
|
||||
self.stderr = FakeStream([])
|
||||
self.returncode = None
|
||||
self._final_returncode = returncode
|
||||
|
||||
async def wait(self):
|
||||
self.returncode = self._final_returncode
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.returncode = -15
|
||||
|
||||
def kill(self):
|
||||
self.returncode = -9
|
||||
|
||||
|
||||
def test_snapshot_sync_atomically_replaces_only_valid_complete_export(tmp_path):
|
||||
async def scenario():
|
||||
destination = tmp_path / "source-snapshot.jsonl"
|
||||
destination.write_bytes(_document("old") + b"\n")
|
||||
|
||||
invalid = FakeProcess([b"not-json\n"])
|
||||
|
||||
async def invalid_factory(*_args, **_kwargs):
|
||||
return invalid
|
||||
|
||||
synchronizer = SSHSnapshotSynchronizer(
|
||||
"host",
|
||||
destination,
|
||||
process_factory=invalid_factory,
|
||||
fingerprint=lambda source_id: f"key:{source_id}",
|
||||
)
|
||||
assert not await synchronizer.sync_once()
|
||||
assert destination.read_bytes() == _document("old") + b"\n"
|
||||
|
||||
rejected = FakeProcess([_document("rejected") + b"\n"], returncode=1)
|
||||
|
||||
async def rejected_factory(*_args, **_kwargs):
|
||||
return rejected
|
||||
|
||||
synchronizer.process_factory = rejected_factory
|
||||
assert not await synchronizer.sync_once()
|
||||
assert destination.read_bytes() == _document("old") + b"\n"
|
||||
|
||||
empty = FakeProcess([])
|
||||
|
||||
async def empty_factory(*_args, **_kwargs):
|
||||
return empty
|
||||
|
||||
synchronizer.process_factory = empty_factory
|
||||
assert not await synchronizer.sync_once()
|
||||
assert destination.read_bytes() == _document("old") + b"\n"
|
||||
|
||||
valid = FakeProcess([_document("new-a") + b"\n", _document("new-b") + b"\n"])
|
||||
|
||||
async def valid_factory(*_args, **_kwargs):
|
||||
return valid
|
||||
|
||||
synchronizer.process_factory = valid_factory
|
||||
assert await synchronizer.sync_once()
|
||||
assert synchronizer.snapshot_fingerprints == frozenset(
|
||||
{"key:new-a", "key:new-b"}
|
||||
)
|
||||
assert destination.read_bytes() == _document("new-a") + b"\n" + _document("new-b") + b"\n"
|
||||
assert destination.stat().st_mode & 0o777 == 0o600
|
||||
assert not list(tmp_path.glob(".source-snapshot.jsonl.*.tmp"))
|
||||
|
||||
synchronizer.process_factory = invalid_factory
|
||||
assert not await synchronizer.sync_once()
|
||||
assert synchronizer.snapshot_fingerprints == frozenset(
|
||||
{"key:new-a", "key:new-b"}
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_local_snapshot_accepts_empty_input_and_anchors_exporter_code(tmp_path):
|
||||
async def scenario():
|
||||
registration_root = tmp_path / "registration"
|
||||
destination = tmp_path / "auth" / "source-snapshot.jsonl"
|
||||
captured = []
|
||||
process = FakeProcess([])
|
||||
|
||||
async def factory(*args, **_kwargs):
|
||||
captured.append(args)
|
||||
return process
|
||||
|
||||
synchronizer = LocalSnapshotSynchronizer(
|
||||
registration_root,
|
||||
destination,
|
||||
process_factory=factory,
|
||||
)
|
||||
|
||||
assert await synchronizer.sync_once()
|
||||
assert destination.read_bytes() == b""
|
||||
assert destination.stat().st_mode & 0o777 == 0o600
|
||||
assert synchronizer.snapshot_fingerprints == frozenset()
|
||||
assert captured[0][0]
|
||||
assert captured[0][1] != str(registration_root / "scripts" / "export_registered_sessions.py")
|
||||
assert captured[0][-2:] == (
|
||||
str(registration_root / "keys" / "auth-sessions.jsonl"),
|
||||
str(registration_root / "keys" / "accounts.txt"),
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_local_snapshot_rejects_candidate_when_either_input_changes(tmp_path):
|
||||
async def scenario():
|
||||
registration_root = tmp_path / "registration"
|
||||
keys = registration_root / "keys"
|
||||
keys.mkdir(parents=True)
|
||||
sessions = keys / "auth-sessions.jsonl"
|
||||
accounts = keys / "accounts.txt"
|
||||
sessions.write_bytes(_document("old") + b"\n")
|
||||
accounts.write_text("", encoding="utf-8")
|
||||
destination = tmp_path / "auth" / "source-snapshot.jsonl"
|
||||
destination.parent.mkdir()
|
||||
destination.write_bytes(_document("stable") + b"\n")
|
||||
|
||||
process = FakeProcess([_document("candidate") + b"\n"])
|
||||
|
||||
async def factory(*_args, **_kwargs):
|
||||
accounts.write_text("new@example.test:p:sso\n", encoding="utf-8")
|
||||
return process
|
||||
|
||||
synchronizer = LocalSnapshotSynchronizer(
|
||||
registration_root,
|
||||
destination,
|
||||
process_factory=factory,
|
||||
)
|
||||
|
||||
assert not await synchronizer.sync_once()
|
||||
assert destination.read_bytes() == _document("stable") + b"\n"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_snapshot_consumer_finishes_open_generation_before_replacement(tmp_path):
|
||||
async def scenario():
|
||||
destination = tmp_path / "source-snapshot.jsonl"
|
||||
destination.write_bytes(_document("old-a") + b"\n" + _document("old-b") + b"\n")
|
||||
source = DiskSnapshotSource(destination, poll_seconds=0.01)
|
||||
records = source.records()
|
||||
first = await anext(records)
|
||||
assert isinstance(first, SourceRecord)
|
||||
assert first.source_id == "old-a"
|
||||
|
||||
replacement = tmp_path / "replacement"
|
||||
replacement.write_bytes(_document("new-a") + b"\n")
|
||||
os.replace(replacement, destination)
|
||||
|
||||
assert (await anext(records)).source_id == "old-b"
|
||||
assert (await asyncio.wait_for(anext(records), 1)).source_id == "new-a"
|
||||
await source.close()
|
||||
await records.aclose()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_snapshot_source_reports_only_aggregate_new_records(tmp_path):
|
||||
class Synchronizer:
|
||||
def __init__(self):
|
||||
self.snapshot_fingerprints = None
|
||||
self.calls = 0
|
||||
self.source = None
|
||||
|
||||
async def sync_once(self):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
self.snapshot_fingerprints = frozenset({"a", "b"})
|
||||
else:
|
||||
self.snapshot_fingerprints = frozenset({"a", "b", "c"})
|
||||
self.source._closed = True
|
||||
return True
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async def scenario():
|
||||
synchronizer = Synchronizer()
|
||||
events = []
|
||||
source = DiskSnapshotSource(
|
||||
tmp_path / "source-snapshot.jsonl",
|
||||
synchronizer=synchronizer,
|
||||
sync_seconds=0.001,
|
||||
event_callback=lambda kind, data: events.append((kind, data)),
|
||||
)
|
||||
synchronizer.source = source
|
||||
|
||||
await source._sync_loop()
|
||||
|
||||
assert events == [
|
||||
("source_connected", {}),
|
||||
("source_updated", {"new": 1, "total": 3}),
|
||||
]
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -1,100 +0,0 @@
|
||||
# Grok Session → CPA / sub2api
|
||||
|
||||
纯前端单页工具:把 grok-free-register 产出的凭证,转成 CPA、sub2api 导入包、batch 建号、或 sso-to-oauth 请求体。
|
||||
|
||||
对照思路:GPT session → CPA / sub2api 纯前端转换器(同类工具,无外部依赖)。
|
||||
|
||||
## 本地使用
|
||||
|
||||
直接打开:
|
||||
|
||||
```text
|
||||
tools/GrokSession2CPAAndSub2API/index.html
|
||||
```
|
||||
|
||||
所有解析和转换都在浏览器本地完成,不上传 token。
|
||||
|
||||
## 支持输入
|
||||
|
||||
| 来源 | 说明 |
|
||||
|------|------|
|
||||
| `authenticated/xai-*.json` | 注册成功 OAuth(base_url 常是 api.x.ai) |
|
||||
| `cpa_ready/xai-*.json` | watchdog 终态(带 headers/email) |
|
||||
| `sub2api-data` / accounts 数组 | 已转换过的包,可再导成其它格式 |
|
||||
| `auth-sessions.jsonl` | `email` + `cookies.sso` |
|
||||
| `accounts.txt` | `email:password:sso` |
|
||||
| `grok.txt` | 每行一个 SSO JWT |
|
||||
|
||||
可粘贴 JSON / 数组 / jsonl,或拖入多个文件。
|
||||
|
||||
## 输出格式
|
||||
|
||||
### CPA
|
||||
|
||||
对齐注册机 cpa_ready:
|
||||
|
||||
- `type: "xai"`
|
||||
- `access_token` / `refresh_token` / `id_token`
|
||||
- `base_url` **强制** `https://cli-chat-proxy.grok.com/v1`(忽略 authenticated 里的 `api.x.ai`)
|
||||
- `headers`:保留源 headers,否则写 grok-cli 默认头
|
||||
- `email` / `sub` / `expired` / `expires_in`
|
||||
|
||||
单条输出对象,多条输出数组。
|
||||
|
||||
### sub2api 导入
|
||||
|
||||
`type: "sub2api-data"` 包,可走 sub2api 数据导入:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "sub2api-data",
|
||||
"version": 1,
|
||||
"exported_at": "...",
|
||||
"proxies": [],
|
||||
"accounts": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
账号字段:
|
||||
|
||||
- `platform: "grok"`, `type: "oauth"`
|
||||
- `credentials.access_token` / `refresh_token` / `id_token` / `expires_at` / `email` / `sub` / `base_url`
|
||||
- **有 `refresh_token`**:不写账号顶层 `expires_at` / `auto_pause_on_expired`(避免 access 6h 到期被 pause)
|
||||
- **无 `refresh_token`**:写 access JWT exp + `auto_pause_on_expired: true`
|
||||
|
||||
### batch 建号
|
||||
|
||||
`POST /api/v1/admin/accounts/batch` 体:
|
||||
|
||||
```json
|
||||
{ "accounts": [ /* 同上 sub2api 账号对象 */ ] }
|
||||
```
|
||||
|
||||
### sso-to-oauth
|
||||
|
||||
`POST /api/v1/admin/grok/sso-to-oauth` 体:
|
||||
|
||||
```json
|
||||
{
|
||||
"sso_tokens": ["eyJ...", "..."],
|
||||
"_meta": [ /* 仅本地对照,API 可忽略 */ ]
|
||||
}
|
||||
```
|
||||
|
||||
纯 SSO 输入(grok.txt / accounts.txt / auth-sessions)**只能**走这个格式;CPA / sub2api / batch 需要 OAuth。
|
||||
|
||||
## 关键规则(别踩坑)
|
||||
|
||||
1. free 号 API 走 `cli-chat-proxy.grok.com`,不是 `api.x.ai`。
|
||||
2. free OAuth access ≈ 6h(`expires_in=21600`)。有 refresh 时让 sub2api 自己刷;别把账号级 `expires_at` 钉成 access exp。
|
||||
3. 当前线上常见:refresh 被整批 revoke,access 在 exp 前仍可用。这种号导入后到期无法续命,只能重铸。
|
||||
4. 注册机 `grok.txt` / `accounts.txt` / `auth-sessions.jsonl` 只有 SSO,没有 OAuth;要进 sub2api 要么走 sso-to-oauth,要么先走 xai_enroller 出 `authenticated/` 再转。
|
||||
|
||||
## 与注册机衔接
|
||||
|
||||
```
|
||||
register → grok.txt / accounts.txt / auth-sessions.jsonl (SSO)
|
||||
→ xai_enroller → authenticated/xai-*.json (OAuth)
|
||||
→ acpa_watchdog → cpa_ready/xai-*.json + acc.md
|
||||
→ 本工具 → sub2api-data / batch / CPA / sso-to-oauth
|
||||
```
|
||||
@@ -1,902 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Grok Session → CPA / sub2api</title>
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0b1220;
|
||||
--surface: #121a2b;
|
||||
--surface-soft: #1a2438;
|
||||
--line: #2a3650;
|
||||
--text: #e8eefc;
|
||||
--muted: #93a0bc;
|
||||
--accent: #38bdf8;
|
||||
--accent-strong: #0ea5e9;
|
||||
--success: #34d399;
|
||||
--danger: #f87171;
|
||||
--warning: #fbbf24;
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
--sans: "Inter", "Avenir Next", "Helvetica Neue", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
--radius: 10px;
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { min-height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #0b1220, #0a101b 40%, #070b14);
|
||||
}
|
||||
button, textarea, input { font: inherit; }
|
||||
button { border: 0; cursor: pointer; }
|
||||
.app { width: min(1280px, calc(100vw - 28px)); margin: 0 auto; padding: 22px 0 36px; }
|
||||
.topbar { display: flex; justify-content: space-between; gap: 16px; align-items: flex-end; margin-bottom: 16px; }
|
||||
.eyebrow { margin: 0 0 8px; color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-size: clamp(1.5rem, 3vw, 2.3rem); line-height: 1.1; }
|
||||
.subtitle { margin: 10px 0 0; color: var(--muted); line-height: 1.5; max-width: 70ch; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; min-height: 32px; padding: 6px 10px;
|
||||
border: 1px solid var(--line); border-radius: 999px; background: rgba(18, 26, 43, 0.9); color: var(--muted); font-size: 0.84rem;
|
||||
}
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.segmented {
|
||||
display: inline-grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px;
|
||||
width: min(760px, 100%); padding: 4px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
|
||||
}
|
||||
.segmented button {
|
||||
min-height: 40px; border-radius: 8px; background: transparent; color: var(--muted); padding: 0 10px;
|
||||
}
|
||||
.segmented button[aria-pressed="true"] { background: var(--accent-strong); color: #041018; font-weight: 800; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.button {
|
||||
display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 14px;
|
||||
border-radius: var(--radius); transition: 0.15s ease;
|
||||
}
|
||||
.button-primary { background: var(--accent-strong); color: #041018; font-weight: 800; }
|
||||
.button-primary:hover { filter: brightness(1.08); }
|
||||
.button-secondary { border: 1px solid var(--line); background: var(--surface); color: var(--text); }
|
||||
.button-secondary:hover { background: var(--surface-soft); }
|
||||
.button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
|
||||
.panel {
|
||||
min-width: 0; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: rgba(18, 26, 43, 0.92); box-shadow: var(--shadow);
|
||||
}
|
||||
.panel-head { display: flex; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
||||
.panel-head h2 { margin: 0; font-size: 1rem; }
|
||||
.panel-head p { margin: 4px 0 0; color: var(--muted); font-size: 0.88rem; line-height: 1.45; }
|
||||
.panel-body { padding: 14px 16px 16px; }
|
||||
.guide {
|
||||
margin: 0 0 12px; padding: 12px 14px; border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
border-left: 4px solid var(--accent); border-radius: var(--radius); background: rgba(14, 165, 233, 0.08);
|
||||
color: var(--muted); line-height: 1.5; font-size: 0.9rem;
|
||||
}
|
||||
.guide strong { color: var(--text); }
|
||||
.guide code { font-family: var(--mono); color: var(--accent); }
|
||||
textarea {
|
||||
width: 100%; min-height: 460px; resize: vertical; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 12px; background: #0a1220; color: var(--text); font-family: var(--mono); font-size: 0.82rem; line-height: 1.5;
|
||||
}
|
||||
textarea:focus { outline: 2px solid rgba(56, 189, 248, 0.35); border-color: var(--accent); }
|
||||
.status-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.status {
|
||||
min-height: 30px; display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 999px;
|
||||
border: 1px solid var(--line); color: var(--muted); font-size: 0.84rem; background: rgba(0,0,0,0.15);
|
||||
}
|
||||
.status.ok { color: var(--success); border-color: rgba(52, 211, 153, 0.35); }
|
||||
.status.err { color: var(--danger); border-color: rgba(248, 113, 113, 0.35); }
|
||||
.status.warn { color: var(--warning); border-color: rgba(251, 191, 36, 0.35); }
|
||||
.accounts, .issues {
|
||||
margin-top: 12px; display: grid; gap: 8px; max-height: 180px; overflow: auto;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: rgba(0,0,0,0.14);
|
||||
font-size: 0.86rem; line-height: 1.4;
|
||||
}
|
||||
.card .title { font-weight: 700; }
|
||||
.card .meta { color: var(--muted); margin-top: 4px; font-family: var(--mono); font-size: 0.78rem; word-break: break-all; }
|
||||
.drop-active textarea { border-color: var(--accent); box-shadow: inset 0 0 0 1px rgba(56,189,248,0.35); }
|
||||
.footer-note { margin-top: 14px; color: var(--muted); font-size: 0.84rem; line-height: 1.5; }
|
||||
@media (max-width: 960px) {
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.segmented { grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
|
||||
.topbar { flex-direction: column; align-items: flex-start; }
|
||||
.chips { justify-content: flex-start; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Grok Free Register toolkit</p>
|
||||
<h1>Grok Session → CPA / sub2api</h1>
|
||||
<p class="subtitle">
|
||||
纯前端转换:把注册机 <code>xai-*.json</code> / SSO / accounts / sessions
|
||||
转成 CPA、sub2api 导入包、batch 建号、或 sso-to-oauth 请求体。本地解析,不上传。
|
||||
</p>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">platform: grok</span>
|
||||
<span class="chip">base: cli-chat-proxy</span>
|
||||
<span class="chip">有 refresh 不写账号 6h 死期</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="segmented" role="tablist" aria-label="输出格式">
|
||||
<button type="button" data-format="cpa" aria-pressed="true">CPA</button>
|
||||
<button type="button" data-format="sub2api" aria-pressed="false">sub2api 导入</button>
|
||||
<button type="button" data-format="batch" aria-pressed="false">batch 建号</button>
|
||||
<button type="button" data-format="sso" aria-pressed="false">sso-to-oauth</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="button button-secondary" id="btn-sample" type="button">填示例</button>
|
||||
<button class="button button-secondary" id="btn-clear" type="button">清空</button>
|
||||
<button class="button button-secondary" id="btn-copy" type="button" disabled>复制输出</button>
|
||||
<button class="button button-primary" id="btn-download" type="button" disabled>下载 JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<section class="panel" id="input-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>输入</h2>
|
||||
<p>粘贴 / 拖入 JSON、JSON 数组、jsonl、accounts 行、纯 SSO 行</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="guide">
|
||||
<strong>支持输入</strong><br />
|
||||
1. <code>authenticated/xai-*.json</code> 或 <code>cpa_ready/xai-*.json</code><br />
|
||||
2. sub2api account / accounts 数组 / sub2api-data 包<br />
|
||||
3. <code>auth-sessions.jsonl</code>(email + cookies.sso)<br />
|
||||
4. <code>accounts.txt</code>:<code>email:password:sso</code><br />
|
||||
5. <code>grok.txt</code>:每行一个 SSO JWT
|
||||
</div>
|
||||
<textarea id="session-input" spellcheck="false" placeholder="在此粘贴 xai-*.json / SSO / accounts 行…"></textarea>
|
||||
<div class="status-row">
|
||||
<span class="status" id="input-status">等待输入</span>
|
||||
<span class="status" id="convert-status">未转换</span>
|
||||
</div>
|
||||
<div class="accounts" id="accounts"></div>
|
||||
<div class="issues" id="issues"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>输出</h2>
|
||||
<p id="output-help">CPA:type=xai,对齐注册机 cpa_ready / new-api 风格</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<textarea id="output" readonly spellcheck="false" placeholder="转换结果会显示在这里"></textarea>
|
||||
<div class="status-row">
|
||||
<span class="status" id="output-status">空</span>
|
||||
</div>
|
||||
<p class="footer-note">
|
||||
规则:<br />
|
||||
• free 号 <code>base_url</code> 固定为 <code>https://cli-chat-proxy.grok.com/v1</code>(忽略 authenticated 里的 api.x.ai)<br />
|
||||
• 有 <code>refresh_token</code> 时:sub2api 账号<strong>不写</strong>顶层 <code>expires_at/auto_pause</code>,避免 6h 被 pause<br />
|
||||
• 无 refresh 时:写 access 的 exp + auto_pause,到期停用<br />
|
||||
• 纯 SSO 输入只能出 <code>sso-to-oauth</code> 请求体;CPA/sub2api 需要 OAuth token
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const CLI_BASE = "https://cli-chat-proxy.grok.com/v1";
|
||||
const TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
|
||||
const HELP = {
|
||||
cpa: "CPA:type=xai,对齐注册机 cpa_ready / cli-proxy 风格",
|
||||
sub2api: "sub2api 数据导入:type=sub2api-data / accounts[] platform=grok",
|
||||
batch: "batch 建号:POST /api/v1/admin/accounts/batch 的 {accounts:[]}",
|
||||
sso: "sso-to-oauth:POST /api/v1/admin/grok/sso-to-oauth 的 {sso_tokens:[]}",
|
||||
};
|
||||
|
||||
const els = {
|
||||
input: document.querySelector("#session-input"),
|
||||
output: document.querySelector("#output"),
|
||||
inputStatus: document.querySelector("#input-status"),
|
||||
convertStatus: document.querySelector("#convert-status"),
|
||||
outputStatus: document.querySelector("#output-status"),
|
||||
accounts: document.querySelector("#accounts"),
|
||||
issues: document.querySelector("#issues"),
|
||||
outputHelp: document.querySelector("#output-help"),
|
||||
inputPanel: document.querySelector("#input-panel"),
|
||||
btnCopy: document.querySelector("#btn-copy"),
|
||||
btnDownload: document.querySelector("#btn-download"),
|
||||
btnClear: document.querySelector("#btn-clear"),
|
||||
btnSample: document.querySelector("#btn-sample"),
|
||||
formatButtons: [...document.querySelectorAll("[data-format]")],
|
||||
};
|
||||
|
||||
const state = {
|
||||
format: "cpa",
|
||||
converted: [],
|
||||
ssoTokens: [],
|
||||
skipped: [],
|
||||
timer: null,
|
||||
outputText: "",
|
||||
};
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values) {
|
||||
for (const value of values) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value === "string" && value.trim() === "") continue;
|
||||
return typeof value === "string" ? value.trim() : value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setStatus(el, text, tone = "") {
|
||||
el.textContent = text;
|
||||
el.className = "status" + (tone ? ` ${tone}` : "");
|
||||
}
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const normalized = String(value).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
try {
|
||||
return decodeURIComponent(
|
||||
Array.from(atob(padded), (c) => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJwtPayload(token) {
|
||||
if (!token || typeof token !== "string" || token.split(".").length < 2) return null;
|
||||
try {
|
||||
const raw = decodeBase64Url(token.split(".")[1]);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeJwt(value) {
|
||||
return typeof value === "string" && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value.trim());
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const ms = value > 1e12 ? value : value * 1000;
|
||||
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(n) && /^\d+(\.\d+)?$/.test(value.trim())) {
|
||||
const ms = n > 1e12 ? n : n * 1000;
|
||||
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
const d = new Date(value);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unixSecondsFromValue(value) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return Math.floor(value > 1e12 ? value / 1000 : value);
|
||||
if (typeof value === "string") {
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(n) && /^\d+(\.\d+)?$/.test(value.trim())) return Math.floor(n > 1e12 ? n / 1000 : n);
|
||||
const d = new Date(value);
|
||||
if (!Number.isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stripUndefined(obj) {
|
||||
if (Array.isArray(obj)) return obj.map(stripUndefined).filter((v) => v !== undefined);
|
||||
if (!isPlainObject(obj)) return obj;
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
const next = stripUndefined(v);
|
||||
if (next === undefined) continue;
|
||||
if (isPlainObject(next) && !Object.keys(next).length) continue;
|
||||
out[k] = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractSsoFromCookies(cookies) {
|
||||
if (!Array.isArray(cookies)) return undefined;
|
||||
let sso = "";
|
||||
let fallback = "";
|
||||
for (const cookie of cookies) {
|
||||
if (!isPlainObject(cookie)) continue;
|
||||
const name = String(cookie.name || "");
|
||||
const value = String(cookie.value || "").trim();
|
||||
if (!value) continue;
|
||||
if (name === "sso" && !sso) sso = value;
|
||||
if (name === "sso-rw" && !fallback) fallback = value;
|
||||
}
|
||||
return sso || fallback || undefined;
|
||||
}
|
||||
|
||||
function collectOAuthLikeObjects(value, sourceName = "pasted-json") {
|
||||
const found = [];
|
||||
const visited = new WeakSet();
|
||||
function visit(item, path) {
|
||||
if (!isPlainObject(item) && !Array.isArray(item)) return;
|
||||
if (Array.isArray(item)) {
|
||||
item.forEach((child, i) => visit(child, `${path}[${i}]`));
|
||||
return;
|
||||
}
|
||||
if (visited.has(item)) return;
|
||||
visited.add(item);
|
||||
|
||||
const access = firstNonEmpty(
|
||||
item.access_token, item.accessToken,
|
||||
item.tokens?.access_token, item.tokens?.accessToken,
|
||||
item.credentials?.access_token, item.credentials?.accessToken,
|
||||
);
|
||||
const refresh = firstNonEmpty(
|
||||
item.refresh_token, item.refreshToken,
|
||||
item.tokens?.refresh_token, item.tokens?.refreshToken,
|
||||
item.credentials?.refresh_token, item.credentials?.refreshToken,
|
||||
);
|
||||
const idToken = firstNonEmpty(
|
||||
item.id_token, item.idToken,
|
||||
item.tokens?.id_token, item.tokens?.idToken,
|
||||
item.credentials?.id_token, item.credentials?.idToken,
|
||||
);
|
||||
const email = firstNonEmpty(item.email, item.user?.email, item.credentials?.email, item.name);
|
||||
const sub = firstNonEmpty(item.sub, item.credentials?.sub, item.subject);
|
||||
const sso = firstNonEmpty(item.sso, item.sso_token, item.ssoToken, extractSsoFromCookies(item.cookies));
|
||||
const isXai = item.type === "xai" || item.auth_kind === "oauth" || item.platform === "grok";
|
||||
const isSub2 = item.platform === "grok" && item.credentials;
|
||||
|
||||
if (access || (refresh && (email || sub || isXai || isSub2)) || (sso && (email || item.cookies))) {
|
||||
found.push({ value: item, sourceName, path, kind: access || refresh ? "oauth" : "sso" });
|
||||
return;
|
||||
}
|
||||
|
||||
// sub2api-data envelope
|
||||
if (Array.isArray(item.accounts)) {
|
||||
visit(item.accounts, `${path}.accounts`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(item)) {
|
||||
if (["access_token", "accessToken", "refresh_token", "id_token", "cookies"].includes(key)) continue;
|
||||
visit(child, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
visit(value, "$");
|
||||
return found;
|
||||
}
|
||||
|
||||
function parseTextInput(text) {
|
||||
const raw = String(text || "").trim();
|
||||
if (!raw) return { oauthSources: [], ssoTokens: [], skipped: [] };
|
||||
|
||||
// Try full JSON first
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const oauthSources = collectOAuthLikeObjects(parsed);
|
||||
const ssoTokens = [];
|
||||
// also collect pure sso fields if any
|
||||
for (const src of oauthSources) {
|
||||
if (src.kind === "sso") {
|
||||
const sso = firstNonEmpty(src.value.sso, src.value.sso_token, extractSsoFromCookies(src.value.cookies));
|
||||
if (sso) ssoTokens.push({ token: sso, email: firstNonEmpty(src.value.email, src.value.user?.email), path: src.path });
|
||||
}
|
||||
}
|
||||
if (oauthSources.length || ssoTokens.length) {
|
||||
return { oauthSources: oauthSources.filter((s) => s.kind === "oauth"), ssoTokens, skipped: [] };
|
||||
}
|
||||
} catch {
|
||||
// line mode below
|
||||
}
|
||||
|
||||
const oauthSources = [];
|
||||
const ssoTokens = [];
|
||||
const skipped = [];
|
||||
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// jsonl
|
||||
if (line.startsWith("{") || line.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const found = collectOAuthLikeObjects(parsed, `line-${index + 1}`);
|
||||
for (const src of found) {
|
||||
if (src.kind === "oauth") oauthSources.push(src);
|
||||
else {
|
||||
const sso = firstNonEmpty(src.value.sso, src.value.sso_token, extractSsoFromCookies(src.value.cookies));
|
||||
if (sso) ssoTokens.push({ token: sso, email: firstNonEmpty(src.value.email), path: src.path });
|
||||
}
|
||||
}
|
||||
if (!found.length) skipped.push({ path: `line-${index + 1}`, reason: "JSON 行未识别为 Grok 凭证" });
|
||||
return;
|
||||
} catch (error) {
|
||||
skipped.push({ path: `line-${index + 1}`, reason: `JSON 行解析失败:${error.message}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// accounts.txt: email:password:sso
|
||||
if (line.includes(":") && !looksLikeJwt(line)) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length >= 3) {
|
||||
const email = parts[0];
|
||||
const sso = parts.slice(2).join(":");
|
||||
if (looksLikeJwt(sso)) {
|
||||
ssoTokens.push({ token: sso.trim(), email: email.trim(), path: `accounts:${index + 1}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pure SSO jwt line
|
||||
if (looksLikeJwt(line)) {
|
||||
ssoTokens.push({ token: line.trim(), path: `sso:${index + 1}` });
|
||||
return;
|
||||
}
|
||||
|
||||
skipped.push({ path: `line-${index + 1}`, reason: "无法识别该行" });
|
||||
});
|
||||
|
||||
return { oauthSources, ssoTokens, skipped };
|
||||
}
|
||||
|
||||
function convertOAuthRecord(record, options = {}) {
|
||||
if (!isPlainObject(record)) throw new Error("记录不是对象");
|
||||
|
||||
// unwrap sub2api account
|
||||
const credsIn = isPlainObject(record.credentials) ? record.credentials : {};
|
||||
const accessToken = firstNonEmpty(
|
||||
record.access_token, record.accessToken,
|
||||
credsIn.access_token, credsIn.accessToken,
|
||||
record.tokens?.access_token,
|
||||
);
|
||||
if (!accessToken) throw new Error("缺少 access_token");
|
||||
|
||||
const refreshToken = firstNonEmpty(
|
||||
record.refresh_token, record.refreshToken,
|
||||
credsIn.refresh_token, credsIn.refreshToken,
|
||||
record.tokens?.refresh_token,
|
||||
);
|
||||
const idToken = firstNonEmpty(
|
||||
record.id_token, record.idToken,
|
||||
credsIn.id_token, credsIn.idToken,
|
||||
record.tokens?.id_token,
|
||||
);
|
||||
const tokenType = firstNonEmpty(record.token_type, credsIn.token_type, "Bearer");
|
||||
const accessClaims = parseJwtPayload(accessToken) || {};
|
||||
const idClaims = parseJwtPayload(idToken) || {};
|
||||
|
||||
const email = firstNonEmpty(
|
||||
record.email, credsIn.email, record.user?.email, record.name,
|
||||
idClaims.email, accessClaims.email, accessClaims.preferred_username,
|
||||
);
|
||||
const sub = firstNonEmpty(
|
||||
record.sub, credsIn.sub, record.subject,
|
||||
idClaims.sub, accessClaims.sub, accessClaims.principal_id,
|
||||
);
|
||||
|
||||
const expiresIn = firstNonEmpty(
|
||||
record.expires_in, credsIn.expires_in,
|
||||
accessClaims.exp && accessClaims.iat ? Number(accessClaims.exp) - Number(accessClaims.iat) : undefined,
|
||||
21600,
|
||||
);
|
||||
|
||||
const expiredIso = firstNonEmpty(
|
||||
normalizeTimestamp(record.expired),
|
||||
normalizeTimestamp(record.expires_at),
|
||||
normalizeTimestamp(credsIn.expires_at),
|
||||
normalizeTimestamp(record.expiresAt),
|
||||
accessClaims.exp ? normalizeTimestamp(accessClaims.exp) : undefined,
|
||||
);
|
||||
const expiresUnix = firstNonEmpty(
|
||||
unixSecondsFromValue(expiredIso),
|
||||
unixSecondsFromValue(accessClaims.exp),
|
||||
);
|
||||
|
||||
const lastRefresh = firstNonEmpty(
|
||||
normalizeTimestamp(record.last_refresh),
|
||||
normalizeTimestamp(credsIn.last_refresh),
|
||||
normalizeTimestamp(options.now || new Date()),
|
||||
);
|
||||
|
||||
const baseUrl = CLI_BASE; // free CLI 强制
|
||||
const hasRefresh = Boolean(refreshToken);
|
||||
const name = firstNonEmpty(email, sub && `grok-${String(sub).slice(0, 8)}`, options.sourceName, "Grok Account");
|
||||
|
||||
const headers = isPlainObject(record.headers) ? record.headers : {
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
"x-authenticateresponse": "authenticate-response",
|
||||
"x-grok-client-identifier": "grok-shell",
|
||||
"x-compaction-at": "400000",
|
||||
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
|
||||
};
|
||||
|
||||
const cpa = stripUndefined({
|
||||
type: "xai",
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
id_token: idToken,
|
||||
token_type: tokenType,
|
||||
expires_in: Number(expiresIn) || undefined,
|
||||
expired: expiredIso,
|
||||
last_refresh: lastRefresh,
|
||||
sub,
|
||||
base_url: baseUrl,
|
||||
token_endpoint: firstNonEmpty(record.token_endpoint, TOKEN_ENDPOINT),
|
||||
auth_kind: "oauth",
|
||||
headers,
|
||||
email,
|
||||
});
|
||||
|
||||
// sub2api credentials always keep token-level expires_at
|
||||
const credentials = stripUndefined({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
id_token: idToken,
|
||||
token_type: tokenType,
|
||||
expires_at: expiredIso,
|
||||
email,
|
||||
sub,
|
||||
base_url: baseUrl,
|
||||
});
|
||||
|
||||
// 有 refresh:不写账号级 expires_at,避免 6h auto-pause
|
||||
// 无 refresh:写 access exp + auto_pause
|
||||
const sub2apiAccount = stripUndefined({
|
||||
name,
|
||||
platform: "grok",
|
||||
type: "oauth",
|
||||
credentials,
|
||||
concurrency: Number.isFinite(Number(record.concurrency)) ? Number(record.concurrency) : 1,
|
||||
priority: Number.isFinite(Number(record.priority)) ? Number(record.priority) : 0,
|
||||
expires_at: hasRefresh ? undefined : expiresUnix,
|
||||
auto_pause_on_expired: hasRefresh ? undefined : (expiresUnix ? true : undefined),
|
||||
notes: firstNonEmpty(record.notes, record.note, record.remark),
|
||||
});
|
||||
|
||||
return {
|
||||
sourceName: options.sourceName,
|
||||
sourcePath: options.sourcePath,
|
||||
email,
|
||||
name,
|
||||
sub,
|
||||
hasRefresh,
|
||||
expiresAt: expiredIso,
|
||||
expiresUnix,
|
||||
cpa,
|
||||
sub2apiAccount,
|
||||
};
|
||||
}
|
||||
|
||||
function convertFromText(text) {
|
||||
const { oauthSources, ssoTokens, skipped } = parseTextInput(text);
|
||||
const converted = [];
|
||||
const allSkipped = [...skipped];
|
||||
const now = new Date();
|
||||
|
||||
oauthSources.forEach((item, index) => {
|
||||
try {
|
||||
converted.push(convertOAuthRecord(item.value, {
|
||||
now,
|
||||
sourceName: item.sourceName,
|
||||
sourcePath: item.path || `$[${index}]`,
|
||||
}));
|
||||
} catch (error) {
|
||||
allSkipped.push({
|
||||
path: item.path || `$[${index}]`,
|
||||
reason: error instanceof Error ? error.message : "无法转换",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// de-dupe sso
|
||||
const seenSso = new Set();
|
||||
const uniqueSso = [];
|
||||
for (const item of ssoTokens) {
|
||||
const token = String(item.token || "").trim();
|
||||
if (!token || seenSso.has(token)) continue;
|
||||
seenSso.add(token);
|
||||
uniqueSso.push(item);
|
||||
}
|
||||
|
||||
if (!converted.length && !uniqueSso.length) {
|
||||
allSkipped.push({ path: "$", reason: "未找到可识别的 Grok OAuth / SSO 记录" });
|
||||
}
|
||||
|
||||
return { converted, ssoTokens: uniqueSso, skipped: allSkipped };
|
||||
}
|
||||
|
||||
function buildSub2apiDocument(converted, now = new Date()) {
|
||||
return {
|
||||
type: "sub2api-data",
|
||||
version: 1,
|
||||
exported_at: normalizeTimestamp(now),
|
||||
proxies: [],
|
||||
accounts: converted.map((item) => item.sub2apiAccount),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBatchDocument(converted) {
|
||||
return { accounts: converted.map((item) => item.sub2apiAccount) };
|
||||
}
|
||||
|
||||
function buildSsoDocument(ssoTokens) {
|
||||
return {
|
||||
sso_tokens: ssoTokens.map((item) => item.token),
|
||||
// 便于对照;API 实际只吃 sso_tokens
|
||||
_meta: ssoTokens.map((item, i) => stripUndefined({
|
||||
index: i + 1,
|
||||
email: item.email,
|
||||
path: item.path,
|
||||
token_head: String(item.token).slice(0, 24) + "...",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCpaDocument(converted) {
|
||||
return converted.length === 1
|
||||
? converted[0].cpa
|
||||
: converted.map((item) => item.cpa);
|
||||
}
|
||||
|
||||
function buildOutputDocument() {
|
||||
const now = new Date();
|
||||
if (state.format === "cpa") {
|
||||
if (!state.converted.length) throw new Error("CPA 需要 OAuth 凭证(access_token)。纯 SSO 请切换到 sso-to-oauth");
|
||||
return buildCpaDocument(state.converted);
|
||||
}
|
||||
if (state.format === "sub2api") {
|
||||
if (!state.converted.length) throw new Error("sub2api 导入需要 OAuth 凭证。纯 SSO 请切换到 sso-to-oauth");
|
||||
return buildSub2apiDocument(state.converted, now);
|
||||
}
|
||||
if (state.format === "batch") {
|
||||
if (!state.converted.length) throw new Error("batch 建号需要 OAuth 凭证。纯 SSO 请切换到 sso-to-oauth");
|
||||
return buildBatchDocument(state.converted);
|
||||
}
|
||||
if (state.format === "sso") {
|
||||
if (!state.ssoTokens.length) {
|
||||
// also allow extracting nothing useful message
|
||||
throw new Error("未找到 SSO token。可粘贴 grok.txt / accounts.txt / auth-sessions.jsonl");
|
||||
}
|
||||
return buildSsoDocument(state.ssoTokens);
|
||||
}
|
||||
return buildSub2apiDocument(state.converted, now);
|
||||
}
|
||||
|
||||
function renderAccounts() {
|
||||
if (!state.converted.length && !state.ssoTokens.length) {
|
||||
els.accounts.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const oauthCards = state.converted.map((item) => `
|
||||
<div class="card">
|
||||
<div class="title">${escapeHtml(item.name || item.email || "Grok Account")}</div>
|
||||
<div class="meta">
|
||||
email=${escapeHtml(item.email || "-")}
|
||||
· sub=${escapeHtml(item.sub || "-")}
|
||||
· refresh=${item.hasRefresh ? "yes" : "no"}
|
||||
· exp=${escapeHtml(item.expiresAt || "-")}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
const ssoCards = state.ssoTokens.slice(0, 20).map((item, i) => `
|
||||
<div class="card">
|
||||
<div class="title">SSO #${i + 1}${item.email ? " · " + escapeHtml(item.email) : ""}</div>
|
||||
<div class="meta">${escapeHtml(String(item.token).slice(0, 48))}...</div>
|
||||
</div>
|
||||
`).join("");
|
||||
els.accounts.innerHTML = oauthCards + ssoCards;
|
||||
}
|
||||
|
||||
function renderIssues() {
|
||||
if (!state.skipped.length) {
|
||||
els.issues.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
els.issues.innerHTML = state.skipped.map((item) => `
|
||||
<div class="card">
|
||||
<div class="title">跳过 ${escapeHtml(item.path || "?")}</div>
|
||||
<div class="meta">${escapeHtml(item.reason || "")}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function updateOutput() {
|
||||
els.outputHelp.textContent = HELP[state.format] || "";
|
||||
try {
|
||||
if (!els.input.value.trim()) {
|
||||
state.outputText = "";
|
||||
els.output.value = "";
|
||||
els.btnCopy.disabled = true;
|
||||
els.btnDownload.disabled = true;
|
||||
setStatus(els.outputStatus, "空");
|
||||
setStatus(els.convertStatus, "未转换");
|
||||
return;
|
||||
}
|
||||
const doc = buildOutputDocument();
|
||||
state.outputText = JSON.stringify(doc, null, 2);
|
||||
els.output.value = state.outputText;
|
||||
els.btnCopy.disabled = false;
|
||||
els.btnDownload.disabled = false;
|
||||
const count = state.format === "sso" ? state.ssoTokens.length : state.converted.length;
|
||||
setStatus(els.outputStatus, `已生成 · ${count} 条`, "ok");
|
||||
setStatus(els.convertStatus, `OAuth ${state.converted.length} / SSO ${state.ssoTokens.length}`, "ok");
|
||||
} catch (error) {
|
||||
state.outputText = "";
|
||||
els.output.value = "";
|
||||
els.btnCopy.disabled = true;
|
||||
els.btnDownload.disabled = true;
|
||||
setStatus(els.outputStatus, error instanceof Error ? error.message : "生成失败", "err");
|
||||
setStatus(els.convertStatus, `OAuth ${state.converted.length} / SSO ${state.ssoTokens.length}`, state.converted.length || state.ssoTokens.length ? "warn" : "err");
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleConvert() {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = setTimeout(() => {
|
||||
try {
|
||||
const result = convertFromText(els.input.value);
|
||||
state.converted = result.converted;
|
||||
state.ssoTokens = result.ssoTokens;
|
||||
state.skipped = result.skipped;
|
||||
const total = state.converted.length + state.ssoTokens.length;
|
||||
setStatus(
|
||||
els.inputStatus,
|
||||
total ? `识别 OAuth ${state.converted.length} · SSO ${state.ssoTokens.length}` : "未识别到凭证",
|
||||
total ? "ok" : "warn",
|
||||
);
|
||||
renderAccounts();
|
||||
renderIssues();
|
||||
updateOutput();
|
||||
} catch (error) {
|
||||
state.converted = [];
|
||||
state.ssoTokens = [];
|
||||
state.skipped = [{ path: "$", reason: error instanceof Error ? error.message : "转换失败" }];
|
||||
setStatus(els.inputStatus, error instanceof Error ? error.message : "转换失败", "err");
|
||||
renderAccounts();
|
||||
renderIssues();
|
||||
updateOutput();
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function downloadOutput() {
|
||||
if (!state.outputText) return;
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const name = `grok-${state.format}-${stamp}.json`;
|
||||
const blob = new Blob([state.outputText], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyOutput() {
|
||||
if (!state.outputText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(state.outputText);
|
||||
setStatus(els.outputStatus, "已复制", "ok");
|
||||
} catch {
|
||||
els.output.select();
|
||||
document.execCommand("copy");
|
||||
setStatus(els.outputStatus, "已复制", "ok");
|
||||
}
|
||||
}
|
||||
|
||||
function setFormat(format) {
|
||||
state.format = format;
|
||||
els.formatButtons.forEach((btn) => {
|
||||
btn.setAttribute("aria-pressed", btn.dataset.format === format ? "true" : "false");
|
||||
});
|
||||
updateOutput();
|
||||
}
|
||||
|
||||
function fillSample() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + 21600;
|
||||
// fake jwt-like payload (not signed; only for local demo decode)
|
||||
const header = btoa(JSON.stringify({ alg: "none", typ: "JWT" })).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
const payload = btoa(JSON.stringify({
|
||||
sub: "55c526cb-9f70-4486-a232-3710e6f3df38",
|
||||
email: "demo@example.com",
|
||||
iat: now,
|
||||
exp,
|
||||
})).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
const access = `${header}.${payload}.demo`;
|
||||
const sample = {
|
||||
type: "xai",
|
||||
access_token: access,
|
||||
refresh_token: "demo-refresh-token-replace-me",
|
||||
id_token: access,
|
||||
token_type: "Bearer",
|
||||
expires_in: 21600,
|
||||
expired: new Date(exp * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||
last_refresh: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||
sub: "55c526cb-9f70-4486-a232-3710e6f3df38",
|
||||
base_url: "https://api.x.ai/v1",
|
||||
token_endpoint: TOKEN_ENDPOINT,
|
||||
auth_kind: "oauth",
|
||||
email: "demo@example.com",
|
||||
};
|
||||
els.input.value = JSON.stringify(sample, null, 2);
|
||||
scheduleConvert();
|
||||
}
|
||||
|
||||
// events
|
||||
els.input.addEventListener("input", scheduleConvert);
|
||||
els.btnClear.addEventListener("click", () => {
|
||||
els.input.value = "";
|
||||
scheduleConvert();
|
||||
});
|
||||
els.btnSample.addEventListener("click", fillSample);
|
||||
els.btnCopy.addEventListener("click", copyOutput);
|
||||
els.btnDownload.addEventListener("click", downloadOutput);
|
||||
els.formatButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => setFormat(btn.dataset.format));
|
||||
});
|
||||
|
||||
// drag-drop
|
||||
["dragenter", "dragover"].forEach((type) => {
|
||||
els.inputPanel.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
els.inputPanel.classList.add("drop-active");
|
||||
});
|
||||
});
|
||||
["dragleave", "drop"].forEach((type) => {
|
||||
els.inputPanel.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
if (type === "drop") {
|
||||
const files = [...(event.dataTransfer?.files || [])];
|
||||
if (!files.length) return;
|
||||
Promise.all(files.map((file) => file.text())).then((parts) => {
|
||||
const existing = els.input.value.trim();
|
||||
const incoming = parts.join("\n");
|
||||
els.input.value = existing ? `${existing}\n${incoming}` : incoming;
|
||||
scheduleConvert();
|
||||
});
|
||||
}
|
||||
els.inputPanel.classList.remove("drop-active");
|
||||
});
|
||||
});
|
||||
|
||||
setFormat("cpa");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
"""Developer-only analysis and stress tools."""
|
||||
@@ -1,419 +0,0 @@
|
||||
"""
|
||||
Parse real runtime monitor logs from both CSP and legacy state-machine runs.
|
||||
|
||||
The analyzer is intentionally read-only. It does not tune parameters or modify
|
||||
runtime state; it only turns production logs into comparable stage rates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
_CSP_RE = re.compile(
|
||||
r"^\[\*\] T:(?P<t>\d+) Q:(?P<q>\d+) phys:(?P<phys>\d+) "
|
||||
r"(?:p_send:(?P<p_send>\d+) )?t_slot:(?P<t_slot>\d+) q_slot:(?P<q_slot>\d+) q_pend:(?P<q_pend>\d+)"
|
||||
r"(?P<rest>.*)rate:(?P<rate>[0-9.]+)/min #(?P<ok>\d+)"
|
||||
)
|
||||
|
||||
_STATE_RE = re.compile(
|
||||
r"^\[\*\] slots:(?P<slots>\d+)/(?P<max_slots>\d+) act:(?P<active>\d+) "
|
||||
r"cpu:(?P<cpu>[0-9.]+)% avg:(?P<cpu_avg>[0-9.]+)%/(?P<cpu_target>[0-9.]+) "
|
||||
r"mem:(?P<mem>\d+)M T:(?P<t>\d+) Q:(?P<q>\d+) "
|
||||
r"sent:(?P<q_sent>\d+) got:(?P<q_ret>\d+)\((?P<q_hit>[0-9.]+)%\) "
|
||||
r"rate:(?P<rate>[0-9.]+)/min #(?P<ok>\d+)"
|
||||
)
|
||||
|
||||
_SOLVER_TIMELINE_PREFIX = "[solver_timeline] "
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonitorRow:
|
||||
kind: str
|
||||
t: int
|
||||
q: int
|
||||
ok: int
|
||||
rate: float
|
||||
phys: int | None = None
|
||||
p_send: int | None = None
|
||||
t_slot: int | None = None
|
||||
q_slot: int | None = None
|
||||
q_pend: int | None = None
|
||||
p_batch: float | None = None
|
||||
t_prog: int | None = None
|
||||
q_inflight: int | None = None
|
||||
t_prod: int | None = None
|
||||
q_sent: int | None = None
|
||||
q_ret: int | None = None
|
||||
q_adm: int | None = None
|
||||
pair: int | None = None
|
||||
fail: int | None = None
|
||||
s_phys_wait: float | None = None
|
||||
s_phys_hold: float | None = None
|
||||
p_phys_wait: float | None = None
|
||||
p_phys_hold: float | None = None
|
||||
c_phys_wait: float | None = None
|
||||
c_phys_hold: float | None = None
|
||||
p_email_create: float | None = None
|
||||
p_page_prepare: float | None = None
|
||||
p_send_stage: float | None = None
|
||||
c_page_acquire: float | None = None
|
||||
c_verify: float | None = None
|
||||
c_register: float | None = None
|
||||
c_hot_hits: int | None = None
|
||||
c_hot_misses: int | None = None
|
||||
solver_goto: float | None = None
|
||||
solver_inject: float | None = None
|
||||
solver_initial: float | None = None
|
||||
solver_click: float | None = None
|
||||
solver_wait: float | None = None
|
||||
solver_reuse: float | None = None
|
||||
solver_visible: float | None = None
|
||||
slots: int | None = None
|
||||
max_slots: int | None = None
|
||||
active: int | None = None
|
||||
|
||||
@property
|
||||
def elapsed_min(self) -> float | None:
|
||||
if self.ok <= 0 or self.rate <= 0:
|
||||
return None
|
||||
return self.ok / self.rate
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolverTimeline:
|
||||
events: list[dict]
|
||||
|
||||
|
||||
def _int_field(rest: str, name: str) -> int | None:
|
||||
match = re.search(rf"\b{name}:(\d+)", rest)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _float_field(rest: str, name: str) -> float | None:
|
||||
match = re.search(rf"\b{name}:([0-9.]+)", rest)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _float_pair_field(rest: str, name: str) -> tuple[float | None, float | None]:
|
||||
match = re.search(rf"\b{name}:([0-9.]+)/([0-9.]+)", rest)
|
||||
if not match:
|
||||
return None, None
|
||||
return float(match.group(1)), float(match.group(2))
|
||||
|
||||
|
||||
def _float_triple_field(rest: str, name: str) -> tuple[float | None, float | None, float | None]:
|
||||
match = re.search(rf"\b{name}:([0-9.]+)/([0-9.]+)/([0-9.]+)", rest)
|
||||
if not match:
|
||||
return None, None, None
|
||||
return float(match.group(1)), float(match.group(2)), float(match.group(3))
|
||||
|
||||
|
||||
def _int_pair_field(rest: str, name: str) -> tuple[int | None, int | None]:
|
||||
match = re.search(rf"\b{name}:(\d+)/(\d+)", rest)
|
||||
if not match:
|
||||
return None, None
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
def parse_monitor_lines(text_or_lines: str | Iterable[str]) -> list[MonitorRow]:
|
||||
if isinstance(text_or_lines, str):
|
||||
lines = text_or_lines.splitlines()
|
||||
else:
|
||||
lines = list(text_or_lines)
|
||||
|
||||
rows: list[MonitorRow] = []
|
||||
for line in lines:
|
||||
csp = _CSP_RE.match(line)
|
||||
if csp:
|
||||
rest = csp.group("rest")
|
||||
s_phys_wait, s_phys_hold = _float_pair_field(rest, "s_phys")
|
||||
p_phys_wait, p_phys_hold = _float_pair_field(rest, "p_phys")
|
||||
c_phys_wait, c_phys_hold = _float_pair_field(rest, "c_phys")
|
||||
p_email_create, p_page_prepare, p_send_stage = _float_triple_field(rest, "p_stage")
|
||||
c_page_acquire, c_verify, c_register = _float_triple_field(rest, "c_stage")
|
||||
c_hot_hits, c_hot_misses = _int_pair_field(rest, "c_hot")
|
||||
rows.append(
|
||||
MonitorRow(
|
||||
kind="csp",
|
||||
t=int(csp.group("t")),
|
||||
q=int(csp.group("q")),
|
||||
ok=int(csp.group("ok")),
|
||||
rate=float(csp.group("rate")),
|
||||
phys=int(csp.group("phys")),
|
||||
p_send=int(csp.group("p_send")) if csp.group("p_send") is not None else None,
|
||||
t_slot=int(csp.group("t_slot")),
|
||||
q_slot=int(csp.group("q_slot")),
|
||||
q_pend=int(csp.group("q_pend")),
|
||||
p_batch=_float_field(rest, "p_batch"),
|
||||
t_prog=_int_field(rest, "t_prog"),
|
||||
q_inflight=_int_field(rest, "q_inflight"),
|
||||
t_prod=_int_field(rest, "t_prod"),
|
||||
q_sent=_int_field(rest, "q_sent"),
|
||||
q_ret=_int_field(rest, "q_ret"),
|
||||
q_adm=_int_field(rest, "q_adm"),
|
||||
pair=_int_field(rest, "pair"),
|
||||
fail=_int_field(rest, "fail"),
|
||||
s_phys_wait=s_phys_wait,
|
||||
s_phys_hold=s_phys_hold,
|
||||
p_phys_wait=p_phys_wait,
|
||||
p_phys_hold=p_phys_hold,
|
||||
c_phys_wait=c_phys_wait,
|
||||
c_phys_hold=c_phys_hold,
|
||||
p_email_create=p_email_create,
|
||||
p_page_prepare=p_page_prepare,
|
||||
p_send_stage=p_send_stage,
|
||||
c_page_acquire=c_page_acquire,
|
||||
c_verify=c_verify,
|
||||
c_register=c_register,
|
||||
c_hot_hits=c_hot_hits,
|
||||
c_hot_misses=c_hot_misses,
|
||||
solver_goto=_float_field(rest, "solver_goto"),
|
||||
solver_inject=_float_field(rest, "solver_inject"),
|
||||
solver_initial=_float_field(rest, "solver_initial"),
|
||||
solver_click=_float_field(rest, "solver_click"),
|
||||
solver_wait=_float_field(rest, "solver_wait"),
|
||||
solver_reuse=_float_field(rest, "solver_reuse"),
|
||||
solver_visible=_float_field(rest, "solver_visible"),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
state = _STATE_RE.match(line)
|
||||
if state:
|
||||
rows.append(
|
||||
MonitorRow(
|
||||
kind="state_machine",
|
||||
t=int(state.group("t")),
|
||||
q=int(state.group("q")),
|
||||
ok=int(state.group("ok")),
|
||||
rate=float(state.group("rate")),
|
||||
q_sent=int(state.group("q_sent")),
|
||||
q_ret=int(state.group("q_ret")),
|
||||
slots=int(state.group("slots")),
|
||||
max_slots=int(state.group("max_slots")),
|
||||
active=int(state.group("active")),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _rate_delta(rows: list[MonitorRow], attr: str) -> float | None:
|
||||
candidates = [r for r in rows if r.elapsed_min is not None and getattr(r, attr) is not None]
|
||||
if len(candidates) < 2:
|
||||
return None
|
||||
first = candidates[0]
|
||||
last = candidates[-1]
|
||||
dt = (last.elapsed_min or 0) - (first.elapsed_min or 0)
|
||||
if dt <= 0:
|
||||
return None
|
||||
return (getattr(last, attr) - getattr(first, attr)) / dt
|
||||
|
||||
|
||||
def _leader(values: dict[str, float | None]) -> str | None:
|
||||
if any(value is None for value in values.values()):
|
||||
return None
|
||||
return max(values, key=lambda key: values[key] or 0)
|
||||
|
||||
|
||||
def _avg(values: list[float]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
return round(sum(values) / len(values), 2)
|
||||
|
||||
|
||||
def _ratio(numerator: int, denominator: int) -> float | None:
|
||||
if denominator <= 0:
|
||||
return None
|
||||
return round(numerator / denominator, 3)
|
||||
|
||||
|
||||
def summarize_monitor_rows(rows: list[MonitorRow], recent_count: int = 6) -> dict[str, float | int | str | None]:
|
||||
if not rows:
|
||||
return {"rows": 0}
|
||||
|
||||
last = rows[-1]
|
||||
recent = rows[-recent_count:]
|
||||
summary: dict[str, float | int | str | None] = {
|
||||
"rows": len(rows),
|
||||
"kind": last.kind,
|
||||
"last_ok": last.ok,
|
||||
"last_cumulative_rate": last.rate,
|
||||
"last_q_minus_t": last.q - last.t,
|
||||
"last_phys": last.phys,
|
||||
"last_p_send_sem": last.p_send,
|
||||
"last_t_slot": last.t_slot,
|
||||
"last_q_slot": last.q_slot,
|
||||
"last_q_pend": last.q_pend,
|
||||
"last_p_batch": last.p_batch,
|
||||
"last_t_prog": last.t_prog,
|
||||
"last_q_inflight": last.q_inflight,
|
||||
"last_q_return_minus_t_prod": (
|
||||
last.q_ret - last.t_prod
|
||||
if last.q_ret is not None and last.t_prod is not None
|
||||
else None
|
||||
),
|
||||
"last_slots": last.slots,
|
||||
"last_t_prod": last.t_prod,
|
||||
"last_q_ret": last.q_ret,
|
||||
"last_q_adm": last.q_adm,
|
||||
"last_pair": last.pair,
|
||||
"last_fail": last.fail,
|
||||
"last_s_phys_wait": last.s_phys_wait,
|
||||
"last_s_phys_hold": last.s_phys_hold,
|
||||
"last_p_phys_wait": last.p_phys_wait,
|
||||
"last_p_phys_hold": last.p_phys_hold,
|
||||
"last_c_phys_wait": last.c_phys_wait,
|
||||
"last_c_phys_hold": last.c_phys_hold,
|
||||
"last_p_email_create": last.p_email_create,
|
||||
"last_p_page_prepare": last.p_page_prepare,
|
||||
"last_p_send": last.p_send_stage,
|
||||
"last_c_page_acquire": last.c_page_acquire,
|
||||
"last_c_verify": last.c_verify,
|
||||
"last_c_register": last.c_register,
|
||||
"last_c_hot_hits": last.c_hot_hits,
|
||||
"last_c_hot_misses": last.c_hot_misses,
|
||||
"last_physical_hold_leader": _leader({
|
||||
"s": last.s_phys_hold,
|
||||
"p": last.p_phys_hold,
|
||||
"c": last.c_phys_hold,
|
||||
}),
|
||||
"last_physical_wait_leader": _leader({
|
||||
"s": last.s_phys_wait,
|
||||
"p": last.p_phys_wait,
|
||||
"c": last.c_phys_wait,
|
||||
}),
|
||||
"last_solver_goto": last.solver_goto,
|
||||
"last_solver_inject": last.solver_inject,
|
||||
"last_solver_initial": last.solver_initial,
|
||||
"last_solver_click": last.solver_click,
|
||||
"last_solver_wait": last.solver_wait,
|
||||
"last_solver_reuse": last.solver_reuse,
|
||||
"last_solver_visible": last.solver_visible,
|
||||
"recent_ok_per_min": _rate_delta(recent, "ok"),
|
||||
"recent_t_prod_per_min": _rate_delta(recent, "t_prod"),
|
||||
"recent_q_ret_per_min": _rate_delta(recent, "q_ret"),
|
||||
"recent_pair_per_min": _rate_delta(recent, "pair"),
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
def parse_solver_timelines(text_or_lines: str | Iterable[str]) -> list[SolverTimeline]:
|
||||
if isinstance(text_or_lines, str):
|
||||
lines = text_or_lines.splitlines()
|
||||
else:
|
||||
lines = list(text_or_lines)
|
||||
|
||||
timelines: list[SolverTimeline] = []
|
||||
for line in lines:
|
||||
if not line.startswith(_SOLVER_TIMELINE_PREFIX):
|
||||
continue
|
||||
payload = line[len(_SOLVER_TIMELINE_PREFIX):]
|
||||
try:
|
||||
events = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(events, list):
|
||||
timelines.append(SolverTimeline(events=[e for e in events if isinstance(e, dict)]))
|
||||
return timelines
|
||||
|
||||
|
||||
def _last_page_trace(events: list[dict]) -> dict | None:
|
||||
for event in reversed(events):
|
||||
page_trace = event.get("page_trace")
|
||||
if isinstance(page_trace, dict):
|
||||
return page_trace
|
||||
return None
|
||||
|
||||
|
||||
def summarize_solver_timelines(timelines: list[SolverTimeline]) -> dict[str, float | int | None]:
|
||||
if not timelines:
|
||||
return {"solver_timeline_count": 0}
|
||||
|
||||
ok_count = 0
|
||||
click_calls: list[float] = []
|
||||
click_move_ms: list[float] = []
|
||||
click_down_up_ms: list[float] = []
|
||||
render_to_token_ms: list[float] = []
|
||||
token_write_to_poll_done_ms: list[float] = []
|
||||
poll_attempts: list[float] = []
|
||||
poll_read_avg_ms: list[float] = []
|
||||
click_before_count = 0
|
||||
center_iframe_hits = 0
|
||||
turnstile_iframe_seen = 0
|
||||
widget_seen = 0
|
||||
|
||||
for timeline in timelines:
|
||||
events = timeline.events
|
||||
page_trace = _last_page_trace(events)
|
||||
if page_trace:
|
||||
render_called = page_trace.get("render_called_at")
|
||||
token_written = page_trace.get("token_written_at")
|
||||
if isinstance(render_called, (int, float)) and isinstance(token_written, (int, float)):
|
||||
render_to_token_ms.append(float(token_written) - float(render_called))
|
||||
|
||||
for event in events:
|
||||
if event.get("event") == "click_before":
|
||||
click_before_count += 1
|
||||
dom = event.get("dom") if isinstance(event.get("dom"), dict) else {}
|
||||
widget = dom.get("widget") if isinstance(dom.get("widget"), dict) else {}
|
||||
center = dom.get("element_at_center") if isinstance(dom.get("element_at_center"), dict) else {}
|
||||
if widget.get("present") and widget.get("visible"):
|
||||
widget_seen += 1
|
||||
if center.get("is_iframe"):
|
||||
center_iframe_hits += 1
|
||||
if (dom.get("turnstile_iframe_count") or 0) > 0:
|
||||
turnstile_iframe_seen += 1
|
||||
|
||||
if event.get("event") == "click_after":
|
||||
call_ms = event.get("click_call_ms")
|
||||
if isinstance(call_ms, (int, float)):
|
||||
click_calls.append(float(call_ms))
|
||||
trace = event.get("click_trace") if isinstance(event.get("click_trace"), dict) else {}
|
||||
move1 = trace.get("mouse_move1_ms")
|
||||
move2 = trace.get("mouse_move2_ms")
|
||||
down = trace.get("mouse_down_ms")
|
||||
up = trace.get("mouse_up_ms")
|
||||
move_total = sum(float(v) for v in (move1, move2) if isinstance(v, (int, float)))
|
||||
down_up_total = sum(float(v) for v in (down, up) if isinstance(v, (int, float)))
|
||||
if move_total:
|
||||
click_move_ms.append(move_total)
|
||||
if down_up_total:
|
||||
click_down_up_ms.append(down_up_total)
|
||||
|
||||
if event.get("event") == "poll_done":
|
||||
if event.get("ok"):
|
||||
ok_count += 1
|
||||
attempts = event.get("poll_attempts")
|
||||
if isinstance(attempts, (int, float)):
|
||||
poll_attempts.append(float(attempts))
|
||||
read_avg = event.get("poll_read_ms_avg")
|
||||
if isinstance(read_avg, (int, float)):
|
||||
poll_read_avg_ms.append(float(read_avg))
|
||||
page_trace = event.get("page_trace") if isinstance(event.get("page_trace"), dict) else {}
|
||||
token_written = page_trace.get("token_written_at")
|
||||
event_t = event.get("t")
|
||||
created_at = page_trace.get("created_at")
|
||||
if all(isinstance(v, (int, float)) for v in (token_written, event_t, created_at)):
|
||||
token_write_to_poll_done_ms.append((float(event_t) * 1000.0) - (float(token_written) - float(created_at)))
|
||||
|
||||
return {
|
||||
"solver_timeline_count": len(timelines),
|
||||
"ok_count": ok_count,
|
||||
"avg_click_call_ms": _avg(click_calls),
|
||||
"avg_click_mouse_move_ms": _avg(click_move_ms),
|
||||
"avg_click_down_up_ms": _avg(click_down_up_ms),
|
||||
"avg_render_to_token_ms": _avg(render_to_token_ms),
|
||||
"avg_token_write_to_poll_done_ms": _avg(token_write_to_poll_done_ms),
|
||||
"avg_poll_attempts": _avg(poll_attempts),
|
||||
"avg_poll_read_ms": _avg(poll_read_avg_ms),
|
||||
"widget_seen_ratio": _ratio(widget_seen, click_before_count),
|
||||
"center_iframe_hit_ratio": _ratio(center_iframe_hits, click_before_count),
|
||||
"turnstile_iframe_seen_ratio": _ratio(turnstile_iframe_seen, click_before_count),
|
||||
}
|
||||
|
||||
|
||||
def analyze_text(text: str) -> dict[str, float | int | str | None]:
|
||||
return summarize_monitor_rows(parse_monitor_lines(text))
|
||||
@@ -0,0 +1,77 @@
|
||||
// Turnstile Patch - 仅做指纹修补,不自动狂点。
|
||||
// xAI 资料页是 managed/invisible Turnstile:iframe 0x0,token 会自动生成;
|
||||
// 每 500ms 点 checkbox 反而会打断校验。
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// 0. 伪造 MouseEvent screen 坐标
|
||||
try {
|
||||
function _rnd(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
var _sx = _rnd(800, 1400);
|
||||
var _sy = _rnd(300, 800);
|
||||
Object.defineProperty(MouseEvent.prototype, "screenX", {
|
||||
configurable: true,
|
||||
get: function () {
|
||||
return _sx + (this.clientX || 0);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(MouseEvent.prototype, "screenY", {
|
||||
configurable: true,
|
||||
get: function () {
|
||||
return _sy + (this.clientY || 0);
|
||||
},
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// 1. 隐藏 navigator.webdriver
|
||||
try {
|
||||
Object.defineProperty(navigator, "webdriver", {
|
||||
get: function () {
|
||||
return false;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// 2. 清理 chrome.runtime 自动化痕迹
|
||||
try {
|
||||
if (window.chrome && window.chrome.runtime) {
|
||||
delete window.chrome.runtime.onConnect;
|
||||
delete window.chrome.runtime.onMessage;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// 3. permissions.query
|
||||
try {
|
||||
var origQuery = navigator.permissions.query.bind(navigator.permissions);
|
||||
navigator.permissions.query = function (params) {
|
||||
if (params.name === "notifications") {
|
||||
return Promise.resolve({ state: Notification.permission });
|
||||
}
|
||||
return origQuery(params);
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
// 4. plugins
|
||||
try {
|
||||
Object.defineProperty(navigator, "plugins", {
|
||||
get: function () {
|
||||
return [1, 2, 3, 4, 5];
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// 5. languages
|
||||
try {
|
||||
Object.defineProperty(navigator, "languages", {
|
||||
get: function () {
|
||||
return ["en-US", "en"];
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
} catch (e) {}
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Turnstile Patch",
|
||||
"version": "1.0.0",
|
||||
"description": "Patch browser automation detection for Turnstile",
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_start",
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"permissions": ["activeTab"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Standalone xAI OAuth Device Flow enroller."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,111 +0,0 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Settings
|
||||
from .coordinator import EnrollmentCoordinator
|
||||
from .executors import HTTPProbeExecutor, PlaywrightExecutor
|
||||
from .protocol import XAIProfile, XAIProtocol
|
||||
from .sinks import CPAAuthFileSink
|
||||
from .sources import FileSourceAdapter, SQLiteSourceAdapter
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description="Bounded xAI OAuth Device Flow enroller")
|
||||
parser.add_argument("--source", choices=("file", "sqlite"))
|
||||
parser.add_argument("--target", type=int)
|
||||
parser.add_argument("--concurrency", type=int)
|
||||
parser.add_argument("--retry-attempts", type=int)
|
||||
parser.add_argument("--executor", choices=("http", "playwright"))
|
||||
parser.add_argument("--sink", choices=("cpa",))
|
||||
return parser
|
||||
|
||||
|
||||
async def main_async(args=None):
|
||||
parsed = build_parser().parse_args(args)
|
||||
env = dict()
|
||||
if parsed.source:
|
||||
env["XAI_ENROLLER_SOURCE_KIND"] = parsed.source
|
||||
if parsed.target is not None:
|
||||
env["XAI_ENROLLER_TARGET"] = str(parsed.target)
|
||||
if parsed.concurrency is not None:
|
||||
env["XAI_ENROLLER_CONCURRENCY"] = str(parsed.concurrency)
|
||||
if parsed.retry_attempts is not None:
|
||||
env["XAI_ENROLLER_RETRY_ATTEMPTS"] = str(parsed.retry_attempts)
|
||||
if parsed.executor:
|
||||
env["XAI_ENROLLER_AUTH_EXECUTOR"] = parsed.executor
|
||||
if parsed.sink:
|
||||
env["XAI_ENROLLER_SINK"] = parsed.sink
|
||||
import os
|
||||
|
||||
merged = dict(os.environ)
|
||||
merged.update(env)
|
||||
settings = Settings.from_environ(merged)
|
||||
if settings.source_kind == "remote":
|
||||
raise ValueError("remote source requires the local auth service entrypoint")
|
||||
source = (
|
||||
FileSourceAdapter(settings.source_file)
|
||||
if settings.source_kind == "file"
|
||||
else SQLiteSourceAdapter(settings.source_db, settings.source_salt)
|
||||
)
|
||||
|
||||
# CF prewarm (external FlareSolverr) + register-side proxy for httpx/Playwright
|
||||
try:
|
||||
from grok_register.clearance import (
|
||||
format_prewarm_log,
|
||||
httpx_proxy_mounts,
|
||||
prewarm_clearance,
|
||||
)
|
||||
|
||||
print(format_prewarm_log(prewarm_clearance()))
|
||||
proxy_url = httpx_proxy_mounts()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[clearance] skip: {exc}")
|
||||
proxy_url = None
|
||||
|
||||
client = httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
|
||||
protocol = XAIProtocol(
|
||||
client,
|
||||
XAIProfile.default(),
|
||||
default_poll_interval=settings.poll_interval,
|
||||
)
|
||||
executor = (
|
||||
HTTPProbeExecutor(client)
|
||||
if settings.executor == "http"
|
||||
else PlaywrightExecutor(settings.concurrency)
|
||||
)
|
||||
sink = None
|
||||
sink_client = None
|
||||
if settings.sink == "cpa":
|
||||
sink_client = (
|
||||
httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
|
||||
)
|
||||
sink = CPAAuthFileSink(settings.cpa_base_url, settings.cpa_management_secret, sink_client)
|
||||
try:
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=source,
|
||||
protocol=protocol,
|
||||
executor=executor,
|
||||
sink=sink,
|
||||
ledger_path=settings.ledger_path,
|
||||
ledger_salt=settings.source_salt,
|
||||
concurrency=settings.concurrency,
|
||||
timeout=settings.timeout_sec,
|
||||
retry_attempts=settings.retry_attempts,
|
||||
)
|
||||
results = await coordinator.run(settings.target)
|
||||
for index, result in enumerate(results, 1):
|
||||
print(f"job {index}: {result.status.value} ({result.reason_code})")
|
||||
finally:
|
||||
await client.aclose()
|
||||
if sink_client:
|
||||
await sink_client.aclose()
|
||||
|
||||
|
||||
def main():
|
||||
asyncio.run(main_async())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,139 +0,0 @@
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _int(env, key, default):
|
||||
try:
|
||||
return int(env.get(key, str(default)))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{key} must be an integer") from exc
|
||||
|
||||
|
||||
def _float(env, key, default):
|
||||
try:
|
||||
return float(env.get(key, str(default)))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{key} must be numeric") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
source_kind: str
|
||||
source_file: Path | None
|
||||
source_db: Path | None
|
||||
source_salt: bytes
|
||||
ledger_path: Path
|
||||
target: int = 1
|
||||
concurrency: int = 1
|
||||
retry_attempts: int = 0
|
||||
timeout_sec: float = 1800.0
|
||||
poll_interval: float = 5.0
|
||||
executor: str = "http"
|
||||
sink: str | None = None
|
||||
cpa_base_url: str | None = None
|
||||
cpa_management_secret: str | None = None
|
||||
local_auth_dir: Path | None = None
|
||||
|
||||
@classmethod
|
||||
def from_environ(cls, env=None):
|
||||
env = dict(os.environ if env is None else env)
|
||||
source_kind = env.get("XAI_ENROLLER_SOURCE_KIND", "file")
|
||||
if source_kind not in {"file", "sqlite", "remote"}:
|
||||
raise ValueError("source kind must be file, sqlite, or remote")
|
||||
source_file = Path(env["XAI_ENROLLER_SOURCE_FILE"]) if env.get("XAI_ENROLLER_SOURCE_FILE") else None
|
||||
source_db = Path(env["XAI_ENROLLER_SOURCE_DB"]) if env.get("XAI_ENROLLER_SOURCE_DB") else None
|
||||
if source_kind == "file" and source_file is None:
|
||||
raise ValueError("source file is required")
|
||||
if source_kind == "sqlite" and source_db is None:
|
||||
raise ValueError("source db is required")
|
||||
salt = env.get("XAI_ENROLLER_SOURCE_SALT")
|
||||
if not salt:
|
||||
raise ValueError("source salt is required")
|
||||
ledger_path = Path(env.get("XAI_ENROLLER_LEDGER_PATH", "xai-enroller-ledger.db"))
|
||||
target = _int(env, "XAI_ENROLLER_TARGET", 1)
|
||||
concurrency = _int(env, "XAI_ENROLLER_CONCURRENCY", 1)
|
||||
retry_attempts = _int(env, "XAI_ENROLLER_RETRY_ATTEMPTS", 0)
|
||||
timeout_sec = _float(env, "XAI_ENROLLER_TIMEOUT_SEC", 1800)
|
||||
poll_interval = _float(env, "XAI_ENROLLER_POLL_SEC", 5)
|
||||
executor = env.get("XAI_ENROLLER_AUTH_EXECUTOR", "http")
|
||||
sink = env.get("XAI_ENROLLER_SINK") or None
|
||||
if not 1 <= target <= 100:
|
||||
raise ValueError("target must be between 1 and 100")
|
||||
if not 1 <= concurrency <= 4:
|
||||
raise ValueError("concurrency must be between 1 and 4")
|
||||
if not 0 <= retry_attempts <= 3:
|
||||
raise ValueError("retry attempts must be between 0 and 3")
|
||||
if timeout_sec <= 0:
|
||||
raise ValueError("timeout must be positive")
|
||||
if poll_interval <= 0:
|
||||
raise ValueError("poll interval must be positive")
|
||||
if executor not in {"http", "playwright"}:
|
||||
raise ValueError("executor must be http or playwright")
|
||||
if sink not in {None, "cpa", "local"}:
|
||||
raise ValueError("sink must be cpa or local")
|
||||
cpa_url = env.get("XAI_ENROLLER_CPA_BASE_URL") or None
|
||||
cpa_secret = env.get("XAI_ENROLLER_CPA_MANAGEMENT_SECRET") or None
|
||||
local_auth_dir = (
|
||||
Path(env["XAI_ENROLLER_LOCAL_AUTH_DIR"]).expanduser()
|
||||
if env.get("XAI_ENROLLER_LOCAL_AUTH_DIR")
|
||||
else None
|
||||
)
|
||||
if sink == "cpa":
|
||||
if not cpa_url or not cpa_secret:
|
||||
raise ValueError("CPA base URL and management secret are required")
|
||||
parsed = urlparse(cpa_url)
|
||||
is_private = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
||||
if parsed.scheme != "https" and not is_private:
|
||||
raise ValueError("CPA base URL must use HTTPS")
|
||||
if sink == "local" and local_auth_dir is None:
|
||||
raise ValueError("local auth directory is required")
|
||||
return cls(
|
||||
source_kind=source_kind,
|
||||
source_file=source_file,
|
||||
source_db=source_db,
|
||||
source_salt=salt.encode(),
|
||||
ledger_path=ledger_path,
|
||||
target=target,
|
||||
concurrency=concurrency,
|
||||
retry_attempts=retry_attempts,
|
||||
timeout_sec=timeout_sec,
|
||||
poll_interval=poll_interval,
|
||||
executor=executor,
|
||||
sink=sink,
|
||||
cpa_base_url=cpa_url,
|
||||
cpa_management_secret=cpa_secret,
|
||||
local_auth_dir=local_auth_dir,
|
||||
)
|
||||
|
||||
def redacted_dict(self):
|
||||
return {
|
||||
"source_kind": self.source_kind,
|
||||
"source_file": str(self.source_file) if self.source_file else None,
|
||||
"source_db": str(self.source_db) if self.source_db else None,
|
||||
"ledger_path": str(self.ledger_path),
|
||||
"target": self.target,
|
||||
"concurrency": self.concurrency,
|
||||
"retry_attempts": self.retry_attempts,
|
||||
"timeout_sec": self.timeout_sec,
|
||||
"poll_interval": self.poll_interval,
|
||||
"executor": self.executor,
|
||||
"sink": self.sink,
|
||||
"cpa_base_url": self.cpa_base_url,
|
||||
"local_auth_dir": str(self.local_auth_dir) if self.local_auth_dir else None,
|
||||
}
|
||||
|
||||
|
||||
def require_private_regular_file(path: Path, *, require_0600=False):
|
||||
try:
|
||||
mode = path.stat()
|
||||
except OSError as exc:
|
||||
raise ValueError(f"source path is unavailable: {path}") from exc
|
||||
if not stat.S_ISREG(mode.st_mode):
|
||||
raise ValueError("source path must be a regular file")
|
||||
if mode.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
||||
raise ValueError("source path must not be group/world writable")
|
||||
if require_0600 and mode.st_mode & (stat.S_IRGRP | stat.S_IROTH):
|
||||
raise ValueError("file source requires mode 0600 or stricter")
|
||||
@@ -1,177 +0,0 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
|
||||
import httpx
|
||||
|
||||
from .ledger import Ledger
|
||||
from .models import (
|
||||
AuthorizationStatus,
|
||||
JobResult,
|
||||
JobStatus,
|
||||
)
|
||||
|
||||
|
||||
class EnrollmentCoordinator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source,
|
||||
protocol,
|
||||
executor,
|
||||
sink,
|
||||
ledger_path,
|
||||
ledger_salt,
|
||||
concurrency=1,
|
||||
timeout=1800,
|
||||
retry_attempts=0,
|
||||
event_callback=None,
|
||||
):
|
||||
self.source = source
|
||||
self.protocol = protocol
|
||||
self.executor = executor
|
||||
self.sink = sink
|
||||
self.ledger = Ledger(ledger_path, ledger_salt)
|
||||
self.concurrency = concurrency
|
||||
self.timeout = timeout
|
||||
self.retry_attempts = max(0, retry_attempts)
|
||||
self.event_callback = event_callback
|
||||
self._semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
@staticmethod
|
||||
def _is_pre_device_transport_failure(error):
|
||||
return isinstance(error, (httpx.TransportError, OSError))
|
||||
|
||||
def _emit(self, kind, data):
|
||||
if self.event_callback is None:
|
||||
return
|
||||
try:
|
||||
self.event_callback(kind, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def run(self, target=1):
|
||||
return await self.run_records(self.source.records(), target=target)
|
||||
|
||||
async def run_records(self, records, target=100):
|
||||
if not 1 <= target <= 100:
|
||||
raise ValueError("target must be between 1 and 100")
|
||||
self.ledger.recover_pending()
|
||||
results = []
|
||||
tasks = []
|
||||
index = 0
|
||||
if hasattr(records, "__aiter__"):
|
||||
async for source in records:
|
||||
if index >= target:
|
||||
break
|
||||
index += 1
|
||||
tasks.append(asyncio.create_task(self._run_one(source)))
|
||||
else:
|
||||
for source in records:
|
||||
if index >= target:
|
||||
break
|
||||
index += 1
|
||||
tasks.append(asyncio.create_task(self._run_one(source)))
|
||||
if hasattr(self.executor, "start"):
|
||||
await self.executor.start()
|
||||
try:
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks)
|
||||
return results
|
||||
finally:
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if hasattr(self.executor, "close"):
|
||||
with suppress(Exception):
|
||||
await self.executor.close()
|
||||
|
||||
async def _run_one(self, source):
|
||||
async with self._semaphore:
|
||||
for attempt in range(1, self.retry_attempts + 2):
|
||||
job_id = self.ledger.start(source.source_id, attempt=attempt)
|
||||
try:
|
||||
async with asyncio.timeout(self.timeout):
|
||||
try:
|
||||
flow = await self.protocol.start_device_flow()
|
||||
except Exception as error:
|
||||
result = JobResult(
|
||||
source.source_id,
|
||||
JobStatus.TRANSPORT_FAILED,
|
||||
"device_flow_failed",
|
||||
attempt,
|
||||
)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
if (
|
||||
self._is_pre_device_transport_failure(error)
|
||||
and attempt <= self.retry_attempts
|
||||
):
|
||||
continue
|
||||
return result
|
||||
self._emit(
|
||||
"device_flow",
|
||||
{
|
||||
"source_id": source.source_id,
|
||||
"user_code": flow.user_code,
|
||||
"verification_url": flow.verification_url,
|
||||
},
|
||||
)
|
||||
return await self._attempt_after_flow(source, job_id, flow, attempt)
|
||||
except asyncio.TimeoutError:
|
||||
result = JobResult(source.source_id, JobStatus.TIMEOUT, "timeout", attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
result = JobResult(source.source_id, JobStatus.CANCELLED, "cancelled", attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
raise
|
||||
except Exception:
|
||||
result = JobResult(
|
||||
source.source_id, JobStatus.TRANSPORT_FAILED, "transport_failed", attempt
|
||||
)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
|
||||
async def _attempt_after_flow(self, source, job_id, flow, attempt):
|
||||
authorization = await self.executor.confirm(source, flow)
|
||||
if authorization.status is not AuthorizationStatus.AUTHORIZED:
|
||||
status = JobStatus(authorization.status.value)
|
||||
result = JobResult(source.source_id, status, authorization.reason_code, attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
if self.sink is None:
|
||||
result = JobResult(source.source_id, JobStatus.SINK_FAILED, "sink_unconfigured", attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
try:
|
||||
credential = await self.protocol.poll_token(
|
||||
endpoint=flow.token_endpoint,
|
||||
flow=flow,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
reason = str(error)
|
||||
mapping = {
|
||||
"oauth_denied": JobStatus.OAUTH_DENIED,
|
||||
"oauth_expired": JobStatus.OAUTH_EXPIRED,
|
||||
"oauth_rejected": JobStatus.OAUTH_REJECTED,
|
||||
}
|
||||
result = JobResult(
|
||||
source.source_id, mapping.get(reason, JobStatus.TRANSPORT_FAILED), reason, attempt
|
||||
)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
try:
|
||||
receipt = await self.sink.store(credential)
|
||||
except Exception:
|
||||
result = JobResult(source.source_id, JobStatus.SINK_FAILED, "sink_failed", attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
result = JobResult(
|
||||
source.source_id,
|
||||
JobStatus.IMPORTED,
|
||||
"imported",
|
||||
attempt,
|
||||
sink_receipt_fingerprint=receipt.fingerprint,
|
||||
)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code, receipt.fingerprint)
|
||||
return result
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user