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
4 changes: 4 additions & 0 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -30627,6 +30627,10 @@
"title",
"price",
"mall",
"updated_at",
"zhi_count",
"buzhi_count",
"favorite_count",
"comments",
"url"
],
Expand Down
146 changes: 120 additions & 26 deletions clis/smzdm/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,50 +6,144 @@
* and scrape the rendered DOM directly.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
cli({
site: 'smzdm',
name: 'search',
access: 'read',
description: '什么值得买搜索好价',
domain: 'www.smzdm.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
],
columns: ['rank', 'title', 'price', 'mall', 'comments', 'url'],
func: async (page, kwargs) => {
const q = encodeURIComponent(kwargs.query);
const limit = kwargs.limit || 20;
// Navigate directly to search results page
await page.goto(`https://search.smzdm.com/?c=home&s=${q}&v=b`);
const data = await page.evaluate(`
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';

function unwrapEvaluateResult(payload) {
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
return payload.data;
}
return payload;
}

function requireSearchRows(payload) {
const rows = unwrapEvaluateResult(payload);
if (!Array.isArray(rows)) {
throw new CommandExecutionError('Unexpected SMZDM search extraction payload shape; expected an array of rows.');
}
return rows;
}

function parseLimit(raw) {
let parsed;
if (raw == null) {
parsed = 20;
}
else if (typeof raw === 'number') {
parsed = raw;
}
else if (typeof raw === 'string' && /^[0-9]+$/.test(raw)) {
parsed = Number(raw);
}
else {
parsed = NaN;
}
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
throw new ArgumentError(`--limit must be an integer between 1 and 100, got ${JSON.stringify(raw)}`);
}
if (parsed < 1 || parsed > 100) {
throw new ArgumentError(`--limit must be between 1 and 100, got ${parsed}`);
}
return parsed;
}

/**
* Build the in-page extraction script. Every result row carries the full
* declared column set; interaction metrics default to 0 and the update time
* to '' when a list item omits them, so no column is ever silently dropped.
*/
function buildSmzdmSearchJs(limit) {
return `
(() => {
const limit = ${limit};
const items = document.querySelectorAll('li.feed-row-wide');
const results = [];
const normalizeCount = (text) => {
const raw = (text || '').replace(/,/g, '').trim();
const match = raw.match(/(\\d+(?:\\.\\d+)?)\\s*([万kK]?)/);
if (!match) return 0;
const base = Number(match[1]);
if (!Number.isFinite(base)) return 0;
const unit = match[2];
if (unit === '万') return Math.round(base * 10000);
if (unit === 'k' || unit === 'K') return Math.round(base * 1000);
return Math.round(base);
};
const intFrom = (el) => {
if (!el) return 0;
return normalizeCount(el.textContent || '');
};
const trustedSmzdmUrl = (raw) => {
const text = (raw || '').trim();
if (!text) return '';
let url;
try {
url = text.startsWith('/')
? new URL(text, 'https://www.smzdm.com')
: new URL(text, location.href);
} catch {
return '';
}
const hostname = url.hostname.toLowerCase();
if (url.protocol !== 'https:' || (hostname !== 'www.smzdm.com' && hostname !== 'post.smzdm.com')) {
return '';
}
return url.toString();
};
items.forEach((li) => {
if (results.length >= limit) return;
const titleEl = li.querySelector('h5.feed-block-title > a')
|| li.querySelector('h5 > a');
if (!titleEl) return;
const title = (titleEl.getAttribute('title') || titleEl.textContent || '').trim();
const url = titleEl.getAttribute('href') || titleEl.href || '';
const url = trustedSmzdmUrl(titleEl.getAttribute('href') || titleEl.href || '');
if (!title || !url) return;
const priceEl = li.querySelector('.z-highlight');
const price = priceEl ? priceEl.textContent.trim() : '';
let mall = '';
const mallEl = li.querySelector('.z-feed-foot-r .feed-block-extras span')
|| li.querySelector('.z-feed-foot-r span');
if (mallEl) mall = mallEl.textContent.trim();
const commentEl = li.querySelector('.feed-btn-comment');
const comments = commentEl ? parseInt(commentEl.textContent.trim()) || 0 : 0;
results.push({ rank: results.length + 1, title, price, mall, comments, url });
// Update time lives as the direct text node(s) of .feed-block-extras,
// alongside the nested mall <span> which we exclude here.
let updated_at = '';
const extrasEl = li.querySelector('.z-feed-foot-r .feed-block-extras');
if (extrasEl) {
updated_at = Array.from(extrasEl.childNodes)
.filter((node) => node.nodeType === 3)
.map((node) => (node.textContent || '').trim())
.filter(Boolean)
.join(' ');
}
const zhi_count = intFrom(li.querySelector('.price-btn-up .unvoted-wrap span'));
const buzhi_count = intFrom(li.querySelector('.price-btn-down .unvoted-wrap span'));
const favorite_count = intFrom(li.querySelector('.feed-btn-fav span'));
const comments = intFrom(li.querySelector('.feed-btn-comment'));
results.push({ rank: results.length + 1, title, price, mall, updated_at, zhi_count, buzhi_count, favorite_count, comments, url });
});
return results;
})()
`);
if (!Array.isArray(data))
return [];
return data;
`;
}

export const smzdmSearchCommand = cli({
site: 'smzdm',
name: 'search',
access: 'read',
description: '什么值得买搜索好价',
domain: 'www.smzdm.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
],
columns: ['rank', 'title', 'price', 'mall', 'updated_at', 'zhi_count', 'buzhi_count', 'favorite_count', 'comments', 'url'],
func: async (page, kwargs) => {
const q = encodeURIComponent(kwargs.query);
const limit = parseLimit(kwargs.limit);
// Navigate directly to search results page
await page.goto(`https://search.smzdm.com/?c=home&s=${q}&v=b`);
return requireSearchRows(await page.evaluate(buildSmzdmSearchJs(limit)));
},
});

export const __test__ = { buildSmzdmSearchJs, parseLimit, requireSearchRows, unwrapEvaluateResult };
114 changes: 114 additions & 0 deletions clis/smzdm/search.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import { smzdmSearchCommand, __test__ } from './search.js';

function runBrowserScript(html, script, url = 'https://search.smzdm.com/?c=home&s=test&v=b') {
const dom = new JSDOM(html, { url, runScripts: 'outside-only' });
return dom.window.eval(script);
}

describe('smzdm/search', () => {
it('declares read access and the enriched column set', () => {
expect(smzdmSearchCommand.access).toBe('read');
expect(smzdmSearchCommand.columns).toEqual([
'rank', 'title', 'price', 'mall', 'updated_at',
'zhi_count', 'buzhi_count', 'favorite_count', 'comments', 'url',
]);
});

it('extracts interaction metrics and update time from a search result item', () => {
const html = `<ul><li class="feed-row-wide">
<h5 class="feed-block-title"><a href="https://www.smzdm.com/p/174854494/" title="ThinkBook14+ 轻薄笔记本">ThinkBook14+ 轻薄笔记本</a></h5>
<span class="z-highlight">4015.44元(需用券)</span>
<div class="z-feed-foot-l">
<span class="feed-btn-group price-btn-hover">
<span class="J_zhi_like_fav price-btn-up" data-type="zhi" data-zhi-type="1"><span class="unvoted-wrap"><span>1.2万</span></span></span>
<span class="J_zhi_like_fav price-btn-down" data-type="zhi" data-zhi-type="-1"><span class="unvoted-wrap"><span>3</span></span></span>
</span>
<span class="J_zhi_like_fav z-group-data feed-btn-fav"><span>40</span></span>
<a class="z-group-data feed-btn-comment" title="评论数 24">24</a>
</div>
<div class="z-feed-foot-r"><span class="feed-block-extras">
05-23 00:28
<span>天猫精选</span>
</span></div>
</li></ul>`;
const rows = runBrowserScript(html, __test__.buildSmzdmSearchJs(20));
expect(rows).toEqual([
{
rank: 1,
title: 'ThinkBook14+ 轻薄笔记本',
price: '4015.44元(需用券)',
mall: '天猫精选',
updated_at: '05-23 00:28',
zhi_count: 12000,
buzhi_count: 3,
favorite_count: 40,
comments: 24,
url: 'https://www.smzdm.com/p/174854494/',
},
]);
});

it('defaults missing interaction metrics to 0 without dropping columns', () => {
const html = `<ul><li class="feed-row-wide">
<h5 class="feed-block-title"><a href="/p/1/" title="No-metrics deal">No-metrics deal</a></h5>
</li></ul>`;
const rows = runBrowserScript(html, __test__.buildSmzdmSearchJs(20));
expect(rows).toEqual([
{
rank: 1,
title: 'No-metrics deal',
price: '',
mall: '',
updated_at: '',
zhi_count: 0,
buzhi_count: 0,
favorite_count: 0,
comments: 0,
url: 'https://www.smzdm.com/p/1/',
},
]);
});

it('drops untrusted result URLs before output', () => {
const html = `<ul><li class="feed-row-wide">
<h5 class="feed-block-title"><a href="https://evil.example/p/1/" title="Bad deal">Bad deal</a></h5>
</li></ul>`;
const rows = runBrowserScript(html, __test__.buildSmzdmSearchJs(20));
expect(rows).toEqual([]);
});

it('respects the limit argument', () => {
const li = `<li class="feed-row-wide"><h5 class="feed-block-title"><a href="/p/9/" title="Deal">Deal</a></h5></li>`;
const rows = runBrowserScript(`<ul>${li.repeat(5)}</ul>`, __test__.buildSmzdmSearchJs(2));
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.rank)).toEqual([1, 2]);
});

it('validates --limit before browser navigation', async () => {
const page = {
goto: vi.fn(),
evaluate: vi.fn(),
};
await expect(smzdmSearchCommand.func(page, { query: 'test', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
await expect(smzdmSearchCommand.func(page, { query: 'test', limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
await expect(smzdmSearchCommand.func(page, { query: 'test', limit: '1e2' })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});

it('unwraps Browser Bridge evaluate envelopes', () => {
const rows = [{ rank: 1, title: 'Deal' }];
expect(__test__.requireSearchRows({ session: 'site:smzdm', data: rows })).toBe(rows);
});

it('fails closed on malformed extraction payloads', async () => {
expect(() => __test__.requireSearchRows({ ok: true })).toThrow(CommandExecutionError);
const page = {
goto: vi.fn(),
evaluate: vi.fn().mockResolvedValue({ ok: true }),
};
await expect(smzdmSearchCommand.func(page, { query: 'test', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
});