vinted-api-kit 1.0.0__tar.gz → 1.1.0__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.
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/PKG-INFO +4 -21
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/README.md +3 -20
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/pyproject.toml +4 -1
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/__init__.py +3 -1
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/api/base.py +4 -2
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/api/catalog.py +33 -12
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/api/items.py +23 -24
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/client.py +16 -4
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/constants.py +0 -2
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/exceptions.py +4 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/models/config.py +5 -1
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/models/item.py +29 -7
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/session.py +41 -14
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/utils.py +5 -12
- vinted_api_kit-1.0.0/.editorconfig +0 -22
- vinted_api_kit-1.0.0/.github/workflows/ci.yaml +0 -35
- vinted_api_kit-1.0.0/.github/workflows/coverage.yaml +0 -31
- vinted_api_kit-1.0.0/.pre-commit-config.yaml +0 -27
- vinted_api_kit-1.0.0/CHANGELOG.md +0 -69
- vinted_api_kit-1.0.0/Makefile +0 -91
- vinted_api_kit-1.0.0/assets/logo.png +0 -0
- vinted_api_kit-1.0.0/examples/base_usage.py +0 -32
- vinted_api_kit-1.0.0/examples/raw_data.py +0 -23
- vinted_api_kit-1.0.0/examples/with_proxy.py +0 -41
- vinted_api_kit-1.0.0/tests/__init__.py +0 -0
- vinted_api_kit-1.0.0/tests/api/__init__.py +0 -0
- vinted_api_kit-1.0.0/tests/api/test_catalog.py +0 -98
- vinted_api_kit-1.0.0/tests/api/test_items.py +0 -64
- vinted_api_kit-1.0.0/tests/conftest.py +0 -74
- vinted_api_kit-1.0.0/tests/models/__init__.py +0 -0
- vinted_api_kit-1.0.0/tests/models/test_catalog_item.py +0 -54
- vinted_api_kit-1.0.0/tests/models/test_config.py +0 -47
- vinted_api_kit-1.0.0/tests/models/test_detailed_item.py +0 -41
- vinted_api_kit-1.0.0/tests/storage/__init__.py +0 -0
- vinted_api_kit-1.0.0/tests/storage/test_json.py +0 -95
- vinted_api_kit-1.0.0/tests/storage/test_mozilla.py +0 -49
- vinted_api_kit-1.0.0/tests/storage/test_pickle.py +0 -59
- vinted_api_kit-1.0.0/tests/test_auth.py +0 -62
- vinted_api_kit-1.0.0/tests/test_client.py +0 -40
- vinted_api_kit-1.0.0/tests/test_session.py +0 -138
- vinted_api_kit-1.0.0/tests/test_utils.py +0 -44
- vinted_api_kit-1.0.0/tox.ini +0 -11
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/.gitignore +0 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/LICENSE +0 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/api/__init__.py +0 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/auth.py +0 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/models/__init__.py +0 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/storage/__init__.py +0 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/storage/base.py +0 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/storage/json.py +0 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/storage/mozilla.py +0 -0
- {vinted_api_kit-1.0.0 → vinted_api_kit-1.1.0}/vinted/storage/pickle.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vinted-api-kit
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Lightweight asynchronous Python client library for accessing Vinted API and scraping item data.
|
|
5
5
|
Project-URL: Homepage, https://github.com/vlymar1/vinted-api-kit
|
|
6
6
|
Project-URL: Documentation, https://github.com/vlymar1/vinted-api-kit
|
|
@@ -50,11 +50,10 @@ Description-Content-Type: text/markdown
|
|
|
50
50
|
- 🚀 **Asynchronous** - Built with asyncio for high performance
|
|
51
51
|
- 🌍 **Auto-locale Detection** - Automatically detects locale from URL
|
|
52
52
|
- 🔍 **Item Search** - Search catalog with filters, sorting and pagination
|
|
53
|
-
- 📦 **Item Details** - Get complete item information with rich metadata
|
|
54
53
|
- 🍪 **Cookie Persistence** - Automatic session management with multiple storage formats
|
|
55
54
|
- 🔐 **Proxy Support** - Simple string-based proxy configuration
|
|
56
55
|
- 📊 **Type Hints** - Full typing support with Literal types for better IDE experience
|
|
57
|
-
- 🎯 **Dataclass Models** - Fast and efficient data models (15% performance boost)
|
|
56
|
+
- 🎯 **Dataclass Models** - Fast and efficient data models (15% performance boost)
|
|
58
57
|
- 🛡️ **Custom Exceptions** - Detailed error hierarchy for precise error handling
|
|
59
58
|
- 💾 **Flexible Storage** - Choose between pickle, JSON, or Mozilla cookie formats
|
|
60
59
|
|
|
@@ -165,22 +164,7 @@ raw_items = await client.search_items(
|
|
|
165
164
|
|
|
166
165
|
### Item Details
|
|
167
166
|
|
|
168
|
-
>
|
|
169
|
-
```python
|
|
170
|
-
item = await client.item_details(
|
|
171
|
-
url="https://www.vinted.com/items/1234567890"
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
print(f"Title: {item.title}")
|
|
175
|
-
print(f"Brand: {item.brand_title}")
|
|
176
|
-
print(f"Size: {item.size_title}")
|
|
177
|
-
print(f"Price: {item.price} {item.currency}")
|
|
178
|
-
print(f"Total: {item.total_item_price}")
|
|
179
|
-
print(f"Description: {item.description}")
|
|
180
|
-
|
|
181
|
-
# Or get raw JSON
|
|
182
|
-
raw_item = await client.item_details(url, raw_data=True)
|
|
183
|
-
```
|
|
167
|
+
> ⚠️ **Deprecated since 1.1.0.** Vinted removed the item details API, so `item_details()` raises `VintedDeprecatedError`.
|
|
184
168
|
|
|
185
169
|
### Parameters
|
|
186
170
|
|
|
@@ -352,8 +336,7 @@ vinted-api-kit/
|
|
|
352
336
|
│ ├── constants.py # Constants and type definitions
|
|
353
337
|
│ ├── exceptions.py # Custom exceptions
|
|
354
338
|
│ └── utils.py # Utility functions
|
|
355
|
-
|
|
356
|
-
└── examples/ # Usage examples
|
|
339
|
+
└── tests/ # Test suite
|
|
357
340
|
```
|
|
358
341
|
|
|
359
342
|
### Testing
|
|
@@ -18,11 +18,10 @@
|
|
|
18
18
|
- 🚀 **Asynchronous** - Built with asyncio for high performance
|
|
19
19
|
- 🌍 **Auto-locale Detection** - Automatically detects locale from URL
|
|
20
20
|
- 🔍 **Item Search** - Search catalog with filters, sorting and pagination
|
|
21
|
-
- 📦 **Item Details** - Get complete item information with rich metadata
|
|
22
21
|
- 🍪 **Cookie Persistence** - Automatic session management with multiple storage formats
|
|
23
22
|
- 🔐 **Proxy Support** - Simple string-based proxy configuration
|
|
24
23
|
- 📊 **Type Hints** - Full typing support with Literal types for better IDE experience
|
|
25
|
-
- 🎯 **Dataclass Models** - Fast and efficient data models (15% performance boost)
|
|
24
|
+
- 🎯 **Dataclass Models** - Fast and efficient data models (15% performance boost)
|
|
26
25
|
- 🛡️ **Custom Exceptions** - Detailed error hierarchy for precise error handling
|
|
27
26
|
- 💾 **Flexible Storage** - Choose between pickle, JSON, or Mozilla cookie formats
|
|
28
27
|
|
|
@@ -133,22 +132,7 @@ raw_items = await client.search_items(
|
|
|
133
132
|
|
|
134
133
|
### Item Details
|
|
135
134
|
|
|
136
|
-
>
|
|
137
|
-
```python
|
|
138
|
-
item = await client.item_details(
|
|
139
|
-
url="https://www.vinted.com/items/1234567890"
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
print(f"Title: {item.title}")
|
|
143
|
-
print(f"Brand: {item.brand_title}")
|
|
144
|
-
print(f"Size: {item.size_title}")
|
|
145
|
-
print(f"Price: {item.price} {item.currency}")
|
|
146
|
-
print(f"Total: {item.total_item_price}")
|
|
147
|
-
print(f"Description: {item.description}")
|
|
148
|
-
|
|
149
|
-
# Or get raw JSON
|
|
150
|
-
raw_item = await client.item_details(url, raw_data=True)
|
|
151
|
-
```
|
|
135
|
+
> ⚠️ **Deprecated since 1.1.0.** Vinted removed the item details API, so `item_details()` raises `VintedDeprecatedError`.
|
|
152
136
|
|
|
153
137
|
### Parameters
|
|
154
138
|
|
|
@@ -320,8 +304,7 @@ vinted-api-kit/
|
|
|
320
304
|
│ ├── constants.py # Constants and type definitions
|
|
321
305
|
│ ├── exceptions.py # Custom exceptions
|
|
322
306
|
│ └── utils.py # Utility functions
|
|
323
|
-
|
|
324
|
-
└── examples/ # Usage examples
|
|
307
|
+
└── tests/ # Test suite
|
|
325
308
|
```
|
|
326
309
|
|
|
327
310
|
### Testing
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vinted-api-kit"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "Lightweight asynchronous Python client library for accessing Vinted API and scraping item data."
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Lymar Volodymyr",email = "volodymyr.lymar1@gmail.com"},
|
|
@@ -71,6 +71,9 @@ all = ["format", "lint", "type-check", "test-cov"]
|
|
|
71
71
|
[tool.hatch.build.targets.wheel]
|
|
72
72
|
packages = ["vinted"]
|
|
73
73
|
|
|
74
|
+
[tool.hatch.build.targets.sdist]
|
|
75
|
+
include = ["vinted", "README.md", "LICENSE"]
|
|
76
|
+
|
|
74
77
|
[tool.ruff]
|
|
75
78
|
exclude = [
|
|
76
79
|
".git",
|
|
@@ -4,6 +4,7 @@ from vinted.exceptions import (
|
|
|
4
4
|
VintedAPIError,
|
|
5
5
|
VintedAuthError,
|
|
6
6
|
VintedConfigError,
|
|
7
|
+
VintedDeprecatedError,
|
|
7
8
|
VintedError,
|
|
8
9
|
VintedNetworkError,
|
|
9
10
|
VintedRateLimitError,
|
|
@@ -11,7 +12,7 @@ from vinted.exceptions import (
|
|
|
11
12
|
)
|
|
12
13
|
from vinted.models.item import CatalogItem, DetailedItem
|
|
13
14
|
|
|
14
|
-
__version__ = "1.
|
|
15
|
+
__version__ = "1.1.0"
|
|
15
16
|
|
|
16
17
|
__all__ = [
|
|
17
18
|
"VintedClient",
|
|
@@ -24,6 +25,7 @@ __all__ = [
|
|
|
24
25
|
"VintedNetworkError",
|
|
25
26
|
"VintedConfigError",
|
|
26
27
|
"VintedValidationError",
|
|
28
|
+
"VintedDeprecatedError",
|
|
27
29
|
"SortOrder",
|
|
28
30
|
"StorageFormat",
|
|
29
31
|
]
|
|
@@ -4,6 +4,8 @@ This module provides a small base class that stores a shared
|
|
|
4
4
|
`HttpSession` instance used by higher-level API wrappers.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from vinted.exceptions import VintedConfigError
|
|
8
|
+
|
|
7
9
|
from ..session import HttpSession
|
|
8
10
|
|
|
9
11
|
|
|
@@ -22,11 +24,11 @@ class BaseAPI:
|
|
|
22
24
|
"""Return configured base URL for API requests.
|
|
23
25
|
|
|
24
26
|
Raises:
|
|
25
|
-
|
|
27
|
+
VintedConfigError: If the session `base_url` is not configured.
|
|
26
28
|
|
|
27
29
|
Returns:
|
|
28
30
|
The base URL (scheme + host) derived from the session.
|
|
29
31
|
"""
|
|
30
32
|
if not self.session.base_url:
|
|
31
|
-
raise
|
|
33
|
+
raise VintedConfigError("Session base_url not configured")
|
|
32
34
|
return self.session.base_url
|
|
@@ -8,7 +8,9 @@ and returns either raw JSON items or parsed `CatalogItem` instances.
|
|
|
8
8
|
import logging
|
|
9
9
|
import time
|
|
10
10
|
from typing import Any, Union
|
|
11
|
-
from urllib.parse import parse_qsl, urlparse
|
|
11
|
+
from urllib.parse import parse_qsl, urljoin, urlparse
|
|
12
|
+
|
|
13
|
+
from vinted.exceptions import VintedValidationError
|
|
12
14
|
|
|
13
15
|
from ..constants import SortOrder
|
|
14
16
|
from ..models import CatalogItem
|
|
@@ -46,8 +48,9 @@ class CatalogAPI(BaseAPI):
|
|
|
46
48
|
Returns:
|
|
47
49
|
List of `CatalogItem` instances or raw item dicts.
|
|
48
50
|
"""
|
|
51
|
+
self._validate_catalog_url(url)
|
|
49
52
|
self.session.configure_from_url(url)
|
|
50
|
-
api_url = f"{self.base_url}/
|
|
53
|
+
api_url = f"{self.base_url}/web/gateway/svc-catalogue/items"
|
|
51
54
|
|
|
52
55
|
params = self._build_params(url, per_page, page)
|
|
53
56
|
params["time"] = timestamp or int(time.time())
|
|
@@ -66,7 +69,11 @@ class CatalogAPI(BaseAPI):
|
|
|
66
69
|
if raw_data:
|
|
67
70
|
return items
|
|
68
71
|
|
|
69
|
-
|
|
72
|
+
catalog_items = [CatalogItem(raw_data=item) for item in items]
|
|
73
|
+
for catalog_item in catalog_items:
|
|
74
|
+
catalog_item.url = urljoin(f"{self.base_url}/", catalog_item.url)
|
|
75
|
+
|
|
76
|
+
return catalog_items
|
|
70
77
|
|
|
71
78
|
def _build_params(self, url: str, per_page: int, page: int) -> dict:
|
|
72
79
|
"""Build API query params from a public catalog URL.
|
|
@@ -80,17 +87,19 @@ class CatalogAPI(BaseAPI):
|
|
|
80
87
|
catalog_id = self._extract_catalog_id(parsed.path)
|
|
81
88
|
catalog_ids_query = self._join_values(query_params, "catalog[]")
|
|
82
89
|
|
|
90
|
+
status_ids = self._extract_values(query_params, "status_ids[]") + self._extract_values(
|
|
91
|
+
query_params, "status[]"
|
|
92
|
+
)
|
|
93
|
+
|
|
83
94
|
params = {
|
|
84
95
|
"search_text": "+".join(self._extract_values(query_params, "search_text")),
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"city_ids": self._join_values(query_params, "city_ids[]"),
|
|
93
|
-
"is_for_swap": ",".join("1" for _ in self._extract_values(query_params, "disposal[]")),
|
|
96
|
+
"attribute_ids[catalog]": str(catalog_id) if catalog_id else catalog_ids_query,
|
|
97
|
+
"attribute_ids[color]": self._join_values(query_params, "color_ids[]"),
|
|
98
|
+
"attribute_ids[brand]": self._join_values(query_params, "brand_ids[]"),
|
|
99
|
+
"attribute_ids[size]": self._join_values(query_params, "size_ids[]"),
|
|
100
|
+
"attribute_ids[material]": self._join_values(query_params, "material_ids[]"),
|
|
101
|
+
"attribute_ids[status]": ",".join(status_ids),
|
|
102
|
+
"attribute_ids[patterns]": self._join_values(query_params, "patterns_ids[]"),
|
|
94
103
|
"currency": self._join_values(query_params, "currency"),
|
|
95
104
|
"price_to": self._join_values(query_params, "price_to"),
|
|
96
105
|
"price_from": self._join_values(query_params, "price_from"),
|
|
@@ -101,6 +110,18 @@ class CatalogAPI(BaseAPI):
|
|
|
101
110
|
|
|
102
111
|
return {k: v for k, v in params.items() if v}
|
|
103
112
|
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _validate_catalog_url(url: str) -> None:
|
|
115
|
+
"""Validate basic catalog URL shape without restricting the domain."""
|
|
116
|
+
parsed = urlparse(url)
|
|
117
|
+
|
|
118
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
119
|
+
raise VintedValidationError("Invalid catalog URL: expected an absolute HTTP(S) URL")
|
|
120
|
+
|
|
121
|
+
path = parsed.path.rstrip("/")
|
|
122
|
+
if path and not path.startswith("/catalog"):
|
|
123
|
+
raise VintedValidationError("Invalid catalog URL: expected path '/catalog'")
|
|
124
|
+
|
|
104
125
|
@staticmethod
|
|
105
126
|
def _extract_catalog_id(path: str) -> int | None:
|
|
106
127
|
"""Extract catalog id from path if present.
|
|
@@ -6,9 +6,11 @@ URL and returns either a parsed `DetailedItem` or raw JSON.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
-
from typing import
|
|
9
|
+
from typing import Union
|
|
10
10
|
from urllib.parse import urlparse
|
|
11
11
|
|
|
12
|
+
from vinted.exceptions import VintedDeprecatedError, VintedValidationError
|
|
13
|
+
|
|
12
14
|
from ..models import DetailedItem
|
|
13
15
|
from .base import BaseAPI
|
|
14
16
|
|
|
@@ -27,33 +29,18 @@ class ItemsAPI(BaseAPI):
|
|
|
27
29
|
url: str,
|
|
28
30
|
raw_data: bool = False,
|
|
29
31
|
) -> Union[DetailedItem, dict]:
|
|
30
|
-
"""
|
|
32
|
+
"""Deprecated: always raises `VintedDeprecatedError`.
|
|
33
|
+
|
|
34
|
+
Vinted removed the item details API endpoint.
|
|
31
35
|
|
|
32
36
|
Args:
|
|
33
37
|
url: Public Vinted item URL.
|
|
34
38
|
raw_data: If True, return raw JSON dictionary instead of
|
|
35
39
|
a `DetailedItem` instance.
|
|
36
|
-
|
|
37
|
-
Returns:
|
|
38
|
-
`DetailedItem` or raw dict depending on `raw_data`.
|
|
39
40
|
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
api_url = f"{self.base_url}/api/v2/items/{product_id}/details"
|
|
44
|
-
|
|
45
|
-
logger.debug("Fetching item details: %s", api_url)
|
|
46
|
-
|
|
47
|
-
response = await self.session.request(api_url)
|
|
48
|
-
data = response.json()
|
|
49
|
-
item_data: dict[Any, Any] = data.get("item", {})
|
|
50
|
-
|
|
51
|
-
logger.debug("Item details fetched successfully")
|
|
52
|
-
|
|
53
|
-
if raw_data:
|
|
54
|
-
return item_data
|
|
55
|
-
|
|
56
|
-
return DetailedItem(raw_data=item_data)
|
|
41
|
+
raise VintedDeprecatedError(
|
|
42
|
+
"item_details() no longer works: Vinted removed the item details API"
|
|
43
|
+
)
|
|
57
44
|
|
|
58
45
|
@staticmethod
|
|
59
46
|
def _extract_product_id(url: str) -> str:
|
|
@@ -62,5 +49,17 @@ class ItemsAPI(BaseAPI):
|
|
|
62
49
|
The public path is expected to be `/items/<id>-...` or similar; the
|
|
63
50
|
method extracts the numeric id component.
|
|
64
51
|
"""
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
parsed = urlparse(url)
|
|
53
|
+
path_parts = parsed.path.strip("/").split("/")
|
|
54
|
+
|
|
55
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
56
|
+
raise VintedValidationError("Invalid item URL: expected an absolute HTTP(S) URL")
|
|
57
|
+
|
|
58
|
+
if len(path_parts) < 2 or path_parts[0] != "items":
|
|
59
|
+
raise VintedValidationError("Invalid item URL: expected path '/items/<id>'")
|
|
60
|
+
|
|
61
|
+
product_id = path_parts[1].split("-")[0]
|
|
62
|
+
if not product_id.isdigit():
|
|
63
|
+
raise VintedValidationError("Invalid item URL: item id must be numeric")
|
|
64
|
+
|
|
65
|
+
return product_id
|
|
@@ -12,6 +12,7 @@ from typing import Type, Union
|
|
|
12
12
|
from .api.catalog import CatalogAPI
|
|
13
13
|
from .api.items import ItemsAPI
|
|
14
14
|
from .constants import SortOrder, StorageFormat
|
|
15
|
+
from .exceptions import VintedConfigError
|
|
15
16
|
from .models.config import ClientConfig
|
|
16
17
|
from .models.item import CatalogItem, DetailedItem
|
|
17
18
|
from .session import HttpSession
|
|
@@ -82,6 +83,12 @@ class VintedClient:
|
|
|
82
83
|
"mozilla": MozillaStorage,
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
if config.storage_format not in storage_map:
|
|
87
|
+
valid_formats = ", ".join(storage_map)
|
|
88
|
+
raise VintedConfigError(
|
|
89
|
+
f"Invalid storage_format '{config.storage_format}'. Valid options: {valid_formats}"
|
|
90
|
+
)
|
|
91
|
+
|
|
85
92
|
storage_class = storage_map[config.storage_format]
|
|
86
93
|
return storage_class(filepath)
|
|
87
94
|
|
|
@@ -110,6 +117,12 @@ class VintedClient:
|
|
|
110
117
|
"mozilla": ".txt",
|
|
111
118
|
}
|
|
112
119
|
|
|
120
|
+
if config.storage_format not in extensions:
|
|
121
|
+
valid_formats = ", ".join(extensions)
|
|
122
|
+
raise VintedConfigError(
|
|
123
|
+
f"Invalid storage_format '{config.storage_format}'. Valid options: {valid_formats}"
|
|
124
|
+
)
|
|
125
|
+
|
|
113
126
|
filename += extensions[config.storage_format]
|
|
114
127
|
|
|
115
128
|
return config.cookies_dir / filename
|
|
@@ -150,14 +163,13 @@ class VintedClient:
|
|
|
150
163
|
url: str,
|
|
151
164
|
raw_data: bool = False,
|
|
152
165
|
) -> Union[DetailedItem, dict]:
|
|
153
|
-
"""
|
|
166
|
+
"""Deprecated: always raises `VintedDeprecatedError`.
|
|
167
|
+
|
|
168
|
+
Vinted removed the item details API endpoint.
|
|
154
169
|
|
|
155
170
|
Args:
|
|
156
171
|
url: Item page URL or API endpoint.
|
|
157
172
|
raw_data: When True returns raw dict instead of `DetailedItem`.
|
|
158
|
-
|
|
159
|
-
Returns:
|
|
160
|
-
`DetailedItem` or raw dict when `raw_data` is True.
|
|
161
173
|
"""
|
|
162
174
|
return await self._items.get_details(url=url, raw_data=raw_data)
|
|
163
175
|
|
|
@@ -57,8 +57,6 @@ LOCALE_TO_ACCEPT_LANGUAGE = {
|
|
|
57
57
|
"com": "en-US",
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
VINTED_BASE_URL_TEMPLATE = "https://www.vinted.{locale}"
|
|
61
|
-
|
|
62
60
|
SortOrder = Literal["newest_first", "relevance", "price_high_to_low", "price_low_to_high"]
|
|
63
61
|
|
|
64
62
|
StorageFormat = Literal["json", "pickle", "mozilla"]
|
|
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
from ..constants import StorageFormat
|
|
11
|
+
from ..exceptions import VintedConfigError
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@dataclass
|
|
@@ -35,4 +36,7 @@ class ClientConfig:
|
|
|
35
36
|
if isinstance(self.cookies_dir, str):
|
|
36
37
|
self.cookies_dir = Path(self.cookies_dir)
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
try:
|
|
40
|
+
self.cookies_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
except OSError as e:
|
|
42
|
+
raise VintedConfigError(f"Invalid cookies_dir '{self.cookies_dir}': {e}") from e
|
|
@@ -8,6 +8,8 @@ from dataclasses import dataclass, field
|
|
|
8
8
|
from datetime import datetime, timezone
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
from ..exceptions import VintedDeprecatedError
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
@dataclass
|
|
13
15
|
class CatalogItem:
|
|
@@ -15,7 +17,12 @@ class CatalogItem:
|
|
|
15
17
|
|
|
16
18
|
The class stores the original `raw_data` (hidden from the repr)
|
|
17
19
|
and extracts commonly-used fields like `id`, `title`, `price` and
|
|
18
|
-
`photo` for convenience.
|
|
20
|
+
`photo` for convenience. Brand, size and condition come from
|
|
21
|
+
`item_box` in the language of the Vinted domain.
|
|
22
|
+
|
|
23
|
+
`created_at_ts` and `raw_timestamp` are deprecated: the catalog API
|
|
24
|
+
no longer returns the photo timestamp, so they always hold epoch
|
|
25
|
+
and 0. They will be removed in the next major release.
|
|
19
26
|
"""
|
|
20
27
|
|
|
21
28
|
raw_data: dict[str, Any] = field(repr=False)
|
|
@@ -23,8 +30,10 @@ class CatalogItem:
|
|
|
23
30
|
title: str = field(init=False)
|
|
24
31
|
brand_title: str = field(init=False)
|
|
25
32
|
size_title: str = field(init=False)
|
|
33
|
+
condition: str = field(init=False)
|
|
26
34
|
currency: str = field(init=False)
|
|
27
35
|
price: float = field(init=False)
|
|
36
|
+
total_item_price: float = field(init=False)
|
|
28
37
|
photo: str = field(init=False)
|
|
29
38
|
url: str = field(init=False)
|
|
30
39
|
created_at_ts: datetime = field(init=False)
|
|
@@ -33,12 +42,19 @@ class CatalogItem:
|
|
|
33
42
|
def __post_init__(self):
|
|
34
43
|
self.id = self.raw_data.get("id", 0)
|
|
35
44
|
self.title = self.raw_data.get("title", "")
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
|
|
46
|
+
item_box = self.raw_data.get("item_box") or {}
|
|
47
|
+
self.brand_title = item_box.get("first_line", "")
|
|
48
|
+
# second_line is "<size> · <condition>", the size part is optional
|
|
49
|
+
*sizes, self.condition = item_box.get("second_line", "").split(" · ")
|
|
50
|
+
self.size_title = sizes[0] if sizes else ""
|
|
38
51
|
|
|
39
52
|
price = self.raw_data.get("price") or {}
|
|
40
53
|
self.currency = price.get("currency_code", "")
|
|
41
|
-
self.price = price.get("amount"
|
|
54
|
+
self.price = float(price.get("amount") or 0.0)
|
|
55
|
+
|
|
56
|
+
total_item_price = self.raw_data.get("total_item_price") or {}
|
|
57
|
+
self.total_item_price = float(total_item_price.get("amount") or 0.0)
|
|
42
58
|
|
|
43
59
|
photo = self.raw_data.get("photo") or {}
|
|
44
60
|
self.photo = photo.get("url", "")
|
|
@@ -69,13 +85,19 @@ class CatalogItem:
|
|
|
69
85
|
return hash(self.id)
|
|
70
86
|
|
|
71
87
|
def is_new_item(self, minutes: int = 1) -> bool:
|
|
72
|
-
"""
|
|
88
|
+
"""Deprecated: always raises `VintedDeprecatedError`.
|
|
89
|
+
|
|
90
|
+
The check compared `created_at_ts` with the current time, but the
|
|
91
|
+
catalog API no longer returns the photo timestamp. The method will
|
|
92
|
+
be removed in the next major release.
|
|
73
93
|
|
|
74
94
|
Args:
|
|
75
95
|
minutes: Age threshold in minutes (default: 1).
|
|
76
96
|
"""
|
|
77
|
-
|
|
78
|
-
|
|
97
|
+
raise VintedDeprecatedError(
|
|
98
|
+
"CatalogItem.is_new_item() no longer works: the Vinted catalog API "
|
|
99
|
+
"does not return item timestamps anymore"
|
|
100
|
+
)
|
|
79
101
|
|
|
80
102
|
|
|
81
103
|
@dataclass
|
|
@@ -12,16 +12,23 @@ from curl_cffi import AsyncSession
|
|
|
12
12
|
from curl_cffi.requests import Response
|
|
13
13
|
from curl_cffi.requests.exceptions import HTTPError as CurlHTTPError
|
|
14
14
|
|
|
15
|
-
from vinted.exceptions import
|
|
15
|
+
from vinted.exceptions import (
|
|
16
|
+
VintedAPIError,
|
|
17
|
+
VintedAuthError,
|
|
18
|
+
VintedConfigError,
|
|
19
|
+
VintedNetworkError,
|
|
20
|
+
VintedRateLimitError,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
from .auth import AuthManager
|
|
18
24
|
from .constants import (
|
|
19
25
|
DEFAULT_HEADERS,
|
|
20
26
|
HTTP_STATUS_FORBIDDEN,
|
|
27
|
+
HTTP_STATUS_RATE_LIMIT,
|
|
21
28
|
HTTP_STATUS_UNAUTHORIZED,
|
|
22
29
|
)
|
|
23
30
|
from .storage import CookieStorage
|
|
24
|
-
from .utils import format_proxy_for_log, get_accept_language
|
|
31
|
+
from .utils import format_proxy_for_log, get_accept_language, get_locale_code
|
|
25
32
|
|
|
26
33
|
logger = logging.getLogger(__name__)
|
|
27
34
|
|
|
@@ -77,23 +84,30 @@ class HttpSession:
|
|
|
77
84
|
def configure_from_url(self, url: str) -> None:
|
|
78
85
|
parsed = urlparse(url)
|
|
79
86
|
self.base_url = f"https://{parsed.netloc}"
|
|
87
|
+
self.locale = None
|
|
88
|
+
|
|
89
|
+
host = parsed.netloc.lower()
|
|
90
|
+
if host.startswith("www."):
|
|
91
|
+
host = host[4:]
|
|
80
92
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
self.locale = domain_parts[-1]
|
|
93
|
+
if host.startswith("vinted."):
|
|
94
|
+
self.locale = host[len("vinted.") :]
|
|
84
95
|
accept_language = get_accept_language(self.locale)
|
|
85
|
-
|
|
96
|
+
# `locale` selects the marketplace whose items the catalog returns
|
|
97
|
+
self.session.headers.update(
|
|
98
|
+
{"Accept-Language": accept_language, "locale": get_locale_code(self.locale)}
|
|
99
|
+
)
|
|
86
100
|
|
|
87
101
|
self.session.headers.update({"Referer": self.base_url})
|
|
88
102
|
logger.debug("Configured: base_url=%s, locale=%s", self.base_url, self.locale)
|
|
89
103
|
|
|
90
104
|
async def refresh_cookies(self) -> None:
|
|
91
105
|
if not self.base_url:
|
|
92
|
-
raise
|
|
106
|
+
raise VintedConfigError("base_url not configured")
|
|
93
107
|
|
|
94
108
|
logger.debug("Refreshing session cookies...")
|
|
95
109
|
|
|
96
|
-
self.
|
|
110
|
+
self._clear_session_cookies()
|
|
97
111
|
|
|
98
112
|
self.session.headers.update({"Referer": ""})
|
|
99
113
|
|
|
@@ -108,15 +122,16 @@ class HttpSession:
|
|
|
108
122
|
logger.debug("Fresh cookies received: %d cookies", len(self.session.cookies))
|
|
109
123
|
|
|
110
124
|
if self.storage:
|
|
111
|
-
|
|
125
|
+
try:
|
|
126
|
+
self.storage.save(self.session.cookies.jar)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
raise VintedConfigError("Failed to save cookies") from e
|
|
112
129
|
|
|
113
130
|
logger.info("Session cookies refreshed successfully")
|
|
114
131
|
|
|
115
|
-
def
|
|
132
|
+
def _clear_session_cookies(self) -> None:
|
|
116
133
|
self.session.cookies.clear()
|
|
117
|
-
|
|
118
|
-
self.storage.clear()
|
|
119
|
-
logger.debug("Cookies cleared")
|
|
134
|
+
logger.debug("Session cookies cleared")
|
|
120
135
|
|
|
121
136
|
def _load_cookies(self) -> bool:
|
|
122
137
|
if not self.storage:
|
|
@@ -137,7 +152,6 @@ class HttpSession:
|
|
|
137
152
|
logger.info("Access token expired, refreshing cookies")
|
|
138
153
|
await self.refresh_cookies()
|
|
139
154
|
else:
|
|
140
|
-
# Если куки не были загружены, то рефрешим
|
|
141
155
|
logger.debug("No saved cookies, refreshing...")
|
|
142
156
|
await self.refresh_cookies()
|
|
143
157
|
|
|
@@ -173,6 +187,19 @@ class HttpSession:
|
|
|
173
187
|
)
|
|
174
188
|
except Exception as e:
|
|
175
189
|
raise VintedNetworkError("Network error on retry", e)
|
|
190
|
+
if response.status_code == HTTP_STATUS_RATE_LIMIT:
|
|
191
|
+
raise VintedRateLimitError(
|
|
192
|
+
f"HTTP {response.status_code}: {response.reason}",
|
|
193
|
+
status_code=response.status_code,
|
|
194
|
+
response=response,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if response.status_code in (HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN):
|
|
198
|
+
raise VintedAuthError(
|
|
199
|
+
f"Authentication failed after retry: HTTP {response.status_code}: {response.reason}",
|
|
200
|
+
status_code=response.status_code,
|
|
201
|
+
response=response,
|
|
202
|
+
)
|
|
176
203
|
|
|
177
204
|
if response.status_code >= 400:
|
|
178
205
|
raise VintedAPIError(
|
|
@@ -4,7 +4,7 @@ Small helpers used across the library for formatting proxy strings and
|
|
|
4
4
|
resolving Accept-Language headers.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from .constants import LOCALE_TO_ACCEPT_LANGUAGE
|
|
7
|
+
from .constants import LOCALE_TO_ACCEPT_LANGUAGE
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def format_proxy_for_log(proxy: str | None) -> str:
|
|
@@ -28,16 +28,9 @@ def format_proxy_for_log(proxy: str | None) -> str:
|
|
|
28
28
|
return proxy
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
Raises `ValueError` when the provided `locale` is not in
|
|
35
|
-
`VALID_LOCALES`.
|
|
36
|
-
"""
|
|
37
|
-
if locale not in VALID_LOCALES:
|
|
38
|
-
raise ValueError(
|
|
39
|
-
"Invalid locale '%s'. Valid options: %s" % (locale, ", ".join(VALID_LOCALES))
|
|
40
|
-
)
|
|
31
|
+
def get_locale_code(locale: str) -> str:
|
|
32
|
+
"""Return the full locale code for `locale` (e.g. "sk" -> "sk-SK")."""
|
|
33
|
+
return LOCALE_TO_ACCEPT_LANGUAGE.get(locale, "en-US")
|
|
41
34
|
|
|
42
35
|
|
|
43
36
|
def get_accept_language(locale: str) -> str:
|
|
@@ -46,6 +39,6 @@ def get_accept_language(locale: str) -> str:
|
|
|
46
39
|
The returned value contains the full locale code and a fallback
|
|
47
40
|
language prefix with a lower q-value.
|
|
48
41
|
"""
|
|
49
|
-
locale_code =
|
|
42
|
+
locale_code = get_locale_code(locale)
|
|
50
43
|
lang_prefix = locale_code.split("-")[0]
|
|
51
44
|
return "%s,%s;q=0.9" % (locale_code, lang_prefix)
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
root = true
|
|
2
|
-
|
|
3
|
-
[**]
|
|
4
|
-
charset = utf-8
|
|
5
|
-
end_of_line = lf
|
|
6
|
-
indent_size = 4
|
|
7
|
-
indent_style = space
|
|
8
|
-
insert_final_newline = true
|
|
9
|
-
trim_trailing_whitespace = true
|
|
10
|
-
max_line_length = 99
|
|
11
|
-
|
|
12
|
-
[**.{yml,yaml,json,conf}]
|
|
13
|
-
indent_size = 2
|
|
14
|
-
|
|
15
|
-
[**.{md,txt,rst}]
|
|
16
|
-
trim_trailing_whitespace = false
|
|
17
|
-
|
|
18
|
-
[**.rst]
|
|
19
|
-
indent_size = 2
|
|
20
|
-
|
|
21
|
-
[Makefile]
|
|
22
|
-
indent_style = tab
|