项目文件夹

文件
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

111 行
4.3 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* guazi car — detail of one used-car listing by its clue id.
*
* Reads the mobile SSR detail page `https://m.guazi.com/car-detail/c<id>.html`:
* the sale price (`"price":<fen-ish int>`), the spec/condition `label`/`value`
* pairs embedded in the RSC flight payload, and the condition summary. Returns
* a key/value sheet. Pure HTML→rows so it is unit-tested against a frozen page.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
CAR_COLUMNS,
EmptyResultError,
GUAZI_M_BASE,
clean,
guaziFetch,
normalizeClueId,
requireText,
} from './utils.js';
/** Spec/condition labels worth surfacing, in display order. */
const SPEC_LABELS = ['首次上牌', '表显里程', '过户次数', '车源地', '车身颜色', '发动机', '变速箱', '驱动方式', '排放标准', '车源编号'];
/** Clean Guazi's SEO title down to the car name. */
function cleanTitle(raw) {
let t = clean(raw);
t = t.replace(/^【[^】]*】/, ''); // drop a leading 【准新车】-style tag
t = t.replace(/^二手/, ''); // drop the 二手 prefix
t = t.replace(/报价[,,].*$/, ''); // drop "报价,真实车源…- 瓜子二手车"
t = t.replace(/\s*-\s*瓜子二手车.*$/, '');
return clean(t);
}
/**
* Pure parser: detail-page HTML → field/value rows. Exported for unit testing.
*/
export function parseCarDetail(html, clueId) {
const u = String(html || '').replace(/\\"/g, '"');
const titleM = u.match(/<meta[^>]+property="og:title"[^>]+content="([^"]+)"/)
|| u.match(/"title":"([^"]{6,80})"/);
const rawTitle = titleM ? titleM[1] : '';
const tagM = clean(rawTitle).match(/^【([^】]+)】/);
const priceM = u.match(/"price":(\d{4,8})/);
const price = priceM ? `${(Number(priceM[1]) / 10000).toFixed(2)}万` : '';
// label/value pairs from the flight payload (deduped, first wins).
const labels = {};
for (const [, k, v] of u.matchAll(/"label":"([^"]{1,12})","value":"([^"]{1,40})"/g)) {
if (!(k in labels)) labels[k] = clean(v);
}
const condM = u.match(/基础车况[^,,"<]{0,16}/);
const fields = [
['clue_id', String(clueId)],
['title', rawTitle ? cleanTitle(rawTitle) : ''],
['tag', tagM ? tagM[1] : ''],
['price', price],
['reg_date', labels['首次上牌'] || ''],
['mileage', labels['表显里程'] || ''],
['transfers', labels['过户次数'] || ''],
['source_city', labels['车源地'] || ''],
['color', labels['车身颜色'] || ''],
['engine', labels['发动机'] || ''],
['gearbox', labels['变速箱'] || ''],
['drivetrain', labels['驱动方式'] || ''],
['emission', labels['排放标准'] || ''],
['condition', condM ? clean(condM[0]) : ''],
['listing_no', labels['车源编号'] || ''],
['url', `${GUAZI_M_BASE}/car-detail/c${clueId}.html`],
];
return fields.map(([field, value]) => ({ field, value }));
}
/** Whether the parsed sheet actually found a car (vs. a 404/empty page). */
function hasData(rows) {
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
return Boolean(map.title || map.price || map.reg_date);
}
cli({
site: 'guazi',
name: 'car',
access: 'read',
aliases: ['detail'],
description: '瓜子二手车车源详情(售价 / 上牌 / 里程 / 过户 / 配置 / 车况)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'clue_id', required: true, positional: true, help: '车源 ID(来自 browse 的 clue_id,或 /car-detail/c<id>.html URL' },
],
columns: CAR_COLUMNS,
func: async (args) => {
const clueId = normalizeClueId(args.clue_id);
const html = await guaziFetch(`/car-detail/c${clueId}.html`, `car ${clueId}`);
const rows = parseCarDetail(html, clueId);
if (!hasData(rows)) {
throw new EmptyResultError(
`guazi car ${clueId}`,
'No listing detail found — the car may have been sold/removed, or the id is wrong.',
);
}
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
requireText(map.title, `guazi car ${clueId} title`);
requireText(map.price, `guazi car ${clueId} price`);
return rows;
},
});