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

181 lines
6.4 KiB
JavaScript

/**
* Shared helpers for the 懂车帝 (Dongchedi) adapter.
*
* Dongchedi (a ByteDance car-info site) is a Next.js app that server-side
* renders every functional page with a `<script id="__NEXT_DATA__">` blob
* holding the full page data. A plain HTTP GET (no login, no signature, no
* browser) returns that blob, so every command here is a PUBLIC `fetch()`
* that pulls `props.pageProps` out of the SSR HTML and parses pure JSON —
* no cookies, no anti-bot tokens, no DOM scraping.
*
* The koubei/config XHR JSON APIs (`/motor/...`) are ByteDance-signature
* gated (a_bogus / X-Bogus) and 404 without a valid signature, so they are
* deliberately NOT used — the SSR pages expose the same data unsigned.
*
* Scores are stored x100 ints (422 == 4.22 / 5) — `parseScore` rescales.
*/
import {
ArgumentError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
export const DCD_BASE = 'https://www.dongchedi.com';
const UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
+ '(KHTML, like Gecko) Chrome/126.0 Safari/537.36';
export const SEARCH_COLUMNS = ['rank', 'series_id', 'name', 'brand', 'official_price', 'dealer_price', 'pictures', 'url'];
export const SERIES_COLUMNS = ['field', 'value'];
export const MODELS_COLUMNS = ['car_id', 'name', 'year', 'official_price', 'dealer_price', 'owner_price'];
export const SPECS_COLUMNS = ['field', 'value'];
export const SCORE_COLUMNS = ['dimension', 'score', 'same_level_avg'];
export const KOUBEI_COLUMNS = ['rank', 'user', 'car', 'score', 'likes', 'comments', 'content', 'url'];
// pageProps keys present on Dongchedi's "city gateway / 404" fallback shell.
// A real page always carries more than just these housekeeping fields.
const FALLBACK_SHELL_KEYS = ['__hasUrlCity', 'is_gray', 'has_gray', 'clientIp', 'sensitiveSeriesIdList'];
/**
* Extract `props.pageProps` from a Dongchedi SSR page's `__NEXT_DATA__`.
*
* Pure (string in, object|null out) so it runs identically against the
* live `fetch()` body and the frozen JSDOM-free fixtures in the unit test.
* Returns null when the blob is missing or unparseable.
*/
export function extractPageProps(html) {
const m = String(html || '').match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) return null;
try {
const data = JSON.parse(m[1]);
return (data && data.props && data.props.pageProps) || null;
} catch {
return null;
}
}
/**
* True when pageProps is Dongchedi's empty fallback shell (wrong URL form,
* city gateway, or a soft 404) rather than a real data page.
*/
export function isFallbackShell(pp) {
if (!pp || typeof pp !== 'object') return true;
const real = Object.keys(pp).filter((k) => !FALLBACK_SHELL_KEYS.includes(k));
return real.length === 0;
}
/**
* Fetch a Dongchedi page and return its parsed `pageProps`.
* Throws typed errors so callers can let them propagate.
*/
export async function dcdFetchPageProps(path, contextHint) {
let resp;
try {
resp = await fetch(`${DCD_BASE}${path}`, {
headers: {
'User-Agent': UA,
Referer: `${DCD_BASE}/`,
'Accept-Language': 'zh-CN,zh;q=0.9',
},
});
} catch (err) {
throw new CommandExecutionError(
`dongchedi ${contextHint} network error: ${err?.message || err}`,
);
}
if (!resp.ok) {
throw new CommandExecutionError(`dongchedi ${contextHint} HTTP ${resp.status}`);
}
const html = await resp.text();
const pp = extractPageProps(html);
if (!pp) {
throw new CommandExecutionError(
`dongchedi ${contextHint} returned no __NEXT_DATA__`,
'Dongchedi likely changed its page structure, or the request hit an anti-bot page.',
);
}
if (isFallbackShell(pp)) {
throw new CommandExecutionError(
`dongchedi ${contextHint}`,
'Dongchedi served its empty fallback shell — the id may not exist or the URL form changed.',
);
}
return pp;
}
export function assertPlainObject(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an object.`);
}
return value;
}
export function requireArray(value, label) {
if (!Array.isArray(value)) {
throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an array.`);
}
return value;
}
export function requireStableId(value, label) {
const id = String(value ?? '').trim();
if (!/^\d+$/.test(id) || id === '0') {
throw new CommandExecutionError(`${label} did not include a stable numeric id.`);
}
return id;
}
export function requireText(value, label) {
const text = clean(value);
if (!text) {
throw new CommandExecutionError(`${label} did not include a stable text value.`);
}
return text;
}
/** Rescale a Dongchedi x100 score int (422) to a /5 float (4.22). */
export function parseScore(raw) {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return Number((n / 100).toFixed(2));
}
/**
* Normalize a series id argument: a bare number, or a
* `https://www.dongchedi.com/auto/series/<id>` URL.
*/
export function normalizeSeriesId(rawInput) {
const raw = String(rawInput || '').trim();
if (!raw) throw new ArgumentError('series_id must be a non-empty value');
const m = raw.match(/series\/(\d+)/) || raw.match(/^(\d+)$/);
if (!m) {
throw new ArgumentError(
`'${rawInput}' does not look like a dongchedi series id (a number, or a /auto/series/<id> URL)`,
);
}
return m[1];
}
/** Validate an integer limit in [1, max]. */
export function requireLimit(value, def, max) {
const raw = value == null || value === '' ? def : value;
const n = typeof raw === 'number' ? raw : Number(String(raw).trim());
if (!Number.isInteger(n) || n < 1 || n > max) {
throw new ArgumentError(`limit must be an integer between 1 and ${max}`);
}
return n;
}
/** Collapse whitespace and trim; returns '' for nullish. */
export function clean(s) {
return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
}
/** Truncate long review text for table display, keeping it on one line. */
export function snippet(s, max = 180) {
const t = clean(s);
return t.length > max ? `${t.slice(0, max)}…` : t;
}