tradera-cli 0.1.2__tar.gz → 0.1.4__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.2 → tradera_cli-0.1.4}/PKG-INFO +1 -1
  2. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/pyproject.toml +1 -1
  3. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tests/test_api.py +124 -14
  4. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tests/test_cli.py +26 -0
  5. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli/api.py +76 -73
  6. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli.egg-info/PKG-INFO +1 -1
  7. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/LICENSE +0 -0
  8. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/README.md +0 -0
  9. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/setup.cfg +0 -0
  10. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tests/test_cli_entry.py +0 -0
  11. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tests/test_formatters.py +0 -0
  12. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tests/test_import.py +0 -0
  13. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tests/test_main_module.py +0 -0
  14. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli/__init__.py +0 -0
  15. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli/__main__.py +0 -0
  16. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli/cli.py +1 -1
  17. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli/formatters.py +0 -0
  18. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli.egg-info/SOURCES.txt +0 -0
  19. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli.egg-info/dependency_links.txt +0 -0
  20. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli.egg-info/entry_points.txt +0 -0
  21. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/tradera_cli.egg-info/requires.txt +0 -0
  22. {tradera_cli-0.1.2 → tradera_cli-0.1.4}/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.2
3
+ Version: 0.1.4
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.2"
7
+ version = "0.1.4"
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,78 @@ 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
+
423
533
  def test_item_payment_calculations_returns_dict() -> None:
424
534
  client = TraderaClient()
425
535
  next_data = {
@@ -207,6 +207,32 @@ def test_cmd_item_table_uses_payment_calculations_for_bid_total(
207
207
  assert "4690" in out
208
208
 
209
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
+
210
236
  def test_cmd_categories_jsonl(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
211
237
  class CategoriesClient(DummyClient):
212
238
  def categories(self, **_kwargs: object) -> list:
@@ -32,7 +32,7 @@ class TraderaClient:
32
32
  self.session.headers.update(
33
33
  {
34
34
  "accept": "application/json, text/plain, */*",
35
- "user-agent": "tradera-cli/0.1.1",
35
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
36
36
  }
37
37
  )
38
38
 
@@ -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,34 +171,61 @@ 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
 
203
- def item_payment_calculations(self, item_id: int) -> dict[str, Any]:
182
+ def _item_page_state(self, item_id: int) -> dict[str, Any]:
204
183
  html = self._request("GET", f"/item/{item_id}")
205
184
  if not isinstance(html, str):
206
185
  raise TraderaApiError(f"Unexpected item page response for item {item_id}")
207
186
 
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}")
187
+ data = self._next_data_from_html(html, f"item page response for item {item_id}")
211
188
 
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 = (
189
+ view_item = (
218
190
  data.get("props", {})
219
191
  .get("pageProps", {})
220
192
  .get("initialState", {})
221
193
  .get("views", {})
222
194
  .get("viewItem", {})
223
- .get("itemDetails", {})
224
- .get("paymentCalculations", {})
225
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", {})
226
229
  if not isinstance(payment_calculations, dict):
227
230
  raise TraderaApiError(f"Unexpected payment calculations for item {item_id}")
228
231
  return payment_calculations
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradera-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -97,9 +97,9 @@ def cmd_item(args: argparse.Namespace) -> int:
97
97
  "title": data.get("shortDescription") or data.get("title") or "",
98
98
  "price": (
99
99
  data.get("buyNowPrice")
100
- or data.get("nextBid")
101
100
  or data.get("price")
102
101
  or data.get("leadingBid")
102
+ or data.get("nextBid")
103
103
  or data.get("fixedPrice")
104
104
  or data.get("openingBid")
105
105
  or ""