乐于分享
好东西不私藏

【三万字记录】AI攻克极验证4代滑块并交付运行实录

【三万字记录】AI攻克极验证4代滑块并交付运行实录

以下文档详细记录了基于反混淆后的 gcaptcha4_deobfuscated.js 源码,对极验 v4 滑块验证码协议的完整逆向分析过程。 包括:协议流程、缺口识别、坐标换算、轨迹模拟、PoW 求解、加密算法还原、payload 构建,以及完整可运行的 Python + Node.js 实现代码。


书接上回:AI自动混淆还原极验gcaptcha4.js—完整技术文档和代码

首先说明,本次教学完全基于小肩膀教育自营AI,基本上一键完成,欢迎体验使用。另外,欢迎加入小肩膀教育,购买教程完整学习AI逆向。

小肩膀自营AI服务
    包纯度(假一赔十),可直接指令逆向,绝对耐蹬。
介绍地址:https://xjbedu.site/token注册地址:https://xjbtoken.site/register淘宝搜索【小肩膀网络科技】购买AI Token欢迎重度claude code opus 4.6 thinking用户来体验尝鲜。可加下方群聊

九、整体架构与协议流程

9.1 极验 v4 滑块验证码工作原理

极验 v4 滑块验证码采用 JSONP 通信(不是常见的 XHR/fetch),整个验证流程分为两个核心 HTTP 请求:

用户浏览器    │    ├─── [1GET /load ──────────────────► gcaptcha4.geetest.com    │    参数: captcha_id, challenge,       │    │          client_type, risk_type, lang │    │    ◄─────────── JSONP 响应 ──────────┘    │    返回: lot_number, payload, process_token,    │          pt, bg(背景图路径), slice(滑块图路径),    │          ypos, pow_detail, 公钥等    │    ├─── [2GET 背景图 ─────────────────► static.geetest.com    │    ◄─────────── PNG 图片 ────────────┘    │    ├─── [3GET 滑块碎片图 ─────────────► static.geetest.com    │    ◄─────────── PNG 图片 ────────────┘    │    │   [本地处理]    │    ├── 识别缺口位置 (OCR/图像处理)    │    ├── 计算缩放比与滑动距离    │    ├── 生成拟人化轨迹    │    ├── 计算 PoW 工作量证明    │    ├── 构建 payload JSON    │    └── 加密生成 w 参数    │    └─── [4GET /verify ────────────────► gcaptcha4.geetest.com         参数: captcha_id, challenge,       │               lot_number, w, payload,      │               process_token, pt 等         │         ◄─────────── JSONP 响应 ──────────┘         返回: result="success"/"fail",               pass_token, gen_time 等

9.2 通信方式:JSONP

极验 v4 的前端 JS 不使用XMLHttpRequest 或 fetch,而是通过动态创建 <script> 标签注入 JSONP 回调来通信。

JS 源码中的通信机制(还原后 gcaptcha4_deobfuscated.js 行 2132 附近):

// 源码行 2132: JSONP 请求发起// (0, _v1912.jsonp)(_v2177, "verify", u, _v2170).$_JAN(function (_v2189) {//     var _v2194 = _v2179.resultAdapt(_v2189);//     ...// });//// 通信流程:// 1. 生成唯一回调函数名: geetest_1776962556467// 2. 在 window 上注册该回调// 3. 创建 <script src="https://gcaptcha4.geetest.com/verify?callback=geetest_xxx&...">// 4. 服务端返回的JS: geetest_xxx({status:"success", data:{...}})// 5. 浏览器执行脚本,自动触发回调函数

在我们的 Python 实现中,直接用 requests.get() 请求同样的 URL,然后用正则提取 JSONP 包裹的 JSON 数据:

def parse_jsonp(text):    """    解析 JSONP 响应,提取内部 JSON 对象    JSONP 格式: geetest_1776962556467({"status":"success","data":{...}})    需要去掉外层的函数调用包装,提取括号内的 JSON    """    match = re.search(r'\((\{.*\})\)', text, re.DOTALL)  # 匹配最外层括号内的JSON    if match:        return json.loads(match.group(1))    return json.loads(text)  # 如果不是JSONP格式,尝试直接解析为纯JSON

9.3 项目文件结构

geetest_ctf/├── geetest_solver.py      # Python 主控脚本:编排整个8步验证流程├── trajectory.py           # 拟人化轨迹生成器:三段式加速模型 + 回弹├── pow_solver.py           # PoW 工作量证明计算:暴力搜索 nonce├── gap_detector.py         # 图片缺口识别:调用 OCR 平台 API└── node_env/    ├── package.json        # Node.js 依赖配置    └── generate_w.js       # 加密参数 w 的生成                            # 包含:轨迹差分编码、Base64、AES-128-CBC、                            # RSA-1024 PKCS#1v1.5、SM4-CBC、SM2

9.4 关键参数速查表

| 参数名 | 含义 | 来源 | 示例值 ||--------|------|------|--------|`captcha_id` | 验证码实例ID(每个网站唯一) | 页面嵌入的JS | `24f56dc13c40dc4a02fd0318567caef5` |`challenge` | 本次挑战的唯一令牌 | 客户端用 guid() 生成 | `a3b1c2d4e5f67890` (16hex) |`lot_number` | 服务端批次号 | load 响应 | `72ce10e53aea4ac6b0b3217285a2a503` |`pt` | 加密方式指示 | load 响应 | `"0"`=Base64, `"1"`=RSA+AES, `"2"`=SM2+SM4 |`payload` | 服务端加密过的会话数据 | load 响应 | 不透明字符串,原样回传 |`process_token` | 会话令牌 | load 响应 | 不透明字符串,原样回传 |`setLeft` | 滑块滑动的像素距离(显示坐标) | 客户端计算 | `128` |`passtime` | 操作总耗时(ms) | 轨迹末尾时间戳 | `1507` |`userresponse` | 原图坐标系的位移+偏移 | `setLeft/scale+2` | `129.24` |`aa` | 轨迹编码字符串 | 差分压缩+自定义编码 | `s!!tuv0!!(/1,` |`w` | 加密后的完整验证数据 | AES加密payload+RSA加密密钥 | 1280字符的hex串 |`pow_msg` | PoW 消息串 | 客户端暴力计算 | `1\|0\|md5\|2026...\|lot...\|cap...\|nonce\|nonce` |`pow_sign` | PoW 哈希签名 | 客户端计算 | md5 hex digest |

十、load 接口分析与图片下载

10.1 challenge 生成

在浏览器端,每次验证前客户端会生成一个 16 位 hex 字符串作为 challenge

JS 源码gcaptcha4_deobfuscated.js 行 457-465):

// guid() 函数:生成16位随机hex密钥// 内部函数 e() 生成4位hex(取 65536 范围内随机数的十六进制后4位)// 调用4次 e() 拼接成 16 位var p = function () {    function e() {        return (65536 * (1 + Math.random()) | 0)  // 生成 65536~131071 的整数            .toString(16)                           // 转十六进制(如"1a3f5",5位)            .substring(1);                          // 去掉首位"1",剩余4位hex    }    return function () {        return e() + e() + e() + e();               // 4×4 = 16位hex    };}();_v80.guid = p;

Python 实现

def guid_python():    """    生成16位随机hex字符串,与JS中的guid()逻辑一致    每次调用内部函数e()生成4位hex,调用4次拼接    """    def e():        # 模拟 JS 的 (65536 * (1 + Math.random()) | 0).toString(16).substring(1)        return format((int(65536 * (1 + random.random())) | 0), 'x')[-4:]    return e() + e() + e() + e()

10.2 load 接口请求

load 接口是验证流程的第一步,获取验证码的所有配置参数和图片路径。

请求格式

GET https://gcaptcha4.geetest.com/load    ?callback=geetest_1776962556467     # JSONP回调函数名    &captcha_id=24f56dc13c40dc4a02fd0318567caef5    &challenge=a3b1c2d4e5f67890         # 客户端生成的16位hex    &client_type=web                    # 客户端类型    &risk_type=slide                    # 验证码类型:滑块    &lang=zh                            # 语言

Python 实现

def call_load(session, captcha_id, challenge):    """    调用极验 load 接口,获取验证码配置    返回 JSONP 格式的响应,解析后得到 lot_number、图片路径、PoW参数等    """    callback = generate_callback()        # 生成 geetest_{timestamp} 格式的回调名    params = {        'callback': callback,             # JSONP 回调函数名        'captcha_id': captcha_id,         # 验证码实例ID        'challenge': challenge,           # 本次挑战令牌        'client_type''web',             # 客户端类型        'risk_type''slide',             # 风险类型:滑块        'lang''zh',                     # 语言:中文    }    resp = session.get(        f"https://{API_SERVER}/load",        params=params,        headers=HEADERS,        timeout=15    )    return parse_jsonp(resp.text)          # 解析 JSONP 包装,返回纯 JSON 对象

10.3 load 响应解析

load 接口返回的关键字段:

{  "status": "success",  "data": {    "lot_number": "72ce10e53aea4ac6b0b3217285a2a503",  // 批次号,后续所有请求都要带上    "payload": "eyJ...(base64加密的服务端数据)...",       // 不透明数据,原样回传给verify    "process_token": "d3a1b2c3...",                     // 会话令牌,原样回传    "pt": "1",                                          // 加密方式: "1" = RSA+AES    "payload_protocol": 1,                              // payload协议版本    "captcha_type": "slide",                            // 验证码类型    "bg": "pictures/gt/..../bg.png",                    // 背景图路径(含缺口)    "slice": "pictures/gt/..../slice.png",              // 滑块碎片图路径    "ypos": 78,                                         // 滑块碎片的Y坐标    "guard": true,                                      // 是否启用 geeGuard 指纹    "check_device": false,                              // 是否检查设备指纹    "pow_detail": {                                     // PoW 工作量证明参数      "version": "1",                                   // PoW 版本      "bits": 0,                                        // 难度位数(0=不需要PoW)      "hashfunc": "md5",                                // 哈希算法      "datetime": "2026-04-28T12:00:00+08:00",         // 服务端时间戳      "nonce": ""                                       // nonce前缀(通常为空)    }  }}

Python 中的解析

# 从 load 响应中提取所有需要的参数data = load_resp['data']lot_number = data['lot_number']             # 批次号payload = data['payload']                   # 不透明数据,原样回传process_token = data['process_token']       # 会话令牌pt = str(data.get('pt''1'))              # 加密方式payload_protocol = data.get('payload_protocol'1)pow_detail = data.get('pow_detail', {})     # PoW 参数bg_path = data.get('bg''')               # 背景图相对路径slice_path = data.get('slice''')         # 滑块图相对路径ypos = data.get('ypos'0)                 # 滑块Y坐标(本实现未使用)

10.4 图片下载与尺寸获取

背景图和滑块图存放在极验的 CDN 上,路径由 load 响应提供。

注意:背景图的原始宽度bg_w)是后续缩放计算的关键参数。

def get_image_size(png_data):    """    从 PNG 文件的二进制数据中提取图片尺寸    PNG 文件格式:      - 字节 0-7: PNG 签名 (89 50 4E 47 ...)      - 字节 8-15: IHDR chunk length + type      - 字节 16-19: 宽度(大端序 uint32)      - 字节 20-23: 高度(大端序 uint32)    """    if png_data[:4] == b'\x89PNG':                       # 检查 PNG 魔数        w = struct.unpack('>I', png_data[16:20])[0]      # 大端序读取宽度        h = struct.unpack('>I', png_data[20:24])[0]      # 大端序读取高度        return w, h    return 300200  # 非PNG格式的备用默认值# === 下载图片 ===bg_url = f"{STATIC_SERVER}/{bg_path}"           # 拼接完整的背景图URLbg_resp = session.get(bg_url, timeout=15)       # 下载背景图bg_w, bg_h = get_image_size(bg_resp.content)    # 提取原始尺寸,如 344x212if slice_path:    slice_url = f"{STATIC_SERVER}/{slice_path}" # 拼接滑块碎片图URL    slice_resp = session.get(slice_url, timeout=15)    sl_w, sl_h = get_image_size(slice_resp.content)

背景图的典型尺寸为 344×212 像素(原图),而网页上的显示尺寸会根据容器宽度进行缩放,这个缩放关系在下一节详细分析。


十一、缺口识别与坐标换算

11.1 缺口位置识别(OCR)

背景图中包含一个缺口(凹槽),需要识别缺口的 X 坐标。本实现使用第三方 OCR 平台进行识别。

Python 实现 (gap_detector.py):

import base64import jsonimport requestsdef detect_gap(image_path=None, image_bytes=None, question_type='框出正确位置'):    """    识别滑块验证码背景图中的缺口位置    :param image_path:     图片文件路径(与 image_bytes 二选一)    :param image_bytes:    图片二进制数据    :param question_type:  识别问题类型,告诉OCR模型要找什么    :return:               缺口左边缘的X坐标(像素,原图坐标系)    """    if image_bytes is None:        if image_path is None:            raise ValueError("必须提供 image_path 或 image_bytes")        with open(image_path, 'rb'as f:            image_bytes = f.read()    # 构建 OCR 平台请求    payload = {        'base64Image': base64.b64encode(image_bytes).decode('utf-8'),  # 图片转base64        'modelName''普通模型',     # 使用的识别模型        'keyCode''vRkfuMQl',      # API 鉴权密钥        'question': question_type,   # 识别任务描述        'system'''    }    # 调用 OCR API    response = requests.post(        'http://gpu1.xinyuocr.xyz:8889/api/qrcode/predict',        json=payload,        timeout=30    )    result = response.json()    if result.get('err'):        raise RuntimeError(f"OCR识别错误: {result['err']}")    # 解析响应:提取缺口区域的左上角X坐标    # 返回格式示例:    #   result['msg'] = '[{"label":"1","box":[[223.0,115.0,296.0,187.0]]}]'    # 其中 box 是 [[x1,y1,x2,y2]],(x1,y1)=左上角,(x2,y2)=右下角    json_obj = json.loads(result['msg'])    gap_x = json_obj[0]['box'][0][0]       # 取第一个检测结果的左边缘X坐标    return gap_x                            # 返回值是原图坐标系中的像素值

注意:OCR 返回的坐标是在原图(如 344×212 像素)上的坐标,不是网页显示尺寸上的坐标。后续需要进行缩放换算。

11.2 缩放比计算(核心公式)

极验会把原图缩放后显示在网页上。缩放比的计算公式隐藏在 JS 源码中。

JS 源码gcaptcha4_deobfuscated.js 行 7083-7086):

// 行 7083: 获取容器宽度(通常为 340px)_v7624 = parseInt(_v7627.width || _v7627.nextWidth || this.$_BGC_, 10);// 行 7084: 如果使用 rem 适配,计算实际显示宽度上限var o = _v7627.rem ? 340 * _v7627.rem : 340;o < _v7624 && (_v7624 = o);        // 如果容器比 340*rem 还大,则限制为 340*rem// 行 7086: ★★★ 核心缩放公式 ★★★var a = this.$_BHEH = .8876 * _v7624 / _v7628.wrap_w;// 含义://   _v7624     = 容器显示宽度(通常 340px)//   _v7628.wrap_w = 背景图原始宽度(通常 344px)//   0.8876     = 极验的固定缩放系数(可能是因为图片实际显示区域占容器的88.76%)//   a ($BHEH)  = 最终缩放比,后续所有坐标转换都用它

wrap_w 的来源(行 7169-7185):

// setImgs 函数中:wrap_w 从背景图的原始宽度获取// _v7688[0].$_CFy.width 就是 <img> 元素的 naturalWidth// 也就是背景图的原始像素宽度(如 344)

Python 实现

CONTAINER_WIDTH = 340                          # 极验验证码容器的固定宽度(像素)# 计算缩放比:与 JS 源码行 7086 完全对应# scale = 0.8876 * 容器宽度 / 原图宽度scale = 0.8876 * CONTAINER_WIDTH / bg_w        # 例: 0.8876 * 340 / 344 = 0.877488...

11.3 滑块初始偏移(SLIDER_OFFSET)

关键发现:滑块小按钮(小方块)并不是从背景图的最左边开始的。在网页上观察可知,滑块的初始位置大约在 X=15px 处。

这意味着 OCR 识别到的缺口 X 坐标(比如 143px),实际滑动距离并不是 143px,而是 143-15=128px(需要减去滑块的初始偏移量)。

背景图(原图坐标系):┌─────────────────────────────────────────────┐│                    ┌──┐                      ││                    │缺│← gap_x = 143px       ││                    │口│                      ││                    └──┘                      │├─────────────────────────────────────────────┤│ ┌──┐                                        ││ │滑│← 初始位置约 15px                        ││ │块│                                        ││ └──┘                                        ││ ◄── 实际滑动距离 = 143 - 15 = 128px ──────► │└─────────────────────────────────────────────┘

Python 实现

SLIDER_OFFSET = 15                             # 滑块初始偏移量(像素,原图坐标系)# 完整的坐标换算流程gap_x = detect_gap(image_bytes=bg_resp.content)               # OCR识别缺口X(原图),如143adjusted_gap_x = gap_x - SLIDER_OFFSET                        # 减去滑块偏移,如128slide_distance = adjusted_gap_x * scale                        # 转为显示坐标系,如128.76setLeft = int(slide_distance)                                  # 取整,如128userresponse = setLeft / scale + 2                             # 转回原图坐标+2,如129.24

11.4 setLeft 和 userresponse 的含义

这两个参数的计算逻辑来自 JS 源码的 mouseup 事件处理函数。

JS 源码gcaptcha4_deobfuscated.js 行 7131-7136):

// mouseup 事件处理函数 $_BGIM:// _v7662 = 鼠标释放时的X偏移像素(显示坐标系)var _v7665 = parseInt(_v7662, 10);     // setLeft: 取整的显示坐标位移var _v7666 = {    setLeft: _v7665,                     // 显示坐标系的滑动像素数(整数)    passtime: _v7663,                    // 从mousedown到mouseup的总耗时(毫秒)    userresponse: _v7665 / _v7659.$_BHEH + 2    //             ↑                ↑       ↑    //           setLeft          scale   固定偏移+2    // 含义:把显示坐标转回原图坐标系,再加上2    // 为什么+2?可能是服务端验证时的容差补偿};

关键

  • setLeft
     = 显示坐标系的滑动距离(像素),取整
  • userresponse
     = 原图坐标系的滑动距离 + 2
  • passtime
     = 操作总耗时(毫秒)

11.5 完整换算流程示例

假设:原图宽度 344px,容器宽度 340px,OCR 缺口位置 143px

Step 1: 计算缩放比    scale = 0.8876 × 340 / 344 = 0.877488Step 2: 减去滑块偏移    adjusted_gap_x = 143 - 15 = 128Step 3: 转为显示坐标    slide_distance = 128 × 0.877488 = 112.32    setLeft = int(112.32) = 112Step 4: 计算 userresponse    userresponse = 112 / 0.877488 + 2 = 129.66Step 5: 轨迹目标距离    轨迹生成的目标距离 = int(slide_distance) = 112 像素(显示坐标系)

十二、轨迹生成与编码

12.1 浏览器端的轨迹记录机制

在浏览器中,极验通过 mousedown/mousemove/mouseup 三个事件来记录用户的滑动轨迹。

JS 源码分析gcaptcha4_deobfuscated.js 行 7097-7130):

// ===== mousedown 事件 (行 7097-7110) =====// 函数名:$_BGFT$_BGFTfunction (_v7634{    var _v7639 = this;    _v7639.$_BGAW = (0, _v7504.now)();   // 记录起始时间戳    // 获取背景图和按钮的位置信息    var _v7644 = _v7640(".bg_" + _v7641).$_EAh();     // 背景图的 getBoundingClientRect    var _v7645 = _v7640(".btn_" + _v7641).$_EAh();    // 按钮的 getBoundingClientRect    // ★ 创建轨迹记录器,初始点为 [负偏移X, 负偏移Y, 0]    // 这个初始点表示:鼠标按下位置相对于滑块按钮的偏移(原图坐标系)    _v7639.$_BHJf new _v7505.default([        Math.round((_v7643 - _v7639.$_BHGl) / _v7639.$_BHEH),   // (btnLeft - mouseX) / scale        Math.round((_v7642 - _v7639.$_BHIt) / _v7639.$_BHEH),   // (btnTop - mouseY) / scale        0                                                         // 时间=0(起始点)    ])    .$_JEJ([000]);    // ★ 紧接着追加一个 [0,0,0] 作为原点标记    // 初始偏移点通常是负数,如 [-25, 3, 0]    // 代表鼠标按下时的位置在按钮左上角更偏左偏下的位置},// ===== mousemove 事件 (行 7112-7118) =====// 函数名:$_BGHI$_BGHIfunction (_v7646{    var _v7652 = _v7646.$_CGf() - _v7651.$_BHGl;   // 当前X - 起始X = X位移    _v7651.$_BHJf.$_JEJ([        Math.round(_v7652 / _v7651.$_BHEH),           // X位移 / scale → 原图坐标        Math.round(_v7653 / _v7651.$_BHEH),           // Y位移 / scale → 原图坐标        (0, _v7504.now)() - _v7651.$_BGAW             // 相对于mousedown的时间差(ms)    ]);},// ===== mouseup 事件 (行 7120-7136) =====// 函数名:$_BGIM$_BGIMfunction (_v7654{    // 追加最后一个轨迹点    _v7659.$_BHJf.$_JEJ([        Math.round(_v7662 / _v7659.$_BHEH),           // 最终X位移 / scale        Math.round(_v7664 / _v7659.$_BHEH),           // 最终Y位移 / scale        _v7659.passtime                                 // 总耗时    ]);    // 构建验证参数    var _v7665 = parseInt(_v7662, 10);                  // setLeft = 显示坐标取整    var _v7666 = {        setLeft: _v7665,        passtime: _v7663,        userresponse: _v7665 / _v7659.$_BHEH 2       // 原图坐标 + 2    };}

轨迹数据结构

[  [-2530],         ← 初始偏移点:鼠标按下位置相对于按钮的偏移(原图坐标)  [0, 0, 0],           ← 原点标记  [5, 0, 32],          ← 第1个移动点: X移动5px, Y不动, 经过32ms  [12, 1, 48],         ← 第2个移动点  [25, -1, 65],        ← ...  ...  [128, 2, 1450],      ← 最终位置(原图坐标)  [128, 2, 1507],      ← mouseup 时的最终确认点]

12.2 拟人化轨迹生成(Python 实现)

为了模拟真人操作,我们实现了三段式速度模型 + 过冲回弹 + Y轴随机抖动。

完整实现 (trajectory.py):

import randomimport mathdef generate_trajectory(distance, scale=1.0):    """    生成拟人化的滑块轨迹    模拟真实人手的运动特征:    1. 三段式速度模型:加速→匀速→减速    2. 过冲与回弹:滑过目标后回退(手指惯性)    3. Y轴随机抖动:真人的手不可能完全水平滑动    4. 不均匀时间间隔:每一步的时间间隔随机波动    :param distance: 需要滑动的像素距离(显示坐标系)    :param scale:    缩放比(未使用,保留参数)    :return:         list of [x, y, timestamp],绝对坐标轨迹    """    if distance <= 0:        return [[000]]    tracks = []    current_x = 0.0    current_y = 0.0    current_t = 0    # === 过冲设计 ===    # 真人操作时,手指惯性会导致滑过目标2~5像素,然后回退    overshoot = random.uniform(25)    target = distance + overshoot              # 实际要滑到的位置(含过冲)    # === 三段式速度模型的分界点 ===    phase1_end = target * 0.4                  # 加速段:0% ~ 40%    phase2_end = target * 0.7                  # 匀速段:40% ~ 70%    # 减速段:70% ~ 100%    tracks.append([000])                    # 起始点    while current_x < target:        remaining = target - current_x        if current_x < phase1_end:            # === 加速段(0~40%)===            # 速度从慢到快,模拟手指从静止开始加速            progress = current_x / phase1_end if phase1_end > 0 else 1            base_step = 1 + progress * 4        # 步长从1逐渐增大到5        elif current_x < phase2_end:            # === 匀速段(40%~70%)===            # 保持中等速度            base_step = random.uniform(36)    # 随机步长3~6        else:            # === 减速段(70%~100%)===            # 速度逐渐降低,模拟手指精确定位            progress = (current_x - phase2_end) / (target - phase2_end) \                       if (target - phase2_end) > 0 else 1            base_step = max(0.54 * (1 - progress))   # 步长从4逐渐减小到0.5        # 添加微小随机扰动,避免步长完全规律        step_x = base_step + random.uniform(-0.50.5)        step_x = min(step_x, remaining)         # 不超过剩余距离        step_x = max(0.3, step_x)               # 最小步长0.3,避免原地不动        # === Y轴抖动 ===        # 真人的手不可能完全水平移动,每步有±2px的上下抖动        step_y = random.uniform(-22)        current_y += step_y        current_y = max(-5min(5, current_y))   # 限制Y轴偏移在±5px以内        current_x += step_x        # === 不均匀的时间间隔 ===        # 加速段:间隔较长(手指刚开始动,反应时间)        # 匀速段:间隔较短(手指快速滑动)        # 减速段:间隔较长(手指精确微调)        if current_x < phase1_end:            dt = random.randint(1225)          # 加速段: 12~25ms        elif current_x < phase2_end:            dt = random.randint(818)           # 匀速段: 8~18ms        else:            dt = random.randint(1535)          # 减速段: 15~35ms        current_t += dt        tracks.append([round(current_x), round(current_y), current_t])    # === 回弹模拟 ===    # 滑过目标后,手指会回退2~4次    for i in range(random.randint(24)):        current_x -= random.uniform(0.52)      # 每次回退0.5~2px        current_y += random.uniform(-11)        current_t += random.randint(2040)        tracks.append([round(current_x), round(current_y), current_t])    # === 精确定位 ===    # 最终确保停在精确的目标位置    current_t += random.randint(3060)    tracks.append([round(distance), round(current_y), current_t])    # === 松手前的短暂停顿 ===    # 真人在松手前会有一个微小的停顿(确认位置正确)    current_t += random.randint(50150)    tracks.append([round(distance), round(current_y), current_t])    return tracksdef get_passtime(tracks):    """从轨迹中提取总耗时(最后一个点的时间戳)"""    return tracks[-1][2if tracks else 0

12.3 轨迹坐标系的转换

在 Python 主控脚本中,轨迹生成器输出的是显示坐标系的轨迹,而 JS 源码中记录的轨迹是原图坐标系的。需要做坐标转换。

# 生成显示坐标系的轨迹track_display = generate_trajectory(int(slide_distance))   # 参数是显示坐标的距离passtime = get_passtime(track_display)                     # 总耗时(ms)# 转换为原图坐标系(除以 scale)# 与 JS 源码行 7118 中 Math.round(_v7652 / _v7651.$_BHEH) 对应track = [[round(p[0] / scale), round(p[1] / scale), p[2]] for p in track_display]# 插入初始偏移点(模拟鼠标按下位置的随机偏移)# 与 JS 源码行 7107 中的初始点对应initial_offset = [random.randint(-30, -10), random.randint(-10, 10), 0]track.insert(0, initial_offset)# 最终轨迹格式: [[-25, 3, 0], [0, 0, 0], [5, 0, 32], [12, 1, 48], ...]

12.4 轨迹编码(差分压缩 + 自定义编码)

轨迹数据不会以原始 JSON 数组传递给服务端,而是经过一套自定义的差分压缩 + 编码算法,最终变成一个紧凑的字符串。

12.4.1 差分压缩 $_BACB

JS 源码(行 1743-1745):

// 差分压缩:把绝对坐标轨迹转为增量 [dx, dy, dt]// 输入: [[0,0,0], [5,0,32], [12,1,48], [25,-1,65], ...]// 输出: [[5,0,32], [7,1,16], [13,-2,17], ...]$_BACBfunction (_v1754) {    for (var t, s, n, i = [], r = 0, o = 0, a = _v1754.length - 1; o < a; o += 1)        t = Math.round(_v1754[o + 1][0] - _v1754[o][0]),    // dx = 下一点X - 当前点X        s = Math.round(_v1754[o + 1][1] - _v1754[o][1]),    // dy = 下一点Y - 当前点Y        n = Math.round(_v1754[o + 1][2] - _v1754[o][2]),    // dt = 下一点T - 当前点T        // 过滤掉完全没有变化的点 (dx=0, dy=0, dt=0)        0 === t && 0 === s && 0 === n || (            0 === t && 0 === s            ? r += n                      // 如果只有时间变化,累积到下一个有效点            : (i.push([t, s, n + r]),     // 有位移变化时,输出 [dx, dy, dt+累积时间]               r = 0)                     // 重置累积时间        );    return 0 !== r && i.push([t, s, r]),  // 尾部累积的时间单独输出           i;}

Node.js 实现

// 差分压缩: 绝对坐标 -> [dx, dy, dt] 增量function trackDiff(trackPoints) {    var i = [], r = 0;                            // i=结果数组, r=累积时间    for (var o = 0, a = trackPoints.length - 1; o < a; o += 1) {        var t = Math.round(trackPoints[o + 1][0] - trackPoints[o][0]);   // dx        var s = Math.round(trackPoints[o + 1][1] - trackPoints[o][1]);   // dy        var n = Math.round(trackPoints[o + 1][2] - trackPoints[o][2]);   // dt        if (!(0 === t && 0 === s && 0 === n)) {   // 过滤完全无变化的点            if (0 === t && 0 === s) {                r += n;                            // 仅时间变化,累积            } else {                i.push([t, s, n + r]);             // 有位移变化,输出并带上累积时间                r = 0;            }        }    }    if (0 !== r) i.push([t, s, r]);                // 尾部累积时间    return i;}

12.4.2 方向编码 encodeDirection

对于常见的运动方向,使用单个字符代替 [dx, dy] 两个数值。

JS 源码(行 1748-1750):

// 方向映射表:9种常见运动方向 → 单字符function i(_v1763) {    var t = [        [1, 0],     // 右移1        → 's'        [2, 0],     // 右移2        → 't'        [1, -1],    // 右移1+下移1  → 'u'        [1, 1],     // 右移1+上移1  → 'v'        [0, 1],     // 上移1        → 'w'        [0, -1],    // 下移1        → 'x'        [3, 0],     // 右移3        → 'y'        [2, -1],    // 右移2+下移1  → 'z'        [2, 1]      // 右移2+上移1  → '~'    ];    var chars = "stuvwxyz~";    for (var s = 0, n = t.length; s < n; s += 1)        if (_v1763[0] === t[s][0] && _v1763[1] === t[s][1])            return chars[s];              // 匹配到预定义方向,返回对应字符    return 0;                             // 不在预定义方向中,返回0表示需要数值编码}

Node.js 实现

// 编码单个轨迹点的运动方向// 如果运动方向在9种预定义方向中,返回对应的单字符// 否则返回0,需要用数值编码function encodeDirection(point) {    var dirs = [[10], [20], [1, -1], [11], [01],                [0, -1], [30], [2, -1], [21]];    var chars = "stuvwxyz~";    for (var s = 0; s < dirs.length; s++) {        if (point[0] === dirs[s][0] && point[1] === dirs[s][1]) return chars[s];    }    return 0;}

12.4.3 数值编码 encodeValue

对于不能用方向字符表示的数值(dx、dy、dt),使用自定义的 Base64 变种编码。

JS 源码(行 1752-1760):

function a(_v1765) {    // 自定义字符集(64个字符)    var t = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr",        s = t.length,         // 64        n = "",        i = Math.abs(_v1765), // 取绝对值        r = parseInt(i / s, 10);  // 高位 = 值 / 64    s <= r && (r = s - 1);    // 如果高位超过64,截断为63(最大编码范围 64*64-1 = 4095)    r && (n = t.charAt(r));   // 高位不为0时,输出高位字符    var o = "";    return _v1765 < 0 && (o += "!"),   // 负数前缀 "!"           n && (o += "$"),             // 有高位时前缀 "$"           o + n + t.charAt(i %= s);   // 拼接:[!][$][高位字符][低位字符]}

编码规则总结

编码
说明
0 (
charset[0]
5 -
charset[5]
65 $)( $

 + charset[1] + charset[0](高位=1, 低位=1)
-3 !+ !

 + charset[3]
-70 !$)(
负数+有高位

Node.js 实现

// 编码单个数值(dx/dy/dt)// 使用自定义64字符集的变长编码function encodeValue(val) {    var charset = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr";    var base = charset.length;                     // 64    var n = "";    var i = Math.abs(val);                         // 取绝对值    var r = parseInt(i / base10);                // 计算高位    if (base <= r) r = base - 1;                   // 防溢出    if (r) n = charset.charAt(r);                  // 高位字符    var o = "";    if (val < 0) o += "!";                         // 负数标记    if (n) o += "$";                               // 多位标记    return o + n + charset.charAt(i % base);       // 拼接最终编码}

12.4.4 完整编码 $_BADe

JS 源码(行 1762-1770):

// 差分压缩后,把轨迹编码为三段式字符串$_BADefunction () {    var s = this.$_BACB(this.$_JBb);         // 差分压缩    var n = [], r = [], o = [];               // X部分、Y部分、T部分    forEach(s, function (_v1767) {        var dirChar = i(_v1767);              // 尝试方向编码        if (dirChar) {            r.push(dirChar);                  // 方向编码只影响 Y 部分        } else {            n.push(a(_v1767[0]));            // X部分:编码 dx            r.push(a(_v1767[1]));            // Y部分:编码 dy        }        o.push(a(_v1767[2]));                // T部分:始终编码 dt    });    // ★ 三段用 "!!" 分隔    return n.join("") + "!!" + r.join("") + "!!" + o.join("");}

输出格式示例

xDeltas!!yDeltas!!tDeltas例: 0-1$)3!!stuv(!x0!!(/1,0-,+
  • !!
     分隔三段:X增量编码 | Y增量编码(含方向字符) | 时间增量编码
  • 方向字符(s,t,u,v,w,x,y,z,~)出现在 Y 段中,此时 X 段没有对应元素

Node.js 完整编码实现

// 完整的轨迹编码:差分压缩 → 分类编码 → 三段拼接function encodeTrack(trackPoints) {    var diffed = trackDiff(trackPoints);       // Step1: 差分压缩    var xParts = [];                           // X增量编码部分    var yParts = [];                           // Y增量编码部分(含方向字符)    var tParts = [];                           // 时间增量编码部分    for (var idx = 0; idx < diffed.length; idx++) {        var point = diffed[idx];        var dirChar = encodeDirection(point);   // Step2: 尝试方向编码        if (dirChar) {            // 如果匹配预定义方向,Y部分用单字符表示,X部分不输出            yParts.push(dirChar);        } else {            // 否则,X和Y都用数值编码            xParts.push(encodeValue(point[0]));            yParts.push(encodeValue(point[1]));        }        // T部分始终用数值编码        tParts.push(encodeValue(point[2]));    }    // 三段用 "!!" 连接    return xParts.join("") + "!!" + yParts.join("") + "!!" + tParts.join("");}

十三、PoW 工作量证明

13.1 PoW 机制概述

极验 v4 在 load 响应中会返回 pow_detail,要求客户端在提交 verify 前完成一个工作量证明(Proof of Work)。目的是增加自动化攻击的计算成本。

PoW 的基本原理:客户端需要找到一个随机 nonce,使得特定格式消息的哈希值前 N 位为零。

13.2 PoW 消息格式

JS 源码gcaptcha4_deobfuscated.js 行 2456-2501):

// PoW 求解函数// 参数含义://   _v2536 = captcha_id     验证码实例ID//   _v2537 = lot_number     批次号//   _v2538 = hashfunc       哈希算法名("md5"/"sha1"/"sha256")//   _v2539 = version        版本号("1")//   _v2540 = bits           难度位数//   _v2541 = datetime       服务端时间戳//   _v2542 = nonce_prefix   nonce前缀(通常为空)function n(_v2536, _v2537, _v2538, _v2539, _v2540, _v2541, _v2542) {    var a = _v2540 % 4,                              // 余数位(用于非4整除的难度判定)        _ = parseInt(_v2540 / 410),                 // 需要前导零的hex字符数        u = "0".repeat(_),                            // 构建前导零前缀字符串    // ★ 消息格式(注意字段顺序!):    // "version|bits|hashfunc|datetime|lot_number|captcha_id|nonce_prefix|"        c = _v2539 + "|" + _v2540 + "|" + _v2538 + "|" +            _v2541 + "|" + _v2537 + "|" + _v2536 + "|" + _v2542 + "|";    // 暴力搜索循环    while (1) {        var h = guid(),                               // 生成16位随机hex作为nonce            p = c + h,                                // 消息 = 前缀 + nonce            l = undefined;        // 根据指定的哈希算法计算摘要        switch (_v2538) {            case "md5":                l = new _v2528.default.MD5().hex(p);                break;            case "sha1":                l = new _v2528.default.SHA1().hex(p);                break;            case "sha256":                l = new _v2528.default.SHA256().hex(p);                break;        }        // 难度判定        if (0 == a) {            // bits 是4的整数倍:直接检查前导零            if (0 === l.indexOf(u)) return {                pow_msg: c + h,                       // ★ 返回的pow_msg包含完整消息(含nonce)                pow_sign: l                           // 哈希值            };        } else if (0 === l.indexOf(u)) {            // bits 不是4的整数倍:检查前导零后,还需检查下一个字符            var f = undefined,                d = l[_];                             // 零前缀后的第一个字符            // 根据余数位确定阈值            switch (a) {                case 1: f = 7break;     // 1位:下一个字符 ≤ '7' (二进制首位为0)                case 2: f = 3break;     // 2位:下一个字符 ≤ '3' (二进制前两位为0)                case 3: f = 1break;     // 3位:下一个字符 ≤ '1' (二进制前三位为0)            }            if (d <= f) return {                pow_msg: c + h,                pow_sign: l            };        }    }}

13.3 Python PoW 求解实现

完整实现 (pow_solver.py):

import hashlibimport randomdef generate_guid():    """    生成16位随机hex字符串    与JS中的guid()一致:4次生成4位hex并拼接    """    return ''.join(random.choices('0123456789abcdef', k=16))def solve_pow(pow_config):    """    解决极验的PoW工作量证明    :param pow_config: dict 包含以下字段:        - version:    PoW版本号(通常为"1")        - bits:       难度位数(0=无难度要求,即任意nonce都满足)        - hashfunc:   哈希算法名("md5"/"sha1"/"sha256")        - datetime:   服务端返回的时间戳字符串        - lot_number: 批次号        - captcha_id: 验证码实例ID        - nonce:      nonce前缀(通常为空字符串)    :return: dict {pow_msg: str, pow_sign: str}    消息格式: "version|bits|hashfunc|datetime|lot_number|captcha_id|nonce_prefix|nonce"    """    version = pow_config.get('version''1')    bits = pow_config.get('bits'0)    hashfunc = pow_config.get('hashfunc''md5')    datetime_str = pow_config.get('datetime''')    lot_number = pow_config.get('lot_number''')    captcha_id = pow_config.get('captcha_id''')    nonce_prefix = pow_config.get('nonce''')    # === 难度参数计算 ===    full_bits = bits % 4              # 余数位(0~3)    prefix_len = int(bits / 4)        # 需要的前导零hex字符数    prefix = '0' * prefix_len         # 前导零字符串    # === 暴力搜索循环 ===    while True:        nonce = generate_guid()       # 生成随机16位hex nonce        # 构建完整的 PoW 消息        # 格式严格对应 JS 源码行 2462        pow_msg = f"{version}|{bits}|{hashfunc}|{datetime_str}|" \                  f"{lot_number}|{captcha_id}|{nonce_prefix}|{nonce}"        # 计算哈希        if hashfunc == 'md5':            pow_sign = hashlib.md5(pow_msg.encode()).hexdigest()        elif hashfunc == 'sha1':            pow_sign = hashlib.sha1(pow_msg.encode()).hexdigest()        elif hashfunc == 'sha256':            pow_sign = hashlib.sha256(pow_msg.encode()).hexdigest()        else:            pow_sign = hashlib.md5(pow_msg.encode()).hexdigest()        # === 难度判定 ===        if full_bits == 0:            # bits 是4的整数倍(包括 bits=0 的情况)            # bits=0 时 prefix="" ,任何哈希都满足            if pow_sign.startswith(prefix):                return {'pow_msg': pow_msg, 'pow_sign': pow_sign}        else:            # bits 不是4的整数倍            if pow_sign.startswith(prefix):                next_char = pow_sign[prefix_len]      # 前导零后的第一个hex字符                # 阈值映射(与JS源码行 2486-2493 对应)                threshold_map = {1'7'2'3'3'1'}                threshold = threshold_map.get(full_bits, 'f')                if next_char <= threshold:             # hex字符比较                    return {'pow_msg': pow_msg, 'pow_sign': pow_sign}

13.4 PoW 在主流程中的调用

# === 在 geetest_solver.py 中 ===# 从 load 响应中提取 PoW 参数pow_config = {    'version': pow_detail.get('version''1'),    'bits': pow_detail.get('bits'0),          # 典型值: 0(无难度要求)    'hashfunc': pow_detail.get('hashfunc''md5'),    'datetime': pow_detail.get('datetime'''),    'lot_number': lot_number,                    # load 响应中的批次号    'captcha_id': captcha_id,                    # 页面配置的验证码ID    'nonce': pow_detail.get('nonce'''),        # 通常为空}pow_result = solve_pow(pow_config)# pow_result 包含:#   pow_msg:  "1|0|md5|2026-04-28T12:00:00+08:00|72ce...|24f5...||abc123def456"#   pow_sign: "d41d8cd98f00b204e9800998ecf8427e"

注意:当 bits=0 时(目前观察到的典型配置),PoW 实际上没有难度要求,第一次随机 nonce 就能通过。但代码必须正确实现完整的 PoW 逻辑,因为服务端可能随时调高难度。


十四、w 参数加密体系

14.1 加密分发机制

w 参数是整个验证流程中最核心的加密参数。根据 load 响应中的 pt 字段,选择不同的加密方案。

JS 源码gcaptcha4_deobfuscated.js 行 3751-3778):

// w 参数加密入口函数// _v3885 = payload JSON 字符串(明文)// _v3886.options = 配置对象,包含 pt 等参数functioni(_v3885, _v3886{    var s = _v3886.options;    // ===== pt="0": 无加密,直接 Base64 URL-safe 编码 =====    if(!s.pt || "0" === s.pt)        return _v3876.default.urlsafe_encode(_v3885);    // 生成16位随机hex密钥    var n = (0, _v3881.guid)();    // 加密方案映射表    var r = {        1: {            symmetrical: _v3877.default,   // AES-128-CBC(对称加密)            asymmetric: new _v3878.default() // RSA-1024(非对称加密)        },        2: {            symmetrical: new _v3879.default({  // SM4-CBC(国密对称加密)                key: n, mode: "cbc", iv: "0000000000000000"            }),            asymmetric: _v3880.default     // SM2(国密非对称加密)        }    };    if("1" === s.pt || "2" === s.pt) {        var o = "1" === s.pt;        var a = s.pt;        // 用非对称算法加密随机密钥        var _ = r[a].asymmetric.encrypt(n);        // 对于 RSA (pt=1),密文必须是256字符(128字节hex)        // 如果长度不对就重新生成密钥(极少发生)        while(o && (!_ || 256 !== _.length))            n = (0, _v3881.guid)(),            _ = new _v3878.default().encrypt(n);        // 用对称算法加密 payload        var u = r[a].symmetrical.encrypt(_v3885, n);        // ★ 最终格式: arrayToHex(对称密文) + 非对称密文        return(0, _v3881.arrayToHex)(u) + _;    }}

加密方案总结

| pt 值 | 对称加密 | 非对称加密 | w 格式 ||-------|---------|-----------|--------|`"0"` | 无 | 无 | `Base64_urlsafe(payload)` |`"1"` | AES-128-CBC | RSA-1024 | `hex(AES(payload, key))` + `RSA(key)` |`"2"` | SM4-CBC | SM2 | `hex(SM4(payload, key))` + `SM2(key)` |

核心思想:混合加密(Hybrid Encryption)—— 对称加密处理大数据,非对称加密保护对称密钥。

14.2 arrayToHex — 字节数组转 Hex 字符串

JS 源码(行 306-312):

// 将字节数组转换为十六进制字符串// 输入: [72, 101, 108, 108, 111]// 输出: "48656c6c6f"_v80.arrayToHex = function _v168(_v167) {    // 第一步:将十进制字节值打包到32位整数数组中(每4字节一个int)    for (var t = [], s = 0, n = 0; n < 2 * _v167.length; n += 2)        t[n >>> 3] |= parseInt(_v167[s], 10) << 24 - n % 8 * 4,        s++;    // 第二步:从32位整数中逐字节提取,转为两位hex    for (var i = [], r = 0; r < _v167.length; r++) {        var o = t[r >>> 2] >>> 24 - r % 4 * 8 & 255;   // 提取单字节        i.push((o >>> 4).toString(16));                  // 高4位转hex        i.push((15 & o).toString(16));                   // 低4位转hex    }    return i.join("");};

Node.js 实现

function arrayToHex(arr) {    var t = [], s = 0;    for (var n = 0; n < 2 * arr.length; n += 2) {        t[n >>> 3] |= parseInt(arr[s], 10) << 24 - n % 8 * 4;        s++;    }    var i = [];    for (var r = 0; r < arr.length; r++) {        var o = t[r >>> 2] >>> 24 - r % 4 * 8 & 255;        i.push((o >>> 4).toString(16));        i.push((15 & o).toString(16));    }    return i.join("");}

14.3 Base64 编码(pt=”0″ 时使用)

JS 源码(行 3780-3879):

// 标准 Base64 字符集var CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";// URL-safe Base64 字符集(将 +/ 替换为 -_)var URLSAFE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

Node.js 实现

var Base64 = (function() {    var CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";    var URLSAFE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";    // 字符串转 UTF-8 字节数组    function strToBytes(str) {        var bytes = [];        for (var i = 0; i < str.length; i++) {            var code = str.charCodeAt(i);            if (code < 128) {                bytes.push(code);                          // ASCII: 单字节            } else if (code < 2048) {                bytes.push(192 | code >> 6);               // 2字节 UTF-8                bytes.push(128 | 63 & code);            } else {                bytes.push(224 | code >> 12);              // 3字节 UTF-8                bytes.push(128 | (code >> 6 & 63));                bytes.push(128 | 63 & code);            }        }        return bytes;    }    function encode(str, urlsafe) {        var chars = urlsafe ? URLSAFE_CHARS : CHARS;        var bytes = strToBytes(str);        var result = [];        // 每3字节编码为4个 Base64 字符        for (var i = 0; i < bytes.length; i += 3) {            var b1 = bytes[i];            var b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;            var b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;            result.push(chars.charAt(b1 >> 2));                             // 取b1高6位            result.push(chars.charAt((3 & b1) << 4 | b2 >> 4));           // b1低2位+b2高4位            result.push(i + 1 < bytes.length                ? chars.charAt((15 & b2) << 2 | b3 >> 6)                  // b2低4位+b3高2位                : (urlsafe ? "" : "="));                                    // 填充            result.push(i + 2 < bytes.length                ? chars.charAt(63 & b3)                                     // b3低6位                : (urlsafe ? "" : "="));                                    // 填充        }        return result.join("");    }    return {        encodefunction(str) { return encode(str, false); },        urlsafe_encodefunction(str) { return encode(str, true); }        // pt="0"时使用    };})();

14.4 AES-128-CBC(pt=”1″ 的对称加密)

JS 源码位置:行 3881-4148,是一个完整的 CryptoJS 风格 AES 实现。

关键参数

  • 算法:AES-128-CBC
  • 密钥:guid() 生成的 16 位 hex 字符串,以 Latin1 编码为 16 字节(= AES-128 密钥长度)
  • IV:固定字符串 "0000000000000000",以 Latin1 编码为 16 字节
  • 填充:PKCS7

Node.js 实现(使用 Node.js 内置 crypto 模块替代原始 CryptoJS 实现):

const nodeCrypto require('crypto');var AES = {    encrypt: function(plaintext, key{        // key 是 16 位 hex 字符串(如 "a3b1c2d4e5f67890")        // 以 Latin1 编码转为 16 字节 Buffer → AES-128 密钥        var keyBuf = Buffer.from(key, 'latin1');        // IV 固定为 "0000000000000000" 的 Latin1 编码        // 注意:这不是 16 字节的零,而是 ASCII '0'(0x30) 重复 16 次        var ivBuf = Buffer.from('0000000000000000''latin1');        var cipher = nodeCrypto.createCipheriv('aes-128-cbc', keyBuf, ivBuf);        // 加密并返回字节数组(不是 hex 字符串)        var encrypted = cipher.update(plaintext, 'utf8');        var final = cipher.final();        var combined = Buffer.concat([encrypted, final]);        return Array.from(combined);  // 转为普通数组,后续用 arrayToHex 转hex    }};

14.5 RSA-1024(pt=”1″ 的非对称加密)

JS 源码位置:行 4149-4510,包含一个完整的大数运算库(BigInteger)和 PKCS#1 v1.5 填充实现。

关键参数(硬编码在 JS 中):

  • 密钥长度:1024 位(128 字节)
  • 公钥 N(模数):00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C7977D02DC1D9451F79DD5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B59706592A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81
  • 公钥 E(指数):10001(= 65537,标准 RSA 公钥指数)
  • 填充:PKCS#1 v1.5(类型 2,随机填充)

PKCS#1 v1.5 填充格式

0x00 | 0x02 | [随机非零字节填充] | 0x00 | [明文消息]  1B    1B     至少8字节            1B     N字节

Node.js 实现

var RSA_N = "00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C7977D02DC1D9451F79DD5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B59706592A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81";var RSA_E = "10001";var RSA = {    encrypt: function(plaintext) {        var n = BigInt('0x' + RSA_N);        var e = BigInt('0x' + RSA_E);        // 密钥长度(字节):去掉前导00后的实际长度        var keyLen = (RSA_N.length - (RSA_N.startsWith('00') ? 2 : 0)) / 2;  // 128        var msgBytes = Buffer.from(plaintext, 'utf8');        if (msgBytes.length + 11 > keyLen) {            console.error("Message too long for RSA");            return null;        }        // === PKCS#1 v1.5 Type 2 填充 ===        var padded = Buffer.alloc(keyLen);       // 128 字节        padded[0] = 0;                           // 0x00        padded[1] = 2;                           // 0x02 (公钥加密)        // 随机非零字节填充        var paddingLen = keyLen - msgBytes.length - 3;        var randomBytes = nodeCrypto.randomBytes(paddingLen);        for (var i = 0; i < paddingLen; i++) {            while (randomBytes[i] === 0) {        // 确保非零                randomBytes[i] = nodeCrypto.randomBytes(1)[0];            }            padded[2 + i] = randomBytes[i];        }        padded[2 + paddingLen] = 0;               // 0x00 分隔符        msgBytes.copy(padded, 3 + paddingLen);     // 复制明文        // === 大数幂模运算:c = m^e mod n ===        var m = BigInt('0x' + padded.toString('hex'));        var c = modPow(m, e, n);        var hex = c.toString(16);        if (hex.length % 2 !== 0) hex = '0' + hex;  // 确保偶数长度        return hex;                                   // 返回 256 字符的 hex    }};// 快速幂模运算(平方-乘法 算法)function modPow(base, exp, mod) {    var result = 1n;    base = base % mod;    while (exp > 0n) {        if (exp % 2n === 1n) {            result = (result * base) % mod;       // 奇数指数:乘以基数        }        exp = exp / 2n;                           // 指数减半        base = (base * base) % mod;               // 基数平方    }    return result;}

14.6 SM4-CBC(pt=”2″ 的对称加密)

JS 源码位置:行 4514-4606,SM4 国密分组密码算法。

关键参数

  • 算法:SM4-CBC
  • 密钥:16 字节(guid() 生成的 16 位 hex,Latin1 编码)
  • IV:"0000000000000000"(16 字节)
  • 填充:PKCS7

SM4 算法结构

  • 分组长度:128 位(16 字节)
  • 密钥长度:128 位(16 字节)
  • 32 轮非线性迭代(Feistel 结构变种)
  • S-box:256 个字节的替换表
  • 线性变换 L:B ⊕ (B<<<2) ⊕ (B<<<10) ⊕ (B<<<18) ⊕ (B<<<24)

Node.js 实现(完整的 SM4 纯 JS 实现,不依赖第三方库):

// SM4 S-box 替换表(256字节)var SM4_SBOX = [    0xd60x900xe90xfe0xcc0xe10x3d0xb7/* ... 完整256字节 ... */];// 32个固定密钥(CK常数)var SM4_CK = [    0x00070e150x1c232a310x383f464d0x545b6269,    /* ... 完整32个 ... */];// 系统参数(FK常数)var SM4_FK = [0xa3b1bac60x56aa33500x677d91970xb27022dc];// 循环左移function sm4_rotl(x, n) { return ((x << n) | (x >>> (32 - n))) >>> 0; }// 非线性变换 τ:4字节 S-box 替换function sm4_tau(A) {    return (SM4_SBOX[(A >>> 24) & 0xff] << 24 |            SM4_SBOX[(A >>> 16) & 0xff] << 16 |            SM4_SBOX[(A >>> 8) & 0xff] << 8 |            SM4_SBOX[A & 0xff]) >>> 0;}// 线性变换 L(加密用)function sm4_L(B) {    return (B ^ sm4_rotl(B, 2) ^ sm4_rotl(B, 10) ^            sm4_rotl(B, 18) ^ sm4_rotl(B, 24)) >>> 0;}// 线性变换 L'(密钥扩展用)function sm4_L_prime(B) {    return (B ^ sm4_rotl(B, 13) ^ sm4_rotl(B, 23)) >>> 0;}// SM4 构造函数:执行密钥扩展function SM4(config) {    var keyBytes = strToBytes(config.key);    var ivBytes = config.iv ? strToBytes(config.iv) : new Array(16).fill(0);    this.key = keyBytes;    this.iv = ivBytes;    this.mode = config.mode || "cbc";    // 密钥扩展:生成32个轮密钥    this.encryptRoundKeys = new Array(32);    var MK = this.toUint32Block(this.key);    var K = [        MK[0] ^ SM4_FK[0], MK[1] ^ SM4_FK[1],        MK[2] ^ SM4_FK[2], MK[3] ^ SM4_FK[3]    ];    for (var i = 0; i < 32; i++) {        var tmp = K[i + 1] ^ K[i + 2] ^ K[i + 3] ^ SM4_CK[i];        tmp = sm4_tau(tmp);        tmp = sm4_L_prime(tmp);        K.push((K[i] ^ tmp) >>> 0);        this.encryptRoundKeys[i] = K[i + 4];    }}// 16字节 → 4个 uint32SM4.prototype.toUint32Block = function(bytes, offset) {    offset = offset || 0;    return [        (bytes[offset]<<24 | bytes[offset+1]<<16 | bytes[offset+2]<<8 | bytes[offset+3]) >>> 0,        (bytes[offset+4]<<24 | bytes[offset+5]<<16 | bytes[offset+6]<<8 | bytes[offset+7]) >>> 0,        (bytes[offset+8]<<24 | bytes[offset+9]<<16 | bytes[offset+10]<<8 | bytes[offset+11]) >>> 0,        (bytes[offset+12]<<24 | bytes[offset+13]<<16 | bytes[offset+14]<<8 | bytes[offset+15]) >>> 0    ];};// 单块加密(32轮迭代)SM4.prototype.doBlockCrypt = function(block, roundKeys) {    var X = block.slice();    for (var i = 0; i < 32; i++) {        var tmp = X[i+1] ^ X[i+2] ^ X[i+3] ^ roundKeys[i];        tmp = sm4_tau(tmp);        tmp = sm4_L(tmp);        X.push((X[i] ^ tmp) >>> 0);    }    return [X[35], X[34], X[33], X[32]];       // 反序输出};// PKCS7 填充SM4.prototype.padding = function(bytes) {    var padLen = 16 - bytes.length % 16;    var padded = bytes.slice();    for (var i = 0; i < padLen; i++) padded.push(padLen);    return padded;};// CBC 模式加密SM4.prototype.encrypt = function(plaintext) {    var bytes = strToBytes(plaintext);    var padded = this.padding(bytes);    var blockCount = padded.length / 16;    var result = new Array(padded.length);    var prev = this.toUint32Block(this.iv);          // 初始向量    for (var i = 0; i < blockCount; i++) {        var offset = 16 * i;        var block = this.toUint32Block(padded, offset);        // CBC: 明文块 ⊕ 前一密文块(或IV)        block[0] ^= prev[0]; block[1] ^= prev[1];        block[2] ^= prev[2]; block[3] ^= prev[3];        var encrypted = this.doBlockCrypt(block, this.encryptRoundKeys);        prev = encrypted;        // uint32 → 字节数组        for (var c = 0; c < 16; c++) {            result[offset + c] = encrypted[parseInt(c/4)] >> (3-c)%4*8 & 255;        }    }    return result;};

14.7 SM2 加密(pt=”2″ 的非对称加密)

JS 源码位置:行 4607-6624(约 2000 行椭圆曲线密码学代码),行 5840-5879 为加密入口。

SM2 实现极其复杂(包含完整的大数运算、椭圆曲线点运算、KDF 等),在 Node.js 中使用 sm-crypto 第三方库替代。

硬编码公钥

9a4ea935b2576f37516d9b29cd8d8cc9bffe548ba6853253ba20f4ba44fba8c9e97a398882769aa0dd1e3e1b5601429287303880ca17bd244ed73bf702a68fc7

Node.js 实现

var SM2_PUBLIC_KEY = "9a4ea935b2576f37516d9b29cd8d8cc9bffe548ba6853253" +                     "ba20f4ba44fba8c9e97a398882769aa0dd1e3e1b56014292" +                     "87303880ca17bd244ed73bf702a68fc7";var SM2 = {    encryptfunction(plaintext, publicKey) {        publicKey = publicKey || SM2_PUBLIC_KEY;        var smCrypto = require('sm-crypto');        // '04' 前缀表示非压缩格式的公钥        // 第二个参数 1 表示使用 C1C3C2 密文格式        var result = smCrypto.sm2.doEncrypt(plaintext, '04' + publicKey, 1);        return result;    }};

14.8 完整的 w 加密主函数

Node.js 实现

function encryptW(payloadJson, pt) {    pt = pt || "0";    // === pt="0": 纯 Base64 URL-safe 编码 ===    if (!pt || pt === "0") {        return Base64.urlsafe_encode(payloadJson);    }    // 生成16位随机hex密钥    var key = guid();    if (pt === "1") {        // === pt="1": RSA + AES 混合加密 ===        // 1. 用 RSA 加密随机密钥        var rsaEncryptedKey = RSA.encrypt(key);        // 确保 RSA 密文长度为 256 字符(128字节hex)        while (!rsaEncryptedKey || rsaEncryptedKey.length !== 256) {            key = guid();            rsaEncryptedKey = RSA.encrypt(key);        }        // 2. 用 AES-128-CBC 加密 payload        var aesCiphertext = AES.encrypt(payloadJson, key);        // 3. 拼接:AES密文hex + RSA密文hex        return arrayToHex(aesCiphertext) + rsaEncryptedKey;    }    if (pt === "2") {        // === pt="2": SM2 + SM4 混合加密 ===        var sm2EncryptedKey = SM2.encrypt(key);        var sm4 = new SM4({ key: key, mode: "cbc", iv: "0000000000000000" });        var sm4Ciphertext = sm4.encrypt(payloadJson);        return arrayToHex(sm4Ciphertext) + sm2EncryptedKey;    }    // 未知 pt 值,降级为 Base64    return Base64.urlsafe_encode(payloadJson);}

w 参数的最终结构(以 pt=”1″ 为例):

w = hex(AES_CBC(payload_json, random_key)) + RSA_1024(random_key)    └── 可变长度(取决于payload大小)──┘   └── 固定256字符 ──┘总长度约为 1024~1280 字符

十五、verify 提交与 payload 构建

15.1 payload(w 参数加密前的明文)构建

在加密成 w 之前,需要构建一个 JSON 对象作为 payload。这个 JSON 的字段和顺序至关重要。

JS 源码gcaptcha4_deobfuscated.js 行 2097-2117):

// 行 2097-2101: 合并基础字段(0, _v1911.$_BBF)(_v2160, {    device_id: _v2169.deviceId,        // 设备ID(通常为空)    lot_number: _v2169.lotNumber,      // 批次号    pow_msg: _v2167.options.powMsg,    // PoW 消息    pow_sign: _v2167.options.powSign   // PoW 签名});// 行 2102: 调用 gee_guard 指纹采集_v2167.$_BBEb(_v2160);// 行 2108-2109: 合并 gee_guard 和 _lib 数据(0, _v1911.$_BBF)(_v2176, {    gee_guard: _v2177.geeGuard         // ★ 当 geeGuard 为 undefined 时,                                        //   JSON.stringify 会跳过这个字段});(0, _v1911.$_BBF)(_v2176, window._lib ? window._lib : {});// window._lib = {"1a8R": "daC2"}      // 全局注入的字段// 行 2111-2117: lot_number 混淆映射// window.lib._abo = {"n[3:5]+n[9:11]": "n[7:12]"}// 这个映射用 lot_number 的子串作为 key,设置 payload 中的嵌套属性var i = getStringByIndexes(_v2177.lot, _v2177.lotNumber);var r = getStringByIndexes(_v2177.lotRes, _v2177.lotNumber);// 例如 lot_number = "72ce10e53aea4ac6b0b3217285a2a503"//   lot 解析 "n[3:5]+n[9:11]" → lot_number[3:5] + "." + lot_number[9:11]//   lotRes 解析 "n[7:12]" → lot_number[7:12]// 产生动态字段,嵌套设置到 payload 中// 行 2117: 设置 em = {} 并清空设备指纹_v2176.em = {};// 行 2118: 最终加密var _v2181 = (0, _v1921.default)(    _v1918.default.stringify(_v2176),    // payload → JSON 字符串    _v2179                               // 配置对象(含 pt));

15.2 我们的 payload 构建

经过分析,我们只需要构建核心字段即可通过验证。gee_guard(当 undefined 时 JSON.stringify 会跳过它)和 _lib 中的 "1a8R":"daC2" 在目前的验证中不是必需的。

Node.js 中的 payload 构建 (generate_w.js):

function processInput(jsonStr) {    var params = JSON.parse(jsonStr);    // 构建 payload 对象    // 字段顺序按照 JS 源码的合并顺序    var payload = {        setLeft: params.setLeft,                 // 滑动距离(显示坐标,整数)        passtime: params.passtime,               // 操作耗时(毫秒)        userresponse: params.userresponse         // 原图坐标位移+2            || (params.setLeft / (params.scale || 1) + 2),        device_id: params.device_id || "",        // 设备ID(空字符串)        lot_number: params.lot_number || "",      // 批次号        pow_msg: params.pow_msg || "",            // PoW 消息        pow_sign: params.pow_sign || "",          // PoW 签名        em: params.em || {},                      // 设备指纹(空对象)    };    // ★ 注意:不包含 gee_guard 字段    //    JS 源码中 geeGuard 为 undefined 时,JSON.stringify 会自动跳过    //    如果我们设 gee_guard: null,JSON 会输出 "gee_guard":null,导致校验失败!    // 轨迹编码    if (params.track && params.track.length > 0) {        payload.aa = encodeTrack(params.track);   // 差分压缩 + 自定义编码    }    // JSON 序列化并加密    var payloadJson = JSON.stringify(payload);    var pt = params.pt || "1";    var w = encryptW(payloadJson, pt);    // 返回加密后的 w 和调试用的明文 payload    process.stdout.write(JSON.stringify({        w: w,        payload_debug: payload    }));}

最终 payload JSON 示例

{    "setLeft"128,    "passtime"1507,    "userresponse"129.24,    "device_id""",    "lot_number""72ce10e53aea4ac6b0b3217285a2a503",    "pow_msg""1|0|md5|2026-04-28T12:00:00+08:00|72ce...|24f5...||abc123",    "pow_sign""d41d8cd98f00b204e9800998ecf8427e",    "em": {},    "aa""0-1$)3!!stuv(!x0!!(/1,0-,+"}

15.3 verify 接口请求

verify 是整个流程的最后一步,将所有计算结果提交给服务端验证。

请求格式

GET https://gcaptcha4.geetest.com/verify    ?callback=geetest_1776962556789    &captcha_id=24f56dc13c40dc4a02fd0318567caef5    &challenge=a3b1c2d4e5f67890    &client_type=web    &lot_number=72ce10e53aea4ac6b0b3217285a2a503    &risk_type=slide    &payload=eyJ...                        # load 返回的不透明数据,原样回传    &process_token=d3a1b2c3...             # load 返回的会话令牌,原样回传    &payload_protocol=1    &pt=1                                   # 加密方式    &w=a3b1c2d4...(1280字符的加密数据)     # 核心加密参数

Python 实现

def call_verify(session, captcha_id, challenge, verify_params):    """    调用极验 verify 接口,提交验证数据    :param verify_params: dict 包含:        - lot_number:       批次号        - payload:          load 返回的不透明数据        - process_token:    会话令牌        - payload_protocol: 协议版本        - pt:               加密方式        - w:                加密后的验证数据    """    callback = generate_callback()    params = {        'callback': callback,        'captcha_id': captcha_id,        'challenge': challenge,        'client_type''web',        'lot_number': verify_params['lot_number'],        'risk_type''slide',        'payload': verify_params['payload'],        'process_token': verify_params['process_token'],        'payload_protocol': verify_params.get('payload_protocol'1),        'pt': verify_params.get('pt''1'),        'w': verify_params['w'],    }    resp = session.get(        f"https://{API_SERVER}/verify",        params=params,        headers=HEADERS,        timeout=15    )    return parse_jsonp(resp.text)

15.4 verify 响应解析

成功响应

{    "status": "success",    "data": {        "result": "success",        "seccode": {            "pass_token": "a1b2c3d4e5f6...",            "gen_time": "2026-04-28 12:00:05",            "lot_number": "72ce10e53aea4ac6b0b3217285a2a503",            "captcha_output": "..."        }    }}

失败响应

{    "status": "success",    "data": {        "result": "fail",        "score": "0"    }}

Python 判定逻辑

status = result.get('status''unknown')res_data = result.get('data', {})inner_result = res_data.get('result''unknown')if status == 'success' and inner_result == 'success':    print("验证通过!")    seccode = res_data.get('seccode', {})    pass_token = seccode.get('pass_token''')      # 验证成功的凭证    gen_time = seccode.get('gen_time''')           # 生成时间else:    print("验证未通过!")

十六、Python 主控流程完整代码

以下是 geetest_solver.py 的完整实现,包含所有 8 个步骤的详细注释。

"""极验 GeeTest v4 滑块验证码自动求解器完整流程:load → 下载图片 → 识别缺口 → 生成轨迹 → PoW → 加密w → verify"""import jsonimport reimport timeimport randomimport subprocessimport osimport structimport requestsfrom trajectory import generate_trajectory, get_passtime   # 轨迹生成模块from pow_solver import solve_pow                            # PoW 求解模块from gap_detector import detect_gap                         # 缺口识别模块# Node.js 加密脚本的绝对路径NODE_SCRIPT = os.path.join(    os.path.dirname(os.path.abspath(__file__)),    'node_env''generate_w.js')# ===== 配置常量 =====CAPTCHA_ID = "24f56dc13c40dc4a02fd0318567caef5"     # 极验演示站的验证码IDAPI_SERVER = "gcaptcha4.geetest.com"                 # 极验API服务器STATIC_SERVER = "https://static.geetest.com"         # 极验静态资源CDNCONTAINER_WIDTH = 340                                # 验证码容器宽度(像素)# 模拟 Chrome 浏览器的请求头HEADERS = {    'User-Agent''Mozilla/5.0 (Windows NT 10.0; Win64; x64) '                  'AppleWebKit/537.36 (KHTML, like Gecko) '                  'Chrome/120.0.0.0 Safari/537.36',    'Accept''*/*',    'Accept-Language''zh-CN,zh;q=0.9,en;q=0.8',    'Referer''https://www.geetest.com/adaptive-captcha',}SEP = "=" * 60  # 分隔线def generate_callback():    """生成 JSONP 回调函数名:geetest_{毫秒时间戳}"""    return f"geetest_{int(time.time() * 1000)}"def guid_python():    """    生成16位随机hex字符串(Python版)    与 JS 的 guid() 函数等价    """    def e():        return format((int(65536 * (1 + random.random())) | 0), 'x')[-4:]    return e() + e() + e() + e()def parse_jsonp(text):    """    解析 JSONP 响应    输入: 'geetest_123({"status":"success","data":{...}})'    输出: {"status":"success","data":{...}}    """    match = re.search(r'\((\{.*\})\)', text, re.DOTALL)    if match:        return json.loads(match.group(1))    return json.loads(text)def get_image_size(png_data):    """    从 PNG 二进制数据中提取宽高    PNG IHDR chunk: 字节16-19=宽度, 字节20-23=高度(大端序uint32)    """    if png_data[:4] == b'\x89PNG':        w = struct.unpack('>I', png_data[16:20])[0]        h = struct.unpack('>I', png_data[20:24])[0]        return w, h    return 300200def call_load(session, captcha_id, challenge):    """    Step 1: 调用 load 接口获取验证码配置    返回: lot_number, 图片路径, PoW参数等    """    callback = generate_callback()    params = {        'callback': callback,        'captcha_id': captcha_id,        'challenge': challenge,        'client_type''web',        'risk_type''slide',        'lang''zh',    }    resp = session.get(        f"https://{API_SERVER}/load",        params=params, headers=HEADERS, timeout=15    )    return parse_jsonp(resp.text)def call_verify(session, captcha_id, challenge, verify_params):    """    Step 8: 调用 verify 接口提交验证数据    """    callback = generate_callback()    params = {        'callback': callback,        'captcha_id': captcha_id,        'challenge': challenge,        'client_type''web',        'lot_number': verify_params['lot_number'],        'risk_type''slide',        'payload': verify_params['payload'],        'process_token': verify_params['process_token'],        'payload_protocol': verify_params.get('payload_protocol'1),        'pt': verify_params.get('pt''1'),        'w': verify_params['w'],    }    resp = session.get(        f"https://{API_SERVER}/verify",        params=params, headers=HEADERS, timeout=15    )    return parse_jsonp(resp.text)def generate_w_via_node(params):    """    调用 Node.js 子进程生成加密参数 w    通过 stdin 传入 JSON 参数,stdout 接收加密结果    """    input_json = json.dumps(params)    result = subprocess.run(        ['node', NODE_SCRIPT],        input=input_json,        capture_output=True,        text=True,        timeout=30    )    if result.returncode != 0:        raise RuntimeError(f"Node.js error: {result.stderr}")    output = json.loads(result.stdout)    return output['w'], output.get('payload_debug', {})def solve_captcha(captcha_id=None):    """    主流程:完成极验 v4 滑块验证码的自动求解    返回 verify 接口的响应结果    """    captcha_id = captcha_id or CAPTCHA_ID    session = requests.Session()    session.headers.update(HEADERS)    challenge = guid_python()                          # 生成本次挑战的唯一令牌    # === Step 1: 调用 load 接口 ===    load_resp = call_load(session, captcha_id, challenge)    if load_resp.get('status') != 'success':        return None    data = load_resp['data']    lot_number = data['lot_number']    payload = data['payload']    process_token = data['process_token']    pt = str(data.get('pt''1'))    payload_protocol = data.get('payload_protocol'1)    pow_detail = data.get('pow_detail', {})    bg_path = data.get('bg''')    slice_path = data.get('slice''')    # === Step 2: 下载背景图和滑块碎片图 ===    bg_url = f"{STATIC_SERVER}/{bg_path}"    bg_resp = session.get(bg_url, timeout=15)    bg_w, bg_h = get_image_size(bg_resp.content)       # 获取原图尺寸(如344x212)    # === Step 3: 识别缺口位置 ===    try:        gap_x = detect_gap(image_bytes=bg_resp.content)    except Exception:        gap_x = random.randint(100250)                # OCR失败时的备用方案    # === 坐标换算 ===    SLIDER_OFFSET = 15                                  # 滑块初始偏移(像素)    scale = 0.8876 * CONTAINER_WIDTH / bg_w             # 缩放比    adjusted_gap_x = gap_x - SLIDER_OFFSET              # 减去滑块偏移    slide_distance = adjusted_gap_x * scale             # 转为显示坐标系    setLeft = int(slide_distance)                       # 取整    userresponse = setLeft / scale + 2                  # 转回原图坐标+2    # === Step 4: 模拟思考延时 ===    delay = random.uniform(1.53.0)    time.sleep(delay)    # === Step 5: 生成拟人化轨迹 ===    track_display = generate_trajectory(int(slide_distance))    passtime = get_passtime(track_display)    # 显示坐标→原图坐标系    track = [[round(p[0] / scale), round(p[1] / scale), p[2]]             for p in track_display]    # 插入初始偏移点    initial_offset = [random.randint(-30, -10), random.randint(-1010), 0]    track.insert(0, initial_offset)    # === Step 6: 计算 PoW ===    pow_config = {        'version': pow_detail.get('version''1'),        'bits': pow_detail.get('bits'0),        'hashfunc': pow_detail.get('hashfunc''md5'),        'datetime': pow_detail.get('datetime'''),        'lot_number': lot_number,        'captcha_id': captcha_id,        'nonce': pow_detail.get('nonce'''),    }    pow_result = solve_pow(pow_config)    # === Step 7: 调用 Node.js 生成加密参数 w ===    w_params = {        'setLeft': setLeft,        'passtime': passtime,        'userresponse': userresponse,        'device_id''',        'lot_number': lot_number,        'pow_msg': pow_result['pow_msg'],        'pow_sign': pow_result['pow_sign'],        'em': {},        'track': track,        'pt': pt,        'scale': scale,    }    w, payload_debug = generate_w_via_node(w_params)    # === Step 8: 提交 verify 请求 ===    result = call_verify(session, captcha_id, challenge, {        'lot_number': lot_number,        'payload': payload,        'process_token': process_token,        'payload_protocol': payload_protocol,        'pt': pt,        'w': w,    })    # 判定验证结果    status = result.get('status''unknown')    res_data = result.get('data', {})    inner_result = res_data.get('result''unknown')    if status == 'success' and inner_result == 'success':        print("验证通过!")    else:        print("验证未通过!")    return resultif __name__ == "__main__":    solve_captcha()

十七、Node.js 加密模块完整代码

以下是 node_env/generate_w.js 的完整实现。由于代码较长,分为 6 个部分逐段说明。

17.1 基础工具函数:guid 与 arrayToHex

'use strict';const nodeCrypto = require('crypto');   // Node.js 内置加密模块// ============================// guid() - 生成16位随机hex密钥// 对应源码: gcaptcha4_deobfuscated.js 行 457-464// 用途: 生成对称加密的随机密钥(AES/SM4的key)// ============================function guid() {    function e() {        // 生成 65536~131071 范围的整数,取十六进制后4位        return (65536 * (1 + Math.random()) | 0).toString(16).substring(1);    }    return e() + e() + e() + e();   // 4×4 = 16位hex}// ============================// arrayToHex() - 字节数组转hex字符串// 对应源码: 行 306-312// 用途: 将 AES/SM4 加密输出的字节数组转为hex字符串// 示例: [72, 101, 108] → "48656c"// ============================function arrayToHex(arr) {    // 第一步:将字节值打包到32位整数数组中    var t = [], s = 0;    for (var n = 0; n < 2 * arr.length; n += 2) {        t[n >>> 3] |= parseInt(arr[s], 10) << 24 - n % 8 * 4;        s++;    }    // 第二步:逐字节提取,转为两位hex字符    var i = [];    for (var r = 0; r < arr.length; r++) {        var o = t[r >>> 2] >>> 24 - r % 4 * 8 & 255;   // 提取单字节        i.push((o >>> 4).toString(16));                  // 高4位        i.push((15 & o).toString(16));                   // 低4位    }    return i.join("");}

17.2 轨迹编码:差分压缩 + 方向映射 + 数值编码

// ============================// 轨迹编码模块// 对应源码: 行 1736-1771// 流程: 原始轨迹 → 差分压缩 → 分类编码 → 三段拼接// ============================// --- 差分压缩 ---// 将绝对坐标轨迹转为增量 [dx, dy, dt]// 如果连续多个点只有时间变化没有位移,累积时间到下一个有效点function trackDiff(trackPoints) {    var i = [],    // 结果数组        r = 0;     // 累积的静止时间    for (var o = 0, a = trackPoints.length - 1; o < a; o += 1) {        var t = Math.round(trackPoints[o + 1][0] - trackPoints[o][0]);  // dx        var s = Math.round(trackPoints[o + 1][1] - trackPoints[o][1]);  // dy        var n = Math.round(trackPoints[o + 1][2] - trackPoints[o][2]);  // dt        if (!(0 === t && 0 === s && 0 === n)) {     // 过滤完全无变化的点            if (0 === t && 0 === s) {                r += n;                              // 仅时间变化,累积不输出            } else {                i.push([t, s, n + r]);               // 有位移,带上累积时间                r = 0;                               // 重置累积器            }        }    }    if (0 !== r) i.push([t, s, r]);                  // 尾部残余时间    return i;}// --- 方向编码 ---// 9种常见运动方向用单字符表示,节省编码空间// 如果不匹配则返回0,需要用数值编码function encodeDirection(point) {    var dirs = [        [1, 0],   // 右移1 → 's'        [2, 0],   // 右移2 → 't'        [1, -1],  // 右1下1 → 'u'        [1, 1],   // 右1上1 → 'v'        [0, 1],   // 上移1 → 'w'        [0, -1],  // 下移1 → 'x'        [3, 0],   // 右移3 → 'y'        [2, -1],  // 右2下1 → 'z'        [2, 1]    // 右2上1 → '~'    ];    var chars = "stuvwxyz~";    for (var s = 0; s < dirs.length; s++) {        if (point[0] === dirs[s][0] && point[1] === dirs[s][1])            return chars[s];    }    return 0;   // 不在预定义方向中}// --- 数值编码 ---// 使用自定义64字符集的变长编码// 编码规则://   正数小于64:   直接 charset[val]        → 1字符//   正数>=64:     "$" + charset[高位] + charset[低位] → 3字符//   负数:         "!" 前缀 + 上述编码       → 2或4字符function encodeValue(val) {    var charset = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr";    var base = charset.length;                  // 64    var n = "";    var i = Math.abs(val);    var r = parseInt(i / base10);             // 高位    if (base <= r) r = base - 1;                // 防溢出(最大编码 64×64-1)    if (r) n = charset.charAt(r);               // 高位字符    var o = "";    if (val < 0) o += "!";                      // 负数标记    if (n) o += "$";                            // 多字节标记    return o + n + charset.charAt(i % base);    // 拼接}// --- 完整编码 ---// 差分 → 分类(方向/数值) → 三段用"!!"连接function encodeTrack(trackPoints) {    var diffed = trackDiff(trackPoints);    var xParts = [];   // X增量编码    var yParts = [];   // Y增量编码(含方向字符)    var tParts = [];   // 时间增量编码    for (var idx = 0; idx < diffed.length; idx++) {        var point = diffed[idx];        var dirChar = encodeDirection(point);        if (dirChar) {            yParts.push(dirChar);               // 方向字符只放Y段,X段不输出        } else {            xParts.push(encodeValue(point[0]));  // X段:数值编码dx            yParts.push(encodeValue(point[1]));  // Y段:数值编码dy        }        tParts.push(encodeValue(point[2]));      // T段:始终编码dt    }    // 最终格式: "X编码!!Y编码!!T编码"    return xParts.join("") + "!!" + yParts.join("") + "!!" + tParts.join("");}

17.3 Base64 编码器 + AES-128-CBC

// ============================// Base64 编码器(标准 + URL-safe 两种变体)// 对应源码: 行 3780-3879// pt="0" 时使用 urlsafe_encode// ============================var Base64 = (function() {    var CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +                "abcdefghijklmnopqrstuvwxyz" +                "0123456789+/";                     // 标准 Base64 字符集    var URLSAFE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +                        "abcdefghijklmnopqrstuvwxyz" +                        "0123456789-_";              // URL-safe 变体(+→-, /→_)    // 字符串 → UTF-8 字节数组    functionstrToBytes(str{        var bytes = [];        for(var i = 0; i < str.length; i++) {            var code = str.charCodeAt(i);            if(code < 128) {                bytes.push(code);                    // 单字节 ASCII            } else if (code < 2048) {                bytes.push(192 | code >> 6);         // 2字节 UTF-8                bytes.push(128 | 63 & code);            } else {                bytes.push(224 | code >> 12);        // 3字节 UTF-8                bytes.push(128 | (code >> 6 & 63));                bytes.push(128 | 63 & code);            }        }        return bytes;    }    // 编码主函数:每3字节输出4个Base64字符    functionencode(str, urlsafe{        var chars = urlsafe ? URLSAFE_CHARS : CHARS;        var bytes = strToBytes(str);        var result = [];        for(var i = 0; i < bytes.length; i += 3) {            var b1 = bytes[i];            var b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;            var b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;            result.push(chars.charAt(b1 >> 2));            result.push(chars.charAt((3 & b1) << 4 | b2 >> 4));            result.push(i + 1 < bytes.length                ? chars.charAt((15 & b2) << 2 | b3 >> 6)                : (urlsafe ? "" : "="));            result.push(i + 2 < bytes.length                ? chars.charAt(63 & b3)                : (urlsafe ? """="));        }        return result.join("");    }    return {        encode: function(strreturn encode(str, false); },        urlsafe_encode: function(strreturn encode(str, true); }    };})();// ============================// AES-128-CBC 加密// 对应源码: 行 3881-4148(CryptoJS 风格实现)// 简化: 使用 Node.js crypto 模块替代//// 参数说明://   密钥 = guid() 生成的16位hex,Latin1编码 → 16字节 = AES-128//   IV   = "0000000000000000" Latin1编码 → 16字节 (0x30重复16次,不是0x00)//   填充 = PKCS7(Node.js crypto 默认)// ============================var AES = {    encrypt: function(plaintext, key{        var keyBuf = Buffer.from(key, 'latin1');             // 16位hex → 16字节        var ivBuf = Buffer.from('0000000000000000''latin1'); // ASCII '0' × 16        var cipher = nodeCrypto.createCipheriv('aes-128-cbc', keyBuf, ivBuf);        var encrypted = cipher.update(plaintext, 'utf8');        var final = cipher.final();        var combined = Buffer.concat([encrypted, final]);        return Array.from(combined);                          // 返回字节数组    }};

17.4 RSA-1024 (PKCS#1 v1.5)

// ============================// RSA-1024 加密// 对应源码: 行 4149-4510// 公钥硬编码在 JS 中,无需从服务端获取// ============================// RSA 公钥(硬编码)var RSA_N = "00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947" +            "713D25EECBFF7E74C7977D02DC1D9451F79DD5D1C10C29AC" +            "B6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221" +            "AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B5970" +            "6592A9219D0BF05C9F65023A21D2330807252AE0066D59CE" +            "EFA5F2748EA80BAB81";var RSA_E = "10001";   // 65537(标准RSA公钥指数)var RSA = {    encrypt: function(plaintext) {        var n = BigInt('0x' + RSA_N);        var e = BigInt('0x' + RSA_E);        // 密钥长度:去掉前导"00"后除以2 → 128字节(1024位)        var keyLen = (RSA_N.length - (RSA_N.startsWith('00') ? 2 : 0)) / 2;        var msgBytes = Buffer.from(plaintext, 'utf8');        if (msgBytes.length + 11 > keyLen) {    // PKCS#1 v1.5 最少需要11字节开销            console.error("Message too long for RSA");            return null;        }        // === PKCS#1 v1.5 Type 2 填充 ===        // 格式: 0x00 | 0x02 | [随机非零字节] | 0x00 | [明文]        var padded = Buffer.alloc(keyLen);        padded[0] = 0;                           // 固定字节 0x00        padded[1] = 2;                           // Type 2(公钥加密)        // 填充随机非零字节(必须非零,否则解密时无法确定分隔位置)        var paddingLen = keyLen - msgBytes.length - 3;        var randomBytes = nodeCrypto.randomBytes(paddingLen);        for (var i = 0; i < paddingLen; i++) {            while (randomBytes[i] === 0) {                randomBytes[i] = nodeCrypto.randomBytes(1)[0];            }            padded[2 + i] = randomBytes[i];        }        padded[2 + paddingLen] = 0;              // 分隔符 0x00        msgBytes.copy(padded, 3 + paddingLen);   // 复制明文        // === RSA 核心运算: c = m^e mod n ===        var m = BigInt('0x' + padded.toString('hex'));        var c = modPow(m, e, n);        var hex = c.toString(16);        if (hex.length % 2 !== 0) hex = '0' + hex;   // 确保偶数长度        return hex;                                     // 应为256字符    }};// 快速幂模运算(平方-乘法算法)// 计算 base^exp mod mod,使用 BigInt 避免精度丢失function modPow(base, exp, mod) {    var result = 1n;    base = base % mod;    while (exp > 0n) {        if (exp % 2n === 1n) {                   // 奇数指数            result = (result * base) % mod;        }        exp = exp / 2n;                          // 指数减半        base = (base * base) % mod;              // 基数平方    }    return result;}

17.5 SM4-CBC(国密对称加密,核心常量表)

// ============================// SM4-CBC 加密// 对应源码: 行 4514-4606// 完整的国密分组密码纯JS实现// ============================// SM4 S-box(256字节替换表)var SM4_SBOX = [    0xd60x900xe90xfe0xcc0xe10x3d0xb7,    0x160xb60x140xc20x280xfb0x2c0x05,    0x2b0x670x9a0x760x2a0xbe0x040xc3,    0xaa0x440x130x260x490x860x060x99,    0x9c0x420x500xf40x910xef0x980x7a,    0x330x540x0b0x430xed0xcf0xac0x62,    0xe40xb30x1c0xa90xc90x080xe80x95,    0x800xdf0x940xfa0x750x8f0x3f0xa6,    0x470x070xa70xfc0xf30x730x170xba,    0x830x590x3c0x190xe60x850x4f0xa8,    0x680x6b0x810xb20x710x640xda0x8b,    0xf80xeb0x0f0x4b0x700x560x9d0x35,    0x1e0x240x0e0x5e0x630x580xd10xa2,    0x250x220x7c0x3b0x010x210x780x87,    0xd40x000x460x570x9f0xd30x270x52,    0x4c0x360x020xe70xa00xc40xc80x9e,    0xea0xbf0x8a0xd20x400xc70x380xb5,    0xa30xf70xf20xce0xf90x610x150xa1,    0xe00xae0x5d0xa40x9b0x340x1a0x55,    0xad0x930x320x300xf50x8c0xb10xe3,    0x1d0xf60xe20x2e0x820x660xca0x60,    0xc00x290x230xab0x0d0x530x4e0x6f,    0xd50xdb0x370x450xde0xfd0x8e0x2f,    0x030xff0x6a0x720x6d0x6c0x5b0x51,    0x8d0x1b0xaf0x920xbb0xdd0xbc0x7f,    0x110xd90x5c0x410x1f0x100x5a0xd8,    0x0a0xc10x310x880xa50xcd0x7b0xbd,    0x2d0x740xd00x120xb80xe50xb40xb0,    0x890x690x970x4a0x0c0x960x770x7e,    0x650xb90xf10x090xc50x6e0xc60x84,    0x180xf00x7d0xec0x3a0xdc0x4d0x20,    0x790xee0x5f0x3e0xd70xcb0x390x48];// 32个固定密钥常量 CKvar SM4_CK = [    0x00070e150x1c232a310x383f464d0x545b6269,    0x70777e850x8c939aa10xa8afb6bd0xc4cbd2d9,    0xe0e7eef50xfc030a110x181f262d0x343b4249,    0x50575e650x6c737a810x888f969d0xa4abb2b9,    0xc0c7ced50xdce3eaf10xf8ff060d0x141b2229,    0x30373e450x4c535a610x686f767d0x848b9299,    0xa0a7aeb50xbcc3cad10xd8dfe6ed0xf4fb0209,    0x10171e250x2c333a410x484f565d0x646b7279];// 系统参数 FKvar SM4_FK = [0xa3b1bac60x56aa33500x677d91970xb27022dc];

17.6 SM4 核心运算函数

// 循环左移(32位无符号)function sm4_rotl(x, n) {    return ((x << n) | (x >>> (32 - n))) >>> 0;}// 非线性变换 τ:对32位输入的每个字节做 S-box 替换function sm4_tau(A) {    return (SM4_SBOX[(A >>> 24) & 0xff] << 24 |            SM4_SBOX[(A >>> 16) & 0xff] << 16 |            SM4_SBOX[(A >>> 8) & 0xff] << 8 |            SM4_SBOX[A & 0xff]) >>> 0;}// 线性变换 L(加密轮函数使用)function sm4_L(B) {    return (B ^ sm4_rotl(B, 2) ^ sm4_rotl(B, 10) ^            sm4_rotl(B, 18) ^ sm4_rotl(B, 24)) >>> 0;}// 线性变换 L'(密钥扩展使用,与 L 不同)function sm4_L_prime(B) {    return (B ^ sm4_rotl(B, 13) ^ sm4_rotl(B, 23)) >>> 0;}// 字符串 → UTF-8 字节数组(SM4 的辅助函数)function strToBytes(str) {    var bytes = [];    for (var i = 0; i < str.length; i++) {        var code = str.charCodeAt(i);        if (code < 128) bytes.push(code);        else if (code < 2048) {            bytes.push(192 | code >> 6);            bytes.push(128 | 63 & code);        } else {            bytes.push(224 | code >> 12);            bytes.push(128 | (code >> 6 & 63));            bytes.push(128 | 63 & code);        }    }    return bytes;}// SM4 构造函数:初始化密钥扩展(生成32个轮密钥)function SM4(config) {    var keyBytes = strToBytes(config.key);    if (keyBytes.length !== 16throw new Error("SM4 key must be 16 bytes");    this.key = keyBytes;    var ivBytes = config.iv ? strToBytes(config.iv) : new Array(16).fill(0);    this.iv = ivBytes;    this.mode = config.mode || "cbc";    // 密钥扩展:从128位主密钥生成32个32位轮密钥    this.encryptRoundKeys = new Array(32);    var MK = this.toUint32Block(this.key);         // 主密钥拆为4个uint32    var K = [        MK[0] ^ SM4_FK[0], MK[1] ^ SM4_FK[1],   // 与系统参数FK异或        MK[2] ^ SM4_FK[2], MK[3] ^ SM4_FK[3]    ];    for (var i = 0; i < 32; i++) {        var tmp = K[i + 1] ^ K[i + 2] ^ K[i + 3] ^ SM4_CK[i];        tmp = sm4_tau(tmp);                        // S-box 替换        tmp = sm4_L_prime(tmp);                    // L' 线性变换        K.push((K[i] ^ tmp) >>> 0);        this.encryptRoundKeys[i] = K[i + 4];       // 保存轮密钥    }}// 16字节 → 4个 uint32(大端序)SM4.prototype.toUint32Block = function(bytes, offset) {    offset = offset || 0;    return [        (bytes[offset]<<24 | bytes[offset+1]<<16 |         bytes[offset+2]<<8 | bytes[offset+3]) >>> 0,        (bytes[offset+4]<<24 | bytes[offset+5]<<16 |         bytes[offset+6]<<8 | bytes[offset+7]) >>> 0,        (bytes[offset+8]<<24 | bytes[offset+9]<<16 |         bytes[offset+10]<<8 | bytes[offset+11]) >>> 0,        (bytes[offset+12]<<24 | bytes[offset+13]<<16 |         bytes[offset+14]<<8 | bytes[offset+15]) >>> 0    ];};// 单块加密:32轮非线性迭代SM4.prototype.doBlockCrypt = function(block, roundKeys) {    var X = block.slice();    for (var i = 0; i < 32; i++) {        var tmp = X[i+1] ^ X[i+2] ^ X[i+3] ^ roundKeys[i];        tmp = sm4_tau(tmp);        tmp = sm4_L(tmp);        X.push((X[i] ^ tmp) >>> 0);    }    return [X[35], X[34], X[33], X[32]];          // 反序输出};// PKCS7 填充SM4.prototype.padding = function(bytes) {    var padLen = 16 - bytes.length % 16;    var padded = bytes.slice();    for (var i = 0; i < padLen; i++) padded.push(padLen);    return padded;};// CBC 模式加密入口SM4.prototype.encrypt = function(plaintext) {    var bytes = strToBytes(plaintext);    var padded = this.padding(bytes);    var blockCount = padded.length / 16;    var result = new Array(padded.length);    if (this.mode === "cbc") {        var prev = this.toUint32Block(this.iv);    // 初始向量        for (var i = 0; i < blockCount; i++) {            var offset = 16 * i;            var block = this.toUint32Block(padded, offset);            // CBC: 明文块 ⊕ 前一密文块            block[0] ^= prev[0]; block[1] ^= prev[1];            block[2] ^= prev[2]; block[3] ^= prev[3];            var encrypted = this.doBlockCrypt(block, this.encryptRoundKeys);            prev = encrypted;            // uint32 → 字节            for (var c = 0; c < 16; c++) {                result[offset + c] =                    encrypted[parseInt(c / 4)] >> (3 - c) % 4 * 8 & 255;            }        }    }    return result;};

17.7 SM2 加密封装 + 加密入口函数 + 主入口

SM2 公钥与加密封装:

SM2 是国密椭圆曲线公钥加密算法,原始 JS 实现约 2000 行(行 4607-6624),极其复杂。 实际使用中通过 sm-crypto npm 包替代,只需传入硬编码公钥即可。

// ============================// SM2 加密// 源码: 行 5840-5879 (简化版)// 完整实现: 行 4607-6624 (~2000行椭圆曲线代码)// ============================// 硬编码 SM2 公钥(64字节,不含04前缀)// 源码位置: 行 5858var SM2_PUBLIC_KEY =    "9a4ea935b2576f37516d9b29cd8d8cc9" +    "bffe548ba6853253ba20f4ba44fba8c9" +    "e97a398882769aa0dd1e3e1b56014292" +    "87303880ca17bd244ed73bf702a68fc7";var SM2 = {    encryptfunction(plaintext, publicKey) {        publicKey = publicKey || SM2_PUBLIC_KEY;        try {            // 使用 sm-crypto 第三方库            // npm install sm-crypto            var smCrypto = require('sm-crypto');            // doEncrypt(明文, 公钥, 密文格式)            // 公钥需要加 "04" 前缀表示非压缩点            // 第三个参数 1 = C1C3C2 格式(国标推荐)            var result = smCrypto.sm2.doEncrypt(                plaintext,                '04' + publicKey,                1  // 输出格式: C1C3C2            );            return result;        } catch(e) {            console.error("SM2 加密需要 sm-crypto 包");            return null;        }    }};

SM2 密文格式说明:

  • C1: 椭圆曲线上的随机点(64字节)
  • C3: SM3 哈希值(32字节)
  • C2: 加密后的数据(与明文等长)
  • 输出为 hex 字符串,格式:C1 || C3 || C2

w 参数加密入口函数 encryptW()

这是整个加密流程的调度中心。根据服务端返回的 pt 值选择不同的加密方案:

// ============================// w 参数加密主函数// 源码: 行 3751-3778// ============================function encryptW(payloadJson, pt) {    pt = pt || "0";    // pt = "0": 不加密,仅 Base64 URL-safe 编码    if (!pt || pt === "0") {        return Base64.urlsafe_encode(payloadJson);    }    // 生成随机对称密钥(16位hex = 16字节Latin1)    var key = guid();    if (pt === "1") {        // === 国际标准方案: RSA + AES ===        // 1. RSA 加密对称密钥(PKCS#1 v1.5)        var rsaEncryptedKey = RSA.encrypt(key);        // 确保 RSA 密文恰好 256 个 hex 字符(128字节 = 1024位)        while (!rsaEncryptedKey || rsaEncryptedKey.length !== 256) {            key = guid();            rsaEncryptedKey = RSA.encrypt(key);        }        // 2. AES-128-CBC 加密 payload        var aesCiphertext = AES.encrypt(payloadJson, key);        // 3. 拼接: AES密文hex + RSA加密的密钥hex        return arrayToHex(aesCiphertext) + rsaEncryptedKey;    }    if (pt === "2") {        // === 国密标准方案: SM2 + SM4 ===        // 1. SM2 加密对称密钥        var sm2EncryptedKey = SM2.encrypt(key);        // 2. SM4-CBC 加密 payload        var sm4 = new SM4({            key: key,            mode: "cbc",            iv: "0000000000000000"  // 固定全零ASCII IV        });        var sm4Ciphertext = sm4.encrypt(payloadJson);        // 3. 拼接: SM4密文hex + SM2加密的密钥hex        return arrayToHex(sm4Ciphertext) + sm2EncryptedKey;    }    // 兜底: 纯 Base64    return Base64.urlsafe_encode(payloadJson);}

加密流程对照表:

> | pt值 | 对称加密 | 非对称加密 | 输出格式 |> |------|---------|-----------|---------|> | "0"  | 无      | 无        | Base64 URL-safe |> | "1"  | AES-128-CBC | RSA-1024 | `AES密文hex + RSA(key)hex` |> | "2"  | SM4-CBC | SM2       | `SM4密文hex + SM2(key)hex` |

processInput 主入口函数:

这是 Node.js 脚本的核心入口,接收 Python 传来的 JSON 参数,构建 payload,调用加密,输出结果:

// ============================// 主入口: 从 stdin 读取参数,构建 payload,加密生成 w// ============================function main() {    var inputData = process.argv[2];    if (!inputData) {        // 从 stdin 读取(Python subprocess 通过 stdin 传参)        var chunks = [];        process.stdin.setEncoding('utf8');        process.stdin.on('data'function(chunk) {            chunks.push(chunk);        });        process.stdin.on('end'function() {            processInput(chunks.join(''));        });        return;    }    processInput(inputData);}function processInput(jsonStr) {    try {        var params = JSON.parse(jsonStr);        // 构建 payload 对象        // 字段顺序严格按照 JS 源码 行 7133-7136 + 2097-2117        // 注意: 字段顺序影响 JSON.stringify 输出,进而影响密文        var payload = {            setLeft: params.setLeft,           // 滑动像素距离(取整)            passtime: params.passtime,         // 操作总耗时(ms)            userresponse: params.userresponse   // setLeft/scale + 2                || (params.setLeft / (params.scale || 1) + 2),            device_id: params.device_id || ""// 设备指纹(可为空)            lot_number: params.lot_number || "",            pow_msg: params.pow_msg || "",            pow_sign: params.pow_sign || "",            em: params.em || {},               // 环境信息(可为空对象)        };        // 关键: gee_guard 字段不能出现在 payload 中        // 不设置 = undefined → JSON.stringify 自动跳过        // 如果设为 null → 输出 "gee_guard":null → 验证失败!        // 轨迹编码: 原始坐标数组 → 差分压缩 → 字符编码        if (params.track && params.track.length > 0) {            payload.aa = encodeTrack(params.track);        }        // 支持额外字段扩展        if (params.extra_payload) {            Object.assign(payload, params.extra_payload);        }        // 序列化 + 加密        var payloadJson = JSON.stringify(payload);        var pt = params.pt || "1";        var w = encryptW(payloadJson, pt);        // 输出结果(JSON格式,供 Python 解析)        var result = {            w: w,                    // 加密后的 w 参数            payload_debug: payload   // 调试用: 加密前的明文 payload        };        process.stdout.write(JSON.stringify(result));    } catch(e) {        process.stderr.write("Error: " + e.message + "\n" + e.stack);        process.exit(1);    }}// 启动if (require.main === module) {    main();}// 模块导出(供单元测试使用)module.exports = {    guid, arrayToHex, encodeTrack, trackDiff,    Base64AESRSASM4SM2, encryptW};

Python ↔ Node.js 通信协议:

Python → stdin → JSON字符串:{  "setLeft"182,  "passtime"1247,  "userresponse"211.37,  "track": [[-2050], [000], [3018], ...],  "lot_number""72ce...",  "pow_msg""1|0|md5|...",  "pow_sign""abc123...",  "pt""1",  "scale"0.8776}Node.js → stdout → JSON字符串:{  "w""a3f2e1...(AES密文hex + RSA(key)hex)",  "payload_debug": { ... 加密前明文 ... }}

十八、调试踩坑记录与总结

在整个逆向分析和自动化实现过程中,遇到了多个关键问题。本节记录这些踩坑经验,帮助后续开发者避免重复犯错。

18.1 gee_guard 字段:null vs undefined 的致命区别

问题现象: verify 请求返回失败,服务端校验不通过,但 payload 结构看起来没问题。

根因分析:

在 JS 源码中,gee_guard 字段在某些条件下会被设置为 undefined

// 源码行 2107 附近var payload = {    setLeft: setLeft,    passtime: passtime,    userresponse: userresponse,    // ... 其他字段 ...    gee_guard: someCondition ? value : undefined};

JavaScript 中 JSON.stringify 对 undefined 和 null 的处理完全不同:

// undefined → 字段被跳过,不出现在 JSON 中JSON.stringify({ a1, b: undefined })// 输出: '{"a":1}'// null → 字段保留,值为 nullJSON.stringify({ a1, bnull })// 输出: '{"a":1,"b":null}'

踩坑过程: 最初在 Node.js 实现中手动设置了 gee_guard: null,导致加密后的 payload 包含 "gee_guard":null 字段。服务端严格校验了 payload 结构,多出这个字段直接导致验证失败。

解决方案: 不在 payload 对象中设置 gee_guard 字段。不设置 = undefined → JSON.stringify 自动跳过 → 与浏览器行为一致。

// ❌ 错误写法var payload = {    setLeft: setLeft,    gee_guardnull    // 会输出 "gee_guard":null};// ✅ 正确写法var payload = {    setLeft: setLeft    // 不设置 gee_guard → undefined → JSON.stringify 跳过};

教训: 逆向还原时,不仅要关注有哪些字段,还要关注哪些字段不应该出现JSON.stringify 的 undefined 跳过行为是 JS 特有的语义细节。

18.2 userresponse 计算公式:一个 “+2” 引发的血案

问题现象: 缺口位置识别正确,轨迹生成正常,但 verify 始终返回失败。

根因分析:

userresponse 是服务端校验滑动距离的关键字段。正确公式藏在源码行 7136:

// 源码行 7136n.userresponse = S(n.setLeft, r.challenge);

跳转到 S 函数定义(行 7072 附近):

// S 函数的简化逻辑function S(setLeft, challenge) {    // 实际就是把显示坐标还原为原图坐标,再加一个固定偏移    return setLeft / scale + 2;}

踩坑过程: 最初错误地以为 userresponse = gap_x + 2(直接用原图缺口坐标加2),这完全搞错了计算路径:

❌ 错误理解:userresponse = gap_x + 2  (OCR原图坐标 + 2)✅ 正确理解:setLeft = parseInt(slide_distance)        = parseInt((gap_x - SLIDER_OFFSET) * scale)userresponse = setLeft / scale + 2             = 从显示坐标反算回原图坐标 + 固定偏移2

数值示例:

gap_x = 223px (OCR识别的原图缺口X)SLIDER_OFFSET = 15px (滑块初始偏移)scale = 0.8776 (缩放比)setLeft = parseInt((223 - 15) * 0.8776) = parseInt(182.54) = 182userresponse = 182 / 0.8776 + 2 = 209.44 (≠ 223 + 2 = 225!)

注意 userresponse ≈ 209.44,而不是 225。差了将近 16,这是因为 parseInt 截断和 SLIDER_OFFSET 的双重影响。服务端对这个值的容差很小,偏差过大直接判定为异常。

解决方案:

# Python 中的正确计算setLeft = int(slide_distance)                # 取整截断,不是四舍五入userresponse = setLeft / scale + 2           # 用 setLeft 反算,不是用 gap_x

教训:userresponse 这个名字让人以为是”用户的响应位置”,实际上是一个经过 int截断 → 除以scale → 加2 的数学变换结果。逆向时必须严格追踪数据流,不能望文生义。

18.3 滑块初始偏移 15px:看不见的起点

问题现象: 即使 OCR 缺口识别完全准确,滑动距离总是偏大约 15px。

根因分析:

在极验的 UI 布局中,滑块碎片(slice)并不是从 x=0 开始的。CSS 设置了初始偏移(通过 left 或 transform),大约 15px:

|<-- 15px -->|滑块碎片||            ||  背景图区域

也就是说:

  • OCR 返回的 gap_x 是缺口在原图中的绝对坐标
  • 滑块的起始位置不是 0,而是约 15px
  • 实际需要滑动的距离 = gap_x - 15(原图坐标系),再乘以缩放比
SLIDER_OFFSET = 15  # 滑块初始偏移(像素,原图坐标系)adjusted_gap_x = gap_x - SLIDER_OFFSET    # 实际需要移动的原图距离slide_distance = adjusted_gap_x * scale    # 转换为显示坐标setLeft = int(slide_distance)              # 取整

如何发现这个偏移:

  1. 在浏览器中用 DevTools 检查滑块元素的初始 CSS left/transform 值
  2. 或对比 OCR 返回的缺口 X 坐标与实际抓包 setLeft 的差异
  3. 反推:setLeft_抓包 ≈ (gap_x - 15) * scale

教训: 滑块验证码的坐标系不是从0开始的。这个 15px 偏移在源码中不是一个明确的常量,而是通过 CSS 布局隐式引入的,需要通过抓包对比来确定。

18.4 缩放比公式:0.8876 的由来

问题现象: 直接用 display_width / original_width 作为缩放比,计算出的滑动距离总是有微小偏差。

根因分析:

极验的缩放比并不是简单的 容器宽度 / 原图宽度。源码行 7073-7079 揭示了真正的公式:

// 源码行 7073-7079// 容器宽度 340px,原图宽度 344px// 但实际显示时图片两侧各有间距var scale = 0.8876 * containerWidth / bgWidth;

这个 0.8876 是一个经验系数,来源于 UI 布局:

  • 验证码容器宽度:340px
  • 图片在容器内不是完全贴边的,有 padding/margin
  • 实际图片显示宽度 ≈ 340 × 0.8876 ≈ 301.8px
  • 对于 344px 原图:scale ≈ 301.8 / 344 ≈ 0.8773
# Python 中的缩放比计算CONTAINER_WIDTH = 340scale = 0.8876 * CONTAINER_WIDTH / bg_w# 当 bg_w = 344 时: scale ≈ 0.8776

验证方法:

  1. 在浏览器中拖拽滑块到缺口位置
  2. 抓包获取 setLeft 值
  3. 反推 scale = setLeft / (gap_x - offset)
  4. 与公式计算值对比

教训: 前端渲染的实际尺寸可能受多层 CSS 影响(父容器 padding、图片 max-width、transform scale 等)。不要假设图片会铺满容器,要从源码中提取精确的缩放系数。

18.5 轨迹坐标系混乱:显示坐标 vs 原图坐标

问题现象: 生成的轨迹在 Python 中看起来合理,但提交后服务端判定轨迹异常。

根因分析:

轨迹坐标存在两套坐标系,容易混淆:

显示坐标系 (浏览器渲染后的像素)├── 轨迹生成目标距离: slide_distance = (gap_x - offset* scale├── 轨迹点中的 [x, y, t] 是在显示坐标系下的└── 这是 mousemove 事件实际捕获的像素值原图坐标系 (背景图的原始像素)├── gap_x: OCR 返回的缺口位置├── userresponse: setLeft / scale + 2 (反算回原图坐标)└── 服务端校验用的坐标

关键转换: 轨迹生成在显示坐标系下完成,但传给 encodeTrack() 之前需要转换回原图坐标系

1. 先在显示坐标系下生成轨迹 (目标: slide_distance px)track_display = generate_trajectory(int(slide_distance))2. 转换为原图坐标系 (除以 scale)track = [    [round(p[0] / scale), round(p[1] / scale), p[2]]    for p in track_display]3. 插入初始偏移点 (mousedown 时的随机偏移)initial_offset = [random.randint(-30, -10), random.randint(-1010), 0]track.insert(0, initial_offset)

为什么要转换? 因为浏览器端的轨迹编码函数 $_BBFj 接收的是原图坐标系的轨迹。浏览器端在 mousemove 事件中收集的本来就是显示坐标,但在提交前做了 /scale 的转换(源码行 7115 附近)。我们的 Python 实现必须复现这一步。

教训: 在多层坐标变换的系统中,每个中间变量都要标注它属于哪个坐标系。建议变量命名中带坐标系后缀,如 track_display vs track_original

18.6 轨迹初始偏移点:mousedown 的随机位置

问题现象: 轨迹编码后与抓包对比,发现起始段的编码有差异。

根因分析:

真实用户的 mousedown 事件不会精确地发生在滑块中心。浏览器端记录的第一个轨迹点是相对于滑块中心的随机偏移

// 源码行 7083-7090 (mousedown 处理)// 记录鼠标按下时相对于滑块中心的偏移var offsetX = event.clientX - sliderCenter.x;  // 通常 -30 ~ -10var offsetY = event.clientY - sliderCenter.y;  // 通常 -10 ~ +10track.push([offsetX, offsetY, 0]);             // 时间戳 0

然后是滑动开始的 [0, 0, 0] 归零点,之后才是实际的增量轨迹:

# 完整的轨迹结构track = [    [-2230],      # 初始偏移 (mousedown相对滑块中心)    [0, 0, 0],         # 归零点 (开始滑动的基准)    [3, 0, 18],        # 第一步增量    [5, 1, 32],        # 第二步增量    # ... 更多增量 ...    [1, 0, 1247],      # 最后一步 (mouseup)]

为什么重要: 差分编码 trackDiff() 会计算相邻点的差值。如果缺少初始偏移点,第一个差值就会不自然(从 [0,0,0] 直接跳到大位移),服务端的轨迹分析会判定为机器行为。

解决方案:

# 随机生成合理的初始偏移initial_offset = [    random.randint(-30, -10),   # X: 鼠标在滑块左侧偏上    random.randint(-10, 10),    # Y: 轻微上下偏移    0                           # 时间: 0 (起始时刻)]track.insert(0, initial_offset)

18.7 AES 密钥编码:Latin1 而非 UTF-8

问题现象: 在 Python 中用 key.encode('utf-8') 生成 AES 密钥,加密结果与浏览器端不一致。

根因分析:

guid() 生成的是 16 位 hex 字符串,如 "a3f2e1b4c5d6e7f8"。这个字符串作为 AES-128 密钥时,使用的是 Latin1 (ISO-8859-1) 编码,而非 UTF-8:

// JS 中的密钥编码 (CryptoJS 风格)// 源码行 3891-3895var keyBuf = Buffer.from(key, 'latin1');  // 每个字符 → 1字节var ivBuf = Buffer.from('0000000000000000''latin1');  // IV 同理

对于纯 ASCII 的 hex 字符串,latin1 和 utf-8 的结果相同。但这是一个巧合,不是保证。 如果未来密钥生成逻辑变化(比如包含非 ASCII 字符),编码方式的差异就会导致密钥不匹配。

标准做法:

# Python 中严格使用 latin1 编码key_bytes = key.encode('latin1')   # 16字符 → 16字节iv_bytes = b'0000000000000000'     # 16字节全零(ASCII '0')

注意 IV 不是 b'\x00' * 16(16 个 null 字节),而是 b'0000000000000000'(16 个 ASCII ‘0’ 字符,即 0x30 重复 16 次)。

18.8 RSA 密文长度校验:必须恰好 256 个 hex 字符

问题现象: 偶尔 RSA 加密后的密文长度不是 256 个 hex 字符,导致 verify 失败。

根因分析:

RSA-1024 的密文应该是 128 字节 = 256 个 hex 字符。但由于大数运算的特性,模幂结果的最高位可能为 0,导致 hex 表示的前导零被省略:

// 大数模幂后转 hexvar c = modPow(m, e, n);var hex = c.toString(16);// 如果 c 的最高字节小于 0x10,hex 长度会是奇数// 如果 c 的最高字节为 0x00,hex 长度会少于 256

源码的处理方式是重试直到得到正确长度:

// 源码行 3759-3762var rsaEncryptedKey = RSA.encrypt(key);while (!rsaEncryptedKey || rsaEncryptedKey.length !== 256) {    key = guid();                    // 换一个密钥    rsaEncryptedKey = RSA.encrypt(key);  // 重新加密}

为什么不是补前导零? 理论上可以 padStart(256, '0') 补齐,但原始 JS 源码选择了重试策略。为了严格还原行为,我们的实现也采用重试。

18.9 JSONP 回调名称格式

问题现象: 使用自定义 callback 名称时,极验服务器返回错误。

根因分析:

极验的 JSONP callback 名称有固定格式:geetest_ + 13位时间戳:

def generate_callback():    return f"geetest_{int(time.time() * 1000)}"    # 示例: "geetest_1714233456789"

服务端会校验 callback 名称的格式。如果使用 callback_123 或 jQuery_xxx 这样的名称,请求可能被拒绝或返回格式不同的响应。

JSONP 响应解析:

def parse_jsonp(text):    # 响应格式: geetest_1714233456789({"status":"success","data":{...}})    match = re.search(r'\((\{.*\})\)', text, re.DOTALL)    if match:        return json.loads(match.group(1))    # 兜底: 有时返回纯 JSON(非 JSONP)    return json.loads(text)

18.10 PoW 难度的非整数位处理

问题现象: 当 bits 不是 4 的倍数时(如 bits=5),简单的 startswith('0' * bits) 检查不正确。

根因分析:

PoW 的难度由 bits 字段控制,要求 hash 的前 bits 位为 0。一个 hex 字符代表 4 个二进制位,所以:

  • bits=4
     → hash 以 "0" 开头(1个零hex字符)
  • bits=8
     → hash 以 "00" 开头(2个零hex字符)
  • bits=5
     → hash 前5位为零 → 第一个hex字符必须是 "0",第二个hex字符的最高1位必须为 0(即 ≤ "7"

源码行 2476-2498 处理了这种非整除情况:

full_bits = bits % 4          # 余数部分 (0-3)prefix_len = int(bits / 4)    # 完整hex零的个数prefix = '0' * prefix_len     # 需要匹配的前缀# ...计算 hash...if full_bits == 0:    # 整除: 只需检查前缀    if pow_sign.startswith(prefix):        return resultelse:    # 非整除: 前缀匹配 + 下一个字符需要满足阈值    if pow_sign.startswith(prefix):        next_char = pow_sign[prefix_len]        # full_bits=1 → 最高1位为0 → 字符 ≤ '7' (0xxx)        # full_bits=2 → 最高2位为0 → 字符 ≤ '3' (00xx)        # full_bits=3 → 最高3位为0 → 字符 ≤ '1' (000x)        threshold_map = {1'7'2'3'3'1'}        threshold = threshold_map.get(full_bits, 'f')        if next_char <= threshold:            return result

教训: 很多人实现 PoW 时只考虑 bits 是 4 的倍数的情况。极验的实际部署中,bits 常常为 0(几乎不需要计算),但逻辑必须支持任意值的情况。

18.11 踩坑总结清单

| 序号 | 踩坑点 | 错误做法 | 正确做法 | 影响 ||------|---------|---------|---------|------|1 | gee_guard 字段 | `gee_guard: null` | 不设置该字段 | 验证失败 |2 | userresponse 公式 | `gap_x + 2` | `setLeft / scale + 2` | 验证失败 |3 | 滑块初始偏移 | 从 x=0 开始计算 | 减去 15px 偏移 | 滑动过头 |4 | 缩放系数 | `containerW / imgW` | `0.8876 * containerW / imgW` | 位置偏差 |5 | 轨迹坐标系 | 全程用显示坐标 | 提交前转原图坐标 | 轨迹异常 |6 | 初始偏移点 | 省略不加 | 必须在 track[0] 插入 | 轨迹异常 |7 | AES 密钥编码 | UTF-8 | Latin1 | 解密失败 |8 | RSA 密文长度 | 不校验 | 必须 256 hex 字符 | 验证失败 |9 | JSONP callback | 随意命名 | `geetest_` + 毫秒戳 | 请求被拒 |10 | PoW 非整数位 | 只检查前缀 | 前缀 + 阈值字符 | 罕见场景 |

十九、完整验证流程时序图

用户浏览器                   Python主控                   极验服务器    │                           │                           │    │                           │──── GET /load ────────────>    │                           │<─── JSONP(lot_number,     │    │                           │     bg, slice, pow_detail) │    │                           │                           │    │                           │──── GET bg.png ──────────>│ (static.geetest.com)    │                           │<─── 背景图二进制 ──────────│    │                           │                           │    │                           │──── GET slice.png ────────>    │                           │<─── 滑块碎片图二进制 ──────│    │                           │                           │    │                           │──── POST OCR ────────────>│ (xinyuocr)    │                           │<─── gap_x = 223 ──────────│    │                           │                           │    │                           │ [本地计算]                 │    │                           │ ├── scale = 0.8876*340/344    │                           │ ├── setLeft = int((223-15)*scale)    │                           │ ├── userresponse = setLeft/scale+2    │                           │ ├── 生成轨迹 track[]      │    │                           │ ├── 计算 PoW              │    │                           │ └── Node.js 加密 → w      │    │                           │                           │    │                           │──── GET /verify ──────────>    │                           │     (lot_number, w,       │    │                           │      payload, process_token)    │                           │<─── JSONP(result:success, │    │                           │     pass_token, gen_time)  │    │                           │                           │

二十、相关文件清单与功能说明

geetest_ctf/├── geetest_solver.py        # 主控脚本: 8步验证流程│                             # - load请求 → 下载图片 → OCR → 轨迹 → PoW → 加密 → verify├── trajectory.py            # 拟人化轨迹生成器│                             # - 三段式速度模型(加速/匀速/减速)│                             # - 回弹模拟 + Y轴抖动├── pow_solver.py             # PoW 工作量证明求解器│                             # - 支持 md5/sha1/sha256│                             # - 处理非4倍数bits难度├── gap_detector.py           # 缺口位置识别│                             # - 调用 OCR 平台 API│                             # - 返回原图坐标系的 gap_x└── node_env/    ├── generate_w.js         # Node.js 加密模块    │                         # - guid (随机密钥生成)    │                         # - arrayToHex (字节转hex)    │                         # - trackDiff + encodeTrack (轨迹编码)    │                         # - Base64 (URL-safe编码)    │                         # - AES-128-CBC (对称加密)    │                         # - RSA-1024 PKCS#1 v1.5 (非对称加密)    │                         # - SM4-CBC (国密对称加密)    │                         # - SM2 (国密非对称加密)    │                         # - encryptW (加密调度入口)    │                         # - processInput (stdin→加密→stdout)    │    └── package.json          # Node.js 依赖 (sm-crypto for SM2)

附录 A:关键源码行号速查表

以下行号均基于 gcaptcha4_deobfuscated.js(还原后的 JS 文件):

| 行号范围 | 功能模块 | 关键函数/变量 ||----------|---------|-------------|105-144 | 全局注入 | `_lib``_abo`, UMD包装 |306-312 | 工具函数 | `arrayToHex()` 字节转hex |457-465 | 工具函数 | `guid()` 16位随机hex |1735-1779 | 轨迹编码 | `trackDiff()``encodeDirection()``encodeValue()``encodeTrack()` |2087-2146 | 验证提交 | `$_BBFj` payload 构建 |2455-2504 | PoW | 工作量证明求解 |3750-3778 | 加密入口 | `encryptW()` pt 分发 |3780-3829 | 编码 | Base64 标准/URL-safe 编码 |3881-4148 | 对称加密 | AES-128-CBC (CryptoJS风格) |4149-4510 | 非对称加密 | RSA-1024 PKCS#1 v1.5 |4514-4606 | 国密对称 | SM4-CBC 加密 |4607-6624 | 国密非对称 | SM2 椭圆曲线 (~2000行) |5840-5879 | SM2入口 | SM2 公钥加密调用 |7070-7149 | 事件处理 | mousedown/move/up, scale, setLeft, userresponse |

附录 B:常用调试命令

# 运行完整验证流程python geetest_solver.py# 单独测试 PoWpython pow_solver.py# 单独测试轨迹生成python trajectory.py# 单独测试 Node.js 加密echo '{"setLeft":182,"passtime":1200,"track":[[0,0,0],[5,0,30]],"lot_number":"test","pow_msg":"1|0|md5|||test||abc","pow_sign":"d41d8cd98f00b204e9800998ecf8427e","pt":"1","scale":0.8776}' | node node_env/generate_w.js# 安装 SM2 依赖 (pt=2 场景需要)cd node_env && npm install sm-crypto

文档完成于 2026年4月28日基于极验 GeeTest v4 滑块验证码 gcaptcha4.js 逆向分析—-小肩膀教育

下边是广告环节(群满了加我)
小肩膀教育安全逆向教学
小肩膀教育作为国内十年逆向老机构,数十年如一日录制教程。
涵盖网络爬虫、JS逆向、安卓逆向、IOS逆向、小程序逆向,AI逆向和指纹浏览器开发等多个版块,完全从零基础开始教学。
官网:https://xjbedu.site/
加入小肩膀,是加入了逆向技术圈子,互相学习、资源共享,欢迎加入小肩膀教育。有时候进了圈子,才能掌握逆向技术动态。

海外IP代理
海外IP代理做的人很多,有贵的也有便宜的,那为什么和如意合作呢?
因为和我们合作我们就有了联系,来找如意合作购买海外动态、静态住宅和包月不限量IP的,价格绝对优惠。(不能国内直接连接,需要海外环境)
https://xjbedu.site/proxy

数据采集和网页抓取服务
AI时代,必须有高质量的数据采集服务搭配才可以。
有海外的数据采集需求的:虾皮、亚马逊、谷歌、ebay、雅虎、领英、X、youtube、TIKTOK等等,都可以来和如意合作。以下现成接口:
入群海外数据采集和数据集定制:我们建了群,提供最全的海外数据采集服务和数据集
而且有海外数据采集脚本的,都可以来联系如意,如意带你进官方群,为你推广脚本,只要被调用就有钱拿,合作共赢!帮你推广脚本,联系如意加群。

小肩膀本人联系方式