feat(adapters): add Chinese car-platform adapters — 懂车帝 / 瓜子二手车 / 汽车之家 (no-login) (#2009)

* feat(adapters): add 懂车帝 (dongchedi) + 瓜子二手车 (guazi) car adapters

Two no-login PUBLIC adapters for Chinese car platforms. Both read
server-rendered data (no cookies, no signature, no browser) and ship
pure parsers unit-tested against frozen real-data fixtures.

dongchedi (6 commands) — parses __NEXT_DATA__ SSR JSON:
  search  车系搜索 + 指导价/经销商价
  series  车系概览(品牌/价格/懂车分/销量排名/款型数)
  models  款型列表 + 价格
  specs   配置概览(尺寸/动力/四驱/悬挂/气囊)
  score   懂车分 8 维评分 + 同级对比
  koubei  车主口碑/评价正文
  (Dongchedi's /motor XHR APIs are ByteDance-signature gated; the SSR
   pages expose the same data unsigned, so the adapter reads those.)

guazi (2 commands) — parses m.guazi.com mobile SSR HTML:
  browse  分城市在售二手车列表(售价/里程/年份)
  car     车源详情(售价/上牌/里程/过户/配置/车况)
  (Desktop www.guazi.com is signature-locked; mobile SSR is open. Deep
   pagination/filtering uses the signed API and is intentionally omitted.)

Gates green: tsc, doc-coverage --strict, silent-column-drop (new=0),
typed-error-lint (no new), 24 adapter tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(adapters): add 汽车之家 (autohome) — brand catalog + 口碑 ratings

Third no-login PUBLIC car adapter (search by brand, not free text).

autohome (2 commands):
  brand  按品牌列出全部车系 + 厂商指导价(grade/carhtml/<INITIAL>.html,
         中文品牌名→拼音首字母目录页,DL 块按品牌定位)
  score  车系口碑评分:总分 + 各维度 + 故障率PPH + 竞品对比
         (k.autohome.com.cn/<id> 的 __NEXT_DATA__.baseData,免登录免签名)

Deliberately omitted (would be silently-wrong without a browser running
Autohome's signing/anti-scrape code): free-text keyword search (signature
gated) and full per-trim 参数配置 (rotating CSS font-glyph obfuscation).
Use dongchedi search/specs/koubei for those. Documented in the adapter doc.

Gates green: tsc, doc-coverage --strict (170/170), silent-column-drop
(new=0), typed-error-lint (no new); 31 adapter tests passing across the
three car adapters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(car-adapters): fail closed on parser drift

* fix(guazi): fail closed on empty SSR listings

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
Louie
2026-06-24 06:07:01 +08:00
committed by GitHub
parent 55b053a6f6
commit ee4820ef59
27 changed files with 4904 additions and 0 deletions
+329
View File
@@ -2928,6 +2928,70 @@
"modulePath": "arxiv/search.js",
"sourceFile": "arxiv/search.js"
},
{
"site": "autohome",
"name": "brand",
"aliases": [
"series"
],
"description": "汽车之家按品牌列出全部车系 + 厂商指导价(免登录)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "brand",
"type": "str",
"required": true,
"positional": true,
"help": "品牌名(宝马 / 比亚迪 / 理想 / 丰田 …)或车系目录首字母 A-Z"
},
{
"name": "limit",
"type": "int",
"default": 60,
"required": false,
"help": "返回的车系数量(最多 120"
}
],
"columns": [
"series_id",
"name",
"price",
"url"
],
"type": "js",
"modulePath": "autohome/brand.js",
"sourceFile": "autohome/brand.js"
},
{
"site": "autohome",
"name": "score",
"aliases": [
"koubei",
"rating"
],
"description": "汽车之家车系口碑评分(总分 + 各维度 + 故障率PPH + 竞品对比,免登录)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "series_id",
"type": "str",
"required": true,
"positional": true,
"help": "车系 ID来自 brand 的 series_id或 k.autohome.com.cn/<id> URL"
}
],
"columns": [
"field",
"value"
],
"type": "js",
"modulePath": "autohome/score.js",
"sourceFile": "autohome/score.js"
},
{
"site": "baidu-scholar",
"name": "search",
@@ -10575,6 +10639,203 @@
"modulePath": "dockerhub/search.js",
"sourceFile": "dockerhub/search.js"
},
{
"site": "dongchedi",
"name": "koubei",
"aliases": [
"reviews"
],
"description": "懂车帝车系口碑/车主评价(评分 / 购车款型 / 点赞 / 评论 / 正文摘要)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "series_id",
"type": "str",
"required": true,
"positional": true,
"help": "车系 ID来自 search 的 series_id或 /auto/series/<id> URL"
},
{
"name": "limit",
"type": "int",
"default": 10,
"required": false,
"help": "返回的口碑条数(最多 15单页 SSR 上限)"
}
],
"columns": [
"rank",
"user",
"car",
"score",
"likes",
"comments",
"content",
"url"
],
"type": "js",
"modulePath": "dongchedi/koubei.js",
"sourceFile": "dongchedi/koubei.js"
},
{
"site": "dongchedi",
"name": "models",
"aliases": [
"trims"
],
"description": "懂车帝车系款型列表car_id / 名称 / 年款 / 指导价 / 经销商价 / 车主成交价)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "series_id",
"type": "str",
"required": true,
"positional": true,
"help": "车系 ID来自 search 的 series_id或 /auto/series/<id> URL"
},
{
"name": "status",
"type": "str",
"default": "online",
"required": false,
"help": "在售 online默认或停售 offline"
}
],
"columns": [
"car_id",
"name",
"year",
"official_price",
"dealer_price",
"owner_price"
],
"type": "js",
"modulePath": "dongchedi/models.js",
"sourceFile": "dongchedi/models.js"
},
{
"site": "dongchedi",
"name": "score",
"aliases": [
"rating"
],
"description": "懂车帝车系评分(懂车分 8 维度 + 同级车均值对比)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "series_id",
"type": "str",
"required": true,
"positional": true,
"help": "车系 ID来自 search 的 series_id或 /auto/series/<id> URL"
}
],
"columns": [
"dimension",
"score",
"same_level_avg"
],
"type": "js",
"modulePath": "dongchedi/score.js",
"sourceFile": "dongchedi/score.js"
},
{
"site": "dongchedi",
"name": "search",
"description": "懂车帝车系搜索(按关键词,返回车系 + 指导价/经销商价)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "keyword",
"type": "str",
"required": true,
"positional": true,
"help": "搜索关键词,例如 \"宝马X5\" 或 \"汉兰达\""
},
{
"name": "limit",
"type": "int",
"default": 15,
"required": false,
"help": "返回的车系数量(最多 30"
}
],
"columns": [
"rank",
"series_id",
"name",
"brand",
"official_price",
"dealer_price",
"pictures",
"url"
],
"type": "js",
"modulePath": "dongchedi/search.js",
"sourceFile": "dongchedi/search.js"
},
{
"site": "dongchedi",
"name": "series",
"aliases": [
"detail"
],
"description": "懂车帝车系概览(品牌 / 指导价 / 二手价 / 懂车分 / 销量排名 / 在售款型数)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "series_id",
"type": "str",
"required": true,
"positional": true,
"help": "车系 ID来自 search 的 series_id或 /auto/series/<id> URL"
}
],
"columns": [
"field",
"value"
],
"type": "js",
"modulePath": "dongchedi/series.js",
"sourceFile": "dongchedi/series.js"
},
{
"site": "dongchedi",
"name": "specs",
"aliases": [
"config"
],
"description": "懂车帝车系配置概览(尺寸 / 动力 / 发动机 / 变速箱 / 四驱 / 悬挂 / 气囊)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "series_id",
"type": "str",
"required": true,
"positional": true,
"help": "车系 ID来自 search 的 series_id或 /auto/series/<id> URL"
}
],
"columns": [
"field",
"value"
],
"type": "js",
"modulePath": "dongchedi/specs.js",
"sourceFile": "dongchedi/specs.js"
},
{
"site": "douban",
"name": "book-hot",
@@ -15253,6 +15514,74 @@
"navigateBefore": false,
"siteSession": "persistent"
},
{
"site": "guazi",
"name": "browse",
"aliases": [
"list"
],
"description": "瓜子二手车在售车源列表(按城市,含售价/首付/里程/年份)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "city",
"type": "str",
"required": false,
"positional": true,
"help": "城市名(北京/上海/...或瓜子城市码bj/sh/...)。默认 bj 北京"
},
{
"name": "limit",
"type": "int",
"default": 20,
"required": false,
"help": "返回的车源数量(最多 40单页 SSR 上限)"
}
],
"columns": [
"rank",
"clue_id",
"title",
"price",
"down_payment",
"mileage",
"year",
"city",
"url"
],
"type": "js",
"modulePath": "guazi/browse.js",
"sourceFile": "guazi/browse.js"
},
{
"site": "guazi",
"name": "car",
"aliases": [
"detail"
],
"description": "瓜子二手车车源详情(售价 / 上牌 / 里程 / 过户 / 配置 / 车况)",
"access": "read",
"strategy": "public",
"browser": false,
"args": [
{
"name": "clue_id",
"type": "str",
"required": true,
"positional": true,
"help": "车源 ID来自 browse 的 clue_id或 /car-detail/c<id>.html URL"
}
],
"columns": [
"field",
"value"
],
"type": "js",
"modulePath": "guazi/car.js",
"sourceFile": "guazi/car.js"
},
{
"site": "hackernews",
"name": "ask",
+11
View File
@@ -0,0 +1,11 @@
<!doctype html><html><body><dl id="15" olr="5"> <dt><a href="//car.autohome.com.cn/price/brand-15.html#pvareaid=2042362"><img width="50" height="50" src="//car2.autoimg.cn/cardfs/series/g28/M08/10/45/autohomecar__CjIFVGUNeJWAOukrAADdG-QkWXI004.png"></a><div><a href="//car.autohome.com.cn/price/brand-15.html#pvareaid=2042362">宝马</a></div></dt> <dd> <li id="s7344">
<h4><a href='//www.autohome.com.cn/7344/#levelsource=000000000_0&pvareaid=101594'>宝马i5</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/7344/price.html#pvareaid=101446'>43.99-53.99万</a></div><div><a href='//car.autohome.com.cn/price/series-7344.html#pvareaid=103446'>报价</a> <a id='atk_7344' href='//car.autohome.com.cn/pic/series/7344.html#pvareaid=103448'>图库</a> <a data-value='7344' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-7344-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/7344/#pvareaid=103459'>口碑</a></div>
</li> <li id="s5758">
<h4><a href='//www.autohome.com.cn/5758/#levelsource=000000000_0&pvareaid=101594'>宝马iX3</a><i class='icon icon-jseason' title='将上市'></i></h4>指导价:暂无<div><span class='text-through'>报价</span> <a id='atk_5758' href='//car.autohome.com.cn/pic/series/5758.html#pvareaid=103448'>图库</a> <a data-value='5758' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-5758-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/5758/#pvareaid=103459'>口碑</a></div>
</li> <li id="s7827">
<h4><a href='//www.autohome.com.cn/7827/#levelsource=000000000_0&pvareaid=101594'>宝马2系</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/7827/price.html#pvareaid=101446'>20.80-22.80万</a></div><div><a href='//car.autohome.com.cn/price/series-7827.html#pvareaid=103446'>报价</a> <a id='atk_7827' href='//car.autohome.com.cn/pic/series/7827.html#pvareaid=103448'>图库</a> <a data-value='7827' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-7827-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/7827/#pvareaid=103459'>口碑</a></div>
</li> <li id="s66">
<h4><a href='//www.autohome.com.cn/66/#levelsource=000000000_0&pvareaid=101594'>宝马3系</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/66/price.html#pvareaid=101446'>25.80-33.80万</a></div><div><a href='//car.autohome.com.cn/price/series-66.html#pvareaid=103446'>报价</a> <a id='atk_66' href='//car.autohome.com.cn/pic/series/66.html#pvareaid=103448'>图库</a> <a data-value='66' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-66-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/66/#pvareaid=103459'>口碑</a></div>
</li> <li id="s6544">
<h4><a href='//www.autohome.com.cn/6544/#levelsource=000000000_0&pvareaid=101594'>宝马i3</a></h4><div>指导价:<a class='red' href='//www.autohome.com.cn/6544/price.html#pvareaid=101446'>27.80-33.80万</a></div><div><a href='//car.autohome.com.cn/price/series-6544.html#pvareaid=103446'>报价</a> <a id='atk_6544' href='//car.autohome.com.cn/pic/series/6544.html#pvareaid=103448'>图库</a> <a data-value='6544' class='js-che168link' href='//www.che168.com/china/series0/'>二手车</a> <a href='//club.autohome.com.cn/bbs/forum-c-6544-1.html#pvareaid=103447'>论坛</a> <a href='//k.autohome.com.cn/6544/#pvareaid=103459'>口碑</a></div>
</li> </dd> </dl></body></html>
+116
View File
@@ -0,0 +1,116 @@
{
"baseData": {
"seriesname": "宝马X5",
"brandName": "宝马",
"levelname": "中大型SUV",
"pricerange": "59.80-74.80",
"average": "4.41",
"seriesAverage": "4.41",
"seriesScoreList": [
{
"typeName": "空间",
"typeKey": 3,
"score": 4.91,
"rank": 0
},
{
"typeName": "驾驶感受",
"typeKey": 4,
"score": 4.75,
"rank": 0
},
{
"typeName": "油耗",
"typeKey": 6,
"score": 4.02,
"rank": 0
},
{
"typeName": "外观",
"typeKey": 8,
"score": 4.75,
"rank": 0
},
{
"typeName": "内饰",
"typeKey": 9,
"score": 4.17,
"rank": 0
},
{
"typeName": "性价比",
"typeKey": 15,
"score": 4.22,
"rank": 0
},
{
"typeName": "配置",
"typeKey": 40,
"score": 4.02,
"rank": 0
}
],
"cmpSeriesScore": [
{
"seriesId": 8449,
"newCarPPH": 0,
"newCarPPHUserCount": 0,
"seriesName": "奥迪E7X",
"score": "4.59",
"maxItemScore": "4.90",
"maxItemName": "动力",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
},
{
"seriesId": 8529,
"newCarPPH": 0,
"newCarPPHUserCount": 0,
"seriesName": "问界M6",
"score": "4.58",
"maxItemScore": "4.80",
"maxItemName": "空间",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
},
{
"seriesId": 8183,
"newCarPPH": 42,
"newCarPPHUserCount": 33,
"seriesName": "理想i6",
"score": "4.57",
"maxItemScore": "4.82",
"maxItemName": "空间",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
},
{
"seriesId": 8171,
"newCarPPH": 123,
"newCarPPHUserCount": 84,
"seriesName": "钛7",
"score": "4.52",
"maxItemScore": "4.70",
"maxItemName": "空间",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
},
{
"seriesId": 6643,
"newCarPPH": 47,
"newCarPPHUserCount": 53,
"seriesName": "问界M7",
"score": "4.51",
"maxItemScore": "4.68",
"maxItemName": "空间",
"reliabilityPPH": 0,
"reliabilityPPHUserCount": 0
}
],
"seriesid": 6548
},
"qualityData": {
"pph": 136,
"userCount": 53
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Unit tests for the 汽车之家 (Autohome) adapter.
*
* `brand` parses the catalog HTML; `score` parses koubei __NEXT_DATA__.
* Both pure parsers run against frozen real-data fixtures (宝马 / series 6548).
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
import {
BRAND_COLUMNS,
SCORE_COLUMNS,
resolveBrandInitial,
normalizeSeriesId,
extractPageProps,
requireLimit,
} from './utils.js';
import { parseBrandSeries } from './brand.js';
import { parseScore } from './score.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CATALOG = readFileSync(join(__dirname, '__fixtures__/catalog.html'), 'utf8');
const KOUBEI = JSON.parse(readFileSync(join(__dirname, '__fixtures__/koubei.json'), 'utf8'));
describe('autohome adapter — registration', () => {
it('registers brand + score as PUBLIC (no browser)', () => {
for (const n of ['brand', 'score']) {
const cmd = getRegistry().get(`autohome/${n}`);
expect(cmd, n).toBeTruthy();
expect(cmd.strategy, n).toBe(Strategy.PUBLIC);
expect(cmd.browser, n).toBe(false);
expect(cmd.access, n).toBe('read');
}
expect(getRegistry().get('autohome/brand').columns).toEqual(BRAND_COLUMNS);
expect(getRegistry().get('autohome/score').columns).toEqual(SCORE_COLUMNS);
});
});
describe('autohome adapter — utils', () => {
it('resolveBrandInitial maps brands and letters', () => {
expect(resolveBrandInitial('宝马')).toBe('B');
expect(resolveBrandInitial('比亚迪')).toBe('B');
expect(resolveBrandInitial('理想')).toBe('L');
expect(resolveBrandInitial('丰田')).toBe('F');
expect(resolveBrandInitial('b')).toBe('B');
expect(() => resolveBrandInitial('不存在的牌子')).toThrow();
expect(() => resolveBrandInitial('')).toThrow();
});
it('normalizeSeriesId accepts numbers and URLs', () => {
expect(normalizeSeriesId('6548')).toBe('6548');
expect(normalizeSeriesId('https://k.autohome.com.cn/6548')).toBe('6548');
expect(normalizeSeriesId('s6548')).toBe('6548');
expect(() => normalizeSeriesId('宝马')).toThrow();
});
it('requireLimit rejects invalid limits instead of silently falling back', () => {
expect(requireLimit(undefined, 60, 120)).toBe(60);
expect(requireLimit('5', 60, 120)).toBe(5);
expect(() => requireLimit('abc', 60, 120)).toThrow(/integer/);
expect(() => requireLimit(121, 60, 120)).toThrow(/integer/);
});
it('extractPageProps returns null on missing blob', () => {
expect(extractPageProps('<html>no</html>')).toBeNull();
});
});
describe('autohome adapter — parsers against frozen fixtures', () => {
it('parseBrandSeries lists a brand\'s series with id + guide price', () => {
const rows = parseBrandSeries(CATALOG, '宝马', 60);
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) {
expect(Object.keys(r).sort()).toEqual([...BRAND_COLUMNS].sort());
expect(r.series_id).toMatch(/^\d+$/);
expect(r.name).toContain('宝马');
expect(r.url).toContain(`/${r.series_id}/`);
}
expect(rows.some((r) => //.test(r.price))).toBe(true);
});
it('parseBrandSeries returns [] for a brand not on the page', () => {
expect(parseBrandSeries(CATALOG, '丰田', 60)).toEqual([]);
});
it('parseBrandSeries rejects catalog pages without brand blocks', () => {
expect(() => parseBrandSeries('<html></html>', '宝马', 60)).toThrow(/unexpected HTML shape/);
});
it('parseBrandSeries rejects malformed series cards', () => {
expect(() => parseBrandSeries('<dl><dt><div><a>宝马</a></div></dt><li id="s6548"></li></dl>', '宝马', 60))
.toThrow(/stable text value/);
});
it('parseScore builds a rating sheet with overall + axes + pph', () => {
const rows = parseScore(KOUBEI, '6548');
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(rows.every((r) => Object.keys(r).sort().join() === 'field,value')).toBe(true);
expect(map.name).toBe('宝马X5');
expect(map.brand).toBe('宝马');
expect(map.guide_price).toMatch(/万$/);
expect(typeof map.overall).toBe('number');
expect(map.overall).toBeGreaterThan(0);
expect(map.overall).toBeLessThanOrEqual(5);
// a known axis from the fixture
expect(typeof map['空间']).toBe('number');
expect(typeof map.pph_每百车故障).toBe('number');
expect(map.url).toContain('/6548');
});
it('parseScore rejects malformed koubei payloads', () => {
expect(() => parseScore({}, '6548')).toThrow(/unexpected payload shape/);
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* autohome brand — list a brand's car series with guide prices.
*
* Fetches the brand catalog page `grade/carhtml/<INITIAL>.html` (UTF-8, fully
* server-rendered), isolates the `<dl>` block whose `<dt>` names the brand,
* and reads each `<li id="s<seriesId>">` series + its 指导价. Pure HTML→rows
* so it is unit-tested against a frozen catalog slice.
*
* This is Autohome's login-free "search": you search by brand (the catalog is
* brand-organized). Free-text model search is signature-gated and not offered;
* for that, use `dongchedi search`.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
AH_BASE,
BRAND_COLUMNS,
CommandExecutionError,
EmptyResultError,
ahFetch,
clean,
requireLimit,
requireStableId,
requireText,
resolveBrandInitial,
} from './utils.js';
/**
* Pure parser: catalog HTML + brand name → series rows. Exported for tests.
*/
export function parseBrandSeries(html, brandName, limit) {
const source = String(html || '');
const blocks = source.match(/<dl[^>]*>[\s\S]*?<\/dl>/g);
if (!blocks) {
throw new CommandExecutionError('autohome brand catalog returned an unexpected HTML shape; expected brand <dl> blocks.');
}
const want = String(brandName || '').replace(/[·\s]/g, '');
// No brand name (single-letter catalog mode): scan the whole page.
// Otherwise isolate the <dl> block whose <dt> names the brand.
let block = html;
if (want) {
block = null;
for (const b of blocks) {
const nameM = b.match(/<dt>[\s\S]*?<div>\s*<a[^>]*>([^<]+)<\/a>/);
const name = nameM ? clean(nameM[1]).replace(/[·\s]/g, '') : '';
if (name && (name === want || name.startsWith(want) || want.startsWith(name))) {
block = b;
break;
}
}
if (!block) return [];
}
const rows = [];
const liRe = /<li id="s(\d+)">([\s\S]*?)<\/li>/g;
let m;
while ((m = liRe.exec(block)) !== null) {
const seriesId = requireStableId(m[1], `autohome brand row ${rows.length + 1}`);
const li = m[2];
const nameM = li.match(/<h4>\s*<a[^>]*>([^<]+)<\/a>/) || li.match(/<a[^>]*>([^<]+)<\/a>/);
const name = requireText(nameM && nameM[1], `autohome brand row ${rows.length + 1} name`);
const priceM = li.match(/指导价[:]\s*<[^>]*>([^<]+)</) || li.match(/指导价[:]\s*([^<]+)</);
let price = clean(priceM && priceM[1]);
if (/暂无|未上市|停售/.test(price)) price = '';
rows.push({
series_id: seriesId,
name,
price,
url: `${AH_BASE}/${seriesId}/`,
});
if (rows.length >= limit) break;
}
return rows;
}
cli({
site: 'autohome',
name: 'brand',
access: 'read',
aliases: ['series'],
description: '汽车之家按品牌列出全部车系 + 厂商指导价(免登录)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'brand', required: true, positional: true, help: '品牌名(宝马 / 比亚迪 / 理想 / 丰田 …)或车系目录首字母 A-Z' },
{ name: 'limit', type: 'int', default: 60, help: '返回的车系数量(最多 120' },
],
columns: BRAND_COLUMNS,
func: async (args) => {
const brand = String(args.brand || '').trim();
const initial = resolveBrandInitial(brand);
const limit = requireLimit(args.limit, 60, 120);
const html = await ahFetch(
`${AH_BASE}/grade/carhtml/${initial}.html`,
`brand ${brand}`,
);
const rows = parseBrandSeries(html, /^[A-Za-z]$/.test(brand) ? '' : brand, limit);
if (rows.length === 0) {
throw new EmptyResultError(
`autohome brand ${brand}`,
`No series found for '${brand}'. Check the brand name spelling (simplified Chinese), or try a single A-Z catalog letter.`,
);
}
return rows;
},
});
+103
View File
@@ -0,0 +1,103 @@
/**
* autohome score — 口碑 (owner-rating) summary for a car series.
*
* Reads `__NEXT_DATA__.props.pageProps.baseData` (+ `qualityData`) from the
* koubei page `k.autohome.com.cn/<seriesId>`: overall rating, per-dimension
* scores, level, guide price, the reliability PPH (每百辆车故障数), and the
* competitor comparison. All unsigned, login-free. Returns a key/value sheet.
*
* Note: Autohome's per-review TEXT list loads from a separate signed XHR and
* is intentionally not scraped — this command surfaces the aggregate only.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
AH_KOUBEI_BASE,
SCORE_COLUMNS,
CommandExecutionError,
EmptyResultError,
assertPlainObject,
ahFetch,
clean,
extractPageProps,
normalizeSeriesId,
} from './utils.js';
/** Number or null. */
function num(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/**
* Pure parser: koubei pageProps → field/value rows. Exported for unit tests.
*/
export function parseScore(pp, seriesId) {
const bd = assertPlainObject(pp?.baseData, 'autohome baseData');
const qd = (pp && pp.qualityData) || {};
const competitors = (Array.isArray(bd.cmpSeriesScore) ? bd.cmpSeriesScore : [])
.map((c) => {
const name = clean(c.seriesname || c.seriesName);
const s = c.average || c.score;
return name ? `${name}(${s})` : '';
})
.filter(Boolean)
.slice(0, 4)
.join(', ');
const fields = [
['series_id', String(seriesId)],
['name', clean(bd.seriesname)],
['brand', clean(bd.brandName)],
['level', clean(bd.levelname)],
['guide_price', bd.pricerange ? `${clean(bd.pricerange)}` : ''],
['overall', num(bd.average ?? bd.seriesAverage)],
];
for (const axis of (Array.isArray(bd.seriesScoreList) ? bd.seriesScoreList : [])) {
const label = clean(axis.typeName);
if (label) fields.push([label, num(axis.score)]);
}
fields.push(['pph_每百车故障', num(qd.pph)]);
fields.push(['review_users', num(qd.userCount)]);
fields.push(['competitors', competitors]);
fields.push(['url', `${AH_KOUBEI_BASE}/${seriesId}`]);
return fields.map(([field, value]) => ({ field, value }));
}
cli({
site: 'autohome',
name: 'score',
access: 'read',
aliases: ['koubei', 'rating'],
description: '汽车之家车系口碑评分(总分 + 各维度 + 故障率PPH + 竞品对比,免登录)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'series_id', required: true, positional: true, help: '车系 ID来自 brand 的 series_id或 k.autohome.com.cn/<id> URL' },
],
columns: SCORE_COLUMNS,
func: async (args) => {
const seriesId = normalizeSeriesId(args.series_id);
const html = await ahFetch(`${AH_KOUBEI_BASE}/${seriesId}`, `score ${seriesId}`);
const pp = extractPageProps(html);
if (!pp) {
throw new CommandExecutionError(
`autohome score ${seriesId}`,
'No koubei data found — the series id may be wrong, or Autohome changed its page.',
);
}
const rows = parseScore(pp, seriesId);
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
if (!map.name && map.overall == null) {
throw new EmptyResultError(
`autohome score ${seriesId}`,
'This series has no koubei rating yet.',
);
}
return rows;
},
});
+157
View File
@@ -0,0 +1,157 @@
/**
* Shared helpers for the 汽车之家 (Autohome) adapter.
*
* Autohome's keyword-search and per-trim-config JSON APIs are app-signature
* gated (and the config page additionally uses CSS font-glyph obfuscation),
* so those are deliberately NOT used — they cannot be read reliably without a
* browser running Autohome's signing code, and faking partial data would be
* worse than omitting it. Two sources ARE clean, no-login, plain-HTTP:
*
* 1. The brand catalog `grade/carhtml/<INITIAL>.html` — every series of a
* brand with its 指导价 (guide price), keyed by the brand's pinyin
* initial letter (hence the BRAND_INITIAL map below).
* 2. The 口碑 page `k.autohome.com.cn/<seriesId>` — a Next.js page whose
* `__NEXT_DATA__.props.pageProps.baseData` carries the aggregate owner
* rating (overall + per-dimension), level, price, competitors, and the
* reliability PPH (每百辆车故障数).
*
* So the adapter searches by BRAND (you almost always know the brand) and
* reads ratings by seriesId — both unsigned, both login-free.
*/
import {
ArgumentError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
export const AH_BASE = 'https://www.autohome.com.cn';
export const AH_KOUBEI_BASE = 'https://k.autohome.com.cn';
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 BRAND_COLUMNS = ['series_id', 'name', 'price', 'url'];
export const SCORE_COLUMNS = ['field', 'value'];
/**
* 中文品牌名 → 车系目录页的拼音首字母 (grade/carhtml/<X>.html).
* Covers the brands people actually search; unknown brands raise a clear
* error rather than guessing the wrong page.
*/
export const BRAND_INITIAL = {
奥迪: 'A', 阿斯顿马丁: 'A', 阿尔法罗密欧: 'A', 阿维塔: 'A', 埃安: 'A', 极狐: 'A',
宝马: 'B', 奔驰: 'B', 比亚迪: 'B', 别克: 'B', 本田: 'B', 标致: 'B', 保时捷: 'B', 宝骏: 'B', 北京: 'B', 北汽: 'B', 宾利: 'B', 北京现代: 'B',
长安: 'C', 长城: 'C', 长安启源: 'C', 长安欧尚: 'C', 传祺: 'C',
大众: 'D', 东风: 'D', 道奇: 'D', 东风风行: 'D', 东风小康: 'D',
法拉利: 'F', 福特: 'F', 丰田: 'F', 菲亚特: 'F', 福田: 'F', 方程豹: 'F', 飞凡: 'F',
广汽: 'G', 广汽丰田: 'G', 广汽本田: 'G', 高合: 'G',
哈弗: 'H', 红旗: 'H', 海马: 'H', 悍马: 'H', 哈飞: 'H', 华晨: 'H',
吉利: 'J', 捷豹: 'J', 极氪: 'J', 江淮: 'J', 几何: 'J', 捷途: 'J', 金杯: 'J', 江铃: 'J', 吉普: 'J', 极石: 'J',
凯迪拉克: 'K', 克莱斯勒: 'K', 开瑞: 'K', 凯翼: 'K',
兰博基尼: 'L', 路虎: 'L', 雷克萨斯: 'L', 林肯: 'L', 铃木: 'L', 劳斯莱斯: 'L', 雷诺: 'L', 理想: 'L', 领克: 'L', 零跑: 'L', 路特斯: 'L', 岚图: 'L', 猎豹: 'L',
马自达: 'M', 迈巴赫: 'M', 名爵: 'M', 玛莎拉蒂: 'M', 迈凯伦: 'M',
哪吒: 'N',
欧拉: 'O',
奇瑞: 'Q', 起亚: 'Q',
日产: 'R', 荣威: 'R',
斯巴鲁: 'S', 斯柯达: 'S', 三菱: 'S', 上汽大通: 'S', 思皓: 'S', 赛力斯: 'S', smart: 'S',
特斯拉: 'T', 腾势: 'T', 坦克: 'T',
沃尔沃: 'W', 五菱: 'W', 蔚来: 'W', 威马: 'W', 魏牌: 'W', 问界: 'W',
现代: 'X', 雪佛兰: 'X', 雪铁龙: 'X', 小鹏: 'X', 星途: 'X', 小米: 'X',
英菲尼迪: 'Y', 一汽: 'Y', 野马: 'Y', 仰望: 'Y',
智己: 'Z', 中华: 'Z', 众泰: 'Z',
};
/** Resolve a brand name to its catalog initial letter. */
export function resolveBrandInitial(brandArg) {
const raw = String(brandArg || '').trim();
if (!raw) throw new ArgumentError('brand must be a non-empty value');
// single A-Z letter passes through (advanced: fetch a whole letter page)
if (/^[A-Za-z]$/.test(raw)) return raw.toUpperCase();
const key = raw.replace(/[·\s]/g, '');
if (BRAND_INITIAL[key]) return BRAND_INITIAL[key];
if (BRAND_INITIAL[raw]) return BRAND_INITIAL[raw];
throw new ArgumentError(
'brand',
`unknown brand '${brandArg}'. Pass a known Chinese brand name (e.g. 宝马 / 比亚迪 / 理想) or a single A-Z catalog letter.`,
);
}
/** Normalize a series id: a bare number or an autohome URL containing it. */
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(/\/(?:s)?(\d+)(?:\/|$|\.)/) || raw.match(/^s?(\d+)$/);
if (!m) {
throw new ArgumentError(`'${rawInput}' does not look like an autohome series id (a number, or a k.autohome.com.cn/<id> URL)`);
}
return m[1];
}
export function clean(s) {
return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
}
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;
}
export function requireStableId(value, label) {
const id = String(value ?? '').trim();
if (!/^\d+$/.test(id)) 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;
}
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;
}
/** Fetch an Autohome page as text. The grade + koubei pages are UTF-8. */
export async function ahFetch(url, contextHint) {
let resp;
try {
resp = await fetch(url, {
headers: {
'User-Agent': UA,
Referer: `${AH_BASE}/`,
'Accept-Language': 'zh-CN,zh;q=0.9',
},
});
} catch (err) {
throw new CommandExecutionError(`autohome ${contextHint} network error: ${err?.message || err}`);
}
if (!resp.ok) {
throw new CommandExecutionError(`autohome ${contextHint} HTTP ${resp.status}`);
}
return resp.text();
}
/** Extract __NEXT_DATA__ pageProps from a koubei page (pure, testable). */
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;
}
}
export { ArgumentError, CommandExecutionError, EmptyResultError };
+60
View File
@@ -0,0 +1,60 @@
{
"data": [
{
"id": "26_dcd_car_series_v2:5273",
"series_id": 5273,
"cell_type": 26,
"koubei": {
"url": "",
"tag_list": null
},
"display": {
"title": "宝马X5",
"series_name": "宝马X5",
"sub_brand_name": "华晨宝马",
"agent_price": "51.00-68.00万",
"official_price": "59.80-74.80万",
"cover_url": "https://p3-dcd.byteimg.com/img/tos-cn-i-dcdx/bf84f7f297c74b4ab69467bef101e7e1~1080x0.png?psm=motor.search.block",
"picture_num": 11355,
"dealer_text": "询最低价"
}
},
{
"id": "200_:7566598885808833062",
"series_id": null,
"cell_type": 200,
"koubei": null,
"display": {}
},
{
"id": "200_:7601400898518090302",
"series_id": null,
"cell_type": 200,
"koubei": null,
"display": {}
},
{
"id": "200_:7595987626075849241",
"series_id": null,
"cell_type": 200,
"koubei": null,
"display": {}
},
{
"id": "200_:7621424306387747352",
"series_id": null,
"cell_type": 200,
"koubei": null,
"display": {}
},
{
"id": "200_:7601471677582803518",
"series_id": null,
"cell_type": 200,
"koubei": null,
"display": {}
}
],
"has_more": 1,
"return_count": 37
}
@@ -0,0 +1,911 @@
{
"seriesHomeHead": {
"brand_id": 4,
"motor_id": 40052730000000,
"motor_id_str": "40052730000000",
"brand_name": "宝马",
"brand_initial": "B",
"brand_logo": "https://p1-dcd.byteimg.com/img/motor-mis-img/4867710a834bd648ba55797ba5e37f14~tplv-resize:100:100.image",
"sub_brand_id": 199,
"sub_brand_name": "华晨宝马",
"series_type": 1,
"series_id": 5273,
"series_name": "宝马X5",
"dealer_low_price": 51,
"dealer_high_price": 68,
"official_low_Price": 59.8,
"official_high_price": 74.8,
"low_price_car_id": 255925,
"sh_low_Price": 4.68,
"sh_high_price": 63.8,
"pre_low_Price": 0,
"pre_high_price": 0,
"dealer_price": "51.00-68.00万",
"has_dealer_price": true,
"official_price": "59.80-74.80万",
"has_official_price": true,
"sh_price": "4.68-63.80万",
"has_sh_price": true,
"pre_price": "暂无报价",
"has_pre_price": false,
"total_score": 422,
"total_review_count": 470,
"comfort_score": 410,
"appearance_score": 457,
"configuration_score": 401,
"control_score": 410,
"power_score": 408,
"space_score": 466,
"interiors_score": 402,
"has_360_ns": true,
"ns_vr_cover_image": "http://p3-dcd.byteimg.com/motor-mis-img/DCP_399b09f1a9264c3c7e9012176f353da4~tplv-f042mdwyw7-original:1024:0.image?psm=motor.pc_car.api",
"cover_url": "http://p3-dcd.byteimg.com/motor-mis-img/DCP_e643b5fe8b99803ddc526fa15c1a5738~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"car_type": "中大型SUV",
"business_status": 0,
"car_id_list": [
255925,
255924,
256128,
255923,
253998,
253999,
254000
],
"pics_summary_info": [
{
"SeriesId": 5273,
"Category": "wg",
"PicCount": 5653,
"CoverPicUrl": "http://p3-dcd.byteimg.com/motor-mis-img/DCP_e643b5fe8b99803ddc526fa15c1a5738~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
},
{
"SeriesId": 5273,
"Category": "ns",
"PicCount": 2071,
"CoverPicUrl": "http://p3-dcd.byteimg.com/motor-mis-img/DCP_53003324509afa8ae45f6c14da58d745~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
},
{
"SeriesId": 5273,
"Category": "kj",
"PicCount": 2629,
"CoverPicUrl": "http://p3-dcd.byteimg.com/motor-mis-img/DCP_2c745b2a830e79bf0025a1f138b4c9a8~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
},
{
"SeriesId": 5273,
"Category": "gft",
"PicCount": 151,
"CoverPicUrl": "http://p3-dcd.byteimg.com/tos-cn-i-dcdx/dca32bbec20743c28bcecbb755222482~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
},
{
"SeriesId": 5273,
"Category": "cz",
"PicCount": 851,
"CoverPicUrl": "http://p3-dcd.byteimg.com/tos-cn-i-dcdx/e1c382fd04b9460a824ccee446ac928f~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
}
],
"series_head_image_summary": {
"ns_pic_count": "2071",
"kj_pic_count": "2629",
"gft_pic_count": "151",
"cz_pic_count": "851",
"wg_pic_count": "5653",
"wg_pic_list": [
"http://p3-dcd.byteimg.com/motor-mis-img/DCP_e643b5fe8b99803ddc526fa15c1a5738~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/motor-mis-img/DCP_ffbcc700e7c4058ddf7ddc1b83866042~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/motor-mis-img/DCP_83fb91a2c3c26a085bdc06b03da4987e~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
],
"ns_pic_list": [
"http://p3-dcd.byteimg.com/motor-mis-img/DCP_53003324509afa8ae45f6c14da58d745~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/motor-mis-img/DCP_3f1d91e1534312edcb8c213868337152~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/motor-mis-img/DCP_51699fe7bf8534a8085123b35add82ee~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
],
"kj_pic_list": [
"http://p3-dcd.byteimg.com/motor-mis-img/DCP_2c745b2a830e79bf0025a1f138b4c9a8~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/motor-mis-img/DCP_95ddb7f4d0ac8b39e48b65686eec6ee7~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/motor-mis-img/DCP_39d0db2cadf9589961404d2c81ddbc82~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
],
"gft_pic_list": [
"http://p3-dcd.byteimg.com/tos-cn-i-dcdx/dca32bbec20743c28bcecbb755222482~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/tos-cn-i-dcdx/295ef2a321734445960dee6e4904e806~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/tos-cn-i-dcdx/f573dccffab545899db113f06fcda6b7~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
],
"cz_pic_list": [
"http://p3-dcd.byteimg.com/tos-cn-i-dcdx/e1c382fd04b9460a824ccee446ac928f~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/tos-cn-i-dcdx/9e10c0150b2d4858960c93da3d159a70~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api",
"http://p3-dcd.byteimg.com/tos-cn-i-dcdx/9ecf07208e8a48c48f8399dde28bce40~tplv-f042mdwyw7-original:960:0.image?psm=motor.pc_car.api"
]
},
"pc_config": {
"oil_tank_volume": "83L",
"baggage_volume": "2070L",
"fuel_comprehensive": "-",
"air_control_model": "自动",
"acceleration_time": "5.5-7.2s",
"max_speed": "250Km/h",
"curb_weight": "2157-2271Kg",
"seat_material": "仿皮",
"wheelbase": "3105mm",
"driver_form": "四驱",
"gearbox_description": "8挡手自一体",
"fuel_label": "95#",
"fuel_form": "48V轻混系统",
"car_body_structure": "承载式",
"car_type": "中大型SUV",
"series_displacement": "2.0T 3.0T",
"environmental_standards": "国VI",
"engine_description": "287-423马力",
"seat_count": "5座"
},
"series_image_list": null,
"series_image_info_list": null,
"series_new_energy": false,
"pic_count": 11355,
"series_rank_info_list": [],
"is_follow_car": false
},
"scoreSimpleInfo": {
"score": 422,
"comfort_score": 410,
"appearance_score": 457,
"configuration_score": 401,
"control_score": 410,
"power_score": 408,
"space_score": 466,
"interiors_score": 402,
"series_id": 5273,
"total_review_count": 470
},
"rankData": {
"sale": {
"series_type": "SUV",
"curr_sell_month": 202605,
"rank_type": 2,
"rank_name": "五月销量",
"is_show": false,
"list": [
{
"series_id": 5273,
"series_name": "宝马X5",
"score": 0,
"rank": 1,
"total_review_count": 0,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 2,
"month_sell_count": 0,
"series_hot": 0,
"cover_uri": "http://p3-dcd.byteimg.com/tos-cn-i-dcdx/bf84f7f297c74b4ab69467bef101e7e1~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 51,
"agent_high_price": 68,
"official_low_price": 59.8,
"official_high_price": 74.8,
"outter_detail_type": "13",
"series_new_energy_type": 0
}
]
},
"score": {
"series_type": "SUV",
"curr_sell_month": 0,
"rank_type": 1,
"rank_name": "懂车分",
"is_show": true,
"list": [
{
"series_id": 5273,
"series_name": "宝马X5",
"score": 422,
"rank": 1,
"total_review_count": 470,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 1,
"month_sell_count": 0,
"series_hot": 0,
"cover_uri": "http://p9-dcd.byteimg.com/tos-cn-i-dcdx/bf84f7f297c74b4ab69467bef101e7e1~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 51,
"agent_high_price": 68,
"official_low_price": 59.8,
"official_high_price": 74.8,
"outter_detail_type": "13",
"series_new_energy_type": 0
},
{
"series_id": 228,
"series_name": "奔驰GLE(进口)",
"score": 417,
"rank": 2,
"total_review_count": 318,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 1,
"month_sell_count": 0,
"series_hot": 0,
"cover_uri": "http://p9-dcd.byteimg.com/tos-cn-i-dcdx/14aeb45af80d419991c8f008e6a965b0~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 49.68,
"agent_high_price": 68.68,
"official_low_price": 69.98,
"official_high_price": 88.98,
"outter_detail_type": "13",
"series_new_energy_type": 0
},
{
"series_id": 1277,
"series_name": "沃尔沃XC90",
"score": 416,
"rank": 3,
"total_review_count": 466,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 1,
"month_sell_count": 0,
"series_hot": 0,
"cover_uri": "http://p9-dcd.byteimg.com/tos-cn-i-dcdx/500ee2f788184aa88852f68563a311c7~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 41.99,
"agent_high_price": 56.49,
"official_low_price": 63.89,
"official_high_price": 78.19,
"outter_detail_type": "13",
"series_new_energy_type": 0
},
{
"series_id": 87,
"series_name": "奥迪Q7",
"score": 414,
"rank": 4,
"total_review_count": 355,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 1,
"month_sell_count": 0,
"series_hot": 0,
"cover_uri": "http://p9-dcd.byteimg.com/tos-cn-i-dcdx/6c0cb174aa964c5a98b79fcde17fff3f~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 43.3,
"agent_high_price": 57.14,
"official_low_price": 60.98,
"official_high_price": 80.48,
"outter_detail_type": "13",
"series_new_energy_type": 0
},
{
"series_id": 986,
"series_name": "卫士",
"score": 414,
"rank": 5,
"total_review_count": 288,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 1,
"month_sell_count": 0,
"series_hot": 0,
"cover_uri": "http://p9-dcd.byteimg.com/tos-cn-i-dcdx/2510264eb3244129af9fcf550a782789~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 58.9,
"agent_high_price": 215.8,
"official_low_price": 68.8,
"official_high_price": 215.8,
"outter_detail_type": "13",
"series_new_energy_type": 0
}
]
},
"hot": {
"series_type": "SUV",
"curr_sell_month": 0,
"rank_type": 3,
"rank_name": "热门",
"is_show": true,
"list": [
{
"series_id": 5273,
"series_name": "宝马X5",
"score": 0,
"rank": 1,
"total_review_count": 0,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 3,
"month_sell_count": 0,
"series_hot": 1027711,
"cover_uri": "http://p3-dcd.byteimg.com/tos-cn-i-dcdx/bf84f7f297c74b4ab69467bef101e7e1~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 51,
"agent_high_price": 68,
"official_low_price": 59.8,
"official_high_price": 74.8,
"outter_detail_type": "13",
"series_new_energy_type": 0
},
{
"series_id": 228,
"series_name": "奔驰GLE(进口)",
"score": 0,
"rank": 2,
"total_review_count": 0,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 3,
"month_sell_count": 0,
"series_hot": 937479,
"cover_uri": "http://p3-dcd.byteimg.com/tos-cn-i-dcdx/14aeb45af80d419991c8f008e6a965b0~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 49.68,
"agent_high_price": 68.68,
"official_low_price": 69.98,
"official_high_price": 88.98,
"outter_detail_type": "13",
"series_new_energy_type": 0
},
{
"series_id": 87,
"series_name": "奥迪Q7",
"score": 0,
"rank": 3,
"total_review_count": 0,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 3,
"month_sell_count": 0,
"series_hot": 936071,
"cover_uri": "http://p3-dcd.byteimg.com/tos-cn-i-dcdx/6c0cb174aa964c5a98b79fcde17fff3f~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 43.3,
"agent_high_price": 57.14,
"official_low_price": 60.98,
"official_high_price": 80.48,
"outter_detail_type": "13",
"series_new_energy_type": 0
},
{
"series_id": 986,
"series_name": "卫士",
"score": 0,
"rank": 4,
"total_review_count": 0,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 3,
"month_sell_count": 0,
"series_hot": 910355,
"cover_uri": "http://p3-dcd.byteimg.com/tos-cn-i-dcdx/2510264eb3244129af9fcf550a782789~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 58.9,
"agent_high_price": 215.8,
"official_low_price": 68.8,
"official_high_price": 215.8,
"outter_detail_type": "13",
"series_new_energy_type": 0
},
{
"series_id": 1277,
"series_name": "沃尔沃XC90",
"score": 0,
"rank": 5,
"total_review_count": 0,
"single_highest_desc": "",
"single_highest_score": "",
"rank_type": 3,
"month_sell_count": 0,
"series_hot": 863492,
"cover_uri": "http://p3-dcd.byteimg.com/tos-cn-i-dcdx/500ee2f788184aa88852f68563a311c7~tplv-f042mdwyw7-original:640:0.png?psm=motor.pc_car.api",
"agent_low_price": 41.99,
"agent_high_price": 56.49,
"official_low_price": 63.89,
"official_high_price": 78.19,
"outter_detail_type": "13",
"series_new_energy_type": 0
}
]
}
},
"reviewData": {
"average_series_review": {
"score": 415,
"comfort_score": 407,
"appearance_score": 448,
"configuration_score": 396,
"control_score": 405,
"power_score": 408,
"space_score": 429,
"interiors_score": 410
},
"same_level_review": [
{
"score": 415,
"comfort_score": 407,
"appearance_score": 448,
"configuration_score": 396,
"control_score": 405,
"power_score": 408,
"space_score": 429,
"interiors_score": 410,
"series_id": "0",
"series_name": "同级车均值"
},
{
"score": 417,
"comfort_score": 393,
"appearance_score": 454,
"configuration_score": 380,
"control_score": 389,
"power_score": 391,
"space_score": 443,
"interiors_score": 468,
"series_name": "奔驰GLE(进口)",
"series_id": "228"
},
{
"score": 414,
"comfort_score": 410,
"appearance_score": 422,
"configuration_score": 410,
"control_score": 411,
"power_score": 410,
"space_score": 432,
"interiors_score": 402,
"series_name": "奥迪Q7",
"series_id": "87"
},
{
"score": 414,
"comfort_score": 406,
"appearance_score": 476,
"configuration_score": 402,
"control_score": 403,
"power_score": 417,
"space_score": 412,
"interiors_score": 381,
"series_name": "卫士",
"series_id": "986"
},
{
"score": 416,
"comfort_score": 420,
"appearance_score": 442,
"configuration_score": 395,
"control_score": 417,
"power_score": 417,
"space_score": 432,
"interiors_score": 391,
"series_name": "沃尔沃XC90",
"series_id": "1277"
}
],
"review": {
"score": 422,
"comfort_score": 410,
"appearance_score": 457,
"configuration_score": 401,
"control_score": 410,
"power_score": 408,
"space_score": 466,
"interiors_score": 402,
"series_name": "宝马X5",
"series_id": "5273"
}
},
"overviewData": {
"series_id": 5273,
"series_name": "",
"space": [
{
"length": "5060",
"width": "2004",
"height": "1776",
"wheelbase": "3105",
"car_id_list": [
255925,
255924,
256128,
253998,
253999
]
},
{
"length": "5060",
"width": "2004",
"height": "1776",
"wheelbase": "3105",
"car_id_list": [
255925,
255924,
256128,
253998,
253999
]
},
{
"length": "5060",
"width": "2004",
"height": "1776",
"wheelbase": "3105",
"car_id_list": [
255925,
255924,
256128,
253998,
253999
]
},
{
"length": "5060",
"width": "2004",
"height": "1768",
"wheelbase": "3105",
"car_id_list": [
254000,
255923
]
},
{
"length": "5060",
"width": "2004",
"height": "1776",
"wheelbase": "3105",
"car_id_list": [
255925,
255924,
256128,
253998,
253999
]
},
{
"length": "5060",
"width": "2004",
"height": "1776",
"wheelbase": "3105",
"car_id_list": [
255925,
255924,
256128,
253998,
253999
]
},
{
"length": "5060",
"width": "2004",
"height": "1768",
"wheelbase": "3105",
"car_id_list": [
254000,
255923
]
}
],
"power": {
"overview": "2.0T / 3.0T",
"power_item": [
{
"acceleration_time": "7.2s",
"gearbox_description": "8挡手自一体",
"engine_description": "2.0T 287马力",
"fuel_form": "48V轻混系统",
"engine_capacity": "2.0T",
"car_id_list": [
253998,
255925
],
"electric_max_horsepower": "",
"acceleration_time_list": [
"7.2",
"7.2"
],
"engine_capacity_4_sort": 2.00001
},
{
"acceleration_time": "5.5s",
"gearbox_description": "8挡手自一体",
"engine_description": "3.0T 423马力",
"fuel_form": "48V轻混系统",
"engine_capacity": "3.0T",
"car_id_list": [
255923,
253999,
254000,
255924,
256128
],
"electric_max_horsepower": "",
"acceleration_time_list": [
"5.5",
"5.5",
"5.5",
"5.5",
"5.5"
],
"engine_capacity_4_sort": 3.00001
}
]
},
"new_energy_power": null,
"fuel_comprehensive": null,
"new_energy_consumption": null,
"manipulation": [
{
"driver_form": "前置四驱",
"fourwheel_drive_type": "适时四驱",
"front_suspension_form": "双叉臂式独立悬挂",
"rear_suspension_form": "多连杆式独立悬挂",
"car_id_list": [
255923,
253998,
253999,
254000,
255925,
255924,
256128
]
}
],
"airbag": [
{
"main_airbag": true,
"vice_airbag": true,
"front_airbag": true,
"rear_airbag": false,
"side_air_curtain": true,
"main_knee_airbag": false,
"vice_knee_airbag": false,
"car_id_list": [
255925,
255924,
256128,
255923,
253998,
253999,
254000
]
}
],
"car_info": [
{
"car_Id": 255925,
"series_id": 5273,
"series_name": "宝马X5",
"car_name": "改款 xDrive30Li 尊享型M运动曜夜套装",
"car_year": "2026",
"dealer_price": "51.00万"
},
{
"car_Id": 255924,
"series_id": 5273,
"series_name": "宝马X5",
"car_name": "改款 xDrive40Li M运动曜夜套装",
"car_year": "2026",
"dealer_price": "61.50万"
},
{
"car_Id": 256128,
"series_id": 5273,
"series_name": "宝马X5",
"car_name": "xDrive40Li 马年版",
"car_year": "2026",
"dealer_price": "63.50万"
},
{
"car_Id": 255923,
"series_id": 5273,
"series_name": "宝马X5",
"car_name": "改款 xDrive40Li 尊享型M运动曜夜套装",
"car_year": "2026",
"dealer_price": "68.00万"
},
{
"car_Id": 253998,
"series_id": 5273,
"series_name": "宝马X5",
"car_name": "xDrive30Li 尊享型M运动曜夜套装",
"car_year": "2026",
"dealer_price": "51.00万"
},
{
"car_Id": 253999,
"series_id": 5273,
"series_name": "宝马X5",
"car_name": "xDrive40Li M运动曜夜套装",
"car_year": "2026",
"dealer_price": "61.50万"
},
{
"car_Id": 254000,
"series_id": 5273,
"series_name": "宝马X5",
"car_name": "xDrive40Li 尊享型M运动曜夜套装",
"car_year": "2026",
"dealer_price": "68.00万"
}
],
"fuel_form_map": {
"253998": "13",
"253999": "13",
"254000": "13",
"255923": "13",
"255924": "13",
"255925": "13",
"256128": "13"
}
},
"seriesId": "5273",
"seriesName": "宝马X5",
"carModelsData": {
"tab_list": [
{
"tab_key": "online_all",
"tab_text": "在售",
"data": [
{
"type": "1137",
"info": {
"name": "2026款-宝马X5",
"hot_car_tag": 0,
"new_car_tag": 0,
"owner_price_num": 0,
"app_new_version_style": false,
"picture_count": 0,
"config_enable": false,
"car_page_enable": false
}
},
{
"type": "1115",
"info": {
"id": 255925,
"name": "改款 xDrive30Li 尊享型M运动曜夜套装",
"car_name": "改款 xDrive30Li 尊享型M运动曜夜套装",
"car_id": 255925,
"brand_name": "宝马",
"brand_id": 4,
"series_name": "宝马X5",
"series_id": 5273,
"year": 2026,
"config_code": "尊享型M运动曜夜套装",
"config_title": "亮点配置:",
"large_pic_key": "tos-cn-i-dcdx/bf84f7f297c74b4ab69467bef101e7e1",
"hot_car_tag": 0,
"new_car_tag": 0,
"price": "59.80万",
"owner_price": "53.04万",
"owner_price_num": 292,
"official_price": 59.8,
"official_price_str": "59.80万",
"dealer_price": "51.00万",
"calculator_url": "sslocal://webview?url=https%3A%2F%2Fi.snssdk.com%2Fmotor%2Finapp%2Fcompute%2Findex.html%3Fcar_id%3D255925&hide_bar=1&bounce_disable=1",
"upgrade_text": "+8.0万即可升级为「M运动曜夜套装」",
"sort_weight": 10,
"cover_url": "motor-mis-img/52a4c1e59f34d0b03e53e09439f7864f",
"app_new_version_style": false,
"tags": [
"2.0T",
"四驱"
],
"follower_rate": {
"text": "33%的人关注",
"highlight_text": "33%"
},
"picture_count": 392,
"config_enable": true,
"car_page_enable": true
}
},
{
"type": "1068",
"info": {
"hot_car_tag": 0,
"new_car_tag": 0,
"owner_price_num": 0,
"app_new_version_style": false,
"bg_color": "#FFFFFF",
"height": 12,
"picture_count": 0,
"config_enable": false,
"car_page_enable": false
}
},
{
"type": "1115",
"info": {
"id": 255924,
"name": "改款 xDrive40Li M运动曜夜套装",
"car_name": "改款 xDrive40Li M运动曜夜套装",
"car_id": 255924,
"brand_name": "宝马",
"brand_id": 4,
"series_name": "宝马X5",
"series_id": 5273,
"year": 2026,
"config_code": "M运动曜夜套装",
"config_title": "增加配置(8.0万):",
"large_pic_key": "tos-cn-i-dcdx/bf84f7f297c74b4ab69467bef101e7e1",
"hot_car_tag": 0,
"new_car_tag": 0,
"price": "67.80万",
"owner_price": "60.84万",
"owner_price_num": 273,
"official_price": 67.8,
"official_price_str": "67.80万",
"dealer_price": "61.50万",
"calculator_url": "sslocal://webview?url=https%3A%2F%2Fi.snssdk.com%2Fmotor%2Finapp%2Fcompute%2Findex.html%3Fcar_id%3D255924&hide_bar=1&bounce_disable=1",
"upgrade_text": "+1.0万即可升级为「马年版」",
"sort_weight": 15,
"cover_url": "motor-mis-img/52a4c1e59f34d0b03e53e09439f7864f",
"app_new_version_style": false,
"tags": [
"3.0T",
"四驱"
],
"follower_rate": {
"text": "26%的人关注",
"highlight_text": "26%"
},
"picture_count": 319,
"config_enable": true,
"car_page_enable": true
}
},
{
"type": "1068",
"info": {
"hot_car_tag": 0,
"new_car_tag": 0,
"owner_price_num": 0,
"app_new_version_style": false,
"bg_color": "#FFFFFF",
"height": 12,
"picture_count": 0,
"config_enable": false,
"car_page_enable": false
}
},
{
"type": "1115",
"info": {
"id": 256128,
"name": "xDrive40Li 马年版",
"car_name": "xDrive40Li 马年版",
"car_id": 256128,
"brand_name": "宝马",
"brand_id": 4,
"series_name": "宝马X5",
"series_id": 5273,
"year": 2026,
"config_code": "马年版",
"config_title": "增加配置(1.0万):",
"large_pic_key": "tos-cn-i-dcdx/bf84f7f297c74b4ab69467bef101e7e1",
"hot_car_tag": 0,
"new_car_tag": 0,
"price": "68.80万",
"owner_price": "64.80万",
"owner_price_num": 5,
"official_price": 68.8,
"official_price_str": "68.80万",
"dealer_price": "63.50万",
"calculator_url": "sslocal://webview?url=https%3A%2F%2Fi.snssdk.com%2Fmotor%2Finapp%2Fcompute%2Findex.html%3Fcar_id%3D256128&hide_bar=1&bounce_disable=1",
"upgrade_text": "+6.0万即可升级为「尊享型M运动曜夜套装」",
"sort_weight": 20,
"cover_url": "motor-mis-img/52a4c1e59f34d0b03e53e09439f7864f",
"app_new_version_style": false,
"tags": [
"3.0T",
"四驱"
],
"follower_rate": {
"text": "8%的人关注",
"highlight_text": "8%"
},
"picture_count": 353,
"config_enable": true,
"car_page_enable": true
}
}
]
},
{
"tab_key": "online_2026",
"tab_text": "2026款",
"data": []
},
{
"tab_key": "offline",
"tab_text": "停售",
"data": []
}
]
}
}
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
/**
* Unit tests for the 懂车帝 (Dongchedi) adapter.
*
* Every command parses `__NEXT_DATA__` JSON from an SSR page, so the pure
* parsers are exercised against frozen real-data fixtures captured from
* dongchedi.com (series 5273 = 宝马X5). No network, no browser.
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
import {
SEARCH_COLUMNS,
SERIES_COLUMNS,
MODELS_COLUMNS,
SPECS_COLUMNS,
SCORE_COLUMNS,
KOUBEI_COLUMNS,
extractPageProps,
isFallbackShell,
parseScore,
normalizeSeriesId,
requireLimit,
clean,
snippet,
} from './utils.js';
import { parseSearchRows } from './search.js';
import { parseSeries } from './series.js';
import { parseModels } from './models.js';
import { parseSpecs } from './specs.js';
import { parseScoreBreakdown } from './score.js';
import { parseKoubei } from './koubei.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fx = (name) => JSON.parse(readFileSync(join(__dirname, '__fixtures__', name), 'utf8'));
const SEARCH = fx('search.json');
const DETAIL = fx('series-detail.json');
const SCORE = fx('series-score.json');
describe('dongchedi adapter — registration', () => {
const names = ['search', 'series', 'models', 'specs', 'score', 'koubei'];
it('registers all commands as PUBLIC (no browser)', () => {
for (const n of names) {
const cmd = getRegistry().get(`dongchedi/${n}`);
expect(cmd, n).toBeTruthy();
expect(cmd.strategy, n).toBe(Strategy.PUBLIC);
expect(cmd.browser, n).toBe(false);
expect(cmd.access, n).toBe('read');
}
});
it('declares the expected columns', () => {
expect(getRegistry().get('dongchedi/search').columns).toEqual(SEARCH_COLUMNS);
expect(getRegistry().get('dongchedi/series').columns).toEqual(SERIES_COLUMNS);
expect(getRegistry().get('dongchedi/models').columns).toEqual(MODELS_COLUMNS);
expect(getRegistry().get('dongchedi/specs').columns).toEqual(SPECS_COLUMNS);
expect(getRegistry().get('dongchedi/score').columns).toEqual(SCORE_COLUMNS);
expect(getRegistry().get('dongchedi/koubei').columns).toEqual(KOUBEI_COLUMNS);
});
});
describe('dongchedi adapter — utils', () => {
it('extractPageProps pulls pageProps from a __NEXT_DATA__ blob', () => {
const html = `<html><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({ props: { pageProps: { hello: 'world' } } })}</script></body></html>`;
expect(extractPageProps(html)).toEqual({ hello: 'world' });
});
it('extractPageProps returns null on missing/invalid blob', () => {
expect(extractPageProps('<html>no data</html>')).toBeNull();
expect(extractPageProps('<script id="__NEXT_DATA__">{not json}</script>')).toBeNull();
});
it('isFallbackShell flags the empty city-gateway shell', () => {
expect(isFallbackShell({ __hasUrlCity: true, is_gray: false, clientIp: '1.2.3.4' })).toBe(true);
expect(isFallbackShell({ seriesHomeHead: {} })).toBe(false);
expect(isFallbackShell(null)).toBe(true);
});
it('parseScore rescales x100 ints to /5 floats', () => {
expect(parseScore(422)).toBe(4.22);
expect(parseScore(500)).toBe(5);
expect(parseScore(0)).toBeNull();
expect(parseScore(undefined)).toBeNull();
});
it('normalizeSeriesId accepts numbers and URLs, rejects junk', () => {
expect(normalizeSeriesId('5273')).toBe('5273');
expect(normalizeSeriesId('https://www.dongchedi.com/auto/series/5273')).toBe('5273');
expect(() => normalizeSeriesId('宝马X5')).toThrow();
expect(() => normalizeSeriesId('')).toThrow();
});
it('requireLimit enforces [1,max]', () => {
expect(requireLimit(undefined, 10, 30)).toBe(10);
expect(requireLimit(5, 10, 30)).toBe(5);
expect(() => requireLimit(0, 10, 30)).toThrow();
expect(() => requireLimit(31, 10, 30)).toThrow();
});
it('clean/snippet normalize whitespace and truncate', () => {
expect(clean(' a\n b ')).toBe('a b');
expect(snippet('x'.repeat(200), 10)).toBe(`${'x'.repeat(10)}`);
expect(snippet('short', 10)).toBe('short');
});
});
describe('dongchedi adapter — parsers against frozen fixtures', () => {
it('parseSearchRows keeps only car series and respects limit', () => {
const rows = parseSearchRows(SEARCH, 3);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(3);
const r = rows[0];
expect(Object.keys(r).sort()).toEqual([...SEARCH_COLUMNS].sort());
expect(r.series_id).toBeTruthy();
expect(r.name).toBeTruthy();
expect(r.url).toContain('/auto/series/');
// every row must be a real series (no zero ids leaked in)
expect(rows.every((x) => x.series_id && x.series_id !== '0')).toBe(true);
});
it('parseSeries builds a complete field/value sheet', () => {
const rows = parseSeries(DETAIL, '5273');
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(rows.every((r) => Object.keys(r).sort().join() === 'field,value')).toBe(true);
expect(map.name).toBe('宝马X5');
expect(map.brand).toBe('宝马');
expect(map.official_price).toMatch(/万/);
expect(typeof map.score).toBe('number');
expect(map.score).toBeGreaterThan(0);
expect(map.score).toBeLessThanOrEqual(5);
expect(map.url).toContain('/auto/series/5273');
});
it('parseModels returns on-sale trims with real car_ids', () => {
const rows = parseModels(DETAIL.carModelsData, 'online');
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) {
expect(Object.keys(r).sort()).toEqual([...MODELS_COLUMNS].sort());
expect(r.car_id).toMatch(/^\d+$/);
expect(r.name).toBeTruthy();
}
});
it('parseSpecs surfaces dimensions and powertrain', () => {
const rows = parseSpecs(DETAIL.overviewData, '5273');
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(rows.every((r) => Object.keys(r).sort().join() === 'field,value')).toBe(true);
expect(map.dimensions).toMatch(/\d+ × \d+ × \d+ mm/);
expect(map.wheelbase).toMatch(/mm/);
expect(map.power).toBeTruthy();
});
it('parseScoreBreakdown maps the 8 axes with same-level averages', () => {
const sameLevel = (DETAIL.reviewData.same_level_review || []).find((r) => String(r.series_id) === '0')
|| DETAIL.reviewData.average_series_review;
const rows = parseScoreBreakdown(DETAIL.scoreSimpleInfo, sameLevel);
expect(rows.length).toBe(8);
const overall = rows.find((r) => r.dimension === '综合');
expect(overall.score).toBeGreaterThan(0);
expect(overall.same_level_avg).toBeGreaterThan(0);
for (const r of rows) expect(Object.keys(r).sort()).toEqual([...SCORE_COLUMNS].sort());
});
it('parseKoubei returns owner reviews with body + url', () => {
const rows = parseKoubei(SCORE.reviewListData, 3);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(3);
const r = rows[0];
expect(Object.keys(r).sort()).toEqual([...KOUBEI_COLUMNS].sort());
expect(r.user).toBeTruthy();
expect(r.content).toBeTruthy();
expect(typeof r.likes).toBe('number');
expect(r.url).toContain('/ugc/article/');
});
it('parsers fail closed on malformed source payloads', () => {
expect(() => parseSearchRows({}, 5)).toThrow(/unexpected payload shape/);
expect(() => parseSearchRows(null, 5)).toThrow(/unexpected payload shape/);
expect(() => parseModels({}, 'online')).toThrow(/unexpected payload shape/);
expect(() => parseKoubei({}, 5)).toThrow(/unexpected payload shape/);
expect(() => parseSpecs(null, '1')).toThrow(/unexpected payload shape/);
expect(() => parseSeries({}, '1')).toThrow(/unexpected payload shape/);
});
it('row parsers reject malformed identities and required text', () => {
expect(() => parseSearchRows({ data: [{ cell_type: 26, series_id: 0, display: { series_name: '坏行' } }] }, 5))
.toThrow(/stable numeric id/);
expect(() => parseSearchRows({ data: [{ cell_type: 26, series_id: 1, display: {} }] }, 5))
.toThrow(/stable text value/);
expect(() => parseModels({ tab_list: [{ tab_key: 'online_all', data: [{ info: { car_id: 1 } }] }] }, 'online'))
.toThrow(/stable text value/);
expect(() => parseKoubei({ review_list: [{ user_info: { name: 'u' }, content: 'body' }] }, 5))
.toThrow(/stable numeric id/);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* dongchedi koubei — owner reviews (口碑) for a car series.
*
* Reads `pageProps.reviewListData.review_list` from the SSR score page
* `https://www.dongchedi.com/auto/series/score/<id>-x-x-x-x-x`. Each entry
* is a real owner write-up: rating, the trim/year they bought, likes,
* comment count, and the full review body (snippetted for the table; the
* `url` column links to the complete article).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import {
DCD_BASE,
KOUBEI_COLUMNS,
clean,
dcdFetchPageProps,
normalizeSeriesId,
parseScore,
requireArray,
requireStableId,
requireText,
requireLimit,
snippet,
} from './utils.js';
/**
* Pure parser: reviewListData → review rows. Exported for unit testing.
*/
export function parseKoubei(reviewListData, limit) {
const list = reviewListData?.review_list;
requireArray(list, 'dongchedi reviewListData.review_list');
const rows = [];
for (const [index, it] of list.entries()) {
const buy = it?.buy_car_info || {};
const carName = clean(buy.car_name || it?.car_name);
const year = buy.year || it?.year;
const car = [year ? String(year) : '', carName].filter(Boolean).join(' ');
const gid = requireStableId(it?.gid_str || it?.gid, `dongchedi koubei row ${index + 1}`);
rows.push({
rank: rows.length + 1,
user: requireText(it?.user_info?.name, `dongchedi koubei row ${index + 1} user`),
car,
score: parseScore(it?.score_info?.score),
likes: Number.isFinite(Number(it?.digg_count_en)) ? Number(it.digg_count_en) : 0,
comments: Number.isFinite(Number(it?.comment_count_en)) ? Number(it.comment_count_en) : 0,
content: snippet(requireText(it?.content, `dongchedi koubei row ${index + 1} content`), 180),
url: `${DCD_BASE}/ugc/article/${gid}`,
});
if (rows.length >= limit) break;
}
return rows;
}
cli({
site: 'dongchedi',
name: 'koubei',
access: 'read',
aliases: ['reviews'],
description: '懂车帝车系口碑/车主评价(评分 / 购车款型 / 点赞 / 评论 / 正文摘要)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'series_id', required: true, positional: true, help: '车系 ID来自 search 的 series_id或 /auto/series/<id> URL' },
{ name: 'limit', type: 'int', default: 10, help: '返回的口碑条数(最多 15单页 SSR 上限)' },
],
columns: KOUBEI_COLUMNS,
func: async (args) => {
const seriesId = normalizeSeriesId(args.series_id);
const limit = requireLimit(args.limit, 10, 15);
// The score page carries the SSR-rendered owner-review list.
const pp = await dcdFetchPageProps(
`/auto/series/score/${seriesId}-x-x-x-x-x`,
`koubei ${seriesId}`,
);
const rows = parseKoubei(pp.reviewListData, limit);
if (rows.length === 0) {
throw new EmptyResultError(
`dongchedi koubei ${seriesId}`,
'This series has no owner reviews yet.',
);
}
return rows;
},
});
+91
View File
@@ -0,0 +1,91 @@
/**
* dongchedi models — the trims (款型) of a car series with prices.
*
* Reads `pageProps.carModelsData.tab_list` from the SSR series page. Each
* tab ("在售" / a model-year / "停售") holds trim rows; rows carry a real
* `info.car_id`. Year-group header rows (no car_id) are skipped.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import {
MODELS_COLUMNS,
clean,
dcdFetchPageProps,
normalizeSeriesId,
requireArray,
requireStableId,
requireText,
} from './utils.js';
/** Normalize a price field that may be a number (59.8) or a string ("51.00万"). */
function priceStr(v) {
if (v == null || v === '') return '';
if (typeof v === 'number') return Number.isFinite(v) && v > 0 ? `${v}` : '';
return clean(v);
}
/**
* Pure parser: carModelsData + status → trim rows. Exported for unit tests.
* status: 'online' (在售, default) or 'offline' (停售).
*/
export function parseModels(carModelsData, status) {
const tabs = carModelsData?.tab_list;
requireArray(tabs, 'dongchedi carModelsData.tab_list');
if (tabs.length === 0) return [];
const wantKey = status === 'offline' ? 'offline' : 'online_all';
const tab = tabs.find((t) => t?.tab_key === wantKey)
|| (status === 'offline' ? tabs.find((t) => /停售/.test(t?.tab_text || '')) : tabs[0]);
if (!tab) return [];
const data = tab?.data;
requireArray(data, `dongchedi models ${status} data`);
const rows = [];
for (const [index, d] of data.entries()) {
const info = d?.info || {};
const carId = info.car_id ?? info.id;
if (!carId) continue; // skip model-year header rows
rows.push({
car_id: requireStableId(carId, `dongchedi models row ${index + 1}`),
name: requireText(info.name || info.car_name, `dongchedi models row ${index + 1} name`),
year: info.year != null ? String(info.year) : '',
official_price: priceStr(info.official_price),
dealer_price: priceStr(info.dealer_price),
owner_price: priceStr(info.owner_price),
});
}
return rows;
}
cli({
site: 'dongchedi',
name: 'models',
access: 'read',
aliases: ['trims'],
description: '懂车帝车系款型列表car_id / 名称 / 年款 / 指导价 / 经销商价 / 车主成交价)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'series_id', required: true, positional: true, help: '车系 ID来自 search 的 series_id或 /auto/series/<id> URL' },
{ name: 'status', default: 'online', help: '在售 online默认或停售 offline' },
],
columns: MODELS_COLUMNS,
func: async (args) => {
const seriesId = normalizeSeriesId(args.series_id);
const status = String(args.status || 'online').trim().toLowerCase();
if (status !== 'online' && status !== 'offline') {
throw new ArgumentError('status', "must be 'online' (在售) or 'offline' (停售)");
}
const pp = await dcdFetchPageProps(`/auto/series/${seriesId}`, `models ${seriesId}`);
const rows = parseModels(pp.carModelsData, status);
if (rows.length === 0) {
const message = status === 'offline' ? 'No discontinued trims listed for this series.' : 'No on-sale trims listed for this series.';
const ErrorCtor = status === 'offline' ? EmptyResultError : CommandExecutionError;
throw new ErrorCtor(
`dongchedi models ${seriesId} (${status})`,
message,
);
}
return rows;
},
});
+83
View File
@@ -0,0 +1,83 @@
/**
* dongchedi score — 懂车分 rating breakdown for a car series.
*
* Reads `pageProps.scoreSimpleInfo` (the series' 8-axis owner rating) and
* `pageProps.reviewData.same_level_review` (the same-class average) from the
* SSR series page, so each axis can be shown next to its segment benchmark.
* Scores are rescaled from x100 ints to /5 floats (422 -> 4.22).
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import {
SCORE_COLUMNS,
dcdFetchPageProps,
normalizeSeriesId,
parseScore,
requireArray,
} from './utils.js';
/** Dongchedi's 8 rating axes, in display order: [field, 中文 label]. */
const AXES = [
['score', '综合'],
['space_score', '空间'],
['power_score', '动力'],
['control_score', '操控'],
['comfort_score', '舒适性'],
['appearance_score', '外观'],
['interiors_score', '内饰'],
['configuration_score', '配置'],
];
/**
* Pure parser: scoreSimpleInfo + same-level average → rows. Exported for tests.
*/
export function parseScoreBreakdown(scoreSimpleInfo, sameLevelAvg) {
if (!scoreSimpleInfo || typeof scoreSimpleInfo !== 'object' || Array.isArray(scoreSimpleInfo)) {
throw new EmptyResultError(
'dongchedi score',
'This series has no 懂车分 rating yet (too few owner reviews).',
);
}
const ssi = scoreSimpleInfo || {};
const avg = sameLevelAvg || {};
return AXES.map(([key, label]) => ({
dimension: label,
score: parseScore(ssi[key]),
same_level_avg: parseScore(avg[key]),
}));
}
/** Pick the "同级车均值" row out of reviewData.same_level_review. */
function sameLevelAverage(reviewData) {
const list = reviewData?.same_level_review;
if (list == null) return reviewData?.average_series_review || {};
requireArray(list, 'dongchedi same_level_review');
return list.find((r) => String(r?.series_id) === '0') || list[0] || {};
}
cli({
site: 'dongchedi',
name: 'score',
access: 'read',
aliases: ['rating'],
description: '懂车帝车系评分(懂车分 8 维度 + 同级车均值对比)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'series_id', required: true, positional: true, help: '车系 ID来自 search 的 series_id或 /auto/series/<id> URL' },
],
columns: SCORE_COLUMNS,
func: async (args) => {
const seriesId = normalizeSeriesId(args.series_id);
const pp = await dcdFetchPageProps(`/auto/series/${seriesId}`, `score ${seriesId}`);
const rows = parseScoreBreakdown(pp.scoreSimpleInfo, sameLevelAverage(pp.reviewData));
if (rows.every((r) => r.score == null)) {
throw new EmptyResultError(
`dongchedi score ${seriesId}`,
'This series has no 懂车分 rating yet (too few owner reviews).',
);
}
return rows;
},
});
+83
View File
@@ -0,0 +1,83 @@
/**
* dongchedi search — find car series (车系) by keyword.
*
* Hits the SSR search page `https://www.dongchedi.com/search?keyword=...`
* and reads `pageProps.searchData.data`. That list mixes cards (series,
* videos, news, dealers); we keep only car-series cards (cell_type 26 with
* a real series_id) and surface name / brand / official + dealer price.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import {
DCD_BASE,
SEARCH_COLUMNS,
clean,
requireArray,
requireStableId,
requireText,
dcdFetchPageProps,
requireLimit,
} from './utils.js';
/** cell_type for a car-series result card. */
const SERIES_CELL_TYPE = 26;
/**
* Pure parser: searchData → series rows. Exported for unit testing against
* the frozen fixture so shape drift is caught without a live fetch.
*/
export function parseSearchRows(searchData, limit) {
const data = requireArray(searchData?.data, 'dongchedi searchData.data');
const rows = [];
for (const [index, item] of data.entries()) {
if (item?.cell_type !== SERIES_CELL_TYPE) continue;
const seriesId = requireStableId(item?.series_id, `dongchedi search row ${index + 1}`);
const d = item.display || {};
const name = requireText(d.series_name || d.title, `dongchedi search row ${index + 1} name`);
rows.push({
rank: rows.length + 1,
series_id: seriesId,
name,
brand: clean(d.sub_brand_name),
official_price: clean(d.official_price),
dealer_price: clean(d.agent_price),
pictures: Number.isFinite(Number(d.picture_num)) ? Number(d.picture_num) : null,
url: `${DCD_BASE}/auto/series/${seriesId}`,
});
if (rows.length >= limit) break;
}
return rows;
}
cli({
site: 'dongchedi',
name: 'search',
access: 'read',
description: '懂车帝车系搜索(按关键词,返回车系 + 指导价/经销商价)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'keyword', required: true, positional: true, help: '搜索关键词,例如 "宝马X5" 或 "汉兰达"' },
{ name: 'limit', type: 'int', default: 15, help: '返回的车系数量(最多 30' },
],
columns: SEARCH_COLUMNS,
func: async (args) => {
const keyword = String(args.keyword || '').trim();
if (!keyword) throw new ArgumentError('keyword', 'must be a non-empty string');
const limit = requireLimit(args.limit, 15, 30);
const pp = await dcdFetchPageProps(
`/search?keyword=${encodeURIComponent(keyword)}`,
`search "${keyword}"`,
);
const rows = parseSearchRows(pp.searchData, limit);
if (rows.length === 0) {
throw new EmptyResultError(
`dongchedi search "${keyword}"`,
'No car series matched. Try the model name, e.g. "宝马X5" or "汉兰达".',
);
}
return rows;
},
});
+87
View File
@@ -0,0 +1,87 @@
/**
* dongchedi series — one car series at a glance.
*
* Reads the SSR series page `https://www.dongchedi.com/auto/series/<id>`
* (`pageProps.seriesHomeHead` + `scoreSimpleInfo` + `rankData` +
* `carModelsData`) into a key/value sheet: brand, official/dealer/used
* price ranges, 懂车分 score + review count, sales rank, and trim count.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
DCD_BASE,
SERIES_COLUMNS,
assertPlainObject,
clean,
dcdFetchPageProps,
normalizeSeriesId,
parseScore,
requireText,
} from './utils.js';
/** Count on-sale trims (info.car_id present) in the carModelsData tabs. */
function countOnSaleModels(carModelsData) {
const tabs = carModelsData?.tab_list;
if (!Array.isArray(tabs)) return null;
const tab = tabs.find((t) => t?.tab_key === 'online_all') || tabs[0];
const data = tab?.data;
if (!Array.isArray(data)) return null;
return data.filter((d) => (d?.info?.car_id ?? d?.info?.id)).length;
}
/** Format a rank entry "懂车分榜 第1名" from a rankData section. */
function formatRank(section) {
const top = section?.list?.[0];
if (!top || !top.rank) return '';
const name = clean(section.rank_name || top.rank_name);
return name ? `${name}${top.rank}` : `${top.rank}`;
}
/**
* Pure parser: series pageProps → field/value rows. Exported for unit tests.
*/
export function parseSeries(pp, seriesId) {
const head = assertPlainObject(pp?.seriesHomeHead, 'dongchedi seriesHomeHead');
const score = pp.scoreSimpleInfo || {};
const rank = pp.rankData || {};
const usedPrice = (head.sh_low_Price || head.sh_high_price)
? `${head.sh_low_Price ?? '?'}-${head.sh_high_price ?? '?'}`
: '';
const fields = [
['series_id', seriesId],
['name', requireText(head.series_name, 'dongchedi series name')],
['brand', requireText(head.brand_name, 'dongchedi series brand')],
['sub_brand', clean(head.sub_brand_name)],
['official_price', head.has_official_price ? clean(head.official_price) : ''],
['dealer_price', head.has_dealer_price ? clean(head.dealer_price) : ''],
['used_price', usedPrice],
['score', parseScore(score.score)],
['review_count', Number.isFinite(Number(score.total_review_count)) ? Number(score.total_review_count) : null],
['sale_rank', formatRank(rank.sale)],
['score_rank', formatRank(rank.score)],
['models', countOnSaleModels(pp.carModelsData)],
['url', `${DCD_BASE}/auto/series/${seriesId}`],
];
return fields.map(([field, value]) => ({ field, value }));
}
cli({
site: 'dongchedi',
name: 'series',
access: 'read',
aliases: ['detail'],
description: '懂车帝车系概览(品牌 / 指导价 / 二手价 / 懂车分 / 销量排名 / 在售款型数)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'series_id', required: true, positional: true, help: '车系 ID来自 search 的 series_id或 /auto/series/<id> URL' },
],
columns: SERIES_COLUMNS,
func: async (args) => {
const seriesId = normalizeSeriesId(args.series_id);
const pp = await dcdFetchPageProps(`/auto/series/${seriesId}`, `series ${seriesId}`);
return parseSeries(pp, seriesId);
},
});
+115
View File
@@ -0,0 +1,115 @@
/**
* dongchedi specs — key configuration overview (配置概览) of a car series.
*
* Reads `pageProps.overviewData` from the SSR series page: body dimensions,
* powertrain (engine / gearbox / 0-100), drivetrain + suspension, and
* airbags. This is the unsigned SSR overview — the full per-trim parameter
* sheet sits behind a ByteDance-signed XHR and is deliberately not faked.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import {
SPECS_COLUMNS,
assertPlainObject,
clean,
dcdFetchPageProps,
normalizeSeriesId,
} from './utils.js';
/** Distinct, cleaned, non-empty values for a key across an array of objects. */
function uniqueVals(arr, key) {
const out = [];
for (const o of (Array.isArray(arr) ? arr : [])) {
const v = clean(o?.[key]);
if (v && !out.includes(v)) out.push(v);
}
return out;
}
/** Build a min-max range string from a numeric field across rows ("6.4-7.2s"). */
function rangeOf(arr, key, suffix = '') {
const nums = (Array.isArray(arr) ? arr : [])
.map((o) => parseFloat(String(o?.[key]).replace(/[^\d.]/g, '')))
.filter((n) => Number.isFinite(n));
if (nums.length === 0) return '';
const lo = Math.min(...nums); const hi = Math.max(...nums);
return lo === hi ? `${lo}${suffix}` : `${lo}-${hi}${suffix}`;
}
/** Summarize airbag coverage into a readable list. */
function summarizeAirbags(airbagArr) {
const a = (Array.isArray(airbagArr) ? airbagArr : []);
if (a.length === 0) return '';
const any = (key) => a.some((x) => x?.[key]);
const labels = [
['main_airbag', '主'], ['vice_airbag', '副'], ['front_airbag', '前'],
['rear_airbag', '后'], ['side_air_curtain', '侧气帘'],
['main_knee_airbag', '主膝部'], ['vice_knee_airbag', '副膝部'],
];
const present = labels.filter(([k]) => any(k)).map(([, label]) => label);
return present.length ? present.join('/') + '气囊' : '';
}
/**
* Pure parser: overviewData → field/value rows. Exported for unit tests.
*/
export function parseSpecs(overviewData, seriesId) {
const ov = assertPlainObject(overviewData, 'dongchedi overviewData');
const space0 = (Array.isArray(ov.space) && ov.space[0]) || {};
const power = ov.power || {};
const powerItems = power.power_item || [];
const manip0 = (Array.isArray(ov.manipulation) && ov.manipulation[0]) || {};
const dims = (space0.length && space0.width && space0.height)
? `${space0.length} × ${space0.width} × ${space0.height} mm`
: '';
const drivetrain = [clean(manip0.driver_form), clean(manip0.fourwheel_drive_type)]
.filter(Boolean).join(' · ');
const suspension = (manip0.front_suspension_form || manip0.rear_suspension_form)
? `${clean(manip0.front_suspension_form) || '?'} / 后 ${clean(manip0.rear_suspension_form) || '?'}`
: '';
const fields = [
['series_id', String(seriesId)],
['dimensions', dims],
['wheelbase', space0.wheelbase ? `${space0.wheelbase} mm` : ''],
['power', clean(power.overview)],
['engine', uniqueVals(powerItems, 'engine_description').join(' / ')],
['gearbox', uniqueVals(powerItems, 'gearbox_description').join(' / ')],
['energy', uniqueVals(powerItems, 'fuel_form').join(' / ')],
['acceleration', rangeOf(powerItems, 'acceleration_time', 's')],
['drivetrain', drivetrain],
['suspension', suspension],
['airbags', summarizeAirbags(ov.airbag)],
];
return fields.map(([field, value]) => ({ field, value }));
}
cli({
site: 'dongchedi',
name: 'specs',
access: 'read',
aliases: ['config'],
description: '懂车帝车系配置概览(尺寸 / 动力 / 发动机 / 变速箱 / 四驱 / 悬挂 / 气囊)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'series_id', required: true, positional: true, help: '车系 ID来自 search 的 series_id或 /auto/series/<id> URL' },
],
columns: SPECS_COLUMNS,
func: async (args) => {
const seriesId = normalizeSeriesId(args.series_id);
const pp = await dcdFetchPageProps(`/auto/series/${seriesId}`, `specs ${seriesId}`);
const rows = parseSpecs(pp.overviewData, seriesId);
// Every series has at least dimensions/power; an all-empty sheet means
// the overview block was absent (layout drift) — don't emit a blank sheet.
if (rows.every((r) => r.field === 'series_id' || !r.value)) {
throw new CommandExecutionError(
`dongchedi specs ${seriesId}`,
'No spec overview found for this series (layout may have changed).',
);
}
return rows;
},
});
+180
View File
@@ -0,0 +1,180 @@
/**
* 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;
}
+7
View File
@@ -0,0 +1,7 @@
<!doctype html><html><head>
<meta property="og:title" content="【准新车】二手奔腾T90 2025款 龙耀版 1.5T 舒享型报价,真实车源图片/视频 - 瓜子二手车"/>
</head><body><script>
ess=image/quality,q_88/resize,m_fill,w_750,h_750\"}],\"type\":2}],\"videoList\":[],\"videoAbResult\":true,\"videoFeedsSwitch\":true,\"imageCropRatio1x1\":false},\"carRecordInfo\":{\"simpleSummary\":[{\"label\":\"首次上牌\",\"value\":\"2024-12\",\"fullRow\":false},{\"label\":\"表显里程\",\"value\":\"0.17万公里\",\"fullRow\":false},{\"label\":\"过户次数\",\"value\":\"0次\",\"fullRow\":false,\"bottomBall\":{\"title\":\"关于过户次数\",\"desc\":\"部分商家为周转车辆会临时过户至他人名下,建议通过视频看车功能直接与商家确认最新状态。\"}},{\"label\":\"车源地\",\"value\":\"苏州\",\"fullRow\":false}],\"carParamsSummary\":[{\"label\":\"发动机\",\"value\":\"1.5T\",\"fullRow\":false},{\"label\":\"变速箱\",\"value\":\"自动\",\"fullRow\":false},{\"key\":\"emission\",\"label\":\"排放标准\",\"value\":\"国六b\",\"fullRow\":false},{\"label\":\"驱动方式\",\"value\":\"前置前驱\",\"fullRow\":false}],\"archiveSummary\":[{\"key\":\"emission\",\"label\":\"排放标准\",\"value\":\"国六b\",\"fullRow\":false},{\"label\":\"车源地\",\"value\":\"苏州\",\"fullRow\":false},{\"label\":\"车身颜色\",\"value\":\"白色\",\"fullRow\":false},{\"label\":\"车源编号\",\"value\":\"162563585\",\"fullRow\":false}],\"highlightConfigItem\":[{\"id\":5,\"title\":\"方向盘加热\",\"image\":\"https://image1.guazistatic.com/qn2105181024035ead3ad6da599924f0d05a54e3f30885.png\",\"desc\":\"由方向盘内的加热系统实现,由方向盘或中控台上的按钮开启或关闭。属于较高的舒适型配置。\"},{\"id\":11,\"title\":\"自适应巡航\",\"image\":\"https://image1.guazistatic.com/qn2105181212480a405f49434fc91994a49bed6888e181.png\",\"desc\":\"Adaptive Cruise Control简称ACC可依所设定速度行驶还可保持预设跟车距离随着车距变化自动加速与减速。\"},{\"id\":13,\"title\":\"自适应远近光\",\"image\":\"https://image1.guazistatic.com/qn210518121604812071c5c5251de8b33dcf01a5319478.png\",\"desc\":\"开启大灯时,行驶中传感器判断对向或同向车辆情况,自动切换远近光灯,有效保护其他车辆行车安全。\"},{\"id\":14,\"title\":\"并线辅
s://m.guazi.com/car-detail/c162563585115789.html","price":73800,"priceCurrency":"CNY","priceValidUnt
1年6个月车况S基础车况极品/理赔0次/过户0次瓜子二手车每车必检超200项官方检测认证
</script></body></html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html><body><div class="list">
<a class="text-gz-black-303740" href="/car-detail/c168029452296957.html"><section class="mb-20 flex justify-between gap-12"><div class="overflow-hidden rounded-md w-114 h-85"><div class="rounded-md object-cover w-114 h-85" style="position:relative;overflow:hidden"><img alt="创维汽车 创维HT-i 2024款 1.5L PHEV 115KM 泰道版" loading="lazy" width="114" height="85" decoding="async" data-nimg="1" class="object-cover" style="color:transparent;position:absolute;width:100%;height:100%;top:0;left:0;opacity:0;transition:opacity 0.3s ease" src="https://image-public.guazistatic.com/qnbdp7206xf2022ab5473b42ad9bced3eb032b23b71780880135.jpg?x-bce-process=image/quality,q_88/resize,m_fill,w_280,h_210"/></div></div><div class="flex-1"><h4 class="line-height-1.5 line-clamp-2"><img alt="icon" loading="lazy" width="41" height="14" decoding="async" data-nimg="1" class="mr-5 inline-block h-14 w-41 -translate-y-1.5" style="color:transparent" src="https://image-public.guazistatic.com/qnbdp1066x2bcdce4af34c493db02eddd6babff42e1754566654.png"/>创维汽车 创维HT-i 2024款 1.5L PHEV 115KM 泰道版</h4><div class="flex h-20 flex-wrap gap-y-10 overflow-hidden whitespace-nowrap w-274 py-4"><span class="text-10 mr-3 flex h-14 items-center justify-center rounded-xs border border-solid px-3" data-text="已检测" style="color:#009B3F;border-color:#99D7B2">已检测</span><span class="text-10 mr-3 flex h-14 items-center justify-center rounded-xs border border-solid px-3" data-text="插电混动" style="color:#009B3F;border-color:#99D7B2">插电混动</span></div><p class="text-11 text-gz-gray-999 leading-11"><span>2025年</span><span></span><span>0.64万公里</span><span></span><span>北京</span></p><div class="flex justify-start pt-4 leading-12"><div class="flex items-end justify-start"><span class="text-gz-red-f22a18 font-din text-18 leading-12 font-bold">6.85</span><span class="text-gz-red-f22a18 text-12 mr-2"></span></div><div class="flex flex-1 items-end justify-between"><span class="text-gz-glod-997147 text-12 ml-4">首付<!-- -->0.69万</span></div></div></div></section></a>
<a class="text-gz-black-303740" href="/car-detail/c168569647231900.html"><section class="mb-20 flex justify-between gap-12"><div class="overflow-hidden rounded-md w-114 h-85"><div class="rounded-md object-cover w-114 h-85" style="position:relative;overflow:hidden"><img alt="雪佛兰 科帕奇 2014款 2.4L 四驱旗舰版 7座" loading="lazy" width="114" height="85" decoding="async" data-nimg="1" class="object-cover" style="color:transparent;position:absolute;width:100%;height:100%;top:0;left:0;opacity:0;transition:opacity 0.3s ease" src="https://image-public.guazistatic.com/qnbdp7206x97a811e2903f49e09bb878a57fdcbd0b1781747835.jpg?x-bce-process=image/quality,q_88/resize,m_fill,w_280,h_210"/></div></div><div class="flex-1"><h4 class="line-height-1.5 line-clamp-2"><img alt="icon" loading="lazy" width="41" height="14" decoding="async" data-nimg="1" class="mr-5 inline-block h-14 w-41 -translate-y-1.5" style="color:transparent" src="https://image-public.guazistatic.com/qnbdp1066x1ee05047385e452c90ad8ae7a264a0b51754566654.png"/>雪佛兰 科帕奇 2014款 2.4L 四驱旗舰版 7座</h4><div class="flex h-20 flex-wrap gap-y-10 overflow-hidden whitespace-nowrap w-274 py-4"><span class="text-10 mr-3 flex h-14 items-center justify-center rounded-xs border border-solid px-3" data-text="已检测" style="color:#009B3F;border-color:#99D7B2">已检测</span><span class="text-10 mr-3 flex h-14 items-center justify-center rounded-xs border border-solid px-3" data-text="顶配" style="color:#303741;border-color:#DFE0E2">顶配</span></div><p class="text-11 text-gz-gray-999 leading-11"><span>2015年</span><span></span><span>18.27万公里</span><span></span><span>北京</span></p><div class="flex justify-start pt-4 leading-12"><div class="flex items-end justify-start"><span class="text-gz-red-f22a18 font-din text-18 leading-12 font-bold">1.41</span><span class="text-gz-red-f22a18 text-12 mr-2"></span></div><div class="flex flex-1 items-end justify-between"><span class="text-gz-glod-997147 text-12 ml-4">首付<!-- -->0.14万</span></div></div></div></section></a>
<a class="text-gz-black-303740" href="/car-detail/c168109339309545.html"><section class="mb-20 flex justify-between gap-12"><div class="overflow-hidden rounded-md w-114 h-85"><div class="rounded-md object-cover w-114 h-85" style="position:relative;overflow:hidden"><img alt="吉利银河 银河A7 2026款 EV 550km 臻享版" loading="lazy" width="114" height="85" decoding="async" data-nimg="1" class="object-cover" style="color:transparent;position:absolute;width:100%;height:100%;top:0;left:0;opacity:0;transition:opacity 0.3s ease" src="https://image-public.guazistatic.com/qnbdp7206xfeffa252cc524361918effe5eca6e18b1780731400.jpg?x-bce-process=image/quality,q_88/resize,m_fill,w_280,h_210"/></div></div><div class="flex-1"><h4 class="line-height-1.5 line-clamp-2"><img alt="icon" loading="lazy" width="41" height="14" decoding="async" data-nimg="1" class="mr-5 inline-block h-14 w-41 -translate-y-1.5" style="color:transparent" src="https://image-public.guazistatic.com/qnbdp1066x2bcdce4af34c493db02eddd6babff42e1754566654.png"/>吉利银河 银河A7 2026款 EV 550km 臻享版</h4><div class="flex h-20 flex-wrap gap-y-10 overflow-hidden whitespace-nowrap w-274 py-4"><span class="text-10 mr-3 flex h-14 items-center justify-center rounded-xs border border-solid px-3" data-text="已检测" style="color:#009B3F;border-color:#99D7B2">已检测</span><span class="text-10 mr-3 flex h-14 items-center justify-center rounded-xs border border-solid px-3" data-text="纯电动" style="color:#009B3F;border-color:#99D7B2">纯电动</span></div><p class="text-11 text-gz-gray-999 leading-11"><span>2026年</span><span></span><span>300公里</span><span></span><span>北京</span></p><div class="flex justify-start pt-4 leading-12"><div class="flex items-end justify-start"><span class="text-gz-red-f22a18 font-din text-18 leading-12 font-bold">10.08</span><span class="text-gz-red-f22a18 text-12 mr-2"></span></div><div class="flex flex-1 items-end justify-between"><span class="text-gz-glod-997147 text-12 ml-4">首付<!-- -->1.01万</span></div></div></div></section></a>
</div></body></html>
+95
View File
@@ -0,0 +1,95 @@
/**
* guazi browse — list used cars for sale in a city.
*
* Reads the mobile SSR list page `https://m.guazi.com/<city>/buy/`. Each
* listing is an `<a href="/car-detail/c<clueId>.html">` whose `<img alt>`
* holds the full title and whose visible text carries price / down-payment
* / mileage / year / city. Parsing is a pure HTML→rows function (no DOM,
* no network) so it runs identically in the unit test against a frozen page.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
BROWSE_COLUMNS,
CommandExecutionError,
GUAZI_M_BASE,
clean,
guaziFetch,
requireStableId,
requireText,
requireLimit,
resolveCityCode,
} from './utils.js';
/** Energy types Guazi tags on a card. */
const ENERGY = ['插电混动', '纯电动', '油电混动', '增程式', '汽油', '柴油'];
/**
* Pure parser: list-page HTML → listing rows. Exported for unit testing.
*/
export function parseListings(html, limit) {
const anchors = String(html || '').match(/<a[^>]+href="\/car-detail\/c\d+\.html"[\s\S]*?<\/a>/g) || [];
const rows = [];
const seen = new Set();
for (const a of anchors) {
const idM = a.match(/car-detail\/c(\d+)\.html/);
if (!idM) continue;
const clueId = requireStableId(idM[1], `guazi listing row ${rows.length + 1}`);
if (seen.has(clueId)) continue;
seen.add(clueId);
const altM = a.match(/<img[^>]+alt="([^"]+)"/);
const title = requireText(altM && altM[1], `guazi listing ${clueId} title`);
const text = clean(a.replace(/<[^>]+>/g, ' '));
const priceM = text.match(/(\d+(?:\.\d+)?)\s*万\s*首付/);
const downM = text.match(/首付\s*(\d+(?:\.\d+)?)\s*万/);
const mileM = text.match(/(\d+(?:\.\d+)?万公里|\d+公里)/);
const yearM = text.match(/(\d{4})年/);
// city sits between the mileage and the price ("… 北京 6.85 万 首付 …")
const cityM = text.match(/[|]\s*([^|]{1,8}?)\s+\d+(?:\.\d+)?\s*万\s*首付/);
const energy = ENERGY.find((e) => text.includes(e)) || '';
rows.push({
rank: rows.length + 1,
clue_id: clueId,
title,
price: priceM ? `${priceM[1]}` : '',
down_payment: downM ? `${downM[1]}` : '',
mileage: mileM ? mileM[1] : '',
year: yearM ? yearM[1] : '',
city: cityM ? clean(cityM[1]) : energy,
url: `${GUAZI_M_BASE}/car-detail/c${clueId}.html`,
});
if (rows.length >= limit) break;
}
return rows;
}
cli({
site: 'guazi',
name: 'browse',
access: 'read',
aliases: ['list'],
description: '瓜子二手车在售车源列表(按城市,含售价/首付/里程/年份)',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'city', positional: true, help: '城市名(北京/上海/...或瓜子城市码bj/sh/...)。默认 bj 北京' },
{ name: 'limit', type: 'int', default: 20, help: '返回的车源数量(最多 40单页 SSR 上限)' },
],
columns: BROWSE_COLUMNS,
func: async (args) => {
const code = resolveCityCode(args.city);
const limit = requireLimit(args.limit, 20, 40);
const html = await guaziFetch(`/${code}/buy/`, `browse ${code}`);
const rows = parseListings(html, limit);
if (rows.length === 0) {
throw new CommandExecutionError(
`guazi browse ${code}`,
'No SSR listing anchors found on a successful Guazi mobile page; the mobile layout may have changed.',
);
}
return rows;
},
});
+110
View File
@@ -0,0 +1,110 @@
/**
* 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;
},
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Unit tests for the 瓜子二手车 (Guazi) adapter.
*
* Both commands parse mobile SSR HTML, so the pure parsers are exercised
* against frozen real-data fixtures captured from m.guazi.com. No network.
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { getRegistry, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import {
BROWSE_COLUMNS,
CAR_COLUMNS,
resolveCityCode,
normalizeClueId,
requireLimit,
} from './utils.js';
import { parseListings } from './browse.js';
import { parseCarDetail } from './car.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const LIST = readFileSync(join(__dirname, '__fixtures__/list.html'), 'utf8');
const DETAIL = readFileSync(join(__dirname, '__fixtures__/detail.html'), 'utf8');
describe('guazi adapter — registration', () => {
it('registers browse + car as PUBLIC (no browser)', () => {
for (const n of ['browse', 'car']) {
const cmd = getRegistry().get(`guazi/${n}`);
expect(cmd, n).toBeTruthy();
expect(cmd.strategy, n).toBe(Strategy.PUBLIC);
expect(cmd.browser, n).toBe(false);
expect(cmd.access, n).toBe('read');
}
expect(getRegistry().get('guazi/browse').columns).toEqual(BROWSE_COLUMNS);
expect(getRegistry().get('guazi/car').columns).toEqual(CAR_COLUMNS);
});
});
describe('guazi adapter — utils', () => {
it('resolveCityCode maps names, codes, and defaults to bj', () => {
expect(resolveCityCode('北京')).toBe('bj');
expect(resolveCityCode('shanghai')).toBe('sh');
expect(resolveCityCode('gz')).toBe('gz');
expect(resolveCityCode('')).toBe('bj');
expect(resolveCityCode(undefined)).toBe('bj');
expect(() => resolveCityCode('火星')).toThrow();
});
it('normalizeClueId accepts numbers and URLs', () => {
expect(normalizeClueId('162563585115789')).toBe('162563585115789');
expect(normalizeClueId('https://m.guazi.com/car-detail/c162563585115789.html')).toBe('162563585115789');
expect(() => normalizeClueId('abc')).toThrow();
});
it('requireLimit enforces [1,max]', () => {
expect(requireLimit(undefined, 20, 40)).toBe(20);
expect(() => requireLimit(41, 20, 40)).toThrow();
});
});
describe('guazi adapter — parsers against frozen fixtures', () => {
it('parseListings extracts listings with price/mileage/year', () => {
const rows = parseListings(LIST, 40);
expect(rows.length).toBe(3);
for (const r of rows) {
expect(Object.keys(r).sort()).toEqual([...BROWSE_COLUMNS].sort());
expect(r.clue_id).toMatch(/^\d+$/);
expect(r.title).toBeTruthy();
expect(r.url).toContain('/car-detail/c');
}
const first = rows[0];
expect(first.price).toMatch(/万$/);
expect(first.mileage).toMatch(/公里$/);
expect(first.year).toMatch(/^\d{4}$/);
});
it('parseListings respects the limit and dedupes', () => {
expect(parseListings(LIST, 2).length).toBe(2);
expect(parseListings('<html>nothing</html>', 40)).toEqual([]);
});
it('browse treats a successful page with no listing anchors as parser drift', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () => '<html><title>瓜子二手车</title><main>new layout</main></html>',
});
try {
await expect(getRegistry().get('guazi/browse').func({ city: 'bj', limit: 20 }))
.rejects.toBeInstanceOf(CommandExecutionError);
} finally {
globalThis.fetch = originalFetch;
}
});
it('parseListings rejects malformed listing cards instead of silently dropping them', () => {
expect(() => parseListings('<a href="/car-detail/c123.html"><span>6.8 万 首付 1 万</span></a>', 40))
.toThrow(/stable text value/);
});
it('parseCarDetail builds a field/value sheet with price + specs', () => {
const rows = parseCarDetail(DETAIL, '162563585115789');
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(rows.every((r) => Object.keys(r).sort().join() === 'field,value')).toBe(true);
expect(map.title).toBeTruthy();
expect(map.title).not.toContain('二手');
expect(map.title).not.toContain('报价');
expect(map.price).toMatch(/万$/);
expect(map.reg_date).toMatch(/^\d{4}-\d{2}$/);
expect(map.mileage).toMatch(/公里$/);
expect(map.engine).toBeTruthy();
expect(map.gearbox).toBeTruthy();
expect(map.condition).toContain('基础车况');
expect(map.url).toContain('/car-detail/c162563585115789.html');
});
it('parseCarDetail tolerates an empty page', () => {
const rows = parseCarDetail('<html></html>', '1');
const map = Object.fromEntries(rows.map((r) => [r.field, r.value]));
expect(map.title).toBe('');
expect(map.price).toBe('');
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Shared helpers for the 瓜子二手车 (Guazi) used-car adapter.
*
* The desktop www.guazi.com SPA renders an empty shell and loads listings
* from a signature-locked API (`mapi.guazi.com`, rejects unsigned requests
* with 签名验证失败). The MOBILE site `m.guazi.com`, however, server-side
* renders the full listing list and car detail into the HTML with no login,
* no signature, and no anti-bot challenge so this adapter reads the mobile
* SSR HTML with an iPhone UA.
*
* Limitation: deep pagination and brand/keyword filtering route through the
* signed API, so `browse` returns the first SSR page (~40 fresh listings) for
* a city. That is surfaced honestly rather than faked.
*/
import {
ArgumentError,
AuthRequiredError,
CommandExecutionError,
EmptyResultError,
} from '@jackwener/opencli/errors';
export const GUAZI_M_BASE = 'https://m.guazi.com';
const UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 '
+ '(KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1';
export const BROWSE_COLUMNS = ['rank', 'clue_id', 'title', 'price', 'down_payment', 'mileage', 'year', 'city', 'url'];
export const CAR_COLUMNS = ['field', 'value'];
/**
* Common city Guazi city code (the path segment in m.guazi.com/<code>/buy/).
*/
export const CITY_CODE = {
beijing: 'bj', '北京': 'bj',
shanghai: 'sh', '上海': 'sh',
guangzhou: 'gz', '广州': 'gz',
shenzhen: 'sz', '深圳': 'sz',
hangzhou: 'hz', '杭州': 'hz',
chengdu: 'cd', '成都': 'cd',
chongqing: 'cq', '重庆': 'cq',
nanjing: 'nj', '南京': 'nj',
wuhan: 'wh', '武汉': 'wh',
tianjin: 'tj', '天津': 'tj',
xian: 'xa', '西安': 'xa',
suzhou: 'su', '苏州': 'su',
zhengzhou: 'zz', '郑州': 'zz',
changsha: 'cs', '长沙': 'cs',
qingdao: 'qd', '青岛': 'qd',
shenyang: 'sy', '沈阳': 'sy',
dalian: 'dl', '大连': 'dl',
jinan: 'jn', '济南': 'jn',
hefei: 'hf', '合肥': 'hf',
foshan: 'fs', '佛山': 'fs',
};
/** Resolve a city arg (name or code) to a Guazi city code; defaults to bj. */
export function resolveCityCode(cityArg) {
if (cityArg == null || cityArg === '') return 'bj';
const raw = String(cityArg).trim().toLowerCase();
if (CITY_CODE[raw]) return CITY_CODE[raw];
if (CITY_CODE[String(cityArg).trim()]) return CITY_CODE[String(cityArg).trim()];
if (/^[a-z]{2,3}$/.test(raw)) return raw; // already a code
const names = Object.keys(CITY_CODE).filter((k) => /^[a-z]+$/.test(k)).join(', ');
throw new ArgumentError('city', `unknown city '${cityArg}'. pass a Guazi city code or one of: ${names}`);
}
/** Normalize a clue id: a bare number or a /car-detail/c<id>.htm(l) URL. */
export function normalizeClueId(rawInput) {
const raw = String(rawInput || '').trim();
if (!raw) throw new ArgumentError('clue_id must be a non-empty value');
const m = raw.match(/car-detail\/c(\d+)/) || raw.match(/^c?(\d+)$/);
if (!m) {
throw new ArgumentError(`'${rawInput}' does not look like a guazi clue id (a number, or a /car-detail/c<id>.html URL)`);
}
return m[1];
}
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;
}
export function clean(s) {
return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
}
export function requireText(value, label) {
const text = clean(value);
if (!text) throw new CommandExecutionError(`${label} did not include a stable text value.`);
return text;
}
export function requireStableId(value, label) {
const id = String(value ?? '').trim();
if (!/^\d+$/.test(id)) throw new CommandExecutionError(`${label} did not include a stable numeric id.`);
return id;
}
/** Fetch a Guazi mobile page as HTML text, throwing typed errors. */
export async function guaziFetch(path, contextHint) {
let resp;
try {
resp = await fetch(`${GUAZI_M_BASE}${path}`, {
headers: {
'User-Agent': UA,
Referer: `${GUAZI_M_BASE}/`,
'Accept-Language': 'zh-CN,zh;q=0.9',
},
});
} catch (err) {
throw new CommandExecutionError(`guazi ${contextHint} network error: ${err?.message || err}`);
}
if (!resp.ok) {
throw new CommandExecutionError(`guazi ${contextHint} HTTP ${resp.status}`);
}
const html = await resp.text();
// Guazi may eventually push the mobile pages behind their JS challenge.
if (/瑞数|reese84|captcha|滑动验证|verify\.guazi|安全验证/i.test(html) && !/car-detail\/c\d+/.test(html)) {
throw new AuthRequiredError(
'guazi.com',
`guazi ${contextHint} hit an anti-bot challenge — Guazi may have started gating the mobile site.`,
);
}
return html;
}
export { ArgumentError, CommandExecutionError, EmptyResultError };
+62
View File
@@ -0,0 +1,62 @@
# 汽车之家 Autohome
**Mode**: 🌐 Public · **Domain**: `autohome.com.cn`
No login, no cookies, no signature. Reads two fully server-rendered sources:
the brand catalog (`grade/carhtml/<INITIAL>.html`) and the 口碑 page
(`k.autohome.com.cn/<seriesId>`, whose `__NEXT_DATA__` carries the aggregate
rating).
## Commands
| Command | Description |
|---------|-------------|
| `opencli autohome brand <品牌>` | A brand's car series + 厂商指导价 (guide price) |
| `opencli autohome score <series_id>` | 口碑 rating: overall + per-dimension + 故障率PPH + competitors |
`score` takes a **series_id** from `brand` (the `series_id` column) or a
`https://k.autohome.com.cn/<id>` URL.
## Usage Examples
```bash
# A brand's whole lineup with guide prices
opencli autohome brand 宝马
opencli autohome brand 比亚迪 --limit 80
opencli autohome brand 理想
# Owner-rating summary for a series
opencli autohome score 6548 # 宝马X5
# JSON output
opencli autohome brand 丰田 -f json
```
## Output Columns
| Command | Columns |
|---------|---------|
| `brand` | `series_id, name, price, url` |
| `score` | `field, value` (series_id, name, brand, level, guide_price, overall, 各维度评分…, pph_每百车故障, review_users, competitors, url) |
## Notes & Limits
- **Search is by brand, not free text.** Autohome's keyword-search and the
per-trim config JSON are app-signature gated; the brand catalog is the
login-free entry point, so you search by brand (e.g. 宝马 / 比亚迪 / 理想) and
drill into a series from there. For free-text model search, use
`dongchedi search`.
- **`brand` accepts known Chinese brand names** (mapped to the catalog's pinyin
initial) or a single A-Z catalog letter. Unknown brands raise a clear error
rather than guessing.
- **`score` is the aggregate rating only.** The per-review owner-text list loads
from a separate signed XHR and is intentionally not scraped (use
`dongchedi koubei` for owner review bodies).
- **Full per-trim config (参数配置) is not offered** — Autohome's config page
obfuscates values with a rotating CSS font glyph map, which can't be read
reliably from plain HTTP; faking partial specs would be worse than omitting
them. Use `dongchedi specs` for a config overview.
## Prerequisites
None — public site, no authentication required.
+80
View File
@@ -0,0 +1,80 @@
# 懂车帝 Dongchedi
**Mode**: 🌐 Public · **Domain**: `dongchedi.com`
No login, no cookies, no signature. Every command does a plain HTTP GET of a
server-rendered page and parses the `__NEXT_DATA__` JSON embedded in the HTML,
so it works out of the box.
## Commands
| Command | Description |
|---------|-------------|
| `opencli dongchedi search <keyword>` | Search car series by keyword → series + 指导价/经销商价 |
| `opencli dongchedi series <series_id>` | Series overview: brand, prices, 懂车分, sales rank, trim count |
| `opencli dongchedi models <series_id>` | Trims (款型) with guide / dealer / owner prices |
| `opencli dongchedi specs <series_id>` | Config overview: dimensions, powertrain, drivetrain, airbags |
| `opencli dongchedi score <series_id>` | 懂车分 rating — 8 axes vs same-class average |
| `opencli dongchedi koubei <series_id>` | Owner reviews (口碑): rating, trim bought, likes, full text |
`series`, `models`, `specs`, `score`, and `koubei` all take a **series_id**
get one from `search` (the `series_id` column) or paste a
`https://www.dongchedi.com/auto/series/<id>` URL.
## Usage Examples
```bash
# Find a car series
opencli dongchedi search "宝马X5" --limit 5
opencli dongchedi search "汉兰达"
# One series at a glance (prices + 懂车分 + ranks)
opencli dongchedi series 5273
# Trims and their prices
opencli dongchedi models 5273
opencli dongchedi models 5273 --status offline # discontinued trims
# Key configuration overview
opencli dongchedi specs 5273
# Rating breakdown vs same-class average
opencli dongchedi score 5273
# Owner reviews (full body in the content column)
opencli dongchedi koubei 5273 --limit 10
# JSON output
opencli dongchedi search "汉兰达" -f json
```
## Output Columns
| Command | Columns |
|---------|---------|
| `search` | `rank, series_id, name, brand, official_price, dealer_price, pictures, url` |
| `series` | `field, value` (series_id, name, brand, sub_brand, official_price, dealer_price, used_price, score, review_count, sale_rank, score_rank, models, url) |
| `models` | `car_id, name, year, official_price, dealer_price, owner_price` |
| `specs` | `field, value` (dimensions, wheelbase, power, engine, gearbox, energy, acceleration, drivetrain, suspension, airbags) |
| `score` | `dimension, score, same_level_avg` |
| `koubei` | `rank, user, car, score, likes, comments, content, url` |
Scores are rescaled to a 05 float (Dongchedi stores them as x100 ints; 422 → 4.22).
## Notes & Limits
- **Why SSR, not the JSON API:** Dongchedi's `/motor/...` XHR endpoints are
ByteDance-signature gated (`a_bogus` / `X-Bogus`) and 404 without a valid
signature. The SSR pages expose the same data unsigned, so this adapter reads
those instead — no signature replication, no breakage when the signing scheme
rotates.
- **`specs` is the overview, not the full parameter sheet.** The complete
per-trim parameter table lives behind a signed XHR; rather than fabricate it,
`specs` returns the unsigned SSR overview (dimensions, powertrain, drivetrain,
suspension, airbags). For per-trim names/prices use `models`.
- **`koubei` is a single SSR page** (up to ~15 reviews). Deep pagination uses the
signed API and is intentionally not implemented.
## Prerequisites
None — public site, no authentication required.
+65
View File
@@ -0,0 +1,65 @@
# 瓜子二手车 Guazi
**Mode**: 🌐 Public · **Domain**: `guazi.com`
No login, no cookies, no signature. Reads the **mobile** site `m.guazi.com`,
which server-renders the full listing list and car detail into the HTML (the
desktop `www.guazi.com` SPA loads data from a signature-locked API and is not
usable from a plain HTTP client).
## Commands
| Command | Description |
|---------|-------------|
| `opencli guazi browse [city]` | Used cars for sale in a city → price / mileage / year |
| `opencli guazi car <clue_id>` | One listing's detail → price, registration, mileage, specs, condition |
`car` takes a **clue_id** — get one from `browse` (the `clue_id` column) or paste
a `https://m.guazi.com/car-detail/c<id>.html` URL.
## Usage Examples
```bash
# Browse listings (defaults to Beijing)
opencli guazi browse
opencli guazi browse 上海 --limit 30
opencli guazi browse sz # city code also works
# One listing in detail
opencli guazi car 168029452296957
opencli guazi car https://m.guazi.com/car-detail/c168029452296957.html
# JSON output
opencli guazi browse 北京 -f json
```
## Output Columns
| Command | Columns |
|---------|---------|
| `browse` | `rank, clue_id, title, price, down_payment, mileage, year, city, url` |
| `car` | `field, value` (clue_id, title, tag, price, reg_date, mileage, transfers, source_city, color, engine, gearbox, drivetrain, emission, condition, listing_no, url) |
## Cities
Pass a Chinese city name or a Guazi city code. Known names:
北京(bj), 上海(sh), 广州(gz), 深圳(sz), 杭州(hz), 成都(cd), 重庆(cq), 南京(nj),
武汉(wh), 天津(tj), 西安(xa), 苏州(su), 郑州(zz), 长沙(cs), 青岛(qd), 沈阳(sy),
大连(dl), 济南(jn), 合肥(hf), 佛山(fs). Any two/three-letter code is passed through
as-is, so other cities work by code too.
## Notes & Limits
- **First SSR page only.** Deep pagination and brand/keyword filtering route
through Guazi's signed `mapi.guazi.com` API, so `browse` returns the first
server-rendered page (~40 fresh listings) per city. Listings rotate, so
re-running surfaces new cars rather than the same page.
- **Condition is a summary**, not the full inspection checklist (基础车况 +
accident/transfer flags). The full 检测报告 sits behind the signed API and is
intentionally not faked.
- If Guazi ever pushes the mobile pages behind their anti-bot challenge, the
commands fail loudly with an auth-required error rather than returning blanks.
## Prerequisites
None — public site, no authentication required.