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.
Files changed (23) hide show
  1. {tradera_cli-0.1.0/tradera_cli.egg-info → tradera_cli-0.1.2}/PKG-INFO +14 -1
  2. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/README.md +13 -0
  3. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/pyproject.toml +1 -1
  4. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_api.py +243 -0
  5. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_cli.py +94 -1
  6. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli/__init__.py +1 -1
  7. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli/api.py +119 -4
  8. tradera_cli-0.1.2/tradera_cli/cli.py +190 -0
  9. {tradera_cli-0.1.0 → tradera_cli-0.1.2/tradera_cli.egg-info}/PKG-INFO +14 -1
  10. tradera_cli-0.1.0/tradera_cli/cli.py +0 -111
  11. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/LICENSE +0 -0
  12. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/setup.cfg +0 -0
  13. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_cli_entry.py +0 -0
  14. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_formatters.py +0 -0
  15. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_import.py +0 -0
  16. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tests/test_main_module.py +0 -0
  17. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli/__main__.py +0 -0
  18. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli/formatters.py +0 -0
  19. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli.egg-info/SOURCES.txt +0 -0
  20. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli.egg-info/dependency_links.txt +0 -0
  21. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli.egg-info/entry_points.txt +0 -0
  22. {tradera_cli-0.1.0 → tradera_cli-0.1.2}/tradera_cli.egg-info/requires.txt +0 -0
  23. {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.0
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tradera-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "CLI for Tradera web endpoints"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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 test_cmd_item_table_and_json(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
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.0"
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.0",
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
- match = re.search(r"/(\d{6,})", value)
135
- if not match:
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(match.group(1))
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.0
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