tradera-cli 0.1.0__tar.gz → 0.1.2__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.
- {tradera_cli-0.1.0/tradera_cli.egg-info → tradera_cli-0.1.2}/PKG-INFO +14 -1
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/README.md +13 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/pyproject.toml +1 -1
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_api.py +243 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_cli.py +94 -1
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli/__init__.py +1 -1
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli/api.py +119 -4
- tradera_cli-0.1.2/tradera_cli/cli.py +190 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2/tradera_cli.egg-info}/PKG-INFO +14 -1
- tradera_cli-0.1.0/tradera_cli/cli.py +0 -111
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/LICENSE +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/setup.cfg +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_cli_entry.py +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_formatters.py +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_import.py +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_main_module.py +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli/__main__.py +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli/formatters.py +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli.egg-info/SOURCES.txt +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli.egg-info/dependency_links.txt +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli.egg-info/entry_points.txt +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli.egg-info/requires.txt +0 -0
- {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tradera-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: CLI for Tradera web endpoints
|
|
5
5
|
Author: Johnny W
|
|
6
6
|
License-Expression: MIT
|
|
@@ -69,6 +69,11 @@ tradera search "iphone"
|
|
|
69
69
|
tradera search "pokemon" --page 2 --page-size 20
|
|
70
70
|
tradera search "kamera" --sort AddedOn --format json
|
|
71
71
|
tradera search "klocka" --country SE --lang sv --format jsonl
|
|
72
|
+
tradera search "lego" --item-status Sold --format json
|
|
73
|
+
tradera search "lego" --condition "Oanvänt" --format json
|
|
74
|
+
tradera search "lego" --item-type FixedPrice --from-price 100 --to-price 500
|
|
75
|
+
tradera search "lego" --allowed-buyer-regions eu --counties Stockholm Uppsala
|
|
76
|
+
tradera search "lego city" --search-type ExactSearch
|
|
72
77
|
```
|
|
73
78
|
|
|
74
79
|
### Get item details
|
|
@@ -105,6 +110,14 @@ Search command (`tradera search`) supports:
|
|
|
105
110
|
| `--sort` | Sort mode (default: `Relevance`) |
|
|
106
111
|
| `--lang` | Language code (default: `sv`) |
|
|
107
112
|
| `--country` | Shipping country code (default: `SE`) |
|
|
113
|
+
| `--item-status` | Filter by item status: `Active`, `Sold`, `Unsold` |
|
|
114
|
+
| `--condition` | Filter by condition: `Oanvänt`, `Mycket gott skick`, `Gott skick`, `Okej skick`, `Defekt` |
|
|
115
|
+
| `--item-type` | Filter by listing type: `All`, `Auction`, `FixedPrice`, `ContactOnly` |
|
|
116
|
+
| `--from-price` | Minimum price |
|
|
117
|
+
| `--to-price` | Maximum price |
|
|
118
|
+
| `--allowed-buyer-regions` | Buyer region filter: `sweden`, `eu`, `international` |
|
|
119
|
+
| `--counties` | Pickup counties, passed as one or more values, for example `Stockholm Uppsala` |
|
|
120
|
+
| `--search-type` | Search mode. Current supported value: `ExactSearch` |
|
|
108
121
|
| `--no-translate` | Disable automatic translation preference |
|
|
109
122
|
| `--format` | Output format: `table`, `json`, `jsonl` |
|
|
110
123
|
|
|
@@ -39,6 +39,11 @@ tradera search "iphone"
|
|
|
39
39
|
tradera search "pokemon" --page 2 --page-size 20
|
|
40
40
|
tradera search "kamera" --sort AddedOn --format json
|
|
41
41
|
tradera search "klocka" --country SE --lang sv --format jsonl
|
|
42
|
+
tradera search "lego" --item-status Sold --format json
|
|
43
|
+
tradera search "lego" --condition "Oanvänt" --format json
|
|
44
|
+
tradera search "lego" --item-type FixedPrice --from-price 100 --to-price 500
|
|
45
|
+
tradera search "lego" --allowed-buyer-regions eu --counties Stockholm Uppsala
|
|
46
|
+
tradera search "lego city" --search-type ExactSearch
|
|
42
47
|
```
|
|
43
48
|
|
|
44
49
|
### Get item details
|
|
@@ -75,6 +80,14 @@ Search command (`tradera search`) supports:
|
|
|
75
80
|
| `--sort` | Sort mode (default: `Relevance`) |
|
|
76
81
|
| `--lang` | Language code (default: `sv`) |
|
|
77
82
|
| `--country` | Shipping country code (default: `SE`) |
|
|
83
|
+
| `--item-status` | Filter by item status: `Active`, `Sold`, `Unsold` |
|
|
84
|
+
| `--condition` | Filter by condition: `Oanvänt`, `Mycket gott skick`, `Gott skick`, `Okej skick`, `Defekt` |
|
|
85
|
+
| `--item-type` | Filter by listing type: `All`, `Auction`, `FixedPrice`, `ContactOnly` |
|
|
86
|
+
| `--from-price` | Minimum price |
|
|
87
|
+
| `--to-price` | Maximum price |
|
|
88
|
+
| `--allowed-buyer-regions` | Buyer region filter: `sweden`, `eu`, `international` |
|
|
89
|
+
| `--counties` | Pickup counties, passed as one or more values, for example `Stockholm Uppsala` |
|
|
90
|
+
| `--search-type` | Search mode. Current supported value: `ExactSearch` |
|
|
78
91
|
| `--no-translate` | Disable automatic translation preference |
|
|
79
92
|
| `--format` | Output format: `table`, `json`, `jsonl` |
|
|
80
93
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
from urllib.parse import parse_qs, urlparse
|
|
5
|
+
|
|
3
6
|
import requests
|
|
4
7
|
import pytest
|
|
5
8
|
|
|
@@ -212,6 +215,201 @@ def test_search_builds_payload() -> None:
|
|
|
212
215
|
assert payload["sortBy"] == "EndDate"
|
|
213
216
|
|
|
214
217
|
|
|
218
|
+
def test_search_uses_search_page_for_sold_items() -> None:
|
|
219
|
+
client = TraderaClient()
|
|
220
|
+
captured: dict[str, object] = {}
|
|
221
|
+
next_data = {
|
|
222
|
+
"props": {
|
|
223
|
+
"pageProps": {
|
|
224
|
+
"initialState": {
|
|
225
|
+
"discover": {
|
|
226
|
+
"items": [{"itemId": 42, "isActive": False}],
|
|
227
|
+
"filters": {"itemStatus": {"selectedValue": "Sold"}},
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
def fake_request(method: str, path: str, **_kwargs: object) -> str:
|
|
235
|
+
captured["method"] = method
|
|
236
|
+
captured["path"] = path
|
|
237
|
+
return (
|
|
238
|
+
'<script id="__NEXT_DATA__" type="application/json">'
|
|
239
|
+
f"{json.dumps(next_data)}"
|
|
240
|
+
"</script>"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
client._request = fake_request # type: ignore[method-assign]
|
|
244
|
+
|
|
245
|
+
result = client.search("kamera", page=3, sort_by="EndDate", item_status="Sold")
|
|
246
|
+
|
|
247
|
+
assert result["items"][0]["itemId"] == 42
|
|
248
|
+
assert captured["method"] == "GET"
|
|
249
|
+
parsed = urlparse(str(captured["path"]))
|
|
250
|
+
assert parsed.path == "/search"
|
|
251
|
+
assert parse_qs(parsed.query) == {
|
|
252
|
+
"q": ["kamera"],
|
|
253
|
+
"paging": ["3"],
|
|
254
|
+
"sortBy": ["EndDate"],
|
|
255
|
+
"languageCodeIso2": ["sv"],
|
|
256
|
+
"itemStatus": ["Sold"],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_search_uses_search_page_for_condition_filter() -> None:
|
|
261
|
+
client = TraderaClient()
|
|
262
|
+
captured: dict[str, object] = {}
|
|
263
|
+
next_data = {
|
|
264
|
+
"props": {
|
|
265
|
+
"pageProps": {
|
|
266
|
+
"initialState": {
|
|
267
|
+
"discover": {
|
|
268
|
+
"items": [{"itemId": 99}],
|
|
269
|
+
"attributeFilters": [{"parameter": "af-condition", "selectedValues": ["Oanvänt"]}],
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def fake_request(method: str, path: str, **_kwargs: object) -> str:
|
|
277
|
+
captured["method"] = method
|
|
278
|
+
captured["path"] = path
|
|
279
|
+
return (
|
|
280
|
+
'<script id="__NEXT_DATA__" type="application/json">'
|
|
281
|
+
f"{json.dumps(next_data, ensure_ascii=False)}"
|
|
282
|
+
"</script>"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
client._request = fake_request # type: ignore[method-assign]
|
|
286
|
+
|
|
287
|
+
result = client.search("kamera", condition="Oanvänt")
|
|
288
|
+
|
|
289
|
+
assert result["items"][0]["itemId"] == 99
|
|
290
|
+
assert captured["method"] == "GET"
|
|
291
|
+
parsed = urlparse(str(captured["path"]))
|
|
292
|
+
assert parsed.path == "/search"
|
|
293
|
+
assert parse_qs(parsed.query) == {
|
|
294
|
+
"q": ["kamera"],
|
|
295
|
+
"paging": ["1"],
|
|
296
|
+
"sortBy": ["Relevance"],
|
|
297
|
+
"languageCodeIso2": ["sv"],
|
|
298
|
+
"af-condition": ["Oanvänt"],
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_search_uses_search_page_for_additional_page_filters() -> None:
|
|
303
|
+
client = TraderaClient()
|
|
304
|
+
captured: dict[str, object] = {}
|
|
305
|
+
next_data = {
|
|
306
|
+
"props": {
|
|
307
|
+
"pageProps": {
|
|
308
|
+
"initialState": {
|
|
309
|
+
"discover": {
|
|
310
|
+
"items": [{"itemId": 77}],
|
|
311
|
+
"filters": {
|
|
312
|
+
"itemType": {"selectedValue": "FixedPrice"},
|
|
313
|
+
"allowedBuyerRegions": {"selectedValue": "eu;international"},
|
|
314
|
+
},
|
|
315
|
+
"filterPrice": {
|
|
316
|
+
"fromPrice": {"parameter": "fromPrice", "value": 100},
|
|
317
|
+
"toPrice": {"parameter": "toPrice", "value": 500},
|
|
318
|
+
},
|
|
319
|
+
"filterCounties": {"selectedValues": ["Stockholm", "Uppsala"]},
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
def fake_request(method: str, path: str, **_kwargs: object) -> str:
|
|
327
|
+
captured["method"] = method
|
|
328
|
+
captured["path"] = path
|
|
329
|
+
return (
|
|
330
|
+
'<script id="__NEXT_DATA__" type="application/json">'
|
|
331
|
+
f"{json.dumps(next_data, ensure_ascii=False)}"
|
|
332
|
+
"</script>"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
client._request = fake_request # type: ignore[method-assign]
|
|
336
|
+
|
|
337
|
+
result = client.search(
|
|
338
|
+
"kamera",
|
|
339
|
+
item_type="FixedPrice",
|
|
340
|
+
from_price=100,
|
|
341
|
+
to_price=500,
|
|
342
|
+
allowed_buyer_regions="eu",
|
|
343
|
+
counties=["Stockholm", "Uppsala"],
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
assert result["items"][0]["itemId"] == 77
|
|
347
|
+
assert captured["method"] == "GET"
|
|
348
|
+
parsed = urlparse(str(captured["path"]))
|
|
349
|
+
assert parsed.path == "/search"
|
|
350
|
+
assert parse_qs(parsed.query) == {
|
|
351
|
+
"q": ["kamera"],
|
|
352
|
+
"paging": ["1"],
|
|
353
|
+
"sortBy": ["Relevance"],
|
|
354
|
+
"languageCodeIso2": ["sv"],
|
|
355
|
+
"itemType": ["FixedPrice"],
|
|
356
|
+
"fromPrice": ["100"],
|
|
357
|
+
"toPrice": ["500"],
|
|
358
|
+
"allowedBuyerRegions": ["eu;international"],
|
|
359
|
+
"counties": ["Stockholm;Uppsala"],
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_search_uses_search_page_for_search_type() -> None:
|
|
364
|
+
client = TraderaClient()
|
|
365
|
+
captured: dict[str, object] = {}
|
|
366
|
+
next_data = {
|
|
367
|
+
"props": {
|
|
368
|
+
"pageProps": {
|
|
369
|
+
"initialState": {
|
|
370
|
+
"discover": {
|
|
371
|
+
"items": [{"itemId": 88}],
|
|
372
|
+
"queryParams": {"q": "kamera", "searchType": "ExactSearch"},
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
def fake_request(method: str, path: str, **_kwargs: object) -> str:
|
|
380
|
+
captured["method"] = method
|
|
381
|
+
captured["path"] = path
|
|
382
|
+
return (
|
|
383
|
+
'<script id="__NEXT_DATA__" type="application/json">'
|
|
384
|
+
f"{json.dumps(next_data)}"
|
|
385
|
+
"</script>"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
client._request = fake_request # type: ignore[method-assign]
|
|
389
|
+
|
|
390
|
+
result = client.search("kamera", search_type="ExactSearch")
|
|
391
|
+
|
|
392
|
+
assert result["items"][0]["itemId"] == 88
|
|
393
|
+
assert captured["method"] == "GET"
|
|
394
|
+
parsed = urlparse(str(captured["path"]))
|
|
395
|
+
assert parsed.path == "/search"
|
|
396
|
+
assert parse_qs(parsed.query) == {
|
|
397
|
+
"q": ["kamera"],
|
|
398
|
+
"paging": ["1"],
|
|
399
|
+
"sortBy": ["Relevance"],
|
|
400
|
+
"languageCodeIso2": ["sv"],
|
|
401
|
+
"searchType": ["ExactSearch"],
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_search_page_raises_when_next_data_is_missing() -> None:
|
|
406
|
+
client = TraderaClient()
|
|
407
|
+
client._request = lambda *_args, **_kwargs: "<html></html>" # type: ignore[method-assign]
|
|
408
|
+
|
|
409
|
+
with pytest.raises(TraderaApiError, match="Could not parse search page response"):
|
|
410
|
+
client.search("kamera", item_status="Unsold")
|
|
411
|
+
|
|
412
|
+
|
|
215
413
|
def test_item_returns_dict_or_raises() -> None:
|
|
216
414
|
client = TraderaClient()
|
|
217
415
|
client._request = lambda *_args, **_kwargs: {"itemId": 123} # type: ignore[method-assign]
|
|
@@ -222,6 +420,45 @@ def test_item_returns_dict_or_raises() -> None:
|
|
|
222
420
|
client.item(123)
|
|
223
421
|
|
|
224
422
|
|
|
423
|
+
def test_item_payment_calculations_returns_dict() -> None:
|
|
424
|
+
client = TraderaClient()
|
|
425
|
+
next_data = {
|
|
426
|
+
"props": {
|
|
427
|
+
"pageProps": {
|
|
428
|
+
"initialState": {
|
|
429
|
+
"views": {
|
|
430
|
+
"viewItem": {
|
|
431
|
+
"itemDetails": {
|
|
432
|
+
"paymentCalculations": {
|
|
433
|
+
"paymentAmountForBid": 4927,
|
|
434
|
+
"buyerProtectionCostForFixedPrice": 242,
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
client._request = lambda *_args, **_kwargs: ( # type: ignore[method-assign]
|
|
444
|
+
'<script id="__NEXT_DATA__" type="application/json">'
|
|
445
|
+
f"{json.dumps(next_data)}"
|
|
446
|
+
"</script>"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
result = client.item_payment_calculations(123)
|
|
450
|
+
assert result["paymentAmountForBid"] == 4927
|
|
451
|
+
assert result["buyerProtectionCostForFixedPrice"] == 242
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_item_payment_calculations_raises_on_missing_next_data() -> None:
|
|
455
|
+
client = TraderaClient()
|
|
456
|
+
client._request = lambda *_args, **_kwargs: "<html></html>" # type: ignore[method-assign]
|
|
457
|
+
|
|
458
|
+
with pytest.raises(TraderaApiError, match="Could not parse item page response"):
|
|
459
|
+
client.item_payment_calculations(123)
|
|
460
|
+
|
|
461
|
+
|
|
225
462
|
def test_categories_calls_expected_path() -> None:
|
|
226
463
|
client = TraderaClient()
|
|
227
464
|
captured: dict[str, str] = {}
|
|
@@ -242,5 +479,11 @@ def test_categories_calls_expected_path() -> None:
|
|
|
242
479
|
def test_parse_item_id_from_digits_and_url_and_invalid() -> None:
|
|
243
480
|
assert parse_item_id("123456") == 123456
|
|
244
481
|
assert parse_item_id("https://www.tradera.com/item/123456789/test") == 123456789
|
|
482
|
+
assert (
|
|
483
|
+
parse_item_id(
|
|
484
|
+
"https://www.tradera.com/item/1000006/707858418/playstation-5-disc-edition-the-last-of-us-dualsense"
|
|
485
|
+
)
|
|
486
|
+
== 707858418
|
|
487
|
+
)
|
|
245
488
|
with pytest.raises(ValueError, match="Could not parse item id"):
|
|
246
489
|
parse_item_id("https://www.tradera.com/item/no-id")
|
|
@@ -23,6 +23,11 @@ class DummyClient:
|
|
|
23
23
|
raise self.error
|
|
24
24
|
return self.payload or {}
|
|
25
25
|
|
|
26
|
+
def item_payment_calculations(self, _item_id: int) -> dict:
|
|
27
|
+
if self.error:
|
|
28
|
+
raise self.error
|
|
29
|
+
return {}
|
|
30
|
+
|
|
26
31
|
def categories(self, **_kwargs: object) -> list:
|
|
27
32
|
if self.error:
|
|
28
33
|
raise self.error
|
|
@@ -64,6 +69,14 @@ def test_cmd_search_and_no_translate(monkeypatch: pytest.MonkeyPatch, capsys: py
|
|
|
64
69
|
sort="Relevance",
|
|
65
70
|
lang="sv",
|
|
66
71
|
country="SE",
|
|
72
|
+
item_status="Sold",
|
|
73
|
+
condition="Oanvänt",
|
|
74
|
+
item_type="FixedPrice",
|
|
75
|
+
from_price=100,
|
|
76
|
+
to_price=500,
|
|
77
|
+
allowed_buyer_regions="eu",
|
|
78
|
+
counties=["Stockholm", "Uppsala"],
|
|
79
|
+
search_type="ExactSearch",
|
|
67
80
|
no_translate=True,
|
|
68
81
|
format="table",
|
|
69
82
|
)
|
|
@@ -72,11 +85,19 @@ def test_cmd_search_and_no_translate(monkeypatch: pytest.MonkeyPatch, capsys: py
|
|
|
72
85
|
|
|
73
86
|
assert code == 0
|
|
74
87
|
assert calls["automatic_translation_preferred"] is False
|
|
88
|
+
assert calls["item_status"] == "Sold"
|
|
89
|
+
assert calls["condition"] == "Oanvänt"
|
|
90
|
+
assert calls["item_type"] == "FixedPrice"
|
|
91
|
+
assert calls["from_price"] == 100
|
|
92
|
+
assert calls["to_price"] == 500
|
|
93
|
+
assert calls["allowed_buyer_regions"] == "eu"
|
|
94
|
+
assert calls["counties"] == ["Stockholm", "Uppsala"]
|
|
95
|
+
assert calls["search_type"] == "ExactSearch"
|
|
75
96
|
out = capsys.readouterr().out
|
|
76
97
|
assert "itemId" in out
|
|
77
98
|
|
|
78
99
|
|
|
79
|
-
def
|
|
100
|
+
def test_cmd_item_table_json_and_jsonl(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
80
101
|
class ItemClient(DummyClient):
|
|
81
102
|
def item(self, _item_id: int) -> dict:
|
|
82
103
|
return {
|
|
@@ -84,9 +105,13 @@ def test_cmd_item_table_and_json(monkeypatch: pytest.MonkeyPatch, capsys: pytest
|
|
|
84
105
|
"shortDescription": "Demo",
|
|
85
106
|
"buyNowPrice": 99,
|
|
86
107
|
"currency": "SEK",
|
|
108
|
+
"buyerProtectionInfo": {"totalPrice": 109, "fee": 10},
|
|
87
109
|
"seller": {"alias": "john"},
|
|
88
110
|
}
|
|
89
111
|
|
|
112
|
+
def item_payment_calculations(self, _item_id: int) -> dict:
|
|
113
|
+
return {}
|
|
114
|
+
|
|
90
115
|
monkeypatch.setattr(cli, "TraderaClient", ItemClient)
|
|
91
116
|
|
|
92
117
|
table_args = argparse.Namespace(item="12", format="table")
|
|
@@ -94,11 +119,24 @@ def test_cmd_item_table_and_json(monkeypatch: pytest.MonkeyPatch, capsys: pytest
|
|
|
94
119
|
table_out = capsys.readouterr().out
|
|
95
120
|
assert "seller" in table_out
|
|
96
121
|
assert "john" in table_out
|
|
122
|
+
assert "buyerProtectionTotal" in table_out
|
|
123
|
+
assert "109" in table_out
|
|
124
|
+
assert "10" in table_out
|
|
97
125
|
|
|
98
126
|
json_args = argparse.Namespace(item="12", format="json")
|
|
99
127
|
assert cli.cmd_item(json_args) == 0
|
|
100
128
|
json_out = capsys.readouterr().out
|
|
101
129
|
assert '"itemId": 12' in json_out
|
|
130
|
+
assert '"buyerProtectionTotal": 109' in json_out
|
|
131
|
+
assert '"buyerProtectionFee": 10' in json_out
|
|
132
|
+
|
|
133
|
+
jsonl_args = argparse.Namespace(item="12", format="jsonl")
|
|
134
|
+
assert cli.cmd_item(jsonl_args) == 0
|
|
135
|
+
jsonl_out = capsys.readouterr().out.strip()
|
|
136
|
+
assert '"itemId": 12' in jsonl_out
|
|
137
|
+
assert '"buyerProtectionTotal": 109' in jsonl_out
|
|
138
|
+
assert '"buyerProtectionFee": 10' in jsonl_out
|
|
139
|
+
assert "\n" not in jsonl_out
|
|
102
140
|
|
|
103
141
|
|
|
104
142
|
def test_cmd_item_table_handles_non_dict_seller(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
@@ -106,6 +144,9 @@ def test_cmd_item_table_handles_non_dict_seller(monkeypatch: pytest.MonkeyPatch,
|
|
|
106
144
|
def item(self, _item_id: int) -> dict:
|
|
107
145
|
return {"id": 33, "title": "No seller dict", "seller": "none"}
|
|
108
146
|
|
|
147
|
+
def item_payment_calculations(self, _item_id: int) -> dict:
|
|
148
|
+
return {}
|
|
149
|
+
|
|
109
150
|
monkeypatch.setattr(cli, "TraderaClient", ItemClient)
|
|
110
151
|
|
|
111
152
|
args = argparse.Namespace(item="33", format="table")
|
|
@@ -114,6 +155,58 @@ def test_cmd_item_table_handles_non_dict_seller(monkeypatch: pytest.MonkeyPatch,
|
|
|
114
155
|
assert "No seller dict" in out
|
|
115
156
|
|
|
116
157
|
|
|
158
|
+
def test_cmd_item_table_supports_hyphenated_buyer_protection_key(
|
|
159
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
160
|
+
) -> None:
|
|
161
|
+
class ItemClient(DummyClient):
|
|
162
|
+
def item(self, _item_id: int) -> dict:
|
|
163
|
+
return {
|
|
164
|
+
"id": 44,
|
|
165
|
+
"title": "With fee",
|
|
166
|
+
"buyer-protection-info": {"priceTotal": 220, "serviceFee": 20},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def item_payment_calculations(self, _item_id: int) -> dict:
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
monkeypatch.setattr(cli, "TraderaClient", ItemClient)
|
|
173
|
+
|
|
174
|
+
args = argparse.Namespace(item="44", format="table")
|
|
175
|
+
assert cli.cmd_item(args) == 0
|
|
176
|
+
out = capsys.readouterr().out
|
|
177
|
+
assert "buyerProtectionTotal" in out
|
|
178
|
+
assert "220" in out
|
|
179
|
+
assert "20" in out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_cmd_item_table_uses_payment_calculations_for_bid_total(
|
|
183
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
184
|
+
) -> None:
|
|
185
|
+
class ItemClient(DummyClient):
|
|
186
|
+
def item(self, _item_id: int) -> dict:
|
|
187
|
+
return {
|
|
188
|
+
"itemId": 55,
|
|
189
|
+
"shortDescription": "Auction item",
|
|
190
|
+
"leadingBid": 4690,
|
|
191
|
+
"bidCount": 1,
|
|
192
|
+
"currency": "SEK",
|
|
193
|
+
"seller": {"alias": "john"},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def item_payment_calculations(self, _item_id: int) -> dict:
|
|
197
|
+
return {"paymentAmountForBid": 4927}
|
|
198
|
+
|
|
199
|
+
monkeypatch.setattr(cli, "TraderaClient", ItemClient)
|
|
200
|
+
|
|
201
|
+
args = argparse.Namespace(item="55", format="table")
|
|
202
|
+
assert cli.cmd_item(args) == 0
|
|
203
|
+
out = capsys.readouterr().out
|
|
204
|
+
assert "buyerProtectionTotal" in out
|
|
205
|
+
assert "4927" in out
|
|
206
|
+
assert "237" in out
|
|
207
|
+
assert "4690" in out
|
|
208
|
+
|
|
209
|
+
|
|
117
210
|
def test_cmd_categories_jsonl(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
118
211
|
class CategoriesClient(DummyClient):
|
|
119
212
|
def categories(self, **_kwargs: object) -> list:
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__all__ = ["__version__"]
|
|
2
|
-
__version__ = "0.1.
|
|
2
|
+
__version__ = "0.1.1"
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import re
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
from typing import Any
|
|
7
|
+
from urllib.parse import urlencode, urlparse
|
|
6
8
|
|
|
7
9
|
import requests
|
|
8
10
|
from requests import RequestException
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
BASE_URL = "https://www.tradera.com"
|
|
14
|
+
ALLOWED_BUYER_REGIONS = {
|
|
15
|
+
"sweden": "sweden;eu;international",
|
|
16
|
+
"eu": "eu;international",
|
|
17
|
+
"international": "international",
|
|
18
|
+
}
|
|
12
19
|
|
|
13
20
|
|
|
14
21
|
class TraderaApiError(RuntimeError):
|
|
@@ -25,7 +32,7 @@ class TraderaClient:
|
|
|
25
32
|
self.session.headers.update(
|
|
26
33
|
{
|
|
27
34
|
"accept": "application/json, text/plain, */*",
|
|
28
|
-
"user-agent": "tradera-cli/0.1.
|
|
35
|
+
"user-agent": "tradera-cli/0.1.1",
|
|
29
36
|
}
|
|
30
37
|
)
|
|
31
38
|
|
|
@@ -84,7 +91,43 @@ class TraderaClient:
|
|
|
84
91
|
language_code_iso2: str = "sv",
|
|
85
92
|
shipping_country_code_iso2: str = "SE",
|
|
86
93
|
automatic_translation_preferred: bool = True,
|
|
94
|
+
item_status: str | None = None,
|
|
95
|
+
condition: str | None = None,
|
|
96
|
+
item_type: str | None = None,
|
|
97
|
+
from_price: int | None = None,
|
|
98
|
+
to_price: int | None = None,
|
|
99
|
+
allowed_buyer_regions: str | None = None,
|
|
100
|
+
counties: list[str] | None = None,
|
|
101
|
+
search_type: str | None = None,
|
|
87
102
|
) -> dict[str, Any]:
|
|
103
|
+
normalized_item_status = item_status or "Active"
|
|
104
|
+
page_filters: dict[str, str] = {}
|
|
105
|
+
if search_type:
|
|
106
|
+
page_filters["searchType"] = search_type
|
|
107
|
+
if normalized_item_status in {"Sold", "Unsold"}:
|
|
108
|
+
page_filters["itemStatus"] = normalized_item_status
|
|
109
|
+
if condition:
|
|
110
|
+
page_filters["af-condition"] = condition
|
|
111
|
+
if item_type and item_type != "All":
|
|
112
|
+
page_filters["itemType"] = item_type
|
|
113
|
+
if from_price is not None:
|
|
114
|
+
page_filters["fromPrice"] = str(from_price)
|
|
115
|
+
if to_price is not None:
|
|
116
|
+
page_filters["toPrice"] = str(to_price)
|
|
117
|
+
if allowed_buyer_regions:
|
|
118
|
+
page_filters["allowedBuyerRegions"] = ALLOWED_BUYER_REGIONS[allowed_buyer_regions]
|
|
119
|
+
if counties:
|
|
120
|
+
page_filters["counties"] = ";".join(counties)
|
|
121
|
+
|
|
122
|
+
if page_filters:
|
|
123
|
+
return self._search_page(
|
|
124
|
+
query=query,
|
|
125
|
+
page=page,
|
|
126
|
+
sort_by=sort_by,
|
|
127
|
+
language_code_iso2=language_code_iso2,
|
|
128
|
+
filters=page_filters,
|
|
129
|
+
)
|
|
130
|
+
|
|
88
131
|
payload: dict[str, Any] = {
|
|
89
132
|
"isCsaSearchQuery": False,
|
|
90
133
|
"query": query,
|
|
@@ -118,12 +161,72 @@ class TraderaClient:
|
|
|
118
161
|
}
|
|
119
162
|
return self._request("POST", "/api/webapi/discover/web/independent-search", json=payload)
|
|
120
163
|
|
|
164
|
+
def _search_page(
|
|
165
|
+
self,
|
|
166
|
+
query: str,
|
|
167
|
+
page: int,
|
|
168
|
+
sort_by: str,
|
|
169
|
+
language_code_iso2: str,
|
|
170
|
+
filters: dict[str, str],
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
params = {
|
|
173
|
+
"q": query,
|
|
174
|
+
"paging": page,
|
|
175
|
+
"sortBy": sort_by,
|
|
176
|
+
"languageCodeIso2": language_code_iso2.lower(),
|
|
177
|
+
}
|
|
178
|
+
params.update(filters)
|
|
179
|
+
html = self._request("GET", f"/search?{urlencode(params)}")
|
|
180
|
+
if not isinstance(html, str):
|
|
181
|
+
raise TraderaApiError("Unexpected search page response")
|
|
182
|
+
|
|
183
|
+
match = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', html)
|
|
184
|
+
if not match:
|
|
185
|
+
raise TraderaApiError("Could not parse search page response")
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
data = json.loads(match.group(1))
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise TraderaApiError("Invalid search page data") from exc
|
|
191
|
+
|
|
192
|
+
discover = data.get("props", {}).get("pageProps", {}).get("initialState", {}).get("discover")
|
|
193
|
+
if not isinstance(discover, dict):
|
|
194
|
+
raise TraderaApiError("Unexpected search page data")
|
|
195
|
+
return discover
|
|
196
|
+
|
|
121
197
|
def item(self, item_id: int) -> dict[str, Any]:
|
|
122
198
|
data = self._request("GET", f"/ajax/item/{item_id}")
|
|
123
199
|
if not isinstance(data, dict):
|
|
124
200
|
raise TraderaApiError(f"Unexpected item response for item {item_id}")
|
|
125
201
|
return data
|
|
126
202
|
|
|
203
|
+
def item_payment_calculations(self, item_id: int) -> dict[str, Any]:
|
|
204
|
+
html = self._request("GET", f"/item/{item_id}")
|
|
205
|
+
if not isinstance(html, str):
|
|
206
|
+
raise TraderaApiError(f"Unexpected item page response for item {item_id}")
|
|
207
|
+
|
|
208
|
+
match = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', html)
|
|
209
|
+
if not match:
|
|
210
|
+
raise TraderaApiError(f"Could not parse item page response for item {item_id}")
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
data = json.loads(match.group(1))
|
|
214
|
+
except ValueError as exc:
|
|
215
|
+
raise TraderaApiError(f"Invalid item page data for item {item_id}") from exc
|
|
216
|
+
|
|
217
|
+
payment_calculations = (
|
|
218
|
+
data.get("props", {})
|
|
219
|
+
.get("pageProps", {})
|
|
220
|
+
.get("initialState", {})
|
|
221
|
+
.get("views", {})
|
|
222
|
+
.get("viewItem", {})
|
|
223
|
+
.get("itemDetails", {})
|
|
224
|
+
.get("paymentCalculations", {})
|
|
225
|
+
)
|
|
226
|
+
if not isinstance(payment_calculations, dict):
|
|
227
|
+
raise TraderaApiError(f"Unexpected payment calculations for item {item_id}")
|
|
228
|
+
return payment_calculations
|
|
229
|
+
|
|
127
230
|
def categories(self, level: int = 1, lang: str = "sv") -> Any:
|
|
128
231
|
return self._request("GET", f"/api/categories/{level}?languageCodeIso2={lang}&next=1")
|
|
129
232
|
|
|
@@ -131,7 +234,19 @@ class TraderaClient:
|
|
|
131
234
|
def parse_item_id(value: str) -> int:
|
|
132
235
|
if value.isdigit():
|
|
133
236
|
return int(value)
|
|
134
|
-
|
|
135
|
-
|
|
237
|
+
parsed = urlparse(value)
|
|
238
|
+
path = parsed.path or value
|
|
239
|
+
segments = [segment for segment in path.split("/") if segment]
|
|
240
|
+
|
|
241
|
+
if "item" in segments:
|
|
242
|
+
item_index = segments.index("item")
|
|
243
|
+
numeric_after_item = [segment for segment in segments[item_index + 1 :] if segment.isdigit()]
|
|
244
|
+
if len(numeric_after_item) >= 2:
|
|
245
|
+
return int(numeric_after_item[1])
|
|
246
|
+
if numeric_after_item:
|
|
247
|
+
return int(numeric_after_item[0])
|
|
248
|
+
|
|
249
|
+
matches = re.findall(r"(\d{6,})", value)
|
|
250
|
+
if not matches:
|
|
136
251
|
raise ValueError(f"Could not parse item id from: {value}")
|
|
137
|
-
return int(
|
|
252
|
+
return int(matches[-1])
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .api import TraderaApiError, TraderaClient, parse_item_id
|
|
8
|
+
from .formatters import (
|
|
9
|
+
normalize_categories_rows,
|
|
10
|
+
normalize_search_rows,
|
|
11
|
+
to_json,
|
|
12
|
+
to_jsonl,
|
|
13
|
+
to_table,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _extract_buyer_protection(data: dict[str, Any], payment_calculations: dict[str, Any] | None = None) -> tuple[Any, Any]:
|
|
18
|
+
info = data.get("buyerProtectionInfo")
|
|
19
|
+
if not isinstance(info, dict):
|
|
20
|
+
info = data.get("buyer-protection-info")
|
|
21
|
+
if not isinstance(info, dict):
|
|
22
|
+
info = {}
|
|
23
|
+
|
|
24
|
+
total = info.get("total") or info.get("totalPrice") or info.get("totalAmount") or info.get("priceTotal") or ""
|
|
25
|
+
fee = info.get("fee") or info.get("buyerProtectionFee") or info.get("serviceFee") or ""
|
|
26
|
+
|
|
27
|
+
if isinstance(payment_calculations, dict):
|
|
28
|
+
if data.get("bidCount") and payment_calculations.get("paymentAmountForBid") is not None:
|
|
29
|
+
total = payment_calculations.get("paymentAmountForBid")
|
|
30
|
+
leading_bid = data.get("leadingBid")
|
|
31
|
+
if isinstance(leading_bid, (int, float)) and isinstance(total, (int, float)):
|
|
32
|
+
fee = total - leading_bid
|
|
33
|
+
elif payment_calculations.get("paymentAmountForFixedPrice") is not None:
|
|
34
|
+
total = payment_calculations.get("paymentAmountForFixedPrice")
|
|
35
|
+
fixed_price = data.get("fixedPrice")
|
|
36
|
+
if payment_calculations.get("buyerProtectionCostForFixedPrice") is not None:
|
|
37
|
+
fee = payment_calculations.get("buyerProtectionCostForFixedPrice")
|
|
38
|
+
elif isinstance(fixed_price, (int, float)) and isinstance(total, (int, float)):
|
|
39
|
+
fee = total - fixed_price
|
|
40
|
+
return total, fee
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _print_output(raw: Any, rows: list[dict[str, Any]], fmt: str, columns: list[str]) -> None:
|
|
44
|
+
if fmt == "json":
|
|
45
|
+
print(to_json(raw))
|
|
46
|
+
return
|
|
47
|
+
if fmt == "jsonl":
|
|
48
|
+
print(to_jsonl(rows))
|
|
49
|
+
return
|
|
50
|
+
print(to_table(rows, columns))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
54
|
+
client = TraderaClient()
|
|
55
|
+
data = client.search(
|
|
56
|
+
query=args.query,
|
|
57
|
+
page=args.page,
|
|
58
|
+
page_size=args.page_size,
|
|
59
|
+
sort_by=args.sort,
|
|
60
|
+
language_code_iso2=args.lang,
|
|
61
|
+
shipping_country_code_iso2=args.country,
|
|
62
|
+
automatic_translation_preferred=not args.no_translate,
|
|
63
|
+
item_status=args.item_status,
|
|
64
|
+
condition=args.condition,
|
|
65
|
+
item_type=args.item_type,
|
|
66
|
+
from_price=args.from_price,
|
|
67
|
+
to_price=args.to_price,
|
|
68
|
+
allowed_buyer_regions=args.allowed_buyer_regions,
|
|
69
|
+
counties=args.counties,
|
|
70
|
+
search_type=args.search_type,
|
|
71
|
+
)
|
|
72
|
+
rows = normalize_search_rows(data)
|
|
73
|
+
_print_output(
|
|
74
|
+
raw=data,
|
|
75
|
+
rows=rows,
|
|
76
|
+
fmt=args.format,
|
|
77
|
+
columns=["itemId", "title", "price", "currency", "endDate", "url"],
|
|
78
|
+
)
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_item(args: argparse.Namespace) -> int:
|
|
83
|
+
client = TraderaClient()
|
|
84
|
+
item_id = parse_item_id(args.item)
|
|
85
|
+
data = client.item(item_id)
|
|
86
|
+
payment_calculations: dict[str, Any] = {}
|
|
87
|
+
try:
|
|
88
|
+
payment_calculations = client.item_payment_calculations(item_id)
|
|
89
|
+
except TraderaApiError:
|
|
90
|
+
payment_calculations = {}
|
|
91
|
+
|
|
92
|
+
buyer_protection_total, buyer_protection_fee = _extract_buyer_protection(data, payment_calculations)
|
|
93
|
+
|
|
94
|
+
if args.format == "table":
|
|
95
|
+
row = {
|
|
96
|
+
"itemId": data.get("itemId") or data.get("id") or item_id,
|
|
97
|
+
"title": data.get("shortDescription") or data.get("title") or "",
|
|
98
|
+
"price": (
|
|
99
|
+
data.get("buyNowPrice")
|
|
100
|
+
or data.get("nextBid")
|
|
101
|
+
or data.get("price")
|
|
102
|
+
or data.get("leadingBid")
|
|
103
|
+
or data.get("fixedPrice")
|
|
104
|
+
or data.get("openingBid")
|
|
105
|
+
or ""
|
|
106
|
+
),
|
|
107
|
+
"currency": data.get("currency") or "SEK",
|
|
108
|
+
"buyerProtectionTotal": buyer_protection_total,
|
|
109
|
+
"buyerProtectionFee": buyer_protection_fee,
|
|
110
|
+
"seller": (data.get("seller") or {}).get("alias") if isinstance(data.get("seller"), dict) else "",
|
|
111
|
+
}
|
|
112
|
+
print(
|
|
113
|
+
to_table(
|
|
114
|
+
[row],
|
|
115
|
+
["itemId", "title", "price", "currency", "buyerProtectionTotal", "buyerProtectionFee", "seller"],
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
enriched: dict[str, Any] = {}
|
|
120
|
+
inserted = False
|
|
121
|
+
for key, value in data.items():
|
|
122
|
+
enriched[key] = value
|
|
123
|
+
if key == "leadingBid":
|
|
124
|
+
enriched["buyerProtectionTotal"] = buyer_protection_total
|
|
125
|
+
enriched["buyerProtectionFee"] = buyer_protection_fee
|
|
126
|
+
inserted = True
|
|
127
|
+
if not inserted:
|
|
128
|
+
enriched["buyerProtectionTotal"] = buyer_protection_total
|
|
129
|
+
enriched["buyerProtectionFee"] = buyer_protection_fee
|
|
130
|
+
if args.format == "jsonl":
|
|
131
|
+
print(to_jsonl([enriched]))
|
|
132
|
+
else:
|
|
133
|
+
print(to_json(enriched))
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_categories(args: argparse.Namespace) -> int:
|
|
138
|
+
client = TraderaClient()
|
|
139
|
+
data = client.categories(level=args.level, lang=args.lang)
|
|
140
|
+
rows = normalize_categories_rows(data)
|
|
141
|
+
_print_output(raw=data, rows=rows, fmt=args.format, columns=["id", "name", "level"])
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
146
|
+
parser = argparse.ArgumentParser(prog="tradera", description="Tradera CLI")
|
|
147
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
148
|
+
|
|
149
|
+
search = subparsers.add_parser("search", help="Search listings")
|
|
150
|
+
search.add_argument("query", help="Search query")
|
|
151
|
+
search.add_argument("--page", type=int, default=1)
|
|
152
|
+
search.add_argument("--page-size", type=int, default=50)
|
|
153
|
+
search.add_argument("--sort", default="Relevance")
|
|
154
|
+
search.add_argument("--lang", default="sv")
|
|
155
|
+
search.add_argument("--country", default="SE")
|
|
156
|
+
search.add_argument("--item-status", choices=["Active", "Sold", "Unsold"])
|
|
157
|
+
search.add_argument("--condition", choices=["Oanvänt", "Mycket gott skick", "Gott skick", "Okej skick", "Defekt"])
|
|
158
|
+
search.add_argument("--item-type", choices=["All", "Auction", "FixedPrice", "ContactOnly"])
|
|
159
|
+
search.add_argument("--from-price", type=int)
|
|
160
|
+
search.add_argument("--to-price", type=int)
|
|
161
|
+
search.add_argument("--allowed-buyer-regions", choices=["sweden", "eu", "international"])
|
|
162
|
+
search.add_argument("--counties", nargs="+")
|
|
163
|
+
search.add_argument("--search-type", choices=["ExactSearch"])
|
|
164
|
+
search.add_argument("--no-translate", action="store_true")
|
|
165
|
+
search.add_argument("--format", choices=["table", "json", "jsonl"], default="table")
|
|
166
|
+
search.set_defaults(func=cmd_search)
|
|
167
|
+
|
|
168
|
+
item = subparsers.add_parser("item", help="Get item details")
|
|
169
|
+
item.add_argument("item", help="Item id or item URL")
|
|
170
|
+
item.add_argument("--format", choices=["table", "json", "jsonl"], default="json")
|
|
171
|
+
item.set_defaults(func=cmd_item)
|
|
172
|
+
|
|
173
|
+
categories = subparsers.add_parser("categories", help="List categories by level")
|
|
174
|
+
categories.add_argument("--level", type=int, default=1)
|
|
175
|
+
categories.add_argument("--lang", default="sv")
|
|
176
|
+
categories.add_argument("--format", choices=["table", "json", "jsonl"], default="table")
|
|
177
|
+
categories.set_defaults(func=cmd_categories)
|
|
178
|
+
|
|
179
|
+
return parser
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main(argv: list[str] | None = None) -> None:
|
|
183
|
+
parser = build_parser()
|
|
184
|
+
args = parser.parse_args(argv)
|
|
185
|
+
try:
|
|
186
|
+
code = args.func(args)
|
|
187
|
+
except (TraderaApiError, ValueError) as exc:
|
|
188
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
189
|
+
raise SystemExit(2) from exc
|
|
190
|
+
raise SystemExit(code)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tradera-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: CLI for Tradera web endpoints
|
|
5
5
|
Author: Johnny W
|
|
6
6
|
License-Expression: MIT
|
|
@@ -69,6 +69,11 @@ tradera search "iphone"
|
|
|
69
69
|
tradera search "pokemon" --page 2 --page-size 20
|
|
70
70
|
tradera search "kamera" --sort AddedOn --format json
|
|
71
71
|
tradera search "klocka" --country SE --lang sv --format jsonl
|
|
72
|
+
tradera search "lego" --item-status Sold --format json
|
|
73
|
+
tradera search "lego" --condition "Oanvänt" --format json
|
|
74
|
+
tradera search "lego" --item-type FixedPrice --from-price 100 --to-price 500
|
|
75
|
+
tradera search "lego" --allowed-buyer-regions eu --counties Stockholm Uppsala
|
|
76
|
+
tradera search "lego city" --search-type ExactSearch
|
|
72
77
|
```
|
|
73
78
|
|
|
74
79
|
### Get item details
|
|
@@ -105,6 +110,14 @@ Search command (`tradera search`) supports:
|
|
|
105
110
|
| `--sort` | Sort mode (default: `Relevance`) |
|
|
106
111
|
| `--lang` | Language code (default: `sv`) |
|
|
107
112
|
| `--country` | Shipping country code (default: `SE`) |
|
|
113
|
+
| `--item-status` | Filter by item status: `Active`, `Sold`, `Unsold` |
|
|
114
|
+
| `--condition` | Filter by condition: `Oanvänt`, `Mycket gott skick`, `Gott skick`, `Okej skick`, `Defekt` |
|
|
115
|
+
| `--item-type` | Filter by listing type: `All`, `Auction`, `FixedPrice`, `ContactOnly` |
|
|
116
|
+
| `--from-price` | Minimum price |
|
|
117
|
+
| `--to-price` | Maximum price |
|
|
118
|
+
| `--allowed-buyer-regions` | Buyer region filter: `sweden`, `eu`, `international` |
|
|
119
|
+
| `--counties` | Pickup counties, passed as one or more values, for example `Stockholm Uppsala` |
|
|
120
|
+
| `--search-type` | Search mode. Current supported value: `ExactSearch` |
|
|
108
121
|
| `--no-translate` | Disable automatic translation preference |
|
|
109
122
|
| `--format` | Output format: `table`, `json`, `jsonl` |
|
|
110
123
|
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import sys
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from .api import TraderaApiError, TraderaClient, parse_item_id
|
|
8
|
-
from .formatters import (
|
|
9
|
-
normalize_categories_rows,
|
|
10
|
-
normalize_search_rows,
|
|
11
|
-
to_json,
|
|
12
|
-
to_jsonl,
|
|
13
|
-
to_table,
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _print_output(raw: Any, rows: list[dict[str, Any]], fmt: str, columns: list[str]) -> None:
|
|
18
|
-
if fmt == "json":
|
|
19
|
-
print(to_json(raw))
|
|
20
|
-
return
|
|
21
|
-
if fmt == "jsonl":
|
|
22
|
-
print(to_jsonl(rows))
|
|
23
|
-
return
|
|
24
|
-
print(to_table(rows, columns))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def cmd_search(args: argparse.Namespace) -> int:
|
|
28
|
-
client = TraderaClient()
|
|
29
|
-
data = client.search(
|
|
30
|
-
query=args.query,
|
|
31
|
-
page=args.page,
|
|
32
|
-
page_size=args.page_size,
|
|
33
|
-
sort_by=args.sort,
|
|
34
|
-
language_code_iso2=args.lang,
|
|
35
|
-
shipping_country_code_iso2=args.country,
|
|
36
|
-
automatic_translation_preferred=not args.no_translate,
|
|
37
|
-
)
|
|
38
|
-
rows = normalize_search_rows(data)
|
|
39
|
-
_print_output(
|
|
40
|
-
raw=data,
|
|
41
|
-
rows=rows,
|
|
42
|
-
fmt=args.format,
|
|
43
|
-
columns=["itemId", "title", "price", "currency", "endDate", "url"],
|
|
44
|
-
)
|
|
45
|
-
return 0
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def cmd_item(args: argparse.Namespace) -> int:
|
|
49
|
-
client = TraderaClient()
|
|
50
|
-
item_id = parse_item_id(args.item)
|
|
51
|
-
data = client.item(item_id)
|
|
52
|
-
if args.format == "table":
|
|
53
|
-
row = {
|
|
54
|
-
"itemId": data.get("itemId") or data.get("id") or item_id,
|
|
55
|
-
"title": data.get("shortDescription") or data.get("title") or "",
|
|
56
|
-
"price": data.get("buyNowPrice") or data.get("nextBid") or data.get("price") or "",
|
|
57
|
-
"currency": data.get("currency") or "SEK",
|
|
58
|
-
"seller": (data.get("seller") or {}).get("alias") if isinstance(data.get("seller"), dict) else "",
|
|
59
|
-
}
|
|
60
|
-
print(to_table([row], ["itemId", "title", "price", "currency", "seller"]))
|
|
61
|
-
else:
|
|
62
|
-
print(to_json(data))
|
|
63
|
-
return 0
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def cmd_categories(args: argparse.Namespace) -> int:
|
|
67
|
-
client = TraderaClient()
|
|
68
|
-
data = client.categories(level=args.level, lang=args.lang)
|
|
69
|
-
rows = normalize_categories_rows(data)
|
|
70
|
-
_print_output(raw=data, rows=rows, fmt=args.format, columns=["id", "name", "level"])
|
|
71
|
-
return 0
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def build_parser() -> argparse.ArgumentParser:
|
|
75
|
-
parser = argparse.ArgumentParser(prog="tradera", description="Tradera CLI")
|
|
76
|
-
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
77
|
-
|
|
78
|
-
search = subparsers.add_parser("search", help="Search listings")
|
|
79
|
-
search.add_argument("query", help="Search query")
|
|
80
|
-
search.add_argument("--page", type=int, default=1)
|
|
81
|
-
search.add_argument("--page-size", type=int, default=50)
|
|
82
|
-
search.add_argument("--sort", default="Relevance")
|
|
83
|
-
search.add_argument("--lang", default="sv")
|
|
84
|
-
search.add_argument("--country", default="SE")
|
|
85
|
-
search.add_argument("--no-translate", action="store_true")
|
|
86
|
-
search.add_argument("--format", choices=["table", "json", "jsonl"], default="table")
|
|
87
|
-
search.set_defaults(func=cmd_search)
|
|
88
|
-
|
|
89
|
-
item = subparsers.add_parser("item", help="Get item details")
|
|
90
|
-
item.add_argument("item", help="Item id or item URL")
|
|
91
|
-
item.add_argument("--format", choices=["table", "json"], default="json")
|
|
92
|
-
item.set_defaults(func=cmd_item)
|
|
93
|
-
|
|
94
|
-
categories = subparsers.add_parser("categories", help="List categories by level")
|
|
95
|
-
categories.add_argument("--level", type=int, default=1)
|
|
96
|
-
categories.add_argument("--lang", default="sv")
|
|
97
|
-
categories.add_argument("--format", choices=["table", "json", "jsonl"], default="table")
|
|
98
|
-
categories.set_defaults(func=cmd_categories)
|
|
99
|
-
|
|
100
|
-
return parser
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def main(argv: list[str] | None = None) -> None:
|
|
104
|
-
parser = build_parser()
|
|
105
|
-
args = parser.parse_args(argv)
|
|
106
|
-
try:
|
|
107
|
-
code = args.func(args)
|
|
108
|
-
except (TraderaApiError, ValueError) as exc:
|
|
109
|
-
print(f"Error: {exc}", file=sys.stderr)
|
|
110
|
-
raise SystemExit(2) from exc
|
|
111
|
-
raise SystemExit(code)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|