本项目仅供学习与技术交流使用。
- 严禁用于任何商业用途、批量刷分、代练、破坏平台正常运营,或任何违反服务条款与法律法规的行为。
- 使用者须自行承担全部风险与法律责任;作者不对任何直接或间接后果负责。
- 本项目与「小猿口算」及其运营方无任何关联,未获其授权或认可。
- 仓库内涉及的商标、App 名称、第三方二进制,版权均归各自权利人所有。
- 如有侵权,请通过 Issues 或邮箱告知,将在收到通知后立即删除相关内容。
继续阅读或使用,即表示你已理解并同意上述条款。完整版见文末 「免责声明(完整版)」 与 DISCLAIMER.md。
一个零外部依赖的本地 Node 服务:起个网页,导入小猿登录态 → 选子账号 → 刷 PK 对局, 带 SQLite 本地库、实时日志(SSE)、管理后台,并可选 Cloudflare 临时内网穿透。
MIT License —— bin/native/ 下的第三方二进制不在授权范围内,见 NOTICE
📄 项目介绍页:https://sxd91.github.io/pk-node/
| 方式 | 说明 |
|---|---|
| 免安装包(Releases) | 解压即用,推荐。含密钥流 + 全部源码,不用 npm install |
git clone 源码 |
在项目根运行下面的命令 |
只要求 Node.js ≥ 22(用到内置 node:sqlite),除此之外零依赖。
./start.sh # 默认 http://127.0.0.1:8792(被占则自动往后找一个空端口)
PK_PORT=8790 ./start.sh # 换端口Windows:双击 start.bat —— 可完整刷局 + 刷练习(内容编码是纯 JS;sign 也已改成纯 JS 模拟 arm64 机器码,不再需要 WSL / arm64)
★ 2026-10-01:
start.sh与start.bat现在都只是薄壳,真正的 「Node 版本校验 / 挑空闲端口 / 打印横幅」都在bin/start.js里(一份代码两个平台)。 之前start.bat在 Windows 上跑不起来,真因是它是 LF 行尾(cmd.exe 要求 CRLF,if errorlevel … (这种多行结构会被当成一行解析直接报错),且端口探测写成了for /f … node -p …,在 UTF-8 中文的 bat 里容易被代码页搅乱。现在 bat 只有 十几行、纯 ASCII + CRLF,不再需要 Git/MSYS2。
浏览器打开 → 用 admin / admin 登录(第一次登录后请立刻改密)。
完整说明见 第五节 使用流程;这里是最短路径。
git clone https://github.com/sxd91/pk-node.git && cd pk-node
./start.sh # Windows:双击 start.bat
# 默认 http://127.0.0.1:8792(端口被占会自动避让)或直接下 免安装包 解压运行(推荐,免 npm install)。
需要 Node.js ≥ 22(用到内置 node:sqlite),除此之外零依赖。
浏览器打开 http://127.0.0.1:8787 → admin / admin → 立刻改密(「管理」页可改)。
「小猿账号」页任选一种(效果一致):
| 方式 | 操作 |
|---|---|
| 短信验证码(推荐) | 填手机号 → 发送验证码 → 填验证码 → 登录 |
| 密码登录 | 手机号 + 密码(密码 RSA 加密后提交,本地不留明文) |
| 粘贴 Cookie | 从已登录环境导出,必须含 sess、域 .yuanfudao.com |
| 玩法 | 入口 | 说明 |
|---|---|---|
| 刷练习 ⭐ | 「刷练习」tab → 选账号 / 知识点 / limit / 轮数 → 开始 |
出题→抄答案→提交全自动闭环。经验 = 答对题数 × 2(100 题 = 200 经验)。轮数无上限;任务是正经后台任务(落库、可在「任务」页看进度、可停止、切走/关掉浏览器都继续跑) |
| 刷 PK 对局 | 「刷局」tab → 选子账号 / 知识点 / 局数 → 开始 | 右侧 SSE 实时日志;「任务」页可查逐轮明细 |
- 「刷练习」/「刷局」页的实时日志逐轮显示:出题 → 提交 → 服务端判对 X/Y → 经验 +N。
- 分数以服务端
curWeekScore为准(「刷新分数/任务」按钮可随时读)。
node bin/selftest.js # 自检(native / sign / RSA / 笔画 / 编码器)
node tools/check-inject.js # H5 注入脚本(语法 + 桥协议)
node tools/test-pk-h5-bot.js # PK H5 三个自动能力的回归测试
node bin/reset-admin.js 新密码 # 重置管理员密码
sh bin/get-cloudflared.sh # 下载穿透客户端(可选)| 模块 | 说明 |
|---|---|
| 本服务账号 | 注册 / 登录 / 会话(scrypt 哈希 + HttpOnly cookie)。默认管理员 admin/admin。 |
| 小猿账号 | 三条入口:短信验证码登录 / 密码登录 / 粘贴 cookie。导入即自动探活 + 拉子账号;可切号。 |
| 刷局 | 出题 → 组 body → 加密 → 提交,逐轮落库;支持局数 / 知识点 / 间隔 / 频控退避。 |
| 实时日志 | SSE 推送每一轮进度到网页;断线可刷新页面看任务明细。 |
| 本地 DB | Node 内置 node:sqlite(不需要 npm install)。 |
| 管理后台 | 用户管理(新增/重置密/禁用即踢下线/删除)、全部任务(看配置 / 暂停 / 继续 / 停止 / 明细 / 批量操作)、审计日志、系统自检。 |
| 穿透 | cloudflared 快速隧道 → *.trycloudflare.com(免账号,进程停即失效)。 |
- 批量开任务:「刷局」和「刷练习」页各有一个「批量开任务」折叠区 —— 勾选多个小猿账号(支持全选)→ 一键全部按当前面板同一套配置开任务, 每个账号一个独立任务,可单独暂停 / 继续 / 停止。
- 暂停 / 继续:任务可以「暂停」(立即中断、保留已刷轮数),之后从
已刷 + 1轮原地续跑(同一个任务 id,日志流不断)。「任务」页和管理页都有按钮。 - 管理页看所有人的任务:每条任务显示归属用户、小猿账号名、是刷局还是刷练习、 以及完整参数快照(知识点 / 画笔 / 间隔 / 频控 / 每轮题数…); 支持暂停 / 继续 / 停止 / 明细(含实时日志)/ 批量勾选操作。
- 禁用 = 立刻生效:点「禁用」的一瞬间,该用户所有进行中任务被暂停、 所有登录会话被清掉(现有页面当场 401 踢回登录页),也无法再登录或开新任务。 重新启用后任务保持暂停,由用户自己决定是否「继续」。
- 删除账号:管理页用户卡片新增「删除」(二次确认)。会先停掉其任务、清掉会话, 再连带删除名下全部小猿账号 / 历史任务 / 逐轮明细(外键级联)。保护规则: 不能删自己、不能删最后一个管理员。
小猿的两个关键环节原本都是 arm64 原生实现。两者都已解决,
现在不需要 bin/native/ 里的任何 arm64 库,Windows 也能完整刷局:
-
内容编码器 —— ✅ 已用纯 JS 复现,不再需要原生库。 差分分析证明
libContentEncoder.so的内层函数c()就是 「与一条固定密钥流逐位置 XOR」:实验 结果 翻转输入 1 bit 输出只变同位置 1 个字节(无扩散) 两段不同的同长输入 in XOR out得到的流完全相同短输入 vs 长输入前缀 密钥流逐字节一致(与总长无关) 同输入重复编码 完全一致(确定性) ⇒
out[i] = in[i] ^ K[i],于是编码全零输入,输出就是密钥流。 密钥流已提取为bin/keystream.bin(128 KiB),纯 JS XOR 在 1B / 2B / 17B / 256B / 4524B / 20000B / 131071B / 131072B 全部逐字节等于原生结果, 并且与真机抓包密文一致。收益:x86/Windows 也能编码,且省掉每轮 80–250ms 的子进程开销 (实测降到 ~14ms)。 -
sign—— ✅ 已改为纯 JS 复刻(2026-10-01,PR #1)。sign的T段原先只能靠bin/native/linker64 + dump7执行lre.so里那段 arm64 机器码取得,Windows / x86 / 非 arm64 上算不出来 ——calcSign一失败,maybeSign()就静默不带sign,练习端点必然 417x-block-by: solar-encoder(这就是「PK 能用、练习不能用」的真正根因)。现由
src/lre-emu.js用 JS 逐条解释执行同一段机器码算出 T (指令表src/lre-insns.js,由tools/gen-lre-insns.py用 capstone 离线生成), 平台无关、零外部依赖。判据:真机抓包 fixture 在该分钟的输出 逐字节一致(410/410), 自检里跑node tools/sign-selftest.js即可验证。说明:
T是 base-100 大数的十进制展开、随分钟变化且无闭式,所以不能「推导」; 但可以「执行」—— 模拟器做的就是执行原机器码本身,因此结果与真机等同。PK_SIGN_MODE默认auto:练习端点一律带 sign;PK 端点按需。
不需要 linker64 / dump7 / libc* / libContentEncoder* 这些可执行资产了(内容编码
纯 JS、sign 纯 JS 模拟)。唯一保留的是 bin/native/lre.so(0.9MB) —— 它不再被
执行,而是作为数据:src/lre-emu.js 从里面读机器码字节与常量表。发布包里已含它。
在 proot 里用 Android 自己的 linker:
LD_LIBRARY_PATH=bin/native bin/native/linker64 bin/native/enc_device <so> <in> <out>bin/native/ 里必须有:linker64、libc.so、libm.so、libdl.so、liblog.so、
libc++.so、libc++_shared.so、libContentEncoder_patched.so、lre.so、dump7、enc_device。
内容编码不再需要这些 —— 它走
bin/keystream.bin(纯 JS XOR)。 上面这些只为sign保留。密钥流换版本时用node tools/keystream-extract.js重新提取。
⚠️ libContentEncoder_patched.so是把DT_NEEDED: libandroid.so等长覆盖成libc.so的版本 —— 因为 libandroid 会拖出一长串 proot 里拉不起来的系统依赖。
自检(不开服务也能跑):
node bin/selftest.js
# 或 npm run selftestpk-node/
├── server.js # HTTP 服务 + 路由(零依赖)
├── start.sh # 启动脚本(薄壳:找到 node → 交给 bin/start.js)
├── start.bat # 同上(Windows;纯 ASCII + CRLF,cmd 直接可跑)
├── package.json
├── src/
│ ├── config.js # 配置与 PK 协议常量(公共参数、频控参数)
│ ├── db.js # SQLite 层(建表 + 全部查询;含默认管理员种子)
│ ├── http.js # 极简 HTTP 客户端 + CookieJar(正确处理前导点域)
│ ├── crypto-rsa.js # RSA 编码(手机号/验证码/密码)+ 格式校验
│ ├── sign.js # sign 公式(纯 JS + 离线自校验样本)
│ ├── native.js # 调 linker64 跑 so:sign / 内容编码 / gzip 对齐
│ ├── strokes.js # 画笔算法:ARC 弧线 / SEVEN_SEGMENT 七段码
│ ├── leo.js # 小猿协议层:URL 组装(公共参数 + sign)、PK / 账号 / 子账号接口
│ ├── pk-engine.js # 出题 → 组装 body → 加密 → 提交 → 频控退避
│ ├── jobs.js # 任务调度(串行 + 停止 + SSE 事件缓冲)
│ ├── tunnel.js # cloudflared 快速隧道封装
│ └── services/
│ ├── auth.js # 本服务账号
│ ├── login.js # 小猿登录(短信 / 密码)
│ └── leo-accounts.js # 小猿账号导入 / 刷新 / 切号
├── public/ # 网页(原生 HTML+JS+CSS,无构建)
│ ├── index.html
│ ├── style.css
│ └── app.js
├── bin/
│ ├── native/ # 原生资产(见上)
│ ├── start.js # ★ 真正的启动器:Node 版本校验 + 挑空闲端口 + 横幅
│ ├── selftest.js # 命令行自检
│ ├── pick-port.js # 挑空闲端口(独立小工具,start.js 已内置同样的逻辑)
│ ├── reset-admin.js # 忘记密码时重置管理员
│ └── get-cloudflared.sh # 下载穿透客户端
└── data/pk-node.sqlite # 运行时生成
PK 端点要 _productId=631&_appId=6,其它主域端点要 _productId=611。
调用方显式给的值必须原样保留,缺的才补 —— 整体覆盖会把 631 冲成 611,PK 直接
401(SolarAuthFilter)。
s1 = path + salt d1 = md5(s1)
s2 = s1 + d1 + path d2 = md5(s2)
s3 = s2 + d2 + T d3 = md5(s3)
sign = md5(s3 + d3 + salt) salt = "wdi4n2t8edr"
顶层展开 examVO 字段,没有 examVO 嵌套、没有 userInfos、没有 updatedTime
(加上去会 400):
{"pkIdStr":"…","pointId":1951,"pointName":"5以内比大小","ruleType":-7,
"questionCnt":20,"correctCnt":20,"costTime":8000,
"questions":[{"id":…,"examId":…,"content":…,"answer":…,"userAnswer":…,"answers":[…],
"status":1,"script":"…","wrongScript":null,"ruleType":"COMPARE","errorState":0,
"curTrueAnswer":{"recognizeResult":"…","pathPoints":[…],"answer":1,"showReductionFraction":0}}]}script=JSON.stringify(pathPoints),两处同源。- 笔迹是画布像素坐标(x≈150–240,y≈450–500),比较题用密集弧线模板(
<左弧 />右弧)。
因此本项目串行跑任务,且默认 60s / 120s 大退避、最多 2 次(可在网页高级参数里改)。
并发提交只会把请求一起打进频控窗口。
这是本项目最反直觉、也最容易踩的一点:
① math/pk/match?pointId=16 → 200(冷却起点)
② 立刻换 pointId=17(另一个知识点) → 400「请求过于频繁」
③ 立刻再打 pointId=16 → 400「请求过于频繁」
以下都试过且全部无效(说明不是请求指纹问题):
| 尝试 | 结果 |
|---|---|
换知识点 pointId |
400 |
换 User-Agent(Leo/… ↔ WebView 真实 UA) |
400 |
换 platform(android36 ↔ browser) |
400 |
加 / 不加 sign |
400 |
删风控头(X-XYKS-* / x-shepherd-sessionid) |
400 |
换其它出题接口(multi/match / english/pk/match) |
400 |
⇒ 冷却是账号级、跨知识点、跨出题接口共享的。
原版为什么快? 原版走 math/pk/match/v2(返回 arraybuffer 加密体)。
/v2 需要 App 内 WebView 原生桥(LeoSecure.requestConfig)参与,纯 Node 请求
在 9 种头/参数组合下一律 417。
本项目的处理:贴着冷却下沿的闭环(这是「最快」的实现方式)
不要去猜窗口大小,也不要靠调大轮间隔 —— 正确做法是闭环:
下一轮等待 = max(配置的轮间隔下限, 上次成功出题时刻 + 冷却 − 现在)
- 引擎记住该账号上次成功出题的时刻(按
leoAccountId存在进程内,跨任务共享); - 到点前不浪费请求,到点后立刻发车 —— 每一轮都恰好卡在窗口开启的瞬间;
- 万一估计偏了(撞到 400),按
出题频控重试间隔兜底重试, 累计超出题最长等待才判该轮失败,所以不会整轮白跑。
实测(真实 HTTP API 跑 3 轮,pointId=16):
第1轮 OK 提交成功并已结算(对 30 题)
第2轮 OK 距上轮 67.2s
第3轮 OK 距上轮 67.3s
平均每轮 67.2s ← 冷却 61.6s + 提交/结算 ~5.6s,等于理论下限
3/3 全成功,0 次白撞
如实说明:「每账号 ≈60s 一局」就是硬上限。原版之所以能秒开下一局, 是因为它走
match/v2(需要 App 的 LeoSecure 原生桥,纯 HTTP 一律 417)。 想再快只有加账号 —— 冷却按账号隔离,多号并行近似线性提速。
中途有一次复测(同账号连续出题、间隔 1.5s,4 次全 200)曾被当成 「服务端已放开」;后续实测推翻了这个结论 —— ≈60 秒的账号级出题冷却依然在。
现在的产品口径(这也是代码里的实现):
谁 行为 引擎 PK.matchCooldownMs默认 0 —— 不强制替你等冷却,节奏完全由用户填的「每轮最小/最大间隔」决定网页 这两个框默认填 60000 / 65000,并明确提示「服务端有约 60 秒冷却,建议设 60 秒」;旁边还有「填入推荐值」按钮(因为输入框的值会持久化到 localStorage,改 HTML 默认值对老用户不生效) 兜底 撞到 400/403 仍按「出题频控重试间隔」(默认 3s)重试,累计超「出题最长等待」(默认 240s = 4 分钟)才判该轮失败 —— 所以间隔设小了也不会立刻白跑,只是等待会挪进重试里、日志多几行 用户要是偏要设 1000ms:不拦,能跑,只是日志会出现一串 「出题被频控…自动重试」,整体耗时反而比设 60s 更长。
想让引擎自动贴着冷却下沿跑(不依赖用户填数):
PK_MATCH_COOLDOWN_MS=61600。
⚠️ 练习链路没有这个冷却(那边实测 ~1 秒,见 4.7),两边的结论不要互相套用。同一思路也应用到 H5 PK 页面:代理侧对
match的 400/403/429 会自动退避重试 最多 6 次 —— 详见 4.11。
真机点了「继续PK」会打开
result.html?pkIdStr=<pkIdStr>#/结算页面,这个页面的数据源是:
GET /leo-game-pk/{client}/math/pk/history/detail?pkIdStr=<pkIdStr>
关键:submit 返回 200 只代表服务端「收下了」,不代表这局已结算。 实测两种历史记录:
| 情况 | history/detail 返回 |
|---|---|
| 提交成功 | {correctCnt:20, questions:[…20 条明细…]} |
| 提交被 403(没算上) | {correctCnt:0, questions:null} ← 服务端仍留占位记录 |
所以引擎在每次 submit 之后都会再拉一次本接口核对,日志里表现为:
[submit] 提交(第 1 次)→ 200
[settle-ok] 已结算:答对 20 题 / 明细 20 题 ← 这局真算上了
[settle-fail] 服务端未结算(correctCnt=0)—— 这局没算上 ← 会记为该轮失败
只读接口、不计入出题频控,每轮都调不影响刷局节奏。
(完整 API 面另见 exercise-legacy bundle:math/pk/match[/v2]、math/pk/multi/match[/v2]、
final/pk/match/{math,english}/v2、english/pk/match[/v2]、word/eliminate/match[/v2]、
math/pk/submit、math/pk/multi/submit、final/pk/submit/{math,english}、
english/pk/submit、word/eliminate/submit、math/pk/reward/claim、pk/pros/use、
pk/login/sync。/v2 系列返回 arraybuffer 加密体,本项目走旧版明文接口。)
与 PK 是两条独立链路。主域端点被 solar-encoder 拦成 417,根因只有一个:
version=3.141.1 → 417 version=3.140.1 → 200 ← 服务端拒绝未知版本号
platform=android36 → 417 platform=android37 → 200
另外两处也要对齐原版(拿真机抓包 auto_oral-2026-09-27.log 逐行比出来的):
| 项 | 值 | 说明 |
|---|---|---|
| 请求头 | leo-client-trace-id + default-namespace-sw8 |
主域风控会看 |
| UA | Leo/3.140.1 (...; Android 17; ...) |
是 17 不是 37(37 是 query 的 platform) |
sign |
必须带 | 不带就 417;与 PK 相反(PK 不需要) |
★ 2026-10-01:sign 已改为纯 JS 复刻,x86 / Windows 也能跑练习了。
原先
sign靠bin/native/linker64 + dump7执行lre.so里那段混淆代码取 T —— 那是 arm64 ELF,Windows/x86 上跑不起来,calcSign一失败,maybeSign()就静默不带sign,练习端点必然 417x-block-by: solar-encoder。 这就是「PK 能用、练习不能用」的真正根因。现在 T 由
src/lre-emu.js在 JS 里执行同一段机器码算出(指令表见src/lre-insns.js,由tools/gen-lre-insns.py用 capstone 离线生成), 平台无关、零外部依赖。正确性判据:
src/sign.js里那份真机抓包的 T fixture,其对应分钟为M = 29839199(可由 fixture 中的M//9 = 3315466、M//3 = 9946399反推锁定)——模拟器在该点的输出与 fixture 逐字节一致(410/410)。
/leo-math);PK 的 ≈60s 冷却是另一回事,仍然在
(见 4.2)。两条链路的冷却不要互相套用。
练习侧实测:
同一账号连续出题(间隔 1.5s) × 4 → 全部 200,无 429
成功出题后隔 1.1s 再出题 → 200(放行)
所以练习的 MATCH_COOLDOWN_MS 是 1_500(可用
PK_EX_MATCH_COOLDOWN_MS 覆盖),配速安全边距 200ms
—— 之前 1000ms 的安全边距会把 1s 的冷却完全抵消,导致每轮都撞 429
再白等 10 秒重试,比不配速还慢。
实测效果:5 轮 × 100 题(1000 经验)共 6.3 秒,零 429。
因此刷练习的「每轮间隔」保持 0~0(贴着冷却跑最快);刷局那边是 60000 起。
| 链路 | 端点 | 状态 |
|---|---|---|
| 出题 | POST /leo-math/android/exams(form: keypointId + limit) |
✅ 每题自带 answer |
| 经验上报(刷分) | POST /leo-star/.../rank/login/attend(@NeedEncode) |
✅ 每次 +200 |
| 整卷提交 | PUT /leo-math/android/exams/{examId}(不是 /v2/,Content-Type: application/json,body 是 JSON 明文不编码) |
✅ 已打通:必须带笔迹 script,服务端靠回放笔迹判卷(不信任 status) |
每个可记账 ruleType 每天只记一次,实测只有 0 与 1 有效 ⇒ 日上限 400 分。
同一 ruleType 当天再报会返回 200 {data:true} 但分数不动(服务端静默去重)。
网页顶部多了「刷练习」tab:
- 自动刷练习:
出题 → 抄答案 → 提交完整闭环。建议limit=100(=200 经验)。 出题冷却实测只有 ~1s(见上),撞频控(429)自动重试。 - 刷新分数/任务、经验上报(刷分)、只看题(不提交)。
- 轮数无上限(2026-10-01:以前
Math.min(99, …)会把填的大数字无声改成 99, 用户看到的就是「填 >100 也按 100 算」,已改为只挡非法值)。 - 是正经后台任务(2026-10-01):
POST /api/exercise/run现在会 ① 往jobs表写一条config.kind='exercise'的记录、② 交给jobs.startExerciseJob()跑。于是:- 「任务」页能看到它(标题带
[刷练习]),可看逐轮明细; - 可以「停止」(立即中断在途请求与轮间隔等待);
- 切到别的 tab、甚至关掉浏览器,服务端都会继续跑完;
- 回到「刷练习」页会按 localStorage 记的 jobId 重新挂上日志流
(
/api/exercise/stream仍保留,事件里带jobId供过滤;多用户互不可见)。 - 进程重启时,残留的
running/queued任务会被自动标成「服务重启,任务已中断」, 不会再有永远「运行中」的僵尸任务。
- 「任务」页能看到它(标题带
| 接口 | 字段 | 加密? |
|---|---|---|
POST /verifier/android/sms |
phone |
RSA 密文 |
POST /accounts/android/safe/login(短信) |
phone |
RSA 密文 |
POST /accounts/android/safe/login(短信) |
verification |
RSA 密文(最容易漏) |
POST /accounts/android/safe/login(密码) |
phone |
明文 |
POST /accounts/android/safe/login(密码) |
password |
RSA 密文 |
- RSA:
RSA/ECB/PKCS1PADDING+ 原版硬编码 1024 位公钥,Base64 输出(src/crypto-rsa.js)。node:crypto原生支持,不需要任何第三方库。 - PKCS#1 自带随机填充 → 同一手机号每次密文不同,这是预期行为。
| 返回 | 含义 |
|---|---|
200 + 空体(x-yfd-service: fenbi-verifier) |
已发出 |
403 + {"message":"已发送短信验证码"} |
此前已发、正在冷却 —— 不是失败,直接填收到的验证码即可 |
403 + {"message":"验证码获取失败"} |
phone 没加密或号码有问题 |
前端对「冷却」会显示倒计时并提示「直接用收到的验证码」,不会报错吓人。
POST /leo-gateway/android/auth/password(主域)实测无论明文还是 RSA 密文
一律 401 unauthorized,拿不到任何语义化错误;而直连
ape-api.yuanfudao.com/accounts/android/safe/login 能给出
401 {"message":"密码错误"} 这种明确信息。所以本项目只走直连版。
旧结论「switch 恒 417、是传输层指纹、做不到」被完全推翻。
| # | 条件 | 错的后果 |
|---|---|---|
| 1 | _productId 必须在查询串最前 |
放最后 → 400 |
| 2 | 必须带 sign |
不带 / 带旧 sign → 417(417 = sign 校验失败,不是 TLS 指纹) |
| 3 | 主域公共参数 version=3.140.1 + platform=android37 |
用 PK 的 3.141.1/android36 → 400/417 |
context: cur=1155551346
POST /leo-gateway/android/accounts/switch body: targetUserId=511467407
-> 200 {"code":1,...} Set-Cookie: 新 sess + userid + ks_*
context: cur=511467407 ★ 切换成功
再切回 -> 200 -> cur=1155551346 ★ 双向可用
GET /leo-profile/android/user-infos/batchGet(+sign,android37/3.140.1)→ 200:
[{userId, nickname, avatarId, avatarUrl, ...}] —— 名字/头像不再是「账号 {uid}」。
src/leo.js:buildUrl按路径选公共参数(主域MAIN_COMMON_QUERY/ PKCOMMON_QUERY),_productId提到最前。config.signMode默认改auto(有 arm64 native 就算 sign,没有则跳过)。leo-accounts.js:switchToSubAccount(id, targetUserId)—— 切号 + 用服务端回包校验生效身份 + 写回库。⚠️ sign 不再需要 arm64(2026-10-01 起为纯 JS 复刻,见 4.7), 所以 switch / batchGet 在 Windows/x86 上同样可用。
ks_*(ks_deviceid / ks_r / ks_u / ks_sess / ks_persistent)是设备级凭据,
原版只有 POST /leo-auth/android/user-devices 才会下发 —— 而该路径整段被 solar-encoder 拦 417(纯 Node 无解)。
但 ks_* 可以不来自设备注册:把同一台设备的 ks_* 复制到别的账号,服务端照样认。实测:
pk/match 无 ks_* → 400(×3) 有 ks_* → 200(×3)
完整 PK 一局 跑不了 ok=true,已结算
「账号」页 → 设备链池:粘贴含 ks_deviceid 的 cookie 即可入库(按 ks_deviceid 去重)。
- 登录导入的账号会自动补链:没
ks_*时按轮询从池里挑一份(多份轮换,分摊风险); - 导入完整 cookie 时,若自带设备链也会自动收进池子;
- 池空则回退:从库里任意「有设备链的账号」借一份。
API:GET/POST /api/device-chains,DELETE /api/device-chains/:id。
所有 cookie 的 value 在库里都是 AES-256-GCM 密文:
enc:v1:<b64 iv12>:<b64 tag16>:<b64 ciphertext>
- 密钥:
PK_SECRET(≥16 字符,推荐)→sha256(PK_SECRET);否则data/secret.key(32 随机字节、0600、首启自动生成)。 name/domain/path保持明文(便于「只列 cookie 名」,页面永不回显 value)。- 历史明文自动迁移:启动时检测到明文就加密,并
VACUUM清掉旧页(POST /api/leo/accounts/migrate-crypt可手动触发)。 ⚠️ 密钥别丢:丢了 = 已加密的 cookie 无法解密,需要重新导入账号。
网页「PK 页面」tab 上那三个勾选框(视为正确答案 / 自动提交画笔 / 自动下一局) 此前是完全无效的。三个独立真 bug,都已定位到源码/实测:
- 入口页
pk.html的 URL 上带pkbot=answer,autoStroke,autoNext; - H5 跳
exercise.html/result.html时是自己拼 URL 的(只带业务参数), 注入脚本的addLeoId()又只补leoAccountId→ 子页面读不到pkbot, localStorage 里也没存过 →pkBotCfg()返回全 false。
后果(正是用户看到的现象):
recognize桥收到「关」→ 直接回空串 → 手写识别永远判错 (「即使写的是正确符号,也完全没有用,根本做不了」);autoStroke/autoNext同理,一个都不跑。
修法(互为保险):① 跳转时把 pkbot 一起带上;② pkBotCfg() 一旦从 URL
读到就写进 localStorage(同源共享,子页面天然继承)。
手写板(useRecognizeBoard-legacy 里 signature_pad 的那份移植)是以
forceUseTouch: true 创建的,on() 的分支是:
(!window.PointerEvent || mac || forceUseTouch)
? (this._handleMouseEvents(), 'ontouchstart' in window && this._handleTouchEvents())
: this._handlePointerEvents()即总是绑 mousedown,浏览器支持触摸时再绑 touchstart ——
从来不绑 pointerdown。而注入脚本原来只 dispatchEvent(PointerEvent),
一笔都进不去。
修法:严格镜像它自己的判定 ——
'ontouchstart' in window → touchstart/touchmove/touchend,否则 →
mousedown/mousemove/mouseup(buttons:1)。
注意 _handleTouchStart 要求 targetTouches.length === 1、
_handleTouchEnd 要求 targetTouches.length === 0,长度给错会被直接忽略。
结算页按钮文案来自 Result-legacy 的 _t:继续PK / 再练一次 / 继续挑战。
原匹配命中「继续」「再来」却漏了「再练」,同时把「返回首页」也算了进去
(点它等于直接离开结算页,白打一局)。
修法:匹配「继续 / 再练 / 再来」开头 + 元素可见(尺寸 > 6px),并去掉「返回首页」。
那张弹窗的文案是图片里的字(assets/type-1.Ng7ZhNY2.png),所以在 H5 的 JS 里
搜字符串永远搜不到。逐行读对局页代码可知它的触发条件只有一个:
catch (h) {
Fe('/debug/oralPk/exercise/netError', { exception: h });
if (!Be() || (h.response.status !== 429 && h.response.status !== 400)) { /* 普通重试弹窗 */ }
else { O.value = true } // ← 渲染 PkAbnormalDialog(默认 type=1) = 这张图
}⇒ match/v2 返回 400 / 429 就会弹「太火爆」(服务端的瞬时出题频控)。
修法:代理侧 /api/pk/h5/api 对匹配类请求(/match、/eliminate/)的
400 / 403 / 429 自动退避重试(最多 6 次,间隔 0.9s×n,上限 4s),
把瞬时频控挡在代理层;普通接口的 401 仍只重试 1 次,避免登录态失效时整页变卡。
可用 PK_H5_RETRY_MAX / PK_H5_RETRY_GAP_MS 调整。
node tools/test-pk-h5-bot.js # 20 项断言:recognize / 事件类型 / 下一局按钮 / 频控重试
node tools/check-inject.js # 注入脚本语法 + 桥协议(每次改 hook 后必跑)三种添加小猿账号的方式,效果完全一致(都走同一套「探活 + 拉子账号 + 落库」):
- 网页 →「小猿账号」→ 选「短信验证码登录」;
- 填手机号 → 点「发送验证码」;
- 填收到的验证码 → 点「登录」。
若提示「验证码此前已发送(服务端冷却中)」,说明短时间重复请求了 —— 直接填上一条短信里的验证码即可,不是错误。
手机号 + 小猿账号密码 → 登录。 (密码会 RSA 加密后提交,本地不留存密码。)
适合没有密码、也收不到短信的场景。从已登录的小猿环境导出 cookie:
必备 sess;带上 userid 才能确定身份;sid 探活时服务端会补发(本项目会自动收下);
ks_* 是设备链,只有原版 App 调 POST /leo-auth/android/user-devices 才会下发 ——
本服务拿不到(该路径整段被 solar-encoder 拦 417),缺它只影响「子账号昵称/头像」与「切换子账号」,不影响刷局/刷练习。
支持整行 Cookie: / 每行 name=value / JSON 数组三种格式。
网页 →「刷局」→ 选账号 / 子账号 / 知识点(可点「拉取知识点」)→ 局数 → 开始。 右侧实时日志看每轮结果;「任务」页可查历史与逐轮明细。
高级参数(点开「高级参数」折叠区):
| 参数 | 说明 |
|---|---|
costTime(毫秒) |
整卷耗时,写进提交体。留空 = 自动(按题数 × 5ms 给下限,避免 0ms 不自然)。 |
| 画笔算法 | 弧线(推荐) = 密集弧线 21/24 点,服务端接受,PK 默认;七段码 = 字形折线,可能被判作弊 403,仅作对照。 |
| 每轮最小 / 最大间隔 | 唯一的节奏旋钮(引擎不强制等冷却)。服务端有 ≈60s 的账号级出题冷却,所以默认 60000~65000ms;旁边有「填入推荐值」按钮。填更小也不拦,只是等待会挪到「出题频控重试」里。 |
| 出题频控重试间隔 / 最长等待 | 出题撞 400/403 时的重试节奏:默认 3000ms、累计 240000ms(4 分钟)后判该轮失败。 |
| 频控退避基数 / 最大次数 | 提交遇 403 时的退避策略:基数 × 2^n,默认 60000ms、最多 2 次。 |
画笔算法两种模式的坐标口径不同(弧线是像素坐标 x≈150-240;七段码是归一化 ×1000), 不要试图统一 —— 见
src/strokes.js顶部注释。 弧线模式下若题目答案不是>/<,会自动回落到七段码。
# 自检(native / sign / RSA / 笔画 / body 结构 / 编码器)
node bin/selftest.js
# PK H5 的「自动能力」回归测试 + 注入脚本校验
node tools/test-pk-h5-bot.js
node tools/check-inject.js
# 重置管理员密码
node bin/reset-admin.js 新密码
# 下载穿透客户端(可选)
sh bin/get-cloudflared.sh| 变量 | 默认 | 说明 |
|---|---|---|
PK_HOST |
127.0.0.1 |
监听地址。0.0.0.0 则局域网可访问 |
PK_PORT |
8787 |
端口(start.sh 会自动避让被占用的端口) |
PK_DB |
data/pk-node.sqlite |
SQLite 路径 |
PK_ADMIN_USER / PK_ADMIN_PASS |
admin / admin |
首次启动写入的管理员 |
PK_MAX_CONCURRENT |
0(不限) |
最大并行任务数;0 = 不限制 |
PK_SHEPHERD_DID |
空 | 风控设备标识 x-shepherd-did,见下 |
PK_DEVICE_BRAND / PK_DEVICE_MODEL / PK_DEVICE_SDK / PK_DEVICE_SCALE |
Redmi / 25053RT47C / 37 / 3.25 |
拼 App 原生 UA 用,建议按自己设备改 |
PK_DEVICE_*必须与你自己设备一致:主域风控会核对 UA。 用getprop ro.product.brand/ro.product.model/ro.build.version.sdk与ro.sf.lcd_density(除以 160 得到 Scale)取值。
PK_SHEPHERD_DID 怎么拿(需 root):
strings /data/data/com.fenbi.android.leo/files/mmkv/leo_shepherd_id \
| grep didKey | head -1 | sed 's/.*String%\$//'它是宿主 App 从服务端同步、持久化在本机的设备级凭据。本服务不复刻那套同步链路, 直接沿用同机宿主的值(与「导入登录态 cookie」同一思路)。 留空则不发送该头 —— PK 系接口不受影响,主域部分端点可能因此 417。
网页「穿透」页点启动,等价于:
bin/cloudflared tunnel --url http://127.0.0.1:8792 --no-autoupdate- 免账号,得到一个
https://xxxx.trycloudflare.com。 - 进程停止即失效,下次是另一个随机域名。
- 地址本身无鉴权 —— 任何拿到地址的人都能打开登录页。所以:
- 立刻改掉
admin/admin; - 不用时点「停止穿透」;
- 别在公开场合贴地址。
- 立刻改掉
| 项 | 说明 |
|---|---|
| cookie 存储 | ✅ 已加密:leo_accounts.cookies_json / device_chains.cookies_json 里每个 cookie 的 value 都是 AES-256-GCM 密文 |
(enc:v1:iv:tag:ct),密钥来自 PK_SECRET(≥16 字符)或 data/secret.key(0600,自动生成)。 |
|
光拿到 db 文件打不开登录态与设备链;要同时拿到密钥文件才行。默认只监听 127.0.0.1。 |
|
| 默认密码 | admin/admin 只是为了「开箱能进」。对外暴露前必须改密。 |
| 频控 | 服务端对提交接口有独立频控窗口。刷太快会 403/400,属正常保护,不是本项目的 bug。任务之间不限制并行个数(PK_MAX_CONCURRENT=0),任务内部逐轮串行;出题/提交撞频控都会自动退避重试。 |
| 风控 | 连续高频出题可能触发「已封禁,暂时无法使用」的短时冷却,等几分钟再试。 |
| H5 弹「PK现场太火爆 / 挤不进去」 | 对局页在 match/v2 返回 400/429 时就会弹那张图(文案在图片里)。代理侧已对匹配请求自动退避重试(最多 6 次);若依旧弹,多半是账号被短时风控(出题 400「已封禁」),等几分钟。 |
| 设备链 | PK 出题必须带 ks_*(没它 pk/match 恒 400)。本服务用「设备链池」解决:多份来源存进池子,登录导入的账号自动挑一份补齐(多份轮换)。 |
| 子账号切换 | ✅ 已攻破(见 4.10):需 sign + _productId 最前 + android37/3.140.1。子账号名字/头像(batchGet)也 ✅。sign 自 2026-10-01 起是纯 JS 复刻,Windows/x86 同样可用。 |
| 短信登录 | 未实现(默认走「导入登录态」)。如需要按 ape-api.yuanfudao.com/accounts/android/safe/login 补。 |
| 现象 | 处理 |
|---|---|
启动报 缺少 native 资产 |
确认 bin/native/ 齐全(见第二节),跑 node bin/selftest.js。 |
启动报 EADDRINUSE |
端口被占:PK_PORT=8790 ./start.sh(或 set PK_PORT=8790 后双击 start.bat)。启动器也会自动往后找空端口。 |
Windows 双击 start.bat 一闪而过 |
已修(2026-10-01):现在 bat 是 CRLF + 纯 ASCII,逻辑在 bin/start.js。若仍失败,在 cmd 里手动跑 node bin\start.js 看报错。 |
| 导入 cookie 报「上下文接口 HTTP 401/417」 | cookie 过期或域不对;重新导出(需含 sess,域 .yuanfudao.com)。 |
| 提交一直 403 / 400「请求过于频繁」 | 服务端频控。停一会儿,或调大「频控退避基数」。 |
| 出题 400「已封禁,暂时无法使用」 | 短时风控冷却,等待后重试。 |
| 看不到子账号昵称 | 明细接口需设备链(见上),不影响刷局。 |
本项目(pk-node)仅供学习、研究与技术交流使用。
-
用途限制 本项目仅用于学习 Android 客户端的网络协议、加固实现与逆向分析等技术。 严禁将本项目或其衍生代码用于:商业用途、批量刷分、代练、账号交易、 攻击或干扰任何平台正常运行、以及任何违反所在地区法律法规或平台服务条款的行为。
-
风险自负 使用本项目产生的一切后果(包括但不限于账号被封禁、数据丢失、法律风险) 由使用者自行承担。作者不对任何直接、间接、附带或后果性损害负责。
-
无关联声明 本项目为个人技术研究作品,与「小猿口算」及其运营方(北京猿力教育科技有限公司等) 无任何隶属、合作或授权关系,也未获其认可或赞助。
-
知识产权 项目中出现的「小猿口算」等名称、商标、App 资源,以及
bin/native/下的 第三方二进制(Android 系统库、NDK 运行库、App 原生库等), 版权均归各自权利人所有,本项目不对其主张任何权利。 详见 bin/native/NOTICE.md。项目自身代码以 MIT License 授权。 -
侵权处理 —— 立即删除 若你是权利人,并认为本仓库的任何内容(代码、文档、二进制、截图等)侵犯了你的合法权益, 请通过 GitHub Issues(或仓库主页邮箱)告知。一经核实,将立即删除相关内容, 必要时删除整个仓库,无需另行通知。
-
合规使用 请在取得合法授权、且仅用于自身学习研究的前提下使用本项目。 请勿将其用于任何可能损害他人或平台利益的行为。
下载、安装、阅读源码或运行本项目,即视为你已阅读、理解并同意以上全部条款。 若不同意,请立即停止使用并删除全部相关文件。