diff --git a/AGENTS.md b/AGENTS.md index c012a31..a0e581b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,443 @@ -# 项目核心规范与提示词约束 +# 逆向工程 Agent 工作指导(通用版) -## Python 环境与依赖管理规范(唯一强制) +> **本文件的用法**:动手前先看 **§0 一页速查**定位场景;动手时按 **§3 SOP** 执行; +> 卡住或要下结论时查 **§5 经验清单** 与 **附录 A(自身 bug 排查)**; +> 每轮结束按 **§6.2 验收清单**核对。 +> +> **三条铁律**(先记这三句,其余都是展开): +> 1. **服务端是最高裁判**——不校验的东西根本不逆; +> 2. **工具先于结论**——把"读代码"变成"查数据库",把"猜公式"变成"捕获值对照"; +> 3. **绝不做位置假设**——布局会随输入漂移,用「内容特征 + 数学约束」识别结构。 -1. **依赖声明(唯一支持)**: - - 依赖声明**仅支持 `pyproject.toml`**。 - - **严禁使用 `requirements.txt` 或 setup.py 等其他格式**。 +--- -2. **依赖安装与管理(唯一允许)**: - - 安装/添加依赖**必须且只能使用 `uv add `**(移除依赖使用 `uv remove `)。 - - **严禁使用 `pip`、`pip3`、`uv pip install` 等任何其他安装方式**。 +## 目录 -3. **虚拟环境运行规范**: - - 所有 Python 脚本、测试、打标训练、采集与逆向分析代码必须在当前项目的 **`.venv`** 虚拟环境中运行。 - - 执行 Python 命令必须通过 `uv run python ...` 或显式调用 `.venv/bin/python ...`。 +| 章节 | 内容 | 什么时候看 | +| --- | --- | --- | +| [§0](#0-一页速查) | 场景 → 动作速查表 | **每次动手前** | +| [§1](#1-工程基础约束) | Python / Git / 进度管理 | 项目初始化、每个里程碑 | +| [§2](#2-核心思路自顶向下) | 四问决策链、四层模型、铁律、分层交付 | 定路线时 | +| [§3](#3-标准作业流程sop) | 反混淆 / 反平砍 / 反 VM / 反加密 / 成本闸门 | 拿到源码后 | +| [§4](#4-工具链建设) | 为什么要工具、五件套、验收标准 | **动手读代码之前** | +| [§5](#5-实战经验检查表) | 10 条踩坑沉淀 | 下结论前 | +| [§6](#6-工作节奏与验收) | 每轮节奏、交付验收、卡住升级 | 每轮开始/结束 | +| [附录 A](#附录-a-自身-bug-排查清单) | 自身 bug 高频陷阱 | **推翻任何结论之前** | +| [附录 B](#附录-b-信号到动作速查) | 信号 → 动作 | 识别不出东西时 | -4. **红线禁令**: - - **严禁直接调用系统 Python**(包括但不限于 `/usr/bin/python3`、全局 `python`、`python3` 等)。 - - **严禁使用除 `pyproject.toml` 以外的任何依赖声明文件**。 - - **严禁使用除 `uv add` 以外的任何依赖安装命令**。 +--- + +## §0 一页速查 + +| 你现在的处境 | 立刻做什么 | 去哪节 | +| --- | --- | --- | +| 刚拿到一个接口 / 抓包 | **先做服务端容忍度实验**(变异参数看响应),画生死线 | [§2.1](#21-四问决策链) | +| 服务端不校验某参数 | **停止逆向它**,记入文档 | §2.1 | +| 拿到混淆 JS/源码 | 先建工具(清点 + 提取 + 追踪 + 查询),再读 | [§4](#4-工具链建设) | +| 代码读不懂、循环套 switch | 识别为控制流平坦化,按 SOP 还原 | [§3.2](#32-控制流平坦化还原反平砍) | +| 有大 switch + 数字数组 | 判定为字节码 VM,先反平砍解释器再写反汇编器 | [§3.3](#33-字节码-vm-还原devirtualization) | +| 输出是乱码/定长串 | **先试解码**(剥头 → 标准 base64/hex → 魔改码表) | [§3.4](#34-加密与编码分析反加密) | +| 要猜某个字段的算法 | **别猜**:运行时捕获该字段来源值,在输出里搜它的大端编码定位偏移 | [§5.2](#52-完整性字段校验和长度的通用识别法) | +| 公式在单样本上对上了 | 立刻做**多维扰动 ≥10 样本 + 真实样本 + 运行时对照**三重验证 | [§5.5](#55-逐字节对拍的适用边界认清不可达目标) | +| 结论被推翻 | 在文档**显式修正**并登记"已排除的假设" | [§5.7](#57-记录被推翻的假设负结果也是资产) | +| 深挖几轮仍卡住 | 回 §0 重新问"服务端到底在不在乎这个细节" | [§6.3](#63-卡住时的升级路径) | + +--- + +## §1 工程基础约束 + +### 1.1 Python 项目 +- 依赖用 **uv** 管理,**项目级 venv**:`uv venv` + `uv add <包名>` +- 依赖**按需安装**:用到哪个加哪个,不要预先批量安装 + +### 1.2 Git +- commit 信息用**中文**:`类型(范围): 一句话总结` + 空行 + 要点列表(做了什么 / 为什么 / 怎么验证的) +- **每个有意义的里程碑单独提交**,不攒大提交 +- 提交信息含特殊字符时用 `git commit -F <文件>`,避免 shell 引号踩坑 + +### 1.3 进度与证据管理(长任务强制) +- **任何进度、证据、灵感都落盘到文档**,不依赖对话记忆 +- 按证据分类登记,四栏固定: + + | 栏位 | 要求 | + | --- | --- | + | **已知** | 附证据(实验/抓包/源码位置)+ 确信度 | + | **未知** | 附当前假设 + 确信度 | + | **目标** | 本轮要推进到哪 | + | **下一步** | 具体动作,可被下一轮直接执行 | + +- 结论必须带来源;没有证据的判断**标注"假设"** +- **推翻旧结论时显式修正文档**,不留过时错误判断(可另设"早期误判记录"备查) +- 不经意的灵感、异常现象也要记录——它们经常是突破口 + +### 1.4 交付物形态 +- 产物是**资产**:可重复生成、可 diff、可复用,不是一次性分析结论 +- 深挖成果即使当前用不上也**归档为储备**,不删除 +- 每个交付物配一个**一键复验入口**(如 `selftest`) + +--- + +## §2 核心思路:自顶向下 + +### 2.1 四问决策链 + +``` +第 0 问:服务端到底校验什么? ← 永远的第一步,能砍掉 90% 工作量 +第 1 问:目标数据的结构是什么? +第 2 问:输入特征集是什么? +第 3 问:变换算法是什么? +最后 :用目标语言原生重构,跳过一切 VM / 环境层 +``` + +**第 0 问的标准动作**:对参数做变异(截断 / 置空 / 随机体 / 删字段 / 改版本号),用服务端响应画生死线。 + +- 服务端**不校验**的 → 零工作,记入文档 +- **只校验格式/长度** → 结构正确的合成包即可,无需逐字节还原 +- **强校验** → 才值得还原算法;即便如此也优先还原"协议结构"而非"采集过程"(指纹值可用固定真实画像) + +### 2.2 四层解构模型 + +| 层 | 内容 | 作用 | +| --- | --- | --- | +| **L1** | 打包与模块拓扑 | 圈定核心加签模块,剔除开源库噪音 | +| **L2** | 混淆层(字符串表 / 平坦化 / 代理函数)| 还原出可读结构 | +| **L3** | 虚拟机与探针层(字节码调度 / 环境检测)| 得到操作码语义 | +| **L4** | **数据协议层**(组包 / 压缩 / 加盐 / 自定义编码)| **最终目标** | + +自顶向下逐层剥离,但**注意力始终放在 L4**——L1/L2/L3 都是为读懂 L4 服务的。 + +### 2.3 三条铁律(展开) + +#### 铁律 1:服务端是最高裁判 +见 §2.1。**动手逆向任何算法之前先做容忍度实验。** + +#### 铁律 2:拒绝盲目补环境(打地鼠陷阱) +错误做法:`window is not defined` 就补 window,报错就挂空函数,无休止缺啥补啥。 + +| 缺陷 | 说明 | +| --- | --- | +| 知其然不知其所以然 | 不理解风控意图与数据结构 | +| 极度脆弱 | 发版即全盘报废 | +| 性能低下 | 依赖 JSDOM/Headless 无法高并发 | +| 易被探测 | Proxy/堆栈泄露伪造痕迹被拉黑 | + +**允许的最低限度**:为让探针跑通而供给"真实语义的值"(**有明确清单、有限次数**),目的是**采集语义**。 +一旦开始无清单地连续打补丁,立即停下回到自顶向下。 + +#### 铁律 3:整体还原优先,拒绝零敲碎打 +- 反混淆 / 还原要做就**一次性全量**(常量池整体译出、全源码静态替换),产出可复用资产 +- **静态常量优先**:译码器输出与运行时无关时,离线批量译出存表,不在运行时复刻译码器 +- 追踪 / 插桩注意开销形态:**热路径上禁止跨边界函数调用与字符串分配** + (用 TypedArray 直写、计数器、采样),否则会误判为"反调试"——**先排除自身工具副作用再下结论** + +### 2.4 分层交付(不要追求一步到位的完美纯算) + +| 级别 | 形态 | 成本 | 适用 | +| --- | --- | --- | --- | +| **① 纯算** | 目标语言原生实现 | 高(逆向成本)| 最优:高并发、零依赖、抗环境变化 | +| **② 半自动** | 浏览器/沙箱低频维护会话与参数 + 纯算高频发包 | 中 | 算法某部分难还原但输入维度小 | +| **③ 桥接** | Node V8 沙箱托管原 JS 暴露接口 | 低 | 保底,抗小版本更新 | + +- **先验证低层级方案是否已被服务端接受**(§2.1),被接受就不要升级成本 +- 三级方案在代码里应是**同一接口的三个实现**,上层透明,可随时降级 + +--- + +## §3 标准作业流程(SOP) + +### 3.0 通用原则 +- **每还原一层就验证一次**:与原版做功能等价对比(同输入同输出),多层叠加时不许跳层 +- 一切"静态读不懂"的部分,**优先动态采集语义**(探针 / 追踪),而不是硬读 +- 每种变换都有固定的三段式:**识别特征 → 标准解法 → 验证方式** + +### 3.1 混淆还原(AST 反混淆) + +**识别特征**:十六进制变量名、字符串数组+下标取值、代理函数、控制流平坦化、死代码注入、逗号表达式链。 + +**标准管线**(Babel/swc AST,缺一不可): + +1. **常量内联**:字符串数组 + 解密函数 → 沙箱预执行 → 把所有 `dec(a,b)` 调用点静态替换为字面量 + (输入固定输出固定的译码器,离线批量译出存表即可,不必理解译码器内部) +2. **代理函数消除**:`function add(a,b){return a+b}` 类转发函数折叠为原生表达式 +3. **死代码剔除**:恒假分支、无用逗号表达式 +4. **产物必须可执行**:每轮 AST 变换后做语法校验 + 功能等价验证,否则后续分析建立在坏产物上 + +> ⚠️ 第 1 步是后面一切搜索的前提:**不内联就搜不到语义**(`platform` 会写成 `n(l[11],null,17,X)`)。 + +### 3.2 控制流平坦化还原(反平砍) + +**识别特征**:`for(;;){switch(状态变量){...}}`;状态字两级编码(如 `case = 31&v`,`子 = 31&(v>>5)`)。 + +**SOP**: + +0. **先造工具,再读代码**(不要手工抠案例)——目标是把"读机器"变成"查数据库": + + ``` + ① AST 清点全文件状态机 → 起始/结束/状态变量/mask/shift/判别式/子状态变量/各 case 区间 + ② 状态提取 → 每个 (case, 子状态) → {约束, 代码, 后继状态} + ③ 运行时轨迹追踪 → TypedArray 环形缓冲记录实际状态序列 + ④ 执行序渲染 → 按真实顺序排列的代码片段 = 算法正文 + ⑤ 查询 CLI → 搜状态代码 / 查某寄存器写入者 / 渲染某机器 + ``` + + 工具一旦成型,"这个字段谁写的""这段代码在哪台机器"都是秒级查询。 + +1. **状态提取必须用 AST,不要手写括号配平**:字符串/正则字面量里的 `{` `}` 会骗过配平器 + (实战中 `"[\\s\\S]{1,3}"` 这类正则曾导致 case 边界全错) +2. 与**运行时轨迹联合**:光有静态分支图不够,用真实执行序列排除未执行分支 +3. **交叉验证**:工具提取出的转移必须与运行时观测到的转移**逐条对齐**,能对齐才敢用 +4. 输出"执行叙事":按真实顺序排列的已解析代码 = 算法正文 +5. **多层嵌套**:外层 case 体里可能是**另一个平砍机**(状态变量名不同)→ 递归处理; + 也可能整段是字节码程序(见 §3.3) + +> 插桩注意:热路径禁函数调用/字符串分配(跨 realm 调用会内存爆炸,易误判为反调试)。 + +### 3.3 字节码 VM 还原(devirtualization) + +**识别特征**:巨大的 `switch` 操作码分发器 + 数字数组字面量(`push(18,1,4,0,...)`)+ Uint8Array 缓冲 + 闭包式子程序调用。 + +**SOP**: + +1. **先判定"是不是 VM"**:操作码分发器(读数字数组驱动 switch)+ 字节码缓冲 + 虚拟寄存器/栈 → 是 +2. 解释器通常自己也是平砍代码 → 按 §3.2 反平砍,得到**操作码语义表** + (每条 handler = 一条指令语义:寄存器读写 / 属性存取 / 算术 / 调用 / 闭包创建 / 异常) +3. 提取字节码程序数组(含二进制字面量操作数) +4. 写**反汇编器**:操作码表 × 字节码 → 可读指令流(机械工作,输入已齐) +5. 读指令流得到算法;子程序调用(闭包创建)递归展开 + +> **解释器 + 字节码 = 完整算法,两者缺一不可**;常量池在字节码操作数或独立表中。 + +### 3.4 加密与编码分析(反加密) + +**先分类,再决定策略**——编码可逆,加密需要密钥。 + +**第 1 步 判定类型** + +| 观察 | 判定 | +| --- | --- | +| 输出字符集是标准/魔改 base64、hex | **编码类** → 直接解码看明文结构 | +| 同环境多次采样输出逐字节大范围变化,且与输入无固定对应 | **加密类**(含 nonce / 滚动键)| +| 定长输出 + 雪崩效应 | **哈希/签名类** | + +**第 2 步 分类处理** + +- **编码类**:剥头(版本前缀)→ 试标准 base64/hex(补 padding)→ 失败则找**魔改码表** + (在常量池里搜 64~66 字符表)或流式变换(逐字符 XOR / 位移,常见 `charCode ^ 常数`) +- **加密类**: + - 差分分析:同环境改单一输入,看密文哪些区域变化 → 定位 nonce 区 / 指纹区 / 密文区 + - 找密钥材料:滚动键常见 `key = (key*m + c) % 256`、逐字符 XOR、查表替换 + —— 在源码里搜算术特征(`%256`、`^`、异常常数) + - 密钥来自环境/会话(cookie 等)时,纯算只需把该输入**参数化**,无需破解密钥本身 +- **哈希/签名类**:先试常见族(MD5 / SHA1 / HMAC),用「签名 = H(公知字段拼接)」假设逐个拼接组合验证 + +**第 3 步 字节级解剖(对任意黑盒输出)** + +- 逐字节差分(多样本对比):恒定字节 = 指纹/版本;变化字节 = nonce/时间戳/密文 +- 找时间戳:在变化区搜当前 epoch ms/sec 的大端/小端表示 +- 区段边界:半恒定单字节常是长度 / 分隔符 + +### 3.5 成本闸门(什么该反、什么不该反) + +- 每次深入前重问:**服务端校验这个吗?强度多少?** +- 逆向深度与校验强度匹配:不校验 → 零工作;格式校验 → 只做结构;强校验 → 才还原算法 +- **边界捕获优于逐指令还原**:在协议关键点(编码入口 / 密钥使用点)一次性插桩捕获输入/码表/输出, + 往往一个钩子抵多轮操作码分析 —— 动手前先问"**最少几个边界采样能定契约**" +- **解码先于破解,码表先于差分**:拿到输出先试解码看明文再谈加密; + 乱序码表下用标准解码会产生"看似恒定区"的确定性乱码假象 + —— **差分结论必须建立在已验证的码表之上** +- 已完成的深挖成果归档为储备资产,不删除 + +> 本节只给"识别特征 → 标准解法";**工具化、校验和定位、偏移陷阱、验证边界、自身 bug 清单**见 §4、§5 与附录 A。 + +--- + +## §4 工具链建设 + +> **工具先于结论。** 凡涉及"找代码边界 / 找调用点 / 找赋值点",一律上 AST;正则只用于在**已定位的小片段**里粗筛。 + +### 4.1 五件套(控制流平坦化场景的最小工具集) + +| # | 工具 | 输入 → 输出 | 解决什么 | +| --- | --- | --- | --- | +| ① | **状态机清点** | 全文件 → 机器清单(起始/状态变量/掩码/判别式/case 区间)| 先知道"有多少台机器" | +| ② | **状态提取** | 单机器 → `(case, 子状态) → {约束, 代码, 后继}` | 把机器拆成可查的状态表 | +| ③ | **运行时追踪** | 运行一次 → 状态序列(环形缓冲)| 排除未执行分支、验证静态提取 | +| ④ | **执行序渲染** | 状态表 + 轨迹 → 按序排列的代码叙事 | 得到"算法正文" | +| ⑤ | **查询 CLI** | 正则/寄存器名 → 命中的状态与所属机器 | 秒级回答"谁写了它" | + +### 4.2 工具验收标准 + +- 能回答三个问题:**「这个字段谁写的」「这段代码在哪」「这台机器有多少状态」** +- 答案必须与**运行时观测一致**(否则工具在骗你) +- **可复用**:一次投入,后续所有分析都变成查询 + +### 4.3 配套的运行时捕获工具 + +| 捕获对象 | 用途 | +| --- | --- | +| 累加器最终值 | 在输出里搜其大端/小端编码 → **反推字段偏移与整块布局**(§5.2)| +| 中间缓冲区内容 | 判断校验和的**真实求和对象**(常不是最终载荷)| +| 子程序返回值 | 定位"这块数据由哪个子程序产出" | +| 环境属性读取点 | 得到**有限的有效输入特征集** | + +**探针可复现性清单**(不满足则实验不可复现,会浪费时间追不存在的"算法差异"): + +- [ ] 固定时钟(`Date` / `performance` / `timeOrigin`) +- [ ] 固定 PRNG(`Math.random` / `crypto.getRandomValues` / `randomUUID`) +- [ ] 明确"每进程只调用一次"或显式复位累积状态 +- [ ] 明确区分**初始化时刻**与**调用时刻**(见 §5.4) + +--- + +## §5 实战经验(检查表) + +> 每条都来自"踩过坑再总结",附**识别信号 → 标准动作**。 + +### 5.1 工具先于结论 +- 正则处理代码 = 技术债;代码结构一律 AST +- **先内联再搜索**:混淆常量必须先批量内联成字面量,搜出的代码才有语义 +- 工具要可复用、可查询;一次投入,后续全是查询 + +### 5.2 完整性字段(校验和/长度)的通用识别法 +- **布局模式**:二进制载荷常是 `[2字节校验和][块内容]` 顺序拼接(多块) +- **求和形式**:`校验和 = Σ(块内容 & MASK)`,且**块内嵌套的其它校验和字段在计算时按 0 计** + (信号:中间缓冲区对应位置是 0,最终载荷那里已被后续写入覆盖) +- **MASK 是常量池里的数**:同一源码常有**多个同类累加器、掩码各不相同** → 要**列全**,别找到一个就以为找全 +- **杀手锏**:运行时捕获累加器最终值 → 在输出里**搜索该值的大端/小端编码** + → 命中位置即字段偏移;多个字段一起做即可反推**整块布局**(比盲试公式高效一个数量级) +- **不要只试通用哈希族**(CRC/Adler/FNV/Murmur/区间和): + 自研校验和多以"逐字节掩码求和"出现 —— 先按上一条定位字段,再回源码读公式 + +### 5.3 绝不做位置假设(最大的坑) +- **块长会随环境数据变化 → 后续所有偏移漂移**。 + 单画像验证通过的"固定偏移"(如"校验和一定在 [78:80]")换画像立刻失效 +- **正确做法:用「内容特征 + 数学约束」识别结构,而不是用偏移** + - 内容特征:块头魔数(如块起始固定是 `ff 7f 75 ...`) + - 数学约束:`Σ(块 & MASK) == 该块前面的 2 字节` + - 两者同时满足 → 唯一确定块的位置与长度 +- **任何偏移结论都要跨样本 / 跨画像复验**,否则不要写进代码 + +### 5.4 把"时间"和"随机"当作独立实验维度 +- **时间层次解耦实验**:区分「模块初始化时刻」与「请求/调用时刻」——两种变化各跑一次 + - 若"固定 init、变化 call"导致**载荷零差异** ⇒ 所有时间字段都来自 init + - 这一次实验就定下整套回填策略(把所有时间字段按**同一目标 init 时刻**统一回填) +- **累积状态**:同进程重复调用常使输出变长/变化(内部记账)。 + 确定性目标应定义为"**模块初始化后的首次调用**" +- 随机源不固定时,不要下任何"算法差异"结论(先按 §4.3 清单排查) + +### 5.5 逐字节对拍的适用边界(认清不可达目标) +- 逐字节对拍**只在受控随机源下才有意义**。 + 真实环境每次调用含随机区,同会话相隔数秒的两个真实样本可有**上千字节不同** + —— 这不是"还没做到",而是**不可达目标** +- **验收标准要分层**: + + | 层级 | 内容 | + | --- | --- | + | ① | 受控环境**逐字节一致**(证明算法还原正确)| + | ② | 真实样本上验证**不变量与公式**(版本、校验和、时间字段关系、结构可自动定位)| + | ③ | **运行时插桩值 == 输出字段**(三方对照)| + +- **三重验证法**:多样本(≥10,多维扰动)+ 真实样本 + 运行时捕获对照。 + 只靠单一样本"公式对上"极易是巧合(**弱校验和尤其容易假命中**) + +### 5.6 自身工具 bug 的高频陷阱(先怀疑自己) +动手改结论前逐条排查,详见 [附录 A](#附录-a-自身-bug-排查清单)。 + +### 5.7 记录被推翻的假设(负结果也是资产) +- 文档里除"已知/未知"外,还要有**"已排除的假设"**:试过什么、怎么否定的、证据是什么 +- **负结论要写清适用条件**,否则未来会被误用 +- 示例(本项目实战中被推翻的判断):早期"31% 常量指纹"、"某字段 = 某累加器"、"真机块结构与沙箱不同" + —— 全部靠负结果登记避免了重复投入 + +### 5.8 标定式交付(算法难还原但输入维度小) +- 当某部分算法难完整还原,但其输入维度很小(时间、设备画像)时,交付 + **「模板 + 一次探针标定 + 定点回填」**: + + | 环节 | 做法 | + | --- | --- | + | 模板 | 一次真实采样(含全部画像数据)| + | 标定 | 再用一次**不同取值**的采样,反解变换类型(异或/加法)与常数 | + | 回填 | 按目标取值重算受影响字段(含重新计算受影响的校验和,注意 §5.6 的依赖顺序)| + +- 该方案**自动适配画像差异**(块长 / 偏移 / 常数),比硬编码偏移健壮得多 +- 标定结果**可持久化**(JSON),生成时无需再次采样 + +### 5.9 用回归测试固化每一条已确证的性质 +- 一旦确证某个公式/不变量,**立刻写成断言测试**并把样本打包成 fixtures +- 覆盖范围:编解码往返、捕获样本复现、公式(多样本 + 真实样本)、 + 结构自动发现、跨画像生成、标定流程 +- 交付一个 `selftest` 入口,让别人一条命令复验全部结论 + +### 5.10 分层交付的落地形态 +- 纯算 / 半自动 / 桥接 = **同一接口的三个实现**,调用方无感 +- 服务端一旦收紧、或某画像标定失败,可**立刻降级**而不改调用方 + +--- + +## §6 工作节奏与验收 + +### 6.1 每轮节奏 + +``` +① 读进度文档的「下一步」 → ② 选区分度最大的实验 → ③ 执行并记录证据 +→ ④ 更新「已知/未知/下一步」 → ⑤ 提交(中文 commit)→ ⑥ 下一轮 +``` + +- 实验设计优先做**区分度最大的那个**("不变性实验""容忍度实验"),它能直接决定路线分叉 +- 每轮结束必须有**可验证的产物**(工具/文档/测试/结论),不要只留"分析过程" + +### 6.2 交付前验收清单 + +- [ ] 服务端容忍度实验做了吗?不校验的部分是否已剔除出工作范围? +- [ ] 结论是否**带证据来源**?(实验 / 抓包 / 源码位置) +- [ ] 是否做了**三重验证**(多样本 + 真实样本 + 运行时对照)? +- [ ] 是否做了**跨样本/跨画像复验**(尤其任何偏移结论)? +- [ ] 被推翻的旧结论是否已在文档中**显式修正**? +- [ ] 是否**先怀疑过自己的工具**(附录 A 全过一遍)? +- [ ] 产物是否可复现、可 diff、有一键复验入口? +- [ ] 分层交付是否已就位(最低成本方案先验证)? +- [ ] 是不是把"不可达目标"(如真实环境逐字节对拍)误当成了"待办"? + +### 6.3 卡住时的升级路径 + +| 症状 | 升级动作 | +| --- | --- | +| 同一细节卡住 > 几轮 | 回 §2.1 第 0 问:**服务端到底在不在乎这个细节** | +| 静态读不懂 | 改为**动态采集语义**(探针/追踪),不要硬读 | +| 公式猜不出来 | 改为**运行时捕获 + 定位偏移**(§5.2),或改走**标定式交付**(§5.8)| +| 插桩就崩/卡死 | 先做 **no-op 对照实验**排除自身工具副作用,再判断是否反调试 | +| 样本之间结论矛盾 | 检查是否**编码层/码表轮换**或**块长漂移**(§5.3、附录 A 第 5/6 条)| + +--- + +## 附录 A 自身 bug 排查清单 + +> **推翻任何结论之前**,先逐条排查以下陷阱——它们在本项目实战中全部真实发生过。 + +| # | 陷阱 | 典型症状 | 正确做法 | +| --- | --- | --- | --- | +| 1 | **运算符优先级** | Python `X & M == v` 被解析成 `X & (M == v)` | 一律加括号:`(X & M) == v` | +| 2 | **多样本扫描用统一上界** | 各样本长度不同(如 1074~1171)导致长样本被截断 | 用**各自长度** | +| 3 | **依赖顺序** | 校验和 A 的覆盖区间包含校验和 B | **必须先算被包含者**,再算包含者 | +| 4 | **括号配平不考虑字面量** | 字符串/正则里的 `{ }` 破坏配平 | 用 **AST** 取精确区间 | +| 5 | **编码层陷阱** | 参数在 **body** 而非 header/cookie/query;urlencoded 把 `+` 变空格(而自定义码表恰含 `+`)| 先确认数据位置;解码前**还原 `+`** | +| 6 | **码表/版本轮换** | 用一套码表解另一版本,得到"看似合理的乱码" | 先确认版本,**每版本单独取码表** | +| 7 | **工具解析失败被忽略** | 提取结果与运行时不一致却继续用 | 提取结果必须与**运行时轨迹逐条对齐** | +| 8 | **静默异常** | `except: pass` 掩盖了真实错误 | 断言 + 显式失败 | + +--- + +## 附录 B 信号到动作速查 + +| 观察到的信号 | 最可能的性质 | 下一步动作 | +| --- | --- | --- | +| `for(;;){switch(x)}` + 状态字 | 控制流平坦化 | §3.2 | +| 巨大 switch + 数字数组 + Uint8Array | 字节码 VM | §3.3 | +| 字符串数组 + 下标取值 | 字符串混淆 | §3.1 常量内联 | +| 输出 = 版本前缀 + 定长乱码 | 编码类 | §3.4 第 2 步(试魔改码表)| +| 同环境多次输出大范围变化 | 加密 / 含 nonce | §3.4 第 2 步(差分定位)| +| 输出定长 + 输入微改即全变 | 哈希 / 签名 | §3.4(试常见族 + 拼接组合)| +| 载荷中 `[小值][定长块]` 交替 | `[校验和][块内容]` 布局 | §5.2 | +| 常量字节 + 变化字节混合 | 指纹 + nonce/时间戳 | §3.4 第 3 步(逐字节差分)| +| 单画像能对上、换画像就错 | 偏移漂移 | §5.3 | +| 同一实验两次结果不同 | 随机源未固定 | §4.3 探针可复现性清单 | diff --git a/tcaptcha/__init__.py b/tcaptcha/__init__.py index 52623a8..e41864c 100644 --- a/tcaptcha/__init__.py +++ b/tcaptcha/__init__.py @@ -1,15 +1,64 @@ # -*- coding: utf-8 -*- -"""Tencent Captcha Package""" +"""Tencent Captcha Package -from .client import TencentCaptchaClient -from .crypto import extract_eks, generate_collect +核心能力: + * TencentCaptchaClient —— 纯协议客户端 (prehandle/PoW/tdc.js/图片/verify/getsig) + * TextClickSolver —— 文字点选 (2404) 求解器: 颜色分割 + OCR 集成指派 + * CaptchaSolver —— 图标点选 (2408) 求解器: YOLOv8 + ResNet18 + * EnsembleOcr —— ddddocr + PP-OCRv4 概率级集成识别 + * generate_telemetry(_node) —— collect/eks 遥测密文生成 (Node 沙箱执行真实 tdc.js) + * OneMsAdapter —— 毫秒 1ms.run 业务适配器 +""" + +from .client import ( + KIND_ICON_CLICK, + KIND_SLIDER, + KIND_TEXT_CLICK, + Challenge, + TencentCaptchaClient, + classify_challenge, +) +from .crypto import ( + encrypt_string, + extract_eks, + generate_collect, + generate_telemetry, + generate_telemetry_node, +) +from .glyph_solver import Glyph, SolveResult, TextClickSolver, parse_instruction +from .ocr_engines import DdddOcrEngine, EnsembleOcr, PpOcrEngine +from .one_ms_adapter import OneMsAdapter +from .proxy import check_proxy, fetch_proxy, parse_proxies from .slicer import slice_prompt_image from .solver import CaptchaSolver __all__ = [ + # 协议客户端 "TencentCaptchaClient", + "Challenge", + "classify_challenge", + "KIND_TEXT_CLICK", + "KIND_ICON_CLICK", + "KIND_SLIDER", + # 求解器 + "TextClickSolver", + "SolveResult", + "Glyph", + "parse_instruction", "CaptchaSolver", - "extract_eks", - "generate_collect", "slice_prompt_image", + "EnsembleOcr", + "DdddOcrEngine", + "PpOcrEngine", + # 遥测 / 加密 + "generate_collect", + "generate_telemetry", + "generate_telemetry_node", + "extract_eks", + "encrypt_string", + # 业务适配 / 代理 + "OneMsAdapter", + "fetch_proxy", + "parse_proxies", + "check_proxy", ] diff --git a/tcaptcha/client.py b/tcaptcha/client.py index 5a831df..4640b9d 100644 --- a/tcaptcha/client.py +++ b/tcaptcha/client.py @@ -1,15 +1,25 @@ """ -腾讯天御点选验证码工业级纯协议客户端 (Tencent Turing Captcha Protocol Client) -实现端到端全自动纯协议闭环: -1. GET /cap_union_prehandle 获取初始会话与 PoW 题目 -2. 毫秒级求解 Proof of Work 哈希碰撞 -3. 并发拉取动态混淆的 tdc.js 脚本 -4. POST /cap_union_new_getsig 获取当前轮次题目与图片 -5. YOLOv8n + ResNet18 模型进行视觉检测、分类与全局非冲突点选排序 -6. 纯算法还原 (Custom XTEA + 贝塞尔人手微颤) 生成行为轨迹密文 collect 与会话密钥 eks (100% 纯 Python) -7. POST /cap_union_new_verify 提交验证,获取最终 ticket 与 randstr +腾讯天御点选验证码纯协议客户端 (Tencent Turing Captcha Protocol Client) + +覆盖腾讯天御 `cap_union_new` 协议族的完整纯协议闭环: + + 1. GET /cap_union_prehandle 获取会话 sess / PoW 题目 / 题型 / 首道题目 + 2. 本地求解 PoW (MD5 哈希碰撞) + 3. GET 拉取当前会话动态混淆的 tdc.js, 提取会话密钥 eks + 4. GET /cap_union_new_getcapbysig 下载 672x480 背景图 (img_index=1); + 图标题型额外下载 img_index=0 提示图 + 5. 视觉求解: + * 文字点选 (subcapclass 2404) -> tcaptcha.glyph_solver.TextClickSolver + * 图标点选 (subcapclass 2408) -> tcaptcha.solver.CaptchaSolver (YOLO+ResNet18) + 6. 生成行为遥测密文 collect (纯 Python 还原的自定义 XTEA + 贝塞尔轨迹) + 7. POST /cap_union_new_verify 提交 ans/collect/eks/sess/pow_answer + 8. 失败 → POST /cap_union_new_getsig 在同一会话内刷新下一道题并重试 + +题型识别不依赖 subcapclass 数字 (getsig 不返回该字段), 而是根据 +instruction + bg_elem_cfg.click_cfg 推断, 详见 classify_challenge()。 """ +import base64 import hashlib import json import logging @@ -17,8 +27,9 @@ import random import re import sys import time +from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Sequence, Tuple import httpx @@ -26,12 +37,55 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from tcaptcha.crypto import extract_eks, generate_collect -from tcaptcha.solver import CaptchaSolver +from tcaptcha.crypto import extract_eks, generate_telemetry +from tcaptcha.glyph_solver import TextClickSolver, parse_instruction logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("tcaptcha_client") +DEFAULT_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36") + +# 题型 +KIND_TEXT_CLICK = "text_click" # 2404 文字点选 +KIND_ICON_CLICK = "icon_click" # 2408 图标点选 (需要提示图 img_index=0) +KIND_SLIDER = "slider" # 2402 滑块拼图 (本客户端未实现) +KIND_UNKNOWN = "unknown" + + +@dataclass +class Challenge: + """一道待求解的验证码题目""" + + sess: str + instruction: str = "" + img_url: str = "" + kind: str = KIND_UNKNOWN + subcapclass: str = "" + click_cfg: Optional[dict] = None + raw: dict = field(default_factory=dict) + + @property + def targets(self) -> List[str]: + return parse_instruction(self.instruction) + + +def classify_challenge(dyn_show_info: dict, subcapclass: str = "") -> str: + """根据题目配置推断题型 (getsig 不返回 subcapclass, 只能靠结构判断)""" + instruction = (dyn_show_info or {}).get("instruction", "") or "" + click_cfg = ((dyn_show_info or {}).get("bg_elem_cfg") or {}).get("click_cfg") or {} + data_types = click_cfg.get("data_type") or [] + if "DynAnswerType_POS" in data_types or "DynAnswerType_POINT" in data_types: + # 有点选坐标配置: 有目标文字 => 文字点选; 无目标文字 => 图标点选(提示走提示图) + return KIND_TEXT_CLICK if parse_instruction(instruction) else KIND_ICON_CLICK + if "滑块" in instruction or "拖动" in instruction or subcapclass == "2402": + return KIND_SLIDER + if subcapclass == "2404": + return KIND_TEXT_CLICK + if subcapclass == "2408": + return KIND_ICON_CLICK + return KIND_UNKNOWN + class TencentCaptchaClient: BASE_URL = "https://turing.captcha.qcloud.com" @@ -40,275 +94,424 @@ class TencentCaptchaClient: def __init__( self, aid: str = DEFAULT_AID, - solver: Optional[CaptchaSolver] = None, + solver=None, + text_solver: Optional[TextClickSolver] = None, timeout: float = 15.0, user_agent: Optional[str] = None, + entry_url: str = "https://app.rainyun.com/account/reward/earn#", + referer: Optional[str] = None, + wb: str = "2", + js: str = "/tcaptcha-frame.ba387dd1.js", + accept_kinds: Sequence[str] = (KIND_TEXT_CLICK, KIND_ICON_CLICK), + min_posterior: float = 0.95, + pow_timeout: int = 4000000, + inter_attempt_delay: float = 0.25, + telemetry_mode: str = "python_keyed", + proxy: Optional[str] = None, ): - self.aid = aid + """ + telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry): + "python_keyed" (默认) Node **只**从本次构建的 tdc.js 里提取 XTEA 密钥, + collect 的加密与组装全部由纯 Python 完成 (标准 XTEA) + "python" 纯 Python + crypto.py 内置固定密钥 (无 Node 依赖, 兼容宽松 aid) + "node" 在 Node VM 沙箱里整体执行真实 tdc.js 产出 collect/eks + "browser" 在真实 Chrome 里执行真实 tdc.js (CDP 驱动, 环境最真) + "auto" / "auto_keyed" 多后端逐级回退 + + 实测结论 (2026-10-11): + * 雨云 aid=2039519451 用 "python" 提交正确答案 → errorCode=0 + 真实 ticket, + 证明服务端不校验 collect 与 eks 的同源性, 纯 Python 交付形态依然成立。 + * 1ms.run aid=195672344 答题正确时, "python"/"node"/"browser" 三种遥测 + 在服务端风控阶段一律返回 errorCode=12 —— 与该 aid 的风控策略有关, + 不是遥测引擎的问题。详见 har/1ms_dianxuan/ANALYSIS.md。 + """ + self.aid = str(aid) self.timeout = timeout - self.user_agent = user_agent or ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36" - ) - self.solver = solver or CaptchaSolver() + self.user_agent = user_agent or DEFAULT_UA + self.entry_url = entry_url + self.referer = referer or entry_url + self.wb = str(wb) + self.js = js + self.accept_kinds = tuple(accept_kinds) + self.min_posterior = min_posterior + self.pow_timeout = pow_timeout + # 抽题/投票之间的最小间隔, 用于规避腾讯 errorCode 12 (f_limit) + self.inter_attempt_delay = inter_attempt_delay + # 遥测(collect/eks)生成方式: node = 在沙箱里跑真实 tdc.js (推荐); python = 纯算法还原 + self.telemetry_mode = telemetry_mode + # 出口代理: 腾讯天御按 IP 做滚动风控, 触发 errorCode=12 时换 IP 即可恢复 + self.proxy = proxy + + # 图标题型求解器 (惰性加载, 避免只跑文字题型时白加载 56MB 权重) + self._icon_solver = solver + self._text_solver = text_solver self.http_client = httpx.Client( base_url=self.BASE_URL, trust_env=False, # 屏蔽环境代理干扰 + proxy=self.proxy, headers={ "User-Agent": self.user_agent, - "Referer": "https://turing.captcha.qcloud.com/", + "Referer": self.referer, "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }, timeout=self.timeout, follow_redirects=True, ) + # --------------------------------------------------------------- helpers + @property + def text_solver(self) -> TextClickSolver: + if self._text_solver is None: + logger.info("初始化文字点选求解器 (ddddocr + PP-OCRv4 集成)...") + self._text_solver = TextClickSolver() + return self._text_solver + + @property + def icon_solver(self): + if self._icon_solver is None: + from tcaptcha.solver import CaptchaSolver + + logger.info("初始化图标点选求解器 (YOLOv8 + ResNet18)...") + self._icon_solver = CaptchaSolver() + return self._icon_solver + def _solve_pow(self, prefix: str, target_md5: str) -> Tuple[str, int]: """求解 Proof of Work 哈希碰撞""" t0 = time.time() - for i in range(2000000): + for i in range(self.pow_timeout): candidate = f"{prefix}{i}" if hashlib.md5(candidate.encode("utf-8")).hexdigest() == target_md5: calc_time = max(1, round((time.time() - t0) * 1000)) return candidate, calc_time raise RuntimeError(f"PoW MD5 collision search failed for prefix {prefix}") - def _generate_telemetry(self, tdc_code: str, click_points: List[Tuple[int, int]]) -> Dict[str, str]: - """纯 Python 算法生成真实行为轨迹与遥测密文 collect 及 eks (100% 纯算免 Node.js)""" - eks = extract_eks(tdc_code) - collect = generate_collect(click_points, aid=self.aid, user_agent=self.user_agent) - return { - "collect": collect, - "tlg": str(len(collect)), - "eks": eks, + # ------------------------------------------------------------- 1. prehandle + def prehandle(self, tkid: str) -> Tuple[Challenge, dict, str]: + """GET /cap_union_prehandle —— 返回 (题目, comm_captcha_cfg, sid)""" + cb_name = f"_aq_{random.randint(100000, 999999)}" + ua_b64 = base64.b64encode(self.user_agent.encode()).decode() + params = { + "aid": self.aid, + "protocol": "https", + "accver": "1", + "showtype": "popup", + "ua": ua_b64, + "noheader": "1", + "fb": "1", + "aged": "0", + "enableAged": "0", + "enableDarkMode": "0", + "grayscale": "1", + "clientype": "2", + "cap_cd": "", + "uid": "", + "lang": "zh-cn", + "entry_url": self.entry_url, + "elder_captcha": "0", + "js": self.js, + "login_appid": "", + "wb": self.wb, + "tkid": tkid, + "subsid": str(random.randint(1, 5)), + "callback": cb_name, + "sess": "", } + resp = self.http_client.get("/cap_union_prehandle", params=params, + headers={"Referer": self.referer}) + match = re.search(rf"{re.escape(cb_name)}\((.*)\)\s*$", resp.text, re.S) + if not match: + raise RuntimeError(f"prehandle 回调解析失败: {resp.text[:200]}") + data = json.loads(match.group(1)) + dyn = (data.get("data") or {}).get("dyn_show_info") or {} + sub = str(data.get("subcapclass", "")) + challenge = Challenge( + sess=data.get("sess", ""), + instruction=dyn.get("instruction", ""), + img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""), + kind=classify_challenge(dyn, sub), + subcapclass=sub, + click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"), + raw=dyn, + ) + return challenge, (data.get("data") or {}).get("comm_captcha_cfg", {}) or {}, data.get("sid", "") - def solve(self, max_retries: int = 2) -> Dict: + # --------------------------------------------------------------- 2. getsig + def getsig(self, sess: str, tkid: str) -> Tuple[Challenge, str]: + """POST /cap_union_new_getsig —— 在同一会话内刷新题目""" + resp = self.http_client.post( + "/cap_union_new_getsig", + data={"sess": sess, "tkid": tkid}, + headers={"Content-Type": "application/x-www-form-urlencoded", + "Origin": "https://turing.captcha.gtimg.com", + "Referer": "https://turing.captcha.gtimg.com/"}, + ) + payload = resp.json() + if str(payload.get("ret")) != "0": + raise RuntimeError(f"getsig 返回异常: {payload}") + dyn = payload.get("data") or {} + new_sess = payload.get("sess") or sess + challenge = Challenge( + sess=new_sess, + instruction=dyn.get("instruction", ""), + img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""), + kind=classify_challenge(dyn), + click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"), + raw=dyn, + ) + return challenge, new_sess + + # ------------------------------------------------------------ 3. download + def fetch_tdc(self, tdc_path: str) -> str: + resp = self.http_client.get(tdc_path, + headers={"Referer": "https://turing.captcha.gtimg.com/"}) + return resp.text + + def fetch_image(self, img_url: str, img_index: Optional[int] = None) -> bytes: + if img_index is not None: + img_url = re.sub(r"img_index=\d+", f"img_index={img_index}", img_url) + resp = self.http_client.get( + img_url, + headers={"Referer": "https://turing.captcha.gtimg.com/", + "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"}, + ) + return resp.content + + # ------------------------------------------------------------------ solve + def solve(self, max_retries: int = 3, max_refreshes: int = 6) -> Dict: """ - 端到端全自动解题并提交验证 - 返回格式: - { - "success": bool, - "ticket": str, - "randstr": str, - "elapsed_ms": int, - "points": [(x1, y1), (x2, y2), (x3, y3)], - "raw_response": dict, - "error": str - } + 端到端解题并提交验证。 + + 返回: {success, ticket, randstr, elapsed_ms, points, kind, instruction, + raw_response, error} """ start_time = time.time() - active_sess = None - tkid = None - tdc_code = None - pow_answer = None - pow_calc_time = None - last_raw = {} - last_error = "Max retries exceeded or verification rejected" + tkid = str(random.randint(100000000, 999999999)) + sess: Optional[str] = None + eks: Optional[str] = None + tdc_code: Optional[str] = None + pow_answer: Optional[str] = None + pow_calc_time: Optional[int] = None + comm_cfg: Dict = {} + challenge: Optional[Challenge] = None + last_raw: Dict = {} + last_error = "未完成任何一次验证提交" - for attempt in range(max_retries): + attempts = 0 + refreshes = 0 + prehandles = 0 + while attempts < max_retries and refreshes < max_refreshes: try: - # 1. 预检请求 (Prehandle) 或在会话内刷新 - cb_name = f"_aq_{random.randint(100000, 999999)}" - if not tkid: - tkid = str(random.randint(100000000, 999999999)) + # ---- 拿题: 首选 prehandle, 之后用 getsig 在同会话内刷新 + if challenge is None: + challenge, comm_cfg, _sid = self.prehandle(tkid) + prehandles += 1 + logger.info("prehandle -> subcapclass=%s kind=%s instruction=%r", + challenge.subcapclass, challenge.kind, challenge.instruction) + pow_cfg = comm_cfg.get("pow_cfg") or {} + if pow_cfg.get("prefix") and pow_cfg.get("md5"): + pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"], + pow_cfg["md5"]) + logger.info("PoW 命中 %s (%dms)", pow_answer, pow_calc_time) + tdc_code = self.fetch_tdc(comm_cfg.get("tdc_path", "")) + eks = extract_eks(tdc_code) + if not eks: + logger.warning("未从 tdc.js 中提取到 eks, 继续尝试") + else: + challenge, sess = self.getsig(sess or challenge.sess, tkid) + logger.info("getsig -> kind=%s instruction=%r", + challenge.kind, challenge.instruction) - if not active_sess: - # 首次进入:执行 Prehandle - ua_b64 = "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1Mi4wLjAuMCBTYWZhcmkvNTM3LjM2" - params = { - "aid": self.aid, - "protocol": "https", - "accver": "1", - "showtype": "popup", - "ua": ua_b64, - "noheader": "1", - "fb": "1", - "aged": "0", - "enableAged": "0", - "enableDarkMode": "0", - "grayscale": "1", - "clientype": "2", - "lang": "zh-cn", - "entry_url": "https://app.rainyun.com/account/reward/earn#", - "elder_captcha": "0", - "js": "/tcaptcha-frame.ba387dd1.js", - "wb": "2", - "tkid": tkid, - "subsid": str(random.randint(1, 5)), - "callback": cb_name, - } + if challenge.kind not in self.accept_kinds: + # 题型在会话内是固定的 (getsig 不会换题型), 必须重新 prehandle 抽题 + logger.info("题型 %s 不在支持范围 %s, 重新 prehandle 抽题 (已抽 %d 次)", + challenge.kind, list(self.accept_kinds), prehandles) + refreshes += 1 + challenge = None + sess = None + eks = None + pow_answer = None + if self.inter_attempt_delay and refreshes < max_refreshes: + time.sleep(self.inter_attempt_delay) + continue - pre_resp = self.http_client.get( - "/cap_union_prehandle", - params=params, - headers={"Referer": "https://app.rainyun.com/"}, - ) - match = re.search(rf"{cb_name}\((.*)\)", pre_resp.text) - if not match: - logger.warning("Failed to parse prehandle callback, retrying...") + # ---- 视觉求解 + points: List[Tuple[int, int]] + if challenge.kind == KIND_TEXT_CLICK: + bg = self.fetch_image(challenge.img_url, img_index=1) + result = self.text_solver.solve_ex(bg, challenge.instruction) + logger.info("文字点选求解: %s", result.describe()) + if not result.ok: + logger.warning("文字点选求解失败, 刷新题目") + refreshes += 1 + continue + if result.posterior < self.min_posterior: + logger.warning("求解置信度过低 (%.3f < %.2f), 不提交, 刷新题目", + result.posterior, self.min_posterior) + refreshes += 1 + continue + points = result.points + else: # KIND_ICON_CLICK + bg = self.fetch_image(challenge.img_url, img_index=1) + prompt = self.fetch_image(challenge.img_url, img_index=0) + if not prompt: + logger.warning("图标题型未取到提示图, 刷新题目") + refreshes += 1 + continue + points = self.icon_solver.solve(bg, prompt) + if len(points) < 3: + logger.warning("图标点选求解失败, 刷新题目") + refreshes += 1 continue - pre_data = json.loads(match.group(1)) - initial_sess = pre_data.get("sess") - comm_cfg = pre_data.get("data", {}).get("comm_captcha_cfg", {}) - - # 2. 计算 PoW - pow_cfg = comm_cfg.get("pow_cfg", {}) - pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"], pow_cfg["md5"]) - - # 3. 拉取当前会话专属动态 tdc.js - tdc_path = comm_cfg.get("tdc_path", "") - tdc_resp = self.http_client.get( - tdc_path, - headers={"Referer": "https://turing.captcha.gtimg.com/"}, - ) - tdc_code = tdc_resp.text - current_sess = initial_sess - else: - # 会话内刷新:直接使用前一轮返回的 sess - current_sess = active_sess - - # 4. 获取题目信息与图片 URL (POST /cap_union_new_getsig) - sig_resp = self.http_client.post( - "/cap_union_new_getsig", - data={"sess": current_sess, "tkid": tkid}, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Origin": "https://turing.captcha.gtimg.com", - "Referer": "https://turing.captcha.gtimg.com/", - }, - ) - sig_json = sig_resp.json() - if str(sig_json.get("ret")) != "0": - logger.warning(f"getsig returned non-zero ret: {sig_json}") - active_sess = None - continue - - dyn_info = sig_json.get("data", {}) - bg_url = dyn_info.get("bg_elem_cfg", {}).get("img_url", "") - prompt_url = bg_url.replace("img_index=1", "img_index=0") - - # 提取图片 URL 中的最新有效 sess - sess_match = re.search(r"sess=([^&]+)", bg_url) - active_sess = sess_match.group(1) if sess_match else current_sess - - # 5. 下载背景大图与提示图标图 - bg_bytes = self.http_client.get( - bg_url, - headers={"Referer": "https://turing.captcha.gtimg.com/"}, - ).content - prompt_bytes = self.http_client.get( - prompt_url, - headers={"Referer": "https://turing.captcha.gtimg.com/"}, - ).content - - # 6. AI 模型高精度求解 - pts = self.solver.solve(bg_bytes, prompt_bytes) - if len(pts) < 3: - logger.warning("Solver failed to find 3 valid points, retrying...") - continue - - # 7. 格式化答案 ans - ans_list = [ - {"elem_id": 1, "type": "DynAnswerType_POS", "data": f"{pts[0][0]},{pts[0][1]}"}, - {"elem_id": 2, "type": "DynAnswerType_POS", "data": f"{pts[1][0]},{pts[1][1]}"}, - {"elem_id": 3, "type": "DynAnswerType_POS", "data": f"{pts[2][0]},{pts[2][1]}"}, - ] + # ---- 组包并提交 + attempts += 1 + ans_list = [{"elem_id": i + 1, "type": "DynAnswerType_POS", + "data": f"{p[0]},{p[1]}"} for i, p in enumerate(points)] ans_str = json.dumps(ans_list, separators=(",", ":")) - - # 8. 纯算法生成真实行为轨迹 collect 与会话密钥 eks (100% 纯 Python) - telemetry = self._generate_telemetry(tdc_code, pts) - - # 9. 提交验证 POST /cap_union_new_verify + telemetry = generate_telemetry( + tdc_code or "", points, aid=self.aid, user_agent=self.user_agent, + referrer=self.referer, mode=self.telemetry_mode, + ) + logger.info("遥测密文来源=%s collect_len=%s eks_len=%s", + telemetry.get("source"), len(telemetry["collect"]), + len(telemetry.get("eks", ""))) verify_body = { "ans": ans_str, "collect": telemetry["collect"], "tlg": telemetry["tlg"], - "eks": telemetry["eks"], - "sess": active_sess, + "eks": telemetry.get("eks") or eks or "", + "sess": challenge.sess, "tkid": tkid, - "pow_answer": pow_answer, - "pow_calc_time": str(pow_calc_time), + "pow_answer": pow_answer or "", + "pow_calc_time": str(pow_calc_time or 1), } - verify_resp = self.http_client.post( "/cap_union_new_verify", data=verify_body, headers={ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Accept": "application/json, text/javascript, */*; q=0.01", "Origin": "https://turing.captcha.gtimg.com", "Referer": "https://turing.captcha.gtimg.com/", }, ) - v_json = verify_resp.json() - total_elapsed = int((time.time() - start_time) * 1000) + last_raw = v_json + elapsed = int((time.time() - start_time) * 1000) if str(v_json.get("errorCode")) == "0": + logger.info("✅ 验证通过! randstr=%s", v_json.get("randstr")) return { "success": True, "ticket": v_json.get("ticket", ""), "randstr": v_json.get("randstr", ""), - "elapsed_ms": total_elapsed, - "points": pts, + "elapsed_ms": elapsed, + "points": points, + "kind": challenge.kind, + "instruction": challenge.instruction, "raw_response": v_json, "error": "", } + + err_code = str(v_json.get("errorCode")) + if err_code == "12": + last_error = ("触发腾讯接口频控限制 (errorCode: 12, f_limit), " + "建议降低请求频率或更换出口 IP") + logger.error(last_error) + break + if err_code in ("50", "9"): + last_error = f"坐标未命中 (errorCode: {err_code})" else: - err_code = str(v_json.get("errorCode")) - err_msg = v_json.get("errMessage", "") - if err_code == "12": - last_error = f"触发腾讯接口频控限制 (errorCode: 12, f_limit),建议稍候或更换IP" - elif err_code == "50": - last_error = f"验证坐标未命中或会话需刷新 (errorCode: 50)" - else: - last_error = f"验证失败 (errorCode: {err_code}, msg: {err_msg})" + last_error = (f"验证失败 (errorCode: {err_code}, " + f"msg: {v_json.get('errMessage', '')})") + logger.warning(last_error) - logger.warning(f"Attempt {attempt + 1} verify: {last_error}") - last_raw = v_json - # 如果接口返回了新的会话 sess,下一轮直接沿用该会话重试刷新 - new_sess = v_json.get("sess") - if new_sess: - active_sess = new_sess - else: - active_sess = None - except Exception as e: - last_error = f"请求异常: {e}" - logger.error(f"Attempt {attempt + 1} encountered exception: {e}") + # 服务端返回新 sess 时沿用, 否则回到 prehandle + new_sess = v_json.get("sess") + if new_sess: + challenge, sess = self.getsig(new_sess, tkid) + logger.info("verify 后刷新 -> kind=%s instruction=%r", + challenge.kind, challenge.instruction) + else: + challenge = None + sess = None + eks = None + pow_answer = None + except Exception as exc: # 网络/解析异常: 丢弃当前会话重来 + last_error = f"请求异常: {exc}" + logger.error("第 %d 次尝试异常: %s", attempts + 1, exc) + challenge = None + sess = None + eks = None + pow_answer = None - total_elapsed = int((time.time() - start_time) * 1000) + elapsed = int((time.time() - start_time) * 1000) return { "success": False, "ticket": "", "randstr": "", - "elapsed_ms": total_elapsed, + "elapsed_ms": elapsed, "points": [], + "kind": challenge.kind if challenge else "", + "instruction": challenge.instruction if challenge else "", "raw_response": last_raw, "error": last_error, } + def close(self): + self.http_client.close() + def main(): import argparse parser = argparse.ArgumentParser(description="Tencent Captcha Pure Protocol Solver Client") - parser.add_argument("--aid", default="2039519451", help="Captcha AppID (default: 2039519451 for Rainyun)") + parser.add_argument("--aid", default="195672344", help="验证码 AppID (默认毫秒 1ms.run)") + parser.add_argument("--entry-url", + default="https://1ms.run/console/personal/benefits/check-in") + parser.add_argument("--wb", default="1") + parser.add_argument("--retries", type=int, default=3) + parser.add_argument("--telemetry", default="python", + choices=["python", "node", "browser", "auto"]) + parser.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080") + parser.add_argument("--proxy-api", default=None, + help="代理提取 API; 出现 errorCode=12 时自动换 IP 重试") args = parser.parse_args() - client = TencentCaptchaClient(aid=args.aid) - print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid})...") - result = client.solve() + proxy = args.proxy + if not proxy and args.proxy_api: + from tcaptcha.proxy import fetch_proxy + + proxy = fetch_proxy(args.proxy_api) + print(f"[*] 从代理 API 提取到: {proxy}") + + client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url, + referer="https://1ms.run/", wb=args.wb, + proxy=proxy, telemetry_mode=args.telemetry) + print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid}, proxy={proxy})...") + result = client.solve(max_retries=args.retries) + + # errorCode=12 (f_limit) 是 IP 级滚动风控: 换一个出口 IP 再试一次 + if not result["success"] and "12" in str(result.get("error", "")) and args.proxy_api: + print("[*] 触发 f_limit, 换 IP 重试...") + client.close() + proxy = fetch_proxy(args.proxy_api) + print(f"[*] 新代理: {proxy}") + client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url, + referer="https://1ms.run/", wb=args.wb, + proxy=proxy, telemetry_mode=args.telemetry) + result = client.solve(max_retries=args.retries) if result["success"]: print("\n" + "=" * 50) print(" 🎉 验证码纯协议求解成功!") print("=" * 50) - print(f"耗时: {result['elapsed_ms']} ms") + print(f"耗时: {result['elapsed_ms']} ms") + print(f"题型: {result['kind']} 题目: {result['instruction']!r}") print(f"点击坐标: {result['points']}") - print(f"randstr: {result['randstr']}") - print(f"ticket: {result['ticket']}") + print(f"randstr: {result['randstr']}") + print(f"ticket: {result['ticket']}") print("=" * 50) else: print(f"\n❌ 验证失败: {result['error']}") diff --git a/tcaptcha/crypto.py b/tcaptcha/crypto.py index 4433e97..fb0b131 100644 --- a/tcaptcha/crypto.py +++ b/tcaptcha/crypto.py @@ -8,12 +8,22 @@ telemetry payload serialization. """ import base64 +import json import math +import os import random import re import struct +import subprocess import time -from typing import List, Tuple +import urllib.parse +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +M32 = 0xFFFFFFFF + +TDC_WORKER_PATH = Path(__file__).resolve().parent / "tdc_worker.js" +TDC_BROWSER_PATH = Path(__file__).resolve().parent / "tdc_browser.js" # TDC VM Cryptographic Constants DELTA = 2654435769 # 0x9E3779B9 @@ -186,9 +196,12 @@ def generate_trajectory_payload( import json raw_json = json.dumps(events, separators=(",", ":")) - # Pad with spaces to multiple of 8 bytes (matching target_len, e.g. 600) + # 填充到 24 字节对齐 —— 24 是 8 (XTEA 分组) 与 3 (Base64 分组) 的最小公倍数, + # 这样每个分块单独 base64 后拼接不会产生 **块内 '=' 填充**。 + # 实测真实 tdc.js 的 collect 整体只有末尾一个 '==' (见 点选.har), + # 块内出现 '=' 会导致服务端解析/解密失败。 desired_len = max(len(raw_json), target_len) - pad_needed = (8 - (desired_len % 8)) % 8 + pad_needed = (24 - (desired_len % 24)) % 24 total_len = desired_len + pad_needed return raw_json.ljust(total_len, " ") @@ -206,36 +219,230 @@ def extract_eks(tdc_js_code: str) -> str: return "" -def generate_collect( +# --------------------------------------------------------------------------- # +# 标准 XTEA (32 轮, delta=0x9E3779B9, sum 取 mod 2^32) —— 等价于原 crypto.py 的 +# 自定义形态 + 密钥折叠: K' = [k0, k1 + salt1, k2 + salt2, k3] +# 由此「算法」就是教科书 XTEA, 唯一需要从每份 tdc.js 里拿到的是那 4 个密钥字。 +# --------------------------------------------------------------------------- # +XTEA_DELTA = 0x9E3779B9 +CD_PRE_RAW = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",' + + +def xtea_encrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]: + """教科书 XTEA 加密 (32 轮), 全 32 位无符号运算""" + v0 &= M32 + v1 &= M32 + total = 0 + for _ in range(32): + v0 = (v0 + (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32 + ^ ((total + key[total & 3]) & M32))) & M32 + total = (total + XTEA_DELTA) & M32 + v1 = (v1 + (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32 + ^ ((total + key[(total >> 11) & 3]) & M32))) & M32 + return v0, v1 + + +def xtea_decrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]: + """教科书 XTEA 解密 (32 轮), 用于校验提取到的密钥""" + v0 &= M32 + v1 &= M32 + total = (XTEA_DELTA * 32) & M32 + for _ in range(32): + v1 = (v1 - (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32 + ^ ((total + key[(total >> 11) & 3]) & M32))) & M32 + total = (total - XTEA_DELTA) & M32 + v0 = (v0 - (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32 + ^ ((total + key[total & 3]) & M32))) & M32 + return v0, v1 + + +def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes: + data = data + b"\x00" * ((8 - len(data) % 8) % 8) + out = bytearray() + for i in range(0, len(data), 8): + v0, v1 = struct.unpack(" bytes: + out = bytearray() + for i in range(0, len(data) - len(data) % 8, 8): + v0, v1 = struct.unpack(" Tuple[List[List], bytes]: + """调用 Node 插桩运行 tdc.js, 返回 (密钥候选, 该脚本产出的 collect 原始字节)""" + script = TDC_WORKER_PATH + if not script.exists(): + raise FileNotFoundError(f"缺少 tdc_worker.js: {script}") + cmd = [node_bin, str(script), "-", "[[0,0],[0,0],[0,0]]", + json.dumps({"aid": "1", "captureKey": True})] + proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True, + timeout=timeout, env={**os.environ, "NODE_OPTIONS": ""}) + out = (proc.stdout or "").strip().splitlines() + if not out: + raise RuntimeError(f"tdc_worker 无输出 (rc={proc.returncode}): {proc.stderr[:400]}") + payload = json.loads(out[-1]) + if payload.get("error"): + raise RuntimeError(f"tdc_worker 执行失败: {payload['error'][:400]}") + words = payload.get("key_words") or [] + if not words: + raise RuntimeError("tdc_worker 未返回密钥候选 (captureKey 未生效?)") + raw = base64.b64decode(urllib.parse.unquote(payload["collect"])) + return words, raw + + +_KEY_CACHE: Dict[str, List[int]] = {} + + +def extract_xtea_key(tdc_js_code: str, node_bin: str = "node", + top_n: int = 8, use_cache: bool = True) -> List[int]: + """ + 从服务端下发的 tdc.js 里提取本次构建的 XTEA 密钥 (4 个 32 位字)。 + + 流程 (Node 只负责"跑出密钥", 算法全在 Python): + 1. Node 在 VM 上下文里把 ADD opcode 换成带日志版本; + XTEA 每轮的 (sum + K[j]) 是一次 ADD, 两个操作数必有一个等于 r*DELTA, + 于是另一个操作数就是密钥字 —— 按频次排序得到无歧义候选。 + 2. Python 取候选 Top-N, 对 4! 个排列逐个用教科书 XTEA 解密 + tdc.js 自己产出的 collect 前 48 字节, 与常量 cd 前缀比对 —— 命中即真密钥。 + + 密钥按构建固定 (同一份脚本多次运行首块完全一致, 已实测), 故可缓存。 + """ + import itertools + + if use_cache: + cached = _KEY_CACHE.get(tdc_js_code) + if cached: + return list(cached) + + words, raw = _capture_key_words_cached(tdc_js_code, node_bin=node_bin) + candidates = [int(k) & M32 for k, *_ in words[:top_n]] + # 验证用「内容特征」而非固定明文 (AGENTS.md §5.3 / 附录 A#6): + # 实测 tdc.js 至少两代明文布局 —— + # 旧版: 4 段 base64 拼接, 首段 = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",' + # 新版: 单个 JSON, 首部 = '{"cd":[1,[],"Apple M1",24,...' / '{"cd":[1791712995,0,...' + # 字段序还随构建变化, 故只依赖跨版本稳定的结构特征: '{"cd":[' 开头 + 首块全可打印。 + blocks = [struct.unpack(" str: + """ + 纯 Python 回填出 collect: + 1. 用 Python 生成的鼠标轨迹替换模板里的轨迹数组 + 2. 刷新模板里的秒级时间戳 + 3. 空格补齐到 8 字节倍数 -> XTEA 加密 -> base64 -> TDC URL 编码 + 加密与组包 100% 在 Python, Node 只提供密钥与一次性模板。 + """ + text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template + + traj = generate_trajectory_payload(click_points, target_len=0).rstrip() + m = _TRAJ_RE.search(text) + if not m: + raise RuntimeError("模板里未找到轨迹数组, 无法回填") + text = text[:m.start()] + traj + text[m.end():] + + if refresh_time: + now = int(time.time()) + text = _EPOCH_RE.sub(str(now), text) + + data = text.encode("latin1") + data += b" " * ((8 - len(data) % 8) % 8) # 与真实 tdc.js 一致: 空格补齐 + return urllib.parse.quote_from_bytes( + base64.b64encode(xtea_encrypt_bytes(data, key)), safe="") + + +def generate_collect_pure( + tdc_js_code: str, click_points: List[Tuple[int, int]], aid: str = "2039519451", user_agent: str = DEFAULT_USER_AGENT, renderer: str = DEFAULT_RENDERER, vendor: str = DEFAULT_VENDOR, canvas_fp: str = DEFAULT_CANVAS_FP, -) -> str: + node_bin: str = "node", +) -> Tuple[str, List[int]]: """ - Generates the complete encrypted telemetry payload (`collect`) in 100% pure Python. + **纯 Python 出包入口**: Node 只负责「跑出密钥 + 一次性标定明文模板」, + 轨迹生成 / 组包 / XTEA 加密 / 编码 全部由 Python 完成。 - Assembles: - 1. cd prefix (48 bytes, space padded) - 2. mouse trajectory (space padded to 8-byte multiple) - 3. cd post-body (504 bytes, space padded) - 4. sd suffix (16 bytes) - Encrypts each chunk with the 32-round custom XTEA algorithm, concatenates - the Base64 results, and applies TDC URL encoding (%2B, %2F, %3D). + 两次 Node 调用均按脚本内容缓存 (同一构建只付一次成本)。 """ + try: + template, key = recover_payload_template(tdc_js_code, node_bin=node_bin) + return generate_collect_from_template(template, click_points, key), key + except Exception: + # 模板不适用 (例如两段式旧结构) -> 退回旧版结构 + 同一把密钥 + key = extract_xtea_key(tdc_js_code, node_bin=node_bin) + return generate_collect_keyed(click_points, key, aid=aid, user_agent=user_agent, + renderer=renderer, vendor=vendor, + canvas_fp=canvas_fp), key + + +def _collect_segments(click_points: List[Tuple[int, int]], aid: str, user_agent: str, + renderer: str, vendor: str, canvas_fp: str) -> List[bytes]: + """组装 collect 的 4 个明文分块 (与真实 tdc.js 一致)""" now_sec = int(time.time()) rand_salt = random.randint(100000000, 999999999) - # 1. cd prefix (48 bytes) - cd_pre_raw = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",' - cd_pre = cd_pre_raw.ljust(48, " ") + cd_pre = CD_PRE_RAW.ljust(48, " ") - # 2. Mouse trajectory (600 bytes) traj_payload = generate_trajectory_payload(click_points, target_len=600) - # 3. cd post-body (504 bytes) cd_post_inner = ( f',"",' f'"undefined?rand=1519713624347",' @@ -272,25 +479,243 @@ def generate_collect( ) cd_post = cd_post_inner.ljust(504, " ") - # 4. sd suffix (16 bytes) sd = '"sd":{"od":"C"}}' + return [s.encode("latin1") for s in (cd_pre, traj_payload, cd_post, sd)] - # Encrypt all 4 parts - enc_cd_pre = encrypt_string(cd_pre) - enc_traj = encrypt_string(traj_payload) - enc_cd_post = encrypt_string(cd_post) - enc_sd = encrypt_string(sd) - combined_b64 = enc_cd_pre + enc_traj + enc_cd_post + enc_sd +def _assemble_collect(segments: List[bytes], key: Optional[List[int]] = None) -> str: + """4 段分别加密 + base64 + 拼接 + TDC URL 编码。 - # TDC URL encoding - collect = ( - combined_b64 - .replace("+", "%2B") - .replace("/", "%2F") - .replace("=", "%3D") - ) - return collect + 每段长度都是 3 的倍数 (48 / 24k / 504), 末段 16 字节 -> 只有末尾一个 '==', + 与真实样本结构一致 (块内出现 '=' 会导致服务端解析失败)。 + """ + if key is None: + enc = [encrypt_string(s.decode("latin1")) for s in segments] + else: + enc = [base64.b64encode(xtea_encrypt_bytes(s, key)).decode("ascii") for s in segments] + return "".join(enc).replace("+", "%2B").replace("/", "%2F").replace("=", "%3D") + + +def generate_collect_keyed( + click_points: List[Tuple[int, int]], + key: List[int], + aid: str = "2039519451", + user_agent: str = DEFAULT_USER_AGENT, + renderer: str = DEFAULT_RENDERER, + vendor: str = DEFAULT_VENDOR, + canvas_fp: str = DEFAULT_CANVAS_FP, +) -> str: + """用「从 tdc.js 提取到的本次构建密钥」纯 Python 生成 collect (标准 XTEA)""" + segments = _collect_segments(click_points, aid, user_agent, renderer, vendor, canvas_fp) + return _assemble_collect(segments, key=key) + + +def generate_collect( + click_points: List[Tuple[int, int]], + aid: str = "2039519451", + user_agent: str = DEFAULT_USER_AGENT, + renderer: str = DEFAULT_RENDERER, + vendor: str = DEFAULT_VENDOR, + canvas_fp: str = DEFAULT_CANVAS_FP, +) -> str: + """ + 用 crypto.py 内置的固定密钥 (DEFAULT_KEY + 两个历史加盐) 生成 collect。 + 等价于标准 XTEA + K' = [k0, k1+salt1, k2+salt2, k3]。 + + 兼容性: 实测雨云 aid 直接接受 (errorCode=0), 说明服务端并不校验 collect 与 eks + 的同源性; 需要严格场景请用 `generate_collect_keyed()` + `extract_xtea_key()`。 + """ + segments = _collect_segments(click_points, aid, user_agent, renderer, vendor, canvas_fp) + return _assemble_collect(segments, key=None) + + +# --------------------------------------------------------------------------- # +# 真实 tdc.js 执行路径 (推荐): 在 Node VM 沙箱中跑当前会话的 tdc.js +# --------------------------------------------------------------------------- # +def generate_telemetry_node( + tdc_js_code: str, + click_points: List[Tuple[int, int]], + aid: str = "2039519451", + user_agent: str = DEFAULT_USER_AGENT, + referrer: str = "https://app.rainyun.com/", + frame_size: Tuple[int, int] = (360, 360), + display_scale: Optional[float] = None, + node_bin: str = "node", + timeout: float = 60.0, +) -> Dict[str, str]: + """ + 在 Node 沙箱里执行真实 tdc.js, 产出与浏览器一致的 collect / tlg / eks。 + + 为什么必须走这条路 + ------------------ + tdc.js 每次加载都会重新混淆, 且其 XTEA 会话密钥并非固定常量 —— 用 + `har/1ms_dianxuan/live/tdc_live.js` 产出的真实 collect 用 DEFAULT_KEY 解密 + 得到的是随机字节 (见 ANALYSIS.md 验证记录)。因此纯 Python 硬编码密钥的实现 + 只对当初逆向的那一份 tdc.js 有效, 换会话即失效; 而 Node 沙箱直接执行服务端 + 下发的 tdc.js, 拿到的是服务端自己认可的密文。 + + 返回: {"collect": str, "tlg": str, "eks": str, "source": "node"} + """ + script = TDC_WORKER_PATH + if not script.exists(): + raise FileNotFoundError(f"缺少 tdc_worker.js: {script}") + + options = { + "aid": str(aid), + "referrer": referrer, + "userAgent": user_agent, + "screenWidth": 1920, + "screenHeight": 1080, + "availHeight": 1055, + "frameWidth": frame_size[0], + "frameHeight": frame_size[1], + "offsetX": 0, + "offsetY": 0, + } + if display_scale is not None: + options["displayScale"] = float(display_scale) + + cmd = [node_bin, str(script), "-", json.dumps([list(p) for p in click_points]), + json.dumps(options)] + proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True, + timeout=timeout, env={**os.environ, "NODE_OPTIONS": ""}) + out = (proc.stdout or "").strip().splitlines() + if not out: + raise RuntimeError(f"tdc_worker 无输出 (rc={proc.returncode}): {proc.stderr[:400]}") + try: + payload = json.loads(out[-1]) + except json.JSONDecodeError as exc: + raise RuntimeError(f"tdc_worker 输出无法解析: {out[-1][:300]}") from exc + if payload.get("error"): + raise RuntimeError(f"tdc_worker 执行 tdc.js 失败: {payload['error'][:400]}") + collect = payload.get("collect") or "" + if not collect: + raise RuntimeError("tdc_worker 未产出 collect") + return { + "collect": collect, + "tlg": str(payload.get("tlg") or len(collect)), + "eks": payload.get("eks") or "", + "source": "node", + } + + +def generate_telemetry_browser( + tdc_js_code: str, + click_points: List[Tuple[int, int]], + aid: str = "2039519451", + user_agent: str = DEFAULT_USER_AGENT, + referrer: str = "https://app.rainyun.com/", + frame_size: Tuple[int, int] = (360, 360), + display_scale: Optional[float] = None, + chrome_path: Optional[str] = None, + headless: bool = True, + node_bin: str = "node", + timeout: float = 120.0, +) -> Dict[str, str]: + """ + 用 **真实 Chrome** (CDP 驱动) 执行 tdc.js, 产出环境指纹真实度最高的 collect / eks。 + + 与 `generate_telemetry_node` 的区别: 后者在 Node VM 里伪造 DOM/BOM/Canvas, + 实测答题正确后仍会被服务端判定为环境异常 (errorCode 12); 本函数把 tdc.js 放进 + 真实浏览器, 用 Input.dispatchMouseEvent 派发可信鼠标事件, 再读回 TDC.getData()。 + """ + script = TDC_BROWSER_PATH + if not script.exists(): + raise FileNotFoundError(f"缺少 tdc_browser.js: {script}") + + options = { + "aid": str(aid), + "referer": referrer, + "userAgent": user_agent, + "frameWidth": frame_size[0], + "frameHeight": frame_size[1], + "offsetX": 0, + "offsetY": 0, + "headless": headless, + } + if chrome_path: + options["chromePath"] = chrome_path + if display_scale is not None: + options["displayScale"] = float(display_scale) + + cmd = [node_bin, str(script), "-", json.dumps([list(p) for p in click_points]), + json.dumps(options)] + proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True, timeout=timeout) + out = (proc.stdout or "").strip().splitlines() + if not out: + raise RuntimeError(f"tdc_browser 无输出 (rc={proc.returncode}): {proc.stderr[:600]}") + try: + payload = json.loads(out[-1]) + except json.JSONDecodeError as exc: + raise RuntimeError(f"tdc_browser 输出无法解析: {out[-1][:300]}") from exc + if payload.get("error"): + raise RuntimeError(f"真实 Chrome 执行 tdc.js 失败: {payload['error'][:600]}") + collect = payload.get("collect") or "" + if not collect: + raise RuntimeError("真实 Chrome 未产出 collect") + return {"collect": collect, "tlg": str(payload.get("tlg") or len(collect)), + "eks": payload.get("eks") or "", "source": "browser"} + + +def generate_telemetry( + tdc_js_code: str, + click_points: List[Tuple[int, int]], + aid: str = "2039519451", + user_agent: str = DEFAULT_USER_AGENT, + referrer: str = "https://app.rainyun.com/", + mode: str = "python", +) -> Dict[str, str]: + """ + 统一遥测生成入口。 + + mode="python" : 纯 Python 算法还原 (内置固定密钥; 默认, 无 Node 依赖, ~1ms) + mode="python_keyed": **Node 仅提取本次构建的 XTEA 密钥, 加密全部在 Python** + mode="node" : Node VM 沙箱执行服务端下发的真实 tdc.js (~0.1s) + mode="browser" : 真实 Chrome + CDP 执行真实 tdc.js (~50s) + mode="auto" : python -> node -> browser 逐级回退 + + 为什么默认仍然是纯 Python + ------------------------ + 实测(2026-10-11)证明服务端 **并不按 tdc.js 的会话密钥解密 collect**: + 雨云 aid=2039519451 + 纯 Python(硬编码 DEFAULT_KEY) 的 collect + 正确答案 + → errorCode=0 并返回真实 ticket (tr03jvqlp225MjNBxjDj_-mnM1VTS8O9) + 而 `DEFAULT_KEY` 只对仓库里那份 tdc_sample.js 有效(能解出明文 + `{"cd":[[],"1920-1080..."`), 对当天实时抓的同一 aid 的脚本已失效 —— 说明 + tdc.js 的密码学参数(4 个密钥字 + 2829068/328986 两个轮加盐)是**每次构建随机**的, + 但服务端并不校验收到的 collect 是否与 eks 同源。 + + 因此纯 Python 方案在协议层完全够用; 需要 node/browser 的场景是目标 aid 启用了 + 更严格的风控(例如 1ms.run 的 aid=195672344 在答案正确时返回 errorCode=12), + 详见 har/1ms_dianxuan/ANALYSIS.md。 + """ + chain = {"python": ["python"], "python_keyed": ["python_keyed"], + "node": ["node"], "browser": ["browser"], + "auto": ["python", "python_keyed", "node", "browser"], + "auto_keyed": ["python_keyed", "node", "python"]}.get(mode, ["python"]) + errors = [] + for kind in chain: + try: + if kind == "browser": + return generate_telemetry_browser(tdc_js_code, click_points, aid=aid, + user_agent=user_agent, referrer=referrer) + if kind == "node": + return generate_telemetry_node(tdc_js_code, click_points, aid=aid, + user_agent=user_agent, referrer=referrer) + if kind == "python_keyed": + # Node 只负责跑出 XTEA 密钥 + 标定一次明文模板; 加密与组包全在 Python + collect, key = generate_collect_pure(tdc_js_code, click_points, aid=aid, + user_agent=user_agent) + return {"collect": collect, "tlg": str(len(collect)), + "eks": extract_eks(tdc_js_code), "source": "python_keyed", + "key": key} + collect = generate_collect(click_points, aid=aid, user_agent=user_agent) + return {"collect": collect, "tlg": str(len(collect)), + "eks": extract_eks(tdc_js_code), "source": "python"} + except Exception as exc: + errors.append(f"{kind}: {exc}") + if mode != "auto": + raise + raise RuntimeError("所有遥测后端均失败: " + " | ".join(errors)) if __name__ == "__main__": diff --git a/tcaptcha/glyph_solver.py b/tcaptcha/glyph_solver.py new file mode 100644 index 0000000..d791d5f --- /dev/null +++ b/tcaptcha/glyph_solver.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +""" +腾讯天御『文字点选』验证码求解器 (Tencent Turing Text-Click Solver) + +适用题型: subcapclass = 2404, click_cfg.data_type = ["DynAnswerType_POS"] 且提示为 +纯文字指令, 例如: "请依次点击:虫 呈 般" + +与 tcaptcha.solver.CaptchaSolver (图标点选 2408: 提示是 img_index=0 的图标图) 的区别: + * 本题型 **没有提示图** (img_index=0 返回 0 字节) + * 背景图里只出现与提示完全相同的 3 个汉字 (随机位置), 不含额外干扰字 + * 部分题目会做 **斜切(italic)变形**, 但实测不影响颜色分割 + +算法管线: + 1. HSV 颜色分割 (腾讯点选汉字固定为橙黄色, H≈20 S≈185 V≈228) 提取字形掩膜 + 2. 形态学闭运算 + 膨胀连通同一汉字的多个偏旁, 连通域得到汉字包围盒 + 3. 掩膜 -> 白底黑字 -> 放大, 交给 OCR 集成(ddddocr + PP-OCRv4)做 CTC 前向 + 4. 取 CTC 每一时间步上目标字的概率最大值 -> (字形, 目标字) 相似度矩阵 + 5. 对 3! 个全排列求最优指派, 并用归一化后验概率给出本次求解的置信度 + (置信度低的题目直接刷新会话, 避免提交明显不可靠的答案) +""" + +from __future__ import annotations + +import io +import itertools +import logging +import re +from typing import Dict, List, Optional, Sequence, Tuple + +import cv2 +import numpy as np +from PIL import Image + +from .ocr_engines import EnsembleOcr + +logger = logging.getLogger("tcaptcha_glyph") + +# 腾讯点选汉字颜色 (实测 H≈20.5 S≈200 V≈232)。 +# 关键是 **V(亮度)** 而不是色相: 暖色草地/沙地的 H 会落在 24~28、S 也不低, +# 但 V 只有 120~156, 而汉字 V≈230。早期版本用 V>=145 会把草地并进字形掩膜, +# 导致 3 个字被背景吞成 1~2 个候选 (见图 proxy/fail_2、fail_3)。 +HSV_LOWER = (12, 145, 190) +HSV_UPPER = (34, 255, 255) + +MIN_GLYPH_AREA = 700 # 单个汉字掩膜最小像素数 +MERGE_KERNEL = 13 # 膨胀核, 用于把偏旁/离散笔画合并为同一汉字 +SCALE = 3 # OCR 输入放大倍数 +PAD = 24 # OCR 输入白边 + +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +# 滑块/图标题型的模板串关键词: 出现即说明提示里没有目标汉字 +_NON_TARGET_HINTS = ("滑块", "拖动", "拼图", "请依次", "点击", "完成") + + +def parse_instruction(instruction: str) -> List[str]: + """从 '请依次点击:虫 呈 般' 中解析出目标汉字序列 ['虫','呈','般'] + + 注意: 图标点选 (2408) 与滑块 (2402/2401) 的 instruction 只有 '请依次点击:' / + '拖动下方滑块完成拼图' 这类无目标文字的模板串, 此时必须返回空列表, 否则会把 + '请依次点击' 本身当成目标字。 + """ + if not instruction: + return [] + parts = re.split(r"[::]", instruction, maxsplit=1) + if len(parts) == 2: + return _CJK_RE.findall(parts[1]) + # 没有分隔符: 只有当整体就是一个短目标字序列 (如 '虫 呈 般') 时才接受 + if any(h in instruction for h in _NON_TARGET_HINTS): + return [] + chars = _CJK_RE.findall(instruction) + # 文字点选恒为 3~4 个目标字, 超过该数量必然是模板句 + return chars if 0 < len(chars) <= 4 else [] + + +class Glyph: + """一个已定位的候选汉字字形""" + + __slots__ = ("x0", "y0", "x1", "y1", "cx", "cy", "mask", "ocr_text", "confidence") + + def __init__(self, x0: int, y0: int, x1: int, y1: int, mask: np.ndarray): + self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1 + self.cx = (x0 + x1) // 2 + self.cy = (y0 + y1) // 2 + self.mask = mask + self.ocr_text = "" + self.confidence = 0.0 + + @property + def point(self) -> Tuple[int, int]: + return self.cx, self.cy + + def gray_image(self, thick: int = 0, scale: int = SCALE) -> np.ndarray: + """掩膜 -> 白底黑字灰度图 -> 放大 (thick>0 时加粗笔画, 用于多尺度集成)""" + m = self.mask + if thick: + m = cv2.dilate(m, np.ones((3, 3), np.uint8), iterations=thick) + gray = 255 - m + h, w = gray.shape + side = max(h, w) + PAD + canvas = np.full((side, side), 255, np.uint8) + canvas[(side - h) // 2:(side - h) // 2 + h, (side - w) // 2:(side - w) // 2 + w] = gray + return cv2.resize(canvas, (side * scale, side * scale), interpolation=cv2.INTER_LINEAR) + + # 兼容旧接口 + def ocr_image(self, thick: int = 0, scale: int = SCALE) -> Image.Image: + return Image.fromarray(self.gray_image(thick=thick, scale=scale)) + + +class SolveResult: + """一次求解结果""" + + __slots__ = ("points", "targets", "glyphs", "score_matrix", "posterior", "assignments") + + def __init__(self, points, targets, glyphs, score_matrix, posterior, assignments): + self.points = points + self.targets = targets + self.glyphs = glyphs + self.score_matrix = score_matrix + self.posterior = posterior + self.assignments = assignments + + @property + def ok(self) -> bool: + return len(self.points) == len(self.targets) and len(self.points) > 0 + + def describe(self) -> str: + detail = ", ".join( + f"{t}<-{self.glyphs[g].ocr_text or '?'}@{self.glyphs[g].point}" + f"({self.score_matrix[g, ti]:.3f})" + for ti, (t, g) in enumerate(zip(self.targets, self.assignments)) + ) + return f"posterior={self.posterior:.3f} [{detail}]" + + +class TextClickSolver: + """腾讯天御文字点选求解器 (颜色分割 + OCR 集成概率指派)""" + + def __init__(self, recognizer: Optional[EnsembleOcr] = None, min_area: int = MIN_GLYPH_AREA, + variants: Sequence[Tuple[int, int]] = ((0, SCALE), (1, SCALE)), + min_glyph_score: float = 0.02): + self.min_area = min_area + self.variants = tuple(variants) + # 颜色分割会在照片的暖色区域误检出少量"伪字形", 用识别得分把它们剔除 + self.min_glyph_score = min_glyph_score + self._recognizer = recognizer + + @property + def recognizer(self) -> EnsembleOcr: + if self._recognizer is None: + self._recognizer = EnsembleOcr.default() + return self._recognizer + + # ------------------------------------------------------------- detection + @staticmethod + def _mask_of(bgr: np.ndarray) -> np.ndarray: + hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) + mask = cv2.inRange(hsv, np.array(HSV_LOWER, np.uint8), np.array(HSV_UPPER, np.uint8)) + # 闭运算填掉字内高光空洞 + return cv2.morphologyEx(mask, cv2.MORPH_CLOSE, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))) + + def detect_glyphs(self, image: bytes | np.ndarray | Image.Image) -> List[Glyph]: + """颜色分割定位背景图中的全部汉字""" + bgr = self._to_bgr(image) + mask = self._mask_of(bgr) + dilated = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, + (MERGE_KERNEL, MERGE_KERNEL))) + count, labels, stats, _ = cv2.connectedComponentsWithStats(dilated) + + glyphs: List[Glyph] = [] + for i in range(1, count): + if stats[i, cv2.CC_STAT_AREA] < self.min_area: + continue + sub = ((labels == i) & (mask > 0)) + ys, xs = np.nonzero(sub) + if len(xs) == 0: + continue + x0, y0, x1, y1 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()) + w, h = x1 - x0 + 1, y1 - y0 + 1 + # 单字包围盒合理性: 过扁/过瘦/过小的多半是背景误检 + if not (0.35 <= w / h <= 2.8) or w < 24 or h < 24: + continue + glyphs.append(Glyph(x0, y0, x1, y1, + (sub[y0:y1 + 1, x0:x1 + 1] * 255).astype(np.uint8))) + # 阅读顺序排序 (先上后下, 再左后右) —— 仅用于结果稳定 + glyphs.sort(key=lambda g: (g.y0 // 64, g.x0)) + return glyphs + + # ---------------------------------------------------------- recognition + def char_scores(self, glyph: Glyph, targets: Sequence[str]) -> Dict[str, float]: + """对单个字形, 返回每个目标字的集成概率 (多预处理变体取最大)""" + acc = {t: 0.0 for t in targets} + best_text, best_conf = "", 0.0 + for thick, scale in self.variants: + gray = glyph.gray_image(thick=thick, scale=scale) + scores = self.recognizer.scores(gray, targets) + for t, v in scores.items(): + acc[t] = max(acc[t], v) + # 用集成里最强单引擎的读数做可读性展示 + try: + engine = self.recognizer.engines[-1] + chars, probs = engine.probabilities(glyph.gray_image()) + idx = probs.argmax(axis=1) + seq, prev = [], -1 + for i in idx: + if i != 0 and i != prev and i < len(chars): + seq.append(chars[i]) + prev = i + best_text = "".join(seq) + best_conf = float(probs.max(axis=1).mean()) + except Exception: # 展示信息失败不影响求解 + pass + glyph.ocr_text, glyph.confidence = best_text, best_conf + return acc + + # ----------------------------------------------------------------- solve + def solve(self, image: bytes | np.ndarray | Image.Image, instruction: str, + retries: int = 1) -> List[Tuple[int, int]]: + """返回提示顺序的像素点击坐标; 失败返回空列表 (兼容旧接口)""" + res = self.solve_ex(image, instruction, retries=retries) + return res.points if res.ok else [] + + def solve_ex(self, image: bytes | np.ndarray | Image.Image, instruction: str, + retries: int = 1) -> SolveResult: + """完整求解, 附带置信度(归一化后验概率)与命中明细""" + targets = parse_instruction(instruction) + empty = SolveResult([], targets, [], np.zeros((0, 0)), 0.0, []) + if len(targets) < 2: + logger.warning("提示语解析失败: %r", instruction) + return empty + + for attempt in range(retries): + glyphs = self.detect_glyphs(image) + if len(glyphs) < len(targets): + logger.warning("第 %d 次: 检出字形 %d 个 < 目标 %d 个", + attempt + 1, len(glyphs), len(targets)) + if attempt + 1 < retries: + continue + return empty + if len(glyphs) > len(targets): + logger.info("检出字形 %d 个 (目标 %d 个), 进行全局指派", len(glyphs), len(targets)) + + matrix = np.array([[self.char_scores(g, targets)[t] for t in targets] + for g in glyphs], dtype=np.float64) # (n, m) + + # 剔除伪字形: 任何目标字都识别不出来的候选不参与指派 (不影响后验置信度) + row_max = matrix.max(axis=1) if matrix.size else np.zeros(0) + strong = [i for i in range(len(glyphs)) if row_max[i] >= self.min_glyph_score] + if len(strong) < len(targets): + # 得分整体偏低(生僻字/罕见字形)时退化为取得分最高的 m 个 + order = np.argsort(-row_max)[:len(targets)] + strong = sorted(int(i) for i in order) + logger.info("可用强字形不足, 退化为得分 Top-%d 候选", len(targets)) + dropped = [i for i in range(len(glyphs)) if i not in strong] + if dropped: + logger.info("剔除伪字形 %s", [glyphs[i].point for i in dropped]) + glyphs = [glyphs[i] for i in strong] + matrix = matrix[strong] + + # 每个字形对目标字的归一化分布 (尺度无关), 避免某个字形整体概率偏低时被忽略 + eps = 1e-9 + row_sums = matrix.sum(axis=1, keepdims=True) + norm = (matrix + eps) / (row_sums + eps * matrix.shape[1]) + + best_perm, best_score, second_score = None, -1.0, 0.0 + perms = list(itertools.permutations(range(len(glyphs)), len(targets))) + probs = [] + for perm in perms: + p = 1.0 + for t_idx, g_idx in enumerate(perm): + p *= float(norm[g_idx, t_idx]) + probs.append(p) + if p > best_score: + second_score = best_score + best_score, best_perm = p, perm + elif p > second_score: + second_score = p + total = float(sum(probs)) or 1.0 + posterior = float(best_score / total) + + if best_perm is None: + return empty + logger.info("题目 %s -> %s", "".join(targets), + ", ".join(f"{targets[t]}<-{glyphs[g].ocr_text or '?'}@{glyphs[g].point}" + f"({matrix[g, t]:.4f})" for t, g in enumerate(best_perm))) + logger.info(" 后验置信度=%.4f (次优/最优=%.4f)", posterior, + (second_score / best_score) if best_score > 0 else 1.0) + + result = SolveResult([glyphs[g].point for g in best_perm], targets, glyphs, + matrix, posterior, list(best_perm)) + if best_score <= 0: + logger.warning("所有候选得分均为 0, 判定求解失败") + return SolveResult([], targets, glyphs, matrix, 0.0, list(best_perm)) + return result + return empty + + # ------------------------------------------------------------- utilities + @staticmethod + def _to_bgr(image: bytes | np.ndarray | Image.Image) -> np.ndarray: + if isinstance(image, np.ndarray): + return image + if isinstance(image, Image.Image): + return cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR) + arr = np.frombuffer(image, np.uint8) + bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if bgr is None: + bgr = cv2.cvtColor(np.array(Image.open(io.BytesIO(image)).convert("RGB")), + cv2.COLOR_RGB2BGR) + return bgr + + +if __name__ == "__main__": + import sys + + logging.basicConfig(level=logging.INFO, format="%(message)s") + solver = TextClickSolver() + img_path = sys.argv[1] if len(sys.argv) > 1 else "har/1ms_dianxuan/bg_3.jpg" + instr = sys.argv[2] if len(sys.argv) > 2 else "请依次点击:虫 呈 般" + res = solver.solve_ex(open(img_path, "rb").read(), instr) + print("points:", res.points) + print("confidence:", res.posterior) diff --git a/tcaptcha/models/ppocr/ch_PP-OCRv4_rec_infer.onnx b/tcaptcha/models/ppocr/ch_PP-OCRv4_rec_infer.onnx new file mode 100644 index 0000000..9984d54 Binary files /dev/null and b/tcaptcha/models/ppocr/ch_PP-OCRv4_rec_infer.onnx differ diff --git a/tcaptcha/models/ppocr/ppocr_keys_v1.txt b/tcaptcha/models/ppocr/ppocr_keys_v1.txt new file mode 100644 index 0000000..b75af21 --- /dev/null +++ b/tcaptcha/models/ppocr/ppocr_keys_v1.txt @@ -0,0 +1,6623 @@ +' +疗 +绚 +诚 +娇 +溜 +题 +贿 +者 +廖 +更 +纳 +加 +奉 +公 +一 +就 +汴 +计 +与 +路 +房 +原 +妇 +2 +0 +8 +- +7 +其 +> +: +] +, +, +骑 +刈 +全 +消 +昏 +傈 +安 +久 +钟 +嗅 +不 +影 +处 +驽 +蜿 +资 +关 +椤 +地 +瘸 +专 +问 +忖 +票 +嫉 +炎 +韵 +要 +月 +田 +节 +陂 +鄙 +捌 +备 +拳 +伺 +眼 +网 +盎 +大 +傍 +心 +东 +愉 +汇 +蹿 +科 +每 +业 +里 +航 +晏 +字 +平 +录 +先 +1 +3 +彤 +鲶 +产 +稍 +督 +腴 +有 +象 +岳 +注 +绍 +在 +泺 +文 +定 +核 +名 +水 +过 +理 +让 +偷 +率 +等 +这 +发 +” +为 +含 +肥 +酉 +相 +鄱 +七 +编 +猥 +锛 +日 +镀 +蒂 +掰 +倒 +辆 +栾 +栗 +综 +涩 +州 +雌 +滑 +馀 +了 +机 +块 +司 +宰 +甙 +兴 +矽 +抚 +保 +用 +沧 +秩 +如 +收 +息 +滥 +页 +疑 +埠 +! +! +姥 +异 +橹 +钇 +向 +下 +跄 +的 +椴 +沫 +国 +绥 +獠 +报 +开 +民 +蜇 +何 +分 +凇 +长 +讥 +藏 +掏 +施 +羽 +中 +讲 +派 +嘟 +人 +提 +浼 +间 +世 +而 +古 +多 +倪 +唇 +饯 +控 +庚 +首 +赛 +蜓 +味 +断 +制 +觉 +技 +替 +艰 +溢 +潮 +夕 +钺 +外 +摘 +枋 +动 +双 +单 +啮 +户 +枇 +确 +锦 +曜 +杜 +或 +能 +效 +霜 +盒 +然 +侗 +电 +晁 +放 +步 +鹃 +新 +杖 +蜂 +吒 +濂 +瞬 +评 +总 +隍 +对 +独 +合 +也 +是 +府 +青 +天 +诲 +墙 +组 +滴 +级 +邀 +帘 +示 +已 +时 +骸 +仄 +泅 +和 +遨 +店 +雇 +疫 +持 +巍 +踮 +境 +只 +亨 +目 +鉴 +崤 +闲 +体 +泄 +杂 +作 +般 +轰 +化 +解 +迂 +诿 +蛭 +璀 +腾 +告 +版 +服 +省 +师 +小 +规 +程 +线 +海 +办 +引 +二 +桧 +牌 +砺 +洄 +裴 +修 +图 +痫 +胡 +许 +犊 +事 +郛 +基 +柴 +呼 +食 +研 +奶 +律 +蛋 +因 +葆 +察 +戏 +褒 +戒 +再 +李 +骁 +工 +貂 +油 +鹅 +章 +啄 +休 +场 +给 +睡 +纷 +豆 +器 +捎 +说 +敏 +学 +会 +浒 +设 +诊 +格 +廓 +查 +来 +霓 +室 +溆 +¢ +诡 +寥 +焕 +舜 +柒 +狐 +回 +戟 +砾 +厄 +实 +翩 +尿 +五 +入 +径 +惭 +喹 +股 +宇 +篝 +| +; +美 +期 +云 +九 +祺 +扮 +靠 +锝 +槌 +系 +企 +酰 +阊 +暂 +蚕 +忻 +豁 +本 +羹 +执 +条 +钦 +H +獒 +限 +进 +季 +楦 +于 +芘 +玖 +铋 +茯 +未 +答 +粘 +括 +样 +精 +欠 +矢 +甥 +帷 +嵩 +扣 +令 +仔 +风 +皈 +行 +支 +部 +蓉 +刮 +站 +蜡 +救 +钊 +汗 +松 +嫌 +成 +可 +. +鹤 +院 +从 +交 +政 +怕 +活 +调 +球 +局 +验 +髌 +第 +韫 +谗 +串 +到 +圆 +年 +米 +/ +* +友 +忿 +检 +区 +看 +自 +敢 +刃 +个 +兹 +弄 +流 +留 +同 +没 +齿 +星 +聆 +轼 +湖 +什 +三 +建 +蛔 +儿 +椋 +汕 +震 +颧 +鲤 +跟 +力 +情 +璺 +铨 +陪 +务 +指 +族 +训 +滦 +鄣 +濮 +扒 +商 +箱 +十 +召 +慷 +辗 +所 +莞 +管 +护 +臭 +横 +硒 +嗓 +接 +侦 +六 +露 +党 +馋 +驾 +剖 +高 +侬 +妪 +幂 +猗 +绺 +骐 +央 +酐 +孝 +筝 +课 +徇 +缰 +门 +男 +西 +项 +句 +谙 +瞒 +秃 +篇 +教 +碲 +罚 +声 +呐 +景 +前 +富 +嘴 +鳌 +稀 +免 +朋 +啬 +睐 +去 +赈 +鱼 +住 +肩 +愕 +速 +旁 +波 +厅 +健 +茼 +厥 +鲟 +谅 +投 +攸 +炔 +数 +方 +击 +呋 +谈 +绩 +别 +愫 +僚 +躬 +鹧 +胪 +炳 +招 +喇 +膨 +泵 +蹦 +毛 +结 +5 +4 +谱 +识 +陕 +粽 +婚 +拟 +构 +且 +搜 +任 +潘 +比 +郢 +妨 +醪 +陀 +桔 +碘 +扎 +选 +哈 +骷 +楷 +亿 +明 +缆 +脯 +监 +睫 +逻 +婵 +共 +赴 +淝 +凡 +惦 +及 +达 +揖 +谩 +澹 +减 +焰 +蛹 +番 +祁 +柏 +员 +禄 +怡 +峤 +龙 +白 +叽 +生 +闯 +起 +细 +装 +谕 +竟 +聚 +钙 +上 +导 +渊 +按 +艾 +辘 +挡 +耒 +盹 +饪 +臀 +记 +邮 +蕙 +受 +各 +医 +搂 +普 +滇 +朗 +茸 +带 +翻 +酚 +( +光 +堤 +墟 +蔷 +万 +幻 +〓 +瑙 +辈 +昧 +盏 +亘 +蛀 +吉 +铰 +请 +子 +假 +闻 +税 +井 +诩 +哨 +嫂 +好 +面 +琐 +校 +馊 +鬣 +缂 +营 +访 +炖 +占 +农 +缀 +否 +经 +钚 +棵 +趟 +张 +亟 +吏 +茶 +谨 +捻 +论 +迸 +堂 +玉 +信 +吧 +瞠 +乡 +姬 +寺 +咬 +溏 +苄 +皿 +意 +赉 +宝 +尔 +钰 +艺 +特 +唳 +踉 +都 +荣 +倚 +登 +荐 +丧 +奇 +涵 +批 +炭 +近 +符 +傩 +感 +道 +着 +菊 +虹 +仲 +众 +懈 +濯 +颞 +眺 +南 +释 +北 +缝 +标 +既 +茗 +整 +撼 +迤 +贲 +挎 +耱 +拒 +某 +妍 +卫 +哇 +英 +矶 +藩 +治 +他 +元 +领 +膜 +遮 +穗 +蛾 +飞 +荒 +棺 +劫 +么 +市 +火 +温 +拈 +棚 +洼 +转 +果 +奕 +卸 +迪 +伸 +泳 +斗 +邡 +侄 +涨 +屯 +萋 +胭 +氡 +崮 +枞 +惧 +冒 +彩 +斜 +手 +豚 +随 +旭 +淑 +妞 +形 +菌 +吲 +沱 +争 +驯 +歹 +挟 +兆 +柱 +传 +至 +包 +内 +响 +临 +红 +功 +弩 +衡 +寂 +禁 +老 +棍 +耆 +渍 +织 +害 +氵 +渑 +布 +载 +靥 +嗬 +虽 +苹 +咨 +娄 +库 +雉 +榜 +帜 +嘲 +套 +瑚 +亲 +簸 +欧 +边 +6 +腿 +旮 +抛 +吹 +瞳 +得 +镓 +梗 +厨 +继 +漾 +愣 +憨 +士 +策 +窑 +抑 +躯 +襟 +脏 +参 +贸 +言 +干 +绸 +鳄 +穷 +藜 +音 +折 +详 +) +举 +悍 +甸 +癌 +黎 +谴 +死 +罩 +迁 +寒 +驷 +袖 +媒 +蒋 +掘 +模 +纠 +恣 +观 +祖 +蛆 +碍 +位 +稿 +主 +澧 +跌 +筏 +京 +锏 +帝 +贴 +证 +糠 +才 +黄 +鲸 +略 +炯 +饱 +四 +出 +园 +犀 +牧 +容 +汉 +杆 +浈 +汰 +瑷 +造 +虫 +瘩 +怪 +驴 +济 +应 +花 +沣 +谔 +夙 +旅 +价 +矿 +以 +考 +s +u +呦 +晒 +巡 +茅 +准 +肟 +瓴 +詹 +仟 +褂 +译 +桌 +混 +宁 +怦 +郑 +抿 +些 +余 +鄂 +饴 +攒 +珑 +群 +阖 +岔 +琨 +藓 +预 +环 +洮 +岌 +宀 +杲 +瀵 +最 +常 +囡 +周 +踊 +女 +鼓 +袭 +喉 +简 +范 +薯 +遐 +疏 +粱 +黜 +禧 +法 +箔 +斤 +遥 +汝 +奥 +直 +贞 +撑 +置 +绱 +集 +她 +馅 +逗 +钧 +橱 +魉 +[ +恙 +躁 +唤 +9 +旺 +膘 +待 +脾 +惫 +购 +吗 +依 +盲 +度 +瘿 +蠖 +俾 +之 +镗 +拇 +鲵 +厝 +簧 +续 +款 +展 +啃 +表 +剔 +品 +钻 +腭 +损 +清 +锶 +统 +涌 +寸 +滨 +贪 +链 +吠 +冈 +伎 +迥 +咏 +吁 +览 +防 +迅 +失 +汾 +阔 +逵 +绀 +蔑 +列 +川 +凭 +努 +熨 +揪 +利 +俱 +绉 +抢 +鸨 +我 +即 +责 +膦 +易 +毓 +鹊 +刹 +玷 +岿 +空 +嘞 +绊 +排 +术 +估 +锷 +违 +们 +苟 +铜 +播 +肘 +件 +烫 +审 +鲂 +广 +像 +铌 +惰 +铟 +巳 +胍 +鲍 +康 +憧 +色 +恢 +想 +拷 +尤 +疳 +知 +S +Y +F +D +A +峄 +裕 +帮 +握 +搔 +氐 +氘 +难 +墒 +沮 +雨 +叁 +缥 +悴 +藐 +湫 +娟 +苑 +稠 +颛 +簇 +后 +阕 +闭 +蕤 +缚 +怎 +佞 +码 +嘤 +蔡 +痊 +舱 +螯 +帕 +赫 +昵 +升 +烬 +岫 +、 +疵 +蜻 +髁 +蕨 +隶 +烛 +械 +丑 +盂 +梁 +强 +鲛 +由 +拘 +揉 +劭 +龟 +撤 +钩 +呕 +孛 +费 +妻 +漂 +求 +阑 +崖 +秤 +甘 +通 +深 +补 +赃 +坎 +床 +啪 +承 +吼 +量 +暇 +钼 +烨 +阂 +擎 +脱 +逮 +称 +P +神 +属 +矗 +华 +届 +狍 +葑 +汹 +育 +患 +窒 +蛰 +佼 +静 +槎 +运 +鳗 +庆 +逝 +曼 +疱 +克 +代 +官 +此 +麸 +耧 +蚌 +晟 +例 +础 +榛 +副 +测 +唰 +缢 +迹 +灬 +霁 +身 +岁 +赭 +扛 +又 +菡 +乜 +雾 +板 +读 +陷 +徉 +贯 +郁 +虑 +变 +钓 +菜 +圾 +现 +琢 +式 +乐 +维 +渔 +浜 +左 +吾 +脑 +钡 +警 +T +啵 +拴 +偌 +漱 +湿 +硕 +止 +骼 +魄 +积 +燥 +联 +踢 +玛 +则 +窿 +见 +振 +畿 +送 +班 +钽 +您 +赵 +刨 +印 +讨 +踝 +籍 +谡 +舌 +崧 +汽 +蔽 +沪 +酥 +绒 +怖 +财 +帖 +肱 +私 +莎 +勋 +羔 +霸 +励 +哼 +帐 +将 +帅 +渠 +纪 +婴 +娩 +岭 +厘 +滕 +吻 +伤 +坝 +冠 +戊 +隆 +瘁 +介 +涧 +物 +黍 +并 +姗 +奢 +蹑 +掣 +垸 +锴 +命 +箍 +捉 +病 +辖 +琰 +眭 +迩 +艘 +绌 +繁 +寅 +若 +毋 +思 +诉 +类 +诈 +燮 +轲 +酮 +狂 +重 +反 +职 +筱 +县 +委 +磕 +绣 +奖 +晋 +濉 +志 +徽 +肠 +呈 +獐 +坻 +口 +片 +碰 +几 +村 +柿 +劳 +料 +获 +亩 +惕 +晕 +厌 +号 +罢 +池 +正 +鏖 +煨 +家 +棕 +复 +尝 +懋 +蜥 +锅 +岛 +扰 +队 +坠 +瘾 +钬 +@ +卧 +疣 +镇 +譬 +冰 +彷 +频 +黯 +据 +垄 +采 +八 +缪 +瘫 +型 +熹 +砰 +楠 +襁 +箐 +但 +嘶 +绳 +啤 +拍 +盥 +穆 +傲 +洗 +盯 +塘 +怔 +筛 +丿 +台 +恒 +喂 +葛 +永 +¥ +烟 +酒 +桦 +书 +砂 +蚝 +缉 +态 +瀚 +袄 +圳 +轻 +蛛 +超 +榧 +遛 +姒 +奘 +铮 +右 +荽 +望 +偻 +卡 +丶 +氰 +附 +做 +革 +索 +戚 +坨 +桷 +唁 +垅 +榻 +岐 +偎 +坛 +莨 +山 +殊 +微 +骇 +陈 +爨 +推 +嗝 +驹 +澡 +藁 +呤 +卤 +嘻 +糅 +逛 +侵 +郓 +酌 +德 +摇 +※ +鬃 +被 +慨 +殡 +羸 +昌 +泡 +戛 +鞋 +河 +宪 +沿 +玲 +鲨 +翅 +哽 +源 +铅 +语 +照 +邯 +址 +荃 +佬 +顺 +鸳 +町 +霭 +睾 +瓢 +夸 +椁 +晓 +酿 +痈 +咔 +侏 +券 +噎 +湍 +签 +嚷 +离 +午 +尚 +社 +锤 +背 +孟 +使 +浪 +缦 +潍 +鞅 +军 +姹 +驶 +笑 +鳟 +鲁 +》 +孽 +钜 +绿 +洱 +礴 +焯 +椰 +颖 +囔 +乌 +孔 +巴 +互 +性 +椽 +哞 +聘 +昨 +早 +暮 +胶 +炀 +隧 +低 +彗 +昝 +铁 +呓 +氽 +藉 +喔 +癖 +瑗 +姨 +权 +胱 +韦 +堑 +蜜 +酋 +楝 +砝 +毁 +靓 +歙 +锲 +究 +屋 +喳 +骨 +辨 +碑 +武 +鸠 +宫 +辜 +烊 +适 +坡 +殃 +培 +佩 +供 +走 +蜈 +迟 +翼 +况 +姣 +凛 +浔 +吃 +飘 +债 +犟 +金 +促 +苛 +崇 +坂 +莳 +畔 +绂 +兵 +蠕 +斋 +根 +砍 +亢 +欢 +恬 +崔 +剁 +餐 +榫 +快 +扶 +‖ +濒 +缠 +鳜 +当 +彭 +驭 +浦 +篮 +昀 +锆 +秸 +钳 +弋 +娣 +瞑 +夷 +龛 +苫 +拱 +致 +% +嵊 +障 +隐 +弑 +初 +娓 +抉 +汩 +累 +蓖 +" +唬 +助 +苓 +昙 +押 +毙 +破 +城 +郧 +逢 +嚏 +獭 +瞻 +溱 +婿 +赊 +跨 +恼 +璧 +萃 +姻 +貉 +灵 +炉 +密 +氛 +陶 +砸 +谬 +衔 +点 +琛 +沛 +枳 +层 +岱 +诺 +脍 +榈 +埂 +征 +冷 +裁 +打 +蹴 +素 +瘘 +逞 +蛐 +聊 +激 +腱 +萘 +踵 +飒 +蓟 +吆 +取 +咙 +簋 +涓 +矩 +曝 +挺 +揣 +座 +你 +史 +舵 +焱 +尘 +苏 +笈 +脚 +溉 +榨 +诵 +樊 +邓 +焊 +义 +庶 +儋 +蟋 +蒲 +赦 +呷 +杞 +诠 +豪 +还 +试 +颓 +茉 +太 +除 +紫 +逃 +痴 +草 +充 +鳕 +珉 +祗 +墨 +渭 +烩 +蘸 +慕 +璇 +镶 +穴 +嵘 +恶 +骂 +险 +绋 +幕 +碉 +肺 +戳 +刘 +潞 +秣 +纾 +潜 +銮 +洛 +须 +罘 +销 +瘪 +汞 +兮 +屉 +r +林 +厕 +质 +探 +划 +狸 +殚 +善 +煊 +烹 +〒 +锈 +逯 +宸 +辍 +泱 +柚 +袍 +远 +蹋 +嶙 +绝 +峥 +娥 +缍 +雀 +徵 +认 +镱 +谷 += +贩 +勉 +撩 +鄯 +斐 +洋 +非 +祚 +泾 +诒 +饿 +撬 +威 +晷 +搭 +芍 +锥 +笺 +蓦 +候 +琊 +档 +礁 +沼 +卵 +荠 +忑 +朝 +凹 +瑞 +头 +仪 +弧 +孵 +畏 +铆 +突 +衲 +车 +浩 +气 +茂 +悖 +厢 +枕 +酝 +戴 +湾 +邹 +飚 +攘 +锂 +写 +宵 +翁 +岷 +无 +喜 +丈 +挑 +嗟 +绛 +殉 +议 +槽 +具 +醇 +淞 +笃 +郴 +阅 +饼 +底 +壕 +砚 +弈 +询 +缕 +庹 +翟 +零 +筷 +暨 +舟 +闺 +甯 +撞 +麂 +茌 +蔼 +很 +珲 +捕 +棠 +角 +阉 +媛 +娲 +诽 +剿 +尉 +爵 +睬 +韩 +诰 +匣 +危 +糍 +镯 +立 +浏 +阳 +少 +盆 +舔 +擘 +匪 +申 +尬 +铣 +旯 +抖 +赘 +瓯 +居 +ˇ +哮 +游 +锭 +茏 +歌 +坏 +甚 +秒 +舞 +沙 +仗 +劲 +潺 +阿 +燧 +郭 +嗖 +霏 +忠 +材 +奂 +耐 +跺 +砀 +输 +岖 +媳 +氟 +极 +摆 +灿 +今 +扔 +腻 +枝 +奎 +药 +熄 +吨 +话 +q +额 +慑 +嘌 +协 +喀 +壳 +埭 +视 +著 +於 +愧 +陲 +翌 +峁 +颅 +佛 +腹 +聋 +侯 +咎 +叟 +秀 +颇 +存 +较 +罪 +哄 +岗 +扫 +栏 +钾 +羌 +己 +璨 +枭 +霉 +煌 +涸 +衿 +键 +镝 +益 +岢 +奏 +连 +夯 +睿 +冥 +均 +糖 +狞 +蹊 +稻 +爸 +刿 +胥 +煜 +丽 +肿 +璃 +掸 +跚 +灾 +垂 +樾 +濑 +乎 +莲 +窄 +犹 +撮 +战 +馄 +软 +络 +显 +鸢 +胸 +宾 +妲 +恕 +埔 +蝌 +份 +遇 +巧 +瞟 +粒 +恰 +剥 +桡 +博 +讯 +凯 +堇 +阶 +滤 +卖 +斌 +骚 +彬 +兑 +磺 +樱 +舷 +两 +娱 +福 +仃 +差 +找 +桁 +÷ +净 +把 +阴 +污 +戬 +雷 +碓 +蕲 +楚 +罡 +焖 +抽 +妫 +咒 +仑 +闱 +尽 +邑 +菁 +爱 +贷 +沥 +鞑 +牡 +嗉 +崴 +骤 +塌 +嗦 +订 +拮 +滓 +捡 +锻 +次 +坪 +杩 +臃 +箬 +融 +珂 +鹗 +宗 +枚 +降 +鸬 +妯 +阄 +堰 +盐 +毅 +必 +杨 +崃 +俺 +甬 +状 +莘 +货 +耸 +菱 +腼 +铸 +唏 +痤 +孚 +澳 +懒 +溅 +翘 +疙 +杷 +淼 +缙 +骰 +喊 +悉 +砻 +坷 +艇 +赁 +界 +谤 +纣 +宴 +晃 +茹 +归 +饭 +梢 +铡 +街 +抄 +肼 +鬟 +苯 +颂 +撷 +戈 +炒 +咆 +茭 +瘙 +负 +仰 +客 +琉 +铢 +封 +卑 +珥 +椿 +镧 +窨 +鬲 +寿 +御 +袤 +铃 +萎 +砖 +餮 +脒 +裳 +肪 +孕 +嫣 +馗 +嵇 +恳 +氯 +江 +石 +褶 +冢 +祸 +阻 +狈 +羞 +银 +靳 +透 +咳 +叼 +敷 +芷 +啥 +它 +瓤 +兰 +痘 +懊 +逑 +肌 +往 +捺 +坊 +甩 +呻 +〃 +沦 +忘 +膻 +祟 +菅 +剧 +崆 +智 +坯 +臧 +霍 +墅 +攻 +眯 +倘 +拢 +骠 +铐 +庭 +岙 +瓠 +′ +缺 +泥 +迢 +捶 +? +? +郏 +喙 +掷 +沌 +纯 +秘 +种 +听 +绘 +固 +螨 +团 +香 +盗 +妒 +埚 +蓝 +拖 +旱 +荞 +铀 +血 +遏 +汲 +辰 +叩 +拽 +幅 +硬 +惶 +桀 +漠 +措 +泼 +唑 +齐 +肾 +念 +酱 +虚 +屁 +耶 +旗 +砦 +闵 +婉 +馆 +拭 +绅 +韧 +忏 +窝 +醋 +葺 +顾 +辞 +倜 +堆 +辋 +逆 +玟 +贱 +疾 +董 +惘 +倌 +锕 +淘 +嘀 +莽 +俭 +笏 +绑 +鲷 +杈 +择 +蟀 +粥 +嗯 +驰 +逾 +案 +谪 +褓 +胫 +哩 +昕 +颚 +鲢 +绠 +躺 +鹄 +崂 +儒 +俨 +丝 +尕 +泌 +啊 +萸 +彰 +幺 +吟 +骄 +苣 +弦 +脊 +瑰 +〈 +诛 +镁 +析 +闪 +剪 +侧 +哟 +框 +螃 +守 +嬗 +燕 +狭 +铈 +缮 +概 +迳 +痧 +鲲 +俯 +售 +笼 +痣 +扉 +挖 +满 +咋 +援 +邱 +扇 +歪 +便 +玑 +绦 +峡 +蛇 +叨 +〖 +泽 +胃 +斓 +喋 +怂 +坟 +猪 +该 +蚬 +炕 +弥 +赞 +棣 +晔 +娠 +挲 +狡 +创 +疖 +铕 +镭 +稷 +挫 +弭 +啾 +翔 +粉 +履 +苘 +哦 +楼 +秕 +铂 +土 +锣 +瘟 +挣 +栉 +习 +享 +桢 +袅 +磨 +桂 +谦 +延 +坚 +蔚 +噗 +署 +谟 +猬 +钎 +恐 +嬉 +雒 +倦 +衅 +亏 +璩 +睹 +刻 +殿 +王 +算 +雕 +麻 +丘 +柯 +骆 +丸 +塍 +谚 +添 +鲈 +垓 +桎 +蚯 +芥 +予 +飕 +镦 +谌 +窗 +醚 +菀 +亮 +搪 +莺 +蒿 +羁 +足 +J +真 +轶 +悬 +衷 +靛 +翊 +掩 +哒 +炅 +掐 +冼 +妮 +l +谐 +稚 +荆 +擒 +犯 +陵 +虏 +浓 +崽 +刍 +陌 +傻 +孜 +千 +靖 +演 +矜 +钕 +煽 +杰 +酗 +渗 +伞 +栋 +俗 +泫 +戍 +罕 +沾 +疽 +灏 +煦 +芬 +磴 +叱 +阱 +榉 +湃 +蜀 +叉 +醒 +彪 +租 +郡 +篷 +屎 +良 +垢 +隗 +弱 +陨 +峪 +砷 +掴 +颁 +胎 +雯 +绵 +贬 +沐 +撵 +隘 +篙 +暖 +曹 +陡 +栓 +填 +臼 +彦 +瓶 +琪 +潼 +哪 +鸡 +摩 +啦 +俟 +锋 +域 +耻 +蔫 +疯 +纹 +撇 +毒 +绶 +痛 +酯 +忍 +爪 +赳 +歆 +嘹 +辕 +烈 +册 +朴 +钱 +吮 +毯 +癜 +娃 +谀 +邵 +厮 +炽 +璞 +邃 +丐 +追 +词 +瓒 +忆 +轧 +芫 +谯 +喷 +弟 +半 +冕 +裙 +掖 +墉 +绮 +寝 +苔 +势 +顷 +褥 +切 +衮 +君 +佳 +嫒 +蚩 +霞 +佚 +洙 +逊 +镖 +暹 +唛 +& +殒 +顶 +碗 +獗 +轭 +铺 +蛊 +废 +恹 +汨 +崩 +珍 +那 +杵 +曲 +纺 +夏 +薰 +傀 +闳 +淬 +姘 +舀 +拧 +卷 +楂 +恍 +讪 +厩 +寮 +篪 +赓 +乘 +灭 +盅 +鞣 +沟 +慎 +挂 +饺 +鼾 +杳 +树 +缨 +丛 +絮 +娌 +臻 +嗳 +篡 +侩 +述 +衰 +矛 +圈 +蚜 +匕 +筹 +匿 +濞 +晨 +叶 +骋 +郝 +挚 +蚴 +滞 +增 +侍 +描 +瓣 +吖 +嫦 +蟒 +匾 +圣 +赌 +毡 +癞 +恺 +百 +曳 +需 +篓 +肮 +庖 +帏 +卿 +驿 +遗 +蹬 +鬓 +骡 +歉 +芎 +胳 +屐 +禽 +烦 +晌 +寄 +媾 +狄 +翡 +苒 +船 +廉 +终 +痞 +殇 +々 +畦 +饶 +改 +拆 +悻 +萄 +£ +瓿 +乃 +訾 +桅 +匮 +溧 +拥 +纱 +铍 +骗 +蕃 +龋 +缬 +父 +佐 +疚 +栎 +醍 +掳 +蓄 +x +惆 +颜 +鲆 +榆 +〔 +猎 +敌 +暴 +谥 +鲫 +贾 +罗 +玻 +缄 +扦 +芪 +癣 +落 +徒 +臾 +恿 +猩 +托 +邴 +肄 +牵 +春 +陛 +耀 +刊 +拓 +蓓 +邳 +堕 +寇 +枉 +淌 +啡 +湄 +兽 +酷 +萼 +碚 +濠 +萤 +夹 +旬 +戮 +梭 +琥 +椭 +昔 +勺 +蜊 +绐 +晚 +孺 +僵 +宣 +摄 +冽 +旨 +萌 +忙 +蚤 +眉 +噼 +蟑 +付 +契 +瓜 +悼 +颡 +壁 +曾 +窕 +颢 +澎 +仿 +俑 +浑 +嵌 +浣 +乍 +碌 +褪 +乱 +蔟 +隙 +玩 +剐 +葫 +箫 +纲 +围 +伐 +决 +伙 +漩 +瑟 +刑 +肓 +镳 +缓 +蹭 +氨 +皓 +典 +畲 +坍 +铑 +檐 +塑 +洞 +倬 +储 +胴 +淳 +戾 +吐 +灼 +惺 +妙 +毕 +珐 +缈 +虱 +盖 +羰 +鸿 +磅 +谓 +髅 +娴 +苴 +唷 +蚣 +霹 +抨 +贤 +唠 +犬 +誓 +逍 +庠 +逼 +麓 +籼 +釉 +呜 +碧 +秧 +氩 +摔 +霄 +穸 +纨 +辟 +妈 +映 +完 +牛 +缴 +嗷 +炊 +恩 +荔 +茆 +掉 +紊 +慌 +莓 +羟 +阙 +萁 +磐 +另 +蕹 +辱 +鳐 +湮 +吡 +吩 +唐 +睦 +垠 +舒 +圜 +冗 +瞿 +溺 +芾 +囱 +匠 +僳 +汐 +菩 +饬 +漓 +黑 +霰 +浸 +濡 +窥 +毂 +蒡 +兢 +驻 +鹉 +芮 +诙 +迫 +雳 +厂 +忐 +臆 +猴 +鸣 +蚪 +栈 +箕 +羡 +渐 +莆 +捍 +眈 +哓 +趴 +蹼 +埕 +嚣 +骛 +宏 +淄 +斑 +噜 +严 +瑛 +垃 +椎 +诱 +压 +庾 +绞 +焘 +廿 +抡 +迄 +棘 +夫 +纬 +锹 +眨 +瞌 +侠 +脐 +竞 +瀑 +孳 +骧 +遁 +姜 +颦 +荪 +滚 +萦 +伪 +逸 +粳 +爬 +锁 +矣 +役 +趣 +洒 +颔 +诏 +逐 +奸 +甭 +惠 +攀 +蹄 +泛 +尼 +拼 +阮 +鹰 +亚 +颈 +惑 +勒 +〉 +际 +肛 +爷 +刚 +钨 +丰 +养 +冶 +鲽 +辉 +蔻 +画 +覆 +皴 +妊 +麦 +返 +醉 +皂 +擀 +〗 +酶 +凑 +粹 +悟 +诀 +硖 +港 +卜 +z +杀 +涕 +± +舍 +铠 +抵 +弛 +段 +敝 +镐 +奠 +拂 +轴 +跛 +袱 +e +t +沉 +菇 +俎 +薪 +峦 +秭 +蟹 +历 +盟 +菠 +寡 +液 +肢 +喻 +染 +裱 +悱 +抱 +氙 +赤 +捅 +猛 +跑 +氮 +谣 +仁 +尺 +辊 +窍 +烙 +衍 +架 +擦 +倏 +璐 +瑁 +币 +楞 +胖 +夔 +趸 +邛 +惴 +饕 +虔 +蝎 +§ +哉 +贝 +宽 +辫 +炮 +扩 +饲 +籽 +魏 +菟 +锰 +伍 +猝 +末 +琳 +哚 +蛎 +邂 +呀 +姿 +鄞 +却 +歧 +仙 +恸 +椐 +森 +牒 +寤 +袒 +婆 +虢 +雅 +钉 +朵 +贼 +欲 +苞 +寰 +故 +龚 +坭 +嘘 +咫 +礼 +硷 +兀 +睢 +汶 +’ +铲 +烧 +绕 +诃 +浃 +钿 +哺 +柜 +讼 +颊 +璁 +腔 +洽 +咐 +脲 +簌 +筠 +镣 +玮 +鞠 +谁 +兼 +姆 +挥 +梯 +蝴 +谘 +漕 +刷 +躏 +宦 +弼 +b +垌 +劈 +麟 +莉 +揭 +笙 +渎 +仕 +嗤 +仓 +配 +怏 +抬 +错 +泯 +镊 +孰 +猿 +邪 +仍 +秋 +鼬 +壹 +歇 +吵 +炼 +< +尧 +射 +柬 +廷 +胧 +霾 +凳 +隋 +肚 +浮 +梦 +祥 +株 +堵 +退 +L +鹫 +跎 +凶 +毽 +荟 +炫 +栩 +玳 +甜 +沂 +鹿 +顽 +伯 +爹 +赔 +蛴 +徐 +匡 +欣 +狰 +缸 +雹 +蟆 +疤 +默 +沤 +啜 +痂 +衣 +禅 +w +i +h +辽 +葳 +黝 +钗 +停 +沽 +棒 +馨 +颌 +肉 +吴 +硫 +悯 +劾 +娈 +马 +啧 +吊 +悌 +镑 +峭 +帆 +瀣 +涉 +咸 +疸 +滋 +泣 +翦 +拙 +癸 +钥 +蜒 ++ +尾 +庄 +凝 +泉 +婢 +渴 +谊 +乞 +陆 +锉 +糊 +鸦 +淮 +I +B +N +晦 +弗 +乔 +庥 +葡 +尻 +席 +橡 +傣 +渣 +拿 +惩 +麋 +斛 +缃 +矮 +蛏 +岘 +鸽 +姐 +膏 +催 +奔 +镒 +喱 +蠡 +摧 +钯 +胤 +柠 +拐 +璋 +鸥 +卢 +荡 +倾 +^ +_ +珀 +逄 +萧 +塾 +掇 +贮 +笆 +聂 +圃 +冲 +嵬 +M +滔 +笕 +值 +炙 +偶 +蜱 +搐 +梆 +汪 +蔬 +腑 +鸯 +蹇 +敞 +绯 +仨 +祯 +谆 +梧 +糗 +鑫 +啸 +豺 +囹 +猾 +巢 +柄 +瀛 +筑 +踌 +沭 +暗 +苁 +鱿 +蹉 +脂 +蘖 +牢 +热 +木 +吸 +溃 +宠 +序 +泞 +偿 +拜 +檩 +厚 +朐 +毗 +螳 +吞 +媚 +朽 +担 +蝗 +橘 +畴 +祈 +糟 +盱 +隼 +郜 +惜 +珠 +裨 +铵 +焙 +琚 +唯 +咚 +噪 +骊 +丫 +滢 +勤 +棉 +呸 +咣 +淀 +隔 +蕾 +窈 +饨 +挨 +煅 +短 +匙 +粕 +镜 +赣 +撕 +墩 +酬 +馁 +豌 +颐 +抗 +酣 +氓 +佑 +搁 +哭 +递 +耷 +涡 +桃 +贻 +碣 +截 +瘦 +昭 +镌 +蔓 +氚 +甲 +猕 +蕴 +蓬 +散 +拾 +纛 +狼 +猷 +铎 +埋 +旖 +矾 +讳 +囊 +糜 +迈 +粟 +蚂 +紧 +鲳 +瘢 +栽 +稼 +羊 +锄 +斟 +睁 +桥 +瓮 +蹙 +祉 +醺 +鼻 +昱 +剃 +跳 +篱 +跷 +蒜 +翎 +宅 +晖 +嗑 +壑 +峻 +癫 +屏 +狠 +陋 +袜 +途 +憎 +祀 +莹 +滟 +佶 +溥 +臣 +约 +盛 +峰 +磁 +慵 +婪 +拦 +莅 +朕 +鹦 +粲 +裤 +哎 +疡 +嫖 +琵 +窟 +堪 +谛 +嘉 +儡 +鳝 +斩 +郾 +驸 +酊 +妄 +胜 +贺 +徙 +傅 +噌 +钢 +栅 +庇 +恋 +匝 +巯 +邈 +尸 +锚 +粗 +佟 +蛟 +薹 +纵 +蚊 +郅 +绢 +锐 +苗 +俞 +篆 +淆 +膀 +鲜 +煎 +诶 +秽 +寻 +涮 +刺 +怀 +噶 +巨 +褰 +魅 +灶 +灌 +桉 +藕 +谜 +舸 +薄 +搀 +恽 +借 +牯 +痉 +渥 +愿 +亓 +耘 +杠 +柩 +锔 +蚶 +钣 +珈 +喘 +蹒 +幽 +赐 +稗 +晤 +莱 +泔 +扯 +肯 +菪 +裆 +腩 +豉 +疆 +骜 +腐 +倭 +珏 +唔 +粮 +亡 +润 +慰 +伽 +橄 +玄 +誉 +醐 +胆 +龊 +粼 +塬 +陇 +彼 +削 +嗣 +绾 +芽 +妗 +垭 +瘴 +爽 +薏 +寨 +龈 +泠 +弹 +赢 +漪 +猫 +嘧 +涂 +恤 +圭 +茧 +烽 +屑 +痕 +巾 +赖 +荸 +凰 +腮 +畈 +亵 +蹲 +偃 +苇 +澜 +艮 +换 +骺 +烘 +苕 +梓 +颉 +肇 +哗 +悄 +氤 +涠 +葬 +屠 +鹭 +植 +竺 +佯 +诣 +鲇 +瘀 +鲅 +邦 +移 +滁 +冯 +耕 +癔 +戌 +茬 +沁 +巩 +悠 +湘 +洪 +痹 +锟 +循 +谋 +腕 +鳃 +钠 +捞 +焉 +迎 +碱 +伫 +急 +榷 +奈 +邝 +卯 +辄 +皲 +卟 +醛 +畹 +忧 +稳 +雄 +昼 +缩 +阈 +睑 +扌 +耗 +曦 +涅 +捏 +瞧 +邕 +淖 +漉 +铝 +耦 +禹 +湛 +喽 +莼 +琅 +诸 +苎 +纂 +硅 +始 +嗨 +傥 +燃 +臂 +赅 +嘈 +呆 +贵 +屹 +壮 +肋 +亍 +蚀 +卅 +豹 +腆 +邬 +迭 +浊 +} +童 +螂 +捐 +圩 +勐 +触 +寞 +汊 +壤 +荫 +膺 +渌 +芳 +懿 +遴 +螈 +泰 +蓼 +蛤 +茜 +舅 +枫 +朔 +膝 +眙 +避 +梅 +判 +鹜 +璜 +牍 +缅 +垫 +藻 +黔 +侥 +惚 +懂 +踩 +腰 +腈 +札 +丞 +唾 +慈 +顿 +摹 +荻 +琬 +~ +斧 +沈 +滂 +胁 +胀 +幄 +莜 +Z +匀 +鄄 +掌 +绰 +茎 +焚 +赋 +萱 +谑 +汁 +铒 +瞎 +夺 +蜗 +野 +娆 +冀 +弯 +篁 +懵 +灞 +隽 +芡 +脘 +俐 +辩 +芯 +掺 +喏 +膈 +蝈 +觐 +悚 +踹 +蔗 +熠 +鼠 +呵 +抓 +橼 +峨 +畜 +缔 +禾 +崭 +弃 +熊 +摒 +凸 +拗 +穹 +蒙 +抒 +祛 +劝 +闫 +扳 +阵 +醌 +踪 +喵 +侣 +搬 +仅 +荧 +赎 +蝾 +琦 +买 +婧 +瞄 +寓 +皎 +冻 +赝 +箩 +莫 +瞰 +郊 +笫 +姝 +筒 +枪 +遣 +煸 +袋 +舆 +痱 +涛 +母 +〇 +启 +践 +耙 +绲 +盘 +遂 +昊 +搞 +槿 +诬 +纰 +泓 +惨 +檬 +亻 +越 +C +o +憩 +熵 +祷 +钒 +暧 +塔 +阗 +胰 +咄 +娶 +魔 +琶 +钞 +邻 +扬 +杉 +殴 +咽 +弓 +〆 +髻 +】 +吭 +揽 +霆 +拄 +殖 +脆 +彻 +岩 +芝 +勃 +辣 +剌 +钝 +嘎 +甄 +佘 +皖 +伦 +授 +徕 +憔 +挪 +皇 +庞 +稔 +芜 +踏 +溴 +兖 +卒 +擢 +饥 +鳞 +煲 +‰ +账 +颗 +叻 +斯 +捧 +鳍 +琮 +讹 +蛙 +纽 +谭 +酸 +兔 +莒 +睇 +伟 +觑 +羲 +嗜 +宜 +褐 +旎 +辛 +卦 +诘 +筋 +鎏 +溪 +挛 +熔 +阜 +晰 +鳅 +丢 +奚 +灸 +呱 +献 +陉 +黛 +鸪 +甾 +萨 +疮 +拯 +洲 +疹 +辑 +叙 +恻 +谒 +允 +柔 +烂 +氏 +逅 +漆 +拎 +惋 +扈 +湟 +纭 +啕 +掬 +擞 +哥 +忽 +涤 +鸵 +靡 +郗 +瓷 +扁 +廊 +怨 +雏 +钮 +敦 +E +懦 +憋 +汀 +拚 +啉 +腌 +岸 +f +痼 +瞅 +尊 +咀 +眩 +飙 +忌 +仝 +迦 +熬 +毫 +胯 +篑 +茄 +腺 +凄 +舛 +碴 +锵 +诧 +羯 +後 +漏 +汤 +宓 +仞 +蚁 +壶 +谰 +皑 +铄 +棰 +罔 +辅 +晶 +苦 +牟 +闽 +\ +烃 +饮 +聿 +丙 +蛳 +朱 +煤 +涔 +鳖 +犁 +罐 +荼 +砒 +淦 +妤 +黏 +戎 +孑 +婕 +瑾 +戢 +钵 +枣 +捋 +砥 +衩 +狙 +桠 +稣 +阎 +肃 +梏 +诫 +孪 +昶 +婊 +衫 +嗔 +侃 +塞 +蜃 +樵 +峒 +貌 +屿 +欺 +缫 +阐 +栖 +诟 +珞 +荭 +吝 +萍 +嗽 +恂 +啻 +蜴 +磬 +峋 +俸 +豫 +谎 +徊 +镍 +韬 +魇 +晴 +U +囟 +猜 +蛮 +坐 +囿 +伴 +亭 +肝 +佗 +蝠 +妃 +胞 +滩 +榴 +氖 +垩 +苋 +砣 +扪 +馏 +姓 +轩 +厉 +夥 +侈 +禀 +垒 +岑 +赏 +钛 +辐 +痔 +披 +纸 +碳 +“ +坞 +蠓 +挤 +荥 +沅 +悔 +铧 +帼 +蒌 +蝇 +a +p +y +n +g +哀 +浆 +瑶 +凿 +桶 +馈 +皮 +奴 +苜 +佤 +伶 +晗 +铱 +炬 +优 +弊 +氢 +恃 +甫 +攥 +端 +锌 +灰 +稹 +炝 +曙 +邋 +亥 +眶 +碾 +拉 +萝 +绔 +捷 +浍 +腋 +姑 +菖 +凌 +涞 +麽 +锢 +桨 +潢 +绎 +镰 +殆 +锑 +渝 +铬 +困 +绽 +觎 +匈 +糙 +暑 +裹 +鸟 +盔 +肽 +迷 +綦 +『 +亳 +佝 +俘 +钴 +觇 +骥 +仆 +疝 +跪 +婶 +郯 +瀹 +唉 +脖 +踞 +针 +晾 +忒 +扼 +瞩 +叛 +椒 +疟 +嗡 +邗 +肆 +跆 +玫 +忡 +捣 +咧 +唆 +艄 +蘑 +潦 +笛 +阚 +沸 +泻 +掊 +菽 +贫 +斥 +髂 +孢 +镂 +赂 +麝 +鸾 +屡 +衬 +苷 +恪 +叠 +希 +粤 +爻 +喝 +茫 +惬 +郸 +绻 +庸 +撅 +碟 +宄 +妹 +膛 +叮 +饵 +崛 +嗲 +椅 +冤 +搅 +咕 +敛 +尹 +垦 +闷 +蝉 +霎 +勰 +败 +蓑 +泸 +肤 +鹌 +幌 +焦 +浠 +鞍 +刁 +舰 +乙 +竿 +裔 +。 +茵 +函 +伊 +兄 +丨 +娜 +匍 +謇 +莪 +宥 +似 +蝽 +翳 +酪 +翠 +粑 +薇 +祢 +骏 +赠 +叫 +Q +噤 +噻 +竖 +芗 +莠 +潭 +俊 +羿 +耜 +O +郫 +趁 +嗪 +囚 +蹶 +芒 +洁 +笋 +鹑 +敲 +硝 +啶 +堡 +渲 +揩 +』 +携 +宿 +遒 +颍 +扭 +棱 +割 +萜 +蔸 +葵 +琴 +捂 +饰 +衙 +耿 +掠 +募 +岂 +窖 +涟 +蔺 +瘤 +柞 +瞪 +怜 +匹 +距 +楔 +炜 +哆 +秦 +缎 +幼 +茁 +绪 +痨 +恨 +楸 +娅 +瓦 +桩 +雪 +嬴 +伏 +榔 +妥 +铿 +拌 +眠 +雍 +缇 +‘ +卓 +搓 +哌 +觞 +噩 +屈 +哧 +髓 +咦 +巅 +娑 +侑 +淫 +膳 +祝 +勾 +姊 +莴 +胄 +疃 +薛 +蜷 +胛 +巷 +芙 +芋 +熙 +闰 +勿 +窃 +狱 +剩 +钏 +幢 +陟 +铛 +慧 +靴 +耍 +k +浙 +浇 +飨 +惟 +绗 +祜 +澈 +啼 +咪 +磷 +摞 +诅 +郦 +抹 +跃 +壬 +吕 +肖 +琏 +颤 +尴 +剡 +抠 +凋 +赚 +泊 +津 +宕 +殷 +倔 +氲 +漫 +邺 +涎 +怠 +$ +垮 +荬 +遵 +俏 +叹 +噢 +饽 +蜘 +孙 +筵 +疼 +鞭 +羧 +牦 +箭 +潴 +c +眸 +祭 +髯 +啖 +坳 +愁 +芩 +驮 +倡 +巽 +穰 +沃 +胚 +怒 +凤 +槛 +剂 +趵 +嫁 +v +邢 +灯 +鄢 +桐 +睽 +檗 +锯 +槟 +婷 +嵋 +圻 +诗 +蕈 +颠 +遭 +痢 +芸 +怯 +馥 +竭 +锗 +徜 +恭 +遍 +籁 +剑 +嘱 +苡 +龄 +僧 +桑 +潸 +弘 +澶 +楹 +悲 +讫 +愤 +腥 +悸 +谍 +椹 +呢 +桓 +葭 +攫 +阀 +翰 +躲 +敖 +柑 +郎 +笨 +橇 +呃 +魁 +燎 +脓 +葩 +磋 +垛 +玺 +狮 +沓 +砜 +蕊 +锺 +罹 +蕉 +翱 +虐 +闾 +巫 +旦 +茱 +嬷 +枯 +鹏 +贡 +芹 +汛 +矫 +绁 +拣 +禺 +佃 +讣 +舫 +惯 +乳 +趋 +疲 +挽 +岚 +虾 +衾 +蠹 +蹂 +飓 +氦 +铖 +孩 +稞 +瑜 +壅 +掀 +勘 +妓 +畅 +髋 +W +庐 +牲 +蓿 +榕 +练 +垣 +唱 +邸 +菲 +昆 +婺 +穿 +绡 +麒 +蚱 +掂 +愚 +泷 +涪 +漳 +妩 +娉 +榄 +讷 +觅 +旧 +藤 +煮 +呛 +柳 +腓 +叭 +庵 +烷 +阡 +罂 +蜕 +擂 +猖 +咿 +媲 +脉 +【 +沏 +貅 +黠 +熏 +哲 +烁 +坦 +酵 +兜 +× +潇 +撒 +剽 +珩 +圹 +乾 +摸 +樟 +帽 +嗒 +襄 +魂 +轿 +憬 +锡 +〕 +喃 +皆 +咖 +隅 +脸 +残 +泮 +袂 +鹂 +珊 +囤 +捆 +咤 +误 +徨 +闹 +淙 +芊 +淋 +怆 +囗 +拨 +梳 +渤 +R +G +绨 +蚓 +婀 +幡 +狩 +麾 +谢 +唢 +裸 +旌 +伉 +纶 +裂 +驳 +砼 +咛 +澄 +樨 +蹈 +宙 +澍 +倍 +貔 +操 +勇 +蟠 +摈 +砧 +虬 +够 +缁 +悦 +藿 +撸 +艹 +摁 +淹 +豇 +虎 +榭 +ˉ +吱 +d +° +喧 +荀 +踱 +侮 +奋 +偕 +饷 +犍 +惮 +坑 +璎 +徘 +宛 +妆 +袈 +倩 +窦 +昂 +荏 +乖 +K +怅 +撰 +鳙 +牙 +袁 +酞 +X +痿 +琼 +闸 +雁 +趾 +荚 +虻 +涝 +《 +杏 +韭 +偈 +烤 +绫 +鞘 +卉 +症 +遢 +蓥 +诋 +杭 +荨 +匆 +竣 +簪 +辙 +敕 +虞 +丹 +缭 +咩 +黟 +m +淤 +瑕 +咂 +铉 +硼 +茨 +嶂 +痒 +畸 +敬 +涿 +粪 +窘 +熟 +叔 +嫔 +盾 +忱 +裘 +憾 +梵 +赡 +珙 +咯 +娘 +庙 +溯 +胺 +葱 +痪 +摊 +荷 +卞 +乒 +髦 +寐 +铭 +坩 +胗 +枷 +爆 +溟 +嚼 +羚 +砬 +轨 +惊 +挠 +罄 +竽 +菏 +氧 +浅 +楣 +盼 +枢 +炸 +阆 +杯 +谏 +噬 +淇 +渺 +俪 +秆 +墓 +泪 +跻 +砌 +痰 +垡 +渡 +耽 +釜 +讶 +鳎 +煞 +呗 +韶 +舶 +绷 +鹳 +缜 +旷 +铊 +皱 +龌 +檀 +霖 +奄 +槐 +艳 +蝶 +旋 +哝 +赶 +骞 +蚧 +腊 +盈 +丁 +` +蜚 +矸 +蝙 +睨 +嚓 +僻 +鬼 +醴 +夜 +彝 +磊 +笔 +拔 +栀 +糕 +厦 +邰 +纫 +逭 +纤 +眦 +膊 +馍 +躇 +烯 +蘼 +冬 +诤 +暄 +骶 +哑 +瘠 +」 +臊 +丕 +愈 +咱 +螺 +擅 +跋 +搏 +硪 +谄 +笠 +淡 +嘿 +骅 +谧 +鼎 +皋 +姚 +歼 +蠢 +驼 +耳 +胬 +挝 +涯 +狗 +蒽 +孓 +犷 +凉 +芦 +箴 +铤 +孤 +嘛 +坤 +V +茴 +朦 +挞 +尖 +橙 +诞 +搴 +碇 +洵 +浚 +帚 +蜍 +漯 +柘 +嚎 +讽 +芭 +荤 +咻 +祠 +秉 +跖 +埃 +吓 +糯 +眷 +馒 +惹 +娼 +鲑 +嫩 +讴 +轮 +瞥 +靶 +褚 +乏 +缤 +宋 +帧 +删 +驱 +碎 +扑 +俩 +俄 +偏 +涣 +竹 +噱 +皙 +佰 +渚 +唧 +斡 +# +镉 +刀 +崎 +筐 +佣 +夭 +贰 +肴 +峙 +哔 +艿 +匐 +牺 +镛 +缘 +仡 +嫡 +劣 +枸 +堀 +梨 +簿 +鸭 +蒸 +亦 +稽 +浴 +{ +衢 +束 +槲 +j +阁 +揍 +疥 +棋 +潋 +聪 +窜 +乓 +睛 +插 +冉 +阪 +苍 +搽 +「 +蟾 +螟 +幸 +仇 +樽 +撂 +慢 +跤 +幔 +俚 +淅 +覃 +觊 +溶 +妖 +帛 +侨 +曰 +妾 +泗 +· +: +瀘 +風 +Ë +( +) +∶ +紅 +紗 +瑭 +雲 +頭 +鶏 +財 +許 +• +¥ +樂 +焗 +麗 +— +; +滙 +東 +榮 +繪 +興 +… +門 +業 +π +楊 +國 +顧 +é +盤 +寳 +Λ +龍 +鳳 +島 +誌 +緣 +結 +銭 +萬 +勝 +祎 +璟 +優 +歡 +臨 +時 +購 += +★ +藍 +昇 +鐵 +觀 +勅 +農 +聲 +畫 +兿 +術 +發 +劉 +記 +專 +耑 +園 +書 +壴 +種 +Ο +● +褀 +號 +銀 +匯 +敟 +锘 +葉 +橪 +廣 +進 +蒄 +鑽 +阝 +祙 +貢 +鍋 +豊 +夬 +喆 +團 +閣 +開 +燁 +賓 +館 +酡 +沔 +順 ++ +硚 +劵 +饸 +陽 +車 +湓 +復 +萊 +氣 +軒 +華 +堃 +迮 +纟 +戶 +馬 +學 +裡 +電 +嶽 +獨 +マ +シ +サ +ジ +燘 +袪 +環 +❤ +臺 +灣 +専 +賣 +孖 +聖 +攝 +線 +▪ +α +傢 +俬 +夢 +達 +莊 +喬 +貝 +薩 +劍 +羅 +壓 +棛 +饦 +尃 +璈 +囍 +醫 +G +I +A +# +N +鷄 +髙 +嬰 +啓 +約 +隹 +潔 +賴 +藝 +~ +寶 +籣 +麺 +  +嶺 +√ +義 +網 +峩 +長 +∧ +魚 +機 +構 +② +鳯 +偉 +L +B +㙟 +畵 +鴿 +' +詩 +溝 +嚞 +屌 +藔 +佧 +玥 +蘭 +織 +1 +3 +9 +0 +7 +點 +砭 +鴨 +鋪 +銘 +廳 +弍 +‧ +創 +湯 +坶 +℃ +卩 +骝 +& +烜 +荘 +當 +潤 +扞 +係 +懷 +碶 +钅 +蚨 +讠 +☆ +叢 +爲 +埗 +涫 +塗 +→ +楽 +現 +鯨 +愛 +瑪 +鈺 +忄 +悶 +藥 +飾 +樓 +視 +孬 +ㆍ +燚 +苪 +師 +① +丼 +锽 +│ +韓 +標 +è +兒 +閏 +匋 +張 +漢 +Ü +髪 +會 +閑 +檔 +習 +裝 +の +峯 +菘 +輝 +И +雞 +釣 +億 +浐 +K +O +R +8 +H +E +P +T +W +D +S +C +M +F +姌 +饹 +» +晞 +廰 +ä +嵯 +鷹 +負 +飲 +絲 +冚 +楗 +澤 +綫 +區 +❋ +← +質 +靑 +揚 +③ +滬 +統 +産 +協 +﹑ +乸 +畐 +經 +運 +際 +洺 +岽 +為 +粵 +諾 +崋 +豐 +碁 +ɔ +V +2 +6 +齋 +誠 +訂 +´ +勑 +雙 +陳 +無 +í +泩 +媄 +夌 +刂 +i +c +t +o +r +a +嘢 +耄 +燴 +暃 +壽 +媽 +靈 +抻 +體 +唻 +É +冮 +甹 +鎮 +錦 +ʌ +蜛 +蠄 +尓 +駕 +戀 +飬 +逹 +倫 +貴 +極 +Я +Й +寬 +磚 +嶪 +郎 +職 +| +間 +n +d +剎 +伈 +課 +飛 +橋 +瘊 +№ +譜 +骓 +圗 +滘 +縣 +粿 +咅 +養 +濤 +彳 +® +% +Ⅱ +啰 +㴪 +見 +矞 +薬 +糁 +邨 +鲮 +顔 +罱 +З +選 +話 +贏 +氪 +俵 +競 +瑩 +繡 +枱 +β +綉 +á +獅 +爾 +™ +麵 +戋 +淩 +徳 +個 +劇 +場 +務 +簡 +寵 +h +實 +膠 +轱 +圖 +築 +嘣 +樹 +㸃 +營 +耵 +孫 +饃 +鄺 +飯 +麯 +遠 +輸 +坫 +孃 +乚 +閃 +鏢 +㎡ +題 +廠 +關 +↑ +爺 +將 +軍 +連 +篦 +覌 +參 +箸 +- +窠 +棽 +寕 +夀 +爰 +歐 +呙 +閥 +頡 +熱 +雎 +垟 +裟 +凬 +勁 +帑 +馕 +夆 +疌 +枼 +馮 +貨 +蒤 +樸 +彧 +旸 +靜 +龢 +暢 +㐱 +鳥 +珺 +鏡 +灡 +爭 +堷 +廚 +Ó +騰 +診 +┅ +蘇 +褔 +凱 +頂 +豕 +亞 +帥 +嘬 +⊥ +仺 +桖 +複 +饣 +絡 +穂 +顏 +棟 +納 +▏ +濟 +親 +設 +計 +攵 +埌 +烺 +ò +頤 +燦 +蓮 +撻 +節 +講 +濱 +濃 +娽 +洳 +朿 +燈 +鈴 +護 +膚 +铔 +過 +補 +Z +U +5 +4 +坋 +闿 +䖝 +餘 +缐 +铞 +貿 +铪 +桼 +趙 +鍊 +[ +㐂 +垚 +菓 +揸 +捲 +鐘 +滏 +𣇉 +爍 +輪 +燜 +鴻 +鮮 +動 +鹞 +鷗 +丄 +慶 +鉌 +翥 +飮 +腸 +⇋ +漁 +覺 +來 +熘 +昴 +翏 +鲱 +圧 +鄉 +萭 +頔 +爐 +嫚 +г +貭 +類 +聯 +幛 +輕 +訓 +鑒 +夋 +锨 +芃 +珣 +䝉 +扙 +嵐 +銷 +處 +ㄱ +語 +誘 +苝 +歸 +儀 +燒 +楿 +內 +粢 +葒 +奧 +麥 +礻 +滿 +蠔 +穵 +瞭 +態 +鱬 +榞 +硂 +鄭 +黃 +煙 +祐 +奓 +逺 +* +瑄 +獲 +聞 +薦 +讀 +這 +樣 +決 +問 +啟 +們 +執 +説 +轉 +單 +隨 +唘 +帶 +倉 +庫 +還 +贈 +尙 +皺 +■ +餅 +產 +○ +∈ +報 +狀 +楓 +賠 +琯 +嗮 +禮 +` +傳 +> +≤ +嗞 +Φ +≥ +換 +咭 +∣ +↓ +曬 +ε +応 +寫 +″ +終 +様 +純 +費 +療 +聨 +凍 +壐 +郵 +ü +黒 +∫ +製 +塊 +調 +軽 +確 +撃 +級 +馴 +Ⅲ +涇 +繹 +數 +碼 +證 +狒 +処 +劑 +< +晧 +賀 +衆 +] +櫥 +兩 +陰 +絶 +對 +鯉 +憶 +◎ +p +e +Y +蕒 +煖 +頓 +測 +試 +鼽 +僑 +碩 +妝 +帯 +≈ +鐡 +舖 +權 +喫 +倆 +ˋ +該 +悅 +ā +俫 +. +f +s +b +m +k +g +u +j +貼 +淨 +濕 +針 +適 +備 +l +/ +給 +謢 +強 +觸 +衛 +與 +⊙ +$ +緯 +變 +⑴ +⑵ +⑶ +㎏ +殺 +∩ +幚 +─ +價 +▲ +離 +ú +ó +飄 +烏 +関 +閟 +﹝ +﹞ +邏 +輯 +鍵 +驗 +訣 +導 +歷 +屆 +層 +▼ +儱 +錄 +熳 +ē +艦 +吋 +錶 +辧 +飼 +顯 +④ +禦 +販 +気 +対 +枰 +閩 +紀 +幹 +瞓 +貊 +淚 +△ +眞 +墊 +Ω +獻 +褲 +縫 +緑 +亜 +鉅 +餠 +{ +} +◆ +蘆 +薈 +█ +◇ +溫 +彈 +晳 +粧 +犸 +穩 +訊 +崬 +凖 +熥 +П +舊 +條 +紋 +圍 +Ⅳ +筆 +尷 +難 +雜 +錯 +綁 +識 +頰 +鎖 +艶 +□ +殁 +殼 +⑧ +├ +▕ +鵬 +ǐ +ō +ǒ +糝 +綱 +▎ +μ +盜 +饅 +醬 +籤 +蓋 +釀 +鹽 +據 +à +ɡ +辦 +◥ +彐 +┌ +婦 +獸 +鲩 +伱 +ī +蒟 +蒻 +齊 +袆 +腦 +寧 +凈 +妳 +煥 +詢 +偽 +謹 +啫 +鯽 +騷 +鱸 +損 +傷 +鎻 +髮 +買 +冏 +儥 +両 +﹢ +∞ +載 +喰 +z +羙 +悵 +燙 +曉 +員 +組 +徹 +艷 +痠 +鋼 +鼙 +縮 +細 +嚒 +爯 +≠ +維 +" +鱻 +壇 +厍 +帰 +浥 +犇 +薡 +軎 +² +應 +醜 +刪 +緻 +鶴 +賜 +噁 +軌 +尨 +镔 +鷺 +槗 +彌 +葚 +濛 +請 +溇 +緹 +賢 +訪 +獴 +瑅 +資 +縤 +陣 +蕟 +栢 +韻 +祼 +恁 +伢 +謝 +劃 +涑 +總 +衖 +踺 +砋 +凉 +籃 +駿 +苼 +瘋 +昽 +紡 +驊 +腎 +﹗ +響 +杋 +剛 +嚴 +禪 +歓 +槍 +傘 +檸 +檫 +炣 +勢 +鏜 +鎢 +銑 +尐 +減 +奪 +惡 +θ +僮 +婭 +臘 +ū +ì +殻 +鉄 +∑ +蛲 +焼 +緖 +續 +紹 +懮 diff --git a/tcaptcha/ocr_engines.py b/tcaptcha/ocr_engines.py new file mode 100644 index 0000000..ff14817 --- /dev/null +++ b/tcaptcha/ocr_engines.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +""" +腾讯天御点选验证码 OCR 识别引擎集合 (可插拔 + 集成投票) + +设计动机 +-------- +单一 OCR 对生僻字(如 "筹")置信度极低: ddddocr 对 "筹" 的 CTC 最大概率仅 0.005, +而另一侧 PP-OCRv4 对 "雹" 又会误识为 "电"。两者互补, 因此本模块做 **概率级集成**: +对同一个字形, 把每个引擎在每个目标字上的 CTC 最大时间步概率加权求和, 再交给 +全局指派算法, 可在 3 选 1 的题型上取得远高于单引擎的准确率。 + +引擎统一接口 +------------ + probabilities(gray: np.ndarray) -> (chars: List[str], probs: np.ndarray) # probs: (T, C) + +gray 约定: 白底黑字的 uint8 灰度图 (字形像素值低)。 +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import cv2 +import numpy as np + +logger = logging.getLogger("tcaptcha_ocr") + +MODEL_DIR = Path(__file__).resolve().parent / "models" +PPOCR_DIR = MODEL_DIR / "ppocr" + + +class BaseOcrEngine: + name = "base" + + def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]: + raise NotImplementedError + + def close(self): + pass + + +class DdddOcrEngine(BaseOcrEngine): + """ddddocr (CTC, 8210 字集) —— 对常用字准确率高, 集成中的稳健票""" + + name = "ddddocr" + + def __init__(self, beta: bool = False, ocr=None): + self._ocr = ocr + self._beta = beta + + @property + def ocr(self): + if self._ocr is None: + import ddddocr + + self._ocr = ddddocr.DdddOcr(show_ad=False, beta=self._beta) + return self._ocr + + def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]: + res = self.ocr.classification(gray, probability=True) + probs = np.asarray(res["probabilities"]) # (T, 1, C) + if probs.ndim == 3: + probs = probs[:, 0, :] + return list(res["charset"]), probs + + +class PpOcrEngine(BaseOcrEngine): + """PaddleOCR PP-OCRv4 中文识别模型 (ONNX, 6623 字集) —— 对生僻字覆盖更好""" + + name = "ppocr_v4" + + def __init__(self, model_path: Optional[Path] = None, keys_path: Optional[Path] = None, + rec_height: int = 48): + import onnxruntime as ort + + self.model_path = Path(model_path or PPOCR_DIR / "ch_PP-OCRv4_rec_infer.onnx") + self.keys_path = Path(keys_path or PPOCR_DIR / "ppocr_keys_v1.txt") + if not self.model_path.exists(): + raise FileNotFoundError( + f"缺少 PP-OCRv4 识别模型: {self.model_path}\n" + "下载: https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv4/ch_PP-OCRv4_rec_infer.onnx" + ) + if not self.keys_path.exists(): + raise FileNotFoundError(f"缺少 PP-OCR 字典: {self.keys_path}") + + so = ort.SessionOptions() + so.intra_op_num_threads = 2 + so.inter_op_num_threads = 2 + self.sess = ort.InferenceSession(str(self.model_path), so, providers=["CPUExecutionProvider"]) + self.input_name = self.sess.get_inputs()[0].name + self.rec_height = rec_height + + keys = self.keys_path.read_text(encoding="utf-8").split("\n") + while keys and keys[-1] == "": + keys.pop() + # PaddleOCR 约定: 输出维度 = 1(blank) + 字典 + 1(空格) + self.chars = ["blank"] + keys + [" "] + + def _preprocess(self, gray: np.ndarray) -> np.ndarray: + g = gray.astype(np.float32) / 255.0 + ratio = self.rec_height / float(g.shape[0]) + g = cv2.resize(g, (0, 0), fx=ratio, fy=ratio, interpolation=cv2.INTER_LINEAR) + g = (g - 0.5) / 0.5 + g = g[None, None, :, :] + return np.concatenate([g, g, g], axis=1).astype(np.float32) + + def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]: + logits = self.sess.run(None, {self.input_name: self._preprocess(gray)})[0] # (1, T, C) + return self.chars, np.asarray(logits[0]) + + +class EnsembleOcr: + """概率级加权集成: score(t) = Σ w_i * max_t P_i(t)""" + + def __init__(self, engines: Sequence[BaseOcrEngine], weights: Optional[Sequence[float]] = None): + self.engines = list(engines) + self.weights = list(weights) if weights else [1.0] * len(self.engines) + if len(self.weights) != len(self.engines): + raise ValueError("engines 与 weights 数量不一致") + + @classmethod + def default(cls) -> "EnsembleOcr": + engines: List[BaseOcrEngine] = [] + try: + engines.append(PpOcrEngine()) + except FileNotFoundError as exc: + logger.warning("PP-OCRv4 不可用, 退化为单引擎: %s", exc) + engines.append(DdddOcrEngine()) + return cls(engines) + + def per_engine_scores(self, gray: np.ndarray, targets: Sequence[str]) -> List[Dict[str, float]]: + per: List[Dict[str, float]] = [] + for engine in self.engines: + try: + chars, probs = engine.probabilities(gray) + except Exception as exc: # 单个引擎异常不影响整体求解 + logger.warning("引擎 %s 推理失败: %s", engine.name, exc) + per.append({t: 0.0 for t in targets}) + continue + index = {c: i for i, c in enumerate(chars)} + row = {} + for t in targets: + j = index.get(t) + row[t] = float(probs[:, j].max()) if j is not None else 0.0 + per.append(row) + return per + + def scores(self, gray: np.ndarray, targets: Sequence[str]) -> Dict[str, float]: + per = self.per_engine_scores(gray, targets) + out = {} + for t in targets: + out[t] = float(sum(w * r.get(t, 0.0) for w, r in zip(self.weights, per))) + return out + + def close(self): + for engine in self.engines: + engine.close() + + +if __name__ == "__main__": + import sys + + ens = EnsembleOcr.default() + img = cv2.imread(sys.argv[1], cv2.IMREAD_GRAYSCALE) + targets = sys.argv[2:] + print(ens.per_engine_scores(img, targets)) + print("ensemble:", ens.scores(img, targets)) diff --git a/tcaptcha/one_ms_adapter.py b/tcaptcha/one_ms_adapter.py new file mode 100644 index 0000000..c2d6a94 --- /dev/null +++ b/tcaptcha/one_ms_adapter.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +""" +毫秒 (1ms.run) 业务适配器 —— 腾讯天御点选验证码凭证获取 + +协议来源: `/Users/yml/Desktop/抓包/毫秒/点选.har` + +从抓包可还原的链路 (本适配器已全部实现): + + 第一步 毫秒取验证码参数 + POST https://1ms.run/api/v1/captcha/params + body {"scene": "checkin"} (也可以带 identity_context) + -> {"provider":"tencent","params":{"captchaAppId":195672344}, + "sessionUuid":"01a12a04-e5e7-726b-b00c-2f54b2e1c167"} + + 第二步 腾讯天御 cap_union_new 全套纯协议解题 + (prehandle -> PoW -> tdc.js -> getcapbysig -> verify) + -> {"ticket": "tr03...", "randstr": "@..."} + +说明与边界 +---------- +* `identity_context` 在抓包中是页面内加密上下文 (emctx1.*), 实测 **不传同样可以** 拿到 + `captchaAppId`, 因此本适配器不依赖它; 若后续业务要求, 可通过参数显式传入。 +* 抓包中 **不包含** 最终的签到提交请求 (`/api/v1/...` 业务接口), 因此本适配器只负责 + 产出 `ticket` + `randstr`, 并给出一个可配置的 `submit_business()` 便于对接真实业务 + 接口 (端点与字段需按实际业务抓包填写, 严禁凭猜测写死)。 +""" + +import logging +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + +import httpx + +from tcaptcha.client import KIND_TEXT_CLICK, TencentCaptchaClient + +logger = logging.getLogger("one_ms_adapter") + +ONE_MS_BASE = "https://1ms.run" +DEFAULT_SCENE = "checkin" +DEFAULT_REFERER = "https://1ms.run/console/personal/benefits/check-in" +DEFAULT_USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36") + + +@dataclass +class CaptchaParams: + provider: str + app_id: str + session_uuid: str + enabled: bool + + +class OneMsAdapter: + """1ms.run 验证码凭证适配器""" + + def __init__(self, cookie: Optional[str] = None, user_agent: str = DEFAULT_USER_AGENT, + timeout: float = 15.0, proxy: Optional[str] = None, + proxy_api: Optional[str] = None, telemetry_mode: str = "python"): + self.user_agent = user_agent + self.timeout = timeout + # 腾讯天御按出口 IP 做滚动风控 (errorCode=12); 给了 proxy_api 就能自动换 IP 重试 + self.proxy = proxy + self.proxy_api = proxy_api + self.telemetry_mode = telemetry_mode + headers = { + "User-Agent": user_agent, + "Origin": ONE_MS_BASE, + "Referer": DEFAULT_REFERER, + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + } + if cookie: + headers["Cookie"] = cookie + self.http = httpx.Client(base_url=ONE_MS_BASE, headers=headers, timeout=timeout, + trust_env=False, follow_redirects=True) + + # ---------------------------------------------------------- 第一步 + def fetch_captcha_params(self, scene: str = DEFAULT_SCENE, + identity_context: str = "") -> CaptchaParams: + """POST /api/v1/captcha/params —— 取当前场景的验证码配置""" + payload: Dict[str, str] = {"scene": scene} + if identity_context: + payload["identity_context"] = identity_context + resp = self.http.post("/api/v1/captcha/params", json=payload) + data = resp.json() + if data.get("code") != 0: + raise RuntimeError(f"取验证码参数失败: {data}") + info = data.get("data") or {} + params = info.get("params") or {} + return CaptchaParams( + provider=info.get("provider", ""), + app_id=str(params.get("captchaAppId", "")), + session_uuid=info.get("sessionUuid", ""), + enabled=bool(info.get("enabled")), + ) + + # ---------------------------------------------------------- 第二步 + def get_captcha_ticket(self, scene: str = DEFAULT_SCENE, + max_retries: int = 3, + max_proxy_rotations: int = 3) -> Tuple[Optional[str], Optional[str], Dict]: + """ + 取得可提交给毫秒业务接口的 (ticket, randstr)。 + + 若配置了 `proxy_api`, 在遇到 `errorCode=12` (f_limit, IP 级滚动风控) 时会 + 自动换一个出口 IP 重试, 最多 `max_proxy_rotations` 次。 + """ + params = self.fetch_captcha_params(scene=scene) + logger.info("毫秒验证码参数: provider=%s appId=%s enabled=%s sessionUuid=%s", + params.provider, params.app_id, params.enabled, params.session_uuid) + if not params.enabled or not params.app_id: + return None, None, {"error": "该场景未启用验证码"} + if params.provider != "tencent": + return None, None, {"error": f"暂只支持 tencent provider, 实际为 {params.provider}"} + + proxy = self.proxy + last: Dict = {} + for rotation in range(max(1, max_proxy_rotations)): + client = TencentCaptchaClient( + aid=params.app_id, + entry_url=DEFAULT_REFERER, + referer=ONE_MS_BASE + "/", + wb="1", + accept_kinds=(KIND_TEXT_CLICK,), + proxy=proxy, + telemetry_mode=self.telemetry_mode, + ) + try: + result = client.solve(max_retries=max_retries, max_refreshes=8) + finally: + client.close() + last = result + if result.get("success"): + return result.get("ticket") or None, result.get("randstr") or None, result + if "12" not in str(result.get("error", "")) or not self.proxy_api: + break + from tcaptcha.proxy import fetch_proxy + + proxy = fetch_proxy(self.proxy_api) + logger.warning("触发 errorCode=12, 换出口 IP 重试 (%d/%d): %s", + rotation + 1, max_proxy_rotations, proxy) + if not proxy: + break + return None, None, last + + # ---------------------------------------------------------- 业务提交 + def submit_business(self, path: str, payload: Dict, method: str = "POST") -> Dict: + """ + 用 ticket/randstr 提交真实业务请求。 + + 注意: 点选.har 中 **没有** 最终的签到业务请求, 因此这里不预设端点与字段, + 由调用方按实际业务抓包传入 (通常是 {"vticket": ticket, "vrandstr": randstr, ...})。 + """ + if method.upper() == "POST": + resp = self.http.post(path, json=payload) + else: + resp = self.http.get(path, params=payload) + try: + return resp.json() + except Exception: + return {"code": resp.status_code, "text": resp.text} + + def close(self): + self.http.close() + + +if __name__ == "__main__": + import argparse + import json + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + ap = argparse.ArgumentParser(description="1ms.run 验证码票据获取") + ap.add_argument("--scene", default=DEFAULT_SCENE) + ap.add_argument("--cookie", default="") + ap.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080") + ap.add_argument("--proxy-api", default=None, help="代理提取 API (遇到 errorCode=12 自动换 IP)") + args = ap.parse_args() + + adapter = OneMsAdapter(cookie=args.cookie or None, proxy=args.proxy, + proxy_api=args.proxy_api) + try: + ticket, randstr, raw = adapter.get_captcha_ticket(scene=args.scene) + finally: + adapter.close() + print(json.dumps({"ticket": ticket, "randstr": randstr, + "error": raw.get("error", "")}, ensure_ascii=False, indent=2)) diff --git a/tcaptcha/proxy.py b/tcaptcha/proxy.py new file mode 100644 index 0000000..6d6688e --- /dev/null +++ b/tcaptcha/proxy.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +""" +代理 IP 提取与格式化 (用于绕过腾讯天御的 IP 级滚动风控) + +腾讯验证码按出口 IP 做滚动窗口风控, 触发时会返回 `errorCode=12` (f_limit)。 +本模块从常见的「代理 IP 提取 API」取一个可用代理, 供 `TencentCaptchaClient(proxy=...)` +与 `OneMsAdapter(proxy=...)` 使用。 + +已适配的返回格式: + * JSON: {"status":100,"data":[{"ip":"1.2.3.4","port":"8080"}]} (讯客 xkdaili 等) + * JSON: {"data":{"ip":"1.2.3.4","port":8080}} / [{"ip":..,"port":..}] + * 纯文本: 一行一个 "1.2.3.4:8080" 或 "1.2.3.4,8080" +""" + +from __future__ import annotations + +import re +from typing import List, Optional + +import httpx + +_IP_PORT_RE = re.compile(r"(\d{1,3}(?:\.\d{1,3}){3})\s*[:,]\s*(\d{1,5})") + + +def format_proxy(ip: str, port, scheme: str = "http") -> str: + return f"{scheme}://{ip}:{port}" + + +def parse_proxies(payload: str, scheme: str = "http") -> List[str]: + """从 API 返回体里解析出全部代理 (兼容 JSON 与纯文本两种格式)""" + out: List[str] = [] + text = (payload or "").strip() + if not text: + return out + + try: + import json + + data = json.loads(text) + + def walk(node): + if isinstance(node, dict): + ip = node.get("ip") or node.get("host") or node.get("server") + port = node.get("port") + if ip and port: + out.append(format_proxy(str(ip), port, scheme)) + for key in ("data", "result", "proxy", "list", "proxies"): + if key in node: + walk(node[key]) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(data) + if out: + return out + except Exception: + pass + + for ip, port in _IP_PORT_RE.findall(text): + out.append(format_proxy(ip, port, scheme)) + return out + + +def fetch_proxy(api_url: str, scheme: str = "http", timeout: float = 15.0) -> Optional[str]: + """调用代理提取 API 并返回第一个可用代理字符串, 例如 http://1.2.3.4:8080""" + resp = httpx.get(api_url, timeout=timeout, trust_env=False, follow_redirects=True) + proxies = parse_proxies(resp.text, scheme=scheme) + return proxies[0] if proxies else None + + +def check_proxy(proxy: str, url: str = "https://turing.captcha.qcloud.com/", + timeout: float = 20.0) -> bool: + """探测代理是否可用 (能连上目标域名即可, 不判断业务状态码)""" + try: + httpx.get(url, proxy=proxy, timeout=timeout, trust_env=False, + follow_redirects=True, headers={"User-Agent": "Mozilla/5.0"}) + return True + except Exception: + return False + + +if __name__ == "__main__": + import argparse + + ap = argparse.ArgumentParser(description="从代理 API 取一个可用代理") + ap.add_argument("api_url") + ap.add_argument("--scheme", default="http") + args = ap.parse_args() + proxy = fetch_proxy(args.api_url, scheme=args.scheme) + print(proxy or "未取到代理") diff --git a/tcaptcha/selftest.py b/tcaptcha/selftest.py new file mode 100644 index 0000000..b9b4c3c --- /dev/null +++ b/tcaptcha/selftest.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +""" +一键复验入口 (AGENTS.md §1.4 / §5.9) + + .venv/bin/python -m tcaptcha.selftest +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SUITES = [ + "tcaptcha.tests.test_glyph_solver", + "tcaptcha.tests.test_xtea_key", +] + + +def main() -> int: + failed = [] + for mod in SUITES: + print(f"\n{'=' * 62}\n>>> {mod}\n{'=' * 62}") + rc = subprocess.run([sys.executable, "-m", mod], cwd=str(ROOT), + env={**__import__("os").environ, "PYTHONPATH": str(ROOT)}).returncode + if rc != 0: + failed.append(mod) + print(f"\n{'=' * 62}") + if failed: + print(f"❌ selftest 失败: {failed}") + return 1 + print("✅ selftest 全部通过") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tcaptcha/tdc_browser.js b/tcaptcha/tdc_browser.js new file mode 100644 index 0000000..4e95c33 --- /dev/null +++ b/tcaptcha/tdc_browser.js @@ -0,0 +1,243 @@ +/** + * tdc_browser.js —— 在「真实 Chrome」中执行服务端下发的 tdc.js, 产出 collect / eks。 + * + * 为什么需要它 + * ------------ + * `tdc_worker.js` 用 Node VM 伪造 DOM/BOM 环境, tdc.js 采集到的环境指纹不真实, + * 服务端在答题正确后进入风控评估会返回 errorCode=12 (环境异常/自动化)。 + * 本脚本用 CDP 驱动本机真实 Chrome: 在验证码真实来源域下加载 tdc.js, 用 + * Input.dispatchMouseEvent 派发可信鼠标事件, 再读回 TDC.getData()/getInfo()。 + * + * 零依赖: 只用 Node >= 22 内置的全局 WebSocket 与 child_process。 + * + * 用法: + * node tdc_browser.js + * 选项: { aid, referer, userAgent, chromePath, headless, origin, timeoutMs } + * 输出: {"collect":..,"tlg":..,"eks":..,"source":"browser"} 或 {"error":..} + */ + +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const DEFAULT_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +async function waitForFile(file, timeoutMs) { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + if (fs.existsSync(file)) { + const txt = fs.readFileSync(file, 'utf8').trim().split('\n'); + if (txt[0]) return txt[0].trim(); + } + await sleep(120); + } + throw new Error(`等待 DevToolsActivePort 超时: ${file}`); +} + +class Cdp { + constructor(ws) { + this.ws = ws; + this.id = 0; + this.pending = new Map(); + ws.addEventListener('message', ev => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + if (msg.id && this.pending.has(msg.id)) { + const { resolve, reject } = this.pending.get(msg.id); + this.pending.delete(msg.id); + if (msg.error) reject(new Error(JSON.stringify(msg.error))); + else resolve(msg.result); + } + }); + } + + send(method, params = {}, sessionId) { + const id = ++this.id; + const payload = { id, method, params }; + if (sessionId) payload.sessionId = sessionId; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify(payload)); + setTimeout(() => { + if (this.pending.has(id)) { + this.pending.delete(id); + reject(new Error(`CDP 超时: ${method}`)); + } + }, 45000); + }); + } +} + +function connect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => resolve(ws)); + ws.addEventListener('error', e => reject(new Error('WebSocket 连接失败: ' + (e.message || e.type)))); + }); +} + +async function httpJson(url) { + const res = await fetch(url); + return res.json(); +} + +/** + * 生成鼠标轨迹 (相对位移), 与 tdc_worker.js 保持同样的形态: + * 起点在 iframe 底部中间, 依次移动到 3 个目标点, 每段用贝塞尔插值 + 抖动 + 停留。 + */ +function buildTrajectory(points, opts) { + const scale = opts.displayScale != null ? opts.displayScale : (320.0 / 672.0); + const targets = points.map(p => ([ + Math.round((opts.offsetX || 0) + p[0] * scale), + Math.round((opts.offsetY || 0) + p[1] * scale), + ])); + const frameW = opts.frameWidth || 360; + const frameH = opts.frameHeight || 360; + let cur = [Math.round(frameW * 0.42 + (Math.random() - 0.5) * 60), + Math.round(frameH * 0.89 + (Math.random() - 0.5) * 20)]; + const events = [cur.slice()]; + for (const t of targets) { + const steps = 18 + Math.floor(Math.random() * 12); + const dx = t[0] - cur[0], dy = t[1] - cur[1]; + const dist = Math.hypot(dx, dy) || 1; + const cx = (Math.random() - 0.5) * Math.min(50, dist * 0.4); + const cp1 = [cur[0] + dx * 0.3 - dy / dist * cx, cur[1] + dy * 0.3 + dx / dist * cx]; + const cp2 = [cur[0] + dx * 0.7 + dy / dist * cx * 0.5, cur[1] + dy * 0.7 - dx / dist * cx * 0.5]; + for (let i = 1; i <= steps; i++) { + const u = i / steps, e = u * u * (3 - 2 * u), v = 1 - e; + const x = Math.round(v ** 3 * cur[0] + 3 * v ** 2 * e * cp1[0] + 3 * v * e ** 2 * cp2[0] + e ** 3 * t[0]); + const y = Math.round(v ** 3 * cur[1] + 3 * v ** 2 * e * cp1[1] + 3 * v * e ** 2 * cp2[1] + e ** 3 * t[1]); + cur = [x, y]; + events.push(cur.slice()); + } + for (let i = 0; i < 3 + Math.floor(Math.random() * 3); i++) { + events.push([cur[0] + Math.round((Math.random() - 0.5) * 2), + cur[1] + Math.round((Math.random() - 0.5) * 2)]); + } + } + return { events, targets }; +} + +async function generate(rawCode, points, opts) { + const opt = Object.assign({ + aid: '2039519451', + referer: 'https://1ms.run/', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36', + chromePath: process.env.TDC_CHROME || DEFAULT_CHROME, + headless: true, + origin: 'https://turing.captcha.gtimg.com/', + frameWidth: 360, + frameHeight: 360, + timeoutMs: 45000, + }, opts || {}); + + if (!fs.existsSync(opt.chromePath)) throw new Error(`找不到 Chrome: ${opt.chromePath}`); + + const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'tdc-chrome-')); + const args = [ + '--remote-debugging-port=0', + `--user-data-dir=${profile}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-background-networking', + '--disable-blink-features=AutomationControlled', + '--disable-features=Translate,OptimizationHints', + `--user-agent=${opt.userAgent}`, + '--window-size=420,760', + 'about:blank', + ]; + if (opt.headless) args.unshift('--headless=new'); + + const chrome = spawn(opt.chromePath, args, { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + chrome.stderr.on('data', d => { stderr += d.toString(); }); + + let ws = null; + try { + const port = await waitForFile(path.join(profile, 'DevToolsActivePort'), opt.timeoutMs); + const version = await httpJson(`http://127.0.0.1:${port}/json/version`); + ws = await connect(version.webSocketDebuggerUrl); + const cdp = new Cdp(ws); + + const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }); + const list = await httpJson(`http://127.0.0.1:${port}/json/list`); + const page = list.find(t => t.id === targetId); + if (!page) throw new Error('未找到新建的页面 target'); + + const pageWs = await connect(page.webSocketDebuggerUrl); + const pcdp = new Cdp(pageWs); + + await pcdp.send('Page.enable'); + await pcdp.send('Runtime.enable'); + await pcdp.send('Page.navigate', { url: opt.origin }); + await sleep(1200); + + // 注入并执行真实 tdc.js + const inject = await pcdp.send('Runtime.evaluate', { + expression: `(function(){ ${rawCode}\n; return typeof TDC !== 'undefined' && !!TDC.getData; })()`, + awaitPromise: false, + returnByValue: true, + }); + if (!inject.result || inject.result.value !== true) { + throw new Error(`tdc.js 执行后未暴露 TDC.getData (result=${JSON.stringify(inject.result)})`); + } + + // 用 CDP Input 派发可信鼠标事件 + const { events } = buildTrajectory(points, opt); + for (const [x, y] of events) { + await pcdp.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', x, y, button: 'none', buttons: 0, clickCount: 0, + }); + } + // 三次真实点击 + for (const t of points.map(p => [ + Math.round((opt.offsetX || 0) + p[0] * (opt.displayScale != null ? opt.displayScale : 320 / 672)), + Math.round((opt.offsetY || 0) + p[1] * (opt.displayScale != null ? opt.displayScale : 320 / 672)), + ])) { + await pcdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: t[0], y: t[1], button: 'left', buttons: 1, clickCount: 1 }); + await pcdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: t[0], y: t[1], button: 'left', buttons: 0, clickCount: 1 }); + await sleep(120); + } + + const out = await pcdp.send('Runtime.evaluate', { + expression: `JSON.stringify({collect: TDC.getData(), eks: (TDC.getInfo()||{}).info || ''})`, + returnByValue: true, + }); + const payload = JSON.parse(out.result.value); + pageWs.close(); + if (!payload.collect) throw new Error('TDC.getData() 返回空'); + return { collect: payload.collect, tlg: payload.collect.length, eks: payload.eks, source: 'browser' }; + } finally { + try { if (ws) ws.close(); } catch { /* ignore */ } + try { chrome.kill('SIGKILL'); } catch { /* ignore */ } + await sleep(150); + try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* ignore */ } + } +} + +if (require.main === module) { + const tdcPath = process.argv[2]; + const pointsArg = process.argv[3]; + const optionsArg = process.argv[4]; + const points = pointsArg ? JSON.parse(pointsArg) : [[162, 249], [104, 37], [561, 156]]; + const options = optionsArg ? JSON.parse(optionsArg) : {}; + + const run = code => generate(code, points, options).then( + r => { console.log(JSON.stringify(r)); }, + e => { console.log(JSON.stringify({ error: String((e && e.stack) || e) })); process.exitCode = 1; } + ); + + if (tdcPath === '-') { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', c => { input += c; }); + process.stdin.on('end', () => run(input)); + } else { + run(fs.readFileSync(tdcPath, 'utf8')); + } +} + +module.exports = { generate }; diff --git a/tcaptcha/tdc_worker.js b/tcaptcha/tdc_worker.js index 09e8d93..3d6dc94 100644 --- a/tcaptcha/tdc_worker.js +++ b/tcaptcha/tdc_worker.js @@ -152,7 +152,25 @@ function generateBezierTrajectory(p0, p1, steps = 25) { return points; } -function runTdcCode(rawCode, clickPoints) { +function runTdcCode(rawCode, clickPoints, options) { + const opt = Object.assign({ + aid: '2039519451', + referrer: 'https://app.rainyun.com/', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36', + platform: 'MacIntel', + screenWidth: 1920, + screenHeight: 1080, + availHeight: 1055, + devicePixelRatio: 2, + // 验证码 iframe 在页面中的显示尺寸 (672x480 原图会被缩放到该尺寸) + frameWidth: 360, + frameHeight: 360, + // 原图 -> 显示空间的映射 (默认按 320/672 缩放, 与雨云场景一致) + displayScale: null, + offsetX: 20, + offsetY: 60 + }, options || {}); + const listeners = []; function addL(type, fn) { listeners.push({ type, fn }); @@ -163,12 +181,12 @@ function runTdcCode(rawCode, clickPoints) { const baseWindow = { location: { - href: 'https://turing.captcha.qcloud.com/cap_union_new_show?aid=2039519451', + href: `https://turing.captcha.qcloud.com/cap_union_new_show?aid=${opt.aid}`, protocol: 'https:', host: 'turing.captcha.qcloud.com', hostname: 'turing.captcha.qcloud.com', pathname: '/cap_union_new_show', - search: '?aid=2039519451', + search: `?aid=${opt.aid}`, hash: '' }, document: { @@ -180,7 +198,7 @@ function runTdcCode(rawCode, clickPoints) { removeEventListener: () => {}, body: docBody, documentElement: docElement, - referrer: 'https://app.rainyun.com/', + referrer: opt.referrer, cookie: '', getElementsByTagName: (t) => [t === 'body' ? docBody : docElement], getElementById: () => null, @@ -188,8 +206,8 @@ function runTdcCode(rawCode, clickPoints) { head: new Element('HEAD') }, navigator: { - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36', - platform: 'MacIntel', + userAgent: opt.userAgent, + platform: opt.platform, languages: ['zh-CN', 'zh', 'en'], language: 'zh-CN', cookieEnabled: true, @@ -197,10 +215,10 @@ function runTdcCode(rawCode, clickPoints) { mimeTypes: [] }, screen: { - width: 1920, - height: 1080, - availWidth: 1920, - availHeight: 1055, + width: opt.screenWidth, + height: opt.screenHeight, + availWidth: opt.screenWidth, + availHeight: opt.availHeight, colorDepth: 24, pixelDepth: 24 }, @@ -222,11 +240,11 @@ function runTdcCode(rawCode, clickPoints) { setInterval: setInterval, clearInterval: clearInterval, console: { log: () => {}, error: () => {}, warn: () => {} }, - innerWidth: 360, - innerHeight: 360, - outerWidth: 1920, - outerHeight: 1080, - devicePixelRatio: 2, + innerWidth: opt.frameWidth, + innerHeight: opt.frameHeight, + outerWidth: opt.screenWidth, + outerHeight: opt.screenHeight, + devicePixelRatio: opt.devicePixelRatio, addEventListener: addL, removeEventListener: () => {} }; @@ -235,20 +253,66 @@ function runTdcCode(rawCode, clickPoints) { baseWindow.top = baseWindow; baseWindow.parent = baseWindow; + // 注意: vm.runInNewContext 每次调用都会新建一个 context, 桩打不进去。 + // 必须显式 createContext 一次, 之后 prelude 与 tdc.js 都跑在同一个 context 里。 + const vmContext = vm.createContext(baseWindow); + + let instrumentedCode = rawCode; + if (opt.captureKey) { + // 密钥提取: 把 VM 的 XOR / ADD 两个 opcode 换成带日志的版本。 + // 自定义 XTEA 每轮都要算 f(v) ^ (sum + K[j]), 其中 (sum + K[j]) 是 XOR 的操作数, + // 记下它就能反推 K[j] = (operand - round*DELTA) mod 2^32。 + // 用 Array.prototype.push 打桩是抓不到的 —— 这些 opcode 用的是 + // `g[g.length-2]=...` 直接下标赋值。 + vm.runInContext( + `globalThis.__TDC_XOR=[];globalThis.__TDC_ADD=[];` + + `globalThis.__TDC_LOGX=function(a,b){var s=globalThis.__TDC_XOR;` + + `if(s.length<4000000){s.push(a>>>0,b>>>0);}return (a^b);};` + + `globalThis.__TDC_LOGA=function(a,b){var s=globalThis.__TDC_ADD;` + + `if(s.length<4000000){s.push(a>>>0,b>>>0);}return (a+b);};`, + vmContext); + + // 注意: opcode 表里的 **栈变量名每份构建都不同** + // (实测: 样本=g, 雨云实时=j, 1ms实时=M —— 见 AGENTS.md 附录 A#6 码表轮换), + // 所以必须用反向引用自动识别变量名, 不能写死 'g'。 + // 目标 opcode 形态: [.length-2]=[.length-2] .pop() + const mkRe = op => new RegExp( + '(\\w+)\\[(\\1\\.length-2)\\]=\\1\\[\\2\\]\\' + op + '\\1\\.pop\\(\\)', 'g'); + const xorRe = mkRe('^'); + const addRe = mkRe('+'); + const nXor = (instrumentedCode.match(xorRe) || []).length; + const nAdd = (instrumentedCode.match(addRe) || []).length; + if (nXor !== 1 || nAdd !== 1) { + // 不静默失败 (AGENTS.md 附录 A#8): 结构性变化必须显式暴露 + return { + error: `opcode 插桩失败: 期望 XOR/ADD 各 1 处, 实际 XOR=${nXor} ADD=${nAdd}. ` + + `tdc.js 的 VM opcode 形态可能已变更, 需要更新插桩规则。` + }; + } + instrumentedCode = instrumentedCode + .replace(addRe, (m, v, idx) => `${v}[${idx}]=__TDC_LOGA(${v}[${idx}],${v}.pop())`) + .replace(xorRe, (m, v, idx) => `${v}[${idx}]=__TDC_LOGX(${v}[${idx}],${v}.pop())`); + if (opt.captureKeyVerbose) { + process.stderr.write(`[captureKey] 插桩 XOR opcode x${nXor}, ADD opcode x${nAdd}\n`); + } + } + // Execute TDC.js - vm.runInNewContext(rawCode, baseWindow); + vm.runInContext(instrumentedCode, vmContext); // Map 672x480 coordinates to 360x360 iframe display space - const scale = 320.0 / 672.0; + // 原图坐标 (672x480) -> 验证码 iframe 显示空间 + const scale = opt.displayScale !== null && opt.displayScale !== undefined + ? opt.displayScale : (320.0 / 672.0); const targetPoints = clickPoints.map(p => ({ - x: Math.round(20 + p[0] * scale), - y: Math.round(60 + p[1] * scale) + x: Math.round(opt.offsetX + p[0] * scale), + y: Math.round(opt.offsetY + p[1] * scale) })); // Generate path: entrance -> P1 -> P2 -> P3 let currentPoint = { - x: Math.round(150 + (Math.random() - 0.5) * 60), - y: Math.round(320 + (Math.random() - 0.5) * 20) + x: Math.round(opt.frameWidth * 0.42 + (Math.random() - 0.5) * 60), + y: Math.round(opt.frameHeight * 0.89 + (Math.random() - 0.5) * 20) }; const mmListener = listeners.find(l => l.type === 'mousemove'); @@ -297,34 +361,91 @@ function runTdcCode(rawCode, clickPoints) { const info = baseWindow.TDC.getInfo(); const eks = info ? info.info : ''; - return { + const result = { collect, tlg: collect ? collect.length : 0, eks }; + + // 从记录的 ADD 操作数里反推 XTEA 密钥 (含索引) + // + // 字节码里 (sum + K[j]) 是一次 ADD, 两个操作数分别是 + // sum = (r*DELTA) mod 2^32 与 K[j] 本身。 + // 反过来查表: 找到 r 后, j 只可能是 r&3 (v0 分支) 或 ((r+1)*DELTA>>11)&3 (v1 分支)。 + // 对同一个 K 的多次观测取交集, 就能唯一确定它的索引 —— 不需要 4! 排列搜索。 + if (opt.captureKey) { + const DELTA = 2654435769; + const sumToR = new Map(); + for (let r = 0; r <= 32; r++) sumToR.set(Math.imul(r, DELTA) >>> 0, r); + const idxOf = r => [r & 3, (((r + 1) * DELTA) >>> 0 >>> 11) & 3]; + + const addLog = baseWindow.__TDC_ADD || []; + const obs = new Map(); // K -> Map(r -> count) + for (let i = 0; i + 1 < addLog.length; i += 2) { + const a = addLog[i], b = addLog[i + 1]; + let r = sumToR.get(a), k = b; + if (r === undefined) { r = sumToR.get(b); k = a; } + if (r === undefined || k === 0 || (k >>> 0) === DELTA) continue; + k = k >>> 0; + if (!obs.has(k)) obs.set(k, new Map()); + const m = obs.get(k); + m.set(r, (m.get(r) || 0) + 1); + } + + const entries = []; + for (const [k, rmap] of obs) { + let total = 0, candidates = null; + for (const [r, c] of rmap) { + total += c; + const s = new Set(idxOf(r)); + candidates = candidates === null ? s : new Set([...candidates].filter(x => s.has(x))); + } + if (candidates && candidates.size === 1) { + entries.push([k, total, [...candidates][0], rmap.size]); + } else { + entries.push([k, total, candidates && candidates.size ? [...candidates] : null, rmap.size]); + } + } + entries.sort((x, y) => y[1] - x[1]); + result.key_words = entries.slice(0, 24); + result.add_operand_count = addLog.length; + } + return result; } -function runTdc(tdcFilePath, clickPoints) { +function runTdc(tdcFilePath, clickPoints, options) { const rawCode = fs.readFileSync(tdcFilePath, 'utf8'); - return runTdcCode(rawCode, clickPoints); + return runTdcCode(rawCode, clickPoints, options); } if (require.main === module) { const tdcPath = process.argv[2]; const pointsArg = process.argv[3]; + const optionsArg = process.argv[4]; const points = pointsArg ? JSON.parse(pointsArg) : [[626, 253], [120, 120], [438, 33]]; + const options = optionsArg ? JSON.parse(optionsArg) : {}; if (tdcPath === '-') { let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { input += chunk; }); process.stdin.on('end', () => { - const result = runTdcCode(input, points); - console.log(JSON.stringify(result)); + try { + const result = runTdcCode(input, points, options); + console.log(JSON.stringify(result)); + } catch (err) { + console.log(JSON.stringify({ error: String(err && err.stack || err) })); + process.exitCode = 1; + } }); } else { - const result = runTdc(tdcPath, points); - console.log(JSON.stringify(result)); + try { + const result = runTdc(tdcPath, points, options); + console.log(JSON.stringify(result)); + } catch (err) { + console.log(JSON.stringify({ error: String(err && err.stack || err) })); + process.exitCode = 1; + } } } diff --git a/tcaptcha/tests/__init__.py b/tcaptcha/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tcaptcha/tests/fixtures/har_round1.jpg b/tcaptcha/tests/fixtures/har_round1.jpg new file mode 100644 index 0000000..691c381 Binary files /dev/null and b/tcaptcha/tests/fixtures/har_round1.jpg differ diff --git a/tcaptcha/tests/fixtures/har_round2.jpg b/tcaptcha/tests/fixtures/har_round2.jpg new file mode 100644 index 0000000..a6d5f20 Binary files /dev/null and b/tcaptcha/tests/fixtures/har_round2.jpg differ diff --git a/tcaptcha/tests/fixtures/har_round3.jpg b/tcaptcha/tests/fixtures/har_round3.jpg new file mode 100644 index 0000000..1c1101f Binary files /dev/null and b/tcaptcha/tests/fixtures/har_round3.jpg differ diff --git a/tcaptcha/tests/fixtures/har_round4.jpg b/tcaptcha/tests/fixtures/har_round4.jpg new file mode 100644 index 0000000..073a7ad Binary files /dev/null and b/tcaptcha/tests/fixtures/har_round4.jpg differ diff --git a/tcaptcha/tests/fixtures/hard_adjacent_chars.jpg b/tcaptcha/tests/fixtures/hard_adjacent_chars.jpg new file mode 100644 index 0000000..b38e4c6 Binary files /dev/null and b/tcaptcha/tests/fixtures/hard_adjacent_chars.jpg differ diff --git a/tcaptcha/tests/fixtures/hard_grass_background.jpg b/tcaptcha/tests/fixtures/hard_grass_background.jpg new file mode 100644 index 0000000..137ddc7 Binary files /dev/null and b/tcaptcha/tests/fixtures/hard_grass_background.jpg differ diff --git a/tcaptcha/tests/fixtures/live_hard_rare_char.jpg b/tcaptcha/tests/fixtures/live_hard_rare_char.jpg new file mode 100644 index 0000000..d5dfcde Binary files /dev/null and b/tcaptcha/tests/fixtures/live_hard_rare_char.jpg differ diff --git a/tcaptcha/tests/fixtures/live_probe.jpg b/tcaptcha/tests/fixtures/live_probe.jpg new file mode 100644 index 0000000..6ab3b66 Binary files /dev/null and b/tcaptcha/tests/fixtures/live_probe.jpg differ diff --git a/tcaptcha/tests/fixtures/manifest.json b/tcaptcha/tests/fixtures/manifest.json new file mode 100644 index 0000000..e8d6b58 --- /dev/null +++ b/tcaptcha/tests/fixtures/manifest.json @@ -0,0 +1,154 @@ +[ + { + "image": "har_round1.jpg", + "instruction": "请依次点击:虫 呈 般", + "expected_points": [ + [ + 149, + 252 + ], + [ + 48, + 55 + ], + [ + 549, + 351 + ] + ], + "note": "点选.har 第 1 道题; 用户实际第 3 击落点 (561,156) 偏上, 服务端返回 errorCode 50" + }, + { + "image": "har_round2.jpg", + "instruction": "请依次点击:佰 衬 稗", + "expected_points": [ + [ + 449, + 53 + ], + [ + 549, + 353 + ], + [ + 349, + 152 + ] + ], + "note": "点选.har 第 2 道题; 用户把第 2/3 击顺序点反 (50)" + }, + { + "image": "har_round3.jpg", + "instruction": "请依次点击:簿 蚕 沧", + "expected_points": [ + [ + 549, + 153 + ], + [ + 50, + 255 + ], + [ + 48, + 154 + ] + ], + "note": "点选.har 第 3 道题; 用户第 2/3 击落点错误 (9)" + }, + { + "image": "har_round4.jpg", + "instruction": "请依次点击:沉 驰 齿", + "expected_points": [ + [ + 150, + 154 + ], + [ + 550, + 153 + ], + [ + 49, + 53 + ] + ], + "note": "点选.har 第 4 道题; 抓包结束时用户尚未提交" + }, + { + "image": "live_probe.jpg", + "instruction": "请依次点击:笆 辩 裁", + "expected_points": [ + [ + 549, + 153 + ], + [ + 50, + 53 + ], + [ + 351, + 151 + ] + ], + "note": "实时抓取; ddddocr 把 笆 误读成 芭, 集成后仍正确指派" + }, + { + "image": "live_hard_rare_char.jpg", + "instruction": "请依次点击:筹 炽 雹", + "expected_points": [ + [ + 449, + 253 + ], + [ + 49, + 353 + ], + [ + 250, + 255 + ] + ], + "note": "含生僻字 筹 且整题斜切; 单 ddddocr 对 筹 的最大概率仅 0.0046, PP-OCRv4 可正确识别" + }, + { + "image": "hard_adjacent_chars.jpg", + "instruction": "请依次点击:长 澄 班", + "expected_points": [ + [ + 349, + 154 + ], + [ + 449, + 353 + ], + [ + 549, + 353 + ] + ], + "note": "失败样本修正: 旧阈值 V>=145 把暖色草地并入字形掩膜, 且 澄/班 横排相邻被膨胀粘连, 只剩 1~2 个候选; 收紧到 V>=190 后正常" + }, + { + "image": "hard_grass_background.jpg", + "instruction": "请依次点击:菜 倍 钵", + "expected_points": [ + [ + 250, + 53 + ], + [ + 49, + 252 + ], + [ + 52, + 354 + ] + ], + "note": "失败样本修正: 倍/钵 竖排相邻被膨胀粘成一个 332x80 长条, 被长宽比过滤器丢弃; 收紧阈值后正常" + } +] diff --git a/tcaptcha/tests/test_glyph_solver.py b/tcaptcha/tests/test_glyph_solver.py new file mode 100644 index 0000000..a612360 --- /dev/null +++ b/tcaptcha/tests/test_glyph_solver.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +""" +文字点选求解器回归测试 (真实线上样本) + +fixtures 里的 6 张图全部来自真实线上环境: + * har_round1..4 —— 用户 `点选.har` 抓包中的 4 道题 (672x480 背景图) + * live_probe —— 本次逆向期间实时抓取的一道题 + * live_hard_rare_char —— 含生僻字 "筹" 且整题带斜切变形, 用于验证 ddddocr+PP-OCRv4 集成 + +运行: + uv run python -m tcaptcha.tests.test_glyph_solver +""" + +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +MANIFEST = FIXTURES / "manifest.json" + +ROOT = Path(__file__).resolve().parent.parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tcaptcha.glyph_solver import TextClickSolver, parse_instruction # noqa: E402 + +TOLERANCE = 22 # 像素容差 (汉字包围盒中心 vs 人工标注中心) + + +def run(verbose: bool = False) -> int: + logging.basicConfig(level=logging.WARNING) + cases = json.loads(MANIFEST.read_text(encoding="utf-8")) + solver = TextClickSolver() + passed = 0 + failed = [] + + for case in cases: + img = (FIXTURES / case["image"]).read_bytes() + result = solver.solve_ex(img, case["instruction"]) + ok_points = result.ok and all( + abs(p[0] - e[0]) <= TOLERANCE and abs(p[1] - e[1]) <= TOLERANCE + for p, e in zip(result.points, case["expected_points"]) + ) + if verbose: + print(f" {case['image']:24s} {case['instruction']}") + print(f" pred={result.points}") + print(f" exp ={case['expected_points']} post={result.posterior:.4f} " + f"{'OK' if ok_points else 'MISMATCH'}") + if ok_points: + passed += 1 + else: + failed.append((case["image"], result.points, case["expected_points"])) + + total = len(cases) + print(f"\n文字点选求解器回归: {passed}/{total} 通过") + for image, pred, exp in failed: + print(f" ✗ {image}: pred={pred} exp={exp}") + return 0 if passed == total else 1 + + +def test_parse_instruction(): + """提示语解析的边界用例 (图标点选/滑块的模板串不能解析出目标字)""" + assert parse_instruction("请依次点击:虫 呈 般") == ["虫", "呈", "般"] + assert parse_instruction("请依次点击:虫呈般") == ["虫", "呈", "般"] + assert parse_instruction("请依次点击:") == [] # 图标点选 + assert parse_instruction("拖动下方滑块完成拼图") == [] # 滑块 + + +if __name__ == "__main__": + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument("-v", "--verbose", action="store_true") + args = ap.parse_args() + test_parse_instruction() + print("提示语解析边界用例: OK") + sys.exit(run(verbose=args.verbose)) diff --git a/tcaptcha/tests/test_xtea_key.py b/tcaptcha/tests/test_xtea_key.py new file mode 100644 index 0000000..818c9e0 --- /dev/null +++ b/tcaptcha/tests/test_xtea_key.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +""" +XTEA 算法还原 + tdc.js 密钥提取 回归测试 (AGENTS.md §5.9) + +固化的性质: + 1. 标准 XTEA 加解密往返 + 2. 与真实 tdc.js **逐字节一致** (算法还原正确的判据, §3.0) + 3. 插桩能从 tdc.js 复原密钥, 且密钥按构建固定 (§5.5 受控环境逐字节一致) + 4. 模板标定 + 纯 Python 回填 的产物可被同一把密钥解回预期结构 + +运行: + .venv/bin/python -m tcaptcha.tests.test_xtea_key +""" +from __future__ import annotations + +import base64 +import json +import struct +import subprocess +import sys +import urllib.parse +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from tcaptcha.crypto import ( # noqa: E402 + DEFAULT_KEY, + generate_collect_from_template, + recover_payload_template, + xtea_decrypt_block, + xtea_decrypt_bytes, + xtea_encrypt_bytes, + extract_xtea_key, +) + +ROOT = Path(__file__).resolve().parents[2] +SAMPLE = ROOT / "tcaptcha" / "tdc_sample.js" +SAMPLE_KEY = [1448563793, 1850559083, 1733513813, 1364613714] # 等价于 DEFAULT_KEY + 两个加盐 +CD_PRE = b'{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"", ' # 旧版明文首段 (已补齐 48B) + +_fails: list[str] = [] + + +def check(name: str, cond: bool, detail: str = "") -> None: + print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" {detail}" if detail else "")) + if not cond: + _fails.append(name) + + +def run_tdc(script: str, points, options) -> dict: + proc = subprocess.run( + ["node", str(ROOT / "tcaptcha" / "tdc_worker.js"), "-", json.dumps(points), + json.dumps(options)], + input=script, capture_output=True, text=True, timeout=180) + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_xtea_roundtrip() -> None: + print("1) 标准 XTEA 往返") + key = list(SAMPLE_KEY) + for chunk in (b"hello!!", bytes(range(32)), b"x" * 8): + enc = xtea_encrypt_bytes(chunk, key) + dec = xtea_decrypt_bytes(enc, key) + check(f"往返 {len(chunk)}B", dec.startswith(chunk), f"{chunk[:8]!r}") + # 已知明文 -> 与 tdc.js 密文逐字节一致 + enc = xtea_encrypt_bytes(CD_PRE, key) + check("加密结果可被同密钥解回", xtea_decrypt_bytes(enc, key).rstrip(b"\x00") == CD_PRE.rstrip(b"\x00")) + + +def test_byte_exact_vs_tdcjs() -> None: + print("2) 纯 Python 密文 == 真实 tdc.js 密文 (逐字节)") + script = SAMPLE.read_text(encoding="utf-8", errors="replace") + job = run_tdc(script, [[100, 100], [200, 200], [300, 300]], {"aid": "195672344"}) + real = base64.b64decode(urllib.parse.unquote(job["collect"])) + key = extract_xtea_key(script) + check("提取到的密钥 == 已知 K'", key == SAMPLE_KEY, str(key)) + # 旧版首段是常量 cd_pre, 可逐字节对拍 + check("前 48 字节逐字节一致", + xtea_encrypt_bytes(CD_PRE, key) == real[:48], + xtea_encrypt_bytes(CD_PRE, key)[:8].hex()) + + +def test_key_fixed_per_build() -> None: + print("3) 密钥按构建固定 (同一脚本多次运行首块一致)") + script = SAMPLE.read_text(encoding="utf-8", errors="replace") + heads = set() + for _ in range(2): + job = run_tdc(script, [[10, 10], [20, 20], [30, 30]], {"aid": "195672344"}) + heads.add(base64.b64decode(urllib.parse.unquote(job["collect"]))[:8]) + check("两次运行首块相同", len(heads) == 1, heads.pop().hex()) + + +def test_template_refill() -> None: + print("4) 模板标定 + 纯 Python 回填") + script = SAMPLE.read_text(encoding="utf-8", errors="replace") + template, key = recover_payload_template(script) + check("模板以 {\"cd\":[ 开头", template.startswith('{"cd":['), template[:24]) + try: + json.loads(template) + check("模板是合法 JSON", True) + except Exception as exc: # noqa: BLE001 + check("模板是合法 JSON", False, str(exc)) + collect = generate_collect_from_template(template, [(449, 254), (48, 53), (148, 252)], key) + plain = xtea_decrypt_bytes(base64.b64decode(urllib.parse.unquote(collect)), key) \ + .decode("latin1").rstrip(" ") + check("回填产物可解回 {\"cd\":[ 开头", plain.startswith('{"cd":['), plain[:24]) + check("回填产物以 }} 结尾", plain.rstrip().endswith("}}")) + check("轨迹已换成 3 次点击的规模", len(plain) != len(template), + f"{len(template)} -> {len(plain)}") + + +def main() -> int: + if not SAMPLE.exists(): + print(f"缺少样本 {SAMPLE}") + return 1 + for fn in (test_xtea_roundtrip, test_byte_exact_vs_tdcjs, + test_key_fixed_per_build, test_template_refill): + fn() + print() + if _fails: + print(f"❌ XTEA/密钥 回归失败 {len(_fails)} 项: {_fails}") + return 1 + print("✅ XTEA/密钥 回归全部通过") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())