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:
yml2213
2026-10-11 19:12:23 +08:00
parent be7a6ee97b
commit 1ef819b48d
7 changed files with 954 additions and 9 deletions
+58
View File
@@ -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、模板标定回填管线
+109
View File
@@ -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
View File
@@ -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}
+171
View File
@@ -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
+134
View File
@@ -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); });
+323
View File
@@ -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); });