接入(推荐):WeiyanGuard

一行 script + 一个 guard。appId 是本站应用标识;接口地址从脚本自身推导, 引擎与版本由 api.php?act=cfg 下发,页面里没有版本号可改。

<script src="//captcha.llua.cn/v4/wcaptcha.js?v=261101.10" data-app-id="demo"></script>
<script>
WeiyanGuard.init();                 // 预建实例,首屏就把题备好
document.querySelector('#login').addEventListener('click', function () {
  WeiyanGuard.guard(function (cap) {
    // cap = { captcha_id, captcha_randstr }
    // 一起提交给业务后端,后端带 app_key 调 api.php?act=verify 核销
  });
});
</script>

浏览器侧只碰这四个地址:GET api.php?act=cfg 取配置, POST act=gen 出题,POST act=check 判定, POST act=abandon 作废(关闭或换题时自动发)。 另有 POST act=tel 是匿名运行环境上报,后台可关。请求/响应是本站自己的 {code,msg,data} 信封。

字段也是本站自己的:题面是 id / bgWidth / sliderWidth / pow / proof, 通过凭证是 {captcha_id, captcha_randstr}。没有 lot_number / captcha_output / pass_token / gen_time 这一套。

正在加载 api.php?act=cfg 与引擎…

服务端核销:POST api.php?act=verify

业务后端拿 captcha_id + captcha_randstr,配 app_key 直接提交。 不需要签名,app_key 本身就是凭据 —— 所以只能放服务端。

POST https://captcha.llua.cn/api.php?act=verify
Content-Type: application/json

{
  "appid": "demo",
  "app_key": "…",
  "id": "SLIDER_…",
  "randstr": "…",
  "idempotency_key": "order-20261101-0001"
}

成功时:

{
  "code": 200, "msg": "OK",
  "data": true,
  "evil_level": 0,
  "captcha_args": {
    "lot_number": "SLIDER_…",
    "user_ip": "192.168.31.50",
    "referer": "https://example.com/",
    "used_type": "slide",
    "scene": "demo"
  }
}

只认 data === true。票据无效或已经用过时,返回的是 {"code":200,"msg":"OK","data":false} —— HTTP 状态码依然是 200, 别拿 HTTP 码判断放行。只有 app_key 不对才是 HTTP 403。 票据一次性,核销成功即作废。

captcha_args 是对账材料,不是判据:user_ip 是过验证那台机器的 IP, referer 是来源页,used_type 是题型,scene 是应用。 拿它和本次业务请求的 IP 比一比,就能发现「A 机过验证、票据转给 B 机用」。
evil_level 取 0 未发现异常 / 1 可疑 / 2 明确恶意, 不影响 data,只给二次判定用。

// 业务后端(PHP),app_key 只在服务端
$res = json_decode(file_get_contents('https://captcha.llua.cn/api.php?act=verify', false,
  stream_context_create(['http' => [
    'method'  => 'POST',
    'header'  => "Content-Type: application/json\r\n",
    'content' => json_encode([
      'appid'           => $appId,
      'app_key'         => $appKey,
      'id'              => $captchaId,
      'randstr'         => $randstr,
      'idempotency_key' => $orderNo,   // 可选,重试时复用同一个
    ]),
  ]])), true);

if (($res['data'] ?? false) === true) { /* 放行 */ }

核销请求超时的时候不要直接判失败:带 idempotency_key(8–64 位, [A-Za-z0-9_-])重试,同一个 key 拿到第一次的结果;换 key 再来必然 data:false。

内嵌形态:mode:'inline'

不弹层,把题面挂进页面里的容器。通过后不自动收起,由接入方决定何时 close() 或换一道题。需要这种精细控制时用 Captcha4.init —— 它和 WeiyanGuard 在同一个 SDK 文件里,只是给的是完整实例。

<div id="capBox"></div>
<script>
Captcha4.init({ appId: 'demo', mode: 'inline' }, function (cap) {
  cap.onReady(function () {})
     .onPass(function (p) {
       // p = { challenge_id, proof, ticket }
     })
     .onError(function (e) {
       // { code, message, detail }
     })
     .appendTo('#capBox');
});
</script>

mode 不传就用配置里的 ui.mode。 接入方显式写了就按自己的来。常用方法: open() / close() / refresh() / reset() / destroy() / appendTo('#box') / getProof()。

回调与取结果

getProof() 随时可取;没通过时返回 null。 浮层形态通过后自动关闭;内嵌形态不自动关。 被风控冷却时不会产生任何成功回调,控件显示「操作过于频繁,请稍后再试」并倒计时, 业务侧拿不到凭证 —— 拿到 getProof() 才意味着验证通过。

var proof = cap.getProof();
// { challenge_id: "SLIDER_…", proof: "…", ticket: "…" }

出题验证(aidEncrypted)

按应用开启,默认关闭。开启后 gen 必须带一个由你的服务端用 app_key 签出来的 aidEncrypted,否则一律回 code 4002。 它挡的是「只拿到 appId 就能白嫖出题」—— appId 写在页面里是公开的, Origin 是 HTTP 头、curl 一行就能伪造,只有 app_key 是真拿不到的。

plain = appid + "|" + expireAt + "|" + nonce      // expireAt 是 unix 秒,nonce 是 16 位十六进制
sig   = hex( HMAC-SHA256(key = hex2bin(app_key), msg = plain) )
aidEncrypted = "v2." + base64url(plain) + "." + sig        // 去掉 base64 结尾的 =
// PHP,6 行,不用装任何扩展
function tac_aid(string $appid, string $appKey, int $ttl = 300): string {
    $exp   = time() + $ttl;
    $nonce = bin2hex(random_bytes(8));
    $plain = $appid . '|' . $exp . '|' . $nonce;
    return 'v2.'
        . rtrim(strtr(base64_encode($plain), '+/', '-_'), '=')
        . '.' . hash_hmac('sha256', $plain, hex2bin($appKey));
}

有效期在后台「凭证有效期」里配(1–86400 秒,默认 300)。一个凭证整页复用,直接传给控件:

Captcha4.init({ appId: 'x', aidEncrypted: '…' }, cb);

// 或者每次出题前现取:
Captcha4.init({
  appId: 'x',
  aidProvider: function () { return fetch('/你的后端/签aid').then(r => r.text()); }
}, cb);

不想自己写签发,可以在你的服务端用 app_key 按上面的方式生成 aid; 这个签名只能放服务端做,别把 app_key 写进页面。 凭证无效或过期时控件显示「验证服务异常,请刷新页面」,不会自动重试。

键盘可用(无障碍)

控件不只有鼠标/触屏一条路。手柄本身是 role="slider" + tabindex="0", 落在弹层的第一个 Tab 位上,接入方不需要做任何事:

Tab            焦点进到弹层,落在手柄上
← / ↓          退一格(量程的 1%)
→ / ↑          进一格
Home / End     回到最左 / 推到最右
Enter / 空格    确认提交(和松开鼠标等价)

步长取量程的 1%,保证任何答案位置都至少有两三档落在容差(显示宽的 2%)里。 手柄拿到键盘焦点时有一圈主题色描边(:focus-visible,鼠标点不会出现), 滑轨提示会从「按住滑块,拖动完成拼图」换成「← → 调整位置,回车确认」。
读屏软件靠 aria-valuenow / aria-valuetext 播报进度, 校验结论走滑轨内那句 role="status" 的文本。