dongchedi-cli 0.1.0__py3-none-any.whl
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.
dongchedi_cli/cli.py
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
"""dcd 命令行入口。
|
|
2
|
+
|
|
3
|
+
免登录子命令:rank / brands / series / cars / specs / reviews / search / dealer
|
|
4
|
+
需登录态子命令:series-info(HTML SSR)/ me(账号信息)
|
|
5
|
+
|
|
6
|
+
登录态来源(auth-import 写入,或 --cookie-file / DCD_COOKIE_FILE 指定):
|
|
7
|
+
dcd auth-import <playwright-state.json> # playwright-cli state-save 的产物
|
|
8
|
+
|
|
9
|
+
约定:
|
|
10
|
+
- 表格结果走 stdout,进度与提示走 stderr,可安全重定向
|
|
11
|
+
- 所有子命令支持 --json 输出原始接口数据
|
|
12
|
+
- 退出码:0 成功 / 1 接口或网络错误
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import unicodedata
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.parse
|
|
26
|
+
import urllib.request
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
|
|
31
|
+
BASE = "https://www.dongchedi.com"
|
|
32
|
+
COMMON = {"aid": "1839", "app_name": "auto_web_pc"}
|
|
33
|
+
UA = (
|
|
34
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
35
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
36
|
+
)
|
|
37
|
+
TIMEOUT = 20
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DcdError(RuntimeError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------- 登录态 ----------
|
|
45
|
+
|
|
46
|
+
DEFAULT_COOKIE_FILE = Path.home() / ".config" / "dcd" / "cookies.txt"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_cookies(explicit: str | None) -> dict[str, str]:
|
|
50
|
+
"""读取 Netscape 格式 Cookie 文件,返回 name -> value。"""
|
|
51
|
+
if explicit:
|
|
52
|
+
path = Path(explicit).expanduser()
|
|
53
|
+
elif os.environ.get("DCD_COOKIE_FILE"):
|
|
54
|
+
path = Path(os.environ["DCD_COOKIE_FILE"]).expanduser()
|
|
55
|
+
else:
|
|
56
|
+
path = DEFAULT_COOKIE_FILE
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
raise DcdError(
|
|
59
|
+
f"缺少登录态 Cookie: {path} 不存在;"
|
|
60
|
+
"先运行 `dcd auth-import <playwright-state.json>` 导入,"
|
|
61
|
+
"或用 --cookie-file / DCD_COOKIE_FILE 指定"
|
|
62
|
+
)
|
|
63
|
+
cookies: dict[str, str] = {}
|
|
64
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
65
|
+
line = line.rstrip("\r")
|
|
66
|
+
# curl 等导出的 Netscape 文件用 "#HttpOnly_" 前缀标记 HttpOnly cookie
|
|
67
|
+
if line.startswith("#HttpOnly_"):
|
|
68
|
+
line = line[len("#HttpOnly_") :]
|
|
69
|
+
elif not line or line.startswith("#"):
|
|
70
|
+
continue
|
|
71
|
+
fields = line.split("\t")
|
|
72
|
+
if len(fields) >= 7 and fields[5]:
|
|
73
|
+
cookies[fields[5]] = fields[6]
|
|
74
|
+
if not cookies:
|
|
75
|
+
raise DcdError(f"Cookie 文件没有有效条目: {path}")
|
|
76
|
+
return cookies
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cookie_header(cookies: dict[str, str]) -> str:
|
|
80
|
+
# urllib 请求头按 latin-1 编码,这里转成裸 UTF-8 字节(与浏览器/curl 线上行为一致)
|
|
81
|
+
raw = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
|
82
|
+
return raw.encode("utf-8").decode("latin-1")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------- HTTP ----------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def api_get(
|
|
89
|
+
path: str, params: dict, referer: str | None = None, cookies: dict[str, str] | None = None
|
|
90
|
+
) -> dict:
|
|
91
|
+
url = f"{BASE}{path}?{urllib.parse.urlencode(params)}"
|
|
92
|
+
headers = {"User-Agent": UA, "Accept": "application/json, text/plain, */*"}
|
|
93
|
+
if referer:
|
|
94
|
+
headers["Referer"] = referer
|
|
95
|
+
if cookies:
|
|
96
|
+
headers["Cookie"] = cookie_header(cookies)
|
|
97
|
+
return _fetch(url, headers)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def api_get_html(path: str, cookies: dict[str, str]) -> tuple[str, str]:
|
|
101
|
+
"""请求 HTML 页面,返回 (html, 最终 URL)。"""
|
|
102
|
+
url = f"{BASE}{path}"
|
|
103
|
+
headers = {
|
|
104
|
+
"User-Agent": UA,
|
|
105
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
106
|
+
"Cookie": cookie_header(cookies),
|
|
107
|
+
"Referer": f"{BASE}/",
|
|
108
|
+
}
|
|
109
|
+
req = urllib.request.Request(url, headers=headers)
|
|
110
|
+
try:
|
|
111
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
112
|
+
return resp.read().decode("utf-8", errors="replace"), resp.geturl()
|
|
113
|
+
except urllib.error.HTTPError as exc:
|
|
114
|
+
raise DcdError(f"HTTP {exc.code}: {url}") from exc
|
|
115
|
+
except urllib.error.URLError as exc:
|
|
116
|
+
raise DcdError(f"网络错误: {exc.reason}") from exc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def extract_next_data(html: str, final_url: str) -> dict:
|
|
120
|
+
"""从页面 __NEXT_DATA__ 脚本块提取 pageProps。"""
|
|
121
|
+
if "/login-required" in final_url:
|
|
122
|
+
raise DcdError(
|
|
123
|
+
"登录态失效或未导入:运行 `dcd auth-import <playwright-state.json>`;"
|
|
124
|
+
"注意 Cookie 需来自已登录懂车帝的浏览器"
|
|
125
|
+
)
|
|
126
|
+
match = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
|
127
|
+
if not match:
|
|
128
|
+
raise DcdError("页面结构异常:未找到 __NEXT_DATA__(可能被风控或页面改版)")
|
|
129
|
+
return (json.loads(match.group(1)).get("props") or {}).get("pageProps") or {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def api_post(path: str, data: dict) -> dict:
|
|
133
|
+
url = f"{BASE}{path}?{urllib.parse.urlencode(COMMON)}"
|
|
134
|
+
headers = {
|
|
135
|
+
"User-Agent": UA,
|
|
136
|
+
"Accept": "application/json, text/plain, */*",
|
|
137
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
138
|
+
}
|
|
139
|
+
return _fetch(url, headers, urllib.parse.urlencode(data).encode())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _fetch(url: str, headers: dict, body: bytes | None = None) -> dict:
|
|
143
|
+
req = urllib.request.Request(url, data=body, headers=headers)
|
|
144
|
+
try:
|
|
145
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
146
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
147
|
+
except urllib.error.HTTPError as exc:
|
|
148
|
+
raise DcdError(f"HTTP {exc.code}: {url}") from exc
|
|
149
|
+
except urllib.error.URLError as exc:
|
|
150
|
+
raise DcdError(f"网络错误: {exc.reason}") from exc
|
|
151
|
+
except json.JSONDecodeError as exc:
|
|
152
|
+
raise DcdError(f"响应不是 JSON(可能被风控拦截): {url}") from exc
|
|
153
|
+
if payload.get("status"):
|
|
154
|
+
msg = payload.get("prompts") or payload.get("message") or ""
|
|
155
|
+
raise DcdError(f"接口错误 status={payload['status']} {msg}")
|
|
156
|
+
return payload
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------- 输出 ----------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _width(text: str) -> int:
|
|
163
|
+
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pad(text: str, width: int) -> str:
|
|
167
|
+
return text + " " * max(0, width - _width(text))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def clip(text: str, limit: int) -> str:
|
|
171
|
+
if _width(text) <= limit:
|
|
172
|
+
return text
|
|
173
|
+
out, w = "", 0
|
|
174
|
+
for ch in text:
|
|
175
|
+
w += 2 if unicodedata.east_asian_width(ch) in "WF" else 1
|
|
176
|
+
if w > limit - 1:
|
|
177
|
+
break
|
|
178
|
+
out += ch
|
|
179
|
+
return out + "…"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def render_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
183
|
+
widths = [_width(h) for h in headers]
|
|
184
|
+
for row in rows:
|
|
185
|
+
for i, cell in enumerate(row[: len(headers)]):
|
|
186
|
+
widths[i] = max(widths[i], _width(str(cell)))
|
|
187
|
+
lines = [" ".join(_pad(h, widths[i]) for i, h in enumerate(headers)).rstrip()]
|
|
188
|
+
lines.append(" ".join("-" * w for w in widths))
|
|
189
|
+
for row in rows:
|
|
190
|
+
lines.append(
|
|
191
|
+
" ".join(
|
|
192
|
+
_pad(str(row[i]) if i < len(row) else "", widths[i])
|
|
193
|
+
for i in range(len(headers))
|
|
194
|
+
).rstrip()
|
|
195
|
+
)
|
|
196
|
+
return "\n".join(lines)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def dump_json(payload) -> None:
|
|
200
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def price_span(low, high) -> str:
|
|
204
|
+
if low is None and high is None:
|
|
205
|
+
return "-"
|
|
206
|
+
if low == high or high is None:
|
|
207
|
+
return f"{low:g}万"
|
|
208
|
+
if low is None:
|
|
209
|
+
return f"{high:g}万"
|
|
210
|
+
return f"{low:g}-{high:g}万"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------- 子命令 ----------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_rank(args) -> None:
|
|
217
|
+
# --local 城市榜:rank_data_type=64,销量=本地新车上牌量;全国无此榜
|
|
218
|
+
if args.local and args.city == "全国":
|
|
219
|
+
raise DcdError("城市榜需要指定城市,如: dcd rank --local -c 杭州")
|
|
220
|
+
payload = api_get(
|
|
221
|
+
"/motor/pc/car/rank_data",
|
|
222
|
+
{
|
|
223
|
+
**COMMON,
|
|
224
|
+
"city_name": args.city,
|
|
225
|
+
"count": args.count,
|
|
226
|
+
"offset": args.offset,
|
|
227
|
+
"rank_data_type": 64 if args.local else args.type,
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
if args.json:
|
|
231
|
+
dump_json(payload)
|
|
232
|
+
return
|
|
233
|
+
data = payload["data"]
|
|
234
|
+
total = (data.get("paging") or {}).get("total")
|
|
235
|
+
if args.local:
|
|
236
|
+
print(
|
|
237
|
+
f"{args.city} 城市榜(数据基于本地新车上牌量"
|
|
238
|
+
+ (f",共 {total} 款车系" if total else "")
|
|
239
|
+
+ ")",
|
|
240
|
+
file=sys.stderr,
|
|
241
|
+
)
|
|
242
|
+
items = data["list"]
|
|
243
|
+
rows = [
|
|
244
|
+
[
|
|
245
|
+
str(it.get("rank") or ""),
|
|
246
|
+
it.get("series_name") or "",
|
|
247
|
+
it.get("brand_name") or "",
|
|
248
|
+
str(it.get("count") or ""),
|
|
249
|
+
price_span(it.get("min_price"), it.get("max_price")),
|
|
250
|
+
str(it.get("series_id") or ""),
|
|
251
|
+
]
|
|
252
|
+
for it in items
|
|
253
|
+
]
|
|
254
|
+
print(
|
|
255
|
+
render_table(
|
|
256
|
+
["排名", "车系", "品牌", "本地销量" if args.local else "销量", "指导价", "车系ID"],
|
|
257
|
+
rows,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cmd_brands(args) -> None:
|
|
263
|
+
payload = api_get(
|
|
264
|
+
"/motor/brand/v4/brand/",
|
|
265
|
+
{
|
|
266
|
+
"hot_brand": 0,
|
|
267
|
+
"no_sales": 1,
|
|
268
|
+
"is_config": 0,
|
|
269
|
+
"version_code": 476,
|
|
270
|
+
"version_name": "4.7.6",
|
|
271
|
+
**COMMON,
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
if args.json:
|
|
275
|
+
dump_json(payload)
|
|
276
|
+
return
|
|
277
|
+
brands = []
|
|
278
|
+
for group in payload.get("data") or []:
|
|
279
|
+
info = group.get("info") or {}
|
|
280
|
+
if info.get("brand_id"):
|
|
281
|
+
brands.append(info)
|
|
282
|
+
brands.extend(info.get("brand") or [])
|
|
283
|
+
if args.filter:
|
|
284
|
+
needle = args.filter
|
|
285
|
+
brands = [
|
|
286
|
+
b
|
|
287
|
+
for b in brands
|
|
288
|
+
if needle in (b.get("brand_name") or "")
|
|
289
|
+
or needle.upper() == (b.get("pinyin") or "").upper()
|
|
290
|
+
]
|
|
291
|
+
rows = [
|
|
292
|
+
[str(b.get("brand_id") or ""), b.get("brand_name") or "", b.get("pinyin") or ""]
|
|
293
|
+
for b in brands
|
|
294
|
+
]
|
|
295
|
+
print(render_table(["品牌ID", "品牌", "首字母"], rows))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_series(args) -> None:
|
|
299
|
+
# 参数名是 brand(不是 brand_id):传 brand_id 会被服务端忽略,返回全站热门车系
|
|
300
|
+
payload = api_post(
|
|
301
|
+
"/motor/pc/car/brand/select_series_v2",
|
|
302
|
+
{"brand": args.brand_id, "sort_new": "hot_desc"},
|
|
303
|
+
)
|
|
304
|
+
if args.json:
|
|
305
|
+
dump_json(payload)
|
|
306
|
+
return
|
|
307
|
+
series = (payload.get("data") or {}).get("series") or []
|
|
308
|
+
rows = [
|
|
309
|
+
[
|
|
310
|
+
str(s.get("id") or ""),
|
|
311
|
+
s.get("outter_name") or "",
|
|
312
|
+
s.get("official_price") or "-",
|
|
313
|
+
s.get("dealer_price") or "-",
|
|
314
|
+
str(s.get("count") or ""),
|
|
315
|
+
f"{s['dcar_score']:.2f}" if s.get("dcar_score") else "-",
|
|
316
|
+
str(s.get("brand_name") or ""),
|
|
317
|
+
]
|
|
318
|
+
for s in series
|
|
319
|
+
]
|
|
320
|
+
print(
|
|
321
|
+
render_table(
|
|
322
|
+
["车系ID", "车系", "指导价", "经销商价", "在售车型数", "评分", "品牌"], rows
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cmd_cars(args) -> None:
|
|
328
|
+
payload = api_get(
|
|
329
|
+
"/motor/pc/car/series/car_list",
|
|
330
|
+
{**COMMON, "series_id": args.series_id},
|
|
331
|
+
)
|
|
332
|
+
if args.json:
|
|
333
|
+
dump_json(payload)
|
|
334
|
+
return
|
|
335
|
+
tabs = (payload.get("data") or {}).get("tab_list") or []
|
|
336
|
+
rows = []
|
|
337
|
+
for tab in tabs:
|
|
338
|
+
status = tab.get("tab_text") or ""
|
|
339
|
+
for entry in tab.get("data") or []:
|
|
340
|
+
info = entry.get("info") or {}
|
|
341
|
+
if entry.get("type") == "1068":
|
|
342
|
+
continue
|
|
343
|
+
if entry.get("type") == "1137":
|
|
344
|
+
rows.append([f"[{status}] {info.get('name') or ''}", "", "", "", "", ""])
|
|
345
|
+
continue
|
|
346
|
+
rows.append(
|
|
347
|
+
[
|
|
348
|
+
info.get("car_name") or info.get("name") or "",
|
|
349
|
+
str(info.get("year") or ""),
|
|
350
|
+
info.get("official_price_str") or "",
|
|
351
|
+
info.get("dealer_price") or "",
|
|
352
|
+
info.get("owner_price") or "",
|
|
353
|
+
str(info.get("car_id") or info.get("id") or ""),
|
|
354
|
+
]
|
|
355
|
+
)
|
|
356
|
+
print(render_table(["车型", "年款", "官方指导价", "经销商价", "车主价", "车型ID"], rows))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _ptype(prop: dict) -> int:
|
|
360
|
+
# type 字段时而为 int 时而为 str(服务端 A/B),统一归一化
|
|
361
|
+
try:
|
|
362
|
+
return int(prop.get("type"))
|
|
363
|
+
except (TypeError, ValueError):
|
|
364
|
+
return -1
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _spec_value(car: dict, prop: dict) -> str:
|
|
368
|
+
info = (car.get("info") or {})
|
|
369
|
+
subs = prop.get("sub_list") or []
|
|
370
|
+
if _ptype(prop) == 3 and subs:
|
|
371
|
+
labels = [
|
|
372
|
+
(s.get("text") or "")
|
|
373
|
+
for s in subs
|
|
374
|
+
if (info.get(s.get("key")) or {}).get("value")
|
|
375
|
+
]
|
|
376
|
+
return "、".join(labels) if labels else "-"
|
|
377
|
+
cell = info.get(prop.get("key")) or {}
|
|
378
|
+
value = (cell.get("value") or "").strip()
|
|
379
|
+
if value:
|
|
380
|
+
return value
|
|
381
|
+
return {1: "标配", 2: "选装"}.get(cell.get("icon_type"), "-")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def cmd_specs(args) -> None:
|
|
385
|
+
car_ids = [cid for raw in args.car_id for cid in raw.split(",") if cid]
|
|
386
|
+
payload = api_get(
|
|
387
|
+
"/motor/pc/car/get_full_config_info",
|
|
388
|
+
{"series_id": args.series_id, "car_ids": ",".join(car_ids)},
|
|
389
|
+
)
|
|
390
|
+
if args.json:
|
|
391
|
+
dump_json(payload)
|
|
392
|
+
return
|
|
393
|
+
data = payload["data"]
|
|
394
|
+
cars = data.get("car_info") or []
|
|
395
|
+
if not cars:
|
|
396
|
+
raise DcdError(f"未找到车型配置: {', '.join(car_ids)}")
|
|
397
|
+
# 实测该接口忽略 car_ids,总是返回整个车系,这里按请求过滤
|
|
398
|
+
by_id = {str(c.get("car_id")): c for c in cars}
|
|
399
|
+
missing = [cid for cid in car_ids if cid not in by_id]
|
|
400
|
+
if missing:
|
|
401
|
+
raise DcdError(f"车系 {args.series_id} 下不存在车型: {', '.join(missing)}")
|
|
402
|
+
cars = [by_id[cid] for cid in car_ids]
|
|
403
|
+
headers = ["参数"] + [c.get("car_name") or str(c.get("car_id")) for c in cars]
|
|
404
|
+
rows: list[list[str]] = []
|
|
405
|
+
section = ""
|
|
406
|
+
for prop in data.get("properties") or []:
|
|
407
|
+
ptype = _ptype(prop)
|
|
408
|
+
text = prop.get("text") or ""
|
|
409
|
+
if ptype == 0:
|
|
410
|
+
section = text
|
|
411
|
+
if args.section and args.section in section:
|
|
412
|
+
rows.append([f"◆ {text}", *[""] * len(cars)])
|
|
413
|
+
continue
|
|
414
|
+
if args.section and args.section not in section:
|
|
415
|
+
continue
|
|
416
|
+
if ptype == 2:
|
|
417
|
+
rows.append([f"◇ {text}", *[""] * len(cars)])
|
|
418
|
+
continue
|
|
419
|
+
if ptype == 6:
|
|
420
|
+
continue
|
|
421
|
+
rows.append([text, *[_spec_value(car, prop) for car in cars]])
|
|
422
|
+
if args.section and not rows:
|
|
423
|
+
names = [
|
|
424
|
+
p.get("text") or "" for p in data.get("properties") or [] if _ptype(p) == 0
|
|
425
|
+
]
|
|
426
|
+
print(
|
|
427
|
+
f"提示: 没有章节匹配 {args.section!r},可用章节: {'、'.join(names)}",
|
|
428
|
+
file=sys.stderr,
|
|
429
|
+
)
|
|
430
|
+
print(render_table(headers, rows))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def cmd_reviews(args) -> None:
|
|
434
|
+
payload = api_get(
|
|
435
|
+
"/motor/pc/car/series/get_review_list",
|
|
436
|
+
{
|
|
437
|
+
"series_id": args.series_id,
|
|
438
|
+
"page": args.page,
|
|
439
|
+
"size": args.size,
|
|
440
|
+
"city_name": args.city,
|
|
441
|
+
"sort_by": args.sort,
|
|
442
|
+
},
|
|
443
|
+
referer=f"{BASE}/auto/series/score/{args.series_id}",
|
|
444
|
+
)
|
|
445
|
+
if args.json:
|
|
446
|
+
dump_json(payload)
|
|
447
|
+
return
|
|
448
|
+
data = payload.get("data") or {}
|
|
449
|
+
reviews = data.get("review_list") or []
|
|
450
|
+
print(
|
|
451
|
+
f"共 {data.get('total_count', '?')} 条评论,第 {args.page} 页 {len(reviews)} 条",
|
|
452
|
+
file=sys.stderr,
|
|
453
|
+
)
|
|
454
|
+
rows = []
|
|
455
|
+
for review in reviews:
|
|
456
|
+
buy = review.get("buy_car_info") or {}
|
|
457
|
+
score = ((review.get("score_info") or {}).get("score") or 0) / 100
|
|
458
|
+
ts = review.get("create_time")
|
|
459
|
+
rows.append(
|
|
460
|
+
[
|
|
461
|
+
time.strftime("%Y-%m-%d", time.localtime(ts)) if ts else "-",
|
|
462
|
+
(review.get("user_info") or {}).get("name") or "",
|
|
463
|
+
buy.get("car_name") or review.get("car_name") or "",
|
|
464
|
+
buy.get("price") or "",
|
|
465
|
+
f"{score:.2f}" if score else "-",
|
|
466
|
+
clip((review.get("content") or "").replace("\n", " "), 60),
|
|
467
|
+
str(review.get("digg_count_en") or "0"),
|
|
468
|
+
]
|
|
469
|
+
)
|
|
470
|
+
print(render_table(["日期", "用户", "车型", "成交价", "评分", "内容", "赞"], rows))
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def cmd_search(args) -> None:
|
|
474
|
+
payload = api_get(
|
|
475
|
+
"/motor/searchapi/search_content",
|
|
476
|
+
{"keyword": args.keyword, "cur_tab": 2, "count": args.count, **COMMON},
|
|
477
|
+
)
|
|
478
|
+
if args.json:
|
|
479
|
+
dump_json(payload)
|
|
480
|
+
return
|
|
481
|
+
items = payload.get("data") or []
|
|
482
|
+
series = [it for it in items if it.get("cell_type") == 5002]
|
|
483
|
+
if series:
|
|
484
|
+
rows = [
|
|
485
|
+
[
|
|
486
|
+
str((it.get("display") or {}).get("series_id") or ""),
|
|
487
|
+
(it.get("display") or {}).get("series_name") or "",
|
|
488
|
+
]
|
|
489
|
+
for it in series
|
|
490
|
+
]
|
|
491
|
+
print(render_table(["车系ID", "车系"], rows))
|
|
492
|
+
return
|
|
493
|
+
print("未返回车系卡片(该词命中内容较多),以下为内容结果:", file=sys.stderr)
|
|
494
|
+
rows = [
|
|
495
|
+
[
|
|
496
|
+
(it.get("group_cell") or {}).get("title") or it.get("title") or "",
|
|
497
|
+
str(it.get("cell_type") or ""),
|
|
498
|
+
]
|
|
499
|
+
for it in items
|
|
500
|
+
]
|
|
501
|
+
print(render_table(["标题", "类型"], rows))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def cmd_dealer(args) -> None:
|
|
505
|
+
car_ids = [cid for raw in args.car_id for cid in raw.split(",") if cid]
|
|
506
|
+
payload = api_get(
|
|
507
|
+
"/motor/pc/car/series/car_dealer_price",
|
|
508
|
+
{**COMMON, "car_ids": ",".join(car_ids), "city_name": args.city},
|
|
509
|
+
)
|
|
510
|
+
if args.json:
|
|
511
|
+
dump_json(payload)
|
|
512
|
+
return
|
|
513
|
+
data = payload.get("data") or {}
|
|
514
|
+
order = [cid for cid in car_ids if cid in data] + [
|
|
515
|
+
cid for cid in data if cid not in car_ids
|
|
516
|
+
]
|
|
517
|
+
rows = []
|
|
518
|
+
for cid in order:
|
|
519
|
+
it = data[cid]
|
|
520
|
+
rows.append(
|
|
521
|
+
[
|
|
522
|
+
it.get("car_name") or "",
|
|
523
|
+
str(it.get("year") or ""),
|
|
524
|
+
it.get("official_price") or "-",
|
|
525
|
+
it.get("dealer_price") if it.get("has_dealer_price") else "暂无报价",
|
|
526
|
+
it.get("cut_price") if it.get("has_cut_price") else "-",
|
|
527
|
+
it.get("series_name") or "",
|
|
528
|
+
cid,
|
|
529
|
+
]
|
|
530
|
+
)
|
|
531
|
+
print(f"{args.city} 经销商报价:", file=sys.stderr)
|
|
532
|
+
print(render_table(["车型", "年款", "指导价", "经销商价", "优惠", "车系", "车型ID"], rows))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def cmd_series_info(args) -> None:
|
|
536
|
+
cookies = load_cookies(args.cookie_file)
|
|
537
|
+
if args.city:
|
|
538
|
+
# 该 Cookie 按百分号编码存储(浏览器即为 %E6%9D%AD%E5%B7%9E 形式)
|
|
539
|
+
cookies["city_name"] = urllib.parse.quote(args.city)
|
|
540
|
+
# 该 HTML 路由有无视 Cookie 的 CDN 缓存(会串城市),加随机参数强制回源
|
|
541
|
+
path = f"/auto/series/{args.series_id}?_r={int(time.time() * 1000)}"
|
|
542
|
+
html, final_url = api_get_html(path, cookies)
|
|
543
|
+
pp = extract_next_data(html, final_url)
|
|
544
|
+
|
|
545
|
+
head = pp.get("seriesHomeHead") or {}
|
|
546
|
+
scores = pp.get("scoreSimpleInfo") or {}
|
|
547
|
+
card = pp.get("carPriceCard") or {}
|
|
548
|
+
dealers = pp.get("allDealerList") or []
|
|
549
|
+
|
|
550
|
+
def score_name(v) -> str:
|
|
551
|
+
return f"{v / 100:.2f}" if isinstance(v, (int, float)) and v else "-"
|
|
552
|
+
|
|
553
|
+
overview = [
|
|
554
|
+
["品牌", f"{head.get('brand_name') or '-'} ({head.get('brand_id') or '?'})"],
|
|
555
|
+
["车系", f"{head.get('series_name') or pp.get('seriesName') or '-'} ({pp.get('seriesId') or args.series_id})"],
|
|
556
|
+
["厂商指导价", head.get("official_price") or "-"],
|
|
557
|
+
["经销商价", head.get("dealer_price") or "-"],
|
|
558
|
+
["懂车分", score_name(head.get("total_score"))],
|
|
559
|
+
["口碑数", str(head.get("total_review_count") or "-")],
|
|
560
|
+
]
|
|
561
|
+
score_rows = [
|
|
562
|
+
[label, score_name(scores.get(key))]
|
|
563
|
+
for label, key in [
|
|
564
|
+
("总分", "score"),
|
|
565
|
+
("外观", "appearance_score"),
|
|
566
|
+
("内饰", "interiors_score"),
|
|
567
|
+
("空间", "space_score"),
|
|
568
|
+
("舒适", "comfort_score"),
|
|
569
|
+
("配置", "configuration_score"),
|
|
570
|
+
("动力", "power_score"),
|
|
571
|
+
("操控", "control_score"),
|
|
572
|
+
]
|
|
573
|
+
if key in scores
|
|
574
|
+
]
|
|
575
|
+
price_rows = [
|
|
576
|
+
["车款", f"{card.get('car_name') or '-'}({card.get('year') or '?'} 款)"],
|
|
577
|
+
["官方指导价", card.get("official_price") or "-"],
|
|
578
|
+
["裸车均价", card.get("naked_price_avg") or "-"],
|
|
579
|
+
["全款均价", card.get("full_price_avg") or "-"],
|
|
580
|
+
["最新车主价", f"{card.get('latest_owner_price') or '-'}({card.get('latest_owner_city_name') or '?'})" if card.get("has_latest_owner_price") else "-"],
|
|
581
|
+
["价格样本数", str(card.get("price_num") or "-")],
|
|
582
|
+
["累计询价", f"{card.get('query_price_count_en')}人" if card.get("query_price_count_en") else "-"],
|
|
583
|
+
]
|
|
584
|
+
dealer_rows = [
|
|
585
|
+
[
|
|
586
|
+
(d.get("dealer_name") or "").strip(),
|
|
587
|
+
d.get("dealer_price") or "-",
|
|
588
|
+
"/".join((l.get("text") or "").strip() for l in d.get("label_list") or []),
|
|
589
|
+
"、".join((p.get("text") or "").strip() for p in d.get("promotion_list") or []),
|
|
590
|
+
clip((d.get("address") or "").strip(), 42),
|
|
591
|
+
]
|
|
592
|
+
for d in dealers
|
|
593
|
+
]
|
|
594
|
+
|
|
595
|
+
if args.json:
|
|
596
|
+
dump_json(
|
|
597
|
+
{
|
|
598
|
+
"overview": dict(overview),
|
|
599
|
+
"scores": dict(score_rows),
|
|
600
|
+
"price_card": card,
|
|
601
|
+
"dealers": dealers,
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
sections = args.section or ["overview", "scores", "price", "dealers"]
|
|
607
|
+
if "overview" in sections:
|
|
608
|
+
print("【概览】")
|
|
609
|
+
print(render_table(["项目", "值"], overview))
|
|
610
|
+
print()
|
|
611
|
+
if "scores" in sections:
|
|
612
|
+
print("【懂车分】")
|
|
613
|
+
print(render_table(["维度", "得分"], score_rows) if score_rows else "(无数据)")
|
|
614
|
+
print()
|
|
615
|
+
if "price" in sections:
|
|
616
|
+
print("【价格卡】")
|
|
617
|
+
print(render_table(["项目", "值"], price_rows))
|
|
618
|
+
print()
|
|
619
|
+
if "dealers" in sections:
|
|
620
|
+
print(f"【经销商门店 {len(dealer_rows)} 家】")
|
|
621
|
+
print(render_table(["门店", "起价", "标签", "优惠", "地址"], dealer_rows))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def cmd_me(args) -> None:
|
|
625
|
+
cookies = load_cookies(args.cookie_file)
|
|
626
|
+
payload = api_get("/passport/account/info/v2/", {}, cookies=cookies)
|
|
627
|
+
data = payload.get("data") or {}
|
|
628
|
+
if data.get("error_code"):
|
|
629
|
+
raise DcdError(
|
|
630
|
+
f"账号接口返回错误 {data['error_code']}: {data.get('description') or ''}"
|
|
631
|
+
"(登录态可能已过期,重新登录后 state-save + auth-import)"
|
|
632
|
+
)
|
|
633
|
+
if args.json:
|
|
634
|
+
dump_json(payload)
|
|
635
|
+
return
|
|
636
|
+
rows = [
|
|
637
|
+
["用户名", data.get("screen_name") or data.get("name") or "-"],
|
|
638
|
+
["用户ID", str(data.get("user_id_str") or data.get("user_id") or "-")],
|
|
639
|
+
["手机号", data.get("mobile") or "-"],
|
|
640
|
+
["头像", data.get("avatar_url") or "-"],
|
|
641
|
+
]
|
|
642
|
+
print(render_table(["项目", "值"], rows))
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def cmd_auth_import(args) -> None:
|
|
646
|
+
src = Path(args.state_file).expanduser()
|
|
647
|
+
if not src.is_file():
|
|
648
|
+
raise DcdError(f"文件不存在: {src}")
|
|
649
|
+
try:
|
|
650
|
+
state = json.loads(src.read_text(encoding="utf-8"))
|
|
651
|
+
except json.JSONDecodeError as exc:
|
|
652
|
+
raise DcdError(f"不是有效的存储状态 JSON: {src}") from exc
|
|
653
|
+
cookies = [
|
|
654
|
+
c
|
|
655
|
+
for c in state.get("cookies") or []
|
|
656
|
+
if any(x in (c.get("domain") or "") for x in ("dongchedi.com", "dcdapp.com"))
|
|
657
|
+
]
|
|
658
|
+
if not cookies:
|
|
659
|
+
raise DcdError("该文件中没有懂车帝相关 Cookie(请先在已登录懂车帝的浏览器里 state-save)")
|
|
660
|
+
dest = Path(args.output).expanduser() if args.output else DEFAULT_COOKIE_FILE
|
|
661
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
662
|
+
lines = ["# Netscape HTTP Cookie File", f"# imported from {src} by dcd auth-import", ""]
|
|
663
|
+
for c in cookies:
|
|
664
|
+
domain = c.get("domain") or ""
|
|
665
|
+
expires = int(c.get("expires") or 0)
|
|
666
|
+
lines.append(
|
|
667
|
+
"\t".join(
|
|
668
|
+
[
|
|
669
|
+
domain,
|
|
670
|
+
"TRUE" if domain.startswith(".") else "FALSE",
|
|
671
|
+
c.get("path") or "/",
|
|
672
|
+
"TRUE" if c.get("secure") else "FALSE",
|
|
673
|
+
str(expires if expires > 0 else 2147483647),
|
|
674
|
+
c.get("name") or "",
|
|
675
|
+
c.get("value") or "",
|
|
676
|
+
]
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
dest.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
680
|
+
print(f"已导入 {len(cookies)} 个 Cookie -> {dest}", file=sys.stderr)
|
|
681
|
+
try:
|
|
682
|
+
data = (api_get("/passport/account/info/v2/", {}, cookies=load_cookies(str(dest))).get("data")) or {}
|
|
683
|
+
if data.get("error_code"):
|
|
684
|
+
print(f"警告: 登录态未通过验证({data.get('description') or data['error_code']})", file=sys.stderr)
|
|
685
|
+
else:
|
|
686
|
+
print(f"登录态有效: {data.get('name')}({data.get('mobile') or '手机号未知'})", file=sys.stderr)
|
|
687
|
+
except DcdError as exc:
|
|
688
|
+
print(f"警告: 登录态校验请求失败({exc})", file=sys.stderr)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
# ---------- 入口 ----------
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
695
|
+
parser = argparse.ArgumentParser(
|
|
696
|
+
prog="dcd",
|
|
697
|
+
description="懂车帝数据查询 CLI(rank/brands/series/cars/specs/reviews/search/dealer 免登录;series-info/me 需登录态)",
|
|
698
|
+
)
|
|
699
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
700
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
701
|
+
|
|
702
|
+
p = sub.add_parser("rank", help="销量榜(默认全国零售量;--local 切换城市榜)")
|
|
703
|
+
p.add_argument(
|
|
704
|
+
"-t", "--type", type=int, default=11, help="榜单类型,默认 11(零售量总榜;--local 时忽略)"
|
|
705
|
+
)
|
|
706
|
+
p.add_argument("-n", "--count", type=int, default=20, help="条数,默认 20")
|
|
707
|
+
p.add_argument("-o", "--offset", type=int, default=0, help="起始偏移,默认 0")
|
|
708
|
+
p.add_argument("-c", "--city", default="全国", help="城市,默认 全国;--local 时必须指定")
|
|
709
|
+
p.add_argument("--local", action="store_true", help="城市榜:本地新车上牌量(如 --local -c 杭州)")
|
|
710
|
+
p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
|
|
711
|
+
p.set_defaults(func=cmd_rank)
|
|
712
|
+
|
|
713
|
+
p = sub.add_parser("brands", help="品牌列表")
|
|
714
|
+
p.add_argument("-f", "--filter", help="按品牌名或首字母过滤")
|
|
715
|
+
p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
|
|
716
|
+
p.set_defaults(func=cmd_brands)
|
|
717
|
+
|
|
718
|
+
p = sub.add_parser("series", help="品牌下的车系列表")
|
|
719
|
+
p.add_argument("brand_id", help="品牌ID(用 dcd brands 查)")
|
|
720
|
+
p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
|
|
721
|
+
p.set_defaults(func=cmd_series)
|
|
722
|
+
|
|
723
|
+
p = sub.add_parser("cars", help="车系下的车型(年款/价格)")
|
|
724
|
+
p.add_argument("series_id", help="车系ID")
|
|
725
|
+
p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
|
|
726
|
+
p.set_defaults(func=cmd_cars)
|
|
727
|
+
|
|
728
|
+
p = sub.add_parser("specs", help="车型参数配置表(可多车对比)")
|
|
729
|
+
p.add_argument("series_id", help="车系ID")
|
|
730
|
+
p.add_argument("car_id", nargs="+", help="车型ID,可多个(用 dcd cars 查)")
|
|
731
|
+
p.add_argument("-s", "--section", help="只看包含该文字的章节,如 电池")
|
|
732
|
+
p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
|
|
733
|
+
p.set_defaults(func=cmd_specs)
|
|
734
|
+
|
|
735
|
+
p = sub.add_parser("reviews", help="车主评论")
|
|
736
|
+
p.add_argument("series_id", help="车系ID")
|
|
737
|
+
p.add_argument("-p", "--page", type=int, default=1, help="页码,默认 1")
|
|
738
|
+
p.add_argument("-n", "--size", type=int, default=20, help="每页条数,默认 20")
|
|
739
|
+
p.add_argument("-c", "--city", default="杭州", help="城市,默认 杭州")
|
|
740
|
+
p.add_argument("-s", "--sort", default="default", help="排序,默认 default")
|
|
741
|
+
p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
|
|
742
|
+
p.set_defaults(func=cmd_reviews)
|
|
743
|
+
|
|
744
|
+
p = sub.add_parser("search", help="搜索车系")
|
|
745
|
+
p.add_argument("keyword", help="关键词")
|
|
746
|
+
p.add_argument("-n", "--count", type=int, default=20, help="条数,默认 20")
|
|
747
|
+
p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
|
|
748
|
+
p.set_defaults(func=cmd_search)
|
|
749
|
+
|
|
750
|
+
p = sub.add_parser("dealer", help="经销商报价(免登录,分城市)")
|
|
751
|
+
p.add_argument("car_id", nargs="+", help="车型ID,可多个(用 dcd cars 查)")
|
|
752
|
+
p.add_argument("-c", "--city", default="杭州", help="城市,默认 杭州")
|
|
753
|
+
p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
|
|
754
|
+
p.set_defaults(func=cmd_dealer)
|
|
755
|
+
|
|
756
|
+
p = sub.add_parser("series-info", help="车系综合信息:概览/懂车分/价格卡/门店(需登录态)")
|
|
757
|
+
p.add_argument("series_id", help="车系ID")
|
|
758
|
+
p.add_argument("-c", "--city", help="城市(默认取 Cookie 中的城市)")
|
|
759
|
+
p.add_argument(
|
|
760
|
+
"-s",
|
|
761
|
+
"--section",
|
|
762
|
+
action="append",
|
|
763
|
+
choices=["overview", "scores", "price", "dealers"],
|
|
764
|
+
help="只输出指定板块,可重复;默认全部",
|
|
765
|
+
)
|
|
766
|
+
p.add_argument("--cookie-file", help=f"Cookie 文件,默认 {DEFAULT_COOKIE_FILE}")
|
|
767
|
+
p.add_argument("-j", "--json", action="store_true", help="输出提取后的 JSON")
|
|
768
|
+
p.set_defaults(func=cmd_series_info)
|
|
769
|
+
|
|
770
|
+
p = sub.add_parser("me", help="账号信息,验证登录态(需登录态)")
|
|
771
|
+
p.add_argument("--cookie-file", help=f"Cookie 文件,默认 {DEFAULT_COOKIE_FILE}")
|
|
772
|
+
p.add_argument("-j", "--json", action="store_true", help="输出原始 JSON")
|
|
773
|
+
p.set_defaults(func=cmd_me)
|
|
774
|
+
|
|
775
|
+
p = sub.add_parser("auth-import", help="从 playwright state-save 的 JSON 导入懂车帝 Cookie")
|
|
776
|
+
p.add_argument("state_file", help="state-save 产出的 storage state JSON")
|
|
777
|
+
p.add_argument("-o", "--output", help=f"输出文件,默认 {DEFAULT_COOKIE_FILE}")
|
|
778
|
+
p.set_defaults(func=cmd_auth_import)
|
|
779
|
+
|
|
780
|
+
return parser
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def main() -> None:
|
|
784
|
+
args = build_parser().parse_args()
|
|
785
|
+
try:
|
|
786
|
+
args.func(args)
|
|
787
|
+
except DcdError as exc:
|
|
788
|
+
print(f"错误: {exc}", file=sys.stderr)
|
|
789
|
+
raise SystemExit(1)
|
|
790
|
+
except KeyboardInterrupt:
|
|
791
|
+
raise SystemExit(130)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
if __name__ == "__main__":
|
|
795
|
+
main()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dongchedi-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 懂车帝(dongchedi.com)数据查询 CLI:销量榜 / 品牌 / 车系 / 车型 / 参数配置 / 车主评论 / 搜索
|
|
5
|
+
Author: weisanju
|
|
6
|
+
Keywords: automobile,car,cli,dongchedi,vehicle-data
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Natural Language :: Chinese (Simplified)
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# dongchedi-cli
|
|
22
|
+
|
|
23
|
+
懂车帝(www.dongchedi.com)数据查询 CLI。免登录命令走 PC 站公开 JSON 接口;登录命令读取浏览器登录态,走 HTML 页面的 SSR 数据。无第三方依赖(纯标准库)。
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv tool install dongchedi-cli # 从 PyPI 安装;也可 pipx install dongchedi-cli 或 pip install dongchedi-cli
|
|
29
|
+
|
|
30
|
+
# 从源码安装(开发)
|
|
31
|
+
uv tool install --editable scripts/dongchedi-cli
|
|
32
|
+
# 免安装临时运行
|
|
33
|
+
PYTHONPATH=scripts/dongchedi-cli/src python3 -m dongchedi_cli.cli rank
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 用法
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# ---- 免登录 ----
|
|
40
|
+
dcd rank # 销量总榜(默认前 20,全国零售量)
|
|
41
|
+
dcd rank -c 杭州 -n 50 # 指定城市与条数(注意:榜单仍是全国榜,仅指导价按城市变化)
|
|
42
|
+
dcd rank --local -c 杭州 # 杭州城市榜:本地新车上牌量(共 317 款车系)
|
|
43
|
+
dcd brands -f J # 品牌列表,按名称/首字母过滤
|
|
44
|
+
dcd series 858 # 吉利银河下的全部车系(含指导价/经销商价/评分)
|
|
45
|
+
dcd cars 20154 # 星愿的车型年款与价格(含停售/未上市)
|
|
46
|
+
dcd specs 20154 259371 # 车型参数配置表
|
|
47
|
+
dcd specs 20154 259371 259369 -s 电池 # 多车对比,只看"电池"章节
|
|
48
|
+
dcd reviews 20154 -p 1 -n 20 # 车主评论(含成交价/评分)
|
|
49
|
+
dcd search 秦plus # 搜车系
|
|
50
|
+
dcd dealer 259371 # 某车型的经销商报价与优惠(默认杭州)
|
|
51
|
+
dcd dealer 259371 259369 -c 上海 # 多车型对比、指定城市
|
|
52
|
+
|
|
53
|
+
# ---- 需登录态(见下方「登录态」)----
|
|
54
|
+
dcd series-info 20154 # 车系主页 SSR:概览/评分/价格卡/经销商
|
|
55
|
+
dcd series-info 20154 -s dealers # 只看经销商(overview/scores/price/dealers)
|
|
56
|
+
dcd series-info 20154 -c 北京 # 指定城市(覆盖登录态里的城市 Cookie)
|
|
57
|
+
dcd me # 当前登录账号信息
|
|
58
|
+
|
|
59
|
+
# 任意命令加 --json 输出原始数据
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
典型链条:`dcd brands -f 吉利` → `dcd series 858` → `dcd cars 20154` → `dcd specs 20154 <车型ID>`。
|
|
63
|
+
|
|
64
|
+
表格结果输出到 stdout,进度/提示输出到 stderr,可安全重定向。
|
|
65
|
+
|
|
66
|
+
## 登录态
|
|
67
|
+
|
|
68
|
+
`series-info` / `me` 需要已登录懂车帝的 Cookie。获取流程(配合 playwright-cli):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
playwright-cli open https://www.dongchedi.com --persistent # 登录(拼图滑块 + 短信验证码)
|
|
72
|
+
playwright-cli state-save scripts/dongchedi-cli/.auth/state.json
|
|
73
|
+
dcd auth-import scripts/dongchedi-cli/.auth/state.json # 提取 Cookie 并当场校验
|
|
74
|
+
# → 写入 ~/.config/dcd/cookies.txt(chmod 600),文件含 sessionid,勿提交
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Cookie 文件解析优先级:`--cookie-file` 参数 → `DCD_COOKIE_FILE` 环境变量 → `~/.config/dcd/cookies.txt`。
|
|
78
|
+
登录态过期后(报错提示会话过期)重新登录并重跑 `state-save` + `auth-import` 即可。
|
|
79
|
+
|
|
80
|
+
## 已验证接口(2026-09 实测)
|
|
81
|
+
|
|
82
|
+
| 命令 | 接口 | 备注 |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| rank | `GET /motor/pc/car/rank_data` | `rank_data_type=11` 零售量总榜;`=64` 为城市榜(`--local`,需 city_name,销量=本地新车上牌量);`=2` 批发量 |
|
|
85
|
+
| brands | `GET /motor/brand/v4/brand/` | 品牌在 `data[].info`(其中 4 个热门品牌在 `info.brand`) |
|
|
86
|
+
| series | `POST /motor/pc/car/brand/select_series_v2` | 表单参数是 `brand`(不是 `brand_id`,传错会静默返回全站热门车系) |
|
|
87
|
+
| cars | `GET /motor/pc/car/series/car_list` | `data.tab_list[]`,type 1115=车型、1137=年款分组,1068 为占位需跳过 |
|
|
88
|
+
| specs | `GET /motor/pc/car/get_full_config_info` | 需 `series_id` + `car_ids`;实测忽略 car_ids,总是返回全车系,CLI 已按请求过滤 |
|
|
89
|
+
| reviews | `GET /motor/pc/car/series/get_review_list` | 需 `Referer: /auto/series/score/{series_id}` |
|
|
90
|
+
| search | `GET /motor/searchapi/search_content` | `cur_tab=2`,车系卡片为 `cell_type=5002`(不一定出现,无则回退内容标题) |
|
|
91
|
+
| dealer | `GET /motor/pc/car/series/car_dealer_price` | 匿名可用;依赖 `city_name`(全国 → 暂无报价),未覆盖城市会回落邻近城市 |
|
|
92
|
+
| series-info | HTML `GET /auto/series/{id}` | 需登录态;数据在 `__NEXT_DATA__.props.pageProps`(经销商/价格卡/评分) |
|
|
93
|
+
| me | `GET /passport/account/info/v2/` | 需登录态;匿名返回 `error_code=13` 会话过期 |
|
|
94
|
+
|
|
95
|
+
公共参数 `aid=1839&app_name=auto_web_pc`;请求头用桌面 Chrome UA。
|
|
96
|
+
|
|
97
|
+
## 注意事项
|
|
98
|
+
|
|
99
|
+
- HTML 路由(`/auto/series/*`、`/auto/params-*`)无登录一律 302 到 `/login-required`;只有带完整登录 Cookie 才能拿到 SSR 数据(仅有 ttwid/tt_webid 不够)。
|
|
100
|
+
- HTML 路由有 CDN 缓存且缓存键不含 Cookie,会串城市数据;CLI 已自动附加 `?_r=<毫秒时间戳>` 破缓存。
|
|
101
|
+
- 城市由 `city_name` Cookie 控制且值为 **percent-encoded**(如 `%E6%9D%AD%E5%B7%9E`),URL 上的 `city_name` 参数会被页面逻辑忽略;CLI 的 `-c` 已按此处理。
|
|
102
|
+
- 登录态 Cookie 含中文值,urllib 请求头默认 latin-1 编码,发送前已转 UTF-8 裸字节(与浏览器线上行为一致)。
|
|
103
|
+
- 参数配置表字段:`data.properties[]` 为行定义(type 0/2 为章节标题,1 普通行,3 带子项,6 跳过),值在 `data.car_info[].info[<key>].value`;type 3 的值挂在 `sub_list[].key` 上。注意 `type` 字段时而为 int 时而为字符串,代码里已统一归一化。
|
|
104
|
+
- 接口可能随版本变化(版本参数当前为 version_code=476),失效时优先用 `--json` 排查原始返回。
|
|
105
|
+
|
|
106
|
+
## 开发与发布
|
|
107
|
+
|
|
108
|
+
开发与发布由 `Makefile` 管理(`make help` 查看全部目标):
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
make lint # ruff 检查
|
|
112
|
+
make smoke # 走工作区源码对真实接口冒烟(覆盖全部免登录命令,需外网)
|
|
113
|
+
make bump part=minor # 升版本号(同步 pyproject.toml 与 __init__.py)
|
|
114
|
+
make check # 离线校验:lint + 构建 + 版本一致性 + twine 元数据检查
|
|
115
|
+
make publish-test # 发布到 TestPyPI 预演(token 放 TESTPYPI_TOKEN)
|
|
116
|
+
make publish # 发布到 PyPI(token 放 UV_PUBLISH_TOKEN)
|
|
117
|
+
make release # 一键发布:check → smoke → bump → publish → tag
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
发布前在 pypi.org 生成 API token(scope 选本项目),`export UV_PUBLISH_TOKEN=pypi-xxx` 即可。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
dongchedi_cli/__init__.py,sha256=3vgMEbIJzaaix4q6Rbf39-vkjzh0KPmgIE8c0FuSD_g,58
|
|
2
|
+
dongchedi_cli/cli.py,sha256=eoo5JB3321QyjOH8CguKN5_vbTOI2oqSfJps1y3-6U4,29919
|
|
3
|
+
dongchedi_cli-0.1.0.dist-info/METADATA,sha256=9KBBjg1qzedVykXQ4L8s4-4SdgN_ahBxBvM6Pql2RzQ,7196
|
|
4
|
+
dongchedi_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
dongchedi_cli-0.1.0.dist-info/entry_points.txt,sha256=l5AngDhZFc7NI9r8nm-iO-kDGwzlMMX_6Z2bkQvVc3s,47
|
|
6
|
+
dongchedi_cli-0.1.0.dist-info/RECORD,,
|