Files
cv-cat dc078b248b catbus:一个命令操作小红书、抖音、TikTok、B 站、快手、微博、闲鱼、淘宝、京东、X
10 个平台的 web 端全部用 TypeScript 移植自 cv-cat 的开源项目,请求与上游逐字节对拍。
统一的命令语法与 JSON 输出、按 平台 × 端 × 账号 隔离的登录态、给 AI Agent 用的技能与 llms.txt。
2026-10-06 19:24:55 +08:00

839 lines
47 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs'
import { models } from '@cv-cat/catbus-assets-jd'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CatbusError } from '../src/core/errors.js'
import { imdecode, img, loadCv } from '../src/core/image.js'
import { pyFloatStr, pyRound } from '../src/core/py.js'
import type { HandlerContext } from '../src/core/registry.js'
import { RAW } from '../src/core/schemas.js'
import { PLATFORMS } from '../src/platforms/index.js'
import * as api from '../src/platforms/jd/web/api.js'
import { ChatClient } from '../src/platforms/jd/web/chat.js'
import { Jd, simpleCookie } from '../src/platforms/jd/web/client.js'
import * as cmd from '../src/platforms/jd/web/commands.js'
import { detectLines, fastAtan2 } from '../src/platforms/jd/web/jcap/lsd.js'
import * as solver from '../src/platforms/jd/web/jcap/solver.js'
import { setSummerSource, summerEncrypt } from '../src/platforms/jd/web/js.js'
import * as login from '../src/platforms/jd/web/login.js'
import * as norm from '../src/platforms/jd/web/normalize.js'
import { TraceContext } from '../src/platforms/jd/web/util.js'
import { expectRequests, type GoldenCase, type GoldenRequest, loadCase, makeCtx, replay } from './golden.js'
// 搜索 605 的对拍里 JCAP 求解换成固定票据(上游对拍同样替换了 solve_graphic_captcha)
vi.mock('../src/platforms/jd/web/jcap.js', () => ({ solveCaptcha: async () => 'FAKEJCAPVERIFYTOKEN0123456789ABCDEF' }))
const COOKIES =
'__jdu=17899999991231234567890; ' +
'__jda=122270672.17899999991231234567890.1789999999.1789999999.1789999999.1; __jdc=122270672; ' +
'areaId=1; ipLoc-djd=1-2800-55812-0.1234567890; ' +
'3AB9D23F7A4B3C9B=FAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEI; ' +
'3AB9D23F7A4B3CSS=jdd03FAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIDFAKEEIAAAAAFAKEFAKEFAKEAAAAAAFAKEFAKEFAKEFAKX; ' +
'thor=FAKETHOR0123456789ABCDEF; pin=fake_pin_%E6%B5%8B%E8%AF%95; unick=fake; ' +
'shshshfpa=0f0e0d0c-0b0a-0908-0706-050403020100-1789999999; shshshfpx=0f0e0d0c-0b0a-0908-0706-050403020100-1789999999; ' +
'shshshfpb=FAKEshshshfpb'
const FRESH = '__jdu=17899999991231234567890; areaId=1; ipLoc-djd=1-2800-55812-0'
const SKU = '100087543376'
const KEYWORD = '机械键盘'
const MOBILE = '13800000000'
const WEBM_STORAGE = { 'https://search.jd.com': { __jdapis_webm_runtime__: '6.0.0-exact-1', hf_time: '1790086400123' } }
const TOKEN_CACHE = JSON.parse(readFileSync(new URL('./golden/jd/_token_cache.json', import.meta.url), 'utf8'))
/** 与 gen.py 的 logged() 一致:假 cookie、预置的 h5st token 与 WebM 有效期。 */
function session(cookies = COOKIES, options: { tokenCache?: boolean; storage?: object; account?: string } = {}) {
const ctx = makeCtx({ platform: 'jd', account: options.account ?? 'default', cookies, cookieDomain: '.jd.com' })
if (options.tokenCache ?? true) ctx.credential.device.h5st = structuredClone(TOKEN_CACHE)
ctx.credential.device.local_storage = structuredClone(options.storage ?? WEBM_STORAGE)
return new Jd(ctx)
}
function chatSession() {
const jd = session()
jd.setChatInfo({ aid: 'FAKEAID0123', app_id: 'im.customer', client_type: 'comet' })
return jd
}
const cookiesOf = (jd: Jd) => Object.fromEntries(jd.cookies)
/** 命令 handler 的上下文:与 session() 相同的假凭证、h5st token 与 WebM 有效期。 */
function cmdCtx(init: { args?: Record<string, string>; options?: Record<string, unknown>; cursor?: string; extra?: Record<string, unknown> } = {}): HandlerContext {
const ctx = makeCtx({ platform: 'jd', account: 'default', cookies: COOKIES, cookieDomain: '.jd.com', ...init })
ctx.credential.device.h5st = structuredClone(TOKEN_CACHE)
ctx.credential.device.local_storage = structuredClone(WEBM_STORAGE)
return ctx
}
const ORDER_ID = '300000000001'
/** 对拍用的收货地区;命令行用 `-` 写法,接口里是 `_`。 */
const AREA = '2_2830_51810_0'
const areaCtx = (init: { args?: Record<string, string>; options?: Record<string, unknown> } = {}) => cmdCtx({ ...init, options: { area: AREA.replaceAll('_', '-'), ...init.options } })
/** 用例名 → TS 侧的等价调用。 */
const CASES: Record<string, () => Promise<unknown>> = {
product_detail: () => api.productDetail(session(), SKU),
product_comments: () => api.productComments(session(), SKU, 10),
recommend_coupon: () => api.recommendCoupon(session(), SKU),
related_search: () => api.relatedSearch(session(), SKU),
search_hotwords: () => api.searchHotwords(session()),
search_relwords: () => api.searchRelwords(session(), KEYWORD),
cart_num: () => api.cartNum(session()),
browse_history: () => api.browseHistory(session(), 2, 20),
follow_products: () => api.followProducts(session(), 1, 20),
check_session: async () => {
const r = await api.checkSession(session())
return [r.alive, r.name]
},
search_cached_webm: () => api.search(session(), KEYWORD, 2, { sort: 'sort_totalsales15_desc' }),
search_fresh_webm: async () => {
const jd = session(FRESH, { storage: {} })
const result = await api.search(jd, KEYWORD, 1)
return { result, cookie: cookiesOf(jd), local_storage: jd.ctx.credential.device.local_storage }
},
order_list: () => api.orderList(session(), 2, '2025'),
aid_info: () => api.aidInfo(session()),
chat_info: () => api.chatInfo(chatSession(), '1000000', SKU),
chat_session_log: () => api.chatSessionLog(chatSession()),
query_last_logs: () => api.queryLastLogs(chatSession(), '1000000', 20),
h5st_token_fetch: async () => {
const jd = session(COOKIES, { tokenCache: false })
jd.h5st.configure('pin=fake_pin', 'https://item.jd.com', 'https://item.jd.com/')
return jd.h5st.sign({ appid: 'item-v3', functionId: 'x', body: 'ab', client: 'pc', clientVersion: '1.0.0', t: '1790000000123' }, 'fb5df')
},
device_fields: async () => {
const jd = session()
jd.device.configure('__jdu=17899999991231234567890; 3AB9D23F7A4B3C9B=OLD; pin=fake_pin')
const first = await jd.device.get()
const forced = await jd.device.get(true)
return { first, forced }
},
summer_encrypt: () => {
setSummerSource('')
const c = loadCase('jd', 'summer_encrypt')
return summerEncrypt(session().http, c.input.public_key, c.input.plaintext)
},
qr_login: async () => {
const jd = session(FRESH, { storage: {} })
const { trace } = await login.prepareTrace(jd)
await login.getQrcode(jd)
const r = await login.checkQrcode(jd)
await login.validateTicket(jd, String(r.ticket), trace)
return { ok: true, cookie: cookiesOf(jd) }
},
sms_login: async () => {
const jd = session(FRESH, { storage: {} })
const sms = await login.startSms(jd)
const sent = await login.sendCode(jd, sms, MOBILE, 'FAKEVERIFYTOKEN0123456789012345678901')
const submitted = await login.submitCode(jd, sms, MOBILE, '123456')
return {
fields: sms.fields,
sent: [sent.success, sent.message],
submitted: [submitted.success, submitted.message],
cookie: cookiesOf(jd),
captcha: [sms.captchaStatus, sms.captchaSessionId, sms.captchaJwtToken],
}
},
safe_verify: async () => {
const jd = session(FRESH, { storage: {} })
new TraceContext('fake-page-uuid-0001')
setSummerSource('')
const page = await login.loadSafeVerify(jd, { safeVerifyUrl: 'https://safe.jd.com/dangerousVerify/index.action?o=QO&s=QS' })
const sent = await login.sendSafeCode(jd, page)
const ok = await login.submitSafeCode(jd, page, sent.method, '654321')
return { config: page.config, params: page.requestParams, sent: [sent.success, sent.message], ok: [ok.success, ok.message], cookie: cookiesOf(jd) }
},
search_risk: async () => {
const jd = session()
jd.update([['unionwsws', '%7B%22devicefinger%22%3A%22FAKEDEVICEFINGER%22%7D']])
const result = await api.search(jd, KEYWORD, 1)
return { result, evtoken: jd.cookie('x-rp-evtoken') }
},
// 按订单咨询:getChatInfo 带 orderId
chat_info_order: () => api.chatInfo(chatSession(), '1', '', ORDER_ID),
chat_info_item_order: () => api.chatInfo(chatSession(), '1000000', SKU, ORDER_ID),
// comment list --limit 30 → commentNum 30
product_comments_30: () => cmd.commentList(cmdCtx({ args: { item: SKU }, options: { limit: 30 } })),
// --area:显式地区覆盖 ipLoc-djd
product_detail_area: () => cmd.itemGet(areaCtx({ args: { item: SKU } })),
recommend_coupon_area: () => cmd.couponList(areaCtx({ args: { item: SKU } })),
// 假响应里没有 cartNum:只比较请求
cart_num_area: () =>
cmd.cartCount(areaCtx()).catch((e) => {
if (e.code !== 'UPSTREAM') throw e
}),
browse_history_area: () => cmd.historyList(areaCtx()),
follow_products_area: () => cmd.userCollects(areaCtx({ args: { user: 'me' } })),
search_area: () => cmd.itemSearch(areaCtx({ args: { keyword: KEYWORD } })),
// 短信登录:+852 的手机号按前缀识别地区码
sms_send_intl: async () => {
const jd = session(FRESH, { storage: {} })
const sms = await login.startSms(jd)
const mobile = login.loginMobile('+85291234567')
const sent = await login.sendCode(jd, sms, mobile, 'FAKEVERIFYTOKEN0123456789012345678901')
return { mobile, sent: [sent.success, sent.message] }
},
}
/** WebM 写进 localStorage 的 canvas / webgl 图像哈希与本机字体有关(见 normalizeUrl 的说明),不比较。 */
function maskWebmStorage(r: any): any {
for (const store of Object.values<Record<string, string>>(r?.local_storage ?? {})) {
for (const k of ['__we_m_cv__', '__we_m_gl__', '__we_m_ftk__']) if (k in store) store[k] = '<masked>'
}
return r
}
/** 结果也要一致的用例(上游返回值是纯数据)。 */
const SAME_RESULT: Record<string, (r: any) => unknown> = {
check_session: (r) => r,
search_fresh_webm: (r) => maskWebmStorage(r),
order_list: (r) => r,
h5st_token_fetch: (r) => r,
device_fields: (r) => r,
summer_encrypt: (r) => r,
qr_login: (r) => r.cookie,
sms_login: (r) => r,
safe_verify: (r) => r,
search_risk: (r) => r,
sms_send_intl: (r) => r,
}
/**
* 比较前统一两处与实现无关的差异:
* - 上游的 `https://api.m.jd.com?...` 在 curl 里发出去是 `/?`;wreq-js 需要显式的 `/`。
* - WebM 指纹里 canvas / webgl 的图像哈希由 @napi-rs/canvas 用本机字体渲染得到,换一台机器(CI)就不同,上游也一样;
* 这两项和汇总它们的 browser_info 不比较;architecture 随 CPU 架构而变,也不比较;Math 指纹按 12 位有效数字比较;
* 其余字段照常逐字节比较。
*/
const MASKS = [/(canvas%20fp%3A)[0-9a-f]{32}/, /^(fp%3A)[0-9a-f]{32}/]
/** wsgw_getinfo 的正文按字段展开比较,出错时能直接看出是哪个字段。 */
function expandWebm(body: string): unknown {
const form = Object.fromEntries(new URLSearchParams(body))
const inner = JSON.parse(form.body!)
const fields = inner.body as Record<string, unknown>
for (const [k, v] of Object.entries(fields)) if (typeof v === 'string') fields[k] = MASKS.reduce((x, re) => x.replace(re, '$1<masked>'), v)
fields.browser_info = '<masked>'
// architecture 取自 Infinity - Infinity 得到的 NaN 的符号位:x86 上是 255,ARM 上是 127,随运行测试的机器而变
fields.architecture = '<masked>'
// Math 指纹(如 Math.pow(Math.PI, -100))在不同 V8 版本 / 系统上可能差 1 ulp,按 12 位有效数字比较
const math = fields.math as Record<string, number> | undefined
if (math) for (const k of Object.keys(math)) math[k] = Number(math[k]!.toPrecision(12))
return { ...form, body: inner }
}
const normalizeUrl = (r: GoldenRequest): GoldenRequest => ({
...r,
url: r.url.replace(/^(https:\/\/[^/?#]+)(\?|$)/, '$1/$2'),
body: typeof r.body === 'string' && r.body.includes('functionId=wsgw_getinfo') ? (expandWebm(r.body) as string) : r.body,
})
async function run(c: GoldenCase, fn: () => Promise<unknown>) {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(c.now)
const out = await replay(c, fn)
if (out.error) throw out.error
expectRequests(out.requests.map(normalizeUrl), c.requests.map(normalizeUrl))
return out.result
}
describe('jd 对拍:请求构造与签名', () => {
// 上游对拍时整个 node 进程的时钟都是固定的(jsdom 的 cookie 过期判断也用它)
afterEach(() => void vi.useRealTimers())
for (const [name, fn] of Object.entries(CASES)) {
it(name, async () => {
const c = loadCase('jd', name)
const result = await run(c, fn)
const pick = SAME_RESULT[name]
const expected = name === 'qr_login' ? c.result.cookie : name === 'search_fresh_webm' ? maskWebmStorage(structuredClone(c.result)) : c.result
if (pick) expect(pick(JSON.parse(JSON.stringify(result)))).toEqual(expected)
}, 60_000)
}
/** 咚咚帧:datetime 是本机时区的本地时间,单独比较。 */
async function expectPackets(name: string, build: () => { url: string; packets: Record<string, unknown>[] }) {
const c = loadCase('jd', name)
const result = (await run(c, async () => build())) as { url: string; packets: Record<string, unknown>[] }
expect(result.url).toBe(c.result.url)
const local = (() => {
const d = new Date(c.now)
const p = (v: number) => String(v).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
})()
expect(result.packets.length).toBe(c.result.packets.length)
for (const [i, p] of result.packets.entries()) {
expect(p.datetime).toBe(local)
expect({ ...p, datetime: null }).toEqual({ ...c.result.packets[i], datetime: null })
}
}
it('chat_packets:咚咚 WebSocket 地址与帧', () =>
expectPackets('chat_packets', () => {
const chat = new ChatClient(chatSession(), '1000000', 'jd.waiter')
return { url: chat.url, packets: [chat.heartbeatPacket(), chat.helloPacket(SKU), chat.textPacket('在吗', SKU)] }
}))
it('chat_packets_order:按订单咨询的欢迎语与消息带 orderId', () =>
expectPackets('chat_packets_order', () => {
const chat = new ChatClient(chatSession(), '1', 'jd.waiter')
return {
url: chat.url,
packets: [
chat.heartbeatPacket(),
chat.helloPacket('', ORDER_ID),
chat.textPacket('这个订单什么时候发货', '', ORDER_ID),
chat.helloPacket(SKU, ORDER_ID),
chat.textPacket('在吗', SKU, ORDER_ID),
],
}
}))
it('normalize_mobile:国家/地区码;--phone 按 + / 00 前缀识别', () => {
const c = loadCase('jd', 'normalize_mobile')
for (const [mobile, area, expected] of c.result as [string, string, string][]) {
expect(login.normalizeMobile(mobile, area), `${mobile} ${area}`).toBe(expected)
expect(login.loginMobile(mobile), mobile).toBe(expected)
}
})
})
describe('jd 403:探测登录态,分清登录失效与限流(diagnose)', () => {
afterEach(() => void vi.useRealTimers())
async function failWith(name: string): Promise<CatbusError> {
const c = loadCase('jd', name)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(c.now)
const out = await replay(c, () => cmd.cartCount(cmdCtx()))
// 业务接口 3 次 403(重签重试)后,发一次免签名的 check_session
expectRequests(out.requests.map(normalizeUrl), c.requests.map(normalizeUrl))
expect(out.error).toBeInstanceOf(CatbusError)
return out.error as CatbusError
}
it('登录态已失效 → AUTH_EXPIRED,提示重新登录', async () => {
const err = await failWith('diagnose_no_session')
expect(loadCase('jd', 'diagnose_no_session').result.diagnose[0]).toBe('no_session')
expect(err.code).toBe('AUTH_EXPIRED')
expect(err.hint).toBe('catbus jd auth login -a default')
})
it('仍登录 → RISK_CONTROL(rate_limit),不再发签名探针', async () => {
const err = await failWith('diagnose_alive')
expect(loadCase('jd', 'diagnose_alive').result.session[0]).toBe(true)
expect(err.code).toBe('RISK_CONTROL')
expect(err.detail).toMatchObject({ kind: 'rate_limit', status: 403, session: 'alive' })
})
})
describe('jd 命令流程', () => {
afterEach(() => void vi.useRealTimers())
it('keyword hot:走免签名接口并归一化;没有词的条目去掉,--raw 的原始对象与输出一一对应', async () => {
const c = loadCase('jd', 'search_hotwords')
const data = [{ n: '', ext_columns: { text: '' } }, { n: '机械键盘', ext_columns: { text: '机械键盘' } }, { n: '显示器', gid: 'g2' }]
const hot = { ...c, responses: [{ status: 200, headers: {}, body: { abBuriedTagMap: null, code: 0, data, msg: 'Success' } }] }
const result = (await run(hot, () => cmd.keywordHot(cmdCtx()))) as any[]
expect(result).toEqual([
{ text: '机械键盘', heat: null },
{ text: '显示器', heat: null },
])
expect(result.map((k) => k[RAW])).toEqual([data[1], data[2]])
})
it('搜索命中 605:纯程序验证后重试,商品归一化', async () => {
const c = loadCase('jd', 'search_risk')
const { itemSearch } = await import('../src/platforms/jd/web/commands.js')
const ctx = makeCtx({ platform: 'jd', account: 'default', cookies: COOKIES + '; unionwsws=%7B%22devicefinger%22%3A%22FAKEDEVICEFINGER%22%7D', cookieDomain: '.jd.com' })
ctx.credential.device.h5st = structuredClone(TOKEN_CACHE)
ctx.credential.device.local_storage = structuredClone(WEBM_STORAGE)
ctx.args.keyword = KEYWORD
const result = (await run(c, () => itemSearch(ctx))) as any
expect(result.page).toEqual({ cursor: null, has_more: false })
expect(result.data).toEqual([
{
id: SKU,
kind: 'goods',
url: `https://item.jd.com/${SKU}.html`,
title: '机械键盘',
text: null,
author: { id: '1000', name: '假店铺', url: 'https://mall.jd.com/index-1000.html' },
created_at: null,
cover: null,
media: [],
stats: { views: null, likes: null, comments: 20000, collects: null, shares: null },
price: { amount: 299, currency: 'CNY' },
status: null,
},
])
expect(ctx.credential.scopes.main!.cookies.find((x) => x.name === 'x-rp-evtoken')?.value).toBe('FAKEEVTOKEN')
})
})
/** 与 gen.py 的 chat_logged() 一致的咚咚会话参数。 */
const CHAT = { chat: { aid: 'FAKEAID0123', app_id: 'im.customer', dvc: null, client_type: 'comet' } }
/**
* 用某个对拍用例的请求、换上别的响应跑命令:发出的请求仍要与上游一致(请求只取决于输入),返回命令的结果或错误。
* compare 为 false 时只按响应顺序回放,不比较请求(多个接口串起来、上游没有对应用例的流程)。
*/
async function runWith(name: string, responses: GoldenCase['responses'], fn: () => Promise<unknown>, compare = true) {
const c = loadCase('jd', name)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(c.now)
const out = await replay({ ...c, responses }, fn)
if (compare) expectRequests(out.requests.map(normalizeUrl), c.requests.map(normalizeUrl))
return out
}
const json = (body: unknown, status = 200, headers: Record<string, string> = {}) => ({ status, headers, body })
/** 只在响应头里的风控处置:`x-rp-content` 是 base64url 的 JSON,body 为空、状态码 200。 */
const disposalHeader = (api: string) =>
Buffer.from(JSON.stringify({ code: '605', disposal: { rpId: 'FAKERPID', evContent: JSON.stringify({ evType: '3', evApi: api, title: '京东验证' }) } })).toString('base64url')
async function expectError(p: Promise<{ error?: unknown; result?: unknown }>, code: string): Promise<CatbusError> {
const out = await p
expect(out.error, JSON.stringify(out.result ?? null)).toBeInstanceOf(CatbusError)
expect((out.error as CatbusError).code).toBe(code)
return out.error as CatbusError
}
describe('jd 风控处置与业务错误', () => {
afterEach(() => void vi.useRealTimers())
it('处置只在 x-rp-content 头里(body 为空、200):item get / coupon list / comment list 报 RISK_CONTROL,不重试', async () => {
const cases: [string, string, () => Promise<unknown>][] = [
['product_detail', 'color_pc_detailpage_wareBusiness', () => cmd.itemGet(cmdCtx({ args: { item: SKU } }))],
['recommend_coupon', 'color_getRecommendCoupon', () => cmd.couponList(cmdCtx({ args: { item: SKU } }))],
['product_comments_30', 'color_getLegoWareDetailComment', () => cmd.commentList(cmdCtx({ args: { item: SKU }, options: { limit: 30 } }))],
]
for (const [name, evApi, fn] of cases) {
const err = await expectError(runWith(name, [json('', 200, { 'x-rp-content': disposalHeader(evApi) })], fn), 'RISK_CONTROL')
expect(err.detail, name).toEqual({ kind: 'captcha', code: '605', api: evApi })
}
// 403 带处置:已经知道原因,不再重签重试、也不探测登录态
const err = await expectError(runWith('product_detail', [json('', 403, { 'x-rp-content': disposalHeader('color_x') })], () => cmd.itemGet(cmdCtx({ args: { item: SKU } }))), 'RISK_CONTROL')
expect(err.detail).toMatchObject({ kind: 'captcha', api: 'color_x' })
// 解不开的头也按风控处理
await expectError(runWith('product_detail', [json({ code: 0 }, 200, { 'x-rp-content': '%%%' })], () => cmd.itemGet(cmdCtx({ args: { item: SKU } }))), 'RISK_CONTROL')
})
it('网关业务错误 {code, echo} 与非 JSON 响应报 UPSTREAM,原始错误码放 detail', async () => {
const itemGet = () => cmd.itemGet(cmdCtx({ args: { item: SKU } }))
const e601 = await expectError(runWith('product_detail', [json({ code: '601', echo: 'request is not valid' })], itemGet), 'UPSTREAM')
expect(e601.detail).toMatchObject({ code: '601', echo: 'request is not valid' })
const html = await expectError(runWith('product_detail', [json('<html>busy</html>')], itemGet), 'UPSTREAM')
expect(html.detail).toMatchObject({ status: 200, body: '<html>busy</html>' })
// item related:relsearch 的中间结果也要检查
await expectError(runWith('related_search', [json({ code: '601', echo: 'x' })], () => cmd.itemRelated(cmdCtx({ args: { item: SKU } }))), 'UPSTREAM')
})
it('item related:取 relsearch 的第一个相关词去搜索;没有相关词时返回空列表', async () => {
const ware = { wareId: '100000000021', wareName: '<font>蓝莓</font>礼盒', jdPrice: '46.50', shopId: 12105625, shopName: '假店铺', comment: '1万+' }
const rel = json({ code: 0, data: [{ keyword: '进口蓝莓' }, { keyword: '蓝莓' }] })
const out = await runWith('related_search', [rel, json({ code: 0, data: { resultCount: 31, wareList: [ware] } })], () => cmd.itemRelated(cmdCtx({ args: { item: SKU } })), false)
expect(out.error).toBeUndefined()
expect(out.requests.map((r) => new URL(r.url).searchParams.get('functionId'))).toEqual(['relsearch', 'pc_search_searchWare'])
expect(new URL(out.requests[1]!.url).searchParams.get('keyword')).toBe('进口蓝莓')
const r = out.result as any
expect(r.data.map((i: any) => [i.id, i.title, i.price?.amount])).toEqual([['100000000021', '蓝莓礼盒', 46.5]])
expect(r.page).toEqual({ cursor: '2', has_more: true })
const empty = await runWith('related_search', [json({ code: 0, data: [] })], () => cmd.itemRelated(cmdCtx({ args: { item: SKU } })))
expect(empty.result).toEqual({ data: [], page: { cursor: null, has_more: false } })
})
it('登录墙:购物车 pin is null、浏览历史 resultCode -100(2026-09-29 真机)报 AUTH_EXPIRED', async () => {
const cart = { success: false, code: 1, message: 'pin is null', url: 'https://passport.jd.com/new/login.aspx' }
const e1 = await expectError(runWith('cart_num', [json(cart)], () => cmd.cartCount(cmdCtx())), 'AUTH_EXPIRED')
expect(e1.hint).toBe('catbus jd auth login -a default')
const history = { resultCode: -100, resultMsg: '用户未登录', success: false }
await expectError(runWith('browse_history', [json(history)], () => cmd.historyList(cmdCtx({ cursor: '2' }))), 'AUTH_EXPIRED')
// success:false 但不是登录墙 → UPSTREAM
const e3 = await expectError(runWith('browse_history', [json({ resultCode: 500, resultMsg: '系统繁忙', success: false })], () => cmd.historyList(cmdCtx({ cursor: '2' }))), 'UPSTREAM')
expect(e3.detail).toMatchObject({ result_code: 500, message: '系统繁忙' })
})
it('code 只对核对过正常值的接口判:hotwords / getChatSessionLog 非 0 报 UPSTREAM;getRecommendCoupon 的 {"code":"1"} 仍是空列表', async () => {
await expectError(runWith('search_hotwords', [json({ code: 1, msg: 'fail' })], () => cmd.keywordHot(cmdCtx())), 'UPSTREAM')
await expectError(runWith('chat_session_log', [json({ code: '1', msg: '失败', subCode: '1' })], () => cmd.msgList(cmdCtx({ extra: CHAT }))), 'UPSTREAM')
const coupons = await runWith('recommend_coupon', [json({ code: '1' })], () => cmd.couponList(cmdCtx({ args: { item: SKU } })))
expect(coupons.error).toBeUndefined()
expect(coupons.result).toEqual([])
})
it('咚咚 aid:会话里没有时先 getAidInfo 并记下;没下发 aid 报 UPSTREAM,code 3 报登录失效', async () => {
const sessions = { code: '0', chatSessions: [{ venderId: '1', venderName: '京东客服', time: 1790000000123 }], msg: '请求成功', subCode: '0' }
const ctx = cmdCtx()
const ok = await runWith('aid_info', [json({ code: '0', pin: 'fake_pin', aid: 'NEWAID', subCode: '0' }), json(sessions)], () => cmd.msgList(ctx), false)
expect(ok.requests.map((r) => new URL(r.url).searchParams.get('functionId'))).toEqual(['getAidInfo', 'getChatSessionLog'])
expect((ok.result as any).data.map((c: any) => c.id)).toEqual(['1'])
expect(ctx.credential.extra.chat).toMatchObject({ aid: 'NEWAID', app_id: 'im.customer', client_type: 'comet' })
const none = await expectError(runWith('aid_info', [json({ code: '0', subCode: '0' })], () => cmd.msgList(cmdCtx()), false), 'UPSTREAM')
expect(none.message).toMatch(/aid/)
await expectError(runWith('aid_info', [json({ code: '3', echo: 'not login' })], () => cmd.msgList(cmdCtx()), false), 'AUTH_EXPIRED')
})
})
describe('jd 咚咚历史与发送回执', () => {
afterEach(() => void vi.useRealTimers())
const msg = (i: number, timestamp: number) => ({ id: `m${i}`, from: { pin: 'waiter_1' }, body: { type: 'text', content: `第 ${i} 条` }, timestamp })
it('msg history:游标是最早一条的毫秒 timestamp(同一秒的多条不会被跳过),满一页才有下一页', async () => {
// 20 条,最早的两条在同一秒里(…123 与 …456 毫秒)
const list = [...Array.from({ length: 18 }, (_, i) => msg(i, 1790000100000 + i * 1000)), msg(18, 1790000000456), msg(19, 1790000000123)]
const full = await runWith('query_last_logs', [json({ code: '0', data: list })], () => cmd.msgHistory(cmdCtx({ args: { conversation: '1000000' }, extra: CHAT })))
expect(full.error).toBeUndefined()
const r = full.result as any
expect(r.data).toHaveLength(20)
expect(r.page).toEqual({ cursor: '1790000000123', has_more: true })
expect(r.data[19]).toMatchObject({ id: 'm19', conversation_id: '1000000', text: '第 19 条', created_at: '2026-09-21T22:13:20+08:00' })
const last = await runWith('query_last_logs', [json({ code: '0', data: list.slice(0, 3) })], () => cmd.msgHistory(cmdCtx({ args: { conversation: '1000000' }, extra: CHAT })))
expect((last.result as any).page).toEqual({ cursor: null, has_more: false })
})
/** 按顺序吐出帧的消息流;hang 之后一直不来新帧。记录迭代器有没有被关掉。 */
function stream(frames: unknown[], hang = true) {
const state = { closed: false }
const messages: AsyncIterable<string> = {
async *[Symbol.asyncIterator]() {
try {
for (const f of frames) yield JSON.stringify(f)
if (hang) await new Promise(() => {})
} finally {
state.closed = true
}
},
}
return { messages, state }
}
it('回执按 id 对上刚发的消息:欢迎语的回执不算;对上的 failure 报 UPSTREAM', async () => {
const ctx = cmdCtx()
const s1 = stream([{ type: 'chat_message_result', id: 'hello' }, [{ type: 'ack' }, { type: 'chat_message_result', id: 'mine' }]])
await expect(cmd.waitReceipt(ctx, s1.messages, 'mine', 1000)).resolves.toBeUndefined()
expect(s1.state.closed).toBe(true)
const s2 = stream([{ type: 'chat_message_result', id: 'hello' }, { type: 'failure', id: 'mine', body: { code: 111, msg: '授权过期' } }])
const err = await cmd.waitReceipt(ctx, s2.messages, 'mine', 1000).catch((e) => e)
expect(err).toBeInstanceOf(CatbusError)
expect(err).toMatchObject({ code: 'UPSTREAM', detail: { code: 111, msg: '授权过期' } })
})
it('等不到回执:途中有没对上 id 的 failure 报 UPSTREAM;什么都没有只提示,按已发送返回', async () => {
const ctx = cmdCtx()
const warn = vi.spyOn(ctx.log, 'warn')
const s1 = stream([{ type: 'chat_message_result', id: 'hello' }])
await expect(cmd.waitReceipt(ctx, s1.messages, 'mine', 50)).resolves.toBeUndefined()
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/没有回执/))
const s2 = stream([{ type: 'failure', body: { code: 5, msg: '发送太频繁' } }])
await expect(cmd.waitReceipt(ctx, s2.messages, 'mine', 50)).rejects.toMatchObject({ code: 'UPSTREAM', detail: { code: 5 } })
// 连接先断了:同样按没有回执处理
const s3 = stream([], false)
await expect(cmd.waitReceipt(ctx, s3.messages, 'mine', 1000)).resolves.toBeUndefined()
})
})
describe('jd 私有选项', () => {
afterEach(() => void vi.useRealTimers())
it('order list --range:3m / this_year / 年份 → 订单中心的 d', async () => {
expect([undefined, '3m', 'this_year', '2025'].map(cmd.orderRange)).toEqual(['1', '1', '2', '2025'])
// 与上游 get_order_list(page=2, date_range='2025') 的请求一致
const c = loadCase('jd', 'order_list')
const result = (await run(c, () => cmd.orderList(cmdCtx({ options: { range: '2025' }, cursor: '2' })))) as any
expect(result.data.map((o: any) => o.id)).toEqual(c.result.orders.map((o: any) => o.orderId))
expect(result.page).toEqual({ cursor: '3', has_more: true })
})
it('--range、--area 的取值校验', () => {
const jd = PLATFORMS.find((p) => p.id === 'jd')!
const web = jd.endpoints.web as Exclude<typeof jd.endpoints.web, 'planned'>
const range = web.commands.get('order list')!.options.range!
expect(range.safeParse(undefined).data).toBe('3m')
for (const ok of ['3m', 'this_year', '2024']) expect(range.safeParse(ok).success).toBe(true)
for (const bad of ['1', '2', 'year', '20245']) expect(range.safeParse(bad).success).toBe(false)
const area = web.commands.get('item get')!.options.area!
for (const ok of ['1_2800_55812_0', '1-2800-55812-0']) expect(area.safeParse(ok).success).toBe(true)
for (const bad of ['1_2800', 'beijing', '1-2800-55812-0.123']) expect(area.safeParse(bad).success).toBe(false)
for (const key of ['item search', 'item related', 'coupon list', 'cart count', 'history list', 'user collects']) {
expect(web.commands.get(key)!.options.area, key).toBeDefined()
}
})
it('msg send --order:与 --item / --conversation 之一合用,或单独使用', async () => {
const jd = PLATFORMS.find((p) => p.id === 'jd')!
const web = jd.endpoints.web as Exclude<typeof jd.endpoints.web, 'planned'>
const check = web.commands.get('msg send')!.check!
const text = { text: '在吗' }
expect(check(text, { order: ORDER_ID })).toBeUndefined()
expect(check(text, { order: ORDER_ID, item: SKU })).toBeUndefined()
expect(check(text, { order: ORDER_ID, conversation: '1000000' })).toBeUndefined()
expect(check(text, { conversation: '1000000' })).toBeUndefined()
expect(check(text, {})).toMatch(/--order/)
expect(check(text, { item: SKU, conversation: '1000000' })).toMatch(/只有 --to 与 --item 可以同时用/)
// --to 与 --item 按通用规则放行,由 handler 报 UNSUPPORTED(京东只能联系商家客服)
expect(check(text, { to: 'u', item: SKU })).toBeUndefined()
expect(check({}, { order: ORDER_ID })).toMatch(/text/)
expect(cmd.resolveOrderId(ORDER_ID)).toBe(ORDER_ID)
expect(cmd.resolveOrderId(`https://details.jd.com/normal/item.action?orderid=${ORDER_ID}`)).toBe(ORDER_ID)
expect(() => cmd.resolveOrderId('abc')).toThrow(CatbusError)
// 只有 --order:京东自营客服(venderId 1),不发请求
expect(await cmd.chatTarget(cmdCtx({ options: { order: ORDER_ID } }), session())).toEqual({ venderId: '1', pid: '', orderId: ORDER_ID })
expect(await cmd.chatTarget(cmdCtx({ options: { order: ORDER_ID, conversation: '1000000' } }), session())).toEqual({
venderId: '1000000',
pid: '',
orderId: ORDER_ID,
})
// --item + --order:venderId 取自商品详情(与 product_detail 对拍的请求相同)
const c = loadCase('jd', 'product_detail')
const detail = { ...c, responses: [{ status: 200, headers: {}, body: { code: 0, shopInfo: { shop: { venderId: 1000000 } } } }] }
const ctx = cmdCtx({ options: { order: ORDER_ID, item: SKU } })
const target = await run(detail, async () => cmd.chatTarget(ctx, new Jd(ctx)))
expect(target).toEqual({ venderId: '1000000', pid: SKU, orderId: ORDER_ID })
})
})
describe('jd 归一化与解析', () => {
it('msg listen:系统消息、撤回、会话建立输出为 other;协议帧不输出', () => {
const base = { from: { app: 'jd.waiter', pin: 'waiter_1', clientType: 'comet' }, datetime: '2025-08-01 12:00:00', timestamp: 1754020800000, ver: '4.2', lang: 'zh_CN', aid: 'x' }
const text = norm.chatEvent({ ...base, id: 'm1', type: 'chat_message', body: { type: 'text', content: '您好', chatinfo: { venderId: '1000000' } } })
expect(text).toMatchObject({ id: 'm1', conversation_id: '1000000', type: 'text', text: '您好', from: { id: 'waiter_1' } })
const sys = norm.chatEvent({ ...base, id: 's1', type: 'sys_msg', body: { data: { tplData: { title: '客服已接入' } }, chatinfo: { venderId: '1000000' } } })
expect(sys).toMatchObject({ id: 's1', conversation_id: '1000000', type: 'other', text: '客服已接入', from: null, created_at: '2025-08-01T12:00:00+08:00' })
const revoke = norm.chatEvent({ ...base, id: 'r1', type: 'revoke_message', body: { revokeContentToC: '客服撤回了一条消息', revokeMsgId: 'm0', venderId: '1000000' } })
expect(revoke).toMatchObject({ id: 'r1', conversation_id: '1000000', type: 'other', text: '客服撤回了一条消息(被撤回的消息:m0)', from: { id: 'waiter_1' } })
expect(norm.chatEvent({ ...base, id: 'r2', type: 'revoke_message', body: {} })?.text).toBe('对方撤回了一条消息')
const open = norm.chatEvent({ ...base, id: 'o1', type: 'chat_session_open', body: { venderId: '1000000', waiter: { pin: 'waiter_1' }, code: 1 } })
expect(open).toMatchObject({ type: 'other', conversation_id: '1000000', text: '会话建立:商家 1000000,客服 waiter_1', from: { id: 'waiter_1' } })
expect(norm.chatEvent({ ...base, id: 'c1', type: 'chat_session_close', body: {} })).toMatchObject({ type: 'other', text: '会话结束' })
for (const type of ['client_heartbeat', 'ack', 'chat_message_result', 'failure', 'msg_read_ack']) expect(norm.chatEvent({ ...base, type })).toBeNull()
expect(norm.chatEvent({ ...base, type: 'chat_message', body: { type: 'template2', data: {} } })).toBeNull()
})
it('订单:金额、时间、商品 SKU', () => {
const html = readFileSync(new URL('../scripts/golden/jd/order_list.html', import.meta.url), 'utf8')
const orders = api.parseOrders(html)
const o = norm.order(orders[0]!)
expect(o).toMatchObject({ id: '300000000001', status: '已完成', total: { amount: 23.28, currency: 'CNY' }, created_at: '2025-08-01T12:00:00+08:00' })
expect(o.items.map((i) => [i.id, i.title])).toEqual([
[SKU, '假商品 A & 配件 键盘'],
['100000000002', '假商品 B'],
])
expect(norm.order(orders[1]!).total).toEqual({ amount: 1099, currency: 'CNY' })
expect((o as any)[RAW].consignee).toBe('张*')
// SKU 不进上游的解析结果(对拍比较 orders 时不出现)
expect(JSON.parse(JSON.stringify(orders[0]))).not.toHaveProperty('skus')
})
it('订单:商品名与 SKU 取自同一个链接,全球购链接、空名链接、认不出的链接都不会让后面的商品错位', () => {
const name = (href: string, text: string) => `<div class="p-name"><a href="${href}" target="_blank">${text}</a></div>`
const html =
'<tbody id="tb-300000000009">' +
name('//npcitem.jd.hk/100000000011.html', '全球购商品') +
name('//item.jd.com/100000000012.html', '') +
name('//item.jd.hk/100000000013.html', '海外商品') +
name('//item.m.jd.com/product/100000000014.html', '移动端链接') +
name('javascript:void(0)', '礼品卡') +
name('//item.jd.com/100000000015.html', '普通商品') +
'<span class="dealtime">2025-08-01 12:00:00</span></tbody>'
const [raw] = api.parseOrders(html)
expect(raw!.products).toEqual(['全球购商品', '海外商品', '移动端链接', '礼品卡', '普通商品'])
expect(norm.order(raw!).items.map((i) => [i.id, i.title, i.url])).toEqual([
['100000000011', '全球购商品', 'https://item.jd.com/100000000011.html'],
['100000000013', '海外商品', 'https://item.jd.com/100000000013.html'],
['100000000014', '移动端链接', 'https://item.jd.com/100000000014.html'],
['', '礼品卡', null],
['100000000015', '普通商品', 'https://item.jd.com/100000000015.html'],
])
})
it('SimpleCookie:未知属性当新 cookie,非法行整体忽略', () => {
expect(simpleCookie('thor=abc; Domain=.jd.com; Path=/; HttpOnly')).toEqual([['thor', 'abc']])
expect(simpleCookie('a=1; Priority=High')).toEqual([
['a', '1'],
['Priority', 'High'],
])
expect(simpleCookie('a=1; Partitioned')).toEqual([])
expect(simpleCookie('q="x\\"y"; Path=/')).toEqual([['q', 'x"y']])
})
it('会话列表:列表在 chatSessions,条目只有 time(毫秒),没有最后一条消息与未读数(2026-09-29 真机的字段结构)', () => {
const session = { groupId: 10000002, venderId: '1', pid: '', label: '官方', type: 3, venderName: '京东客服', appId: 'jd.waiter', time: 1790598767492 }
const cs = norm.conversations({ code: '0', chatSessions: [session], msg: '请求成功', subCode: '0' })
expect(cs).toHaveLength(1)
expect(cs[0]).toMatchObject({ id: '1', peer: { id: '1', name: '京东客服' }, last_message: null, unread: null, updated_at: '2026-09-28T20:32:47+08:00' })
expect((cs[0] as any)[RAW]).toBe(session)
})
it('商品详情:按 wareInfo / price.p / shopInfo.shop / 主图列表取,浅层的 name、url 不会压过真正的字段', () => {
const d = {
name: '活动名',
url: '//pro.jd.com/activity.html',
p: '1',
price: { p: '299.00', op: '399.00', id: SKU },
wareInfo: { wname: '假商品 键盘', venderId: 1000000, imageList: ['jfs/t1/a.jpg', 'jfs/t1/b.jpg'] },
shopInfo: { customerService: { name: '客服' }, shop: { shopId: 1000001, name: '假店铺', venderId: 1000000, url: '//mall.jd.com/index-1000001.html' } },
}
const item = norm.detail(SKU, d)
expect(item).toMatchObject({
id: SKU,
url: `https://item.jd.com/${SKU}.html`,
title: '假商品 键盘',
author: { id: '1000001', name: '假店铺', url: 'https://mall.jd.com/index-1000001.html' },
cover: 'https://img14.360buyimg.com/n1/jfs/t1/a.jpg',
price: { amount: 299, currency: 'CNY' },
})
expect(item.media.map((m) => m.url)).toEqual(['https://img14.360buyimg.com/n1/jfs/t1/a.jpg', 'https://img14.360buyimg.com/n1/jfs/t1/b.jpg'])
expect(norm.detailVenderId(d)).toBe('1000000')
// 没有 wareInfo 时按专有键名兜底;图片列表的元素也可以是对象
const other = norm.detail(SKU, { name: '活动名', data: { skuName: '兜底名', imgs: [{ big: '//img10.360buyimg.com/n1/x.jpg' }] }, price: { finalPrice: { price: '9.90' } } })
expect(other).toMatchObject({ title: '兜底名', cover: 'https://img10.360buyimg.com/n1/x.jpg', price: { amount: 9.9 } })
// venderId 为 0 不是商家
expect(norm.detailVenderId({ shopInfo: { shop: { venderId: 0 } } })).toBeNull()
expect(norm.detailVenderId({})).toBeNull()
})
it('购物车数量取 cartNum', () => {
expect(norm.cartCount({ cartNum: 2 })).toBe(2)
expect(norm.cartCount({ code: 0, data: { num: 5 } })).toBeNull()
})
it('评价、优惠券、关注商品按字段名提取', () => {
// 真实响应里问答 questionList 排在评价 commentInfoList 前面,问答条目也有 content(2026-09-28 真机)
const cs = norm.comments(SKU, {
result: {
questionList: [{ id: '69405999', content: '能打fps游戏吗', answerList: [] }],
commentInfoList: [{ commentId: 9, userNickName: 'j***n', commentData: '好用', commentDate: '2025-08-01 10:00:00', praiseCnt: '3' }],
},
})
expect(cs[0]).toMatchObject({ id: '9', item_id: SKU, text: '好用', created_at: '2025-08-01T10:00:00+08:00', stats: { likes: 3, replies: null } })
const cp = norm.coupons({ couponList: [{ couponId: 'c1', name: '满100减10', discount: 10, quota: 100, beginTime: '2025-08-01 00:00:00', endTime: 1790000000000 }] })
expect(cp[0]).toMatchObject({ id: 'c1', title: '满100减10', discount: { amount: 10 }, threshold: { amount: 100 }, start_at: '2025-08-01T00:00:00+08:00' })
expect(norm.listedItems({ data: { list: [{ skuId: 1, wname: '<b>商品</b>', imgUrl: 'jfs/t1/a.jpg', jdPrice: '9.90' }] } })[0]).toMatchObject({
id: '1',
title: '商品',
cover: 'https://img14.360buyimg.com/n1/jfs/t1/a.jpg',
price: { amount: 9.9 },
})
})
it('pyFloatStr 与 Python 的 str(float) 一致', () => {
expect(pyFloatStr(0.5939828956034034)).toBe('0.5939828956034034')
expect(pyFloatStr(5.123e-5)).toBe('5.123e-05')
expect(pyFloatStr(0)).toBe('0.0')
// round(773.915, 2):773.915 的二进制值略小于 773.915,Python 得 773.91
expect(pyRound(773.915, 2)).toBe(773.91)
})
})
// ================================================================ JCAP 求解器(纯算部分)
const b64png = async (s: string) => (await imdecode(Buffer.from(s, 'base64'), 'color'))!
describe('jd JCAP 图片解码', () => {
it('JPEG / WebP 与 PNG 解出同样的像素(@napi-rs/canvas 的解码是异步的,没等就会得到全黑)', async () => {
const { createCanvas } = await import('@napi-rs/canvas')
const c = createCanvas(40, 30)
const g = c.getContext('2d')
g.fillStyle = '#c08040'
g.fillRect(0, 0, 40, 30)
const mean = (m: { data: ArrayLike<number> }) => Array.from(m.data).reduce((a, b) => a + b, 0) / m.data.length
const png = (await imdecode(c.toBuffer('image/png')))!
for (const mime of ['image/jpeg', 'image/webp'] as const) {
const m = (await imdecode(c.toBuffer(mime)))!
expect([m.width, m.height, m.channels], mime).toEqual([40, 30, 3])
expect(Math.abs(mean(m) - mean(png)), mime).toBeLessThan(3)
}
})
})
const f32 = (s: string) => new Float32Array(new Uint8Array(Buffer.from(s, 'base64')).buffer)
const close = (a: number, b: number, tol: number) => expect(Math.abs(a - b), `${a} vs ${b}`).toBeLessThanOrEqual(tol)
describe('jd JCAP 求解器对拍', () => {
it('滑块:缺口位置与各项得分', async () => {
const c = loadCase('jd', 'jcap_slider').result
const main = await b64png(c.main)
const slot = (await imdecode(Buffer.from(c.slot, 'base64'), 'unchanged'))!
const s = await solver.solveSlider(main, slot)
expect(s).toMatchObject({ retry: c.solution.retry, reason: c.solution.reason, solver: c.solution.solver, offset: c.solution.offset })
close(s.score!, c.solution.score, 2e-4)
close(s.margin as number, c.solution.margin, 2e-4)
close(s.correlation as number, c.solution.correlation, 2e-4)
})
it('骨架化、最长路径、重采样、可信轨迹', async () => {
const c = loadCase('jd', 'jcap_skeleton').result
const mask = (await imdecode(Buffer.from(c.mask, 'base64'), 'color'))!
const w = mask.width
const h = mask.height
const bin = new Uint8Array(w * h).map((_, i) => (mask.data[i * 3]! > 0 ? 1 : 0))
const skel = solver.skeletonize(bin, w, h)
const bits = Buffer.from(c.skeleton, 'base64')
const expected = new Uint8Array(w * h).map((_, i) => (bits[i >> 3]! >> (7 - (i & 7))) & 1)
expect(Array.from(skel)).toEqual(Array.from(expected))
const { path, metrics } = solver.longestSkeletonPath(skel, w, h)
expect(path).toEqual(c.path)
expect(metrics).toEqual(c.metrics)
expect(await solver.resamplePath(path)).toEqual(c.resampled)
const confident = await solver.extractConfidentPath(img(w, h, 1, f32(c.saliency)))
expect(confident).toEqual(c.confident)
})
it('轨迹:LAB 残差、笔画似然、四角几何与打分', async () => {
const c = loadCase('jd', 'jcap_trace').result
const image = await b64png(c.image)
const maps = await solver.traceMaps(image, img(image.width, image.height, 1, f32(c.saliency)))
close(solver.mean32(maps.residual.data), c.residual_mean, 1e-3)
close(solver.mean32(maps.stroke!.data), c.stroke_mean, 1e-5)
const samples = [
[50, 80],
[47, 140],
[80, 150],
[10, 10],
].map(([y, x]) => maps.stroke!.data[y! * image.width + x!]!)
samples.forEach((v, i) => close(v, c.stroke_sample[i], 1e-6))
c.params.forEach((p: number[], i: number) => {
expect(solver.corners(p)).toEqual(c.corners[i])
expect(solver.traceChain(p, [0, 2, 1, 3])).toEqual(c.chains[i])
;[
[0, 1, 2, 3],
[0, 2, 1, 3],
].forEach((t, j) => close(solver.traceScore(maps, p, t), c.scores[i][j], Math.max(1e-3, Math.abs(c.scores[i][j]) * 1e-4)))
})
})
it('LSD:OpenCV 5 的直线检测', async () => {
const c = loadCase('jd', 'jcap_lsd').result
const gray = (await imdecode(Buffer.from(c.image, 'base64'), 'color'))!
const g1 = img(gray.width, gray.height, 1, new Uint8Array(gray.width * gray.height).map((_, i) => gray.data[i * 3]!))
const lines = detectLines(await loadCv(), g1)
expect(lines.length).toBe(c.lines.length)
lines.forEach((l, i) => l.forEach((v, j) => close(v, c.lines[i][j], 1e-3)))
// OpenCV 的 fastAtan2 是多项式近似(约 0.01°)
expect(fastAtan2(1, 1)).toBeCloseTo(44.99045, 4)
})
it('旋转:方向分类 + 直线轴向', async () => {
const c = loadCase('jd', 'jcap_rotation').result
const s = await solver.solveRotation(await b64png(c.image), models.orientation)
expect(s.orientationClass).toBe(c.solution.orientationClass)
close(s.angle as number, c.solution.angle, 0.05)
close(s.score!, c.solution.score, 2e-3)
close(s.axisStrength as number, c.solution.axisStrength, 2e-3)
}, 120_000)
it('点选:U2Net 显著性与带掩码模板匹配', async () => {
const c = loadCase('jd', 'jcap_click').result
const tip = await b64png(c.tip)
const sal = await solver.u2netSaliency(tip, models.u2netp)
close(solver.mean32(sal.data), c.saliency_mean, 1e-3)
;[
[20, 30],
[5, 5],
[30, 20],
].forEach(([y, x], i) => close(sal.data[y! * tip.width + x!]!, c.saliency_sample[i], 2e-3))
const s = await solver.solveClick(await b64png(c.image), tip, models.u2netp)
expect(s.retry).toBe(c.solution.retry)
close(s.x as number, c.solution.x, 1)
close(s.y as number, c.solution.y, 1)
close(s.score!, c.solution.score, 5e-3)
}, 120_000)
})