dongchedi-cli 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,3 +7,4 @@ build/
7
7
  *.egg-info/
8
8
  __pycache__/
9
9
  .ruff_cache/
10
+ uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dongchedi-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: 懂车帝(dongchedi.com)数据查询 CLI:销量榜 / 品牌 / 车系 / 车型库筛选 / 车型 / 参数配置 / 车主评论 / 搜索
5
5
  Author: weisanju
6
6
  Keywords: automobile,car,cli,dongchedi,vehicle-data
@@ -46,8 +46,15 @@ dcd filter --price 10,20 --fuel 纯电 --body 轿车 # 车型库筛选:10-20
46
46
  dcd filter --body 小型SUV # 全部小型SUV(级别细分,如 紧凑型SUV/中大型车/中型MPV)
47
47
  dcd filter --price 10, --fuel 新能源 --sort 销量高 -n 50 # 10万以上新能源,按销量排序
48
48
  dcd cars 20154 # 星愿的车型年款与价格(含停售/未上市)
49
- dcd specs 20154 259371 # 车型参数配置表
49
+ dcd cars 26051 -k 700 --fuel 纯电 # 车型筛选:名称含 700 的纯电车型(大小写不敏感)
50
+ dcd cars 26051 --price 15,17 # 按指导价筛选(万,一侧留空表示不限)
51
+ dcd specs 20154 259371 # 车型参数配置表(默认按 ◆ 章节/◇ 子章节分类)
50
52
  dcd specs 20154 259371 259369 -s 电池 # 多车对比,只看"电池"章节
53
+ dcd specs 26051 261982 257691 -d # 差异对比:只显示配置不同的项(至少 2 款车型)
54
+ dcd specs 26051:261982 26012:261406 # 跨车系对比:车系ID:车型ID 配对(启源Q06 vs MG 07 EV)
55
+ dcd specs 26051 261982 26012:261406 -d # 混用:首个参数为默认车系,其余可写 车系:车型
56
+ dcd specs 26051 --all # 全系可查配置车型横向对比
57
+ dcd specs 26051 --all --price 15,17 --fuel 纯电 -s 电池 # 全系筛选后对比
51
58
  dcd reviews 20154 -p 1 -n 20 # 车主评论(含成交价/评分)
52
59
  dcd search 秦plus # 搜车系
53
60
  dcd dealer 259371 # 某车型的经销商报价与优惠(默认杭州)
@@ -88,8 +95,8 @@ Cookie 文件解析优先级:`--cookie-file` 参数 → `DCD_COOKIE_FILE` 环
88
95
  | brands | `GET /motor/brand/v4/brand/` | 品牌在 `data[].info`(其中 4 个热门品牌在 `info.brand`) |
89
96
  | series | `POST /motor/pc/car/brand/select_series_v2` | 表单参数是 `brand`(不是 `brand_id`,传错会静默返回全站热门车系) |
90
97
  | filter | `POST /motor/pc/car/brand/select_series_v2` | 与 series 同接口,隐藏筛选参数:`price="10,20"`(万元,区间重叠即命中,`-1` 表示不限)、`fuel_form`(1汽油/2柴油/3油电混合/4纯电/5增程/6插混/13轻混)、`series_type`(0轿车/1SUV/2MPV…)、`outter_detail_type`(细分级别:10小型SUV/11紧凑型SUV…);`page` 分页有效,总数在 `data.series_count`;枚举出自车型库页前端 chunk(2026-09 逐项实测) |
91
- | cars | `GET /motor/pc/car/series/car_list` | `data.tab_list[]`,type 1115=车型、1137=年款分组,1068 为占位需跳过 |
92
- | specs | `GET /motor/pc/car/get_full_config_info` | 需 `series_id` + `car_ids`;实测忽略 car_ids,总是返回全车系,CLI 已按请求过滤 |
98
+ | cars | `GET /motor/pc/car/series/car_list` | `data.tab_list[]`,type 1115=车型、1137=年款分组,1068 为占位需跳过;`-k/--price/--fuel` 为本地筛选,能源取自配置接口 `car_info[].info.fuel_form.value`(纯电动/增程式/插电式混合动力/汽油/油电混合/48V轻混系统,子串匹配),筛选时按 car_id 去重(同一车型会同时出现在「在售」与年款 tab) |
99
+ | specs | `GET /motor/pc/car/get_full_config_info` | 需 `series_id` + `car_ids`;实测忽略 car_ids,总是返回全车系,CLI 已按请求过滤(返回车型与 car_list 不完全一致,约只含可查配置/在售车型);`--all` 一次取全系在本地筛选;默认按 `◆` 章节/`◇` 子章节分层渲染,`-d/--diff` 只保留各车型取值不全相同的行;跨车系用 `车系ID:车型ID` 配对,每个车系各请求一次后按行名合并,`-j` 多车系输出 `{车系ID: 原始数据}` |
93
100
  | reviews | `GET /motor/pc/car/series/get_review_list` | 需 `Referer: /auto/series/score/{series_id}` |
94
101
  | search | `GET /motor/searchapi/search_content` | `cur_tab=2`,车系卡片为 `cell_type=5002`(不一定出现,无则回退内容标题) |
95
102
  | dealer | `GET /motor/pc/car/series/car_dealer_price` | 匿名可用;依赖 `city_name`(全国 → 暂无报价),未覆盖城市会回落邻近城市 |
@@ -106,6 +113,10 @@ Cookie 文件解析优先级:`--cookie-file` 参数 → `DCD_COOKIE_FILE` 环
106
113
  - 登录态 Cookie 含中文值,urllib 请求头默认 latin-1 编码,发送前已转 UTF-8 裸字节(与浏览器线上行为一致)。
107
114
  - 参数配置表字段:`data.properties[]` 为行定义(type 0/2 为章节标题,1 普通行,3 带子项,6 跳过),值在 `data.car_info[].info[<key>].value`;type 3 的值挂在 `sub_list[].key` 上。注意 `type` 字段时而为 int 时而为字符串,代码里已统一归一化。
108
115
  - 车型库筛选中 `series_type` 与 `outter_detail_type` **互斥**:同传时服务端会忽略后者(按大类返回);CLI 的 `--body` 已自动只传其一(轿车/SUV 传 series_type,小型SUV 等细分传 outter_detail_type)。
116
+ - 年款分组标题的「/ 后缀」不一定是能源:奥迪A6L 是世代代号(/ C9、/ C8),部分车系无后缀(钛7 PHEV)。`cars --fuel` 不解析标题,统一以配置接口的「能源类型」为准;因此只覆盖可查配置的车型,停售车型若无配置数据会被筛掉。
117
+ - `cars` / `specs --all` 的筛选均在本地完成;`--json` 始终输出接口原始数据、不受筛选影响。筛选无匹配时:`cars` 输出空表(退出码 0,提示在 stderr),`specs --all` 报错(退出码 1)。
118
+ - `specs -d/--diff` 行保留条件为各车型取值去重后 >1(`-` 视为相同值);无差异的章节/子章节整段省略,汇总写 stderr(`差异 N 项 / 共 M 项`),无差异时 stdout 为空、退出码 0;最终参与对比车型不足 2 款报错(退出码 1)。
119
+ - `specs` 跨车系对比:位置参数可写 `车系ID:车型ID`;首个参数无冒号时作为其余裸车型ID的默认车系(如 `specs 26051 261982 26012:261406`),与 `--all` 不可同时使用。合并按行名 `(key, text)` 对齐,行序以先出现的车系为准;某车系没有的行,该列回退取同行其他车系的定义渲染(通常显示 `-`)。列标题在多车系时带上车系名(如 `MG 07 EV 845 旗舰版`);每个车系各发一次请求,跨 N 个车系即 N 次网络请求。
109
120
  - 接口可能随版本变化(版本参数当前为 version_code=476),失效时优先用 `--json` 排查原始返回。
110
121
 
111
122
  ## 开发与发布
@@ -26,8 +26,15 @@ dcd filter --price 10,20 --fuel 纯电 --body 轿车 # 车型库筛选:10-20
26
26
  dcd filter --body 小型SUV # 全部小型SUV(级别细分,如 紧凑型SUV/中大型车/中型MPV)
27
27
  dcd filter --price 10, --fuel 新能源 --sort 销量高 -n 50 # 10万以上新能源,按销量排序
28
28
  dcd cars 20154 # 星愿的车型年款与价格(含停售/未上市)
29
- dcd specs 20154 259371 # 车型参数配置表
29
+ dcd cars 26051 -k 700 --fuel 纯电 # 车型筛选:名称含 700 的纯电车型(大小写不敏感)
30
+ dcd cars 26051 --price 15,17 # 按指导价筛选(万,一侧留空表示不限)
31
+ dcd specs 20154 259371 # 车型参数配置表(默认按 ◆ 章节/◇ 子章节分类)
30
32
  dcd specs 20154 259371 259369 -s 电池 # 多车对比,只看"电池"章节
33
+ dcd specs 26051 261982 257691 -d # 差异对比:只显示配置不同的项(至少 2 款车型)
34
+ dcd specs 26051:261982 26012:261406 # 跨车系对比:车系ID:车型ID 配对(启源Q06 vs MG 07 EV)
35
+ dcd specs 26051 261982 26012:261406 -d # 混用:首个参数为默认车系,其余可写 车系:车型
36
+ dcd specs 26051 --all # 全系可查配置车型横向对比
37
+ dcd specs 26051 --all --price 15,17 --fuel 纯电 -s 电池 # 全系筛选后对比
31
38
  dcd reviews 20154 -p 1 -n 20 # 车主评论(含成交价/评分)
32
39
  dcd search 秦plus # 搜车系
33
40
  dcd dealer 259371 # 某车型的经销商报价与优惠(默认杭州)
@@ -68,8 +75,8 @@ Cookie 文件解析优先级:`--cookie-file` 参数 → `DCD_COOKIE_FILE` 环
68
75
  | brands | `GET /motor/brand/v4/brand/` | 品牌在 `data[].info`(其中 4 个热门品牌在 `info.brand`) |
69
76
  | series | `POST /motor/pc/car/brand/select_series_v2` | 表单参数是 `brand`(不是 `brand_id`,传错会静默返回全站热门车系) |
70
77
  | filter | `POST /motor/pc/car/brand/select_series_v2` | 与 series 同接口,隐藏筛选参数:`price="10,20"`(万元,区间重叠即命中,`-1` 表示不限)、`fuel_form`(1汽油/2柴油/3油电混合/4纯电/5增程/6插混/13轻混)、`series_type`(0轿车/1SUV/2MPV…)、`outter_detail_type`(细分级别:10小型SUV/11紧凑型SUV…);`page` 分页有效,总数在 `data.series_count`;枚举出自车型库页前端 chunk(2026-09 逐项实测) |
71
- | cars | `GET /motor/pc/car/series/car_list` | `data.tab_list[]`,type 1115=车型、1137=年款分组,1068 为占位需跳过 |
72
- | specs | `GET /motor/pc/car/get_full_config_info` | 需 `series_id` + `car_ids`;实测忽略 car_ids,总是返回全车系,CLI 已按请求过滤 |
78
+ | cars | `GET /motor/pc/car/series/car_list` | `data.tab_list[]`,type 1115=车型、1137=年款分组,1068 为占位需跳过;`-k/--price/--fuel` 为本地筛选,能源取自配置接口 `car_info[].info.fuel_form.value`(纯电动/增程式/插电式混合动力/汽油/油电混合/48V轻混系统,子串匹配),筛选时按 car_id 去重(同一车型会同时出现在「在售」与年款 tab) |
79
+ | specs | `GET /motor/pc/car/get_full_config_info` | 需 `series_id` + `car_ids`;实测忽略 car_ids,总是返回全车系,CLI 已按请求过滤(返回车型与 car_list 不完全一致,约只含可查配置/在售车型);`--all` 一次取全系在本地筛选;默认按 `◆` 章节/`◇` 子章节分层渲染,`-d/--diff` 只保留各车型取值不全相同的行;跨车系用 `车系ID:车型ID` 配对,每个车系各请求一次后按行名合并,`-j` 多车系输出 `{车系ID: 原始数据}` |
73
80
  | reviews | `GET /motor/pc/car/series/get_review_list` | 需 `Referer: /auto/series/score/{series_id}` |
74
81
  | search | `GET /motor/searchapi/search_content` | `cur_tab=2`,车系卡片为 `cell_type=5002`(不一定出现,无则回退内容标题) |
75
82
  | dealer | `GET /motor/pc/car/series/car_dealer_price` | 匿名可用;依赖 `city_name`(全国 → 暂无报价),未覆盖城市会回落邻近城市 |
@@ -86,6 +93,10 @@ Cookie 文件解析优先级:`--cookie-file` 参数 → `DCD_COOKIE_FILE` 环
86
93
  - 登录态 Cookie 含中文值,urllib 请求头默认 latin-1 编码,发送前已转 UTF-8 裸字节(与浏览器线上行为一致)。
87
94
  - 参数配置表字段:`data.properties[]` 为行定义(type 0/2 为章节标题,1 普通行,3 带子项,6 跳过),值在 `data.car_info[].info[<key>].value`;type 3 的值挂在 `sub_list[].key` 上。注意 `type` 字段时而为 int 时而为字符串,代码里已统一归一化。
88
95
  - 车型库筛选中 `series_type` 与 `outter_detail_type` **互斥**:同传时服务端会忽略后者(按大类返回);CLI 的 `--body` 已自动只传其一(轿车/SUV 传 series_type,小型SUV 等细分传 outter_detail_type)。
96
+ - 年款分组标题的「/ 后缀」不一定是能源:奥迪A6L 是世代代号(/ C9、/ C8),部分车系无后缀(钛7 PHEV)。`cars --fuel` 不解析标题,统一以配置接口的「能源类型」为准;因此只覆盖可查配置的车型,停售车型若无配置数据会被筛掉。
97
+ - `cars` / `specs --all` 的筛选均在本地完成;`--json` 始终输出接口原始数据、不受筛选影响。筛选无匹配时:`cars` 输出空表(退出码 0,提示在 stderr),`specs --all` 报错(退出码 1)。
98
+ - `specs -d/--diff` 行保留条件为各车型取值去重后 >1(`-` 视为相同值);无差异的章节/子章节整段省略,汇总写 stderr(`差异 N 项 / 共 M 项`),无差异时 stdout 为空、退出码 0;最终参与对比车型不足 2 款报错(退出码 1)。
99
+ - `specs` 跨车系对比:位置参数可写 `车系ID:车型ID`;首个参数无冒号时作为其余裸车型ID的默认车系(如 `specs 26051 261982 26012:261406`),与 `--all` 不可同时使用。合并按行名 `(key, text)` 对齐,行序以先出现的车系为准;某车系没有的行,该列回退取同行其他车系的定义渲染(通常显示 `-`)。列标题在多车系时带上车系名(如 `MG 07 EV 845 旗舰版`);每个车系各发一次请求,跨 N 个车系即 N 次网络请求。
89
100
  - 接口可能随版本变化(版本参数当前为 version_code=476),失效时优先用 `--json` 排查原始返回。
90
101
 
91
102
  ## 开发与发布
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dongchedi-cli"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "懂车帝(dongchedi.com)数据查询 CLI:销量榜 / 品牌 / 车系 / 车型库筛选 / 车型 / 参数配置 / 车主评论 / 搜索"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """懂车帝数据查询 CLI。"""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.4.0"
@@ -3,6 +3,8 @@
3
3
  免登录子命令:rank / brands / series / filter / cars / specs / reviews / search / dealer
4
4
  需登录态子命令:series-info(HTML SSR)/ me(账号信息)
5
5
 
6
+ cars 可按 关键字/价格/能源 筛选;specs 默认按分类(◆/◇)对比,支持 车系:车型 跨车系配对,--all 对比全系并叠加同一套筛选,-d 只看差异项
7
+
6
8
  登录态来源(auth-import 写入,或 --cookie-file / DCD_COOKIE_FILE 指定):
7
9
  dcd auth-import <playwright-state.json> # playwright-cli state-save 的产物
8
10
 
@@ -420,13 +422,19 @@ SORTS = {
420
422
  }
421
423
 
422
424
 
423
- def parse_price(text: str) -> str:
424
- """'10,20' -> '10,20';一侧留空表示不限:'10,' -> '10,-1',',20' -> '0,20'。"""
425
+ def parse_price_range(text: str) -> tuple[str | None, str | None]:
426
+ """'10,20' -> ('10','20');一侧留空表示不限:'10,' -> ('10',None),',20' -> (None,'20')。"""
425
427
  normalized = text.replace("万", "").replace(",", ",").replace("~", "~")
426
428
  match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)?\s*[,\-~]\s*(\d+(?:\.\d+)?)?\s*", normalized)
427
429
  if not match or (not match.group(1) and not match.group(2)):
428
430
  raise DcdError('价格格式应为 "最低,最高"(单位万),如 "10,20";一侧留空表示不限,如 "10,"')
429
- return f"{match.group(1) or '0'},{match.group(2) or '-1'}"
431
+ return match.group(1), match.group(2)
432
+
433
+
434
+ def parse_price(text: str) -> str:
435
+ """'10,20' -> '10,20';一侧留空表示不限:'10,' -> '10,-1',',20' -> '0,20'。"""
436
+ low, high = parse_price_range(text)
437
+ return f"{low or '0'},{high or '-1'}"
430
438
 
431
439
 
432
440
  def resolve_fuel(text: str) -> str:
@@ -502,6 +510,99 @@ def cmd_filter(args) -> None:
502
510
  print_series_table(series)
503
511
 
504
512
 
513
+ # ---------- 车系内筛选(cars / specs --all 共用) ----------
514
+
515
+ # 配置接口的能源类型实际取值:纯电动 / 增程式 / 插电式混合动力 / 汽油 / 油电混合 / 48V轻混系统
516
+ # 按子串匹配,别名与 filter 子命令保持一致
517
+ FUEL_TEXTS = {
518
+ "新能源": ("纯电", "增程", "插电"),
519
+ "纯电": ("纯电",),
520
+ "纯电动": ("纯电",),
521
+ "增程": ("增程",),
522
+ "增程式": ("增程",),
523
+ "插混": ("插电",),
524
+ "插电混动": ("插电",),
525
+ "插电式混合动力": ("插电",),
526
+ "汽油": ("汽油",),
527
+ "柴油": ("柴油",),
528
+ "油电混动": ("油电",),
529
+ "油电混合": ("油电",),
530
+ "轻混": ("轻混", "48V"),
531
+ "48V轻混": ("轻混", "48V"),
532
+ }
533
+
534
+
535
+ def resolve_fuel_text(text: str) -> tuple[str, ...]:
536
+ key = text.strip()
537
+ if key in FUEL_TEXTS:
538
+ return FUEL_TEXTS[key]
539
+ raise DcdError(
540
+ f"未知能源 {text!r};可用: 新能源/纯电/增程/插混/汽油/柴油/油电混动/轻混"
541
+ )
542
+
543
+
544
+ def parse_wan(value) -> float | None:
545
+ """车系接口指导价是浮点,配置接口是 '13.99万' 字符串,统一为数字(万)。"""
546
+ if isinstance(value, (int, float)):
547
+ return float(value)
548
+ match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*万?\s*", str(value or ""))
549
+ return float(match.group(1)) if match else None
550
+
551
+
552
+ def car_energy(car: dict) -> str:
553
+ return (((car.get("info") or {}).get("fuel_form") or {}).get("value") or "").strip()
554
+
555
+
556
+ def matches_keyword(name: str, keyword: str) -> bool:
557
+ return keyword.lower() in name.lower()
558
+
559
+
560
+ def matches_price(price_wan: float | None, bounds: tuple[float | None, float | None]) -> bool:
561
+ if price_wan is None:
562
+ return False
563
+ low, high = bounds
564
+ if low is not None and price_wan < low:
565
+ return False
566
+ return high is None or price_wan <= high
567
+
568
+
569
+ def matches_fuel(energy: str, texts: tuple[str, ...]) -> bool:
570
+ return bool(energy) and any(t in energy for t in texts)
571
+
572
+
573
+ def parse_price_bounds(text: str) -> tuple[float | None, float | None]:
574
+ low, high = parse_price_range(text)
575
+ return (float(low) if low else None, float(high) if high else None)
576
+
577
+
578
+ def fetch_car_fuels(series_id) -> dict[str, str]:
579
+ """车系内全部可查配置车型的能源类型:car_id -> fuel_form。"""
580
+ payload = api_get("/motor/pc/car/get_full_config_info", {"series_id": series_id})
581
+ fuels = {}
582
+ for car in (payload.get("data") or {}).get("car_info") or []:
583
+ cid = car.get("car_id")
584
+ if cid is not None:
585
+ fuels[str(cid)] = car_energy(car)
586
+ return fuels
587
+
588
+
589
+ def filter_desc(keyword, price, fuel) -> str:
590
+ parts = []
591
+ if keyword:
592
+ parts.append(f"关键字 {keyword}")
593
+ if price:
594
+ low, high = parse_price_range(price)
595
+ if low and high:
596
+ parts.append(f"价格 {low}-{high}万")
597
+ elif low:
598
+ parts.append(f"价格 {low}万以上")
599
+ else:
600
+ parts.append(f"价格 {high}万以下")
601
+ if fuel:
602
+ parts.append(fuel)
603
+ return " · ".join(parts)
604
+
605
+
505
606
  def cmd_cars(args) -> None:
506
607
  payload = api_get(
507
608
  "/motor/pc/car/series/car_list",
@@ -510,27 +611,66 @@ def cmd_cars(args) -> None:
510
611
  if args.json:
511
612
  dump_json(payload)
512
613
  return
513
- tabs = (payload.get("data") or {}).get("tab_list") or []
514
- rows = []
515
- for tab in tabs:
614
+ filtering = bool(args.keyword or args.price or args.fuel)
615
+ bounds = parse_price_bounds(args.price) if args.price else None
616
+ texts = resolve_fuel_text(args.fuel) if args.fuel else None
617
+ fuels = fetch_car_fuels(args.series_id) if texts else {}
618
+ seen: set[str] = set()
619
+ rows: list[list[str]] = []
620
+ header: list[str] | None = None
621
+ section: list[list[str]] = []
622
+
623
+ def flush() -> None:
624
+ # 筛选模式下整段无匹配的章节连标题一起隐藏
625
+ if not filtering or section:
626
+ if header:
627
+ rows.append(header)
628
+ rows.extend(section)
629
+
630
+ for tab in (payload.get("data") or {}).get("tab_list") or []:
516
631
  status = tab.get("tab_text") or ""
517
632
  for entry in tab.get("data") or []:
518
633
  info = entry.get("info") or {}
519
- if entry.get("type") == "1068":
634
+ etype = entry.get("type")
635
+ if etype == "1068":
520
636
  continue
521
- if entry.get("type") == "1137":
522
- rows.append([f"[{status}] {info.get('name') or ''}", "", "", "", "", ""])
637
+ if etype == "1137":
638
+ flush()
639
+ header = [f"[{status}] {info.get('name') or ''}", "", "", "", "", ""]
640
+ section = []
523
641
  continue
524
- rows.append(
642
+ name = info.get("car_name") or info.get("name") or ""
643
+ car_id = str(info.get("car_id") or info.get("id") or "")
644
+ if filtering:
645
+ if args.keyword and not matches_keyword(name, args.keyword):
646
+ continue
647
+ if bounds and not matches_price(parse_wan(info.get("official_price")), bounds):
648
+ continue
649
+ if texts and not matches_fuel(fuels.get(car_id, ""), texts):
650
+ continue
651
+ # 同一车型会同时出现在「在售」与年款 tab,筛选时按 car_id 去重
652
+ if car_id and car_id in seen:
653
+ continue
654
+ if car_id:
655
+ seen.add(car_id)
656
+ section.append(
525
657
  [
526
- info.get("car_name") or info.get("name") or "",
658
+ name,
527
659
  str(info.get("year") or ""),
528
660
  info.get("official_price_str") or "",
529
661
  info.get("dealer_price") or "",
530
662
  info.get("owner_price") or "",
531
- str(info.get("car_id") or info.get("id") or ""),
663
+ car_id,
532
664
  ]
533
665
  )
666
+ flush()
667
+ if filtering:
668
+ matched = sum(1 for r in rows if r[5])
669
+ desc = filter_desc(args.keyword, args.price, args.fuel)
670
+ if matched:
671
+ print(f"筛选后 {matched} 款({desc})", file=sys.stderr)
672
+ else:
673
+ print(f"没有匹配的车型({desc})", file=sys.stderr)
534
674
  print(render_table(["车型", "年款", "官方指导价", "经销商价", "车主价", "车型ID"], rows))
535
675
 
536
676
 
@@ -560,51 +700,249 @@ def _spec_value(car: dict, prop: dict) -> str:
560
700
 
561
701
 
562
702
  def cmd_specs(args) -> None:
563
- car_ids = [cid for raw in args.car_id for cid in raw.split(",") if cid]
564
- payload = api_get(
565
- "/motor/pc/car/get_full_config_info",
566
- {"series_id": args.series_id, "car_ids": ",".join(car_ids)},
567
- )
568
- if args.json:
569
- dump_json(payload)
570
- return
571
- data = payload["data"]
572
- cars = data.get("car_info") or []
573
- if not cars:
574
- raise DcdError(f"未找到车型配置: {', '.join(car_ids)}")
575
- # 实测该接口忽略 car_ids,总是返回整个车系,这里按请求过滤
576
- by_id = {str(c.get("car_id")): c for c in cars}
577
- missing = [cid for cid in car_ids if cid not in by_id]
578
- if missing:
579
- raise DcdError(f"车系 {args.series_id} 下不存在车型: {', '.join(missing)}")
580
- cars = [by_id[cid] for cid in car_ids]
581
- headers = ["参数"] + [c.get("car_name") or str(c.get("car_id")) for c in cars]
582
- rows: list[list[str]] = []
583
- section = ""
584
- for prop in data.get("properties") or []:
585
- ptype = _ptype(prop)
586
- text = prop.get("text") or ""
587
- if ptype == 0:
588
- section = text
589
- if args.section and args.section in section:
590
- rows.append([f"◆ {text}", *[""] * len(cars)])
591
- continue
592
- if args.section and args.section not in section:
593
- continue
594
- if ptype == 2:
595
- rows.append([f"◇ {text}", *[""] * len(cars)])
596
- continue
597
- if ptype == 6:
598
- continue
599
- rows.append([text, *[_spec_value(car, prop) for car in cars]])
600
- if args.section and not rows:
601
- names = [
602
- p.get("text") or "" for p in data.get("properties") or [] if _ptype(p) == 0
703
+ if args.all and args.car_id:
704
+ raise DcdError("--all 与车型ID 不能同时使用")
705
+ if args.all and ":" in args.series_id:
706
+ raise DcdError("--all 模式下不能使用「车系:车型」格式")
707
+ if not args.all and (args.keyword or args.price or args.fuel):
708
+ raise DcdError(
709
+ "--keyword/--price/--fuel 需配合 --all 使用,如: dcd specs 26051 --all --fuel 纯电"
710
+ )
711
+
712
+ if args.all:
713
+ payload = api_get(
714
+ "/motor/pc/car/get_full_config_info",
715
+ {"series_id": args.series_id, "car_ids": ""},
716
+ )
717
+ if args.json:
718
+ dump_json(payload)
719
+ return
720
+ data = payload["data"]
721
+ cars = data.get("car_info") or []
722
+ bounds = parse_price_bounds(args.price) if args.price else None
723
+ texts = resolve_fuel_text(args.fuel) if args.fuel else None
724
+ cars = [
725
+ car
726
+ for car in cars
727
+ if (not args.keyword or matches_keyword(car.get("car_name") or "", args.keyword))
728
+ and (not bounds or matches_price(parse_wan(car.get("official_price")), bounds))
729
+ and (not texts or matches_fuel(car_energy(car), texts))
603
730
  ]
731
+ desc = filter_desc(args.keyword, args.price, args.fuel)
732
+ if not cars:
733
+ raise DcdError(f"全系没有匹配的车型({desc})")
734
+ print(f"全系 {len(cars)} 款" + (f"({desc})" if desc else ""), file=sys.stderr)
735
+ series_order = [args.series_id]
736
+ data_by_series = {args.series_id: data}
737
+ resolved = [(car, args.series_id) for car in cars]
738
+ else:
739
+ # 支持 车系ID:车型ID 配对(跨车系对比);首个位置参数无冒号时作为默认车系
740
+ specs: list[tuple[str, str]] = []
741
+ default_series: str | None = None
742
+ if ":" in args.series_id:
743
+ series, _, cid = args.series_id.partition(":")
744
+ if not series or not cid:
745
+ raise DcdError(f"无效的「车系:车型」格式: {args.series_id}")
746
+ specs.append((series, cid))
747
+ else:
748
+ default_series = args.series_id
749
+ for raw in args.car_id:
750
+ for token in raw.split(","):
751
+ if not token:
752
+ continue
753
+ if ":" in token:
754
+ series, _, cid = token.partition(":")
755
+ if not series or not cid:
756
+ raise DcdError(f"无效的「车系:车型」格式: {token}")
757
+ specs.append((series, cid))
758
+ elif default_series is None:
759
+ raise DcdError(
760
+ f"车型 {token} 缺少车系,请写作 车系:车型(如 26051:{token})"
761
+ )
762
+ else:
763
+ specs.append((default_series, token))
764
+ if not specs:
765
+ raise DcdError(
766
+ "需要车型ID(可多个,逗号分隔),或用 --all 对比全系可查配置车型;车型ID 用 dcd cars 查"
767
+ )
768
+
769
+ by_series: dict[str, list[str]] = {}
770
+ series_order: list[str] = []
771
+ for series, cid in specs:
772
+ if series not in by_series:
773
+ by_series[series] = []
774
+ series_order.append(series)
775
+ by_series[series].append(cid)
776
+ payloads = {
777
+ series: api_get(
778
+ "/motor/pc/car/get_full_config_info",
779
+ {"series_id": series, "car_ids": ",".join(by_series[series])},
780
+ )
781
+ for series in series_order
782
+ }
783
+ if args.json:
784
+ if len(series_order) == 1:
785
+ dump_json(payloads[series_order[0]])
786
+ else:
787
+ dump_json({series: payloads[series] for series in series_order})
788
+ return
789
+
790
+ data_by_series = {series: payloads[series]["data"] for series in series_order}
791
+ # 实测该接口忽略 car_ids,总是返回整个车系,这里按请求过滤
792
+ by_id_series: dict[str, dict[str, dict]] = {}
793
+ problems: list[str] = []
794
+ for series in series_order:
795
+ cars_s = data_by_series[series].get("car_info") or []
796
+ if not cars_s:
797
+ problems.append(f"未找到车型配置: {', '.join(by_series[series])}")
798
+ continue
799
+ by_id = {str(c.get("car_id")): c for c in cars_s}
800
+ by_id_series[series] = by_id
801
+ missing = [cid for cid in by_series[series] if cid not in by_id]
802
+ if missing:
803
+ problems.append(f"车系 {series} 下不存在车型: {', '.join(missing)}")
804
+ if problems:
805
+ raise DcdError(";".join(problems))
806
+ resolved = [(by_id_series[series][cid], series) for series, cid in specs]
807
+ if len(series_order) > 1:
808
+ print(f"跨车系对比:{'、'.join(series_order)}", file=sys.stderr)
809
+
810
+ cars = [car for car, _ in resolved]
811
+ if args.diff and len(cars) < 2:
812
+ raise DcdError(f"--diff 需要至少 2 款车型对比(当前 {len(cars)} 款)")
813
+ if len(series_order) > 1:
814
+ headers = ["参数"] + [
815
+ f"{car.get('series_name') or series} {car.get('car_name') or car.get('car_id')}"
816
+ for car, series in resolved
817
+ ]
818
+ else:
819
+ headers = ["参数"] + [c.get("car_name") or str(c.get("car_id")) for c in cars]
820
+
821
+ # 结构化为 章节 → 子章节 → 行;跨车系按 (key, text, 序号) 对齐,行序以先出现的车系为准
822
+ records: list[dict] = []
823
+ row_index: dict[tuple[tuple[str, str], int], dict] = {}
824
+ matched_section = False
825
+ for series in series_order:
826
+ cur_section: str | None = None
827
+ cur_sub: str | None = None
828
+ seen: dict[tuple[str, str], int] = {}
829
+ for prop in data_by_series[series].get("properties") or []:
830
+ ptype = _ptype(prop)
831
+ text = prop.get("text") or ""
832
+ if ptype == 0:
833
+ if args.section and args.section not in text:
834
+ cur_section = cur_sub = None
835
+ continue
836
+ matched_section = True
837
+ cur_section = text
838
+ cur_sub = None
839
+ continue
840
+ if cur_section is None and args.section:
841
+ continue
842
+ if ptype == 6:
843
+ continue
844
+ if cur_section is None:
845
+ cur_section = ""
846
+ if ptype == 2:
847
+ cur_sub = text
848
+ continue
849
+ if cur_sub is None:
850
+ cur_sub = ""
851
+ pair = (prop.get("key") or "", text)
852
+ ordinal = seen.get(pair, 0)
853
+ seen[pair] = ordinal + 1
854
+ ident = (pair, ordinal)
855
+ record = row_index.get(ident)
856
+ if record is None:
857
+ record = {
858
+ "section": cur_section,
859
+ "sub": cur_sub,
860
+ "text": text,
861
+ "props": {},
862
+ }
863
+ row_index[ident] = record
864
+ records.append(record)
865
+ if series not in record["props"]:
866
+ record["props"][series] = prop
867
+
868
+ sections: list[dict] = []
869
+ sec_map: dict[str, dict] = {}
870
+ sub_map: dict[tuple[str, str], dict] = {}
871
+ for record in records:
872
+ fallback = next(iter(record["props"].values()))
873
+ sec = sec_map.get(record["section"])
874
+ if sec is None:
875
+ sec = {"text": record["section"], "subs": []}
876
+ sec_map[record["section"]] = sec
877
+ sections.append(sec)
878
+ sub_key = (record["section"], record["sub"])
879
+ sub = sub_map.get(sub_key)
880
+ if sub is None:
881
+ sub = {"text": record["sub"], "rows": []}
882
+ sub_map[sub_key] = sub
883
+ sec["subs"].append(sub)
884
+ sub["rows"].append(
885
+ [
886
+ record["text"],
887
+ *[
888
+ _spec_value(car, record["props"].get(series) or fallback)
889
+ for car, series in resolved
890
+ ],
891
+ ]
892
+ )
893
+
894
+ rows: list[list[str]] = []
895
+ if args.section and not matched_section:
896
+ names: list[str] = []
897
+ for series in series_order:
898
+ for prop in data_by_series[series].get("properties") or []:
899
+ text = prop.get("text") or ""
900
+ if _ptype(prop) == 0 and text not in names:
901
+ names.append(text)
604
902
  print(
605
903
  f"提示: 没有章节匹配 {args.section!r},可用章节: {'、'.join(names)}",
606
904
  file=sys.stderr,
607
905
  )
906
+ elif args.diff:
907
+ # 差异模式:取值去重后 >1 才算差异行,无差异的章节/子章节整段省略
908
+ total = sum(len(sub["rows"]) for sec in sections for sub in sec["subs"])
909
+ kept = 0
910
+ for sec in sections:
911
+ sec_rows: list[list[str]] = []
912
+ for sub in sec["subs"]:
913
+ diff_rows = [r for r in sub["rows"] if len(set(r[1:])) > 1]
914
+ if not diff_rows:
915
+ continue
916
+ if sub["text"]:
917
+ sec_rows.append([f"◇ {sub['text']}", *[""] * len(cars)])
918
+ sec_rows.extend(diff_rows)
919
+ kept += len(diff_rows)
920
+ if not sec_rows:
921
+ continue
922
+ if sec["text"]:
923
+ rows.append([f"◆ {sec['text']}", *[""] * len(cars)])
924
+ rows.extend(sec_rows)
925
+ if kept:
926
+ print(
927
+ f"差异 {kept} 项 / 共 {total} 项(已隐藏相同 {total - kept} 项)",
928
+ file=sys.stderr,
929
+ )
930
+ else:
931
+ print(
932
+ f"未发现差异:{len(cars)} 款车型配置完全一致(共 {total} 项)",
933
+ file=sys.stderr,
934
+ )
935
+ else:
936
+ for sec in sections:
937
+ if sec["text"]:
938
+ rows.append([f"◆ {sec['text']}", *[""] * len(cars)])
939
+ for sub in sec["subs"]:
940
+ if sub["text"]:
941
+ rows.append([f"◇ {sub['text']}", *[""] * len(cars)])
942
+ rows.extend(sub["rows"])
943
+
944
+ if args.diff and not rows:
945
+ return
608
946
  print(render_table(headers, rows))
609
947
 
610
948
 
@@ -912,15 +1250,40 @@ def build_parser() -> argparse.ArgumentParser:
912
1250
  p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
913
1251
  p.set_defaults(func=cmd_filter)
914
1252
 
915
- p = sub.add_parser("cars", help="车系下的车型(年款/价格)")
1253
+ p = sub.add_parser("cars", help="车系下的车型(年款/价格),可按条件筛选")
916
1254
  p.add_argument("series_id", help="车系ID")
1255
+ p.add_argument("-k", "--keyword", help="按车型名筛选(不区分大小写),如 700、max")
1256
+ p.add_argument("--price", help='按指导价筛选(万),如 "15,17";一侧留空表示不限')
1257
+ p.add_argument(
1258
+ "-f", "--fuel", help="按能源筛选:纯电/增程/插混/汽油/柴油/油电混动/新能源/轻混"
1259
+ )
917
1260
  p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
918
1261
  p.set_defaults(func=cmd_cars)
919
1262
 
920
- p = sub.add_parser("specs", help="车型参数配置表(可多车对比)")
921
- p.add_argument("series_id", help="车系ID")
922
- p.add_argument("car_id", nargs="+", help="车型ID,可多个(用 dcd cars 查)")
1263
+ p = sub.add_parser(
1264
+ "specs", help="车型参数配置表(按分类对比,支持 车系:车型 跨车系;-d 只看差异,--all 对比全系)"
1265
+ )
1266
+ p.add_argument("series_id", help="车系ID;跨车系可直接写 车系ID:车型ID")
1267
+ p.add_argument(
1268
+ "car_id",
1269
+ nargs="*",
1270
+ help="车型ID(可多个,逗号分隔;用 dcd cars 查),支持 车系ID:车型ID 跨车系配对;或用 --all 对比全系",
1271
+ )
1272
+ p.add_argument(
1273
+ "--all", action="store_true", help="对比全系可查配置车型,可叠加 --keyword/--price/--fuel"
1274
+ )
1275
+ p.add_argument("-k", "--keyword", help="按车型名筛选(需 --all),如 Max、四驱")
1276
+ p.add_argument("--price", help='按指导价筛选(需 --all,万),如 "15,17"')
1277
+ p.add_argument(
1278
+ "-f", "--fuel", help="按能源筛选(需 --all):纯电/增程/插混/汽油/油电混动/新能源/轻混"
1279
+ )
923
1280
  p.add_argument("-s", "--section", help="只看包含该文字的章节,如 电池")
1281
+ p.add_argument(
1282
+ "-d",
1283
+ "--diff",
1284
+ action="store_true",
1285
+ help="只显示各车型取值不同的配置项(至少 2 款车型,无差异章节自动省略)",
1286
+ )
924
1287
  p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
925
1288
  p.set_defaults(func=cmd_specs)
926
1289