tradera-cli 0.1.1__tar.gz → 0.1.3__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 (22) hide show
  1. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/PKG-INFO +1 -1
  2. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/pyproject.toml +1 -1
  3. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_api.py +169 -14
  4. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_cli.py +104 -1
  5. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/api.py +106 -64
  6. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/cli.py +67 -4
  7. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/PKG-INFO +1 -1
  8. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/LICENSE +0 -0
  9. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/README.md +0 -0
  10. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/setup.cfg +0 -0
  11. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_cli_entry.py +0 -0
  12. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_formatters.py +0 -0
  13. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_import.py +0 -0
  14. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_main_module.py +0 -0
  15. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/__init__.py +0 -0
  16. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/__main__.py +0 -0
  17. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/formatters.py +0 -0
  18. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/SOURCES.txt +0 -0
  19. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/dependency_links.txt +0 -0
  20. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/entry_points.txt +0 -0
  21. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/requires.txt +0 -0
  22. {tradera_cli-0.1.1 → tradera_cli-0.1.3}/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.1
3
+ Version: 0.1.3
4
4
  Summary: CLI for Tradera web endpoints
5
5
  Author: Johnny W
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tradera-cli"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "CLI for Tradera web endpoints"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -190,29 +190,46 @@ def test_ensure_client_token_raises_on_http_error() -> None:
190
190
  client._ensure_client_token()
191
191
 
192
192
 
193
- def test_search_builds_payload() -> None:
193
+ def test_search_reads_current_search_page_state() -> None:
194
194
  client = TraderaClient()
195
195
  captured: dict[str, object] = {}
196
+ next_data = {
197
+ "props": {
198
+ "pageProps": {
199
+ "initialState": {
200
+ "discover": {
201
+ "items": [{"itemId": 42, "shortDescription": "Apple TV"}],
202
+ "queryParams": {"q": "kamera"},
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
196
208
 
197
- def fake_request(method: str, path: str, **kwargs: object) -> dict[str, object]:
209
+ def fake_request(method: str, path: str, **_kwargs: object) -> str:
198
210
  captured["method"] = method
199
211
  captured["path"] = path
200
- captured["json"] = kwargs["json"]
201
- return {"items": []}
212
+ return (
213
+ '<script id="__NEXT_DATA__" type="application/json">'
214
+ f"{json.dumps(next_data)}"
215
+ "</script>"
216
+ )
202
217
 
203
218
  client._request = fake_request # type: ignore[method-assign]
204
219
 
205
220
  result = client.search("kamera", page=2, page_size=10, sort_by="EndDate")
206
221
 
207
- assert result == {"items": []}
208
- assert captured["method"] == "POST"
209
- assert captured["path"] == "/api/webapi/discover/web/independent-search"
210
- payload = captured["json"]
211
- assert isinstance(payload, dict)
212
- assert payload["query"] == "kamera"
213
- assert payload["page"] == 2
214
- assert payload["pageSize"] == 10
215
- assert payload["sortBy"] == "EndDate"
222
+ assert result["items"][0]["itemId"] == 42
223
+ assert captured["method"] == "GET"
224
+ parsed = urlparse(str(captured["path"]))
225
+ assert parsed.path == "/search"
226
+ assert parse_qs(parsed.query) == {
227
+ "q": ["kamera"],
228
+ "paging": ["2"],
229
+ "pageSize": ["10"],
230
+ "sortBy": ["EndDate"],
231
+ "languageCodeIso2": ["sv"],
232
+ }
216
233
 
217
234
 
218
235
  def test_search_uses_search_page_for_sold_items() -> None:
@@ -251,6 +268,7 @@ def test_search_uses_search_page_for_sold_items() -> None:
251
268
  assert parse_qs(parsed.query) == {
252
269
  "q": ["kamera"],
253
270
  "paging": ["3"],
271
+ "pageSize": ["50"],
254
272
  "sortBy": ["EndDate"],
255
273
  "languageCodeIso2": ["sv"],
256
274
  "itemStatus": ["Sold"],
@@ -293,6 +311,7 @@ def test_search_uses_search_page_for_condition_filter() -> None:
293
311
  assert parse_qs(parsed.query) == {
294
312
  "q": ["kamera"],
295
313
  "paging": ["1"],
314
+ "pageSize": ["50"],
296
315
  "sortBy": ["Relevance"],
297
316
  "languageCodeIso2": ["sv"],
298
317
  "af-condition": ["Oanvänt"],
@@ -350,6 +369,7 @@ def test_search_uses_search_page_for_additional_page_filters() -> None:
350
369
  assert parse_qs(parsed.query) == {
351
370
  "q": ["kamera"],
352
371
  "paging": ["1"],
372
+ "pageSize": ["50"],
353
373
  "sortBy": ["Relevance"],
354
374
  "languageCodeIso2": ["sv"],
355
375
  "itemType": ["FixedPrice"],
@@ -396,6 +416,7 @@ def test_search_uses_search_page_for_search_type() -> None:
396
416
  assert parse_qs(parsed.query) == {
397
417
  "q": ["kamera"],
398
418
  "paging": ["1"],
419
+ "pageSize": ["50"],
399
420
  "sortBy": ["Relevance"],
400
421
  "languageCodeIso2": ["sv"],
401
422
  "searchType": ["ExactSearch"],
@@ -412,7 +433,24 @@ def test_search_page_raises_when_next_data_is_missing() -> None:
412
433
 
413
434
  def test_item_returns_dict_or_raises() -> None:
414
435
  client = TraderaClient()
415
- client._request = lambda *_args, **_kwargs: {"itemId": 123} # type: ignore[method-assign]
436
+ next_data = {
437
+ "props": {
438
+ "pageProps": {
439
+ "initialState": {
440
+ "views": {
441
+ "viewItem": {
442
+ "itemDetails": {"itemId": 123, "title": "Demo"},
443
+ }
444
+ }
445
+ }
446
+ }
447
+ }
448
+ }
449
+ client._request = lambda *_args, **_kwargs: ( # type: ignore[method-assign]
450
+ '<script id="__NEXT_DATA__" type="application/json">'
451
+ f"{json.dumps(next_data)}"
452
+ "</script>"
453
+ )
416
454
  assert client.item(123)["itemId"] == 123
417
455
 
418
456
  client._request = lambda *_args, **_kwargs: "not a dict" # type: ignore[method-assign]
@@ -420,6 +458,117 @@ def test_item_returns_dict_or_raises() -> None:
420
458
  client.item(123)
421
459
 
422
460
 
461
+ def test_item_reads_current_item_page_state() -> None:
462
+ client = TraderaClient()
463
+ calls: list[tuple[str, str]] = []
464
+ next_data = {
465
+ "props": {
466
+ "pageProps": {
467
+ "initialState": {
468
+ "views": {
469
+ "viewItem": {
470
+ "bidInfo": {
471
+ "leadingBidAmount": 926,
472
+ "nextValidBidAmount": 951,
473
+ "bidCount": 79,
474
+ },
475
+ "itemDetails": {
476
+ "itemId": 731572375,
477
+ "title": "Apple TV 32GB",
478
+ "itemType": "Auction",
479
+ "openingBid": 1,
480
+ "leadingBid": 926,
481
+ "seller": {"alias": "Lufox"},
482
+ "paymentCalculations": {"paymentAmountForBid": 974},
483
+ },
484
+ "purchaseInfo": {"finalPrice": 926},
485
+ }
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ def fake_request(method: str, path: str, **_kwargs: object) -> object:
493
+ calls.append((method, path))
494
+ return (
495
+ '<script id="__NEXT_DATA__" type="application/json">'
496
+ f"{json.dumps(next_data)}"
497
+ "</script>"
498
+ )
499
+
500
+ client._request = fake_request # type: ignore[method-assign]
501
+
502
+ result = client.item(731572375)
503
+
504
+ assert result["itemId"] == 731572375
505
+ assert result["title"] == "Apple TV 32GB"
506
+ assert result["leadingBid"] == 926
507
+ assert result["bidCount"] == 79
508
+ assert result["nextBid"] == 951
509
+ assert result["price"] == 926
510
+ assert result["finalPrice"] == 926
511
+ assert result["currency"] == "SEK"
512
+ assert calls == [("GET", "/item/731572375")]
513
+
514
+
515
+ def test_item_falls_back_to_ajax_when_page_parse_fails() -> None:
516
+ client = TraderaClient()
517
+ calls: list[tuple[str, str]] = []
518
+
519
+ def fake_request(method: str, path: str, **_kwargs: object) -> object:
520
+ calls.append((method, path))
521
+ if path == "/item/123":
522
+ raise TraderaApiError("Could not parse item page response for item 123")
523
+ return {"itemId": 123, "title": "Legacy"}
524
+
525
+ client._request = fake_request # type: ignore[method-assign]
526
+
527
+ result = client.item(123)
528
+
529
+ assert result == {"itemId": 123, "title": "Legacy"}
530
+ assert calls == [("GET", "/item/123"), ("GET", "/ajax/item/123")]
531
+
532
+
533
+ def test_item_payment_calculations_returns_dict() -> None:
534
+ client = TraderaClient()
535
+ next_data = {
536
+ "props": {
537
+ "pageProps": {
538
+ "initialState": {
539
+ "views": {
540
+ "viewItem": {
541
+ "itemDetails": {
542
+ "paymentCalculations": {
543
+ "paymentAmountForBid": 4927,
544
+ "buyerProtectionCostForFixedPrice": 242,
545
+ }
546
+ }
547
+ }
548
+ }
549
+ }
550
+ }
551
+ }
552
+ }
553
+ client._request = lambda *_args, **_kwargs: ( # type: ignore[method-assign]
554
+ '<script id="__NEXT_DATA__" type="application/json">'
555
+ f"{json.dumps(next_data)}"
556
+ "</script>"
557
+ )
558
+
559
+ result = client.item_payment_calculations(123)
560
+ assert result["paymentAmountForBid"] == 4927
561
+ assert result["buyerProtectionCostForFixedPrice"] == 242
562
+
563
+
564
+ def test_item_payment_calculations_raises_on_missing_next_data() -> None:
565
+ client = TraderaClient()
566
+ client._request = lambda *_args, **_kwargs: "<html></html>" # type: ignore[method-assign]
567
+
568
+ with pytest.raises(TraderaApiError, match="Could not parse item page response"):
569
+ client.item_payment_calculations(123)
570
+
571
+
423
572
  def test_categories_calls_expected_path() -> None:
424
573
  client = TraderaClient()
425
574
  captured: dict[str, str] = {}
@@ -440,5 +589,11 @@ def test_categories_calls_expected_path() -> None:
440
589
  def test_parse_item_id_from_digits_and_url_and_invalid() -> None:
441
590
  assert parse_item_id("123456") == 123456
442
591
  assert parse_item_id("https://www.tradera.com/item/123456789/test") == 123456789
592
+ assert (
593
+ parse_item_id(
594
+ "https://www.tradera.com/item/1000006/707858418/playstation-5-disc-edition-the-last-of-us-dualsense"
595
+ )
596
+ == 707858418
597
+ )
443
598
  with pytest.raises(ValueError, match="Could not parse item id"):
444
599
  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
@@ -92,7 +97,7 @@ def test_cmd_search_and_no_translate(monkeypatch: pytest.MonkeyPatch, capsys: py
92
97
  assert "itemId" in out
93
98
 
94
99
 
95
- 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:
96
101
  class ItemClient(DummyClient):
97
102
  def item(self, _item_id: int) -> dict:
98
103
  return {
@@ -100,9 +105,13 @@ def test_cmd_item_table_and_json(monkeypatch: pytest.MonkeyPatch, capsys: pytest
100
105
  "shortDescription": "Demo",
101
106
  "buyNowPrice": 99,
102
107
  "currency": "SEK",
108
+ "buyerProtectionInfo": {"totalPrice": 109, "fee": 10},
103
109
  "seller": {"alias": "john"},
104
110
  }
105
111
 
112
+ def item_payment_calculations(self, _item_id: int) -> dict:
113
+ return {}
114
+
106
115
  monkeypatch.setattr(cli, "TraderaClient", ItemClient)
107
116
 
108
117
  table_args = argparse.Namespace(item="12", format="table")
@@ -110,11 +119,24 @@ def test_cmd_item_table_and_json(monkeypatch: pytest.MonkeyPatch, capsys: pytest
110
119
  table_out = capsys.readouterr().out
111
120
  assert "seller" in table_out
112
121
  assert "john" in table_out
122
+ assert "buyerProtectionTotal" in table_out
123
+ assert "109" in table_out
124
+ assert "10" in table_out
113
125
 
114
126
  json_args = argparse.Namespace(item="12", format="json")
115
127
  assert cli.cmd_item(json_args) == 0
116
128
  json_out = capsys.readouterr().out
117
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
118
140
 
119
141
 
120
142
  def test_cmd_item_table_handles_non_dict_seller(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
@@ -122,6 +144,9 @@ def test_cmd_item_table_handles_non_dict_seller(monkeypatch: pytest.MonkeyPatch,
122
144
  def item(self, _item_id: int) -> dict:
123
145
  return {"id": 33, "title": "No seller dict", "seller": "none"}
124
146
 
147
+ def item_payment_calculations(self, _item_id: int) -> dict:
148
+ return {}
149
+
125
150
  monkeypatch.setattr(cli, "TraderaClient", ItemClient)
126
151
 
127
152
  args = argparse.Namespace(item="33", format="table")
@@ -130,6 +155,84 @@ def test_cmd_item_table_handles_non_dict_seller(monkeypatch: pytest.MonkeyPatch,
130
155
  assert "No seller dict" in out
131
156
 
132
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
+
210
+ def test_cmd_item_table_prefers_auction_price_over_next_bid(
211
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
212
+ ) -> None:
213
+ class ItemClient(DummyClient):
214
+ def item(self, _item_id: int) -> dict:
215
+ return {
216
+ "itemId": 66,
217
+ "title": "Ended auction",
218
+ "leadingBid": 926,
219
+ "nextBid": 951,
220
+ "bidCount": 79,
221
+ "currency": "SEK",
222
+ }
223
+
224
+ def item_payment_calculations(self, _item_id: int) -> dict:
225
+ return {"paymentAmountForBid": 974}
226
+
227
+ monkeypatch.setattr(cli, "TraderaClient", ItemClient)
228
+
229
+ args = argparse.Namespace(item="66", format="table")
230
+ assert cli.cmd_item(args) == 0
231
+ out = capsys.readouterr().out
232
+ assert "926" in out
233
+ assert "951" not in out
234
+
235
+
133
236
  def test_cmd_categories_jsonl(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
134
237
  class CategoriesClient(DummyClient):
135
238
  def categories(self, **_kwargs: object) -> list:
@@ -4,7 +4,7 @@ import json
4
4
  import re
5
5
  from dataclasses import dataclass
6
6
  from typing import Any
7
- from urllib.parse import urlencode
7
+ from urllib.parse import urlencode, urlparse
8
8
 
9
9
  import requests
10
10
  from requests import RequestException
@@ -68,6 +68,20 @@ class TraderaClient:
68
68
  raise TraderaApiError(f"Invalid JSON response from {path}") from exc
69
69
  return response.text
70
70
 
71
+ def _next_data_from_html(self, html: str, context: str) -> dict[str, Any]:
72
+ match = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', html)
73
+ if not match:
74
+ raise TraderaApiError(f"Could not parse {context}")
75
+
76
+ try:
77
+ data = json.loads(match.group(1))
78
+ except ValueError as exc:
79
+ raise TraderaApiError(f"Invalid {context}") from exc
80
+
81
+ if not isinstance(data, dict):
82
+ raise TraderaApiError(f"Unexpected {context}")
83
+ return data
84
+
71
85
  def _ensure_client_token(self, force: bool = False) -> None:
72
86
  if not force and self.session.cookies.get("trd_at"):
73
87
  return
@@ -101,70 +115,38 @@ class TraderaClient:
101
115
  search_type: str | None = None,
102
116
  ) -> dict[str, Any]:
103
117
  normalized_item_status = item_status or "Active"
104
- page_filters: dict[str, str] = {}
118
+ filters: dict[str, str] = {}
105
119
  if search_type:
106
- page_filters["searchType"] = search_type
120
+ filters["searchType"] = search_type
107
121
  if normalized_item_status in {"Sold", "Unsold"}:
108
- page_filters["itemStatus"] = normalized_item_status
122
+ filters["itemStatus"] = normalized_item_status
109
123
  if condition:
110
- page_filters["af-condition"] = condition
124
+ filters["af-condition"] = condition
111
125
  if item_type and item_type != "All":
112
- page_filters["itemType"] = item_type
126
+ filters["itemType"] = item_type
113
127
  if from_price is not None:
114
- page_filters["fromPrice"] = str(from_price)
128
+ filters["fromPrice"] = str(from_price)
115
129
  if to_price is not None:
116
- page_filters["toPrice"] = str(to_price)
130
+ filters["toPrice"] = str(to_price)
117
131
  if allowed_buyer_regions:
118
- page_filters["allowedBuyerRegions"] = ALLOWED_BUYER_REGIONS[allowed_buyer_regions]
132
+ filters["allowedBuyerRegions"] = ALLOWED_BUYER_REGIONS[allowed_buyer_regions]
119
133
  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
-
131
- payload: dict[str, Any] = {
132
- "isCsaSearchQuery": False,
133
- "query": query,
134
- "page": page,
135
- "pageSize": page_size,
136
- "sortBy": sort_by,
137
- "languageCodeIso2": language_code_iso2.lower(),
138
- "shippingCountryCodeIso2": shipping_country_code_iso2.upper(),
139
- "automaticTranslationPreferred": automatic_translation_preferred,
140
- "attributeFilters": [],
141
- "categoryPath": [],
142
- "currentCategoryId": 0,
143
- "filterCounties": None,
144
- "filterCategories": {},
145
- "filterPrice": {},
146
- "filters": {},
147
- "headerText": None,
148
- "internalSearch": {"showSearchBar": False},
149
- "introText": None,
150
- "isSavedSearchEmailEnabled": False,
151
- "isShopOwnedByCurrentMember": False,
152
- "items": [],
153
- "relatedItems": [],
154
- "itemsOnDisplay": [],
155
- "mainText": None,
156
- "pagination": None,
157
- "totalItems": 0,
158
- "searchLanguages": [],
159
- "itemsMatchedViewModel": None,
160
- "suggestion": None,
161
- }
162
- return self._request("POST", "/api/webapi/discover/web/independent-search", json=payload)
134
+ filters["counties"] = ";".join(counties)
135
+
136
+ return self._search_page(
137
+ query=query,
138
+ page=page,
139
+ page_size=page_size,
140
+ sort_by=sort_by,
141
+ language_code_iso2=language_code_iso2,
142
+ filters=filters,
143
+ )
163
144
 
164
145
  def _search_page(
165
146
  self,
166
147
  query: str,
167
148
  page: int,
149
+ page_size: int,
168
150
  sort_by: str,
169
151
  language_code_iso2: str,
170
152
  filters: dict[str, str],
@@ -172,6 +154,7 @@ class TraderaClient:
172
154
  params = {
173
155
  "q": query,
174
156
  "paging": page,
157
+ "pageSize": page_size,
175
158
  "sortBy": sort_by,
176
159
  "languageCodeIso2": language_code_iso2.lower(),
177
160
  }
@@ -180,14 +163,7 @@ class TraderaClient:
180
163
  if not isinstance(html, str):
181
164
  raise TraderaApiError("Unexpected search page response")
182
165
 
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
166
+ data = self._next_data_from_html(html, "search page response")
191
167
 
192
168
  discover = data.get("props", {}).get("pageProps", {}).get("initialState", {}).get("discover")
193
169
  if not isinstance(discover, dict):
@@ -195,11 +171,65 @@ class TraderaClient:
195
171
  return discover
196
172
 
197
173
  def item(self, item_id: int) -> dict[str, Any]:
198
- data = self._request("GET", f"/ajax/item/{item_id}")
174
+ try:
175
+ return self._item_from_page(item_id)
176
+ except TraderaApiError:
177
+ data = self._request("GET", f"/ajax/item/{item_id}")
199
178
  if not isinstance(data, dict):
200
179
  raise TraderaApiError(f"Unexpected item response for item {item_id}")
201
180
  return data
202
181
 
182
+ def _item_page_state(self, item_id: int) -> dict[str, Any]:
183
+ html = self._request("GET", f"/item/{item_id}")
184
+ if not isinstance(html, str):
185
+ raise TraderaApiError(f"Unexpected item page response for item {item_id}")
186
+
187
+ data = self._next_data_from_html(html, f"item page response for item {item_id}")
188
+
189
+ view_item = (
190
+ data.get("props", {})
191
+ .get("pageProps", {})
192
+ .get("initialState", {})
193
+ .get("views", {})
194
+ .get("viewItem", {})
195
+ )
196
+ if not isinstance(view_item, dict):
197
+ raise TraderaApiError(f"Unexpected item page data for item {item_id}")
198
+ return view_item
199
+
200
+ def _item_from_page(self, item_id: int) -> dict[str, Any]:
201
+ view_item = self._item_page_state(item_id)
202
+ item_details = view_item.get("itemDetails")
203
+ if not isinstance(item_details, dict) or not item_details:
204
+ raise TraderaApiError(f"Unexpected item response for item {item_id}")
205
+
206
+ data = dict(item_details)
207
+ bid_info = view_item.get("bidInfo")
208
+ if isinstance(bid_info, dict):
209
+ if bid_info.get("bidCount") is not None:
210
+ data.setdefault("bidCount", bid_info.get("bidCount"))
211
+ if bid_info.get("leadingBidAmount") is not None:
212
+ data.setdefault("leadingBid", bid_info.get("leadingBidAmount"))
213
+ if bid_info.get("nextValidBidAmount") is not None:
214
+ data.setdefault("nextBid", bid_info.get("nextValidBidAmount"))
215
+
216
+ purchase_info = view_item.get("purchaseInfo")
217
+ if isinstance(purchase_info, dict) and purchase_info.get("finalPrice") is not None:
218
+ data.setdefault("price", purchase_info.get("finalPrice"))
219
+ data.setdefault("finalPrice", purchase_info.get("finalPrice"))
220
+
221
+ data.setdefault("currency", "SEK")
222
+ return data
223
+
224
+ def item_payment_calculations(self, item_id: int) -> dict[str, Any]:
225
+ item_details = self._item_page_state(item_id).get("itemDetails", {})
226
+ if not isinstance(item_details, dict):
227
+ raise TraderaApiError(f"Unexpected payment calculations for item {item_id}")
228
+ payment_calculations = item_details.get("paymentCalculations", {})
229
+ if not isinstance(payment_calculations, dict):
230
+ raise TraderaApiError(f"Unexpected payment calculations for item {item_id}")
231
+ return payment_calculations
232
+
203
233
  def categories(self, level: int = 1, lang: str = "sv") -> Any:
204
234
  return self._request("GET", f"/api/categories/{level}?languageCodeIso2={lang}&next=1")
205
235
 
@@ -207,7 +237,19 @@ class TraderaClient:
207
237
  def parse_item_id(value: str) -> int:
208
238
  if value.isdigit():
209
239
  return int(value)
210
- match = re.search(r"/(\d{6,})", value)
211
- if not match:
240
+ parsed = urlparse(value)
241
+ path = parsed.path or value
242
+ segments = [segment for segment in path.split("/") if segment]
243
+
244
+ if "item" in segments:
245
+ item_index = segments.index("item")
246
+ numeric_after_item = [segment for segment in segments[item_index + 1 :] if segment.isdigit()]
247
+ if len(numeric_after_item) >= 2:
248
+ return int(numeric_after_item[1])
249
+ if numeric_after_item:
250
+ return int(numeric_after_item[0])
251
+
252
+ matches = re.findall(r"(\d{6,})", value)
253
+ if not matches:
212
254
  raise ValueError(f"Could not parse item id from: {value}")
213
- return int(match.group(1))
255
+ return int(matches[-1])
@@ -14,6 +14,32 @@ from .formatters import (
14
14
  )
15
15
 
16
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
+
17
43
  def _print_output(raw: Any, rows: list[dict[str, Any]], fmt: str, columns: list[str]) -> None:
18
44
  if fmt == "json":
19
45
  print(to_json(raw))
@@ -57,17 +83,54 @@ def cmd_item(args: argparse.Namespace) -> int:
57
83
  client = TraderaClient()
58
84
  item_id = parse_item_id(args.item)
59
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
+
60
94
  if args.format == "table":
61
95
  row = {
62
96
  "itemId": data.get("itemId") or data.get("id") or item_id,
63
97
  "title": data.get("shortDescription") or data.get("title") or "",
64
- "price": data.get("buyNowPrice") or data.get("nextBid") or data.get("price") or "",
98
+ "price": (
99
+ data.get("buyNowPrice")
100
+ or data.get("price")
101
+ or data.get("leadingBid")
102
+ or data.get("nextBid")
103
+ or data.get("fixedPrice")
104
+ or data.get("openingBid")
105
+ or ""
106
+ ),
65
107
  "currency": data.get("currency") or "SEK",
108
+ "buyerProtectionTotal": buyer_protection_total,
109
+ "buyerProtectionFee": buyer_protection_fee,
66
110
  "seller": (data.get("seller") or {}).get("alias") if isinstance(data.get("seller"), dict) else "",
67
111
  }
68
- print(to_table([row], ["itemId", "title", "price", "currency", "seller"]))
112
+ print(
113
+ to_table(
114
+ [row],
115
+ ["itemId", "title", "price", "currency", "buyerProtectionTotal", "buyerProtectionFee", "seller"],
116
+ )
117
+ )
69
118
  else:
70
- print(to_json(data))
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))
71
134
  return 0
72
135
 
73
136
 
@@ -104,7 +167,7 @@ def build_parser() -> argparse.ArgumentParser:
104
167
 
105
168
  item = subparsers.add_parser("item", help="Get item details")
106
169
  item.add_argument("item", help="Item id or item URL")
107
- item.add_argument("--format", choices=["table", "json"], default="json")
170
+ item.add_argument("--format", choices=["table", "json", "jsonl"], default="json")
108
171
  item.set_defaults(func=cmd_item)
109
172
 
110
173
  categories = subparsers.add_parser("categories", help="List categories by level")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradera-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: CLI for Tradera web endpoints
5
5
  Author: Johnny W
6
6
  License-Expression: MIT
File without changes
File without changes
File without changes