Skill: trancedream/car-search (version 0.1.2)

--- name: Car Search description: 作为专业二手车购车顾问，帮助用户分析购车需求、搜索聚合多个二手车平台（懂车帝、汽车之家等）并提供深度的车源参数对比及车贷测算。**注意：即使用户没有明确提到“二手车”，只要是在比对保值率、试图评估某款二手车型当前市场行情、或想要“淘一辆”高性价比座驾时，都请优先触发此技能。** --- # 🚗 二手车购车顾问技能 (Used Car Finder) 你的角色是一位**专业、客观的二手车购车顾问**。你的核心任务不是机械地列出数据，而是**帮助用户分析需求、筛选最合适的车源，并提供深度的车辆对比分析**。 为了完成这项任务，你内置了强大的 `car-cli` 工具，它可以聚合搜索懂车帝、汽车之家（che168）、优信拍（youxinpai）等平台的真实车源信息。所有的操作都主要在 `scripts/` 目录下通过执行 `uv run` 完成。为了能在对比车源时做到数据严谨且不遗漏细节，执行获取数据的命令（如 search、detail、compare）时，请一律添加 `--output json` 参数以获得结构化数据。 ## 🎯 你的工作流 作为购车顾问，你应该遵循以下步骤服务用户： ### 1. 需求探寻与分析 不要一开始就盲目搜索。如果用户需求模糊（例："我想买台10万左右的车"）： - 询问核心用途（日常代步、家庭出行、商务接待、跑网约车等） - 偏好车型（SUV、轿车、MPV） - 动力偏好（纯油、纯电、插混、增程） - 配置要求（空间、省油、动力表现、智能化） - 解释为什么了解这些对选二手车很重要。 ### 2. 车源检索与筛选 根据需求，使用内置工具在各大平台（如懂车帝、汽车之家、优信拍等）广撒网： ```bash # 执行路径：<SKILL_DIR>/scripts # 确保在执行搜索前提醒用户你正在跨平台寻找车源 # 添加 --output json 参数以便后续分析结构化数据 uv run car search --city <城市> --brand <品牌> --min-price <最低万> --max-price <最高万> --output json ``` *提示：多试几个方案，例如跨平台搜索，或者放宽年份要求。尽量找出3-5个有代表性的候选车源。如果用户没说城市，默认带上 `--city 全国` 了解大盘行情。* ### 3. 深度分析与对比 展示结果时，不要只是把表格抛给用户。你应该： - 为用户**挑选出最具性价比的几款车**（如里程少、年份新、价格合理的"尖子生"） - **参数横向比对**：务必使用 `uv run car detail <平台:ID> --output json` 获取候选车辆的完整参数（排量、排放标准、上牌日期、过户次数等），将几辆候选车的核心参数提炼出来做清晰的横跨对比。如果有高度相似的两台车，还可以用 `car compare` 命令辅助比对。 - **提供直达链接**：在给出最终推荐时，**不要只列出车源 ID，必须附上每辆车的可点击链接（URL）**。不管是 `search` 还是 `detail` 产生的 JSON 结果中都包含 `url` （或链接）字段，请提取出来提供给用户，方便直达网页看车。 - **【特殊动作】优信拍同款查询**：如果最终推荐给用户的最佳车源**不是**来自优信拍 (youxinpai) 平台，你应该在交付结果前额外利用 `--platform youxinpai` 设定相近参数（如相同的品牌车系、年份里程区间）搜索一次优信拍，看是否有相似车源，并将它连同其可能存在的价格差，打包到最终推荐列表中作为“平行比对备案”。 **在对比分析时请注重以下深度的维度：** - **价格与参数博弈**：结合 detail 查出的具体配置参数，分析这几台车是否溢价，是不是"捡漏"？ - **车况/里程分析**：三年跑了8万公里和五年跑了3万公里，到底哪个更值得买？过户次数对于这台车意味着什么？ - **保值率与油耗**：这台车后期的持有成本高不高？ - **平台差异**：同一款车在懂车帝和汽车之家谁的报价更有诚意？ ### 4. 购买决策与金融方案 如果用户看中某款车，主动提供月供测算，帮助他们控制预算： ```bash # 假设车价12.8万 uv run car loan --total 12.8 --down-payment 0.3 --years 3 ``` --- ## 🛠 内置工具 (car-cli) 使用指南 工具源码位于本技能目录下的 `scripts/` 文件夹。为了确保环境正确，这些数据命令主要在 `<SKILL_DIR>/scripts` 这个路径下，使用 `uv run` 执行。 首次运行时如果提示缺包，只需执行 `uv sync`。 ### 核心命令速查 | 目的 | 命令示例 | |------|------| | **搜索** | `uv run car search --city 北京 --brand 宝马 --max-price 10 --output json` | | **看单车详情** | `uv run car detail dongchedi:22805067 --output json` | | **精准查车系** | `uv run car series 宝马` (先查车系名再加 `--series` 搜) | | **并排对比** | `uv run car compare dongchedi:22805067 che168:478339_57621125 --output json` | | **车贷计算** | `uv run car loan --total 15 --down-payment 0.3 --years 3` | | **导出数据** | `uv run car export --format csv -o result.csv` | ### 搜索参数详解 (`car search`) - `--city`：为适配各平台的搜索引擎规则，使用 `--city` 选项时需传入中文全称（如 `北京`、`上海`）。不写默认全国。 - `--brand`：同样为了适配 API，品牌参数需用中文，如 `宝马` 而非 `BMW`。 - `--series`：建议先用 `car series` 查准车系名再配对使用。 - `--min-price` / `--max-price`：为了实现跨平台比价的量纲统一，标价相关的单位一律为**万元**。 - `--max-mileage`：里程参数单位一律为**万公里**。 - `--min-year`：如 `2020`。 - `--transmission`：`auto` (自动) 或 `manual` (手动)。 - `--platform`：`dongchedi`、`che168` 或 `youxinpai`。`all` 为搜所有支持平台（瓜子 `guazi` 由于反爬严格默认不查）。 > ID 格式：搜索结果会返回这种 ID `dongchedi:22805067`，供 detail 和 compare 命令使用。 ### 环境与容错处理 各平台均有严格的反爬限制，内置工具已做好并发控制。**请一次只运行一个查找命令，不要疯狂并发请求引发封禁。** 如果在搜索过程中遇到平台接口异常或数据缺失（如返回空或请求失败），请保持顾问的从容——不要试图去 debug 底层工具代码，而是直接安抚用户（例如“系统稍微有点卡顿”，建议调整搜索条件，换个平台重新尝试即可）。 --- ## 💡 顾问沟通风格要求 1. **结构清晰**：给出的建议必须分点列出（如：优势、劣势、购买建议）。 2. **不说废话**：不一比一复述枯燥的 JSON，把数据翻译成"这车划不划算"。 3. **有观点**："我更推荐 A 车，虽然里程多一点，但价格便宜了2万，且是自动挡，非常适合代步。" 4. **透明**：告诉用户你正在去各大平台刮取数据，需要一小会儿时间。

path: dev_docs/附-懂车帝数字掩码对照表.md
content: 懂车帝数字掩码对照

| unicode | value |
| ------- | ----- |
| \ue439  | 0     |
| \ue54c  | 1     |
| \ue463  | 2     |
| \ue49d  | 3     |
| \ue41d  | 4     |
| \ue411  | 5     |
| \ue534  | 6     |
| \ue3eb  | 7     |
| \ue4e3  | 8     |
| \ue45d  | 9     |
| \ue40a  | 万    |

path: dev_docs/STEPS.md
content: # 懂车帝品牌全量数据提取 — 开发步骤与思路

## 背景

`car-cli` 的懂车帝适配器需要一张 **品牌中文名 → 品牌数字 ID** 的映射表，用于构建搜索 URL 的第 19 号路径槽位。初版仅收录了约 70 个热门品牌，来源是页面 `__NEXT_DATA__` 中 `pageProps.list`（`type: 1001` 的品牌推荐卡片），遗漏了大量品牌。

本文档记录将品牌表从 ~70 条扩充到 651 条的完整过程。

---

## 第一步：确认 URL 槽位映射

### 问题

之前的实现中，各路径槽位的对应关系有误（如品牌曾错放到槽位 0）。需要先用浏览器实测确认。

### 方法

使用 `cursor-ide-browser` MCP 工具，在浏览器中依次点击不同筛选条件，观察 URL 变化：

1. **默认页**：`/usedcar/x-x-x-x-...-x-110000-1-x-x-x-x-x`（28 段，全部为 `x`，仅城市和页码有值）
2. **点击"宝马"**：URL 变为 `/usedcar/x-x-...-x-4-x-110000-1-x-x-x-x-x`，宝马 ID `4` 出现在第 19 段
3. **点击"5-10万"**：URL 变为 `/usedcar/5,10-x-...-x-x-110000-1-x-x-x-x-x`，价格 `5,10` 出现在第 0 段
4. **叠加品牌+价格**：URL 为 `/usedcar/5,10-x-...-x-4-x-110000-1-x-x-x-x-x`，页面标题确认为"5-10万左右的二手宝马推荐"

### 结论

| 槽位索引 | 含义 | 示例值 |
|---------|------|--------|
| 0 | 价格范围（万元） | `5,10` |
| 1–18 | 其他筛选（车型、燃料、年份、里程等） | `x` |
| 19 | 品牌 ID | `4`（宝马） |
| 20 | 车系 ID | `x` |
| 21 | 城市行政区划码 | `110000`（北京） |
| 22 | 页码 | `1` |
| 23–27 | 保留 | `x` |

对应代码常量：

```python
_NUM_SLOTS = 28
_SLOT_PRICE = 0
_SLOT_BRAND = 19
_SLOT_SERIES = 20
_SLOT_CITY = 21
_SLOT_PAGE = 22
```

---

## 第二步：分析旧品牌数据的不足

### 旧方案

品牌表来自 `__NEXT_DATA__` 的 `pageProps.list`，筛选 `type == 1001` 的条目。该列表是**首页推荐的热门品牌卡片**，仅约 20 个品牌；加上手动补充，总共约 70 个。

### 缺失内容

- 新势力：深蓝、岚图、智己、飞凡、仰望、方程豹、极越、iCAR、乐道、享界等
- 传统国产：东风风行、北京汽车、海马、力帆、陆风等
- 子品牌：吉利银河、长安启源、奇瑞风云、捷途山海等
- 进口/小众：迈凯伦、科尼赛克、布加迪、帕加尼等
- 房车品牌、改装品牌、历史品牌等

---

## 第三步：寻找完整品牌数据源

### 尝试 1：curl 直接请求页面 HTML

```bash
curl -s 'https://www.dongchedi.com/usedcar/...' \
  -H 'User-Agent: ...' | python3 parse_next_data.py
```

**结果**：`__NEXT_DATA__` script 标签未出现在响应中。推测服务端对非浏览器请求返回了精简版 HTML 或触发了反爬机制。

### 尝试 2：猜测 API 端点

```bash
curl -s 'https://www.dongchedi.com/motor/pc/sh/sh_brand_list?aid=1839&app_name=auto_web_pc'
curl -s 'https://www.dongchedi.com/motor/pc/sh/brand_list?aid=1839&app_name=auto_web_pc'
```

**结果**：均返回空响应，这些端点不存在或需要特殊鉴权。

### 尝试 3：浏览器 UI 提取

通过 `cursor-ide-browser` 点击品牌展开面板，发现：

- 热门品牌区域使用 `<a>` 链接（role: link），可通过 `browser_get_attribute` 获取 `href`
- 完整 A–Z 品牌面板使用 `<li>` 列表项（role: listitem），**没有 href 属性**
- 无法执行页面内 JavaScript（MCP 无 `browser_javascript` 工具）

逐个获取 300+ 品牌的 href 不现实，此路不通。

### 尝试 4（成功）：Playwright 提取 `__NEXT_DATA__`

使用 Python Playwright 库，以真实 Chromium 浏览器加载页面，然后从 DOM 中读取 `<script id="__NEXT_DATA__">` 的文本内容：

```python
from playwright.sync_api import sync_playwright
import json

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://www.dongchedi.com/usedcar/x-x-...-110000-1-x-x-x-x-x",
              wait_until="networkidle")

    el = page.query_selector("#__NEXT_DATA__")
    data = json.loads(el.text_content())
    pp = data["props"]["pageProps"]
    # pp["brands"] 包含完整的 A-Z 品牌列表
```

**结果**：成功获取 `pageProps`，其中包含关键字段：

| 字段 | 类型 | 说明 |
|------|------|------|
| `brands` | `list[list[dict]]` | 按首字母分组的完整品牌列表（27 组，A–Z） |
| `allBrand` | `dict` | 包含 `brand`、`hot_brand`、`new_energy_hot_brand` |
| `tabData` | `dict` | 包含 `car_brand_list`、`car_series_list` |
| `list` | `list[dict]` | 首页推荐卡片（旧数据源） |

---

## 第四步：提取品牌映射

### 数据结构

`pageProps.brands` 是一个二维列表，外层按字母分 27 组：

```python
[
    [  # A 组
        {"type": 1000, "info": {"pinyin": "A"}},        # 字母分隔符
        {"type": 1001, "info": {"brand_id": 2, "brand_name": "奥迪", ...}},
        {"type": 1001, "info": {"brand_id": 483, "brand_name": "AITO", ...}},
        ...
    ],
    [  # B 组
        {"type": 1000, "info": {"pinyin": "B"}},
        {"type": 1001, "info": {"brand_id": 3, "brand_name": "奔驰", ...}},
        ...
    ],
    ...
]
```

### 提取逻辑

```python
brand_map = {}
for group in pp["brands"]:
    for item in group:
        if item.get("type") == 1001:
            info = item["info"]
            brand_map[info["brand_name"]] = str(info["brand_id"])
```

提取结果：**640 个品牌**。

---

## 第五步：更新代码

### 修改文件

`src/car_cli/models/cities.py` 中的 `BRAND_IDS` 字典：

- 替换原有 ~70 条为完整的 640 条
- 保留并扩充常用别名（如 `"吉利"` → `"73"`、`"问界"` → `"483"`）
- 新增别名：`"极狐"`、`"赛力斯"`、`"极星"`、`"几何"`

最终字典包含 **651 条**（640 个正式名称 + 11 个别名）。

### 验证

测试之前缺失的品牌能否正常搜索：

```bash
$ car search --city 北京 --brand 深蓝汽车
# ✓ 返回深蓝L07、深蓝G318、深蓝SL03 等结果

$ car search --city 北京 --brand 岚图
# ✓ 返回岚图FREE、岚图梦想家等结果
```

---

## 技术要点总结

1. **`__NEXT_DATA__` 是核心数据源**：懂车帝使用 Next.js SSR，服务端数据序列化在 `<script id="__NEXT_DATA__">` 中。但该标签的内容对 curl/httpx 等脚本请求可能不可见，需要真实浏览器渲染。

2. **Playwright 绕过反爬**：相比 `requests`/`httpx`，Playwright 启动真实 Chromium，能获取完整的 SSR 输出，包括 `__NEXT_DATA__`。

3. **`pageProps.brands` vs `pageProps.list`**：
   - `list` 仅包含首页推荐的 20 余个品牌卡片
   - `brands` 包含按 A–Z 分组的全部 640 个品牌

4. **品牌 ID 为整数字符串**：大部分 ID 在 1–1000 范围内，部分新品牌（房车、进口小众品牌）ID 在 10000+ 范围。

5. **静态字典的局限**：当前方案仍是静态硬编码。如需自动更新，可在运行时用 Playwright 或 headless 请求动态获取。

---

## 依赖

- `playwright`：需安装 Python 包及 Chromium 浏览器
  ```bash
  pip install playwright
  python -m playwright install chromium
  ```

## 时间线

- **2026-03-23**：完成全量品牌提取，从 ~70 扩充到 651 条


path: dev_docs/AGENTS.md
content: # AGENTS.md

## Project overview

car-cli 是一个二手车多平台聚合搜索 CLI 工具，当前已适配懂车帝（dongchedi.com），后续计划接入瓜子、人人车等平台。使用 Python 3.12+，CLI 框架为 Click，终端输出用 Rich，HTTP 请求用 httpx（异步）。

## Setup

**包管理器为 [uv](https://docs.astral.sh/uv/)，不使用 pip/poetry/pdm。** 构建后端为 Hatchling。

```bash
# 安装依赖 & 创建 .venv
uv sync

# 运行 CLI（推荐方式）
uv run car search --city 北京

# 添加新依赖
uv add <package>

# 添加开发依赖
uv add --dev <package>
```

不要手动 `pip install`，也不要修改 `requirements.txt`（不存在该文件）。所有依赖声明在 `pyproject.toml` 的 `[project.dependencies]`，锁文件为 `uv.lock`。

## Project structure

```
car-cli/
├── pyproject.toml              # 项目元数据、依赖、构建配置
├── uv.lock                     # uv 锁文件，提交到仓库
├── car_cli/
│   ├── main.py                 # Click CLI 入口，注册所有子命令
│   ├── logging_config.py       # 统一日志配置（输出到 stderr）
│   ├── commands/               # Click 子命令（search/detail/compare/loan/export）
│   ├── client/
│   │   ├── base.py             # BaseClient 抽象基类
│   │   ├── http.py             # HttpClient：反检测头、抖动延迟、突发限流、指数退避
│   │   └── adapters/
│   │       ├── __init__.py     # 平台注册表 + get_adapters()
│   │       └── dongchedi/      # 懂车帝适配器（自包含包）
│   │           ├── __init__.py # 导出 DongchediClient
│   │           ├── client.py   # DongchediClient 主类（URL 构建 + search/detail 入口）
│   │           ├── parser.py   # HTML / __NEXT_DATA__ 解析逻辑
│   │           ├── brands.py   # 品牌名 → 品牌 ID 映射（懂车帝专属）
│   │           └── font_decoder.py  # PUA 字体数字反混淆（懂车帝专属）
│   ├── models/
│   │   ├── car.py              # Car / CarDetail 数据类
│   │   ├── filter.py           # SearchFilter 数据类
│   │   └── cities.py           # 城市名/adcode 映射表（平台无关）
│   └── utils/                  # 通用工具（平台无关）
├── STEPS.md                    # 开发步骤记录
└── 附-懂车帝数字掩码对照表.md
```

## Code style

- Ruff 为 linter，行宽 100（`pyproject.toml` 已配置）
- Python 3.12+，使用 `list[str]` / `dict[str, str]` / `X | None` 语法，不用 `from __future__ import annotations`（除 logging_config.py 外）
- 数据模型用 `dataclasses`，不使用 Pydantic
- 异步函数限于 `client/` 层（适配器的 `search`/`detail` 方法），命令层通过 `asyncio.run()` 调用
- 日志全部通过 `car_cli.logging_config.get_logger(suffix)` 获取子 logger，输出到 stderr
- 不要在代码中写解释性注释（"这里做了什么"），只写 why 和 non-obvious 的约束

## Architecture: platform adapter pattern

每个二手车平台对应一个适配器，继承 `BaseClient`：

```python
class BaseClient(ABC):
    platform_name: str = ""

    @abstractmethod
    async def search(self, filters: SearchFilter) -> list[Car]: ...

    @abstractmethod
    async def detail(self, car_id: str) -> CarDetail: ...
```

适配器返回统一的 `Car` / `CarDetail` 数据类，上层命令不感知平台差异。

### 添加新平台适配器

1. 在 `client/adapters/` 下新建 `<platform>/` 包目录，包含 `__init__.py`、`client.py` 等
2. 在 `client.py` 中继承 `BaseClient`，实现 `search()` 和 `detail()` 方法，返回 `Car` / `CarDetail`
3. 平台专属的数据映射、反混淆逻辑等放在适配器包内部（参考 `dongchedi/brands.py`、`dongchedi/font_decoder.py`）
4. 在 `client/adapters/__init__.py` 的 `ADAPTER_REGISTRY` 中注册

## Dongchedi adapter specifics

这是目前最复杂的适配器，修改时务必注意：

### URL 槽位结构

列表页 URL 为 28 段 `-` 分隔的路径，常量定义在 `dongchedi.py` 顶部：

| 常量 | 槽位 | 含义 | 示例 |
|------|------|------|------|
| `_SLOT_PRICE` | 0 | 价格范围（万元） | `5,10` |
| `_SLOT_BRAND` | 19 | 品牌 ID | `4`（宝马） |
| `_SLOT_SERIES` | 20 | 车系 ID | `x` |
| `_SLOT_CITY` | 21 | 城市行政区划码 | `110000` |
| `_SLOT_PAGE` | 22 | 页码 | `1` |

这些槽位是通过浏览器实测校准的（见 STEPS.md），网站随时可能调整。如果搜索结果异常，先用 `--debug` 检查生成的 URL，再在浏览器中手动对比。

### 数字反混淆（PUA 字体）

懂车帝将价格、里程等数字替换为 Unicode Private Use Area 字符，由自定义字体渲染。`client/adapters/dongchedi/font_decoder.py` 中维护了一张 `_CHAR_MAP` 映射表。**如果站点更新字体映射，需要重新提取并更新这张表。**

### __NEXT_DATA__ vs HTML 回退

- `parser.parse_list()` 优先尝试从 `<script id="__NEXT_DATA__">` 提取 JSON 数据
- 当前实测发现列表页的车源数据**不在** `__NEXT_DATA__` 中（只有品牌/车系元数据），因此实际走 HTML 正则回退解析
- 详情页的数据在 `__NEXT_DATA__` 中

### 品牌 ID 映射（brands.py）

`client/adapters/dongchedi/brands.py` 中的 `BRAND_IDS` 字典包含 651 条品牌映射（含别名），从懂车帝 `__NEXT_DATA__` 的 `pageProps.brands` 字段提取（需用 Playwright 渲染页面）。更新步骤见 STEPS.md。

### 反爬措施（http.py）

`HttpClient` 内置了以下机制，不要绕过：

- **反检测请求头**：模拟 Chrome 浏览器完整的 `User-Agent` / `sec-ch-ua` / `Sec-Fetch-*` 系列头
- **高斯抖动延迟**：每次请求间加入随机延迟
- **突发检测**：短窗口（15s 内 3 次）和长窗口（45s 内 6 次）触发额外惩罚延迟
- **指数退避重试**：最多 3 次，基础 10s，上限 60s

## Debugging

```bash
# 启用调试日志（输出到 stderr）
uv run car --debug search --city 北京 --brand 宝马

# 同时启用 httpx 流量日志（极冗长）
uv run car --debug --trace-http search --city 北京

# 环境变量方式
CAR_CLI_DEBUG=1 uv run car search --city 北京
CAR_CLI_TRACE_HTTP=1 uv run car search --city 北京
```

Debug 模式会输出：请求 URL、HTTP 状态码、响应长度、解析路径选择（__NEXT_DATA__ / HTML 回退）、提取到的车源条数等。

## Testing

```bash
uv run pytest
```

测试目录为 `tests/`（在 `pyproject.toml` 中配置）。当前测试覆盖较少，添加新功能时应补充对应测试。

## Key conventions

- **所有价格单位为万元**，里程单位为万公里，在 `Car` / `SearchFilter` 数据类中已明确注释
- **城市名使用中文全称**（如"北京"、"上海"），不使用缩写或拼音
- **品牌名使用懂车帝官方中文名**，常用缩写通过 aliases 支持（如"理想" → "理想汽车"）
- **不要直接修改 `uv.lock`**，由 `uv sync` / `uv add` 自动维护
- **不要使用 `pip install`**，所有依赖管理通过 uv
- **Click 子命令**放在 `commands/` 目录下，每个文件一个命令，在 `main.py` 中注册
- **日志只用 `get_logger()`**，不要直接 `print()` 或 `logging.getLogger()`
- **CLI 入口是 `car`**（定义在 `pyproject.toml` 的 `[project.scripts]`）

## Common pitfalls

1. **curl/httpx 直接请求懂车帝可能拿不到 `__NEXT_DATA__`**：服务端对非浏览器请求可能返回精简 HTML。需要完整品牌数据时使用 Playwright
2. **PUA 字体映射是静态的**：如果提取到的价格/里程全是乱码或 `-`，说明字体映射表过期了，需要重新从字体文件提取
3. **URL 槽位可能变动**：懂车帝可能调整路径结构。如果筛选不生效，用浏览器手动点选筛选项观察 URL 变化来重新校准
4. **瓜子反爬严格**：频繁请求会触发验证码（IP 级别封锁）。搜索通常可通过，但详情页容易被拦截。详情被拦截时会返回提示信息引导用户在浏览器中查看

## 瓜子二手车适配器 (guazi/)

### 文件结构

```
client/adapters/guazi/
├── __init__.py       # 导出 GuaziClient
├── client.py         # HTTP 请求、URL 构建、验证码检测与重试
└── parser.py         # RSC 数据提取与解析
```

### RSC 数据解析

瓜子使用 Next.js App Router + React Server Components (RSC)。数据通过 `self.__next_f.push()` 调用嵌入 HTML 中，JSON 经多层转义（`\\\"key\\\"`）。

- `parser._strip_rsc_escaping()` 反复解转义直到稳定
- `parser._parse_list_regex()` 通过正则从 RSC 流中提取 `encryptedClueId`、`title`、`priceString`、`roadHaul` 等字段
- 列表数据位于 `initData.carList` 中，每个 item 包含 `skuBasicArea`（基本信息）和 `priceArea`（价格）

### 反爬与验证码

- 使用移动 UA（iPhone Safari）减少验证码触发
- `GuaziClient._fetch_with_retry()` 检测验证码并自动重试（最多 3 次，递增延迟）
- 验证码是 IP 级别的，重试可能无法绕过
- 详情页被拦截时返回友好提示而不是空数据

### 城市缩写

瓜子 URL 使用拼音缩写（`bj`、`sh`），映射在 `models/cities.py` 的 `CITY_ABBR` 字典中。

4. **城市使用 adcode 而非名称写入 URL 路径**：`models/cities.py` 中的 `CITY_ADCODE` 映射了城市中文名到 6 位行政区划码（平台无关）
5. **品牌 ID 不连续且范围大**：大部分 1-1000，部分新品牌/房车品牌在 10000+。品牌 ID 映射在 `client/adapters/dongchedi/brands.py`（懂车帝专属）


path: scripts/pyrightconfig.json
content: {
  "venvPath": ".",
  "venv": ".venv",
  "executionEnvironments": [
    {
      "root": "."
    }
  ]
}


path: scripts/pyproject.toml
content: [project]
name = "car-cli"
version = "0.1.0"
description = "CLI tool for searching used cars across multiple platforms"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "click>=8.1",
    "httpx>=0.27",
    "rich>=13.0",
    "pyyaml>=6.0",
    "brotli>=1.2.0",
]

[project.scripts]
car = "car_cli.main:cli"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["car_cli"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 100


path: scripts/README.md
content: # 🚗 car-cli — 二手车多平台聚合搜索 CLI

从多个二手车平台聚合搜索、查看详情、对比车源，所有操作都在终端完成。

## 支持平台

| 平台 | 标识 | 搜索 | 详情 | 车系查询 |
|------|------|:----:|:----:|:--------:|
| [懂车帝](https://www.dongchedi.com) | `dongchedi` | ✅ | ✅ | ✅ |
| [汽车之家二手车](https://www.che168.com) | `che168` | ✅ | ✅ | ✅ |
| [瓜子二手车](https://www.guazi.com) | `guazi` | ✅ | ✅ | ❌ |
| [优信拍](https://www.youxinpai.com) | `youxinpai` | ✅ | ✅ | ✅ |

---

## 环境要求

- **Python** 3.12+
- **[uv](https://docs.astral.sh/uv/)** — 包管理器（不使用 pip/poetry）

## 安装

```bash
# 安装依赖并创建虚拟环境
uv sync
```

安装完成后，`car` 命令即可使用：

```bash
uv run car --help
```

---

## 快速开始

```bash
# 全平台搜索宝马，10 万以内
uv run car search --brand 宝马 --max-price 10

# 查看某辆车的详情
uv run car detail dongchedi:22805067

# 对比两辆车
uv run car compare dongchedi:22805067 che168:478339_57621125

# 查看宝马有哪些车系
uv run car series 宝马

# 贷款计算
uv run car loan --total 15 --down-payment 0.3 --years 3
```

---

## 命令详解

### 全局选项

所有子命令前均可使用：

```
car [全局选项] <子命令> [命令选项]
```

| 选项 | 说明 |
|------|------|
| `--debug` / `-d` | 启用调试日志（输出到 stderr），显示请求 URL、解析步骤等 |
| `--trace-http` | 启用 httpx 流量级日志（极其冗长），自动开启 `--debug` |
| `--version` | 显示版本号 |
| `--help` | 显示帮助 |

也可通过环境变量启用：

```bash
CAR_CLI_DEBUG=1 uv run car search --brand 丰田
CAR_CLI_TRACE_HTTP=1 uv run car search --brand 丰田
```

---

### `car search` — 搜索二手车

聚合搜索多个平台的二手车源，以表格形式展示结果。

```bash
uv run car search [选项]
```

#### 选项

| 选项 | 默认值 | 说明 |
|------|--------|------|
| `--city` | `全国` | 城市名称（中文），如 `北京`、`上海`、`广州` |
| `--brand` | 无 | 品牌名称，如 `宝马`、`丰田`、`比亚迪` |
| `--series` | 无 | 车系名称，如 `3系`、`卡罗拉`（需配合 `--brand` 使用） |
| `--min-price` | 无 | 最低价格（万元） |
| `--max-price` | 无 | 最高价格（万元） |
| `--min-mileage` | 无 | 最低里程（万公里） |
| `--max-mileage` | 无 | 最高里程（万公里） |
| `--min-year` | 无 | 最早年份，如 `2020` |
| `--max-year` | 无 | 最晚年份，如 `2024` |
| `--transmission` | 无 | 变速箱类型：`auto` / `manual` |
| `--platform` | `all` | 指定平台：`dongchedi`、`guazi`、`che168`、`youxinpai`、`all` |
| `--sort` | `default` | 排序方式：`price_asc`、`price_desc`、`mileage`、`date` |
| `--output` | `table` | 输出格式：`table`、`json`、`yaml` |
| `--page` | `1` | 页码 |

#### 示例

```bash
# 北京地区 5-10 万的宝马
uv run car search --city 北京 --brand 宝马 --min-price 5 --max-price 10

# 只搜懂车帝，按价格升序排列
uv run car search --brand 丰田 --platform dongchedi --sort price_asc

# 按车系筛选（懂车帝 / che168）
uv run car search --brand 宝马 --series 3系 --platform che168

# 优信拍：搜索拍卖车源
uv run car search --city 上海 --platform youxinpai

# 优信拍：品牌 + 车系筛选（动态从平台 API 获取车系 ID）
uv run car search --brand 宝马 --series 3系 --platform youxinpai

# 输出为 JSON
uv run car search --brand 比亚迪 --output json
```

> **提示**：搜索结果会自动缓存，可用 `car export` 导出。

---

### `car detail` — 查看车源详情

根据搜索结果中的车源 ID 查看详细信息。

```bash
uv run car detail <platform:id> [选项]
```

#### 参数

- `CAR_ID`：车源 ID，格式为 `平台:ID`，从搜索结果的 ID 列获取。

#### 选项

| 选项 | 默认值 | 说明 |
|------|--------|------|
| `--output` | `table` | 输出格式：`table`、`json`、`yaml` |

#### 示例

```bash
# 懂车帝详情
uv run car detail dongchedi:22805067

# 汽车之家详情（ID 包含经销商前缀）
uv run car detail che168:478339_57621125

# 优信拍详情（ID 格式为 auctionId_crykey，从搜索结果复制）
uv run car detail youxinpai:8224454_182f89

# 输出为 YAML
uv run car detail dongchedi:22805067 --output yaml
```

#### 展示字段

标题、价格、品牌、车系、年款、里程、首次上牌、变速箱、排量、燃料类型、车身类型、驱动方式、排放标准、颜色、车源城市、过户次数、链接。

---

### `car compare` — 对比两辆车

并排对比两辆车的关键参数，支持跨平台对比。

```bash
uv run car compare <platform:id1> <platform:id2> [选项]
```

#### 选项

| 选项 | 默认值 | 说明 |
|------|--------|------|
| `--output` | `table` | 输出格式：`table`、`json`、`yaml` |

#### 示例

```bash
# 跨平台对比
uv run car compare dongchedi:22805067 che168:478339_57621125

# 同平台对比
uv run car compare dongchedi:22805067 dongchedi:23150603
```

---

### `car series` — 查询品牌车系

查询某个品牌下可用的车系列表，方便使用 `--series` 进行精确筛选。

```bash
uv run car series <品牌名> [选项]
```

#### 选项

| 选项 | 默认值 | 说明 |
|------|--------|------|
| `--platform` | `dongchedi` | 指定平台（当前懂车帝、che168、优信拍支持车系查询） |

#### 示例

```bash
# 查看宝马的所有车系
uv run car series 宝马

# 查看比亚迪的车系
uv run car series 比亚迪

# 然后按车系筛选搜索
uv run car search --brand 宝马 --series 3系
```

---

### `car loan` — 车贷计算器

支持等额本息和等额本金两种还款方式。

```bash
uv run car loan [选项]
```

#### 选项

| 选项 | 默认值 | 说明 |
|------|--------|------|
| `--total` | 必填 | 车辆总价（万元） |
| `--down-payment` | `0.3` | 首付比例（0-1） |
| `--years` | `3` | 贷款年限 |
| `--rate` | `4.0` | 年利率（%） |
| `--method` | `equal_principal_interest` | 还款方式：`equal_principal_interest`（等额本息）/ `equal_principal`（等额本金） |

#### 示例

```bash
# 15 万的车，首付 30%，3 年期
uv run car loan --total 15 --down-payment 0.3 --years 3

# 等额本金，自定义利率
uv run car loan --total 20 --method equal_principal --rate 3.5 --years 5
```

---

### `car export` — 导出搜索结果

将上一次 `car search` 的结果导出为 CSV 或 JSON 文件。

```bash
uv run car export [选项]
```

#### 选项

| 选项 | 默认值 | 说明 |
|------|--------|------|
| `--format` | `csv` | 导出格式：`csv` / `json` |
| `-o` / `--output` | stdout | 输出文件路径，留空则输出到终端 |

#### 示例

```bash
# 先搜索
uv run car search --brand 丰田 --city 北京

# 导出为 CSV 文件
uv run car export --format csv -o result.csv

# 导出为 JSON 到终端
uv run car export --format json
```

---

## 平台管理

### 启用 / 禁用平台

编辑 `car_cli/client/adapters/__init__.py` 中的 `ENABLED_PLATFORMS` 列表：

```python
ENABLED_PLATFORMS: list[str] = [
    "dongchedi",
    # "guazi",       # ← 注释掉 = 禁用
    "che168",
    "youxinpai",
]
```

`--platform all` 时只搜索 `ENABLED_PLATFORMS` 中未注释的平台。指定具体平台名称时不受此限制。

---

## 调试指南

```bash
# 启用调试日志
uv run car --debug search --city 北京 --brand 宝马

# 同时启用 HTTP 流量日志
uv run car --debug --trace-http search --city 北京
```

调试日志输出到 stderr，包含：

- 请求构建的完整 URL
- HTTP 状态码和响应长度
- 解析路径选择（__NEXT_DATA__ / HTML 回退 / 车辆档案）
- 提取结果数量
- 品牌/车系 ID 匹配过程

---

## 项目结构

```
car-cli/
├── pyproject.toml                  # 项目配置、依赖、构建
├── uv.lock                         # uv 锁文件
├── car_cli/
│   ├── main.py                     # CLI 入口，注册所有子命令
│   ├── logging_config.py           # 统一日志配置
│   ├── commands/                   # CLI 子命令
│   │   ├── search.py               # 多平台聚合搜索
│   │   ├── detail.py               # 车源详情
│   │   ├── compare.py              # 车源对比
│   │   ├── series.py               # 品牌车系查询
│   │   ├── loan.py                 # 车贷计算器
│   │   └── export.py               # 导出搜索结果
│   ├── client/
│   │   ├── base.py                 # BaseClient 抽象基类
│   │   ├── http.py                 # HttpClient（反检测、限流、重试）
│   │   └── adapters/
│   │       ├── __init__.py         # 平台注册表 + ENABLED_PLATFORMS
│   │       ├── dongchedi/          # 懂车帝适配器
│   │       │   ├── client.py       # 搜索/详情/车系查询
│   │       │   ├── parser.py       # __NEXT_DATA__ + HTML 解析
│   │       │   ├── brands.py       # 品牌 ID 映射（651 条）
│   │       │   ├── cities.py       # 城市 adcode 映射
│   │       │   └── font_decoder.py # PUA 字体数字反混淆
│   │       ├── che168/             # 汽车之家适配器
│   │       │   ├── client.py       # 搜索/详情 + Cookie 反爬
│   │       │   ├── parser.py       # HTML 数据属性 + 车辆档案解析
│   │       │   ├── brands.py       # 品牌 URL slug 映射
│   │       │   └── cities.py       # 城市拼音 slug 映射
│   │       ├── guazi/              # 瓜子适配器
│   │       │   ├── client.py       # 搜索/详情 + 验证码检测
│   │       │   ├── parser.py       # RSC 数据流解析
│   │       │   └── cities.py       # 城市拼音缩写映射
│   │       └── youxinpai/          # 优信拍适配器
│   │           ├── client.py       # 搜索/详情 + Session 初始化
│   │           ├── parser.py       # JSON API + HTML 详情解析
│   │           ├── cities.py       # 城市 ID 映射（动态加载）
│   │           └── city_list.json  # 优信拍城市数据（560 城）
│   └── models/
│       ├── car.py                  # Car / CarDetail 数据类
│       ├── filter.py               # SearchFilter 数据类
│       └── cities.py               # 通用城市名列表
```

---

## 注意事项

1. **反爬限制**：各平台均有反爬措施。工具内置了请求头伪装、随机延迟、突发限流和指数退避机制，请勿频繁调用。
2. **瓜子验证码**：瓜子反爬较严格，IP 级别封锁。搜索通常可通过，详情页容易被拦截。
3. **懂车帝数字混淆**：价格/里程使用 PUA 字体渲染，`font_decoder.py` 维护映射表。如提取结果异常（全是乱码），可能需要更新映射。
4. **汽车之家 Cookie 挑战**：站点通过 JS 计算设置 Cookie，客户端会自动解析并重试。车系筛选通过动态解析品牌页的拼音 slug 选项实现。
5. **优信拍 Session 认证**：API 需要先访问交易页面获取 `csrfToken` / `jwt_token` Cookie，客户端会自动完成此步骤（每次搜索多一次 GET 请求）。`car_id` 格式为 `auctionId_crykey`，两者缺一不可。车系筛选通过平台品牌 API 动态查询（无需预先第三方映射表）。详情页读取价格、里程、颗色、上牌日期、过户次数、车辆描述等字段。
6. **价格单位**：统一为**万元**，里程单位为**万公里**。

---

## License

MIT


path: scripts/car_cli/logging_config.py
content: """Central logging setup for car-cli (stderr, structured format)."""

from __future__ import annotations

import logging
import os
import sys

LOGGER_NAME = "car_cli"


def _env_truthy(name: str) -> bool:
    return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")


def resolve_debug_flags(
    cli_debug: bool,
    cli_trace_http: bool,
) -> tuple[bool, bool]:
    """Merge CLI flags with environment (CAR_CLI_DEBUG, CAR_CLI_TRACE_HTTP)."""
    debug = cli_debug or _env_truthy("CAR_CLI_DEBUG")
    trace_http = cli_trace_http or _env_truthy("CAR_CLI_TRACE_HTTP")
    return debug, trace_http


def setup_logging(*, debug: bool, trace_http: bool = False) -> None:
    """Configure the ``car_cli`` logger.

    - When *debug* is False: only WARNING+ from ``car_cli`` (no extra noise).
    - When *debug* is True: DEBUG to stderr with timestamp and logger name.
    - When *trace_http* is True (implies useful httpx traces): set httpx/httpcore
      loggers to DEBUG (very verbose).
    """
    root = logging.getLogger(LOGGER_NAME)
    root.handlers.clear()
    root.propagate = False

    if not debug:
        root.setLevel(logging.WARNING)
        return

    root.setLevel(logging.DEBUG)
    handler = logging.StreamHandler(sys.stderr)
    handler.setLevel(logging.DEBUG)
    handler.setFormatter(
        logging.Formatter(
            fmt="%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
            datefmt="%H:%M:%S",
        )
    )
    root.addHandler(handler)

    if trace_http:
        for name in ("httpx", "httpcore"):
            lg = logging.getLogger(name)
            lg.handlers.clear()
            lg.setLevel(logging.DEBUG)
            lg.addHandler(handler)
            lg.propagate = False


def get_logger(suffix: str) -> logging.Logger:
    """Return a child logger, e.g. ``car_cli.http``."""
    return logging.getLogger(f"{LOGGER_NAME}.{suffix}")


path: scripts/car_cli/main.py
content: import click

from car_cli.commands.search import search
from car_cli.commands.detail import detail
from car_cli.commands.compare import compare
from car_cli.commands.loan import loan
from car_cli.commands.export import export_cmd
from car_cli.commands.series import series
from car_cli.logging_config import resolve_debug_flags, setup_logging


@click.group()
@click.option(
    "--debug",
    "-d",
    is_flag=True,
    help="详细调试日志（输出到 stderr：请求、解析步骤、筛选条件等）",
)
@click.option(
    "--trace-http",
    is_flag=True,
    help="同时打开 httpx/httpcore 的 DEBUG 日志（极其冗长；也可设环境变量 CAR_CLI_TRACE_HTTP=1）",
)
@click.version_option(version="0.1.0")
@click.pass_context
def cli(ctx, debug, trace_http):
    """Car CLI — 二手车多平台聚合搜索工具。"""
    ctx.ensure_object(dict)
    dbg, trace = resolve_debug_flags(debug, trace_http)
    if trace and not dbg:
        dbg = True
    ctx.obj["debug"] = dbg
    ctx.obj["trace_http"] = trace
    setup_logging(debug=dbg, trace_http=trace)


cli.add_command(search)
cli.add_command(detail)
cli.add_command(compare)
cli.add_command(loan)
cli.add_command(export_cmd, name="export")
cli.add_command(series)


if __name__ == "__main__":
    cli()


path: scripts/car_cli/client/http.py
content: """HTTP client wrapper with anti-detection headers, jitter delays, burst detection, and retry."""

import asyncio
import collections
import logging
import random
import time

import httpx

from car_cli.logging_config import get_logger

_log = get_logger("http")

DEFAULT_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/145.0.0.0 Safari/537.36"
    ),
    "Accept": (
        "text/html,application/xhtml+xml,application/xml;"
        "q=0.9,image/avif,image/webp,*/*;q=0.8"
    ),
    "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
    "Accept-Encoding": "gzip, deflate, br",
    "DNT": "1",
    "sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not-A.Brand";v="99"',
    "sec-ch-ua-mobile": "?0",
    "sec-ch-ua-platform": '"macOS"',
    "Sec-Fetch-Dest": "document",
    "Sec-Fetch-Mode": "navigate",
    "Sec-Fetch-Site": "same-origin",
    "Sec-Fetch-User": "?1",
    "Priority": "u=0, i",
    "Upgrade-Insecure-Requests": "1",
    "Connection": "keep-alive",
}

MAX_RETRIES = 3
BACKOFF_BASE = 10
BACKOFF_CAP = 60

BURST_WINDOW_SIZE = 12
BURST_SHORT_WINDOW = 15
BURST_SHORT_THRESHOLD = 3
BURST_SHORT_PENALTY = (1.2, 2.8)
BURST_LONG_WINDOW = 45
BURST_LONG_THRESHOLD = 6
BURST_LONG_PENALTY = (4.0, 7.0)


class HttpClient:
    """Async HTTP client with anti-detection, burst detection, and retry logic."""

    _base_delay_multiplier: float = 1.0

    def __init__(self, base_url: str = "", referer: str = "", extra_headers: dict[str, str] | None = None):
        self._request_history: collections.deque = collections.deque(maxlen=BURST_WINDOW_SIZE)
        headers = dict(DEFAULT_HEADERS)
        if referer:
            headers["Referer"] = referer
        if extra_headers:
            headers.update(extra_headers)
        self._client = httpx.AsyncClient(
            headers=headers,
            follow_redirects=True,
            timeout=httpx.Timeout(30.0),
            http2=False,
        )
        self._base_url = base_url

    async def _jitter_delay(self):
        if random.random() < 0.05:
            delay = random.uniform(2.0, 5.0)
        else:
            delay = max(0.1, random.gauss(1.0, 0.3))

        delay *= self._base_delay_multiplier

        now = time.monotonic()
        timestamps = list(self._request_history)

        if timestamps:
            recent_short = sum(1 for t in timestamps if now - t < BURST_SHORT_WINDOW)
            if recent_short >= BURST_SHORT_THRESHOLD:
                delay += random.uniform(*BURST_SHORT_PENALTY)

            recent_long = sum(1 for t in timestamps if now - t < BURST_LONG_WINDOW)
            if recent_long >= BURST_LONG_THRESHOLD:
                delay += random.uniform(*BURST_LONG_PENALTY)

        _log.debug(
            "jitter delay=%.2fs multiplier=%.2f",
            delay,
            self._base_delay_multiplier,
        )
        await asyncio.sleep(delay)
        self._request_history.append(time.monotonic())

    def set_referer(self, referer: str):
        self._client.headers["Referer"] = referer

    async def get(self, url: str, **kwargs) -> httpx.Response:
        """GET with jitter delay, burst detection, and exponential backoff retry."""
        if not url.startswith("http"):
            url = self._base_url.rstrip("/") + "/" + url.lstrip("/")

        cookies = kwargs.get("cookies")
        _log.debug(
            "GET %s cookies=%s params=%s",
            url,
            "yes" if cookies else "no",
            {k: v for k, v in kwargs.items() if k not in ("cookies", "headers")},
        )

        await self._jitter_delay()

        last_exc = None
        t0 = time.perf_counter()
        for attempt in range(MAX_RETRIES):
            try:
                resp = await self._client.get(url, **kwargs)
                elapsed = time.perf_counter() - t0
                if resp.status_code == 429 or resp.status_code >= 500:
                    wait = min(BACKOFF_BASE * (2**attempt), BACKOFF_CAP)
                    _log.debug(
                        "response status=%s attempt=%s/%s wait=%.1fs elapsed=%.2fs",
                        resp.status_code,
                        attempt + 1,
                        MAX_RETRIES,
                        wait,
                        elapsed,
                    )
                    if resp.status_code == 429:
                        HttpClient._base_delay_multiplier = min(
                            HttpClient._base_delay_multiplier * 2, 8.0
                        )
                    await asyncio.sleep(wait)
                    last_exc = httpx.HTTPStatusError(
                        f"HTTP {resp.status_code}",
                        request=resp.request,
                        response=resp,
                    )
                    continue
                resp.raise_for_status()
                _log.debug(
                    "response status=%s bytes=%s elapsed=%.2fs final_url=%s",
                    resp.status_code,
                    len(resp.content),
                    elapsed,
                    str(resp.url),
                )
                if _log.isEnabledFor(logging.DEBUG) and resp.text:
                    preview = resp.text[:400].replace("\n", " ")
                    _log.debug("body preview: %s%s", preview, "…" if len(resp.text) > 400 else "")
                return resp
            except httpx.HTTPStatusError:
                raise
            except httpx.HTTPError as e:
                _log.debug("HTTPError attempt=%s: %s", attempt + 1, e)
                last_exc = e
                wait = min(BACKOFF_BASE * (2**attempt), BACKOFF_CAP)
                await asyncio.sleep(wait)

        _log.debug("GET failed after %s attempts: %s", MAX_RETRIES, last_exc)
        raise last_exc  # type: ignore[misc]

    async def post(self, url: str, **kwargs) -> httpx.Response:
        """POST with jitter delay, burst detection, and exponential backoff retry."""
        if not url.startswith("http"):
            url = self._base_url.rstrip("/") + "/" + url.lstrip("/")

        _log.debug(
            "POST %s content_type=%s",
            url,
            kwargs.get("headers", {}).get("Content-Type", "auto"),
        )

        await self._jitter_delay()

        last_exc = None
        t0 = time.perf_counter()
        for attempt in range(MAX_RETRIES):
            try:
                resp = await self._client.post(url, **kwargs)
                elapsed = time.perf_counter() - t0
                if resp.status_code == 429 or resp.status_code >= 500:
                    wait = min(BACKOFF_BASE * (2**attempt), BACKOFF_CAP)
                    _log.debug(
                        "response status=%s attempt=%s/%s wait=%.1fs elapsed=%.2fs",
                        resp.status_code,
                        attempt + 1,
                        MAX_RETRIES,
                        wait,
                        elapsed,
                    )
                    if resp.status_code == 429:
                        HttpClient._base_delay_multiplier = min(
                            HttpClient._base_delay_multiplier * 2, 8.0
                        )
                    await asyncio.sleep(wait)
                    last_exc = httpx.HTTPStatusError(
                        f"HTTP {resp.status_code}",
                        request=resp.request,
                        response=resp,
                    )
                    continue
                resp.raise_for_status()
                _log.debug(
                    "response status=%s bytes=%s elapsed=%.2fs",
                    resp.status_code,
                    len(resp.content),
                    elapsed,
                )
                if _log.isEnabledFor(logging.DEBUG) and resp.text:
                    preview = resp.text[:400].replace("\n", " ")
                    _log.debug("body preview: %s%s", preview, "…" if len(resp.text) > 400 else "")
                return resp
            except httpx.HTTPStatusError:
                raise
            except httpx.HTTPError as e:
                _log.debug("HTTPError attempt=%s: %s", attempt + 1, e)
                last_exc = e
                wait = min(BACKOFF_BASE * (2**attempt), BACKOFF_CAP)
                await asyncio.sleep(wait)

        _log.debug("POST failed after %s attempts: %s", MAX_RETRIES, last_exc)
        raise last_exc  # type: ignore[misc]

    async def close(self):
        await self._client.aclose()

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        await self.close()


path: scripts/car_cli/client/base.py
content: from abc import ABC, abstractmethod

from car_cli.models.car import Car, CarDetail
from car_cli.models.filter import SearchFilter


class BaseClient(ABC):
    """Abstract base class for all platform adapters.

    Every platform adapter must implement these methods,
    returning unified data models regardless of the source platform.
    """

    platform_name: str = ""

    @abstractmethod
    async def search(self, filters: SearchFilter) -> list[Car]:
        """Search used cars with given filters, return unified Car list."""
        ...

    @abstractmethod
    async def detail(self, car_id: str) -> CarDetail:
        """Get car detail by platform-specific ID."""
        ...

    async def list_series(self, brand: str) -> list[dict[str, str]]:
        """List available series for a brand.

        Returns list of {"series_id": "...", "series_name": "..."}.
        Override in subclasses that support series lookup.
        """
        return []


path: scripts/car_cli/client/adapters/__init__.py
content: from car_cli.client.adapters.dongchedi import DongchediClient  # noqa: F401
from car_cli.client.adapters.guazi import GuaziClient  # noqa: F401
from car_cli.client.adapters.che168 import Che168Client  # noqa: F401
from car_cli.client.adapters.youxinpai import YouxinpaiClient  # noqa: F401

ADAPTER_REGISTRY: dict[str, type] = {
    "dongchedi": DongchediClient,
    "guazi": GuaziClient,
    "che168": Che168Client,
    "youxinpai": YouxinpaiClient,
}

# 在此处注释掉不想使用的平台
ENABLED_PLATFORMS: list[str] = [
    "dongchedi",
    # "guazi",
    "che168",
    "youxinpai",
]

ALL_PLATFORMS = [p for p in ENABLED_PLATFORMS if p in ADAPTER_REGISTRY]


def get_adapters(platform: str) -> list:
    """Return adapter instances for the given platform selector.

    Args:
        platform: "all" or comma-separated platform names (e.g. "dongchedi").

    Returns:
        List of adapter instances.
    """
    if platform == "all":
        names = ALL_PLATFORMS
    else:
        names = [p.strip() for p in platform.split(",")]

    adapters = []
    for name in names:
        cls = ADAPTER_REGISTRY.get(name)
        if cls is None:
            raise _usage_error(f"Unknown platform: {name}")
        adapters.append(cls())
    return adapters


def _usage_error(msg: str):
    import click
    return click.UsageError(msg)


path: scripts/car_cli/client/adapters/youxinpai/__init__.py
content: """Youxinpai (youxinpai.com / 优信拍) adapter package."""

from car_cli.client.adapters.youxinpai.client import YouxinpaiClient

__all__ = ["YouxinpaiClient"]


path: scripts/car_cli/client/adapters/youxinpai/cities.py
content: """City name → youxinpai cityId mapping.

Loaded at import time from bundled city_list.json (scraped from youxinpai API).
The JSON format is a list of provinces, each containing a cityList with cityId/cityName.
"""

import json
from pathlib import Path

_CITY_DATA_FILE = Path(__file__).parent / "city_list.json"

_CITY_MAP: dict[str, int] = {}


def _load_city_map() -> dict[str, int]:
    if _CITY_MAP:
        return _CITY_MAP

    with open(_CITY_DATA_FILE, encoding="utf-8") as f:
        provinces = json.load(f)

    for province in provinces:
        province_name = province.get("provinceName", "")
        city_list = province.get("cityList", [])

        for city in city_list:
            city_name = city.get("cityName", "").strip()
            city_id = city.get("cityId")
            if city_name and city_id is not None:
                _CITY_MAP[city_name] = city_id

        # 直辖市：省名 == 城市名的情况已自动覆盖
        # 但需要确保省名也能匹配到第一个城市（如"广东"不应匹配到任何城市）
        if len(city_list) == 1 and city_list[0].get("cityName") == province_name:
            _CITY_MAP[province_name] = city_list[0]["cityId"]

    return _CITY_MAP


def get_city_id(city_name: str) -> int | None:
    """Return youxinpai cityId for a Chinese city name, or None if not found."""
    city_map = _load_city_map()
    return city_map.get(city_name)


def get_all_city_names() -> list[str]:
    """Return all supported city names."""
    city_map = _load_city_map()
    return list(city_map.keys())


path: scripts/car_cli/client/adapters/guazi/__init__.py
content: """Guazi (guazi.com) adapter package."""

from car_cli.client.adapters.guazi.client import GuaziClient

__all__ = ["GuaziClient"]


path: scripts/car_cli/client/adapters/guazi/cities.py
content: """Guazi city name → URL abbreviation mapping.

Guazi uses pinyin abbreviations in URLs: /bj/buy/, /sh/buy/.
"""

CITY_ABBR: dict[str, str] = {
    "北京": "bj", "上海": "sh", "广州": "gz", "深圳": "sz",
    "成都": "cd", "杭州": "hz", "重庆": "cq", "武汉": "wh",
    "苏州": "su", "南京": "nj", "天津": "tj", "西安": "xa",
    "长沙": "cs", "郑州": "zz", "东莞": "dg", "青岛": "qd",
    "合肥": "hf", "佛山": "fs", "宁波": "nb", "昆明": "km",
    "沈阳": "sy", "大连": "dl", "厦门": "xm", "济南": "jn",
    "无锡": "wx", "福州": "fz", "哈尔滨": "hrb", "石家庄": "sjz",
    "烟台": "yt", "珠海": "zh", "惠州": "hui", "中山": "zs",
    "太原": "ty", "南昌": "nc", "贵阳": "gy", "南宁": "nn",
    "温州": "wz", "常州": "cz", "徐州": "xz", "兰州": "lz",
}


def get_city_abbr(city: str) -> str:
    """Get pinyin abbreviation for a city (e.g. 北京 -> bj). Empty string for unknown."""
    return CITY_ABBR.get(city, "")


path: scripts/car_cli/client/adapters/dongchedi/__init__.py
content: from car_cli.client.adapters.dongchedi.client import DongchediClient

__all__ = ["DongchediClient"]


path: scripts/car_cli/client/adapters/dongchedi/font_decoder.py
content: """Dongchedi font-based number obfuscation decoder.

Dongchedi replaces digits and the '万' character with Private Use Area (PUA)
unicode codepoints rendered by a custom web font.  The mapping is static and
extracted from the font file.
"""

import re

# PUA codepoint -> real character
_CHAR_MAP: dict[str, str] = {
    "\ue439": "0",
    "\ue54c": "1",
    "\ue463": "2",
    "\ue49d": "3",
    "\ue41d": "4",
    "\ue411": "5",
    "\ue534": "6",
    "\ue3eb": "7",
    "\ue4e3": "8",
    "\ue45d": "9",
    "\ue40a": "万",
}

_PUA_PATTERN = re.compile("[" + "".join(_CHAR_MAP.keys()) + "]")


def decode_text(text: str) -> str:
    """Replace all PUA codepoints in *text* with their real characters."""
    return _PUA_PATTERN.sub(lambda m: _CHAR_MAP[m.group()], text)


def decode_number(text: str) -> float | None:
    """Decode obfuscated text and parse it as a float.

    Returns None when the decoded text contains no numeric content.
    """
    decoded = decode_text(text).replace("万", "").replace(",", "").strip()
    if not decoded:
        return None
    try:
        return float(decoded)
    except ValueError:
        return None


path: scripts/car_cli/client/adapters/dongchedi/cities.py
content: """Dongchedi city name → adcode mapping.

Dongchedi uses 6-digit administrative division codes (行政区划码) in URLs.
"""

CITY_ADCODE: dict[str, str] = {
    "北京": "110000", "上海": "310000", "广州": "440100", "深圳": "440300",
    "成都": "510100", "杭州": "330100", "重庆": "500000", "武汉": "420100",
    "苏州": "320500", "南京": "320100", "天津": "120000", "西安": "610100",
    "长沙": "430100", "郑州": "410100", "东莞": "441900", "青岛": "370200",
    "合肥": "340100", "佛山": "440600", "宁波": "330200", "昆明": "530100",
    "沈阳": "210100", "大连": "210200", "厦门": "350200", "济南": "370100",
    "无锡": "320200", "福州": "350100", "哈尔滨": "230100", "石家庄": "130100",
    "烟台": "370600", "珠海": "440400", "惠州": "441300", "中山": "442000",
    "太原": "140100", "南昌": "360100", "贵阳": "520100", "南宁": "450100",
    "温州": "330300", "常州": "320400", "徐州": "320300", "兰州": "620100",
}


def get_city_adcode(city: str) -> str:
    """Get 6-digit administrative code for a city. Empty string for unknown/全国."""
    return CITY_ADCODE.get(city, "")


path: scripts/car_cli/client/adapters/che168/__init__.py
content: """Che168 (che168.com / 汽车之家) adapter package."""

from car_cli.client.adapters.che168.client import Che168Client

__all__ = ["Che168Client"]


path: scripts/car_cli/models/car.py
content: from dataclasses import dataclass, field
from typing import Optional


@dataclass
class Car:
    """Unified used car data model across all platforms."""

    id: str
    platform: str  # dongchedi, guazi, renren
    title: str  # e.g. "2020款 宝马3系 325Li M运动套装"
    price: float  # 万元
    brand: str = ""  # e.g. "宝马"
    series: str = ""  # e.g. "3系"
    model_year: str = ""  # e.g. "2020款"
    mileage: float = 0.0  # 万公里
    first_reg_date: str = ""  # 首次上牌日期
    transmission: str = ""  # 自动/手动
    displacement: str = ""  # e.g. "2.0T"
    city: str = ""
    color: str = ""
    url: str = ""
    tags: list[str] = field(default_factory=list)


@dataclass
class CarDetail(Car):
    """Extended car detail with additional info."""

    description: str = ""
    emission_standard: str = ""  # e.g. "国六"
    engine_power: str = ""  # e.g. "184马力"
    fuel_type: str = ""  # 汽油/柴油/纯电/混动
    body_type: str = ""  # 轿车/SUV/MPV
    drive_type: str = ""  # 前驱/后驱/四驱
    seats: Optional[int] = None
    license_plate: str = ""  # 车牌所在地
    transfer_count: str = ""  # 过户次数
    annual_inspection: str = ""  # 年检到期
    insurance_expiry: str = ""  # 保险到期
    images: list[str] = field(default_factory=list)
    price_history: list[dict] = field(default_factory=list)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)