tradera-cli 0.1.1__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/PKG-INFO +1 -1
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/pyproject.toml +1 -1
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tests/test_api.py +45 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tests/test_cli.py +78 -1
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli/api.py +43 -4
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli/cli.py +67 -4
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli.egg-info/PKG-INFO +1 -1
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/LICENSE +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/README.md +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/setup.cfg +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tests/test_cli_entry.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tests/test_formatters.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tests/test_import.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tests/test_main_module.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli/__init__.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli/__main__.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli/formatters.py +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli.egg-info/SOURCES.txt +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli.egg-info/dependency_links.txt +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli.egg-info/entry_points.txt +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli.egg-info/requires.txt +0 -0
- {tradera_cli-0.1.1 → tradera_cli-0.1.2}/tradera_cli.egg-info/top_level.txt +0 -0
|
@@ -420,6 +420,45 @@ def test_item_returns_dict_or_raises() -> None:
|
|
|
420
420
|
client.item(123)
|
|
421
421
|
|
|
422
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
|
+
|
|
423
462
|
def test_categories_calls_expected_path() -> None:
|
|
424
463
|
client = TraderaClient()
|
|
425
464
|
captured: dict[str, str] = {}
|
|
@@ -440,5 +479,11 @@ def test_categories_calls_expected_path() -> None:
|
|
|
440
479
|
def test_parse_item_id_from_digits_and_url_and_invalid() -> None:
|
|
441
480
|
assert parse_item_id("123456") == 123456
|
|
442
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
|
+
)
|
|
443
488
|
with pytest.raises(ValueError, match="Could not parse item id"):
|
|
444
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
|
|
@@ -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,58 @@ 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
|
+
|
|
133
210
|
def test_cmd_categories_jsonl(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
|
134
211
|
class CategoriesClient(DummyClient):
|
|
135
212
|
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
|
|
@@ -200,6 +200,33 @@ class TraderaClient:
|
|
|
200
200
|
raise TraderaApiError(f"Unexpected item response for item {item_id}")
|
|
201
201
|
return data
|
|
202
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
|
+
|
|
203
230
|
def categories(self, level: int = 1, lang: str = "sv") -> Any:
|
|
204
231
|
return self._request("GET", f"/api/categories/{level}?languageCodeIso2={lang}&next=1")
|
|
205
232
|
|
|
@@ -207,7 +234,19 @@ class TraderaClient:
|
|
|
207
234
|
def parse_item_id(value: str) -> int:
|
|
208
235
|
if value.isdigit():
|
|
209
236
|
return int(value)
|
|
210
|
-
|
|
211
|
-
|
|
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:
|
|
212
251
|
raise ValueError(f"Could not parse item id from: {value}")
|
|
213
|
-
return int(
|
|
252
|
+
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("nextBid")
|
|
101
|
+
or data.get("price")
|
|
102
|
+
or data.get("leadingBid")
|
|
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
|