Compare commits

..
1 Commits
Author SHA1 Message Date
chaos d10009d639 Initial commit: grok-free-register-oss
Open-source Grok free registration CLI, xai_enroller auth pipeline,
local auth service, tests and docs.
2026-07-16 21:04:05 +08:00
104 changed files with 18750 additions and 10626 deletions

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
# 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
+17 -12
View File
@@ -1,14 +1,19 @@
# Python runtime / cache
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
# Local agent / IDE
.workbuddy/
.idea/
.vscode/
*.swp
*.pyc
.env
*.log
logs/
*.bak
.venv/
.setup.lock/
.DS_Store
.pytest_cache/
xai-enroller-ledger.db*
tmp_*.py
# 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
+21
View File
@@ -0,0 +1,21 @@
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.
+370
View File
@@ -0,0 +1,370 @@
# 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
-50
View File
@@ -1,50 +0,0 @@
# 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>"
```
-130
View File
@@ -1,130 +0,0 @@
#!/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
-5
View File
@@ -1,5 +0,0 @@
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
-4
View File
@@ -1,4 +0,0 @@
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
+55
View File
@@ -0,0 +1,55 @@
#!/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"
-240
View File
@@ -1,240 +0,0 @@
#!/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()
+61
View File
@@ -0,0 +1,61 @@
// ============================================================
// 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
View File
@@ -1,62 +0,0 @@
{
"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": 99999,
"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": true,
"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
}
-50
View File
@@ -1,50 +0,0 @@
"""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",
]
-93
View File
@@ -1,93 +0,0 @@
"""把 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
-279
View File
@@ -1,279 +0,0 @@
"""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
-174
View File
@@ -1,174 +0,0 @@
"""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 必须含 referrer=grok-build,否则 cli-chat-proxy 拒用。
铸造请走 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
-56
View File
@@ -1,56 +0,0 @@
"""原子写 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
@@ -1,26 +0,0 @@
{
"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"
}
@@ -1,26 +0,0 @@
{
"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"
}
@@ -1,26 +0,0 @@
{
"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"
}
@@ -1,26 +0,0 @@
{
"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"
}
-270
View File
@@ -1,270 +0,0 @@
"""注册成功钩子: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 _mint_tokens(
*,
email: str,
password: str,
sso: str,
page: Any | None,
cfg: dict,
log: Callable[[str], None],
proxy: str | None,
) -> dict[str, Any]:
"""优先 HTTP SSO 授权码;TLS 失败时用注册浏览器 PKCE;可选设备码。"""
from oidc_mint.oauth_code import (
OAuthCodeError,
_is_http_tls_failure,
mint_from_sso,
mint_from_sso_browser,
normalize_sso_cookie,
)
sso_token = normalize_sso_cookie(sso or "")
prefer_sso = bool(cfg.get("cpa_prefer_sso_oauth", True))
allow_browser = bool(cfg.get("cpa_allow_browser_fallback", True))
allow_device = bool(cfg.get("cpa_allow_device_fallback", False))
timeout = float(cfg.get("mint_timeout_sec", 300) or 300)
require_ref = bool(cfg.get("cpa_require_referrer", True))
http_err: Exception | None = None
if prefer_sso and sso_token:
log("[cpa] 使用 SSO→OAuth(PKCE, referrer=grok-build)")
try:
return mint_from_sso(
sso_token,
proxy=proxy,
log=lambda m: log(f"[Debug] {m}"),
require_referrer=require_ref,
)
except OAuthCodeError as exc:
http_err = exc
log(f"[!] SSO→OAuth 失败: {exc}")
except Exception as exc: # noqa: BLE001
http_err = exc
log(f"[!] SSO→OAuth 异常: {exc}")
# HTTP 失败后:有 page+sso 就优先浏览器 PKCE(Chrome TLS 通常正常,且保留 referrer)
if allow_browser and page is not None and sso_token and http_err is not None:
why = "TLS/连接" if _is_http_tls_failure(http_err) else "HTTP"
log(f"[cpa] 回退浏览器 PKCE 铸造({why}失败,绕开 Python TLS)")
try:
return mint_from_sso_browser(
sso_token,
page,
log=lambda m: log(f"[Debug] {m}"),
require_referrer=require_ref,
timeout_sec=min(timeout, 120.0),
)
except Exception as exc: # noqa: BLE001
log(f"[!] 浏览器 PKCE 失败: {exc}")
if not allow_device:
raise
log("[cpa] 继续回退设备码铸造(可能缺 referrer)")
elif http_err is not None and not allow_device:
raise http_err
if not allow_device and not sso_token:
raise RuntimeError("无 sso cookie,且已禁用设备码回退;无法铸造带 referrer 的 token")
if not allow_device:
# 有 sso 但 browser 也没开/没 page
if http_err is not None:
raise http_err
raise RuntimeError("SSO 铸造失败,且未启用任何回退")
# 设备码回退(旧路径,通常无 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
+180
View File
@@ -0,0 +1,180 @@
# 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 压测。
+67
View File
@@ -0,0 +1,67 @@
# 本地认证服务
认证服务把已有的 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 秒更新一次;内容无变化时终端保持安静。有效快照和已生成凭据会在重启后继续使用。
+19
View File
@@ -0,0 +1,19 @@
# 凭据库存与取用
认证成功的凭据保存在本地认证目录,并在 SQLite 库存中维护三种状态:
- `available`:已经认证、尚未取用;
- `claiming`:正在移动到取用批次;
- `claimed`:已经取用。
运行认证服务后输入:
```text
take 100
```
服务会选择最新的 100 个可用凭据,移动到 `claimed/<batch-id>/`,再把对应库存标记为 `claimed`。认证 ledger 中的 `imported` 记录仍然保留,所以这些账号不会被重新认证。
每条库存记录预留 `note` 字段,默认为空。取用操作不要求填写用途;以后需要备注时可直接更新该字段。
若文件移动中断,服务下次启动会恢复 `claiming` 状态。库存不足或凭据文件缺失时,操作失败但认证服务继续运行。
+54
View File
@@ -0,0 +1,54 @@
# 注册教程
## 开始运行
```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。
+38
View File
@@ -0,0 +1,38 @@
# 运行状态与排障
## 普通模式
普通模式的每一行都对应一次状态变化:
- `[→]`:新任务开始;
- `[✓]`:任务成功或来源连接完成;
- `[✗]`:当前任务失败或被跳过;
- `[⏸]`:进入限流冷却或服务暂停;
- `[▶]`:限流解除或服务恢复;
- `[!]`:来源、配置或流水线出现需要关注的问题。
认证端输入 `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
View File
@@ -0,0 +1 @@
"""Registration service package."""
+395
View File
@@ -0,0 +1,395 @@
"""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
)
View File
Whitespace-only changes.
+129
View File
@@ -0,0 +1,129 @@
"""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()
+61
View File
@@ -0,0 +1,61 @@
"""
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})'
+169
View File
@@ -0,0 +1,169 @@
"""
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
+215
View File
@@ -0,0 +1,215 @@
"""
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
+171
View File
@@ -0,0 +1,171 @@
"""
邮件接收 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
-4025
View File
File diff suppressed because it is too large. Load diff
+944
View File
@@ -0,0 +1,944 @@
#!/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())
+188
View File
@@ -0,0 +1,188 @@
#!/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())
-981
View File
@@ -1,981 +0,0 @@
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
-42
View File
@@ -1,42 +0,0 @@
"""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,
mint_from_sso_browser,
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",
"mint_from_sso_browser",
"sso_to_token",
"normalize_sso_cookie",
"CLIENT_ID",
"SCOPE",
"CODE_CLIENT_ID",
"CODE_SCOPE",
"GROK_REFERRER",
"OAuthDeviceError",
"OAuthCodeError",
"resolve_proxy",
"set_runtime_proxy",
]
-937
View File
@@ -1,937 +0,0 @@
"""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)
-923
View File
@@ -1,923 +0,0 @@
"""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 allow
-> oauth2/token (authorization_code + PKCE)
"""
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"
REDIRECT_URI = "http://127.0.0.1:56121/callback"
# 比旧 device-code scope 多 conversations:*,对齐 grok-build
SCOPE = (
"openid profile email offline_access "
"grok-cli:access api:access conversations:read conversations:write"
)
GROK_REFERRER = "grok-build"
GROK_VERSION = "0.2.93"
GROK_TOKEN_UA = (
f"grok-pager/{GROK_VERSION} grok-shell/{GROK_VERSION} (linux; x86_64)"
)
# Next.js Server Action id(consent 页 POST 需要)
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
@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_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,
)
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 _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]:
return {
"User-Agent": GROK_TOKEN_UA,
"Accept": "*/*",
"X-Grok-Client-Version": GROK_VERSION,
"Content-Type": "application/x-www-form-urlencoded",
}
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:
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:
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 approve_authorization(session: Any, consent_url: str, flow: AuthCodeFlow) -> str:
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=True,
timeout=30,
)
text = resp.text or ""
if resp.status_code < 200 or resp.status_code >= 300:
raise OAuthCodeError(f"consent HTTP {resp.status_code}: {_short(text, 300)}")
# 有时 302 到 redirect_uri?code=
final = _final_url(resp)
if "code=" in final:
qs = parse_qs(urlparse(final).query)
code = (qs.get("code") or [""])[0]
if code:
return code
return parse_consent_code(text)
def exchange_auth_code(session: Any, code: str, flow: AuthCodeFlow) -> TokenResult:
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 referrer={GROK_REFERRER} 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")
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。"""
log = log or _noop_log
sso = normalize_sso_cookie(sso_cookie)
if not sso:
raise OAuthCodeError("sso cookie 为空")
# 先 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:
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
)
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),
}
def _is_http_tls_failure(exc: BaseException | str) -> bool:
"""HTTP 层 TLS/连接失败:适合改走浏览器铸造。"""
text = str(exc or "").lower()
needles = (
"unexpected_eof_while_reading",
"sslerror",
"ssleoferror",
"max retries exceeded",
"openssl_internal:invalid library",
"tls connect error",
"curl: (35)",
"failed to perform, curl: (35)",
"ssl_error_syscall",
"connection reset",
"connection aborted",
"name resolution",
"timed out",
"timeout",
)
return any(n in text for n in needles)
def _page_eval(page: Any, js: str, *args: Any) -> Any:
"""兼容 DrissionPage page.run_js / page.run_js_loaded。"""
if page is None:
raise OAuthCodeError("page 为空,无法浏览器铸造")
last_err: Exception | None = None
for name in ("run_js", "run_js_loaded", "run_async_js"):
fn = getattr(page, name, None)
if not callable(fn):
continue
try:
if args:
return fn(js, *args)
return fn(js)
except TypeError:
# 某些签名不接受额外参数
try:
return fn(js)
except Exception as exc: # noqa: BLE001
last_err = exc
except Exception as exc: # noqa: BLE001
last_err = exc
continue
raise OAuthCodeError(f"page 无法执行 JS: {last_err or 'no run_js'}")
def _ensure_sso_on_page(page: Any, sso: str, log: LogFn) -> None:
sso = normalize_sso_cookie(sso)
if not sso:
raise OAuthCodeError("sso cookie 为空")
# 先落到 accounts 域,再写 cookie,避免 set 失败
try:
page.get("https://accounts.x.ai/")
time.sleep(0.4)
except Exception as exc: # noqa: BLE001
log(f"open accounts.x.ai warn: {exc}")
set_js = r"""
(sso) => {
try {
const maxAge = 60 * 60 * 24 * 30;
const base = `; path=/; max-age=${maxAge}; SameSite=Lax`;
document.cookie = `sso=${sso}${base}`;
document.cookie = `sso-rw=${sso}${base}`;
// 兼容 secure 场景
document.cookie = `sso=${sso}${base}; Secure`;
document.cookie = `sso-rw=${sso}${base}; Secure`;
return document.cookie.includes('sso=');
} catch (e) {
return String(e);
}
}
"""
try:
ok = _page_eval(page, set_js, sso)
log(f"browser sso cookie set: {ok!r}")
except Exception:
# 退而求其次:DrissionPage set.cookies
try:
setter = getattr(page, "set", None)
cookies = getattr(setter, "cookies", None) if setter is not None else None
if callable(cookies):
for domain in ("accounts.x.ai", "auth.x.ai", ".x.ai"):
cookies({"name": "sso", "value": sso, "domain": domain, "path": "/"})
cookies({"name": "sso-rw", "value": sso, "domain": domain, "path": "/"})
log("browser sso cookie set via page.set.cookies")
else:
raise OAuthCodeError("无法写入 sso cookie")
except Exception as exc: # noqa: BLE001
raise OAuthCodeError(f"写入 sso cookie 失败: {exc}") from exc
def _browser_click_allow(page: Any, log: LogFn) -> bool:
js = r"""
() => {
function isVisible(node) {
if (!node) return false;
const style = window.getComputedStyle(node);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function textOf(node) {
return [node.innerText, node.textContent, node.getAttribute('aria-label'), node.getAttribute('value')]
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
}
const nodes = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"], a'));
const prefer = [];
const weak = [];
for (const n of nodes) {
if (!isVisible(n) || n.disabled || n.getAttribute('aria-disabled') === 'true') continue;
const t = textOf(n);
const compact = t.replace(/\s+/g, '');
const lower = compact.toLowerCase();
if (!compact) continue;
// 精确允许,排除“全部允许”
if (compact === '允许' || lower === 'allow' || lower === 'authorize' || compact === '授权') {
prefer.push(n);
continue;
}
if ((compact.includes('允许') || lower.includes('allow') || lower.includes('authorize'))
&& !compact.includes('全部') && !lower.includes('all')) {
weak.push(n);
}
}
const target = prefer[0] || weak[0];
if (!target) return {clicked:false, texts: nodes.slice(0,8).map(textOf)};
target.focus();
target.click();
return {clicked:true, text: textOf(target)};
}
"""
try:
ret = _page_eval(page, js)
except Exception as exc: # noqa: BLE001
log(f"click allow js failed: {exc}")
return False
if isinstance(ret, dict) and ret.get("clicked"):
log(f"browser clicked allow: {ret.get('text')!r}")
return True
log(f"browser allow button not found: {ret!r}")
return False
def _browser_fetch_token(page: Any, code: str, flow: AuthCodeFlow, log: LogFn) -> TokenResult:
"""在浏览器上下文用 fetch 换 token,绕开 Python TLS 对 auth.x.ai 的 EOF。"""
form = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"code_verifier": flow.code_verifier,
}
body = urlencode(form)
js = r"""
(tokenUrl, body, ua, ver) => {
return fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': '*/*',
'User-Agent': ua,
'X-Grok-Client-Version': ver,
},
body: body,
credentials: 'include',
}).then(async (r) => {
const text = await r.text();
return {status: r.status, text: text};
}).catch((e) => ({status: 0, text: String(e)}));
}
"""
# 先到 auth 域,减少跨站限制
try:
page.get(ISSUER + "/")
time.sleep(0.3)
except Exception:
pass
ret = None
# DrissionPage 对 Promise 支持不一,做短轮询包装
wrap = r"""
(tokenUrl, body, ua, ver) => {
const key = '__cpa_token_result_' + Date.now();
window[key] = null;
fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': '*/*',
'User-Agent': ua,
'X-Grok-Client-Version': ver,
},
body: body,
credentials: 'include',
}).then(async (r) => {
const text = await r.text();
window[key] = {status: r.status, text: text};
}).catch((e) => {
window[key] = {status: 0, text: String(e)};
});
return key;
}
"""
try:
key = _page_eval(page, wrap, TOKEN_URL, body, GROK_TOKEN_UA, GROK_VERSION)
except Exception:
# 无参回退:把参数内联
key = _page_eval(
page,
f"""
(() => {{
const key = '__cpa_token_result_' + Date.now();
window[key] = null;
fetch({TOKEN_URL!r}, {{
method: 'POST',
headers: {{
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': '*/*',
'User-Agent': {GROK_TOKEN_UA!r},
'X-Grok-Client-Version': {GROK_VERSION!r},
}},
body: {body!r},
credentials: 'include',
}}).then(async (r) => {{
const text = await r.text();
window[key] = {{status: r.status, text: text}};
}}).catch((e) => {{
window[key] = {{status: 0, text: String(e)}};
}});
return key;
}})()
""",
)
deadline = time.time() + 30
while time.time() < deadline:
try:
ret = _page_eval(page, f"() => window[{key!r}]")
except Exception:
try:
ret = _page_eval(page, f"window[{key!r}]")
except Exception as exc:
raise OAuthCodeError(f"读取 browser token 结果失败: {exc}") from exc
if ret:
break
time.sleep(0.2)
if not isinstance(ret, dict):
raise OAuthCodeError(f"browser token 无响应: {ret!r}")
status = int(ret.get("status") or 0)
text = str(ret.get("text") or "")
if status < 200 or status >= 300:
raise OAuthCodeError(f"browser token HTTP {status}: {_short(text, 300)}")
try:
data = json.loads(text)
except Exception as e:
raise OAuthCodeError(f"browser token 非 JSON: {_short(text)}") from e
if not isinstance(data, dict) or not data.get("access_token"):
raise OAuthCodeError(f"browser token 缺少 access_token: {data!r}")
access = str(data["access_token"]).strip()
refresh = str(data.get("refresh_token") or "").strip()
if not refresh:
raise OAuthCodeError("browser token 缺少 refresh_token")
referrer = ""
try:
referrer = str(jwt_payload(access).get("referrer") or "")
except Exception:
pass
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=int(data.get("expires_in") or 21600),
raw=data,
referrer=referrer,
)
def mint_from_sso_browser(
sso_cookie: str,
page: Any,
*,
log: LogFn | None = None,
require_referrer: bool = True,
timeout_sec: float = 90.0,
) -> dict[str, Any]:
"""用注册浏览器完成 SSO→PKCE(绕开 Python TLS 访问 auth.x.ai 失败)。
流程:
1. 写入 sso cookie
2. 打开 authorize(referrer=grok-build)
3. 在 consent 页点击允许 / 或解析 callback code
4. 浏览器 fetch oauth2/token
"""
log = log or _noop_log
sso = normalize_sso_cookie(sso_cookie)
if not sso:
raise OAuthCodeError("sso cookie 为空")
if page is None:
raise OAuthCodeError("page 为空")
flow = new_auth_code_flow()
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,
}
auth_url = f"{AUTHORIZE_URL}?{urlencode(params)}"
log(f"browser PKCE authorize referrer={GROK_REFERRER}")
_ensure_sso_on_page(page, sso, log)
try:
page.get(auth_url)
except Exception as exc: # noqa: BLE001
raise OAuthCodeError(f"browser 打开 authorize 失败: {exc}") from exc
code = ""
deadline = time.time() + max(20.0, float(timeout_sec))
last_url = ""
while time.time() < deadline:
try:
url = str(getattr(page, "url", "") or "")
except Exception:
url = ""
if url and url != last_url:
log(f"browser url: {_short(url, 140)}")
last_url = url
# callback 已跳到 redirect_uri?code=
if "code=" in url and ("127.0.0.1" in url or "callback" in url or "localhost" in url):
qs = parse_qs(urlparse(url).query)
code = (qs.get("code") or [""])[0].strip()
if code:
log("browser got code from redirect")
break
# consent 页
if "/oauth2/consent" in url or "consent" in url:
_browser_click_allow(page, log)
time.sleep(0.8)
# 有时 consent 响应是 RSC,不跳转;尝试从 HTML 抽 code
try:
html = ""
try:
html = str(getattr(page, "html", "") or "")
except Exception:
html = str(_page_eval(page, "() => document.documentElement.outerHTML") or "")
if html:
try:
code = parse_consent_code(html)
if code:
log("browser got code from consent html")
break
except OAuthCodeError:
pass
except Exception:
pass
continue
if "sign-in" in url or "sign-up" in url:
# cookie 可能没带上,重写一次
_ensure_sso_on_page(page, sso, log)
try:
page.get(auth_url)
except Exception:
pass
time.sleep(0.8)
continue
time.sleep(0.5)
if not code:
raise OAuthCodeError(
f"browser PKCE 超时未拿到 code(last_url={_short(last_url, 160)})"
)
token = _browser_fetch_token(page, code, flow, log)
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("browser access_token referrer=grok-build ok")
log(f"browser token ok expires_in={token.expires_in}")
return {
"access_token": token.access_token,
"refresh_token": token.refresh_token,
"id_token": token.id_token,
"token_type": token.token_type,
"expires_in": token.expires_in,
"referrer": token.referrer,
"sso": sso,
}
-201
View File
@@ -1,201 +0,0 @@
"""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"
SCOPE = "openid profile email offline_access grok-cli:access api:access"
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")
-71
View File
@@ -1,71 +0,0 @@
"""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)"
-2
View File
@@ -1,2 +0,0 @@
# auto-exported by proxy_manager — only accounts.x.ai-validated proxies
# updated 2026-07-12 23:14:13
-28
View File
@@ -1,28 +0,0 @@
{
"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
}
}
}
-501
View File
@@ -1,501 +0,0 @@
#!/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
View File
@@ -1,339 +0,0 @@
#!/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 '关'}"
)
-343
View File
@@ -1,343 +0,0 @@
#!/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
View File
@@ -1,295 +0,0 @@
#!/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),
}
+7 -18
View File
@@ -1,18 +1,7 @@
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
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
+222
View File
@@ -0,0 +1,222 @@
#!/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"
+140
View File
@@ -0,0 +1,140 @@
#!/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 "完成。"
+32
View File
@@ -0,0 +1,32 @@
#!/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
}
+206
View File
@@ -0,0 +1,206 @@
#!/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()
+153
View File
@@ -0,0 +1,153 @@
#!/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
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/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
View File
@@ -1,299 +0,0 @@
#!/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())
Executable
+80
View File
@@ -0,0 +1,80 @@
#!/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[@]}"
View File
Whitespace-only changes.
+92
View File
@@ -0,0 +1,92 @@
"""
共享 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
View File
@@ -0,0 +1,317 @@
"""
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)
+2
View File
@@ -0,0 +1,2 @@
pytest>=8.0.0
pytest-asyncio>=0.24.0
+82
View File
@@ -0,0 +1,82 @@
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()
+475
View File
@@ -0,0 +1,475 @@
"""
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)
+491
View File
@@ -0,0 +1,491 @@
"""
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
+58
View File
@@ -0,0 +1,58 @@
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()
+482
View File
@@ -0,0 +1,482 @@
"""
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
+132
View File
@@ -0,0 +1,132 @@
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()
+64
View File
@@ -0,0 +1,64 @@
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",
]
+411
View File
@@ -0,0 +1,411 @@
"""
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}')
+298
View File
@@ -0,0 +1,298 @@
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())
+391
View File
@@ -0,0 +1,391 @@
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,
},
)
]
+58
View File
@@ -0,0 +1,58 @@
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,
}
+125
View File
@@ -0,0 +1,125 @@
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)
+293
View File
@@ -0,0 +1,293 @@
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
+411
View File
@@ -0,0 +1,411 @@
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())
+188
View File
@@ -0,0 +1,188 @@
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
+163
View File
@@ -0,0 +1,163 @@
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"]
+245
View File
@@ -0,0 +1,245 @@
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"
+79
View File
@@ -0,0 +1,79 @@
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
+240
View File
@@ -0,0 +1,240 @@
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())
+100
View File
@@ -0,0 +1,100 @@
# 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
```
+902
View File
@@ -0,0 +1,902 @@
<!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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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
View File
@@ -0,0 +1 @@
"""Developer-only analysis and stress tools."""
+419
View File
@@ -0,0 +1,419 @@
"""
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))
-77
View File
@@ -1,77 +0,0 @@
// 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) {}
})();
-15
View File
@@ -1,15 +0,0 @@
{
"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"]
}
+3
View File
@@ -0,0 +1,3 @@
"""Standalone xAI OAuth Device Flow enroller."""
__version__ = "0.1.0"
+111
View File
@@ -0,0 +1,111 @@
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
+139
View File
@@ -0,0 +1,139 @@
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")
+177
View File
@@ -0,0 +1,177 @@
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
+595
View File
@@ -0,0 +1,595 @@
import asyncio
import glob
import importlib
import os
from contextlib import suppress
from urllib.parse import parse_qs, urlparse
import httpx
from grok_register.clearance import (
apply_clearance_to_context,
cached_user_agent,
playwright_proxy_settings,
)
from .models import AuthorizationResult, AuthorizationStatus, DeviceFlow, SourceRecord
class HTTPProbeExecutor:
def __init__(self, client: httpx.AsyncClient, max_body=64 * 1024):
self.client = client
self.max_body = max_body
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
parsed = urlparse(flow.verification_url)
if parsed.scheme != "https" or parsed.hostname != "accounts.x.ai":
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "invalid_url")
response = await self.client.get(
flow.verification_url,
headers={"Cookie": f"sso={source.sso_token}"},
follow_redirects=False,
)
body = await response.aread()
if len(body) > self.max_body:
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "response_too_large")
text = body.decode("utf-8", errors="replace").lower()
if response.status_code == 403 and any(
marker in text for marker in ("challenge", "cf-chl", "captcha")
):
return AuthorizationResult(AuthorizationStatus.NEEDS_BROWSER, "challenge")
if response.status_code in {301, 302, 303, 307, 308}:
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "redirect")
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "http_probe_inconclusive"
)
class PlaywrightExecutor:
ALLOWED_CONTROLS = frozenset({"Authorize", "Allow", "Continue", "Confirm"})
ATTEMPT_TIMEOUT_SECONDS = 55.0
CLOSE_TIMEOUT_SECONDS = 5.0
# Keep confirm loops tight: most successful device verifies finish under ~15s.
LOOP_DEADLINE_SECONDS = 28.0
COOKIE_WAIT_MS = 250
CONSENT_WAIT_MS = 350
CODE_WAIT_MS = 300
CHALLENGE_WAIT_MS = 700
POLL_WAIT_MS = 250
# Drop heavy assets that never participate in device verification.
BLOCKED_RESOURCE_TYPES = frozenset({"image", "media", "font", "stylesheet"})
BLOCKED_URL_MARKERS = (
"google-analytics",
"googletagmanager",
"doubleclick",
"facebook.net",
"hotjar",
"segment.io",
)
def __init__(self, concurrency=1, playwright_factory=None, executable_path=None):
self.concurrency = concurrency
self.playwright_factory = playwright_factory
self.executable_path = executable_path or self._find_executable_path()
self._playwright = None
self._browser = None
self._semaphore = asyncio.Semaphore(concurrency)
self._lifecycle_lock = asyncio.Lock()
@staticmethod
def _find_executable_path():
configured = os.environ.get("XAI_ENROLLER_BROWSER_EXECUTABLE")
if configured:
return configured
candidates = glob.glob(os.path.expanduser("~/.cloakbrowser/chromium-*/chrome"))
candidates.extend(
glob.glob(
os.path.expanduser(
"~/.cloakbrowser/chromium-*/Chromium.app/Contents/MacOS/Chromium"
)
)
)
return sorted(candidates)[-1] if candidates else None
async def start(self):
async with self._lifecycle_lock:
if self._browser is not None:
is_connected = getattr(self._browser, "is_connected", None)
if not callable(is_connected) or is_connected():
return
await self._close_unlocked()
factory = self.playwright_factory
if factory is None:
module = importlib.import_module("playwright.async_api")
factory = module.async_playwright
playwright = await factory().start()
options = {"headless": True}
if self.executable_path:
options["executable_path"] = self.executable_path
# REGISTER_PROXY / HTTP_PROXY / HTTPS_PROXY → this process only
proxy = playwright_proxy_settings()
if proxy:
options["proxy"] = proxy
try:
browser = await playwright.chromium.launch(**options)
except BaseException:
await self._close_transport_resource(playwright.stop())
raise
self._playwright = playwright
self._browser = browser
async def close(self):
async with self._lifecycle_lock:
await self._close_unlocked()
async def _close_unlocked(self):
browser = self._browser
playwright = self._playwright
self._browser = None
self._playwright = None
try:
if browser is not None:
await self._close_transport_resource(browser.close())
finally:
if playwright is not None:
await self._close_transport_resource(playwright.stop())
@staticmethod
def _consume_task_result(task):
with suppress(BaseException):
task.result()
@classmethod
async def _close_transport_resource(cls, awaitable):
task = asyncio.create_task(awaitable)
try:
done, _pending = await asyncio.wait(
{task}, timeout=cls.CLOSE_TIMEOUT_SECONDS
)
except asyncio.CancelledError:
task.cancel()
task.add_done_callback(cls._consume_task_result)
raise
if task in done:
cls._consume_task_result(task)
return True
task.cancel()
task.add_done_callback(cls._consume_task_result)
return False
async def _recycle_browser(self, browser):
async with self._lifecycle_lock:
if self._browser is browser:
await self._close_unlocked()
async def __aenter__(self):
await self.start()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.close()
@classmethod
async def _close_attempt_resources(cls, page, context):
async def close_session():
closers = []
if page is not None:
closers.append(page.close())
if context is not None:
closers.append(context.close())
if closers:
await asyncio.gather(*closers, return_exceptions=True)
cleanup = asyncio.create_task(close_session())
cancelled = False
deadline = asyncio.get_running_loop().time() + cls.CLOSE_TIMEOUT_SECONDS
while not cleanup.done():
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
break
try:
await asyncio.wait({cleanup}, timeout=remaining)
except asyncio.CancelledError:
cancelled = True
if cleanup.done():
cls._consume_task_result(cleanup)
else:
# asyncio.wait_for() is not a hard deadline: it cancels the child and
# then waits for cancellation to finish, which a wedged Playwright
# transport may never do. Abandon the cleanup task after one real
# wall-clock deadline and consume its eventual result asynchronously.
cleanup.cancel()
cleanup.add_done_callback(cls._consume_task_result)
if cancelled:
raise asyncio.CancelledError
return cleanup.done()
@staticmethod
async def _click_visible_exact(page, names):
for name in names:
try:
button = page.get_by_role("button", name=name, exact=True)
except TypeError:
button = page.get_by_role("button", name=name)
for index in range(await button.count()):
candidate = button.nth(index) if hasattr(button, "nth") else button.first
is_visible = getattr(candidate, "is_visible", None)
if is_visible is None or await is_visible():
await candidate.click()
return True
return False
@staticmethod
async def _click_visible_turnstile(page):
"""Click a visible Turnstile widget using the registration solver motion."""
for selector in (
".cf-turnstile",
"iframe[src*='challenges.cloudflare.com']",
"iframe[src*='turnstile']",
):
locator = page.locator(selector).first
if not await locator.count() or not await locator.is_visible():
continue
box = await locator.bounding_box()
if not box:
continue
x = box["x"] + box["width"] / 2
y = box["y"] + box["height"] / 2
await page.mouse.move(max(0, x - 25), max(0, y - 8))
await page.mouse.move(x, y, steps=8)
await page.mouse.down()
await asyncio.sleep(0.05)
await page.mouse.up()
return True
return False
@staticmethod
def _expanded_cookies(source):
allowed = {
"name",
"value",
"url",
"domain",
"path",
"expires",
"httpOnly",
"secure",
"sameSite",
}
cookies = []
for source_cookie in source.cookies:
cookie = {
key: value for key, value in source_cookie.items() if key in allowed
}
if cookie.get("domain"):
cookie.pop("url", None)
elif cookie.get("url"):
cookie.pop("domain", None)
cookie.pop("path", None)
cookies.append(cookie)
if not cookies:
cookies = [
{
"name": "sso",
"value": source.sso_token,
"domain": "accounts.x.ai",
"path": "/",
"secure": True,
"httpOnly": True,
}
]
seen = {
(cookie.get("name"), cookie.get("domain"), cookie.get("path", "/"))
for cookie in cookies
}
for cookie in list(cookies) if source.cookies else ():
name = cookie.get("name", "")
if not name.startswith("sso"):
continue
key = (name, ".x.ai", cookie.get("path", "/"))
if key in seen:
continue
clone = dict(cookie)
clone.pop("url", None)
clone["domain"] = ".x.ai"
clone.setdefault("path", "/")
cookies.append(clone)
seen.add(key)
return cookies
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
await self.start()
async with self._semaphore:
browser = self._browser
attempt = asyncio.create_task(
self._confirm_once(browser, source, flow)
)
try:
done, _pending = await asyncio.wait(
{attempt}, timeout=self.ATTEMPT_TIMEOUT_SECONDS
)
except asyncio.CancelledError:
attempt.cancel()
attempt.add_done_callback(self._consume_task_result)
raise
if attempt in done:
return attempt.result()
# A Playwright transport can swallow cancellation while one of its
# RPCs is wedged. Do not let asyncio.wait_for() extend the nominal
# timeout indefinitely: detach the attempt and replace the complete
# browser transport before admitting the next account.
attempt.cancel()
attempt.add_done_callback(self._consume_task_result)
await self._recycle_browser(browser)
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
)
async def _install_request_filters(self, page):
"""Skip non-essential resources so device verification spends less wall time."""
async def route_handler(route):
request = route.request
resource_type = getattr(request, "resource_type", "") or ""
url = getattr(request, "url", "") or ""
if resource_type in self.BLOCKED_RESOURCE_TYPES or any(
marker in url for marker in self.BLOCKED_URL_MARKERS
):
await route.abort()
return
await route.continue_()
with suppress(Exception):
await page.route("**/*", route_handler)
async def _confirm_once(self, browser, source: SourceRecord, flow: DeviceFlow):
context = None
page = None
code_submitted = False
consent_submitted = False
challenge_clicks = 0
try:
context_kwargs = {}
ua = cached_user_agent()
if ua:
context_kwargs["user_agent"] = ua
context = await browser.new_context(**context_kwargs)
# CF cookies from external FlareSolverr prewarm (host-level)
await apply_clearance_to_context(context)
page = await context.new_page()
await self._install_request_filters(page)
await context.add_cookies(self._expanded_cookies(source))
await page.goto(flow.verification_url, wait_until="domcontentloaded")
deadline = (
asyncio.get_running_loop().time() + self.LOOP_DEADLINE_SECONDS
)
unknown_since = None
while asyncio.get_running_loop().time() < deadline:
parsed = urlparse(page.url)
if parsed.scheme != "https" or not parsed.hostname or not (
parsed.hostname == "x.ai"
or parsed.hostname.endswith(".x.ai")
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "unsafe_page"
)
query_error = parse_qs(parsed.query).get("error", [None])[0]
text = await page.locator("body").inner_text()
text_lower = text.lower()
page_title = getattr(page, "title", None)
title_lower = (
(await page_title()).lower() if page_title is not None else ""
)
if query_error == "rate_limited":
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
)
if query_error:
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "device_verify_failed"
)
if any(
marker in text_lower
for marker in ("rate limit", "too many requests", "请求过于频繁")
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
)
if (
"attention required" in title_lower
and "cloudflare" in title_lower
) or any(
marker in text_lower
for marker in (
"sorry, you have been blocked",
"unable to access x.ai",
"cloudflare ray id",
)
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"challenge_required",
)
if "/oauth2/device/done" in parsed.path or any(
marker in text_lower
for marker in ("device authorized", "设备已授权")
):
return AuthorizationResult(
AuthorizationStatus.AUTHORIZED, "confirmed"
)
cookie_clicked = await self._click_visible_exact(
page,
(
"全部拒绝",
"拒绝全部",
"Reject all",
"Reject All",
),
)
if cookie_clicked:
unknown_since = None
await page.wait_for_timeout(self.COOKIE_WAIT_MS)
continue
if "/oauth2/device/consent" in parsed.path or any(
marker in text_lower
for marker in ("authorize grok build", "授权 grok build")
):
if not consent_submitted:
allowed = await self._click_visible_exact(
page, ("允许", "Allow", "Authorize", "Approve")
)
if allowed:
consent_submitted = True
unknown_since = None
await page.wait_for_timeout(self.CONSENT_WAIT_MS)
continue
else:
await page.wait_for_timeout(self.POLL_WAIT_MS)
continue
code_input = page.locator('input[name="user_code"]')
try:
code_input_count = await code_input.count()
except AttributeError:
if await self._click_visible_exact(
page, self.ALLOWED_CONTROLS
):
return AuthorizationResult(
AuthorizationStatus.AUTHORIZED,
"confirmed",
)
code_input_count = 0
if code_input_count and await code_input.first.is_visible():
if code_submitted:
await page.wait_for_timeout(self.POLL_WAIT_MS)
continue
unknown_since = None
current = await code_input.first.input_value()
# fill() is far cheaper than press_sequentially and the
# device-code form does not require keystroke events.
if flow.user_code.replace("-", "") not in current.replace(
"-", ""
):
await code_input.first.fill(flow.user_code)
submit = page.locator(
'button[type="submit"], input[type="submit"]'
)
if not await submit.count():
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"submit_missing",
)
predicate = lambda response: (
urlparse(response.url).hostname == "auth.x.ai"
and urlparse(response.url).path == "/oauth2/device/verify"
)
async with page.expect_response(
predicate, timeout=12000
) as response_info:
code_submitted = True
await submit.first.click()
response = await response_info.value
location = urlparse(response.headers.get("location", ""))
error = parse_qs(location.query).get("error", [None])[0]
if error == "rate_limited":
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"rate_limited",
)
if error:
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"device_verify_failed",
)
await page.wait_for_timeout(self.CODE_WAIT_MS)
continue
if any(
marker in text_lower
for marker in (
"continue with email",
"sign in with email",
"使用邮箱登录",
)
) or await page.locator('input[type="password"]').count():
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"login_required",
)
if any(
marker in text_lower
for marker in (
"captcha",
"verify you are human",
"security challenge",
"turnstile",
"人机验证",
"安全验证",
)
):
if (
challenge_clicks < 3
and await self._click_visible_turnstile(page)
):
challenge_clicks += 1
unknown_since = None
await page.wait_for_timeout(self.CHALLENGE_WAIT_MS)
continue
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"challenge_required",
)
if any(
marker in text_lower
for marker in (
"multi-factor",
"two-factor",
"authentication code",
"verification code",
"双重验证",
"验证码",
)
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"mfa_required",
)
if await self._click_visible_exact(page, self.ALLOWED_CONTROLS):
return AuthorizationResult(
AuthorizationStatus.AUTHORIZED,
"confirmed",
)
now = asyncio.get_running_loop().time()
if unknown_since is None:
unknown_since = now
elif now - unknown_since >= 2.5:
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "unknown_page"
)
await page.wait_for_timeout(self.POLL_WAIT_MS)
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
)
except asyncio.CancelledError:
raise
except Exception as error:
is_timeout = (
error.__class__.__name__ == "TimeoutError"
and error.__class__.__module__.startswith("playwright")
)
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"confirmation_timeout" if is_timeout else "browser_error",
)
finally:
await self._close_attempt_resources(page, context)
+105
View File
@@ -0,0 +1,105 @@
import os
import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
class InventoryError(RuntimeError):
pass
@dataclass(frozen=True)
class ClaimBatch:
batch_id: str
directory: Path
moved: int
note: str = ""
class CredentialInventory:
def __init__(self, ledger, available_directory, claimed_directory):
self.ledger = ledger
self.available_directory = Path(available_directory)
self.claimed_directory = Path(claimed_directory)
def take(self, limit, note=""):
try:
limit = int(limit)
except (TypeError, ValueError) as exc:
raise ValueError("claim count must be an integer") from exc
if limit <= 0:
raise ValueError("claim count must be positive")
note = str(note or "").strip()
batch_id = self._new_batch_id()
receipts = self.ledger.claim_available(limit, batch_id)
return self._complete_batch(batch_id, receipts, note)
def recover(self):
rows = self.ledger.pending_claims()
batches = {}
for row in rows:
batches.setdefault(row["batch_id"], []).append(
row["sink_receipt_fingerprint"]
)
recovered = 0
for batch_id, receipts in batches.items():
recovered += self._complete_batch(batch_id, receipts, "").moved
return recovered
@staticmethod
def _new_batch_id():
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return f"{timestamp}-{secrets.token_hex(3)}"
@staticmethod
def _validate_receipt(receipt):
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", receipt):
raise InventoryError("invalid credential receipt")
@staticmethod
def _fsync_directory(directory):
descriptor = os.open(directory, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _complete_batch(self, batch_id, receipts, note):
destination_directory = self.claimed_directory / batch_id
if not receipts:
return ClaimBatch(batch_id, destination_directory, 0, note)
self.available_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
self.claimed_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
destination_directory.mkdir(mode=0o700, parents=False, exist_ok=True)
for directory in (
self.available_directory,
self.claimed_directory,
destination_directory,
):
os.chmod(directory, 0o700)
moved = 0
for receipt in receipts:
self._validate_receipt(receipt)
source = self.available_directory / f"{receipt}.json"
destination = destination_directory / source.name
source_exists = source.is_file()
destination_exists = destination.is_file()
if source_exists and destination_exists:
raise InventoryError("credential exists in both inventory locations")
if not source_exists and not destination_exists:
raise InventoryError("credential file is missing")
if source_exists:
os.replace(source, destination)
os.chmod(destination, 0o600)
moved += 1
self._fsync_directory(self.available_directory)
self._fsync_directory(destination_directory)
self._fsync_directory(self.claimed_directory)
marked = self.ledger.mark_claimed(batch_id, note=note)
if marked != len(receipts):
raise InventoryError("inventory batch did not commit completely")
return ClaimBatch(batch_id, destination_directory, moved, note)
+275
View File
@@ -0,0 +1,275 @@
import hashlib
import hmac
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from .models import JobStatus
class Ledger:
def __init__(self, path: Path, salt: bytes):
self.path = Path(path)
self.salt = bytes(salt)
self._init()
def _connect(self):
connection = sqlite3.connect(self.path)
connection.row_factory = sqlite3.Row
return connection
def _init(self):
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS 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
)
"""
)
columns = {
row["name"] for row in connection.execute("PRAGMA table_info(jobs)")
}
if "authorization_started" not in columns:
connection.execute(
"ALTER TABLE jobs ADD COLUMN authorization_started INTEGER NOT NULL DEFAULT 0"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS jobs_source_status_idx "
"ON jobs(source_fingerprint, status)"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS credential_inventory (
sink_receipt_fingerprint TEXT PRIMARY KEY,
state TEXT NOT NULL CHECK(state IN ('available', 'claiming', 'claimed')),
claimed_at TEXT NOT NULL DEFAULT '',
batch_id TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT ''
)
"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS credential_inventory_state_idx "
"ON credential_inventory(state)"
)
connection.execute(
"""
INSERT OR IGNORE INTO credential_inventory(
sink_receipt_fingerprint, state
)
SELECT sink_receipt_fingerprint, 'available'
FROM jobs
WHERE status=?
AND COALESCE(TRIM(sink_receipt_fingerprint), '') <> ''
""",
(JobStatus.IMPORTED.value,),
)
os.chmod(self.path, 0o600)
def fingerprint(self, source_id):
return hmac.new(self.salt, source_id.encode(), hashlib.sha256).hexdigest()
def _fingerprint(self, source_id):
return self.fingerprint(source_id)
def start(self, source_id, attempt=1):
return self.start_fingerprint(self.fingerprint(source_id), attempt=attempt)
def start_fingerprint(self, source_fingerprint, attempt=None):
if attempt is None:
attempt = self.next_attempt(source_fingerprint)
now = datetime.now(timezone.utc).isoformat()
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO jobs(source_fingerprint, attempt_number, status, started_at) "
"VALUES (?, ?, ?, ?)",
(source_fingerprint, attempt, "pending", now),
)
return cursor.lastrowid
def next_attempt(self, source_fingerprint):
with self._connect() as connection:
row = connection.execute(
"SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt "
"FROM jobs WHERE source_fingerprint=?",
(source_fingerprint,),
).fetchone()
return int(row["next_attempt"])
def mark_authorization_started(self, job_id):
with self._connect() as connection:
connection.execute(
"UPDATE jobs SET authorization_started=1 "
"WHERE job_id=? AND status='pending'",
(job_id,),
)
def finish(self, job_id, status, reason_code, sink_receipt_fingerprint=None):
status_value = status.value if isinstance(status, JobStatus) else str(status)
now = datetime.now(timezone.utc).isoformat()
with self._connect() as connection:
cursor = connection.execute(
"UPDATE jobs SET status=?, finished_at=?, reason_code=?, "
"sink_receipt_fingerprint=? "
"WHERE job_id=? AND status='pending'",
(status_value, now, reason_code, sink_receipt_fingerprint, job_id),
)
if (
cursor.rowcount == 1
and status_value == JobStatus.IMPORTED.value
and sink_receipt_fingerprint
):
connection.execute(
"INSERT OR IGNORE INTO credential_inventory("
"sink_receipt_fingerprint, state) VALUES (?, 'available')",
(sink_receipt_fingerprint,),
)
def claim_available(self, limit, batch_id):
limit = int(limit)
if limit <= 0:
return []
batch_id = str(batch_id).strip()
if not batch_id:
raise ValueError("batch_id must not be empty")
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
rows = connection.execute(
"""
WITH latest_import AS (
SELECT sink_receipt_fingerprint, MAX(finished_at) AS finished_at
FROM jobs
WHERE status=?
AND COALESCE(TRIM(sink_receipt_fingerprint), '') <> ''
GROUP BY sink_receipt_fingerprint
)
SELECT inventory.sink_receipt_fingerprint
FROM credential_inventory AS inventory
JOIN latest_import USING (sink_receipt_fingerprint)
WHERE inventory.state='available'
ORDER BY latest_import.finished_at DESC,
inventory.sink_receipt_fingerprint ASC
LIMIT ?
""",
(JobStatus.IMPORTED.value, limit),
).fetchall()
fingerprints = [row["sink_receipt_fingerprint"] for row in rows]
connection.executemany(
"UPDATE credential_inventory SET state='claiming', batch_id=?, "
"claimed_at='', note='' "
"WHERE sink_receipt_fingerprint=? AND state='available'",
((batch_id, fingerprint) for fingerprint in fingerprints),
)
return fingerprints
def mark_claimed(self, batch_id, note=""):
batch_id = str(batch_id).strip()
if not batch_id:
raise ValueError("batch_id must not be empty")
with self._connect() as connection:
cursor = connection.execute(
"UPDATE credential_inventory SET state='claimed', claimed_at=?, note=? "
"WHERE state='claiming' AND batch_id=?",
(datetime.now(timezone.utc).isoformat(), str(note), batch_id),
)
return cursor.rowcount
def inventory_counts(self):
counts = {"available": 0, "claiming": 0, "claimed": 0}
with self._connect() as connection:
rows = connection.execute(
"SELECT state, COUNT(*) AS count FROM credential_inventory GROUP BY state"
)
for row in rows:
counts[row["state"]] = int(row["count"])
return counts
def pending_claims(self, batch_id=None):
query = (
"SELECT sink_receipt_fingerprint, batch_id, note "
"FROM credential_inventory WHERE state='claiming'"
)
params = ()
if batch_id is not None:
query += " AND batch_id=?"
params = (str(batch_id),)
query += " ORDER BY batch_id, sink_receipt_fingerprint"
with self._connect() as connection:
rows = connection.execute(query, params)
return [dict(row) for row in rows]
def recover_claiming(self, batch_id=None, *, note=""):
query = (
"UPDATE credential_inventory SET state='available', claimed_at='', "
"batch_id='', note=? WHERE state='claiming'"
)
params = [str(note)]
if batch_id is not None:
query += " AND batch_id=?"
params.append(str(batch_id))
with self._connect() as connection:
cursor = connection.execute(query, params)
return cursor.rowcount
def recover_pending(self, *, reason="recovered_pending"):
with self._connect() as connection:
connection.execute(
"UPDATE jobs SET status=?, finished_at=?, reason_code=? WHERE status='pending'",
(JobStatus.CANCELLED.value, datetime.now(timezone.utc).isoformat(), reason),
)
def has_imported(self, source_id):
with self._connect() as connection:
row = connection.execute(
"SELECT 1 FROM jobs WHERE source_fingerprint=? AND status=? LIMIT 1",
(self.fingerprint(source_id), JobStatus.IMPORTED.value),
).fetchone()
return row is not None
def imported_fingerprints(self):
with self._connect() as connection:
rows = connection.execute(
"SELECT DISTINCT source_fingerprint FROM jobs WHERE status=?",
(JobStatus.IMPORTED.value,),
)
return {row["source_fingerprint"] for row in rows}
def aggregate_counts(self):
with self._connect() as connection:
row = connection.execute(
"""
SELECT
COUNT(DISTINCT CASE WHEN authorization_started=1 OR status=?
THEN source_fingerprint END) AS attempted_unique,
COUNT(DISTINCT CASE WHEN status=?
THEN source_fingerprint END) AS imported_unique,
COUNT(CASE WHEN finished_at IS NOT NULL THEN 1 END) AS finalized_attempts,
COUNT(CASE WHEN status=? THEN 1 END) AS imported_attempts,
COUNT(CASE WHEN reason_code='rate_limited' THEN 1 END) AS rate_limited
FROM jobs
""",
(
JobStatus.IMPORTED.value,
JobStatus.IMPORTED.value,
JobStatus.IMPORTED.value,
),
).fetchone()
return {key: int(row[key] or 0) for key in row.keys()}
def get(self, job_id):
with self._connect() as connection:
row = connection.execute(
"SELECT source_fingerprint, attempt_number, status, started_at, finished_at, "
"reason_code, sink_receipt_fingerprint FROM jobs WHERE job_id=?",
(job_id,),
).fetchone()
return dict(row) if row else None
+128
View File
@@ -0,0 +1,128 @@
from dataclasses import dataclass
from enum import Enum
from typing import Optional
@dataclass(frozen=True)
class SourceRecord:
source_id: str
sso_token: str
cookies: tuple[dict, ...] = ()
def __repr__(self) -> str:
return "SourceRecord(<redacted>)"
@dataclass(frozen=True)
class DeviceFlow:
device_code: str
user_code: str
verification_url: str
expires_in: int
interval: float
token_endpoint: str = "https://auth.x.ai/oauth2/token"
def __repr__(self) -> str:
return "DeviceFlow(<redacted>)"
@dataclass(frozen=True)
class OAuthCredential:
access_token: str
refresh_token: str
id_token: Optional[str]
token_type: Optional[str]
expires_in: int
expires_at: str
last_refresh: str
subject: Optional[str]
token_endpoint: str
def __repr__(self) -> str:
return "OAuthCredential(<redacted>)"
@dataclass(frozen=True)
class SinkReceipt:
fingerprint: str
def __repr__(self) -> str:
return "SinkReceipt(<redacted>)"
class AuthorizationStatus(str, Enum):
AUTHORIZED = "authorized"
NEEDS_BROWSER = "needs_browser"
NEEDS_INTERACTION = "needs_interaction"
@dataclass(frozen=True)
class AuthorizationResult:
status: AuthorizationStatus
reason_code: str
class JobStatus(str, Enum):
IMPORTED = "imported"
NEEDS_BROWSER = "needs_browser"
NEEDS_INTERACTION = "needs_interaction"
SOURCE_INVALID = "source_invalid"
OAUTH_DENIED = "oauth_denied"
OAUTH_EXPIRED = "oauth_expired"
OAUTH_REJECTED = "oauth_rejected"
SINK_FAILED = "sink_failed"
TRANSPORT_FAILED = "transport_failed"
TIMEOUT = "timeout"
CANCELLED = "cancelled"
@dataclass(frozen=True)
class JobResult:
source_id: str
status: JobStatus
reason_code: str
attempt_number: int = 1
sink_receipt_fingerprint: Optional[str] = None
def __repr__(self) -> str:
return (
f"JobResult(source_id=<redacted>, status={self.status.value!r}, "
f"reason_code={self.reason_code!r}, attempt_number={self.attempt_number})"
)
class PipelineState(str, Enum):
QUEUED = "queued"
PREPARED = "prepared"
ACTIVE = "active"
RETRY_WAITING = "retry_waiting"
IMPORTED = "imported"
TERMINAL = "terminal"
@dataclass(frozen=True)
class PreparedJob:
source: SourceRecord
source_fingerprint: str
flow: DeviceFlow
flow_created_monotonic: float
job_id: int
attempt_number: int
task_number: Optional[int] = None
def __repr__(self) -> str:
return (
"PreparedJob(source=<redacted>, flow=<redacted>, "
f"attempt_number={self.attempt_number})"
)
@dataclass(frozen=True)
class CompletionJob:
prepared: PreparedJob
def __repr__(self) -> str:
return (
"CompletionJob(source=<redacted>, flow=<redacted>, "
f"attempt_number={self.prepared.attempt_number})"
)
+181
View File
@@ -0,0 +1,181 @@
import asyncio
import base64
import binascii
import json
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Awaitable, Callable
from urllib.parse import urlencode
import httpx
from .models import DeviceFlow, OAuthCredential
@dataclass(frozen=True)
class XAIProfile:
client_id: str
scope: str
version: str = "xai-device-v1"
@classmethod
def default(cls):
return cls(
client_id="b1a00492-073a-47ea-816f-4c329264a828",
scope="openid profile email offline_access grok-cli:access api:access",
version="grok-cli-device-v1",
)
class XAIProtocol:
DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration"
MAX_BODY = 64 * 1024
def __init__(
self,
client: httpx.AsyncClient,
profile: XAIProfile,
sleep=None,
default_poll_interval=5.0,
):
self.client = client
self.profile = profile
self.sleep = sleep or asyncio.sleep
self.default_poll_interval = float(default_poll_interval)
self._discovered = None
@staticmethod
def _allowed_url(value):
from urllib.parse import urlparse
parsed = urlparse(value)
hostname = parsed.hostname
return bool(hostname) and parsed.scheme == "https" and (
hostname == "x.ai" or hostname.endswith(".x.ai")
)
async def _json(self, response):
body = await response.aread()
if len(body) > self.MAX_BODY:
raise RuntimeError("response body exceeds limit")
try:
return json.loads(body)
except (TypeError, ValueError) as exc:
raise RuntimeError("invalid JSON response") from exc
async def discover(self):
response = await self.client.get(self.DISCOVERY_URL, follow_redirects=False)
if response.status_code // 100 != 2:
raise RuntimeError("discovery rejected")
document = await self._json(response)
device_endpoint = document.get("device_authorization_endpoint")
token_endpoint = document.get("token_endpoint")
if not device_endpoint or not token_endpoint or not all(
self._allowed_url(url) for url in (device_endpoint, token_endpoint)
):
raise ValueError("discovery endpoint failed x.ai HTTPS allowlist")
self._discovered = (device_endpoint, token_endpoint)
return self._discovered
async def start_device_flow(self):
device_endpoint, token_endpoint = await self.discover()
response = await self.client.post(
device_endpoint,
data={"client_id": self.profile.client_id, "scope": self.profile.scope},
follow_redirects=False,
)
if response.status_code // 100 != 2:
raise RuntimeError("device authorization rejected")
document = await self._json(response)
try:
device_code = document["device_code"]
user_code = document["user_code"]
base_url = document.get("verification_uri") or document["verification_url"]
expires_in = int(document["expires_in"])
except (KeyError, TypeError, ValueError) as exc:
raise RuntimeError("invalid device authorization response") from exc
if not self._allowed_url(base_url):
raise ValueError("verification URL failed x.ai HTTPS allowlist")
interval = float(document.get("interval", self.default_poll_interval))
verification_url = document.get("verification_uri_complete")
if verification_url is None:
verification_url = (
f"{base_url}{'&' if '?' in base_url else '?'}"
f"{urlencode({'user_code': user_code})}"
)
if not self._allowed_url(verification_url):
raise ValueError("verification URL failed x.ai HTTPS allowlist")
return DeviceFlow(device_code, user_code, verification_url, expires_in, interval, token_endpoint)
async def poll_token(self, *, endpoint, flow, timeout):
deadline = time.monotonic() + timeout
interval = max(0.0, float(flow.interval))
while time.monotonic() < deadline:
response = await self.client.post(
endpoint,
data={
"client_id": self.profile.client_id,
"device_code": flow.device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
follow_redirects=False,
)
document = await self._json(response)
if response.status_code // 100 == 2:
return self._credential(document, endpoint)
error = document.get("error")
if error in {"authorization_pending", "slow_down"}:
interval += 1 if error == "slow_down" else 0
await self.sleep(interval)
continue
if error == "access_denied":
raise RuntimeError("oauth_denied")
if error == "expired_token":
raise RuntimeError("oauth_expired")
raise RuntimeError("oauth_rejected")
raise RuntimeError("oauth_expired")
@staticmethod
def _jwt_subject(token):
if not isinstance(token, str):
return None
parts = token.split(".")
if len(parts) != 3 or not parts[1]:
return None
payload = parts[1] + "=" * (-len(parts[1]) % 4)
try:
claims = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
except (binascii.Error, UnicodeDecodeError, ValueError):
return None
if not isinstance(claims, dict):
return None
for claim in ("sub", "principal_id"):
value = claims.get(claim)
if isinstance(value, str) and value:
return value
return None
@staticmethod
def _credential(document, endpoint):
if not document.get("access_token") or not document.get("refresh_token"):
raise RuntimeError("oauth_rejected")
now = datetime.now(timezone.utc)
expires_in = int(document.get("expires_in", 3600))
expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, timezone.utc).isoformat().replace("+00:00", "Z")
subject = (
XAIProtocol._jwt_subject(document.get("id_token"))
or XAIProtocol._jwt_subject(document["access_token"])
or document.get("sub")
)
return OAuthCredential(
access_token=document["access_token"],
refresh_token=document["refresh_token"],
id_token=document.get("id_token"),
token_type=document.get("token_type"),
expires_in=expires_in,
expires_at=expires_at,
last_refresh=now.isoformat().replace("+00:00", "Z"),
subject=subject,
token_endpoint=endpoint,
)
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more