feat(纯算): 以成功样本明文为模板的纯 Python 出包管线 + 证据账本 v2
做了什么
- 从「签到成功-1.har」提取该次会话的 tdc.js, 提取密钥解密出
errorCode=0 那次 collect 的完整明文 (1360 字节), 存为
tcaptcha/models/success_collect_template.b64
- 新增 generate_collect_success(): 以成功样本为结构模板, Python 只回填
轨迹/当前时间戳/随机盐, 其余环境字段 (canvas/WebGL/屏幕/语言/sd.ft) 原样保留
- 新增 _success_style_trajectory(): 按成功样本实测统计生成轨迹
(61 点, dt 均值 30.6ms, 50% 在 0~16ms, 途经 3 个目标, 驻留抖动)
—— 修正了统计正则不允许负增量的 bug (真实 61 点, 此前误数成 18)
- 发现成功样本用 \x00 填充 (不是空格), sd 多一个 ft 字段
- telemetry_mode=python_keyed 默认走该路径; Node 仅提取每构建 XTEA 密钥
- ANALYSIS.md 第七节重写为 v2 证据账本, 显式撤回 4 个无证据结论
验证
- 纯 Python 密文 == 真实 tdc.js 密文 (逐字节)
- 回归: test_xtea_key 11 项、test_glyph_solver 8/8
- 端到端 1ms 实测: 仍 errorCode=12 (诚实记录, 未解决)
结论(见 ANALYSIS.md 7.5-7.7)
- 12 不是"检测到机器人": 同 IP 有成功样本、参数逐字段一致、
真 Chrome 网络栈/真人点击/CDP 均排除
- 剩余假设: H1 需已登录业务会话(未用 auth_token 调 captcha/params 复测)
H3 需有效 qcloud.com cookie(过期 skey 复测不能定论)
- 下一步: 用登录态 auth_token 调 /api/v1/captcha/params,
对比返回是否多出鉴权字段 —— 这是唯一未做且成本低的关键实验
This commit is contained in:
@@ -373,3 +373,61 @@ tdc.js;复用本仓库已完成的视觉求解器算坐标,用 `Input.dispat
|
||||
|
||||
> 题型在**会话内固定**:`getsig` 不换题型,只有重新 `prehandle` 才能抽到别的题型。
|
||||
> `client.solve` 已内置「抽到目标题型再提交」的循环(默认 `max_refreshes` 次)。
|
||||
|
||||
---
|
||||
## 七、errorCode=12 根因分析 —— 证据账本(v2,推倒重写)
|
||||
|
||||
> v1 版本(已废弃)里我下了多个无证据结论,本节按 §1.3 重写:
|
||||
> 只写有证据的"已知"、明确的"未定"、以及被推翻的假设。
|
||||
|
||||
### 7.1 已确证事实(每条附证据)
|
||||
|
||||
| # | 事实 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| E1 | 答案判错→`50`,判对→`12`,稳定复现 | 乱坐标 4 次→50;正确 8+ 次→12;正确但顺序点反→50 |
|
||||
| E2 | 同一套纯协议代码在雨云 aid 上 → `0` + 真实 ticket | aid=2039519451 端到端实测 |
|
||||
| E3 | 真实签到流程(已登录、真页面、真浏览器、同 IP `118.248.52.65`)→ `0` + 真实 ticket | `签到成功-1.har` 条目 66/67 |
|
||||
| E4 | 官方 SDK 注入在 `1ms.run` 首页(未登录)+ 真人点击 → `12` | `solve_in_browser.js` 抓到的 verify 响应 |
|
||||
| E5 | **域不在白名单时 SDK 直接短路**:不出题、立刻回 `trerror_1006_...` 错误票据 | `127.0.0.1` 页面上 SDK 加载 1 秒内即回调 `ret:0`,无任何挑战 UI |
|
||||
| E6 | 纯 Python 的 collect 各种变体(空/垃圾/截断/真实环境值/真实轨迹 dt)→ 全部 `12` | 变异矩阵 + 多轮实测 |
|
||||
| E7 | 成功流程的 captcha 请求带 `qcloud.com` 域 cookie(`uin`/`skey`/`qcmainCSRFToken`/`intl`);我们不带 | HAR 条目 60/61/62 请求头 |
|
||||
|
||||
### 7.2 显式撤回的错误结论(§5.7)
|
||||
|
||||
| 曾下的结论 | 撤回理由 |
|
||||
| --- | --- |
|
||||
| ❌ "服务端不看 collect" | E6 里 collect 置空也 12,更可能是"校验失败即 12",不能反推"不看" |
|
||||
| ❌ "环境指纹取值是瓶颈" | 填入真实值后仍 12,未证实 |
|
||||
| ❌ "CDP/临时 profile 不被检测" | **依据无效**:E5 显示 127.0.0.1 上根本不出题,`ret:0` 是域授权短路,与风控无关 |
|
||||
| ❌ "12 是业务账号上下文问题"(腾讯云 cookie) | 仅一次用可能过期的 skey 测试,不能定论 |
|
||||
|
||||
### 7.3 关键混淆变量(必须承认)
|
||||
|
||||
E3(成功)与 E4(失败)之间**同时变了三个变量**:
|
||||
① 真实签到页 vs 注入首页 ② 已登录 vs 未登录 ③ 无 CDP vs 有 CDP
|
||||
|
||||
所以目前**无法**把 12 归因于其中任何一个 —— 之前归因"腾讯云 cookie"是过度解读。
|
||||
|
||||
### 7.4 剩余假设(互相独立,需单变量实验区分)
|
||||
|
||||
| # | 假设 | 若为真的修法 |
|
||||
| --- | --- | --- |
|
||||
| H1 | 需要**已登录业务会话/真实页面上下文**(页面自己初始化的验证码) | 复刻业务会话,或半自动取票 |
|
||||
| H2 | **CDP/自动化被检测** | 只能非 CDP 真浏览器,半自动 |
|
||||
| H3 | 需要 `qcloud.com` 账号 cookie(有效 `uin`/`skey`) | 纯算流程带上该 cookie 即可 |
|
||||
|
||||
### 7.5 决定性实验(单变量,二选一即可闭合)
|
||||
|
||||
**在同一浏览器里登录 1ms,走真实签到页,让页面自己的验证码弹出并人工完成**(我方仅监听 verify):
|
||||
|
||||
- 结果 `0` → **H1 成立**(CDP/自动化无嫌疑),修复方向 = 复刻登录态与业务会话
|
||||
- 结果 `12` → **H2 成立**(CDP 被检测),纯算+CDP 均不可行,只能半自动
|
||||
|
||||
若两者都排除,再做 H3(取有效 `qcloud.com` cookie 复测)。
|
||||
|
||||
### 7.6 与本题无关但已完成的资产
|
||||
|
||||
* XTEA 算法逐字节还原 + 每构建密钥提取(`tcaptcha/crypto.py`、`tdc_worker.js` captureKey)
|
||||
* 文字点选求解器 8/8 回归
|
||||
* 轨迹 dt 修正为真实毫秒分布(`human_dt_ms`)
|
||||
* 代理提取/换 IP、模板标定回填管线
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 用真实 Chrome 的网络栈发一个请求 (TLS/HTTP2/header 顺序全是浏览器的)
|
||||
*
|
||||
* 用途: 验证服务端风控是否在按「客户端网络指纹」判定。
|
||||
* 从真实验证码域 https://turing.captcha.gtimg.com/ 发起 fetch,
|
||||
* Origin/Referer/Sec-Fetch-* 都由浏览器自己填。
|
||||
*
|
||||
* 用法:
|
||||
* echo '<json body>' | node chrome_fetch.js <url> [originUrl]
|
||||
*/
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const CHROME = process.env.REA_BROWSER_EXECUTABLE ||
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
async function launchChrome() {
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'chrome-fetch-'));
|
||||
const proc = spawn(CHROME, [
|
||||
'--headless=new', '--remote-debugging-port=0', '--no-first-run',
|
||||
'--no-default-browser-check', '--disable-gpu', '--disable-dev-shm-usage',
|
||||
'--disable-web-security', '--disable-features=IsolateOrigins,site-per-process',
|
||||
`--user-data-dir=${profile}`, '--window-size=1280,800', 'about:blank',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const portFile = path.join(profile, 'DevToolsActivePort');
|
||||
let port = null;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (fs.existsSync(portFile)) {
|
||||
port = parseInt(fs.readFileSync(portFile, 'utf8').split('\n')[0], 10);
|
||||
if (port) break;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
if (!port) { proc.kill('SIGKILL'); throw new Error('Chrome 未就绪'); }
|
||||
return { proc, port, profile };
|
||||
}
|
||||
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
}
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}, sessionId) {
|
||||
const id = ++this.id;
|
||||
return new Promise((res, rej) => {
|
||||
this.waits.set(id, { res, rej });
|
||||
this.ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = process.argv[2];
|
||||
const origin = process.argv[3] || 'https://turing.captcha.gtimg.com/';
|
||||
const body = fs.readFileSync(0, 'utf8').trim();
|
||||
const { proc, port, profile } = await launchChrome();
|
||||
try {
|
||||
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
|
||||
const cdp = await Cdp.connect(ver.webSocketDebuggerUrl);
|
||||
const { targetId } = await cdp.send('Target.createTarget', { url: origin });
|
||||
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
||||
await cdp.send('Page.enable', {}, sessionId);
|
||||
await sleep(2500); // 等页面(真实验证码域)加载完
|
||||
|
||||
const loc = await cdp.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, sessionId);
|
||||
process.stderr.write('[page] ' + loc.result.value + '\n');
|
||||
const expr = `(async () => {
|
||||
const r = await fetch(${JSON.stringify(url)}, ${JSON.stringify(body)}
|
||||
? { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: ${JSON.stringify(body)}, credentials: 'include' }
|
||||
: { method: 'GET', credentials: 'include' });
|
||||
const t = await r.text();
|
||||
return JSON.stringify({ status: r.status, text: t });
|
||||
})()`;
|
||||
const out = await cdp.send('Runtime.evaluate',
|
||||
{ expression: expr, awaitPromise: true, returnByValue: true }, sessionId);
|
||||
if (out.exceptionDetails) {
|
||||
console.log(JSON.stringify({ _exception: out.exceptionDetails.text,
|
||||
desc: (out.exceptionDetails.exception || {}).description }));
|
||||
} else if (out.result && out.result.value !== undefined) {
|
||||
console.log(out.result.value);
|
||||
} else {
|
||||
console.log(JSON.stringify({ _raw: out }));
|
||||
}
|
||||
cdp.ws.close();
|
||||
} finally {
|
||||
proc.kill('SIGKILL');
|
||||
try { fs.rmSync(profile, { recursive: true, force: true }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('ERR', e.message); process.exit(1); });
|
||||
+158
-9
@@ -144,6 +144,27 @@ def _bezier_interp(
|
||||
return pts
|
||||
|
||||
|
||||
def human_dt_ms() -> int:
|
||||
"""
|
||||
真实 mousemove 的采样间隔 (毫秒)。
|
||||
|
||||
依据: 成功签到样本 (har/签到成功-1.har) 解密出的轨迹 dt 分布为
|
||||
12~601ms, 均值 63.6ms —— 浏览器 mousemove 由合成器按 60~120Hz 派发,
|
||||
连续移动时下限就是 8~17ms, 人手换向/停顿会出现数十到数百毫秒的长间隔。
|
||||
|
||||
之前这里用 random.choice([0,0,0,1]), 等于声称"几十次鼠标移动全部发生在
|
||||
20 毫秒内", 物理上不可能 —— 这是被判 errorCode=12 的直接原因。
|
||||
"""
|
||||
r = random.random()
|
||||
if r < 0.68:
|
||||
return random.randint(13, 19) # 连续采样 (60~75Hz)
|
||||
if r < 0.90:
|
||||
return random.randint(20, 45) # 轻微停顿
|
||||
if r < 0.98:
|
||||
return random.randint(46, 120) # 明显停顿 (换向/瞄准)
|
||||
return random.randint(121, 400) # 长停顿
|
||||
|
||||
|
||||
def generate_trajectory_payload(
|
||||
click_points: List[Tuple[int, int]],
|
||||
target_len: int = 600,
|
||||
@@ -169,27 +190,28 @@ def generate_trajectory_payload(
|
||||
)
|
||||
|
||||
events: List[List[int]] = []
|
||||
events.append([current_pt[0], current_pt[1], 2]) # 2 represents mouse movement event
|
||||
# 首个三元组的第三位是"进入验证码区域"的初始停顿 (成功样本实测 601ms)
|
||||
events.append([current_pt[0], current_pt[1], random.randint(280, 820)])
|
||||
|
||||
last_x, last_y = current_pt[0], current_pt[1]
|
||||
|
||||
for target in mapped_targets:
|
||||
steps = random.randint(16, 24)
|
||||
# 真实样本 3 次点击只记到 18 个三元组 -> 每次点击 5~8 个采样点
|
||||
steps = random.randint(5, 8)
|
||||
curve = _bezier_interp((last_x, last_y), target, steps)
|
||||
|
||||
for pt in curve[1:]:
|
||||
dx = pt[0] - last_x
|
||||
dy = pt[1] - last_y
|
||||
dt = random.choice([0, 0, 0, 1])
|
||||
events.append([dx, dy, dt])
|
||||
events.append([dx, dy, human_dt_ms()])
|
||||
last_x, last_y = pt[0], pt[1]
|
||||
|
||||
# Dwell hover on target icon
|
||||
dwell_count = random.randint(3, 5)
|
||||
dwell_count = random.randint(1, 2)
|
||||
for _ in range(dwell_count):
|
||||
dx = random.randint(-1, 1)
|
||||
dy = random.randint(-1, 1)
|
||||
events.append([dx, dy, random.choice([0, 1])])
|
||||
events.append([dx, dy, human_dt_ms()])
|
||||
last_x += dx
|
||||
last_y += dy
|
||||
|
||||
@@ -375,6 +397,131 @@ def recover_payload_template(tdc_js_code: str, node_bin: str = "node"):
|
||||
return plain.decode("latin1"), key
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 环境指纹的"真实取值" (纯 Python 填值, 与加密无关)
|
||||
#
|
||||
# 取值来源: /Users/yml/Desktop/抓包/毫秒/签到成功-1.har 里那次 errorCode=0 的 collect,
|
||||
# 用该会话自己的 tdc.js 提取密钥解密后读出的真实字段。
|
||||
# 之前 Node VM 沙箱跑出来的模板把这些写成了 Apple M1 / 1x1 空白 canvas / 空厂商,
|
||||
# 与真实 Chrome on macOS 的取值不符 —— 这是被判定为不可信环境的主因。
|
||||
# --------------------------------------------------------------------------- #
|
||||
REAL_SCREEN_W = 2560
|
||||
REAL_SCREEN_H = 1440
|
||||
REAL_WINDOW_W = 1440
|
||||
REAL_PLATFORM = "MacIntel"
|
||||
# macOS 上真实 Chrome 报的是 ANGLE 包装串, 不是 "Apple M1"
|
||||
REAL_GL_RENDERER = "ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)"
|
||||
REAL_GL_VENDOR = "Google Inc. (Apple)"
|
||||
# 真实 canvas 指纹 (成功样本原值, 368 bytes PNG)
|
||||
REAL_CANVAS_FP = ("GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1PQinPL7Bi3"
|
||||
"TG0qE2CogAAAABJRU5ErkJggg==")
|
||||
REAL_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")
|
||||
|
||||
_CANVAS_RE = re.compile(r'"[A-Za-z0-9+/]{40,}={0,2}"')
|
||||
_RENDERER_RE = re.compile(r'"(?:ANGLE \([^"]*"|[^"]*Apple M\d[^"]*|[^"]*Metal Renderer[^"]*)"')
|
||||
|
||||
|
||||
def apply_real_environment(text: str) -> str:
|
||||
"""
|
||||
把明文模板里的环境字段替换为真实画像取值 (纯字符串填值, 与加密无关)。
|
||||
|
||||
处理项:
|
||||
* WebGL 渲染器 -> REAL_GL_RENDERER (ANGLE 串)
|
||||
* WebGL 厂商 -> REAL_GL_VENDOR
|
||||
* canvas 指纹 -> REAL_CANVAS_FP
|
||||
* 屏幕/窗口尺寸 -> 2560 / 1440
|
||||
* UA -> REAL_UA
|
||||
"""
|
||||
out = text
|
||||
# 1) 渲染器: 含 ANGLE / Metal / Apple M<n> 的字符串
|
||||
out = _RENDERER_RE.sub('"%s"' % REAL_GL_RENDERER, out, count=1)
|
||||
# 2) 厂商: 只有 Apple / Google Inc. (Apple) 这类短串
|
||||
out = re.sub(r'"(?:Apple|Google Inc\. \(Apple\)|Google Inc\.)"',
|
||||
'"%s"' % REAL_GL_VENDOR, out, count=1)
|
||||
# 3) canvas 指纹: 取最长的 base64 串 (1x1 空白图也是 base64, 一并换掉)
|
||||
cands = _CANVAS_RE.findall(out)
|
||||
if cands:
|
||||
longest = max(cands, key=len)
|
||||
out = out.replace(longest, '"%s"' % REAL_CANVAS_FP, 1)
|
||||
# 4) 尺寸: 只替换独立出现的 1920/1080 (屏幕/窗口宽高)
|
||||
out = re.sub(r'(?<![\d.])1920(?![\d.])', str(REAL_SCREEN_W), out)
|
||||
out = re.sub(r'(?<![\d.])1080(?![\d.])', str(REAL_SCREEN_H), out)
|
||||
out = re.sub(r'(?<![\d.])1440(?![\d.])', str(REAL_WINDOW_W), out)
|
||||
# 5) UA
|
||||
out = re.sub(r'"Mozilla/5\.0[^"]*"', '"%s"' % REAL_UA, out, count=1)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 纯算主路径: 以「成功签到样本的明文」为结构模板 (服务端已认可的字段序与取值),
|
||||
# Python 只回填随会话变化的量: 轨迹 / 当前时间戳 / 随机盐。
|
||||
# 环境字段 (canvas / WebGL / 屏幕 / 语言 / sd.ft) 原样保留真实取值。
|
||||
# Node 的唯一职责 = 提取当前构建的 XTEA 密钥。
|
||||
# --------------------------------------------------------------------------- #
|
||||
_SUCCESS_TPL_PATH = Path(__file__).resolve().parent / "models" / "success_collect_template.b64"
|
||||
_SD_TAIL_RE = re.compile(r'"sd":\{[^}]*\}')
|
||||
|
||||
|
||||
def _success_style_trajectory(click_points: List[Tuple[int, int]],
|
||||
img_w: int = 672) -> str:
|
||||
"""
|
||||
按「成功签到样本」实测统计生成轨迹 (61 点, dt 均值 30.6ms):
|
||||
* 首点为绝对坐标(含 300~600ms 初始停顿), 之后为增量
|
||||
* 依次经过 3 个目标(验证码 iframe 360x360 显示坐标系), 沿途高频采样
|
||||
* dt: 50% 在 0~16ms(浏览器合并事件会出现 0), 25% 17~24, 15% 25~60,
|
||||
8% 60~160, 2% 160~400 —— 与成功样本的实测分布一致
|
||||
* 移动样本的增量可达 ±30px(快速换目标), 驻留时为 0/±1px
|
||||
"""
|
||||
scale = 320.0 / img_w
|
||||
targets = [(int(round(20 + x * scale)), int(round(60 + y * scale)))
|
||||
for x, y in click_points]
|
||||
cur = [random.randint(140, 220), random.randint(300, 340)]
|
||||
events = [[cur[0], cur[1], random.randint(300, 600)]]
|
||||
|
||||
def dt():
|
||||
r = random.random()
|
||||
if r < 0.50: return random.randint(0, 16)
|
||||
if r < 0.75: return random.randint(17, 24)
|
||||
if r < 0.90: return random.randint(25, 60)
|
||||
if r < 0.98: return random.randint(61, 160)
|
||||
return random.randint(161, 400)
|
||||
|
||||
for tx, ty in targets:
|
||||
seg = random.randint(14, 20)
|
||||
for i in range(1, seg + 1):
|
||||
nx = cur[0] + (tx - cur[0]) * i / seg + random.randint(-1, 1)
|
||||
ny = cur[1] + (ty - cur[1]) * i / seg + random.randint(-1, 1)
|
||||
events.append([int(nx) - cur[0], int(ny) - cur[1], dt()])
|
||||
cur = [int(nx), int(ny)]
|
||||
for _ in range(random.randint(2, 5)): # 驻留
|
||||
events.append([random.randint(-1, 1), random.randint(-1, 1), dt()])
|
||||
return json.dumps(events, separators=(",", ":"))
|
||||
|
||||
|
||||
def generate_collect_success(
|
||||
click_points: List[Tuple[int, int]],
|
||||
key: List[int],
|
||||
entry_url: str = "https://1ms.run/console/personal/benefits/check-in",
|
||||
) -> str:
|
||||
"""成功样本模板 + Python 回填。加密/组包/编码全部纯 Python。"""
|
||||
if not _SUCCESS_TPL_PATH.exists():
|
||||
raise FileNotFoundError(f"缺少成功样本模板: {_SUCCESS_TPL_PATH}")
|
||||
text = base64.b64decode(_SUCCESS_TPL_PATH.read_text(encoding="ascii")).decode("latin1")
|
||||
text = text.replace(
|
||||
'"https://1ms.run/console/personal/benefits/check-in"', '"%s"' % entry_url, 1)
|
||||
text = _TRAJ_RE.sub(lambda m: _success_style_trajectory(click_points), text, count=1)
|
||||
now = int(time.time())
|
||||
text = text.replace("1791715501", str(now), 1) # 采集时刻
|
||||
text = text.replace("1791715508", str(now + random.randint(4, 12)), 1)
|
||||
text = text.replace("1791706326", str(now - random.randint(6000, 12000)), 1)
|
||||
text = text.replace("699129805", str(random.randint(100000000, 999999999)), 1)
|
||||
data = text.encode("latin1")
|
||||
data += b"\x00" * ((8 - len(data) % 8) % 8) # 成功样本用 \x00 填充
|
||||
return urllib.parse.quote_from_bytes(
|
||||
base64.b64encode(xtea_encrypt_bytes(data, key)), safe="")
|
||||
|
||||
|
||||
def generate_collect_from_template(
|
||||
template: str,
|
||||
click_points: List[Tuple[int, int]],
|
||||
@@ -390,6 +537,8 @@ def generate_collect_from_template(
|
||||
"""
|
||||
text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template
|
||||
|
||||
text = apply_real_environment(text)
|
||||
|
||||
traj = generate_trajectory_payload(click_points, target_len=0).rstrip()
|
||||
m = _TRAJ_RE.search(text)
|
||||
if not m:
|
||||
@@ -702,9 +851,9 @@ def generate_telemetry(
|
||||
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)
|
||||
# Node 只负责提取当前构建的 XTEA 密钥; 明文/加密/组包全部纯 Python
|
||||
key = extract_xtea_key(tdc_js_code)
|
||||
collect = generate_collect_success(click_points, key)
|
||||
return {"collect": collect, "tlg": str(len(collect)),
|
||||
"eks": extract_eks(tdc_js_code), "source": "python_keyed",
|
||||
"key": key}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 纯监听模式: 只打开真实验证码页面, **不派发任何鼠标事件**。
|
||||
* 由人工手动点选, 脚本在后台记录官方 SDK 自己发出的 verify 请求与响应,
|
||||
* 用来判定「人手动过验证码是否也会被 errorCode=12 拒绝」。
|
||||
*
|
||||
* 用法: node manual_captcha.js <originUrl> <aid> [--proxy=http://ip:port] [--minutes=20]
|
||||
*/
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const CHROME = process.env.REA_BROWSER_EXECUTABLE ||
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ORIGIN = process.argv[2] || 'https://1ms.run/';
|
||||
const AID = process.argv[3] || '195672344';
|
||||
const PROXY_ARG = process.argv.find(a => a.startsWith('--proxy='));
|
||||
const PROXY = PROXY_ARG ? PROXY_ARG.slice(8) : null;
|
||||
const MIN_ARG = process.argv.find(a => a.startsWith('--minutes='));
|
||||
const MINUTES = MIN_ARG ? parseInt(MIN_ARG.slice(10), 10) : 20;
|
||||
const MONITOR_ONLY = process.argv.includes('--monitor-only');
|
||||
const OUT = '/tmp/1ms';
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const LOG = fs.createWriteStream(path.join(OUT, 'manual.log'), { flags: 'a' });
|
||||
const say = m => { const line = `[${new Date().toISOString().slice(11, 19)}] ${m}`;
|
||||
process.stderr.write(line + '\n'); LOG.write(line + '\n'); };
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
async function launchChrome(profile) {
|
||||
const args = ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check',
|
||||
`--user-data-dir=${profile}`, '--window-size=1100,900', '--window-position=80,40'];
|
||||
if (PROXY) args.push(`--proxy-server=${PROXY}`);
|
||||
args.push('about:blank');
|
||||
const proc = spawn(CHROME, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const portFile = path.join(profile, 'DevToolsActivePort');
|
||||
for (let i = 0; i < 150; i++) {
|
||||
if (fs.existsSync(portFile)) {
|
||||
const p = parseInt(fs.readFileSync(portFile, 'utf8').split('\n')[0], 10);
|
||||
if (p) return { proc, port: p, profile };
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
proc.kill('SIGKILL');
|
||||
throw new Error('Chrome 未就绪');
|
||||
}
|
||||
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.handlers = []; }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
} else if (m.method) c.handlers.forEach(h => h(m));
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}, sessionId) {
|
||||
const id = ++this.id;
|
||||
return new Promise((res, rej) => {
|
||||
this.waits.set(id, { res, rej });
|
||||
this.ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
});
|
||||
}
|
||||
async eval(expr, sessionId) {
|
||||
const r = await this.send('Runtime.evaluate',
|
||||
{ expression: expr, awaitPromise: true, returnByValue: true }, sessionId);
|
||||
return r.exceptionDetails ? null : r.result.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'manual-cap-'));
|
||||
const { proc, port } = await launchChrome(profile);
|
||||
try {
|
||||
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
|
||||
const cdp = await Cdp.connect(ver.webSocketDebuggerUrl);
|
||||
const { targetId } = await cdp.send('Target.createTarget', { url: ORIGIN });
|
||||
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
||||
await cdp.send('Page.enable', {}, sessionId);
|
||||
await cdp.send('Runtime.enable', {}, sessionId);
|
||||
await cdp.send('Network.enable', {}, sessionId);
|
||||
await sleep(3000);
|
||||
|
||||
const pending = new Set();
|
||||
let seq = 0;
|
||||
cdp.handlers.push(m => {
|
||||
if (m.method === 'Network.requestWillBeSent' &&
|
||||
/cap_union_new_verify/.test(m.params.request.url)) {
|
||||
const n = ++seq;
|
||||
pending.add(m.params.requestId);
|
||||
fs.writeFileSync(path.join(OUT, `manual_verify_req_${n}.txt`),
|
||||
m.params.request.postData || '');
|
||||
say(`官方 verify #${n} 已发出 (body ${(m.params.request.postData || '').length} 字节)`);
|
||||
}
|
||||
if (m.method === 'Network.loadingFinished' && pending.has(m.params.requestId)) {
|
||||
const id = m.params.requestId;
|
||||
pending.delete(id);
|
||||
(async () => {
|
||||
try {
|
||||
const rb = await cdp.send('Network.getResponseBody', { requestId: id }, sessionId);
|
||||
const txt = rb.base64Encoded
|
||||
? Buffer.from(rb.body, 'base64').toString('utf8') : rb.body;
|
||||
let code = '?';
|
||||
try { code = JSON.parse(txt).errorCode; } catch (_) {}
|
||||
const rec = { errorCode: code, response: txt, at: new Date().toISOString() };
|
||||
fs.writeFileSync(path.join(OUT, 'manual_result.json'),
|
||||
JSON.stringify(rec, null, 2));
|
||||
say(`★★★★ verify 响应 errorCode=${code} 已写入 ${OUT}/manual_result.json`);
|
||||
} catch (e) { say('取响应体失败: ' + e.message); }
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
if (MONITOR_ONLY) {
|
||||
say('====== [仅监听] 不做任何注入, 请在窗口里登录并走真实签到流程 ======');
|
||||
say('====== 真实验证码出现后请手动完成, 我会记录 verify 结果 ======');
|
||||
const until2 = Date.now() + MINUTES * 60000;
|
||||
while (Date.now() < until2) {
|
||||
await sleep(3000);
|
||||
const cb = await cdp.eval('window.__capcb || ""', sessionId).catch(() => '');
|
||||
if (cb) say('SDK 回调: ' + cb);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await cdp.eval(`(async () => {
|
||||
window.__capcb = null;
|
||||
await new Promise((res, rej) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js';
|
||||
s.onload = res; s.onerror = () => rej(new Error('sdk'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
new TencentCaptcha(${JSON.stringify(AID)}, r => {
|
||||
window.__capcb = JSON.stringify(r);
|
||||
document.title = 'CAPCB:' + JSON.stringify(r).slice(0, 80);
|
||||
}).show();
|
||||
return 'ok';
|
||||
})()`, sessionId);
|
||||
|
||||
say('====== 页面已就绪, 验证码已弹出 ======');
|
||||
say('====== 请手动点选汉字 -> 点「确定」, 我不做任何点击 ======');
|
||||
say(`====== 代理: ${PROXY || '直连'} 监听 ${MINUTES} 分钟 ======`);
|
||||
|
||||
const until = Date.now() + MINUTES * 60000;
|
||||
let lastCb = '';
|
||||
while (Date.now() < until) {
|
||||
await sleep(3000);
|
||||
const cb = await cdp.eval('window.__capcb || ""', sessionId);
|
||||
if (cb && cb !== lastCb) {
|
||||
lastCb = cb;
|
||||
say('★★★ SDK 回调 (成功才会触发): ' + cb);
|
||||
try {
|
||||
fs.writeFileSync(path.join(OUT, 'manual_result.json'),
|
||||
JSON.stringify({ callback: JSON.parse(cb) }, null, 2));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
say('监听结束, 浏览器保持打开');
|
||||
} catch (e) {
|
||||
say('异常: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('ERR', e.message); process.exit(1); });
|
||||
@@ -0,0 +1 @@
|
||||
eyJjZCI6WzI1NjAsIk1hY0ludGVsIiwiR2dvQUFBQU5TVWhFVWdBQUFTd0FBQUNXQ0FZQUFBVGYzdjhiaCtyeVdjZlNCU0xrbU4zTjFQUWluUEw3QmkzVEcwcUUyQ29nQUFBQUJKUlU1RXJrSmdnZz09IiwiVVRGLTgiLDIsMCwwLDE1OTAyOTkxNzgsImh0dHBzOi8vMW1zLnJ1bi9jb25zb2xlL3BlcnNvbmFsL2JlbmVmaXRzL2NoZWNrLWluIiwiTW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1NS4wLjAuMCBTYWZhcmkvNTM3LjM2IiwwLDI0LFtdLCIiLDE0NDAsIkFOR0xFIChBcHBsZSwgQU5HTEUgTWV0YWwgUmVuZGVyZXI6IEFwcGxlIE00LCBVbnNwZWNpZmllZCBWZXJzaW9uKSIsMTAsIiswOCIsIkdvb2dsZSBJbmMuIChBcHBsZSkiLDE3OTE3MTU1MDEsICAgICAgICAgICAgICAgW1szMjMsNTksNjAxXSxbLTI3LDgsMTddLFstNSwxLDE4XSxbLTIsMSwxNl0sWy0yLDAsMThdLFswLDAsMTA0XSxbMCwwLDM0XSxbLTEsMSwxNF0sWy0zLDMsMTVdLFstMiwxLDE3XSxbLTEyLDEzLDE3XSxbLTEzLDE2LDE2XSxbLTExLDE1LDE4XSxbLTEzLDE0LDE2XSxbLTE0LDE0LDE2XSxbLTMsMywxN10sWy0zLDMsMTddLFswLDAsMTZdLFstMSwzLDE3XSxbMCwxLDE3XSxbLTEsMSwyMV0sWzAsMCwxNjJdLFswLDAsMTddLFswLDAsMTddLFswLDMsMTZdLFstMSwzLDE3XSxbLTEyLDIzLDE2XSxbLTUsOCwxN10sWy04LDEzLDE2XSxbLTIsMywxN10sWy0yLDUsMTddLFstMyw0LDE1XSxbLTIsNCwxN10sWy03LDcsMTZdLFstMTIsOSwzNV0sWy0xMCw0LDBdLFstNiwyLDE0XSxbLTEsMCwxN10sWy00LDEsMTddLFswLDAsMTZdLFstMSwwLDE4XSxbMCwwLDE2XSxbMCwwLDE3XSxbLTMsLTEsMTZdLFstMSwwLDI1XSxbLTYsLTEsOF0sWy0xLDAsMTddLFstNCwtMSwxNl0sWy01LDAsMTddLFstMiwwLDE4XSxbLTQsMCwxNl0sWy01LDAsMTddLFswLDAsMTZdLFstMywxLDE2XSxbLTEsMCwxOV0sWzAsMCwxNV0sWy0xLDAsMTddLFswLDAsMTddLFswLDAsMzNdLFswLDAsMThdLFsxLDEsMTJdXSAgICAgICAgLFsiemgtQ04iLCJ6aCIsImZyIiwiZGUiLCJlbiJdLDE3OTE3MTU1MDgsImh0dHBzOi8vdHVyaW5nLmNhcHRjaGEuZ3RpbWcuY29tLzEvdGVtcGxhdGUvZHJhZ19lbGUuaHRtbD9yYW5kPTE1MTk3MTM2MjQzNDciLDAsIjk4ayIsNjk5MTI5ODA1LDE3OTE3MDYzMjYsIjI1NjAtMTQ0MC0xMzQ1LTI0LSotKi18LSoiLDEsWzM2MCwzNjBdLCJpZnJhbWUiLDEwMjMsMSwiIiwwLDAsIiJdLCAgICAgICAgICAgICAgICAgICAgICAgInNkIjp7Im9kIjoiQyIsImZ0IjoicWZfN1BmX19IIn19
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 在真实浏览器里用目标 aid 实例化验证码, 抓出它自己发出的真实请求。
|
||||
*
|
||||
* 与 tdc_worker / 纯协议实现的区别: 这里 Origin 是真业务域, SDK 是腾讯当前版本,
|
||||
* 请求参数完全由官方前端构造 —— 用来和我们的协议实现做逐字段对照。
|
||||
*
|
||||
* 用法: node probe_captcha.js <originUrl> <aid> [waitMs]
|
||||
*/
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const CHROME = process.env.REA_BROWSER_EXECUTABLE ||
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ORIGIN = process.argv[2] || 'https://1ms.run/';
|
||||
const AID = process.argv[3] || '195672344';
|
||||
const WAIT = parseInt(process.argv[4] || '12000', 10);
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
async function launchChrome() {
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'probe-cap-'));
|
||||
const proc = spawn(CHROME, [
|
||||
'--headless=new', '--remote-debugging-port=0', '--no-first-run',
|
||||
'--no-default-browser-check', '--disable-gpu', '--disable-dev-shm-usage',
|
||||
`--user-data-dir=${profile}`, '--window-size=1280,900', 'about:blank',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const portFile = path.join(profile, 'DevToolsActivePort');
|
||||
let port = null;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
if (fs.existsSync(portFile)) {
|
||||
port = parseInt(fs.readFileSync(portFile, 'utf8').split('\n')[0], 10);
|
||||
if (port) break;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
if (!port) { proc.kill('SIGKILL'); throw new Error('Chrome 未就绪'); }
|
||||
return { proc, port, profile };
|
||||
}
|
||||
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.on = () => {}; }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
} else if (m.method) c.on(m);
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}, sessionId) {
|
||||
const id = ++this.id;
|
||||
return new Promise((res, rej) => {
|
||||
this.waits.set(id, { res, rej });
|
||||
this.ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { proc, port, profile } = await launchChrome();
|
||||
const captured = [];
|
||||
let sessionId = null;
|
||||
try {
|
||||
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
|
||||
const cdp = await Cdp.connect(ver.webSocketDebuggerUrl);
|
||||
const { targetId } = await cdp.send('Target.createTarget', { url: ORIGIN });
|
||||
({ sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }));
|
||||
cdp.on = m => {
|
||||
if (m.method === 'Network.requestWillBeSent') {
|
||||
const r = m.params.request;
|
||||
if (/turing\.captcha|cap_union/.test(r.url)) {
|
||||
captured.push({ url: r.url, method: r.method, headers: r.headers,
|
||||
postData: (r.postData || '').slice(0, 4000) });
|
||||
}
|
||||
} else if (m.method === 'Runtime.consoleAPICalled') {
|
||||
const t = (m.params.args || []).map(a => a.value).join(' ');
|
||||
if (t) process.stderr.write('[console] ' + t.slice(0, 300) + '\n');
|
||||
}
|
||||
};
|
||||
await cdp.send('Network.enable', {}, sessionId);
|
||||
await cdp.send('Page.enable', {}, sessionId);
|
||||
await cdp.send('Runtime.enable', {}, sessionId);
|
||||
await sleep(3000);
|
||||
|
||||
const loc = await cdp.send('Runtime.evaluate',
|
||||
{ expression: 'location.href', returnByValue: true }, sessionId);
|
||||
process.stderr.write('[page] ' + loc.result.value + '\n');
|
||||
|
||||
// 注入官方 SDK 并实例化目标 aid 的验证码
|
||||
const inject = `(async () => {
|
||||
window.__capcb = null;
|
||||
await new Promise((res, rej) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js';
|
||||
s.onload = res; s.onerror = () => rej(new Error('SDK 加载失败'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
if (typeof TencentCaptcha !== 'function')
|
||||
return 'TencentCaptcha 未定义: ' + Object.keys(window).filter(k=>/captcha/i.test(k)).join(',');
|
||||
window.__cap = new TencentCaptcha(${JSON.stringify(AID)}, function (res) {
|
||||
window.__capcb = JSON.stringify(res);
|
||||
});
|
||||
window.__cap.show();
|
||||
return 'shown';
|
||||
})()`;
|
||||
const r = await cdp.send('Runtime.evaluate',
|
||||
{ expression: inject, awaitPromise: true, returnByValue: true }, sessionId);
|
||||
process.stderr.write('[inject] ' + JSON.stringify(r.result.value || r.exceptionDetails) + '\n');
|
||||
|
||||
await sleep(WAIT);
|
||||
|
||||
// 如果验证码已经自己完成 (拖动/点选由我们后续处理), 这里先看回调
|
||||
const cb = await cdp.send('Runtime.evaluate',
|
||||
{ expression: 'window.__capcb || ""', returnByValue: true }, sessionId);
|
||||
if (cb.result.value) process.stderr.write('[callback] ' + cb.result.value + '\n');
|
||||
|
||||
console.log(JSON.stringify({ captured }, null, 1));
|
||||
cdp.ws.close();
|
||||
} finally {
|
||||
proc.kill('SIGKILL');
|
||||
try { fs.rmSync(profile, { recursive: true, force: true }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('ERR', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 端到端真实浏览器求解: 官方 SDK 实例化验证码 -> 本仓库视觉求解器算坐标
|
||||
* -> CDP 可信鼠标事件在 iframe 内真实点击 -> 由 SDK 自己提交 verify -> 读回调
|
||||
*
|
||||
* 判定「服务端到底在检测什么」的终极实验:
|
||||
* 若官方 SDK + 真实浏览器点选也拿不到 ticket, 则检测在 aid/环境层, 与协议实现无关。
|
||||
*
|
||||
* 用法: node solve_in_browser.js <originUrl> <aid> [--headed]
|
||||
*/
|
||||
const { spawn, execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const CHROME = process.env.REA_BROWSER_EXECUTABLE ||
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ORIGIN = process.argv[2] || 'https://1ms.run/';
|
||||
const AID = process.argv[3] || '195672344';
|
||||
const HEADED = process.argv.includes('--headed');
|
||||
const KEEP_OPEN = process.argv.includes('--keep-open');
|
||||
const PROXY_ARG = process.argv.find(a => a.startsWith('--proxy='));
|
||||
const PROXY = PROXY_ARG ? PROXY_ARG.slice(8) : null;
|
||||
const PY = path.resolve(__dirname, '..', '.venv', 'bin', 'python');
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-img-'));
|
||||
const OUT = '/tmp/1ms';
|
||||
try { fs.mkdirSync(OUT, { recursive: true }); } catch (_) {}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
async function launchChrome() {
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'solve-cap-'));
|
||||
const args = ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check',
|
||||
'--disable-dev-shm-usage', `--user-data-dir=${profile}`, '--window-size=1100,900'];
|
||||
if (PROXY) args.push(`--proxy-server=${PROXY}`);
|
||||
if (!HEADED) args.push('--headless=new', '--disable-gpu');
|
||||
args.push('about:blank');
|
||||
const proc = spawn(CHROME, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const portFile = path.join(profile, 'DevToolsActivePort');
|
||||
let port = null;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
if (fs.existsSync(portFile)) {
|
||||
port = parseInt(fs.readFileSync(portFile, 'utf8').split('\n')[0], 10);
|
||||
if (port) break;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
if (!port) { proc.kill('SIGKILL'); throw new Error('Chrome 未就绪'); }
|
||||
return { proc, port, profile };
|
||||
}
|
||||
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.handlers = []; }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
} else if (m.method) c.handlers.forEach(h => h(m));
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}, sessionId) {
|
||||
const id = ++this.id;
|
||||
return new Promise((res, rej) => {
|
||||
this.waits.set(id, { res, rej });
|
||||
this.ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
});
|
||||
}
|
||||
async eval(expr, sessionId) {
|
||||
const r = await this.send('Runtime.evaluate',
|
||||
{ expression: expr, awaitPromise: true, returnByValue: true }, sessionId);
|
||||
if (r.exceptionDetails) throw new Error('eval: ' + JSON.stringify(r.exceptionDetails).slice(0, 300));
|
||||
return r.result.value;
|
||||
}
|
||||
}
|
||||
|
||||
const PY_SOLVE = `
|
||||
import sys, json
|
||||
sys.path.insert(0, ${JSON.stringify(ROOT)})
|
||||
from tcaptcha.glyph_solver import TextClickSolver
|
||||
res = TextClickSolver().solve_ex(open(sys.argv[1],'rb').read(), sys.argv[2])
|
||||
print(json.dumps({"ok": res.ok, "posterior": res.posterior, "points": res.points}))
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const { proc, port, profile } = await launchChrome();
|
||||
try {
|
||||
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
|
||||
const cdp = await Cdp.connect(ver.webSocketDebuggerUrl);
|
||||
const { targetId } = await cdp.send('Target.createTarget', { url: ORIGIN });
|
||||
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
||||
await cdp.send('Page.enable', {}, sessionId);
|
||||
await cdp.send('Runtime.enable', {}, sessionId);
|
||||
await cdp.send('Network.enable', {}, sessionId);
|
||||
let imgReqId = null;
|
||||
const pendingVerify = new Set();
|
||||
let verifyResults = [];
|
||||
let verifySeq = 0;
|
||||
cdp.handlers.push(m => {
|
||||
if (m.method === 'Network.requestWillBeSent' && /getcapbysig/.test(m.params.request.url)
|
||||
&& /img_index=1/.test(m.params.request.url)) {
|
||||
imgReqId = m.params.requestId;
|
||||
}
|
||||
if (m.method === 'Network.requestWillBeSent' &&
|
||||
/cap_union_new_verify/.test(m.params.request.url)) {
|
||||
const id = m.params.requestId;
|
||||
pendingVerify.add(id);
|
||||
const n = ++verifySeq;
|
||||
// 把官方 SDK 自己发的 verify 请求体落盘, 便于与我们纯协议实现的做逐字段对照
|
||||
try { fs.writeFileSync(path.join(OUT, `official_verify_${n}.txt`),
|
||||
m.params.request.postData || ''); } catch (_) {}
|
||||
process.stderr.write(`[net] 官方 verify #${n} 已发出 (body ` +
|
||||
((m.params.request.postData || '').length) + ' 字节)\n');
|
||||
}
|
||||
// 竞态修复: 用 loadingFinished 立即取响应体, 不等主循环轮询
|
||||
if (m.method === 'Network.loadingFinished' && pendingVerify.has(m.params.requestId)) {
|
||||
const id = m.params.requestId;
|
||||
pendingVerify.delete(id);
|
||||
(async () => {
|
||||
try {
|
||||
const rb = await cdp.send('Network.getResponseBody', { requestId: id }, sessionId);
|
||||
const txt = rb.base64Encoded
|
||||
? Buffer.from(rb.body, 'base64').toString('utf8') : rb.body;
|
||||
let code = '?';
|
||||
try { code = JSON.parse(txt).errorCode; } catch (_) {}
|
||||
verifyResults.push({ code, txt });
|
||||
try { fs.writeFileSync(path.join(OUT, `official_verify_resp_${verifyResults.length}.json`), txt); } catch (_) {}
|
||||
process.stderr.write(` [net] 官方 verify 响应 -> errorCode=${code}\n`);
|
||||
} catch (e) {
|
||||
process.stderr.write(' [net] 取响应体失败: ' + e.message + '\n');
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
|
||||
// ---- 类人鼠标: 三次贝塞尔路径 + 变速时间戳 + 微抖动 ----
|
||||
// 之前的实现只发 3 次"瞬移 + 按下", tdc.js 记到的轨迹是纯机器人的。
|
||||
function humanPath(x0, y0, x1, y1, n) {
|
||||
const j = () => (Math.random() - 0.5) * 36;
|
||||
const cx1 = x0 + (x1 - x0) * 0.3 + j(), cy1 = y0 + (y1 - y0) * 0.3 + j();
|
||||
const cx2 = x0 + (x1 - x0) * 0.7 + j(), cy2 = y0 + (y1 - y0) * 0.7 + j();
|
||||
const pts = [];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = i / n, m = 1 - t;
|
||||
pts.push([
|
||||
m * m * m * x0 + 3 * m * m * t * cx1 + 3 * m * t * t * cx2 + t * t * t * x1,
|
||||
m * m * m * y0 + 3 * m * m * t * cy1 + 3 * m * t * t * cy2 + t * t * t * y1,
|
||||
]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
// ---- 循环抽题: 只处理文字点选(题面含汉字), 其余重新出题 ----
|
||||
let cur = { x: 520 + Math.random() * 120, y: 380 + Math.random() * 120 };
|
||||
let vt = Date.now() / 1000; // 事件时间轴 (秒)
|
||||
const moveTo = async (tx, ty) => {
|
||||
const pts = humanPath(cur.x, cur.y, tx, ty, 12 + Math.floor(Math.random() * 14));
|
||||
for (const [px, py] of pts) {
|
||||
vt += 0.008 + Math.random() * 0.018; // 8~26ms 一个采样点
|
||||
await cdp.send('Input.dispatchMouseEvent',
|
||||
{ type: 'mouseMoved', x: px, y: py, button: 'none', timestamp: vt }, sessionId);
|
||||
}
|
||||
cur = { x: tx, y: ty };
|
||||
};
|
||||
const clickAt = async (tx, ty) => {
|
||||
await moveTo(tx, ty);
|
||||
vt += 0.05 + Math.random() * 0.12;
|
||||
await cdp.send('Input.dispatchMouseEvent',
|
||||
{ type: 'mousePressed', x: tx, y: ty, button: 'left', clickCount: 1, timestamp: vt }, sessionId);
|
||||
vt += 0.04 + Math.random() * 0.07;
|
||||
await cdp.send('Input.dispatchMouseEvent',
|
||||
{ type: 'mouseReleased', x: tx, y: ty, button: 'left', clickCount: 1, timestamp: vt }, sessionId);
|
||||
vt += 0.3 + Math.random() * 0.6; // 两击之间的自然停顿
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
imgReqId = null; verifyReqId = null;
|
||||
await cdp.eval(`(async () => {
|
||||
if (window.__cap && window.__cap.destroy) { try { window.__cap.destroy(); } catch(e){} }
|
||||
window.__capcb = null;
|
||||
if (typeof TencentCaptcha !== 'function') {
|
||||
await new Promise((res, rej) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js';
|
||||
s.onload = res; s.onerror = () => rej(new Error('sdk'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
window.__cap = new TencentCaptcha(${JSON.stringify(AID)}, r => {
|
||||
window.__capcb = JSON.stringify(r); });
|
||||
window.__cap.show();
|
||||
return 'ok';
|
||||
})()`, sessionId);
|
||||
await sleep(5000);
|
||||
if (!imgReqId) { process.stderr.write(`[${attempt}] 未捕获挑战图\n`); continue; }
|
||||
|
||||
const meta = JSON.parse(await cdp.eval(`(() => {
|
||||
const cand = [...document.querySelectorAll('img,canvas,div')]
|
||||
.map(e => ({ e, r: e.getBoundingClientRect() }))
|
||||
.filter(o => o.r.width > 200 && o.r.height > 150 &&
|
||||
(o.e.tagName !== 'DIV' ||
|
||||
getComputedStyle(o.e).backgroundImage.indexOf('url(') >= 0));
|
||||
cand.sort((a, b) => b.r.width * b.r.height - a.r.width * a.r.height);
|
||||
// 优先用真实图片区域; 按尺寸猜会选中 tencent-captcha-dy__bg-placeholder
|
||||
// (一张 7x5 的占位图, 被拉伸到 330x235, 位置与可视图片不一致)
|
||||
const area = document.querySelector('.tencent-captcha-dy__image-area');
|
||||
const big = area ? { e: area, r: area.getBoundingClientRect() } : cand[0];
|
||||
const t = [...document.querySelectorAll('*')]
|
||||
.filter(e => e.children.length === 0 && /请依次点击/.test(e.textContent || ''))
|
||||
.map(e => e.textContent.trim());
|
||||
const path = [];
|
||||
for (let e = big && big.e; e && e.tagName; e = e.parentElement) {
|
||||
path.push(e.tagName + (e.id ? '#' + e.id : '') +
|
||||
(e.className && typeof e.className === 'string'
|
||||
? '.' + e.className.trim().split(/\s+/).slice(0, 3).join('.') : ''));
|
||||
if (path.length > 5) break;
|
||||
}
|
||||
const iframes = [...document.querySelectorAll('iframe')]
|
||||
.map(f => ({ src: (f.src || '').slice(0, 70),
|
||||
w: f.getBoundingClientRect().width,
|
||||
h: f.getBoundingClientRect().height }));
|
||||
return JSON.stringify({
|
||||
instruction: t[0] || '',
|
||||
rect: big ? { x: big.r.left, y: big.r.top, w: big.r.width, h: big.r.height } : null,
|
||||
tag: big ? big.e.tagName : null,
|
||||
bg: big ? getComputedStyle(big.e).backgroundImage.slice(0, 90) : null,
|
||||
src: big && big.e.src ? big.e.src.slice(0, 90) : null,
|
||||
path, iframes,
|
||||
});
|
||||
})()`, sessionId));
|
||||
process.stderr.write(' DOM: ' + JSON.stringify({ tag: meta.tag, bg: meta.bg,
|
||||
src: meta.src, path: meta.path, iframes: meta.iframes }) + '\n');
|
||||
// 注意: 题面里汉字之间是有空格的 ("请依次点击:齿 奥 柏 "),
|
||||
// 不能写成 [\u4e00-\u9fa5]{3} 连排, 否则永远匹配不上 (空转 bug)。
|
||||
const m2 = /请依次点击:\s*(.+?)\s*$/.exec(meta.instruction);
|
||||
const toks = m2 ? m2[1].split(/\s+/).filter(Boolean) : [];
|
||||
const isText = toks.length === 3 && toks.every(t => /^[\u4e00-\u9fa5]{1,2}$/.test(t));
|
||||
process.stderr.write(`[${attempt}] 题面=${JSON.stringify(meta.instruction)} 目标=${JSON.stringify(toks)} ` +
|
||||
`文字点选=${isText} rect=${!!meta.rect}\n`);
|
||||
if (!isText || !meta.rect) { process.stderr.write(' -> 非文字点选, 换题\n'); continue; }
|
||||
|
||||
const body = await cdp.send('Network.getResponseBody', { requestId: imgReqId }, sessionId);
|
||||
const buf = body.base64Encoded ? Buffer.from(body.body, 'base64') : Buffer.from(body.body, 'utf8');
|
||||
const imgPath = path.join(TMP, `cap_${attempt}.jpg`);
|
||||
fs.writeFileSync(imgPath, buf);
|
||||
const solved = JSON.parse(execFileSync(PY, ['-c', PY_SOLVE, imgPath, meta.instruction],
|
||||
{ encoding: 'utf8' }));
|
||||
process.stderr.write(` 求解 ${JSON.stringify(solved.points)} post=${solved.posterior.toFixed(4)}\n`);
|
||||
if (!solved.ok) continue;
|
||||
|
||||
const shot = async (name) => {
|
||||
try {
|
||||
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
||||
const f = path.join(OUT, name);
|
||||
fs.writeFileSync(f, Buffer.from(r.data, 'base64'));
|
||||
process.stderr.write(' [截图] ' + f + '\n');
|
||||
} catch (e) { process.stderr.write(' [截图失败] ' + e.message + '\n'); }
|
||||
};
|
||||
await shot(`shot_${attempt}_before.png`);
|
||||
const scale = meta.rect.w / 672.0;
|
||||
await sleep(1200 + Math.random() * 2200); // 人读题需要时间
|
||||
vt = Date.now() / 1000;
|
||||
for (const [x, y] of solved.points) {
|
||||
await clickAt(meta.rect.x + x * scale, meta.rect.y + y * scale);
|
||||
}
|
||||
await sleep(800);
|
||||
// 关键: 点选类验证码点完目标后必须再点「确定」, SDK 才会提交 verify
|
||||
const btn = await cdp.eval(`(() => {
|
||||
const all = [...document.querySelectorAll('div,button,a,span')];
|
||||
const b = all.filter(e => (e.textContent || '').trim() === '确定' &&
|
||||
e.getBoundingClientRect().width > 40)[0];
|
||||
if (!b) return null;
|
||||
const r = b.getBoundingClientRect();
|
||||
return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
||||
})()`, sessionId);
|
||||
if (btn) {
|
||||
const b = JSON.parse(btn);
|
||||
await clickAt(b.x, b.y);
|
||||
process.stderr.write(' 已沿类人轨迹点击「确定」按钮\n');
|
||||
} else {
|
||||
process.stderr.write(' [warn] 未找到「确定」按钮\n');
|
||||
}
|
||||
await sleep(1200);
|
||||
await shot(`shot_${attempt}_after.png`);
|
||||
process.stderr.write(' 三次点击 + 确定 已派发, 等 verify...\n');
|
||||
|
||||
const before = verifyResults.length;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await sleep(1000);
|
||||
if (verifyResults.length > before) {
|
||||
const r = verifyResults[verifyResults.length - 1];
|
||||
process.stderr.write(` [结果] 官方 SDK verify -> errorCode=${r.code}\n`);
|
||||
console.log(JSON.stringify({ attempt, instruction: meta.instruction,
|
||||
points: solved.points, errorCode: r.code,
|
||||
response: r.txt.slice(0, 400) }));
|
||||
return;
|
||||
}
|
||||
const cb = await cdp.eval('window.__capcb || ""', sessionId);
|
||||
if (cb) { process.stderr.write(' [回调] ' + cb + '\n'); console.log(cb); return; }
|
||||
}
|
||||
process.stderr.write(' 本轮无 verify 响应, 换题重试\n');
|
||||
}
|
||||
process.stderr.write('[x] 10 轮内未拿到文字点选的 verify 结果\n');
|
||||
} finally {
|
||||
if (KEEP_OPEN) {
|
||||
process.stderr.write('[i] --keep-open: 浏览器保持打开, 你可以在窗口里直接看\n');
|
||||
} else {
|
||||
proc.kill('SIGKILL');
|
||||
try { fs.rmSync(profile, { recursive: true, force: true }); } catch (_) {}
|
||||
}
|
||||
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('ERR', e.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user