PageSourceSearch

https://captcha.58pic.com/captcha.js

js 58pic.com collected 2026-10-02 06:57:24 UTC 31,946 bytes, 799 lines download raw bytes

1/**
2 * 58pic Captcha SDK v1.0
3 * 行为验证码 Web/H5 SDK
4 *
5 * 使用方式:
6 *   const captcha = new CaptchaSDK({ captchaId: 'your_captcha_id', apiBase: '/v1/captcha' });
7 *   captcha.render('#captcha-container');
8 *   captcha.onSuccess(validate => { // 提交 validate 到业务后端 });
9 */
10(function (global) {
11  'use strict';
12
13  // ─── 脚本动态加载 ─────────────────────────────────────────────────────────────
14
15  function _loadScript(src) {
16    return new Promise(function (resolve, reject) {
17      if (document.querySelector('script[src="' + src + '"]')) { resolve(); return; }
18      var s = document.createElement('script');
19      s.src = src; s.onload = resolve; s.onerror = reject;
20      document.head.appendChild(s);
21    });
22  }
23
24  function _settle(promise) {
25    return Promise.resolve(promise).then(function (value) {
26      return { status: 'fulfilled', value: value };
27    }, function (reason) {
28      return { status: 'rejected', reason: reason };
29    });
30  }
31
32  function _buildQuery(params) {
33    var parts = [];
34    Object.keys(params).forEach(function (key) {
35      if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
36        parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
37      }
38    });
39    return parts.join('&');
40  }
41
42  function _dynamicImport(src) {
43    try {
44      return new Function('src', 'return ' + 'import(src)')(src);
45    } catch (e) {
46      return Promise.reject(e);
47    }
48  }
49
50  // ─── 加密工具 ────────────────────────────────────────────────────────────────
51
52  const Crypto = {
53    /**
54     * 生成随机 AES-256-GCM 密钥
55     */
56    async genAESKey() {
57      return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']);
58    },
59
60    /**
61     * AES-GCM 加密,返回 { iv, ciphertext } 均为 Uint8Array
62     */
63    async aesEncrypt(key, plaintext) {
64      const iv = crypto.getRandomValues(new Uint8Array(12));
65      const enc = new TextEncoder();
66      const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(plaintext));
67      return { iv, ciphertext: new Uint8Array(ct) };
68    },
69
70    /**
71     * 用 RSA 公钥(PKCS#8 PEM)加密 AES key(RSA-OAEP-SHA256)
72     */
73    async rsaEncryptAESKey(pubKeyPem, aesKey) {
74      const rawKey = await crypto.subtle.exportKey('raw', aesKey);
75      const pubKey = await Crypto._importRSAPub(pubKeyPem);
76      const enc = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, pubKey, rawKey);
77      return new Uint8Array(enc);
78    },
79
80    async _importRSAPub(pem) {
81      const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, '');
82      const der = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
83      return crypto.subtle.importKey(
84        'spki', der.buffer,
85        { name: 'RSA-OAEP', hash: 'SHA-256' },
86        false, ['encrypt']
87      );
88    },
89
90    toBase64(buf) {
91      return btoa(String.fromCharCode(...new Uint8Array(buf)));
92    },
93
94    /**
95     * 打包行为数据为 w 参数(Base64 JSON)
96     */
97    async packW(pubKeyPem, pubKeyId, payload) {
98      const aesKey = await Crypto.genAESKey();
99      const { iv, ciphertext } = await Crypto.aesEncrypt(aesKey, JSON.stringify(payload));
100      const encKey = await Crypto.rsaEncryptAESKey(pubKeyPem, aesKey);
101      const pkt = {
102        enc_key: Crypto.toBase64(encKey),
103        iv: Crypto.toBase64(iv),
104        ct: Crypto.toBase64(ciphertext),
105        kid: pubKeyId,
106      };
107      return btoa(JSON.stringify(pkt));
108    },
109  };
110
111  // ─── 行为采集 ────────────────────────────────────────────────────────────────
112
113  const Collector = {
114    /**
115     * 采集设备与环境信息
116     */
117    device() {
118      const canvas = document.createElement('canvas');
119      const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
120      return {
121        ua: navigator.userAgent,
122        screen: `${screen.width}x${screen.height}`,
123        dpr: window.devicePixelRatio || 1,
124        tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
125        lang: navigator.language,
126        canvas_fp: Collector._canvasFP(),
127        webgl_fp: gl ? Collector._webglFP(gl) : '',
128        fonts_cnt: 0, // M2 补充字体列表探测
129      };
130    },
131
132    env() {
133      return {
134        webdriver: !!navigator.webdriver,
135        headless: /HeadlessChrome/.test(navigator.userAgent),
136        plugins_cnt: navigator.plugins ? navigator.plugins.length : 0,
137        // 检测常见自动化框架特征
138        phantom: !!(window.callPhantom || window._phantom),
139        selenium: !!window.__selenium_evaluate || !!document.__selenium_evaluate,
140      };
141    },
142
143    _canvasFP() {
144      try {
145        const c = document.createElement('canvas');
146        const ctx = c.getContext('2d');
147        ctx.fillStyle = '#f60';
148        ctx.fillRect(0, 0, 60, 20);
149        ctx.fillStyle = '#069';
150        ctx.font = '14px Arial';
151        ctx.fillText('58pic', 2, 15);
152        return c.toDataURL().slice(-32);
153      } catch (_) { return ''; }
154    },
155
156    _webglFP(gl) {
157      try {
158        const ext = gl.getExtension('WEBGL_debug_renderer_info');
159        return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL).slice(0, 32) : '';
160      } catch (_) { return ''; }
161    },
162
163    /**
164     * 在指定元素上绑定轨迹采集,返回 stop 函数
165     */
166    bindTrack(el, onTrack) {
167      const points = [];
168      const startTime = Date.now();
169
170      function record(x, y) {
171        points.push([Math.round(x), Math.round(y * 100) / 100, Date.now() - startTime]);
172      }
173
174      function onMouseMove(e) { record(e.clientX, e.clientY); }
175      function onTouchMove(e) {
176        const t = e.touches[0];
177        record(t.clientX, t.clientY);
178        e.preventDefault();
179      }
180
181      el.addEventListener('mousemove', onMouseMove);
182      el.addEventListener('touchmove', onTouchMove, { passive: false });
183
184      return function stop() {
185        el.removeEventListener('mousemove', onMouseMove);
186        el.removeEventListener('touchmove', onTouchMove);
187        onTrack(points);
188      };
189    },
190  };
191
192  // ─── API 客户端 ───────────────────────────────────────────────────────────────
193
194  function CaptchaAPI(apiBase) {
195    async function request(method, path, body) {
196      const res = await fetch(apiBase + path, {
197        method,
198        headers: { 'Content-Type': 'application/json' },
199        body: body ? JSON.stringify(body) : undefined,
200      });
201      const json = await res.json();
202      if (json.code !== 0) throw Object.assign(new Error(json.msg), { code: json.code });
203      return json.data;
204    }
205
206    return {
207      register(captchaId, clientType, riskAction, captchaType) {
208        const qs = _buildQuery({
209          captcha_id: captchaId,
210          client_type: clientType,
211          risk_action: riskAction,
212          captcha_type: captchaType,
213        });
214        return request('GET', `/register?${qs}`);
215      },
216      load(lotNumber) {
217        return request('GET', `/load?lot_number=${lotNumber}`);
218      },
219      async verify(lotNumber, captchaId, w) {
220        const res = await fetch(apiBase + '/verify', {
221          method: 'POST',
222          headers: { 'Content-Type': 'application/json' },
223          body: JSON.stringify({ lot_number: lotNumber, captcha_id: captchaId, w }),
224        });
225        const json = await res.json();
226        if (json.code === 2001) {
227          return Object.assign({ captcha_failed: true, msg: json.msg }, json.data || {});
228        }
229        if (json.code !== 0) throw Object.assign(new Error(json.msg), { code: json.code });
230        return json.data;
231      },
232    };
233  }
234
235  // ─── 滑块 UI ─────────────────────────────────────────────────────────────────
236
237  const CSS = `
238.cp-wrap { position:relative; width:320px; background:#f7f7f7; border:1px solid #ddd; border-radius:4px; user-select:none; }
239.cp-img-box { position:relative; width:320px; height:160px; overflow:hidden; }
240.cp-bg { display:block; width:100%; height:100%; }
241.cp-slice { position:absolute; top:0; left:0; width:60px; height:60px; cursor:pointer; transition:left 0s; }
242.cp-bar { position:relative; height:40px; background:#f0f0f0; border-top:1px solid #ddd; border-radius:0 0 4px 4px; }
243.cp-track { position:absolute; left:0; top:0; height:100%; background:#d0e8ff; border-radius:0 0 0 4px; width:0; }
244.cp-btn { position:absolute; left:0; top:0; width:40px; height:40px; background:#fff; border:1px solid #ccc; border-radius:3px; cursor:grab; display:flex; align-items:center; justify-content:center; font-size:18px; }
245.cp-btn:active { cursor:grabbing; }
246.cp-tip { position:absolute; left:0; top:0; width:100%; height:100%; display:flex; align-items:center; justify-content:center; color:#999; font-size:13px; pointer-events:none; }
247.cp-status { position:absolute; left:0; top:0; width:100%; height:100%; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:bold;
247 opacity:0; transition:opacity 0.3s; border-radius:0 0 4px 4px; pointer-events:none; }
248.cp-status.show { opacity:1; }
249.cp-status.ok { background:#d4f7d4; color:#2d7a2d; }
250.cp-status.fail { background:#ffd4d4; color:#a00; }
251.cp-refresh { position:absolute; right:6px; top:6px; cursor:pointer; font-size:16px; color:#999; background:rgba(255,255,255,.7); border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; }
252.cp-loading { position:absolute; inset:0; background:rgba(255,255,255,.8); display:flex; align-items:center; justify-content:center; font-size:13px; color:#999; }
253/* ── 滑块 v2 ── */
254.cp-v2-wrap{position:relative;width:320px;background:#fff;border-radius:8px;user-select:none;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.1);}
255.cp-v2-accent{height:4px;background:linear-gradient(90deg,#1677ff,#40a9ff);}
256.cp-v2-title{padding:10px 14px 8px;font-size:14px;color:#333;text-align:center;font-weight:500;}
257.cp-v2-img-box{position:relative;width:320px;height:160px;overflow:hidden;}
258.cp-v2-bg{display:block;width:100%;height:100%;object-fit:cover;}
259.cp-v2-slice{position:absolute;left:0;width:auto;height:auto;pointer-events:none;}
260.cp-v2-bar-wrap{padding:8px 0 6px;}
261.cp-v2-bar{position:relative;height:44px;background:#f0f2f5;border-radius:22px;}
262.cp-v2-fill{position:absolute;left:0;top:0;height:100%;background:#dbeafe;border-radius:22px;width:44px;}
263.cp-v2-btn{position:absolute;left:0;top:0;width:44px;height:44px;background:#1677ff;border-radius:50%;cursor:grab;display:flex;align-items:center;justify-content:center;color:#fff;font-size:20px;box-shadow:0 2px 6px rgba(22,119,255,.5);z-index:2;}
264.cp-v2-btn:active{cursor:grabbing;}
265.cp-v2-tip{position:absolute;left:0;top:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#bbb;font-size:13px;pointer-events:none;padding-left:50px;}
266.cp-v2-status{position:absolute;left:0;right:0;top:0;bottom:0;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:bold;opacity:0;transition:opacity .3s;border-radius:22px;pointer-events:none;}
267.cp-v2-status.show{opacity:1;}
268.cp-v2-status.ok{background:rgba(212,247,212,.9);color:#2d7a2d;}
269.cp-v2-status.fail{background:rgba(255,212,212,.9);color:#a00;}
270.cp-v2-actions{display:flex;align-items:center;gap:18px;padding:4px 14px 10px;}
271.cp-v2-act{display:inline-flex;align-items:center;justify-content:center;color:#ccc;cursor:pointer;transition:color .15s;}
272.cp-v2-act:hover{color:#888;}
273/* ── 点选 v2 ── */
274.cp-ck-wrap{position:relative;width:320px;background:#fff;border-radius:8px;user-select:none;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.1);}
275.cp-ck-header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;}
276.cp-ck-instr{font-size:13px;color:#444;}
277.cp-ck-chars{border:2px solid #222;border-radius:2px;padding:2px 8px;font-size:16px;font-weight:700;color:#111;letter-spacing:2px;}
278.cp-ck-img-box{position:relative;width:320px;height:160px;overflow:hidden;cursor:pointer;}
279.cp-ck-bg{display:block;width:100%;height:100%;object-fit:cover;}
280.cp-ck-dot{position:absolute;width:26px;height:26px;border-radius:50%;background:rgba(22,119,255,.9);border:2px solid #fff;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px;font-weight:bold;pointer-events:none;box-shadow:0 1px 4px rgba(0,0,0,.3);}
281.cp-ck-btn-row{padding:8px 12px 6px;}
282.cp-ck-confirm{width:100%;height:36px;border:none;border-radius:4px;font-size:14px;background:#dce9f9;color:#a0bfe6;cursor:not-allowed;transition:background .2s,color .2s;}
283.cp-ck-confirm.ready{background:#1677ff;color:#fff;cursor:pointer;}
284.cp-ck-actions{display:flex;align-items:center;gap:18px;padding:4px 14px 10px;}
285.cp-ck-act{display:inline-flex;align-items:center;justify-content:center;color:#ccc;cursor:pointer;transition:color .15s;}
286.cp-ck-act:hover{color:#888;}
287`;
288
289  function injectCSS() {
290    if (document.getElementById('cp-style')) return;
291    const s = document.createElement('style');
292    s.id = 'cp-style';
293    s.textContent = CSS;
294    document.head.appendChild(s);
295  }
296
297  // ─── 主类 ────────────────────────────────────────────────────────────────────
298
299  function CaptchaSDK(opts) {
300    const cfg = Object.assign({
301      captchaId: '',
302      apiBase: '/v1/captcha',
303      clientType: 'web',
304      riskAction: '',
305    }, opts);
306
307    const api = CaptchaAPI(cfg.apiBase);
308    let _onSuccess = null;
309    let _onFail = null;
310    let _onNeedUI = null;
311    let _container = null;
312    let _session = null;
313    let _imgData = null;
314    let _validate = null;
315
316    // DOM 节点引用
317    let elWrap, elBg, elSlice, elBtn, elTrack, elStatus, elLoading;
318
319    // ─── FingerprintJS + Botd 初始化(SDK 创建时立即启动,与 register/load 并行)
320    const _libBase = cfg.apiBase;
321
322    const _fpPromise = (async function () {
323      try {
324        await _loadScript(_libBase + '/lib/fp.min.js');
325        const fp = await global.FingerprintJS.load();
326        return await fp.get();
327      } catch (_) { return null; }
328    }());
329
330    const _botdPromise = (async function () {
331      try {
332        const mod = await _dynamicImport(_libBase + '/lib/botd.esm.js');
333        const botd = await mod.load();
334        return await botd.detect();
335      } catch (_) { return null; }
336    }());
337
338    function render(selector) {
339      _container = typeof selector === 'string'
340        ? document.querySelector(selector) : selector;
341      if (!_container) throw new Error('container not found: ' + selector);
342      injectCSS();
343      _load();
344    }
345
346    function _buildDOM() {
347      _container.innerHTML = '';
348      elWrap = _el('div', 'cp-v2-wrap');
349
350      const accent = _el('div', 'cp-v2-accent');
351
352      const title = _el('div', 'cp-v2-title');
353      title.textContent = '请拖动滑块完成拼图';
354
355      const imgBox = _el('div', 'cp-v2-img-box');
356      elBg    = _el('img', 'cp-v2-bg');
357      elBg.draggable = false;
358      elSlice = _el('img', 'cp-v2-slice');
359      elSlice.draggable = false;
360      elLoading = _el('div', 'cp-loading');
361      elLoading.textContent = '加载中…';
362      imgBox.append(elBg, elSlice, elLoading);
363
364      const barWrap = _el('div', 'cp-v2-bar-wrap');
365      const bar = _el('div', 'cp-v2-bar');
366      elTrack  = _el('div', 'cp-v2-fill');
367      elBtn    = _el('div', 'cp-v2-btn');
368      elBtn.textContent = '→';
369      const tip = _el('div', 'cp-v2-tip');
370      tip.textContent = '向右拖动完成验证';
371      elStatus = _el('div', 'cp-v2-status');
372      bar.append(elTrack, elBtn, tip, elStatus);
373      barWrap.appendChild(bar);
374
375      const actions = _el('div', 'cp-v2-actions');
376      const closeAct = _el('span', 'cp-v2-act');
377      closeAct.title = '关闭';
378      closeAct.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
379      closeAct.addEventListener('click', () => { if (_onFail) _onFail(new Error('user_cancelled')); });
380      const refreshAct = _el('span', 'cp-v2-act');
381      refreshAct.title = '刷新';
382      refreshAct.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
383      refreshAct.addEventListener('click', _load);
384      actions.append(closeAct, refreshAct);
385
386      elWrap.append(accent, title, imgBox, barWrap, actions);
387      _container.appendChild(elWrap);
388
389      _bindDrag();
390    }
391
392    async function _load(forceCaptchaType) {
393      _resetUI();
394      _validate = null;
395
396      try {
397        const reg = await api.register(cfg.captchaId, cfg.clientType, cfg.riskAction, forceCaptchaType);
398        const pubKeyPem = reg.encrypt && reg.encrypt.pubkey_id
399          ? await _fetchPubKey(reg.encrypt.pubkey_id)
400          : null;
401
402        _session = {
403          lotNumber:   reg.lot_number,
404          challenge:   reg.challenge,
405          pubKeyId:    reg.encrypt ? reg.encrypt.pubkey_id : '',
406          pubKeyPem,
407          captchaType: reg.captcha_type,
408        };
409
410        const imgData = await api.load(reg.lot_number);
411
412        if (imgData.captcha_type === 'invisible') {
413          // 无感模式:不构建任何 UI,直接静默采集并提交
414          await _invisibleVerify();
415          return;
416        }
417
418        // 需要用户交互:先通知业务层展示容器,再构建 UI
419        if (_onNeedUI) _onNeedUI(imgData.captcha_type);
420
421        if (imgData.captcha_type === 'click') {
422          _buildClickDOM(imgData);
423          return;
424        }
425
426        // 滑块流程:先构建 DOM 再填充图片
427        _buildDOM();
428        _setLoading(true);
429        _imgData = imgData;
430        elBg.src = imgData.bg;
431        elBg.onload = () => {
432          elSlice.src = imgData.slice;
433          elSlice.style.top = imgData.slice_y + 'px';
434          elSlice.style.left = '0px';
435          _setLoading(false);
436        };
437      } catch (e) {
438        console.error('[captcha] load error:', e);
439        if (elStatus) _setStatus('加载失败,请刷新', false);
440      }
441    }
442
443    // ─── 点选验证 UI ──────────────────────────────────────────────────────────────
444
445    function _buildClickDOM(imgData) {
446      _container.innerHTML = '';
447
448      // 从 prompt "请依次点击:验、证" 中提取字符部分
449      const raw = imgData.prompt || '';
450      const sep = raw.search(/[::]/);
451      const charsDisplay = sep >= 0
452        ? raw.slice(sep + 1).replace(/[、,,\s]/g, '')
453        : raw;
454
455      const wrap = _el('div', 'cp-ck-wrap');
456
457      // 顶部标题栏
458      const header = _el('div', 'cp-ck-header');
459      const instr = _el('span', 'cp-ck-instr');
460      instr.textContent = '请在下图依次点击';
461      const charsBox = _el('span', 'cp-ck-chars');
462      charsBox.textContent = charsDisplay;
463      header.append(instr, charsBox);
464
465      // 图片区
466      const imgBox = _el('div', 'cp-ck-img-box');
467      const bg = _el('img', 'cp-ck-bg');
468      bg.src = imgData.bg;
469      bg.draggable = false;
470      imgBox.appendChild(bg);
471
472      // 确定按钮行
473      const btnRow = _el('div', 'cp-ck-btn-row');
474      const confirmBtn = _el('button', 'cp-ck-confirm');
475      confirmBtn.textContent = '确 定';
476      confirmBtn.disabled = true;
477      btnRow.appendChild(confirmBtn);
478
479      // 底部操作栏
480      const actions = _el('div', 'cp-ck-actions');
481      const closeAct = _el('span', 'cp-ck-act');
482      closeAct.title = '关闭';
483      closeAct.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
484      closeAct.addEventListener('click', () => { if (_onFail) _onFail(new Error('user_cancelled')); });
485      const refreshAct = _el('span', 'cp-ck-act');
486      refreshAct.title = '刷新';
487      refreshAct.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
488      refreshAct.addEventListener('click', _load);
489      actions.append(closeAct, refreshAct);
490
491      wrap.append(header, imgBox, btnRow, actions);
492      _container.appendChild(wrap);
493
494      // 点击采集逻辑
495      const clicks = [];
496      const targetCount = imgData.target_count || 2;
497
498      imgBox.addEventListener('click', function (e) {
499        if (_validate || clicks.length >= targetCount) return;
500        const rect = imgBox.getBoundingClientRect();
501        const x = Math.round(e.clientX - rect.left);
502        const y = Math.round(e.clientY - rect.top);
503
504        const dot = _el('div', 'cp-ck-dot');
505        dot.textContent = clicks.length + 1;
506        dot.style.left = x + 'px';
507        dot.style.top = y + 'px';
508        imgBox.appendChild(dot);
509        clicks.push([x, y]);
510
511        if (clicks.length >= targetCount) {
512          imgBox.style.pointerEvents = 'none';
513          confirmBtn.disabled = false;
514          confirmBtn.classList.add('ready');
515        }
516      });
517
518      confirmBtn.addEventListener('click', async function () {
519        if (confirmBtn.disabled || clicks.length < targetCount) return;
520        confirmBtn.disabled = true;
521        confirmBtn.classList.remove('ready');
522        confirmBtn.textContent = '验证中…';
523        await _clickVerify(clicks.slice());
524      });
525    }
526
527    async function _clickVerify(clicks) {
528      if (!_session) return;
529      const fp = await _collectFingerprint();
530      const payload = {
531        challenge: _session.challenge,
532        setLeft: 0, passtime: 0, track: [],
533        clicks,
534        ...fp,
535        device: Collector.device(),
536        env:    Collector.env(),
537      };
538
539      let w;
540      try {
541        if (_session.pubKeyPem) {
542          w = await Crypto.packW(_session.pubKeyPem, _session.pubKeyId, payload);
543        } else {
544          w = btoa(JSON.stringify(payload));
545        }
546      } catch (e) { return; }
547
548      try {
549        const res = await api.verify(_session.lotNumber, cfg.captchaId, w);
550        if (res.captcha_failed) throw Object.assign(new Error(res.msg || 'verify failed'), res);
551        _validate = res.validate;
552        const btn = _container.querySelector('.cp-ck-confirm');
553        if (btn) { btn.style.background = '#52c41a'; btn.style.color = '#fff'; btn.textContent = '验证通过 ✓'; }
554        if (_onSuccess) _onSuccess(_validate, _successMeta());
555      } catch (e) {
556        const btn = _container.querySelector('.cp-ck-confirm');
557        if (btn) { btn.style.background = '#ff4d4f'; btn.style.color = '#fff'; btn.textContent = '验证失败,请重试'; }
558        setTimeout(_load, 1500);
559        if (_onFail) _onFail(e);
560      }
561    }
562
563    // 无感验证:收集设备和环境信号,无需用户交互
564    // 等待 FP + Botd 结果,任一失败静默降级
565    async function _collectFingerprint() {
566      const [fpResult, botdResult] = await Promise.all([_settle(_fpPromise), _settle(_botdPromise)]);
567
568      const fpVal   = fpResult.status   === 'fulfilled' ? fpResult.value   : null;
569      const botdVal = botdResult.status === 'fulfilled' ? botdResult.value : null;
570
571      // 从 FingerprintJS components 提取用于一致性校验的原始信号
572      let fpComponents = {};
573      if (fpVal && fpVal.components) {
574        const c = fpVal.components;
575        const touchValue = c.touchSupport ? c.touchSupport.value : null;
576        fpComponents = {
577          fp_platform:     c.platform        ? String(c.platform.value || '')                : '',
578          fp_touch_points: touchValue && touchValue.maxTouchPoints != null ? Number(touchValue.maxTouchPoints) : -1,
579          fp_screen:       c.screenResolution ? (c.screenResolution.value || [])              : [],
580          fp_hw_concurrency: c.hardwareConcurrency && c.hardwareConcurrency.value != null ? Number(c.hardwareConcurrency.value) : 0,
581          fp_color_depth:  c.colorDepth && c.colorDepth.value != null ? Number(c.colorDepth.value) : 0,
582        };
583      }
584
585      return {
586        visitor_id:   fpVal   ? fpVal.visitorId            : '',
587        bot_detected: botdVal ? !!botdVal.bot              : false,
588        bot_kind:     botdVal ? (botdVal.botKind || '')    : '',
589        ...fpComponents,
590      };
591    }
592
593    async function _invisibleVerify() {
594      if (!_session) return;
595
596      const fp = await _collectFingerprint();
597      const payload = {
598        challenge: _session.challenge,
599        setLeft:   0,
600        passtime:  0,
601        track:     [],
602        ...fp,
603        device:    Collector.device(),
604        env:       Collector.env(),
605      };
606
607      let w;
608      try {
609        if (_session.pubKeyPem) {
610          w = await Crypto.packW(_session.pubKeyPem, _session.pubKeyId, payload);
611        } else {
612          w = btoa(JSON.stringify(payload));
613          console.warn('[captcha] pubkey not available, sending unencrypted w (dev only)');
614        }
615      } catch (e) {
616        console.error('[captcha] encrypt error:', e);
617        return;
618      }
619
620      try {
621        const res = await api.verify(_session.lotNumber, cfg.captchaId, w);
622
623        if (res.next_captcha_type === 'slide' || res.next_captcha_type === 'click') {
624          await _load(res.next_captcha_type);
625          return;
626        }
627        if (res.captcha_failed) throw Object.assign(new Error(res.msg || 'verify failed'), res);
628
629        _validate = res.validate;
630        if (_onSuccess) _onSuccess(_validate, _successMeta());
631      } catch (e) {
632        if (_onFail) _onFail(e);
633      }
634    }
635
636    // 从服务端拉取 RSA 公钥
637    async function _fetchPubKey(kid) {
638      try {
639        const res = await fetch(`${cfg.apiBase}/pubkey?kid=${encodeURIComponent(kid)}`);
640        const json = await res.json();
641        if (json.code === 0 && json.data && json.data.pubkey) return json.data.pubkey;
642      } catch (_) {}
643      return window.__CAPTCHA_PUBKEY__ || null; // 降级:全局变量
644    }
645
646    function _bindDrag() {
647      let dragging = false;
648      let startX = 0;
649      let startTrack = null;
650      let trackPoints = [];
651
652      function onStart(clientX) {
653        if (!_imgData || _validate) return;
654        dragging = true;
655        startX = clientX;
656        trackPoints = [];
657        startTrack = Collector.bindTrack(elWrap, pts => { trackPoints = pts; });
658        elBtn.style.transition = 'none';
659        elTrack.style.transition = 'none';
660      }
661
662      function onMove(clientX) {
663        if (!dragging) return;
664        const dx = Math.max(0, Math.min(clientX - startX, 320 - 44));
665        elBtn.style.left = dx + 'px';
666        elTrack.style.width = (dx + 44) + 'px';
667        elSlice.style.left = dx + 'px';
668      }
669
670      async function onEnd(clientX) {
671        if (!dragging) return;
672        dragging = false;
673        const stopTrack = startTrack;
674        startTrack = null;
675        if (stopTrack) stopTrack(); // 触发 onTrack 回调,填充 trackPoints
676
677        const setLeft = Math.round(Math.max(0, Math.min(clientX - startX, 320 - 44)));
678        const passtime = trackPoints.length
679          ? trackPoints[trackPoints.length - 1][2] - (trackPoints[0] ? trackPoints[0][2] : 0)
680          : 0;
681
682        await _verify(setLeft, passtime, trackPoints);
683      }
684
685      // Mouse
686      elBtn.addEventListener('mousedown', e => { onStart(e.clientX); e.preventDefault(); });
687      document.addEventListener('mousemove', e => onMove(e.clientX));
688      document.addEventListener('mouseup', e => onEnd(e.clientX));
689
690      // Touch
691      elBtn.addEventListener('touchstart', e => {
692        onStart(e.touches[0].clientX);
693        e.preventDefault();
694      }, { passive: false });
695      document.addEventListener('touchmove', e => {
696        if (dragging) onMove(e.touches[0].clientX);
697      });
698      document.addEventListener('touchend', e => {
699        if (dragging) onEnd(e.changedTouches[0].clientX);
700      });
701    }
702
703    async function _verify(setLeft, passtime, track) {
704      if (!_session) return;
705
706      const fp = await _collectFingerprint();
707      const payload = {
708        challenge: _session.challenge,
709        setLeft,
710        passtime,
711        track,
712        ...fp,
713        device: Collector.device(),
714        env: Collector.env(),
715      };
716
717      let w;
718      try {
719        if (_session.pubKeyPem) {
720          w = await Crypto.packW(_session.pubKeyPem, _session.pubKeyId, payload);
721        } else {
722          // 无公钥降级:base64 明文(仅开发调试,生产必须加密)
723          w = btoa(JSON.stringify(payload));
724          console.warn('[captcha] pubkey not available, sending unencrypted w (dev only)');
725        }
726      } catch (e) {
727        console.error('[captcha] encrypt error:', e);
728        return;
729      }
730
731      try {
732        const res = await api.verify(_session.lotNumber, cfg.captchaId, w);
733
734        if (res.next_captcha_type === 'click') {
735          // 滑块判定不通过,升级点选
736          await _load('click');
737          return;
738        }
739        if (res.captcha_failed) throw Object.assign(new Error(res.msg || 'verify failed'), res);
740
741        _validate = res.validate;
742        _setStatus('验证通过', true);
743        elBtn.style.transition = '';
744        if (_onSuccess) _onSuccess(_validate, _successMeta());
745      } catch (e) {
746        _setStatus('验证失败,请重试', false);
747        setTimeout(_load, 1500);
748        if (_onFail) _onFail(e);
749      }
750    }
751
752    function _resetUI() {
753      elBtn && (elBtn.style.left = '0px');
754      elTrack && (elTrack.style.width = '44px');
755      elStatus && elStatus.classList.remove('show', 'ok', 'fail');
756    }
757
758    function _setStatus(msg, ok) {
759      elStatus.textContent = msg;
760      elStatus.className = 'cp-v2-status show ' + (ok ? 'ok' : 'fail');
761    }
762
763    function _setLoading(show) {
764      if (elLoading) elLoading.style.display = show ? 'flex' : 'none';
765    }
766
767    function _el(tag, cls) {
768      const el = document.createElement(tag);
769      if (cls) el.className = cls;
770      return el;
771    }
772
773    function _successMeta() {
774      return {
775        lotNumber: _session ? _session.lotNumber : null,
776        captchaType: _session ? _session.captchaType : null,
777      };
778    }
779
780    return {
781      render,
782      onSuccess(fn) { _onSuccess = fn; return this; },
783      onFail(fn)    { _onFail    = fn; return this; },
784      onNeedUI(fn)  { _onNeedUI  = fn; return this; },
785      reset: _load,
786      getValidate()  { return _validate; },
787      getLotNumber() { return _session ? _session.lotNumber : null; },
788    };
789  }
790
791  // 暴露到全局;浏览器环境始终挂 window,兼容页面上其他脚本污染 module 变量的情况
792  if (typeof window !== 'undefined') {
793    window.CaptchaSDK = CaptchaSDK;
794  }
795  if (typeof module !== 'undefined' && module.exports) {
796    module.exports = CaptchaSDK;
797  }
798
799}(typeof window !== 'undefined' ? window : this));

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.