Skip to content

Commit fc5752b

Browse files
committed
feat(jd,taobao,cnki): revive shopping adapters on current layout
1 parent 93a650b commit fc5752b

15 files changed

Lines changed: 1110 additions & 0 deletions

clis/_shared/common.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,36 @@
22
* Shared utilities for CLI adapters.
33
*/
44

5+
import { ArgumentError } from '@jackwener/opencli/errors';
6+
57
/**
68
* Clamp a numeric value to [min, max].
79
* Matches the signature of lodash.clamp and Rust's clamp.
810
*/
911
export function clamp(value: number, min: number, max: number): number {
1012
return Math.max(min, Math.min(value, max));
1113
}
14+
15+
export function clampInt(raw: unknown, fallback: number, min: number, max: number): number {
16+
const parsed = Number(raw);
17+
if (!Number.isFinite(parsed)) {
18+
return fallback;
19+
}
20+
return clamp(Math.floor(parsed), min, max);
21+
}
22+
23+
export function normalizeNumericId(value: unknown, label: string, example: string): string {
24+
const normalized = String(value ?? '').trim();
25+
if (!/^\d+$/.test(normalized)) {
26+
throw new ArgumentError(`${label} must be a numeric ID`, `Pass a numeric ${label}, for example: ${example}`);
27+
}
28+
return normalized;
29+
}
30+
31+
export function requireNonEmptyQuery(value: unknown, label = 'query'): string {
32+
const normalized = String(value ?? '').trim();
33+
if (!normalized) {
34+
throw new ArgumentError(`${label} cannot be empty`);
35+
}
36+
return normalized;
37+
}

clis/cnki/search.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import './search.js';
4+
5+
describe('cnki search command', () => {
6+
const command = getRegistry().get('cnki/search');
7+
8+
it('registers the command', () => {
9+
expect(command).toBeDefined();
10+
expect(command!.site).toBe('cnki');
11+
expect(command!.name).toBe('search');
12+
});
13+
14+
it('rejects empty queries before browser navigation', async () => {
15+
const page = { goto: async () => undefined } as any;
16+
await expect(command!.func!(page, { query: ' ' })).rejects.toMatchObject({
17+
name: 'ArgumentError',
18+
code: 'ARGUMENT',
19+
});
20+
});
21+
});

clis/cnki/search.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
3+
4+
cli({
5+
site: 'cnki',
6+
name: 'search',
7+
description: '中国知网论文搜索(海外版)',
8+
domain: 'oversea.cnki.net',
9+
strategy: Strategy.COOKIE,
10+
args: [
11+
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
12+
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
13+
],
14+
columns: ['rank', 'title', 'authors', 'journal', 'date', 'url'],
15+
navigateBefore: false,
16+
func: async (page, kwargs) => {
17+
const limit = clampInt(kwargs.limit, 10, 1, 20);
18+
const query = requireNonEmptyQuery(kwargs.query);
19+
20+
await page.goto(`https://oversea.cnki.net/kns/search?dbcode=CFLS&kw=${encodeURIComponent(query)}&korder=SU`);
21+
await page.wait(8);
22+
23+
const data = await page.evaluate(`
24+
(async () => {
25+
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
26+
for (let i = 0; i < 40; i++) {
27+
if (document.querySelector('.result-table-list tbody tr, #gridTable tbody tr')) break;
28+
await new Promise(r => setTimeout(r, 500));
29+
}
30+
const rows = document.querySelectorAll('.result-table-list tbody tr, #gridTable tbody tr');
31+
const results = [];
32+
for (const row of rows) {
33+
const tds = row.querySelectorAll('td');
34+
if (tds.length < 5) continue;
35+
36+
const nameCell = row.querySelector('td.name') || tds[2];
37+
const titleEl = nameCell?.querySelector('a');
38+
const title = normalize(titleEl?.textContent).replace(/免费$/, '');
39+
if (!title) continue;
40+
41+
let url = titleEl?.getAttribute('href') || '';
42+
if (url && !url.startsWith('http')) url = 'https://oversea.cnki.net' + url;
43+
44+
const authorCell = row.querySelector('td.author') || tds[3];
45+
const journalCell = row.querySelector('td.source') || tds[4];
46+
const dateCell = row.querySelector('td.date') || tds[5];
47+
48+
results.push({
49+
rank: results.length + 1,
50+
title,
51+
authors: normalize(authorCell?.textContent),
52+
journal: normalize(journalCell?.textContent),
53+
date: normalize(dateCell?.textContent),
54+
url,
55+
});
56+
if (results.length >= ${limit}) break;
57+
}
58+
return results;
59+
})()
60+
`);
61+
return Array.isArray(data) ? data : [];
62+
},
63+
});

clis/jd/add-cart.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { AuthRequiredError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import { clampInt, normalizeNumericId } from '../_shared/common.js';
4+
5+
cli({
6+
site: 'jd',
7+
name: 'add-cart',
8+
description: '京东加入购物车',
9+
domain: 'item.jd.com',
10+
strategy: Strategy.COOKIE,
11+
args: [
12+
{ name: 'sku', positional: true, required: true, help: '商品 SKU ID' },
13+
{ name: 'num', type: 'int', default: 1, help: '数量' },
14+
{ name: 'dry-run', type: 'bool', default: false, help: '仅预览,不实际加入购物车' },
15+
],
16+
columns: ['status', 'title', 'price', 'sku'],
17+
navigateBefore: false,
18+
func: async (page, kwargs) => {
19+
const sku = normalizeNumericId(kwargs.sku, 'sku', '100291143898');
20+
const num = clampInt(kwargs.num, 1, 1, 99);
21+
const dryRun = !!kwargs['dry-run'];
22+
23+
await page.goto(`https://item.jd.com/${sku}.html`);
24+
await page.wait(4);
25+
26+
const info = await page.evaluate(`
27+
(() => {
28+
const text = document.body?.innerText || '';
29+
const titleMatch = document.title.match(/^【[^】]*】(.+?)【/);
30+
const title = titleMatch ? titleMatch[1].trim() : document.title.split('-')[0].trim();
31+
const priceMatch = text.match(/¥([\\d,.]+)/);
32+
const price = priceMatch ? '¥' + priceMatch[1] : '';
33+
return { title, price };
34+
})()
35+
`);
36+
37+
if (dryRun) {
38+
return [{
39+
status: 'dry-run',
40+
title: (info?.title || '').slice(0, 80),
41+
price: info?.price || '',
42+
sku,
43+
}];
44+
}
45+
46+
await page.goto(`https://cart.jd.com/gate.action?pid=${sku}&pcount=${num}&ptype=1`);
47+
await page.wait(4);
48+
49+
const result = await page.evaluate(`
50+
(() => {
51+
const url = location.href;
52+
const text = document.body?.innerText || '';
53+
if (text.includes('已成功加入') || text.includes('商品已成功') || url.includes('addtocart')) {
54+
return 'success';
55+
}
56+
if (text.includes('请登录') || text.includes('login') || url.includes('login')) {
57+
return 'login_required';
58+
}
59+
return 'page:' + url.substring(0, 60) + ' | ' + text.substring(0, 100);
60+
})()
61+
`);
62+
63+
if (result === 'login_required') {
64+
throw new AuthRequiredError('jd add-cart requires a logged-in JD session');
65+
}
66+
67+
let status = '? 未知';
68+
if (result === 'success') status = '✓ 已加入购物车';
69+
else status = '? ' + result;
70+
71+
return [{
72+
status,
73+
title: (info?.title || '').slice(0, 80),
74+
price: info?.price || '',
75+
sku,
76+
}];
77+
},
78+
});

clis/jd/cart.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { AuthRequiredError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
4+
cli({
5+
site: 'jd',
6+
name: 'cart',
7+
description: '查看京东购物车',
8+
domain: 'cart.jd.com',
9+
strategy: Strategy.COOKIE,
10+
args: [],
11+
columns: ['index', 'title', 'price', 'quantity', 'sku'],
12+
navigateBefore: false,
13+
func: async (page) => {
14+
await page.goto('https://cart.jd.com/cart_index');
15+
await page.wait(5);
16+
17+
const data = await page.evaluate(`
18+
(async () => {
19+
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
20+
for (let i = 0; i < 20; i++) {
21+
if (document.body?.innerText?.length > 500) break;
22+
await new Promise(r => setTimeout(r, 500));
23+
}
24+
const text = document.body?.innerText || '';
25+
const url = location.href;
26+
if (text.includes('请登录') || url.includes('passport.jd.com')) {
27+
return { error: 'auth-required' };
28+
}
29+
30+
try {
31+
const resp = await fetch('https://api.m.jd.com/api?appid=JDC_mall_cart&functionId=pcCart_jc_getCurrentCart&body=%7B%22serInfo%22%3A%7B%22area%22%3A%2222_1930_50948_52157%22%7D%7D', {
32+
credentials: 'include',
33+
headers: { referer: 'https://cart.jd.com/' },
34+
});
35+
const json = await resp.json();
36+
const cartData = json?.resultData?.cartInfo?.vendors || [];
37+
const items = [];
38+
for (const vendor of cartData) {
39+
const sorted = vendor.sorted || [];
40+
for (const item of sorted) {
41+
const product = item.item || item;
42+
if (!product.Id && !product.skuId) continue;
43+
items.push({
44+
index: items.length + 1,
45+
title: normalize(product.name || product.Name || '').slice(0, 80),
46+
price: product.price ? '¥' + product.price : '',
47+
quantity: String(product.num || product.Num || 1),
48+
sku: String(product.Id || product.skuId || ''),
49+
});
50+
}
51+
}
52+
if (items.length > 0) return { items };
53+
} catch {}
54+
55+
const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
56+
const items = [];
57+
for (let i = 0; i < lines.length; i++) {
58+
const line = lines[i];
59+
const priceMatch = line.match(/¥([\\d,.]+)/);
60+
if (priceMatch && i > 0) {
61+
const title = lines[i - 1];
62+
if (title && title.length > 5 && title.length < 200 && !title.startsWith('¥')) {
63+
items.push({
64+
index: items.length + 1,
65+
title: title.slice(0, 80),
66+
price: '¥' + priceMatch[1],
67+
quantity: '',
68+
sku: '',
69+
});
70+
}
71+
}
72+
}
73+
return { items };
74+
})()
75+
`);
76+
77+
if (data?.error === 'auth-required') {
78+
throw new AuthRequiredError('jd cart requires a logged-in JD session');
79+
}
80+
return Array.isArray(data?.items) ? data.items : [];
81+
},
82+
});

clis/jd/commands.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import type { IPage } from '../../src/types.js';
4+
import './search.js';
5+
import './detail.js';
6+
import './reviews.js';
7+
import './cart.js';
8+
import './add-cart.js';
9+
10+
function createPageMock() {
11+
return {
12+
goto: vi.fn().mockResolvedValue(undefined),
13+
evaluate: vi.fn().mockResolvedValue({ title: 'Demo', price: '¥99' }),
14+
snapshot: vi.fn().mockResolvedValue(undefined),
15+
click: vi.fn().mockResolvedValue(undefined),
16+
typeText: vi.fn().mockResolvedValue(undefined),
17+
pressKey: vi.fn().mockResolvedValue(undefined),
18+
scrollTo: vi.fn().mockResolvedValue(undefined),
19+
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
20+
wait: vi.fn().mockResolvedValue(undefined),
21+
tabs: vi.fn().mockResolvedValue([]),
22+
selectTab: vi.fn().mockResolvedValue(undefined),
23+
networkRequests: vi.fn().mockResolvedValue([]),
24+
consoleMessages: vi.fn().mockResolvedValue([]),
25+
scroll: vi.fn().mockResolvedValue(undefined),
26+
autoScroll: vi.fn().mockResolvedValue(undefined),
27+
installInterceptor: vi.fn().mockResolvedValue(undefined),
28+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
29+
getCookies: vi.fn().mockResolvedValue([]),
30+
screenshot: vi.fn().mockResolvedValue(''),
31+
waitForCapture: vi.fn().mockResolvedValue(undefined),
32+
} as unknown as IPage & { goto: ReturnType<typeof vi.fn>; evaluate: ReturnType<typeof vi.fn> };
33+
}
34+
35+
describe('jd command registration', () => {
36+
it('registers all jd shopping commands', () => {
37+
for (const name of ['search', 'detail', 'reviews', 'cart', 'add-cart']) {
38+
expect(getRegistry().get(`jd/${name}`)).toBeDefined();
39+
}
40+
});
41+
});
42+
43+
describe('jd command safety', () => {
44+
it('rejects invalid numeric sku before evaluating page scripts', async () => {
45+
const page = createPageMock();
46+
const detail = getRegistry().get('jd/detail');
47+
await expect(detail!.func!(page, { sku: 'abc' })).rejects.toMatchObject({
48+
name: 'ArgumentError',
49+
code: 'ARGUMENT',
50+
});
51+
expect(page.goto).not.toHaveBeenCalled();
52+
expect(page.evaluate).not.toHaveBeenCalled();
53+
});
54+
55+
it('supports dry-run for add-cart without mutating the cart', async () => {
56+
const page = createPageMock();
57+
const addCart = getRegistry().get('jd/add-cart');
58+
59+
const result = await addCart!.func!(page, { sku: '100291143898', 'dry-run': true });
60+
61+
expect(result).toEqual([
62+
expect.objectContaining({
63+
status: 'dry-run',
64+
sku: '100291143898',
65+
}),
66+
]);
67+
expect(page.goto).toHaveBeenCalledTimes(1);
68+
expect(page.goto).toHaveBeenCalledWith('https://item.jd.com/100291143898.html');
69+
expect(page.evaluate).toHaveBeenCalledTimes(1);
70+
});
71+
});

0 commit comments

Comments
 (0)