Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions clis/_shared/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,36 @@
* Shared utilities for CLI adapters.
*/

import { ArgumentError } from '@jackwener/opencli/errors';

/**
* Clamp a numeric value to [min, max].
* Matches the signature of lodash.clamp and Rust's clamp.
*/
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}

export function clampInt(raw: unknown, fallback: number, min: number, max: number): number {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return fallback;
}
return clamp(Math.floor(parsed), min, max);
}

export function normalizeNumericId(value: unknown, label: string, example: string): string {
const normalized = String(value ?? '').trim();
if (!/^\d+$/.test(normalized)) {
throw new ArgumentError(`${label} must be a numeric ID`, `Pass a numeric ${label}, for example: ${example}`);
}
return normalized;
}

export function requireNonEmptyQuery(value: unknown, label = 'query'): string {
const normalized = String(value ?? '').trim();
if (!normalized) {
throw new ArgumentError(`${label} cannot be empty`);
}
return normalized;
}
21 changes: 21 additions & 0 deletions clis/cnki/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './search.js';

describe('cnki search command', () => {
const command = getRegistry().get('cnki/search');

it('registers the command', () => {
expect(command).toBeDefined();
expect(command!.site).toBe('cnki');
expect(command!.name).toBe('search');
});

it('rejects empty queries before browser navigation', async () => {
const page = { goto: async () => undefined } as any;
await expect(command!.func!(page, { query: ' ' })).rejects.toMatchObject({
name: 'ArgumentError',
code: 'ARGUMENT',
});
});
});
63 changes: 63 additions & 0 deletions clis/cnki/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';

cli({
site: 'cnki',
name: 'search',
description: '中国知网论文搜索(海外版)',
domain: 'oversea.cnki.net',
strategy: Strategy.COOKIE,
args: [
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
],
columns: ['rank', 'title', 'authors', 'journal', 'date', 'url'],
navigateBefore: false,
func: async (page, kwargs) => {
const limit = clampInt(kwargs.limit, 10, 1, 20);
const query = requireNonEmptyQuery(kwargs.query);

await page.goto(`https://oversea.cnki.net/kns/search?dbcode=CFLS&kw=${encodeURIComponent(query)}&korder=SU`);
await page.wait(8);

const data = await page.evaluate(`
(async () => {
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
for (let i = 0; i < 40; i++) {
if (document.querySelector('.result-table-list tbody tr, #gridTable tbody tr')) break;
await new Promise(r => setTimeout(r, 500));
}
const rows = document.querySelectorAll('.result-table-list tbody tr, #gridTable tbody tr');
const results = [];
for (const row of rows) {
const tds = row.querySelectorAll('td');
if (tds.length < 5) continue;

const nameCell = row.querySelector('td.name') || tds[2];
const titleEl = nameCell?.querySelector('a');
const title = normalize(titleEl?.textContent).replace(/免费$/, '');
if (!title) continue;

let url = titleEl?.getAttribute('href') || '';
if (url && !url.startsWith('http')) url = 'https://oversea.cnki.net' + url;

const authorCell = row.querySelector('td.author') || tds[3];
const journalCell = row.querySelector('td.source') || tds[4];
const dateCell = row.querySelector('td.date') || tds[5];

results.push({
rank: results.length + 1,
title,
authors: normalize(authorCell?.textContent),
journal: normalize(journalCell?.textContent),
date: normalize(dateCell?.textContent),
url,
});
if (results.length >= ${limit}) break;
}
return results;
})()
`);
return Array.isArray(data) ? data : [];
},
});
78 changes: 78 additions & 0 deletions clis/jd/add-cart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { AuthRequiredError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { clampInt, normalizeNumericId } from '../_shared/common.js';

cli({
site: 'jd',
name: 'add-cart',
description: '京东加入购物车',
domain: 'item.jd.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'sku', positional: true, required: true, help: '商品 SKU ID' },
{ name: 'num', type: 'int', default: 1, help: '数量' },
{ name: 'dry-run', type: 'bool', default: false, help: '仅预览,不实际加入购物车' },
],
columns: ['status', 'title', 'price', 'sku'],
navigateBefore: false,
func: async (page, kwargs) => {
const sku = normalizeNumericId(kwargs.sku, 'sku', '100291143898');
const num = clampInt(kwargs.num, 1, 1, 99);
const dryRun = !!kwargs['dry-run'];

await page.goto(`https://item.jd.com/${sku}.html`);
await page.wait(4);

const info = await page.evaluate(`
(() => {
const text = document.body?.innerText || '';
const titleMatch = document.title.match(/^【[^】]*】(.+?)【/);
const title = titleMatch ? titleMatch[1].trim() : document.title.split('-')[0].trim();
const priceMatch = text.match(/¥([\\d,.]+)/);
const price = priceMatch ? '¥' + priceMatch[1] : '';
return { title, price };
})()
`);

if (dryRun) {
return [{
status: 'dry-run',
title: (info?.title || '').slice(0, 80),
price: info?.price || '',
sku,
}];
}

await page.goto(`https://cart.jd.com/gate.action?pid=${sku}&pcount=${num}&ptype=1`);
await page.wait(4);

const result = await page.evaluate(`
(() => {
const url = location.href;
const text = document.body?.innerText || '';
if (text.includes('已成功加入') || text.includes('商品已成功') || url.includes('addtocart')) {
return 'success';
}
if (text.includes('请登录') || text.includes('login') || url.includes('login')) {
return 'login_required';
}
return 'page:' + url.substring(0, 60) + ' | ' + text.substring(0, 100);
})()
`);

if (result === 'login_required') {
throw new AuthRequiredError('jd add-cart requires a logged-in JD session');
}

let status = '? 未知';
if (result === 'success') status = '✓ 已加入购物车';
else status = '? ' + result;

return [{
status,
title: (info?.title || '').slice(0, 80),
price: info?.price || '',
sku,
}];
},
});
82 changes: 82 additions & 0 deletions clis/jd/cart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { AuthRequiredError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';

cli({
site: 'jd',
name: 'cart',
description: '查看京东购物车',
domain: 'cart.jd.com',
strategy: Strategy.COOKIE,
args: [],
columns: ['index', 'title', 'price', 'quantity', 'sku'],
navigateBefore: false,
func: async (page) => {
await page.goto('https://cart.jd.com/cart_index');
await page.wait(5);

const data = await page.evaluate(`
(async () => {
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
for (let i = 0; i < 20; i++) {
if (document.body?.innerText?.length > 500) break;
await new Promise(r => setTimeout(r, 500));
}
const text = document.body?.innerText || '';
const url = location.href;
if (text.includes('请登录') || url.includes('passport.jd.com')) {
return { error: 'auth-required' };
}

try {
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', {
credentials: 'include',
headers: { referer: 'https://cart.jd.com/' },
});
const json = await resp.json();
const cartData = json?.resultData?.cartInfo?.vendors || [];
const items = [];
for (const vendor of cartData) {
const sorted = vendor.sorted || [];
for (const item of sorted) {
const product = item.item || item;
if (!product.Id && !product.skuId) continue;
items.push({
index: items.length + 1,
title: normalize(product.name || product.Name || '').slice(0, 80),
price: product.price ? '¥' + product.price : '',
quantity: String(product.num || product.Num || 1),
sku: String(product.Id || product.skuId || ''),
});
}
}
if (items.length > 0) return { items };
} catch {}

const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
const items = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const priceMatch = line.match(/¥([\\d,.]+)/);
if (priceMatch && i > 0) {
const title = lines[i - 1];
if (title && title.length > 5 && title.length < 200 && !title.startsWith('¥')) {
items.push({
index: items.length + 1,
title: title.slice(0, 80),
price: '¥' + priceMatch[1],
quantity: '',
sku: '',
});
}
}
}
return { items };
})()
`);

if (data?.error === 'auth-required') {
throw new AuthRequiredError('jd cart requires a logged-in JD session');
}
return Array.isArray(data?.items) ? data.items : [];
},
});
71 changes: 71 additions & 0 deletions clis/jd/commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import type { IPage } from '../../src/types.js';
import './search.js';
import './detail.js';
import './reviews.js';
import './cart.js';
import './add-cart.js';

function createPageMock() {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({ title: 'Demo', price: '¥99' }),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
} as unknown as IPage & { goto: ReturnType<typeof vi.fn>; evaluate: ReturnType<typeof vi.fn> };
}

describe('jd command registration', () => {
it('registers all jd shopping commands', () => {
for (const name of ['search', 'detail', 'reviews', 'cart', 'add-cart']) {
expect(getRegistry().get(`jd/${name}`)).toBeDefined();
}
});
});

describe('jd command safety', () => {
it('rejects invalid numeric sku before evaluating page scripts', async () => {
const page = createPageMock();
const detail = getRegistry().get('jd/detail');
await expect(detail!.func!(page, { sku: 'abc' })).rejects.toMatchObject({
name: 'ArgumentError',
code: 'ARGUMENT',
});
expect(page.goto).not.toHaveBeenCalled();
expect(page.evaluate).not.toHaveBeenCalled();
});

it('supports dry-run for add-cart without mutating the cart', async () => {
const page = createPageMock();
const addCart = getRegistry().get('jd/add-cart');

const result = await addCart!.func!(page, { sku: '100291143898', 'dry-run': true });

expect(result).toEqual([
expect.objectContaining({
status: 'dry-run',
sku: '100291143898',
}),
]);
expect(page.goto).toHaveBeenCalledTimes(1);
expect(page.goto).toHaveBeenCalledWith('https://item.jd.com/100291143898.html');
expect(page.evaluate).toHaveBeenCalledTimes(1);
});
});
Loading