Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

150 lines
5.9 KiB
JavaScript

/**
* 什么值得买搜索好价 — browser cookie, DOM scraping.
*
* Fix: The old adapter used `search.smzdm.com/ajax/` which returns 404.
* New approach: navigate to `search.smzdm.com/?c=home&s=<keyword>&v=b`
* and scrape the rendered DOM directly.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
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 = 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();
// 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;
})()
`;
}
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 };