encarapi 1.0.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.
- encarapi-1.0.0/.gitignore +10 -0
- encarapi-1.0.0/LICENSE +25 -0
- encarapi-1.0.0/PKG-INFO +152 -0
- encarapi-1.0.0/README.md +131 -0
- encarapi-1.0.0/encarapi/__init__.py +26 -0
- encarapi-1.0.0/encarapi/client.py +348 -0
- encarapi-1.0.0/examples/quickstart.py +19 -0
- encarapi-1.0.0/pyproject.toml +47 -0
- encarapi-1.0.0/tests/test_offline.py +115 -0
encarapi-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EnCarAPI (encarapi.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
Note: This MIT license covers the client library only. Access to the EnCarAPI
|
|
24
|
+
service and its data requires a valid API key and is subject to the terms at
|
|
25
|
+
https://encarapi.com.
|
encarapi-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: encarapi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for EnCarAPI: Korean used car data (Encar, KB Chachacha, K Car) and Chinese used car data (Dongchedi, Che168) in one REST API.
|
|
5
|
+
Project-URL: Homepage, https://encarapi.com
|
|
6
|
+
Project-URL: Documentation, https://encarapi.com/documentation
|
|
7
|
+
Project-URL: Source, https://github.com/ThatMojo/encarapi-python
|
|
8
|
+
Project-URL: China data, https://chinacarapi.com
|
|
9
|
+
Author-email: EnCarAPI <support@encarapi.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: car export,che168,china car api,chinese used car data,dongchedi,encar,encar api,encar.com,k car,kb chachacha,kbchacha,kcar,korea car api,korean car api,korean used car data,vehicle data api
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Requires-Dist: requests>=2.20
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# EnCarAPI: Python client for Korean and Chinese used car data
|
|
23
|
+
|
|
24
|
+
Official **Python client** for [EnCarAPI](https://encarapi.com), the REST **Encar API** and
|
|
25
|
+
**Korean Car API**. One package for:
|
|
26
|
+
|
|
27
|
+
- **Korea:** Encar.com, KB Chachacha (`source="kbc"`) and K Car (`source="kcar"`), with
|
|
28
|
+
photos, specs, options, inspection reports, accident and ownership records, condition
|
|
29
|
+
reports, price history, a change feed and full catalog export.
|
|
30
|
+
- **China:** Dongchedi and Che168 in English via [ChinaCarAPI](https://chinacarapi.com)
|
|
31
|
+
(separate key or the China add-on for EnCarAPI keys).
|
|
32
|
+
|
|
33
|
+
Built for car exporters, dealers and marketplaces that need reliable used car data without
|
|
34
|
+
scraping, proxies or geo-blocks.
|
|
35
|
+
|
|
36
|
+
> **An API key is required.** The data is a paid service. Get a key (5-day trial) at
|
|
37
|
+
> **[encarapi.com](https://encarapi.com)**.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install encarapi
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.8+ and `requests`.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from encarapi import EnCarAPI
|
|
51
|
+
|
|
52
|
+
client = EnCarAPI("YOUR_API_KEY") # or set ENCARAPI_KEY
|
|
53
|
+
|
|
54
|
+
# Search Korean listings with flat English filters
|
|
55
|
+
cars = client.korea.catalog(
|
|
56
|
+
manufacturer="Hyundai",
|
|
57
|
+
max_mileage=60000,
|
|
58
|
+
frame_clean=True, # accident-free chassis only
|
|
59
|
+
lang="en",
|
|
60
|
+
count=True,
|
|
61
|
+
)
|
|
62
|
+
print(cars["Count"], cars["SearchResults"][0])
|
|
63
|
+
|
|
64
|
+
# Full detail, inspection report and insurance record for one car
|
|
65
|
+
detail = client.korea.vehicle("12345678")
|
|
66
|
+
inspection = client.korea.inspection("12345678")
|
|
67
|
+
record = client.korea.record("12345678")
|
|
68
|
+
|
|
69
|
+
# KB Chachacha and K Car listings, or all three marketplaces deduplicated
|
|
70
|
+
kbc = client.korea.catalog(source="kbc", limit=20)
|
|
71
|
+
everything = client.korea.catalog(source="all", count=True)
|
|
72
|
+
|
|
73
|
+
# Chinese listings (Dongchedi + Che168)
|
|
74
|
+
byd = client.china.catalog(make="BYD", export_ready=True, limit=25)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Keep a local copy in sync (Business / Scale)
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
csv_text = client.korea.export_csv() # 1) baseline
|
|
81
|
+
|
|
82
|
+
for change in client.korea.iterate_changes(cursor=saved_cursor or 0): # 2) deltas
|
|
83
|
+
# change["type"]: "added" | "updated" | "reappeared" | "removed"
|
|
84
|
+
...
|
|
85
|
+
save_cursor(client.korea.last_cursor)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Korea API (`client.korea`)
|
|
89
|
+
|
|
90
|
+
| Method | Endpoint | Notes |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `catalog(**params)` | `GET /api/catalog` | Search & list. `source`: `encar` (default), `kbc`, `kcar`, `all` |
|
|
93
|
+
| `iterate_catalog(**params)` | `GET /api/catalog` | Generator over all pages |
|
|
94
|
+
| `leasing(**params)` | `GET /api/catalog/leasing` | Lease takeovers and rentals |
|
|
95
|
+
| `nav(**params)` | `GET /api/nav` | Filter facets with counts |
|
|
96
|
+
| `enums(**params)` | `GET /api/enums` | Valid values for the flat filters |
|
|
97
|
+
| `model_search(search, **params)` | `GET /api/model-search` | Model autocomplete |
|
|
98
|
+
| `vehicle(id)` | `GET /api/vehicle/{id}` | Ids: Encar id, `kbc:<id>`, `kcar:<id>` |
|
|
99
|
+
| `inspection(id)` | `GET /api/inspection/{id}` | Official inspection report |
|
|
100
|
+
| `record(id)` | `GET /api/record/{id}` | Accident & ownership record |
|
|
101
|
+
| `refresh(id)` | `POST /api/vehicle/{id}/refresh` | On-demand refresh (daily quota) |
|
|
102
|
+
| `price_check(id)` | `GET /api/price-check/{id}` | Market estimate (beta, Scale) |
|
|
103
|
+
| `bulk_vehicles(ids)` / `bulk_inspections(ids)` / `bulk_records(ids)` | `POST /api/.../bulk` | Up to 500 ids (Business/Scale) |
|
|
104
|
+
| `inspection_versions(id)` / `record_versions(id)` | `GET /api/.../versions` | Report history (Business/Scale) |
|
|
105
|
+
| `changes(**params)` / `iterate_changes(**params)` | `GET /api/catalog/changes` | Incremental sync (Business/Scale) |
|
|
106
|
+
| `export_csv()` | `GET /api/catalog/export` | Full catalog CSV (Business/Scale) |
|
|
107
|
+
| `makes_csv()` / `models_csv()` / `badges_csv()` | `GET /api/taxonomy/*.csv` | Reference data |
|
|
108
|
+
|
|
109
|
+
## China API (`client.china`)
|
|
110
|
+
|
|
111
|
+
| Method | Endpoint | Notes |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `catalog(**params)` | `GET /api/catalog` | `make`, `price_min/max` (CNY), `export_ready`, `sort`, ... |
|
|
114
|
+
| `vehicle(id)` | `GET /api/vehicle/{id}` | Price history, photos, seller, export status |
|
|
115
|
+
| `inspection(id)` | `GET /api/inspection/{id}` | Accident, flood and fire checks, EV battery data |
|
|
116
|
+
| `bulk_vehicles(ids)` | `POST /api/vehicle/bulk` | Up to 500 ids |
|
|
117
|
+
| `changes(**params)` | `GET /api/catalog/changes` | Change feed |
|
|
118
|
+
| `export_csv()` | `GET /api/catalog/export` | Full catalog CSV |
|
|
119
|
+
| `enums()` / `models(make)` | `GET /api/enums`, `/api/models` | Filter values, models per make |
|
|
120
|
+
|
|
121
|
+
China only? `from encarapi import ChinaCarAPI` (also available as the
|
|
122
|
+
[`chinacarapi`](https://pypi.org/project/chinacarapi/) package).
|
|
123
|
+
|
|
124
|
+
## Errors
|
|
125
|
+
|
|
126
|
+
Every failed request raises `EnCarAPIError` with `status` and the raw `body`. A `403` on a
|
|
127
|
+
plan-gated endpoint includes an upgrade hint from the API.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from encarapi import EnCarAPIError
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
client.korea.changes(cursor=0)
|
|
134
|
+
except EnCarAPIError as e:
|
|
135
|
+
if e.status == 403:
|
|
136
|
+
print("Plan does not include this endpoint:", e.body)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Links
|
|
140
|
+
|
|
141
|
+
- Website and pricing: https://encarapi.com
|
|
142
|
+
- Documentation: https://encarapi.com/documentation
|
|
143
|
+
- OpenAPI reference: https://api.encarapi.com/reference
|
|
144
|
+
- China data: https://chinacarapi.com
|
|
145
|
+
- Node.js client: https://github.com/ThatMojo/encarapi-node
|
|
146
|
+
|
|
147
|
+
EnCarAPI is an independent service and not affiliated with Encar, KB Chachacha, K Car,
|
|
148
|
+
Dongchedi or Che168.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
encarapi-1.0.0/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# EnCarAPI: Python client for Korean and Chinese used car data
|
|
2
|
+
|
|
3
|
+
Official **Python client** for [EnCarAPI](https://encarapi.com), the REST **Encar API** and
|
|
4
|
+
**Korean Car API**. One package for:
|
|
5
|
+
|
|
6
|
+
- **Korea:** Encar.com, KB Chachacha (`source="kbc"`) and K Car (`source="kcar"`), with
|
|
7
|
+
photos, specs, options, inspection reports, accident and ownership records, condition
|
|
8
|
+
reports, price history, a change feed and full catalog export.
|
|
9
|
+
- **China:** Dongchedi and Che168 in English via [ChinaCarAPI](https://chinacarapi.com)
|
|
10
|
+
(separate key or the China add-on for EnCarAPI keys).
|
|
11
|
+
|
|
12
|
+
Built for car exporters, dealers and marketplaces that need reliable used car data without
|
|
13
|
+
scraping, proxies or geo-blocks.
|
|
14
|
+
|
|
15
|
+
> **An API key is required.** The data is a paid service. Get a key (5-day trial) at
|
|
16
|
+
> **[encarapi.com](https://encarapi.com)**.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install encarapi
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Python 3.8+ and `requests`.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from encarapi import EnCarAPI
|
|
30
|
+
|
|
31
|
+
client = EnCarAPI("YOUR_API_KEY") # or set ENCARAPI_KEY
|
|
32
|
+
|
|
33
|
+
# Search Korean listings with flat English filters
|
|
34
|
+
cars = client.korea.catalog(
|
|
35
|
+
manufacturer="Hyundai",
|
|
36
|
+
max_mileage=60000,
|
|
37
|
+
frame_clean=True, # accident-free chassis only
|
|
38
|
+
lang="en",
|
|
39
|
+
count=True,
|
|
40
|
+
)
|
|
41
|
+
print(cars["Count"], cars["SearchResults"][0])
|
|
42
|
+
|
|
43
|
+
# Full detail, inspection report and insurance record for one car
|
|
44
|
+
detail = client.korea.vehicle("12345678")
|
|
45
|
+
inspection = client.korea.inspection("12345678")
|
|
46
|
+
record = client.korea.record("12345678")
|
|
47
|
+
|
|
48
|
+
# KB Chachacha and K Car listings, or all three marketplaces deduplicated
|
|
49
|
+
kbc = client.korea.catalog(source="kbc", limit=20)
|
|
50
|
+
everything = client.korea.catalog(source="all", count=True)
|
|
51
|
+
|
|
52
|
+
# Chinese listings (Dongchedi + Che168)
|
|
53
|
+
byd = client.china.catalog(make="BYD", export_ready=True, limit=25)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Keep a local copy in sync (Business / Scale)
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
csv_text = client.korea.export_csv() # 1) baseline
|
|
60
|
+
|
|
61
|
+
for change in client.korea.iterate_changes(cursor=saved_cursor or 0): # 2) deltas
|
|
62
|
+
# change["type"]: "added" | "updated" | "reappeared" | "removed"
|
|
63
|
+
...
|
|
64
|
+
save_cursor(client.korea.last_cursor)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Korea API (`client.korea`)
|
|
68
|
+
|
|
69
|
+
| Method | Endpoint | Notes |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `catalog(**params)` | `GET /api/catalog` | Search & list. `source`: `encar` (default), `kbc`, `kcar`, `all` |
|
|
72
|
+
| `iterate_catalog(**params)` | `GET /api/catalog` | Generator over all pages |
|
|
73
|
+
| `leasing(**params)` | `GET /api/catalog/leasing` | Lease takeovers and rentals |
|
|
74
|
+
| `nav(**params)` | `GET /api/nav` | Filter facets with counts |
|
|
75
|
+
| `enums(**params)` | `GET /api/enums` | Valid values for the flat filters |
|
|
76
|
+
| `model_search(search, **params)` | `GET /api/model-search` | Model autocomplete |
|
|
77
|
+
| `vehicle(id)` | `GET /api/vehicle/{id}` | Ids: Encar id, `kbc:<id>`, `kcar:<id>` |
|
|
78
|
+
| `inspection(id)` | `GET /api/inspection/{id}` | Official inspection report |
|
|
79
|
+
| `record(id)` | `GET /api/record/{id}` | Accident & ownership record |
|
|
80
|
+
| `refresh(id)` | `POST /api/vehicle/{id}/refresh` | On-demand refresh (daily quota) |
|
|
81
|
+
| `price_check(id)` | `GET /api/price-check/{id}` | Market estimate (beta, Scale) |
|
|
82
|
+
| `bulk_vehicles(ids)` / `bulk_inspections(ids)` / `bulk_records(ids)` | `POST /api/.../bulk` | Up to 500 ids (Business/Scale) |
|
|
83
|
+
| `inspection_versions(id)` / `record_versions(id)` | `GET /api/.../versions` | Report history (Business/Scale) |
|
|
84
|
+
| `changes(**params)` / `iterate_changes(**params)` | `GET /api/catalog/changes` | Incremental sync (Business/Scale) |
|
|
85
|
+
| `export_csv()` | `GET /api/catalog/export` | Full catalog CSV (Business/Scale) |
|
|
86
|
+
| `makes_csv()` / `models_csv()` / `badges_csv()` | `GET /api/taxonomy/*.csv` | Reference data |
|
|
87
|
+
|
|
88
|
+
## China API (`client.china`)
|
|
89
|
+
|
|
90
|
+
| Method | Endpoint | Notes |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `catalog(**params)` | `GET /api/catalog` | `make`, `price_min/max` (CNY), `export_ready`, `sort`, ... |
|
|
93
|
+
| `vehicle(id)` | `GET /api/vehicle/{id}` | Price history, photos, seller, export status |
|
|
94
|
+
| `inspection(id)` | `GET /api/inspection/{id}` | Accident, flood and fire checks, EV battery data |
|
|
95
|
+
| `bulk_vehicles(ids)` | `POST /api/vehicle/bulk` | Up to 500 ids |
|
|
96
|
+
| `changes(**params)` | `GET /api/catalog/changes` | Change feed |
|
|
97
|
+
| `export_csv()` | `GET /api/catalog/export` | Full catalog CSV |
|
|
98
|
+
| `enums()` / `models(make)` | `GET /api/enums`, `/api/models` | Filter values, models per make |
|
|
99
|
+
|
|
100
|
+
China only? `from encarapi import ChinaCarAPI` (also available as the
|
|
101
|
+
[`chinacarapi`](https://pypi.org/project/chinacarapi/) package).
|
|
102
|
+
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
Every failed request raises `EnCarAPIError` with `status` and the raw `body`. A `403` on a
|
|
106
|
+
plan-gated endpoint includes an upgrade hint from the API.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from encarapi import EnCarAPIError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
client.korea.changes(cursor=0)
|
|
113
|
+
except EnCarAPIError as e:
|
|
114
|
+
if e.status == 403:
|
|
115
|
+
print("Plan does not include this endpoint:", e.body)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Links
|
|
119
|
+
|
|
120
|
+
- Website and pricing: https://encarapi.com
|
|
121
|
+
- Documentation: https://encarapi.com/documentation
|
|
122
|
+
- OpenAPI reference: https://api.encarapi.com/reference
|
|
123
|
+
- China data: https://chinacarapi.com
|
|
124
|
+
- Node.js client: https://github.com/ThatMojo/encarapi-node
|
|
125
|
+
|
|
126
|
+
EnCarAPI is an independent service and not affiliated with Encar, KB Chachacha, K Car,
|
|
127
|
+
Dongchedi or Che168.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Official Python client for EnCarAPI: Korean used car data (Encar, KB Chachacha,
|
|
2
|
+
K Car) and Chinese used car data (Dongchedi, Che168) in one package.
|
|
3
|
+
|
|
4
|
+
Get an API key at https://encarapi.com (a key is required).
|
|
5
|
+
"""
|
|
6
|
+
from .client import (
|
|
7
|
+
ChinaCarAPI,
|
|
8
|
+
ChinaCarAPIError,
|
|
9
|
+
ChinaClient,
|
|
10
|
+
EnCarAPI,
|
|
11
|
+
EnCarAPIError,
|
|
12
|
+
KoreaClient,
|
|
13
|
+
MissingApiKeyError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "1.0.0"
|
|
17
|
+
__all__ = [
|
|
18
|
+
"EnCarAPI",
|
|
19
|
+
"ChinaCarAPI",
|
|
20
|
+
"KoreaClient",
|
|
21
|
+
"ChinaClient",
|
|
22
|
+
"EnCarAPIError",
|
|
23
|
+
"ChinaCarAPIError",
|
|
24
|
+
"MissingApiKeyError",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Official Python client for EnCarAPI: Korean (Encar, KB Chachacha, K Car) and
|
|
2
|
+
Chinese (Dongchedi, Che168) used car data."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"EnCarAPI",
|
|
13
|
+
"ChinaCarAPI",
|
|
14
|
+
"KoreaClient",
|
|
15
|
+
"ChinaClient",
|
|
16
|
+
"EnCarAPIError",
|
|
17
|
+
"ChinaCarAPIError",
|
|
18
|
+
"MissingApiKeyError",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
KOREA_BASE_URL = "https://api.encarapi.com"
|
|
22
|
+
CHINA_BASE_URL = "https://api.chinacarapi.com"
|
|
23
|
+
DEFAULT_BASE_URL = KOREA_BASE_URL # 0.x compatibility
|
|
24
|
+
SIGNUP_URL = "https://encarapi.com"
|
|
25
|
+
CHINA_SIGNUP_URL = "https://chinacarapi.com"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EnCarAPIError(Exception):
|
|
29
|
+
"""Raised when the API returns an error. ``status`` and ``body`` hold the response."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, status: Optional[int] = None, body: Optional[str] = None) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.status = status
|
|
34
|
+
self.body = body
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ChinaCarAPIError = EnCarAPIError
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MissingApiKeyError(EnCarAPIError):
|
|
41
|
+
"""Raised when no API key is provided."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _enc(value: str) -> str:
|
|
45
|
+
return quote(str(value), safe="")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Http:
|
|
49
|
+
"""Shared requests session: x-api-key header, bool/list params, readable errors."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, api_key: str, base_url: str, signup_url: str, product: str, timeout: float) -> None:
|
|
52
|
+
self.base_url = base_url.rstrip("/")
|
|
53
|
+
self.signup_url = signup_url
|
|
54
|
+
self.product = product
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.session = requests.Session()
|
|
57
|
+
self.session.headers.update({"x-api-key": api_key, "Accept": "application/json"})
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _clean(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
61
|
+
if not params:
|
|
62
|
+
return None
|
|
63
|
+
out: Dict[str, Any] = {}
|
|
64
|
+
for k, v in params.items():
|
|
65
|
+
if v is None:
|
|
66
|
+
continue
|
|
67
|
+
if isinstance(v, bool):
|
|
68
|
+
v = "true" if v else "false"
|
|
69
|
+
elif isinstance(v, (list, tuple, set)):
|
|
70
|
+
v = ",".join(str(x) for x in v)
|
|
71
|
+
out[k] = v
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
def request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None, json: Any = None, text: bool = False) -> Any:
|
|
75
|
+
resp = self.session.request(
|
|
76
|
+
method, f"{self.base_url}{path}", params=self._clean(params), json=json, timeout=self.timeout,
|
|
77
|
+
headers={"Accept": "text/csv"} if text else None,
|
|
78
|
+
)
|
|
79
|
+
if resp.status_code in (401, 403):
|
|
80
|
+
raise EnCarAPIError(
|
|
81
|
+
f"{self.product} rejected the request ({resp.status_code}). Check your key or plan at "
|
|
82
|
+
f"{self.signup_url}. Body: {resp.text[:300]}",
|
|
83
|
+
resp.status_code, resp.text,
|
|
84
|
+
)
|
|
85
|
+
if not resp.ok:
|
|
86
|
+
raise EnCarAPIError(f"{self.product} error {resp.status_code}: {resp.text[:300]}", resp.status_code, resp.text)
|
|
87
|
+
if text:
|
|
88
|
+
return resp.text
|
|
89
|
+
try:
|
|
90
|
+
return resp.json()
|
|
91
|
+
except ValueError:
|
|
92
|
+
return resp.text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class KoreaClient:
|
|
96
|
+
"""Korean used car data: Encar (default), KB Chachacha (``source="kbc"``) and
|
|
97
|
+
K Car (``source="kcar"``); ``source="all"`` is the deduplicated union.
|
|
98
|
+
Endpoints marked Business/Scale return 403 with an upgrade hint on smaller plans."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, api_key: str, *, base_url: str = KOREA_BASE_URL, timeout: float = 30.0) -> None:
|
|
101
|
+
self._http = _Http(api_key, base_url, SIGNUP_URL, "EnCarAPI", timeout)
|
|
102
|
+
self.last_cursor: Optional[int] = None
|
|
103
|
+
|
|
104
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None, text: bool = False) -> Any:
|
|
105
|
+
return self._http.request("GET", path, params, text=text)
|
|
106
|
+
|
|
107
|
+
def _post(self, path: str, json: Any = None) -> Any:
|
|
108
|
+
return self._http.request("POST", path, json=json)
|
|
109
|
+
|
|
110
|
+
# -- search ------------------------------------------------------------
|
|
111
|
+
def catalog(self, **params: Any) -> Any:
|
|
112
|
+
"""Search & list vehicles with flat English filters, e.g.
|
|
113
|
+
``catalog(manufacturer="BMW", max_mileage=50000, frame_clean=True, lang="en")``."""
|
|
114
|
+
return self._get("/api/catalog", params)
|
|
115
|
+
|
|
116
|
+
def leasing(self, **params: Any) -> Any:
|
|
117
|
+
"""Lease-takeover and rental listings (same filters as catalog)."""
|
|
118
|
+
return self._get("/api/catalog/leasing", params)
|
|
119
|
+
|
|
120
|
+
def nav(self, **params: Any) -> Any:
|
|
121
|
+
"""Filter facets with counts (the navigation tree)."""
|
|
122
|
+
return self._get("/api/nav", params)
|
|
123
|
+
|
|
124
|
+
def enums(self, **params: Any) -> Any:
|
|
125
|
+
"""Valid values for the flat filter parameters."""
|
|
126
|
+
return self._get("/api/enums", params)
|
|
127
|
+
|
|
128
|
+
def model_search(self, search: str, **params: Any) -> Any:
|
|
129
|
+
"""Model autocomplete across brands."""
|
|
130
|
+
return self._get("/api/model-search", {**params, "search": search})
|
|
131
|
+
|
|
132
|
+
def iterate_catalog(self, **params: Any) -> Iterator[Dict[str, Any]]:
|
|
133
|
+
"""Yields every listing across all pages: ``for car in korea.iterate_catalog(manufacturer="Kia"): ...``"""
|
|
134
|
+
limit = params.pop("limit", 100)
|
|
135
|
+
page = params.pop("page", 1)
|
|
136
|
+
while True:
|
|
137
|
+
res = self.catalog(**params, limit=limit, page=page)
|
|
138
|
+
items = res.get("SearchResults") or []
|
|
139
|
+
yield from items
|
|
140
|
+
if len(items) < limit:
|
|
141
|
+
return
|
|
142
|
+
page += 1
|
|
143
|
+
|
|
144
|
+
# -- one vehicle ---------------------------------------------------------
|
|
145
|
+
def vehicle(self, vehicle_id: str, **params: Any) -> Any:
|
|
146
|
+
"""Full vehicle detail. Ids: Encar id, ``kbc:<id>`` or ``kcar:<id>``."""
|
|
147
|
+
return self._get(f"/api/vehicle/{_enc(vehicle_id)}", params)
|
|
148
|
+
|
|
149
|
+
def inspection(self, vehicle_id: str) -> Any:
|
|
150
|
+
"""Official inspection report."""
|
|
151
|
+
return self._get(f"/api/inspection/{_enc(vehicle_id)}")
|
|
152
|
+
|
|
153
|
+
def record(self, vehicle_id: str) -> Any:
|
|
154
|
+
"""Insurance accident & ownership record."""
|
|
155
|
+
return self._get(f"/api/record/{_enc(vehicle_id)}")
|
|
156
|
+
|
|
157
|
+
def refresh(self, vehicle_id: str) -> Any:
|
|
158
|
+
"""Request a fresh copy of one listing (daily quota per plan)."""
|
|
159
|
+
return self._post(f"/api/vehicle/{_enc(vehicle_id)}/refresh")
|
|
160
|
+
|
|
161
|
+
def price_check(self, vehicle_id: str) -> Any:
|
|
162
|
+
"""Price check (beta, Scale): below / in line with / above the market."""
|
|
163
|
+
return self._get(f"/api/price-check/{_enc(vehicle_id)}")
|
|
164
|
+
|
|
165
|
+
def inspection_versions(self, vehicle_id: str) -> Any:
|
|
166
|
+
"""Inspection report versions (Business/Scale)."""
|
|
167
|
+
return self._get(f"/api/inspection/{_enc(vehicle_id)}/versions")
|
|
168
|
+
|
|
169
|
+
def record_versions(self, vehicle_id: str) -> Any:
|
|
170
|
+
"""Insurance record versions (Business/Scale)."""
|
|
171
|
+
return self._get(f"/api/record/{_enc(vehicle_id)}/versions")
|
|
172
|
+
|
|
173
|
+
# -- bulk & sync (Business/Scale) ---------------------------------------
|
|
174
|
+
def bulk_vehicles(self, ids: Iterable[str]) -> Any:
|
|
175
|
+
"""Up to 500 vehicle details in one call."""
|
|
176
|
+
return self._post("/api/vehicle/bulk", {"ids": list(ids)})
|
|
177
|
+
|
|
178
|
+
def bulk_inspections(self, ids: Iterable[str]) -> Any:
|
|
179
|
+
"""Up to 500 inspection reports in one call."""
|
|
180
|
+
return self._post("/api/inspection/bulk", {"ids": list(ids)})
|
|
181
|
+
|
|
182
|
+
def bulk_records(self, ids: Iterable[str]) -> Any:
|
|
183
|
+
"""Up to 500 insurance records in one call."""
|
|
184
|
+
return self._post("/api/record/bulk", {"ids": list(ids)})
|
|
185
|
+
|
|
186
|
+
def changes(self, **params: Any) -> Any:
|
|
187
|
+
"""Incremental change feed: ``cursor`` from the previous call, or ``since`` right after an export."""
|
|
188
|
+
return self._get("/api/catalog/changes", params)
|
|
189
|
+
|
|
190
|
+
def iterate_changes(self, **params: Any) -> Iterator[Dict[str, Any]]:
|
|
191
|
+
"""Follows the change feed until drained and yields each change once as
|
|
192
|
+
``{"type": "added" | "updated" | "reappeared" | "removed", **item}``.
|
|
193
|
+
Afterwards ``korea.last_cursor`` is the cursor to resume from."""
|
|
194
|
+
query = dict(params)
|
|
195
|
+
while True:
|
|
196
|
+
res = self.changes(**query)
|
|
197
|
+
for kind in ("added", "updated", "reappeared", "removed"):
|
|
198
|
+
for item in res.get(kind) or []:
|
|
199
|
+
yield {"type": kind, **item}
|
|
200
|
+
if res.get("nextCursor") is not None:
|
|
201
|
+
self.last_cursor = res["nextCursor"]
|
|
202
|
+
if not (res.get("hasMore") or res.get("truncated")) or res.get("nextCursor") is None:
|
|
203
|
+
return
|
|
204
|
+
query = {k: v for k, v in params.items() if k != "since"}
|
|
205
|
+
query["cursor"] = res["nextCursor"]
|
|
206
|
+
|
|
207
|
+
def export_csv(self, **params: Any) -> str:
|
|
208
|
+
"""Full catalog as CSV text."""
|
|
209
|
+
return self._get("/api/catalog/export", params, text=True)
|
|
210
|
+
|
|
211
|
+
# -- reference data -------------------------------------------------------
|
|
212
|
+
def makes_csv(self, **params: Any) -> str:
|
|
213
|
+
"""All makes as CSV."""
|
|
214
|
+
return self._get("/api/taxonomy/makes.csv", params, text=True)
|
|
215
|
+
|
|
216
|
+
def models_csv(self, **params: Any) -> str:
|
|
217
|
+
"""All models as CSV."""
|
|
218
|
+
return self._get("/api/taxonomy/models.csv", params, text=True)
|
|
219
|
+
|
|
220
|
+
def badges_csv(self, **params: Any) -> str:
|
|
221
|
+
"""All badges (trims) as CSV."""
|
|
222
|
+
return self._get("/api/taxonomy/badges.csv", params, text=True)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class ChinaClient:
|
|
226
|
+
"""Chinese used car data (Dongchedi and Che168) in English. Works with a
|
|
227
|
+
ChinaCarAPI key or an EnCarAPI key that has the China add-on."""
|
|
228
|
+
|
|
229
|
+
def __init__(self, api_key: str, *, base_url: str = CHINA_BASE_URL, timeout: float = 30.0) -> None:
|
|
230
|
+
self._http = _Http(api_key, base_url, CHINA_SIGNUP_URL, "ChinaCarAPI", timeout)
|
|
231
|
+
|
|
232
|
+
def catalog(self, **params: Any) -> Any:
|
|
233
|
+
"""Search listings. Filters: source, make, model, year_min/max, price_min/max (CNY),
|
|
234
|
+
mileage_max, city, fuel, has_report, export_ready, sort, page, limit, lang ('zh' = original)."""
|
|
235
|
+
return self._http.request("GET", "/api/catalog", params)
|
|
236
|
+
|
|
237
|
+
def vehicle(self, vehicle_id: str, **params: Any) -> Any:
|
|
238
|
+
"""Full record: price history, photos, seller, export status, alsoListedOn."""
|
|
239
|
+
return self._http.request("GET", f"/api/vehicle/{_enc(vehicle_id)}", params)
|
|
240
|
+
|
|
241
|
+
def inspection(self, vehicle_id: str, **params: Any) -> Any:
|
|
242
|
+
"""Inspection report: accident, flood and fire checks, battery data for EVs."""
|
|
243
|
+
return self._http.request("GET", f"/api/inspection/{_enc(vehicle_id)}", params)
|
|
244
|
+
|
|
245
|
+
def bulk_vehicles(self, ids: Iterable[str], **params: Any) -> Any:
|
|
246
|
+
"""Up to 500 full records in one call (Business/Scale)."""
|
|
247
|
+
return self._http.request("POST", "/api/vehicle/bulk", params, json={"ids": list(ids)})
|
|
248
|
+
|
|
249
|
+
# 0.x name used by the standalone chinacarapi client
|
|
250
|
+
bulk = bulk_vehicles
|
|
251
|
+
|
|
252
|
+
def changes(self, **params: Any) -> Any:
|
|
253
|
+
"""Change feed: ``since`` once, then ``cursor=nextCursor`` (Business/Scale)."""
|
|
254
|
+
return self._http.request("GET", "/api/catalog/changes", params)
|
|
255
|
+
|
|
256
|
+
def export_csv(self, **params: Any) -> str:
|
|
257
|
+
"""Full catalog as CSV text (Business/Scale)."""
|
|
258
|
+
return self._http.request("GET", "/api/catalog/export", params, text=True)
|
|
259
|
+
|
|
260
|
+
def enums(self, **params: Any) -> Any:
|
|
261
|
+
"""Filter values with counts: makes, fuels, cities, sources, sorts."""
|
|
262
|
+
return self._http.request("GET", "/api/enums", params)
|
|
263
|
+
|
|
264
|
+
def models(self, make: str) -> Any:
|
|
265
|
+
"""Models of one make (id or English name) with counts."""
|
|
266
|
+
return self._http.request("GET", "/api/models", {"make": make})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class EnCarAPI:
|
|
270
|
+
"""One client for both markets.
|
|
271
|
+
|
|
272
|
+
from encarapi import EnCarAPI
|
|
273
|
+
|
|
274
|
+
client = EnCarAPI("YOUR_API_KEY") # or set ENCARAPI_KEY
|
|
275
|
+
kr = client.korea.catalog(manufacturer="Hyundai", lang="en", count=True)
|
|
276
|
+
cn = client.china.catalog(make="BYD", limit=25)
|
|
277
|
+
|
|
278
|
+
An API key is **required**: https://encarapi.com (Korea) or https://chinacarapi.com (China).
|
|
279
|
+
EnCarAPI keys with the China add-on work for both. Pass ``china_key`` (or set
|
|
280
|
+
CHINACARAPI_KEY) to use a separate ChinaCarAPI key.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def __init__(
|
|
284
|
+
self,
|
|
285
|
+
api_key: Optional[str] = None,
|
|
286
|
+
*,
|
|
287
|
+
china_key: Optional[str] = None,
|
|
288
|
+
base_url: str = KOREA_BASE_URL,
|
|
289
|
+
china_base_url: str = CHINA_BASE_URL,
|
|
290
|
+
timeout: float = 30.0,
|
|
291
|
+
) -> None:
|
|
292
|
+
api_key = api_key or os.environ.get("ENCARAPI_KEY")
|
|
293
|
+
china_key = china_key or os.environ.get("CHINACARAPI_KEY") or api_key
|
|
294
|
+
if not api_key and not china_key:
|
|
295
|
+
raise MissingApiKeyError(
|
|
296
|
+
"An API key is required. Pass it as EnCarAPI('YOUR_KEY') or set ENCARAPI_KEY "
|
|
297
|
+
f"(Korea) / CHINACARAPI_KEY (China). Get a key at {SIGNUP_URL}"
|
|
298
|
+
)
|
|
299
|
+
self._api_key = api_key
|
|
300
|
+
self._china_key = china_key
|
|
301
|
+
self._base_url = base_url
|
|
302
|
+
self._china_base_url = china_base_url
|
|
303
|
+
self._timeout = timeout
|
|
304
|
+
self._korea: Optional[KoreaClient] = None
|
|
305
|
+
self._china: Optional[ChinaClient] = None
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def korea(self) -> KoreaClient:
|
|
309
|
+
"""Korean data: Encar (default), KB Chachacha (``kbc``), K Car (``kcar``)."""
|
|
310
|
+
if self._korea is None:
|
|
311
|
+
if not self._api_key:
|
|
312
|
+
raise MissingApiKeyError(f"An EnCarAPI key is required for Korean data. Get one at {SIGNUP_URL}")
|
|
313
|
+
self._korea = KoreaClient(self._api_key, base_url=self._base_url, timeout=self._timeout)
|
|
314
|
+
return self._korea
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def china(self) -> ChinaClient:
|
|
318
|
+
"""Chinese data: Dongchedi and Che168."""
|
|
319
|
+
if self._china is None:
|
|
320
|
+
self._china = ChinaClient(self._china_key, base_url=self._china_base_url, timeout=self._timeout) # type: ignore[arg-type]
|
|
321
|
+
return self._china
|
|
322
|
+
|
|
323
|
+
# 0.x shortcuts (Korean catalog)
|
|
324
|
+
def catalog(self, **params: Any) -> Any:
|
|
325
|
+
return self.korea.catalog(**params)
|
|
326
|
+
|
|
327
|
+
def nav(self, **params: Any) -> Any:
|
|
328
|
+
return self.korea.nav(**params)
|
|
329
|
+
|
|
330
|
+
def vehicle(self, vehicle_id: str, **params: Any) -> Any:
|
|
331
|
+
return self.korea.vehicle(vehicle_id, **params)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class ChinaCarAPI(ChinaClient):
|
|
335
|
+
"""China-only client (also published as the ``chinacarapi`` package).
|
|
336
|
+
|
|
337
|
+
from encarapi import ChinaCarAPI
|
|
338
|
+
client = ChinaCarAPI("YOUR_KEY") # or set CHINACARAPI_KEY
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: str = CHINA_BASE_URL, timeout: float = 30.0) -> None:
|
|
342
|
+
api_key = api_key or os.environ.get("CHINACARAPI_KEY")
|
|
343
|
+
if not api_key:
|
|
344
|
+
raise MissingApiKeyError(
|
|
345
|
+
"A ChinaCarAPI key is required. Pass it as ChinaCarAPI('YOUR_KEY') or set CHINACARAPI_KEY. "
|
|
346
|
+
f"Get a key at {CHINA_SIGNUP_URL}"
|
|
347
|
+
)
|
|
348
|
+
super().__init__(api_key, base_url=base_url, timeout=timeout)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""EnCarAPI Python quickstart. Get a key at https://encarapi.com"""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from encarapi import EnCarAPI
|
|
5
|
+
|
|
6
|
+
client = EnCarAPI(os.environ["ENCARAPI_KEY"]) # required
|
|
7
|
+
|
|
8
|
+
# Korean catalog (Encar by default), English values, with total count
|
|
9
|
+
kr = client.korea.catalog(manufacturer="Hyundai", lang="en", limit=5, count=True)
|
|
10
|
+
print("Korea:", kr.get("Count"), "matches")
|
|
11
|
+
|
|
12
|
+
# All three Korean marketplaces, deduplicated (plan-dependent, see encarapi.com/#pricing)
|
|
13
|
+
# everything = client.korea.catalog(source="all", limit=5, count=True)
|
|
14
|
+
|
|
15
|
+
# Full detail for one vehicle (Encar id, "kbc:<id>" or "kcar:<id>")
|
|
16
|
+
# car = client.korea.vehicle("12345678")
|
|
17
|
+
|
|
18
|
+
# Chinese listings (ChinaCarAPI key or EnCarAPI key with the China add-on)
|
|
19
|
+
# cn = client.china.catalog(make="BYD", limit=5)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "encarapi"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python client for EnCarAPI: Korean used car data (Encar, KB Chachacha, K Car) and Chinese used car data (Dongchedi, Che168) in one REST API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "EnCarAPI", email = "support@encarapi.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"encar",
|
|
15
|
+
"encar api",
|
|
16
|
+
"encar.com",
|
|
17
|
+
"kbchacha",
|
|
18
|
+
"kb chachacha",
|
|
19
|
+
"kcar",
|
|
20
|
+
"k car",
|
|
21
|
+
"korean car api",
|
|
22
|
+
"korea car api",
|
|
23
|
+
"korean used car data",
|
|
24
|
+
"china car api",
|
|
25
|
+
"chinese used car data",
|
|
26
|
+
"che168",
|
|
27
|
+
"dongchedi",
|
|
28
|
+
"car export",
|
|
29
|
+
"vehicle data api",
|
|
30
|
+
]
|
|
31
|
+
classifiers = [
|
|
32
|
+
"Development Status :: 5 - Production/Stable",
|
|
33
|
+
"Intended Audience :: Developers",
|
|
34
|
+
"License :: OSI Approved :: MIT License",
|
|
35
|
+
"Programming Language :: Python :: 3",
|
|
36
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
37
|
+
]
|
|
38
|
+
dependencies = ["requests>=2.20"]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://encarapi.com"
|
|
42
|
+
Documentation = "https://encarapi.com/documentation"
|
|
43
|
+
Source = "https://github.com/ThatMojo/encarapi-python"
|
|
44
|
+
"China data" = "https://chinacarapi.com"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["encarapi"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Offline tests: key gating, request shape and iterators via a fake session.
|
|
2
|
+
Run: python -m unittest discover tests"""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
from encarapi import ChinaCarAPI, EnCarAPI, EnCarAPIError, MissingApiKeyError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeResponse:
|
|
12
|
+
def __init__(self, status, payload):
|
|
13
|
+
self.status_code = status
|
|
14
|
+
self.ok = status < 400
|
|
15
|
+
self.text = payload if isinstance(payload, str) else json.dumps(payload)
|
|
16
|
+
|
|
17
|
+
def json(self):
|
|
18
|
+
return json.loads(self.text)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeSession:
|
|
22
|
+
def __init__(self, responses):
|
|
23
|
+
self.responses = list(responses)
|
|
24
|
+
self.calls = []
|
|
25
|
+
self.headers = {}
|
|
26
|
+
|
|
27
|
+
def request(self, method, url, params=None, json=None, timeout=None, headers=None):
|
|
28
|
+
self.calls.append({"method": method, "url": url, "params": params, "json": json, "headers": headers})
|
|
29
|
+
status, payload = self.responses.pop(0) if len(self.responses) > 1 else self.responses[0]
|
|
30
|
+
return FakeResponse(status, payload)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def patch_session(client_http, responses):
|
|
34
|
+
fake = FakeSession(responses)
|
|
35
|
+
fake.headers.update(client_http.session.headers)
|
|
36
|
+
client_http.session = fake
|
|
37
|
+
return fake
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OfflineTests(unittest.TestCase):
|
|
41
|
+
def setUp(self):
|
|
42
|
+
self.env = mock.patch.dict(os.environ, {}, clear=False)
|
|
43
|
+
self.env.start()
|
|
44
|
+
os.environ.pop("ENCARAPI_KEY", None)
|
|
45
|
+
os.environ.pop("CHINACARAPI_KEY", None)
|
|
46
|
+
|
|
47
|
+
def tearDown(self):
|
|
48
|
+
self.env.stop()
|
|
49
|
+
|
|
50
|
+
def test_key_required(self):
|
|
51
|
+
with self.assertRaises(MissingApiKeyError) as ctx:
|
|
52
|
+
EnCarAPI()
|
|
53
|
+
self.assertIn("encarapi.com", str(ctx.exception))
|
|
54
|
+
with self.assertRaises(MissingApiKeyError) as ctx:
|
|
55
|
+
ChinaCarAPI()
|
|
56
|
+
self.assertIn("chinacarapi.com", str(ctx.exception))
|
|
57
|
+
|
|
58
|
+
def test_korea_catalog_params(self):
|
|
59
|
+
client = EnCarAPI("kr_key", china_key="cn_key")
|
|
60
|
+
fake = patch_session(client.korea._http, [(200, {"Count": 1, "SearchResults": [{"Id": "1"}]})])
|
|
61
|
+
res = client.korea.catalog(manufacturer="BMW", frame_clean=True, options=["a", "b"], page=None)
|
|
62
|
+
self.assertEqual(res["Count"], 1)
|
|
63
|
+
call = fake.calls[-1]
|
|
64
|
+
self.assertEqual(call["url"], "https://api.encarapi.com/api/catalog")
|
|
65
|
+
self.assertEqual(call["params"], {"manufacturer": "BMW", "frame_clean": "true", "options": "a,b"})
|
|
66
|
+
self.assertEqual(fake.headers["x-api-key"], "kr_key")
|
|
67
|
+
|
|
68
|
+
def test_prefixed_id_and_bulk(self):
|
|
69
|
+
client = EnCarAPI("kr_key")
|
|
70
|
+
fake = patch_session(client.korea._http, [(200, {})])
|
|
71
|
+
client.korea.vehicle("kbc:123")
|
|
72
|
+
self.assertTrue(fake.calls[-1]["url"].endswith("/api/vehicle/kbc%3A123"))
|
|
73
|
+
client.korea.bulk_vehicles(["1", "2"])
|
|
74
|
+
self.assertEqual(fake.calls[-1]["method"], "POST")
|
|
75
|
+
self.assertEqual(fake.calls[-1]["json"], {"ids": ["1", "2"]})
|
|
76
|
+
|
|
77
|
+
def test_china_uses_china_key_and_host(self):
|
|
78
|
+
client = EnCarAPI("kr_key", china_key="cn_key")
|
|
79
|
+
fake = patch_session(client.china._http, [(200, {"total": 0, "results": []})])
|
|
80
|
+
client.china.catalog(make="BYD")
|
|
81
|
+
self.assertTrue(fake.calls[-1]["url"].startswith("https://api.chinacarapi.com/"))
|
|
82
|
+
self.assertEqual(fake.headers["x-api-key"], "cn_key")
|
|
83
|
+
|
|
84
|
+
def test_iterate_changes(self):
|
|
85
|
+
client = EnCarAPI("kr_key")
|
|
86
|
+
fake = patch_session(client.korea._http, [
|
|
87
|
+
(200, {"nextCursor": 10, "hasMore": True, "added": [{"Id": "a"}], "removed": [{"Id": "b"}]}),
|
|
88
|
+
(200, {"nextCursor": 12, "hasMore": False, "updated": [{"Id": "c"}]}),
|
|
89
|
+
])
|
|
90
|
+
events = list(client.korea.iterate_changes(since="2026-10-01T00:00:00Z"))
|
|
91
|
+
self.assertEqual([f'{e["type"]}:{e["Id"]}' for e in events], ["added:a", "removed:b", "updated:c"])
|
|
92
|
+
self.assertEqual(fake.calls[1]["params"], {"cursor": 10})
|
|
93
|
+
self.assertEqual(client.korea.last_cursor, 12)
|
|
94
|
+
|
|
95
|
+
def test_iterate_catalog_stops_on_short_page(self):
|
|
96
|
+
client = EnCarAPI("kr_key")
|
|
97
|
+
patch_session(client.korea._http, [(200, {"SearchResults": [{"Id": "1"}]})])
|
|
98
|
+
self.assertEqual(len(list(client.korea.iterate_catalog(limit=5))), 1)
|
|
99
|
+
|
|
100
|
+
def test_403_carries_body(self):
|
|
101
|
+
client = EnCarAPI("kr_key")
|
|
102
|
+
patch_session(client.korea._http, [(403, {"error": "Upgrade to Business"})])
|
|
103
|
+
with self.assertRaises(EnCarAPIError) as ctx:
|
|
104
|
+
client.korea.changes(cursor=0)
|
|
105
|
+
self.assertEqual(ctx.exception.status, 403)
|
|
106
|
+
self.assertIn("Upgrade to Business", ctx.exception.body)
|
|
107
|
+
|
|
108
|
+
def test_legacy_shortcuts(self):
|
|
109
|
+
client = EnCarAPI("kr_key")
|
|
110
|
+
patch_session(client.korea._http, [(200, {"Count": 3, "SearchResults": []})])
|
|
111
|
+
self.assertEqual(client.catalog()["Count"], 3)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
unittest.main()
|