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.
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/PKG-INFO +1 -1
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/pyproject.toml +1 -1
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_api.py +169 -14
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_cli.py +104 -1
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/api.py +106 -64
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/cli.py +67 -4
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/PKG-INFO +1 -1
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/LICENSE +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/README.md +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/setup.cfg +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_cli_entry.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_formatters.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_import.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tests/test_main_module.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/__init__.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/__main__.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli/formatters.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/SOURCES.txt +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/dependency_links.txt +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/entry_points.txt +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/requires.txt +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.3}/tradera_cli.egg-info/top_level.txt +0 -0
|
@@ -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
|
|
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, **
|
|
209
|
+
def fake_request(method: str, path: str, **_kwargs: object) -> str:
|
|
198
210
|
captured["method"] = method
|
|
199
211
|
captured["path"] = path
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
208
|
-
assert captured["method"] == "
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
assert
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
118
|
+
filters: dict[str, str] = {}
|
|
105
119
|
if search_type:
|
|
106
|
-
|
|
120
|
+
filters["searchType"] = search_type
|
|
107
121
|
if normalized_item_status in {"Sold", "Unsold"}:
|
|
108
|
-
|
|
122
|
+
filters["itemStatus"] = normalized_item_status
|
|
109
123
|
if condition:
|
|
110
|
-
|
|
124
|
+
filters["af-condition"] = condition
|
|
111
125
|
if item_type and item_type != "All":
|
|
112
|
-
|
|
126
|
+
filters["itemType"] = item_type
|
|
113
127
|
if from_price is not None:
|
|
114
|
-
|
|
128
|
+
filters["fromPrice"] = str(from_price)
|
|
115
129
|
if to_price is not None:
|
|
116
|
-
|
|
130
|
+
filters["toPrice"] = str(to_price)
|
|
117
131
|
if allowed_buyer_regions:
|
|
118
|
-
|
|
132
|
+
filters["allowedBuyerRegions"] = ALLOWED_BUYER_REGIONS[allowed_buyer_regions]
|
|
119
133
|
if counties:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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(
|
|
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":
|
|
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(
|
|
112
|
+
print(
|
|
113
|
+
to_table(
|
|
114
|
+
[row],
|
|
115
|
+
["itemId", "title", "price", "currency", "buyerProtectionTotal", "buyerProtectionFee", "seller"],
|
|
116
|
+
)
|
|
117
|
+
)
|
|
69
118
|
else:
|
|
70
|
-
|
|
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")
|
|
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
|
|
File without changes
|
|
File without changes
|