vinted-api-kit 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- vinted_api_kit/client/__init__.py +0 -0
- vinted_api_kit/client/user_agents.py +25 -0
- vinted_api_kit/client/vinted_http_client.py +353 -0
- vinted_api_kit/models/__init__.py +4 -0
- vinted_api_kit/models/catalog_item.py +102 -0
- vinted_api_kit/models/detailed_item.py +140 -0
- vinted_api_kit/services/__init__.py +0 -0
- vinted_api_kit/services/item_service.py +230 -0
- vinted_api_kit/utils.py +22 -0
- vinted_api_kit/vinted_api.py +120 -0
- vinted_api_kit-0.1.0.dist-info/METADATA +207 -0
- vinted_api_kit-0.1.0.dist-info/RECORD +15 -0
- vinted_api_kit-0.1.0.dist-info/WHEEL +4 -0
- vinted_api_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import random
|
|
2
|
+
|
|
3
|
+
USER_AGENTS = [
|
|
4
|
+
# Windows 10/11 CHROME
|
|
5
|
+
# 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
|
6
|
+
# Edge
|
|
7
|
+
# 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.3351.95',
|
|
8
|
+
# Firefox Win10
|
|
9
|
+
# 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0',
|
|
10
|
+
# MacOS CHROME
|
|
11
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
|
12
|
+
# Safari MacOS
|
|
13
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15",
|
|
14
|
+
# Firefox MacOS
|
|
15
|
+
# 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15.5; rv:140.0) Gecko/20100101 Firefox/140.0',
|
|
16
|
+
# Linux CHROME
|
|
17
|
+
# 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_random_user_agent() -> str:
|
|
22
|
+
"""
|
|
23
|
+
Return a random user agent string from predefined list.
|
|
24
|
+
"""
|
|
25
|
+
return random.choice(USER_AGENTS)
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pickle
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional, cast
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
import curl_cffi
|
|
8
|
+
from curl_cffi import AsyncSession
|
|
9
|
+
from curl_cffi.requests import Response
|
|
10
|
+
from curl_cffi.requests.exceptions import HTTPError
|
|
11
|
+
|
|
12
|
+
from vinted_api_kit.client.user_agents import get_random_user_agent
|
|
13
|
+
from vinted_api_kit.utils import format_proxy_for_log
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
HTTP_STATUS_UNAUTHORIZED = 401
|
|
18
|
+
HTTP_STATUS_FORBIDDEN = 403
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VintedHttpClient:
|
|
22
|
+
"""
|
|
23
|
+
Asynchronous HTTP client for Vinted API.
|
|
24
|
+
|
|
25
|
+
Manages sessions, headers, proxies, cookies persistence and handles authentication.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
locale: Optional[str] = None,
|
|
31
|
+
proxies: Optional[dict[str, str]] = None,
|
|
32
|
+
client_ip: Optional[str] = None,
|
|
33
|
+
cookies_dir: Optional[Path] = None,
|
|
34
|
+
persist_cookies: bool = True,
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
Initialize the HTTP client with optional locale, proxies, client IP and cookie handling.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
locale (Optional[str]): Locale code (e.g. 'fr', 'de').
|
|
41
|
+
proxies (Optional[dict]): Proxy settings.
|
|
42
|
+
client_ip (Optional[str]): Client IP for headers.
|
|
43
|
+
cookies_dir (Optional[Path]): Directory to store cookies.
|
|
44
|
+
persist_cookies (bool): Whether to save/load cookies from disk.
|
|
45
|
+
"""
|
|
46
|
+
self.locale = locale
|
|
47
|
+
self.proxies = proxies
|
|
48
|
+
self.client_ip = client_ip
|
|
49
|
+
self.base_url: Optional[str] = None
|
|
50
|
+
self.session: AsyncSession = curl_cffi.AsyncSession()
|
|
51
|
+
self.cookies_dir = cookies_dir or Path(".")
|
|
52
|
+
self.cookies_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
self.cookies_path = self._generate_cookies_path()
|
|
54
|
+
self.persist_cookies = persist_cookies
|
|
55
|
+
logger.debug(
|
|
56
|
+
"Initializing VintedHttpClient with locale=%s, proxy=%s, client_ip=%s, cookies_path=%s, persist_cookies=%s",
|
|
57
|
+
locale,
|
|
58
|
+
format_proxy_for_log(proxies),
|
|
59
|
+
client_ip,
|
|
60
|
+
self.cookies_path,
|
|
61
|
+
persist_cookies,
|
|
62
|
+
)
|
|
63
|
+
self._init_default_headers()
|
|
64
|
+
if proxies:
|
|
65
|
+
self.session.proxies.update(proxies) # type: ignore[typeddict-item]
|
|
66
|
+
ip = proxies.get("http", "").split("@")[-1].split(":")[0]
|
|
67
|
+
self._set_x_forwarded_for(ip)
|
|
68
|
+
elif client_ip:
|
|
69
|
+
self._set_x_forwarded_for(client_ip)
|
|
70
|
+
|
|
71
|
+
def _generate_cookies_path(self) -> Path:
|
|
72
|
+
"""
|
|
73
|
+
Generate file path for cookie storage based on proxies or client IP.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Path object representing cookies file location.
|
|
77
|
+
"""
|
|
78
|
+
if self.proxies:
|
|
79
|
+
proxy_str = self.proxies.get("http") or self.proxies.get("https")
|
|
80
|
+
if proxy_str:
|
|
81
|
+
proxy_uri = urlparse(proxy_str)
|
|
82
|
+
ip = proxy_uri.hostname or "unknown"
|
|
83
|
+
port = proxy_uri.port or 0
|
|
84
|
+
filename = f"cookies_{ip}_{port}.pk"
|
|
85
|
+
return self.cookies_dir / filename
|
|
86
|
+
if self.client_ip:
|
|
87
|
+
ip_safe = self.client_ip.replace(":", "_")
|
|
88
|
+
filename = f"cookies_{ip_safe}.pk"
|
|
89
|
+
return self.cookies_dir / filename
|
|
90
|
+
return self.cookies_dir / "cookies.pk"
|
|
91
|
+
|
|
92
|
+
def _init_default_headers(self):
|
|
93
|
+
"""
|
|
94
|
+
Set default HTTP headers for all requests.
|
|
95
|
+
"""
|
|
96
|
+
self.session.headers.update(
|
|
97
|
+
{
|
|
98
|
+
"User-Agent": get_random_user_agent(),
|
|
99
|
+
"Accept": "application/json, text/plain, */*",
|
|
100
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
101
|
+
"cache-control": "max-age=0",
|
|
102
|
+
"DNT": "1",
|
|
103
|
+
"Referer": "",
|
|
104
|
+
"Sec-CH-UA": '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
|
|
105
|
+
"Sec-CH-UA-Mobile": "?0",
|
|
106
|
+
"Sec-CH-UA-Platform": '"macOS"',
|
|
107
|
+
"Sec-Fetch-Dest": "empty",
|
|
108
|
+
"Sec-Fetch-Mode": "cors",
|
|
109
|
+
"Sec-Fetch-Site": "same-origin",
|
|
110
|
+
"X-Money-Object": "true",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
logger.debug("Default headers set: %s", self.session.headers)
|
|
114
|
+
|
|
115
|
+
def _set_x_forwarded_for(self, ip) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Set 'X-Forwarded-For' HTTP header to the specified IP.
|
|
118
|
+
"""
|
|
119
|
+
self.session.headers.update({"X-Forwarded-For": ip})
|
|
120
|
+
|
|
121
|
+
def configure_from_url(self, url: str) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Configure base URL and locale based on provided URL.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
url (str): URL string to parse.
|
|
127
|
+
"""
|
|
128
|
+
parsed_url = urlparse(url)
|
|
129
|
+
self.base_url = f"https://{parsed_url.netloc}"
|
|
130
|
+
if not self.locale:
|
|
131
|
+
domain_parts = parsed_url.netloc.split(".")
|
|
132
|
+
if len(domain_parts) > 1:
|
|
133
|
+
self.locale = domain_parts[-1]
|
|
134
|
+
self.session.headers.update({"Referer": self.base_url})
|
|
135
|
+
logger.debug(
|
|
136
|
+
"Configured client from URL: base_url=%s, locale=%s, referer header updated",
|
|
137
|
+
self.base_url,
|
|
138
|
+
self.locale,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def save_cookies(self) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Persist session cookies to disk if enabled.
|
|
144
|
+
"""
|
|
145
|
+
if not self.persist_cookies:
|
|
146
|
+
logger.debug("Persist cookies disabled, skipping save")
|
|
147
|
+
return
|
|
148
|
+
try:
|
|
149
|
+
with self.cookies_path.open("wb") as f:
|
|
150
|
+
cookies_jar = cast(Any, self.session.cookies.jar)
|
|
151
|
+
pickle.dump(cookies_jar._cookies, f) # noqa
|
|
152
|
+
logger.debug("Cookies saved successfully to %s", self.cookies_path)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.error("Failed to save cookies: %s", e, exc_info=True)
|
|
155
|
+
|
|
156
|
+
def load_cookies(self) -> Optional[dict]:
|
|
157
|
+
"""
|
|
158
|
+
Load cookies from disk if available.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Cookies dictionary or None if not exist/disabled.
|
|
162
|
+
"""
|
|
163
|
+
if not self.persist_cookies:
|
|
164
|
+
logger.debug("Persist cookies disabled, skipping load")
|
|
165
|
+
return None
|
|
166
|
+
if not self.cookies_path.is_file():
|
|
167
|
+
logger.debug("Cookies file does not exist: %s", self.cookies_path)
|
|
168
|
+
return None
|
|
169
|
+
try:
|
|
170
|
+
with self.cookies_path.open("rb") as f:
|
|
171
|
+
cookies = pickle.load(f)
|
|
172
|
+
if not isinstance(cookies, dict):
|
|
173
|
+
logger.warning(
|
|
174
|
+
"Cookies loaded but invalid format: expected dict, got %s", type(cookies)
|
|
175
|
+
)
|
|
176
|
+
return None
|
|
177
|
+
logger.debug("Cookies loaded successfully from %s", self.cookies_path)
|
|
178
|
+
return cookies
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error("Failed to load cookies: %s", e, exc_info=True)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
async def refresh_session_cookies(self) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Get fresh session cookies by visiting the site as a first-time user.
|
|
186
|
+
|
|
187
|
+
This method:
|
|
188
|
+
1. Clears all existing cookies
|
|
189
|
+
2. Makes a GET request to the base URL
|
|
190
|
+
3. Saves new cookies received from the server
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ValueError: If base_url is not configured
|
|
194
|
+
HTTPError: If the refresh request fails
|
|
195
|
+
"""
|
|
196
|
+
if not self.base_url:
|
|
197
|
+
raise ValueError("base_url is not configured")
|
|
198
|
+
|
|
199
|
+
logger.info("Getting fresh cookies as first-time visitor...")
|
|
200
|
+
|
|
201
|
+
self.clear_all_cookies()
|
|
202
|
+
|
|
203
|
+
response = await self.session.get(self.base_url, impersonate="chrome", verify=False)
|
|
204
|
+
response.raise_for_status()
|
|
205
|
+
|
|
206
|
+
logger.debug("Fresh cookies received: %s", len(self.session.cookies))
|
|
207
|
+
logger.debug(
|
|
208
|
+
"New cookies: %s", [f"{k}={str(v)[:20]}..." for k, v in self.session.cookies.items()]
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
self.save_cookies()
|
|
212
|
+
logger.info("Fresh session cookies obtained successfully")
|
|
213
|
+
|
|
214
|
+
def clear_all_cookies(self) -> None:
|
|
215
|
+
"""
|
|
216
|
+
Clear all cookies from session and delete cookies file.
|
|
217
|
+
|
|
218
|
+
This method is called when we need to start fresh, typically
|
|
219
|
+
when authentication fails or tokens are expired.
|
|
220
|
+
"""
|
|
221
|
+
self.session.cookies.clear()
|
|
222
|
+
|
|
223
|
+
if self.cookies_path.exists():
|
|
224
|
+
try:
|
|
225
|
+
self.cookies_path.unlink()
|
|
226
|
+
logger.debug("Cookies file deleted: %s", self.cookies_path)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.error("Failed to delete cookies file: %s", e)
|
|
229
|
+
|
|
230
|
+
logger.info("All cookies cleared")
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _is_token_expired(access_token: str) -> bool:
|
|
234
|
+
"""Check if JWT access token is expired."""
|
|
235
|
+
try:
|
|
236
|
+
import base64
|
|
237
|
+
import json
|
|
238
|
+
from datetime import datetime
|
|
239
|
+
|
|
240
|
+
payload_b64: str = access_token.split(".")[1]
|
|
241
|
+
payload_b64 += "=" * (4 - len(payload_b64) % 4)
|
|
242
|
+
payload = json.loads(base64.b64decode(payload_b64))
|
|
243
|
+
|
|
244
|
+
exp_timestamp = payload.get("exp", 0)
|
|
245
|
+
current_timestamp = datetime.now().timestamp()
|
|
246
|
+
result: bool = current_timestamp >= exp_timestamp
|
|
247
|
+
|
|
248
|
+
return result
|
|
249
|
+
except Exception:
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
def _update_auth_headers_from_cookies(self) -> None:
|
|
253
|
+
"""
|
|
254
|
+
Update authentication and related headers from stored cookies.
|
|
255
|
+
"""
|
|
256
|
+
cookies = self.session.cookies
|
|
257
|
+
access_token_web = cookies.get("access_token_web")
|
|
258
|
+
csrf_token = cookies.get("x-csrf-token")
|
|
259
|
+
anon_id = cookies.get("anon_id")
|
|
260
|
+
accept_language = cookies.get("anonymous-locale")
|
|
261
|
+
|
|
262
|
+
logger.debug(
|
|
263
|
+
"Current session cookies: %s",
|
|
264
|
+
[f"{k}={v[:20]}..." if len(str(v)) > 20 else f"{k}={v}" for k, v in cookies.items()],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if csrf_token:
|
|
268
|
+
self.session.headers.update({"X-Csrf-Token": csrf_token})
|
|
269
|
+
if access_token_web:
|
|
270
|
+
self.session.headers.update({"Authorization": f"Bearer {access_token_web}"})
|
|
271
|
+
if anon_id:
|
|
272
|
+
self.session.headers.update({"X-Anon-Id": anon_id})
|
|
273
|
+
if accept_language:
|
|
274
|
+
self.session.headers.update({"Accept-Language": accept_language})
|
|
275
|
+
logger.debug(
|
|
276
|
+
"Authentication headers updated from cookies: csrf_token=%s, access_token_web=%s, anon_id=%s, accept_language=%s",
|
|
277
|
+
bool(csrf_token),
|
|
278
|
+
bool(access_token_web),
|
|
279
|
+
bool(anon_id),
|
|
280
|
+
bool(accept_language),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def request(
|
|
284
|
+
self,
|
|
285
|
+
url: str,
|
|
286
|
+
params: Optional[dict] = None,
|
|
287
|
+
) -> Response:
|
|
288
|
+
"""
|
|
289
|
+
Perform an async GET request with cookie and auth management.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
url (str): URL for the HTTP GET.
|
|
293
|
+
params (Optional[dict]): Query parameters.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Response object.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
HTTPError: If response code >= 400.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
loaded_cookies = self.load_cookies()
|
|
303
|
+
if loaded_cookies:
|
|
304
|
+
cookies_jar = cast(Any, self.session.cookies.jar)
|
|
305
|
+
cookies_jar._cookies.update(loaded_cookies) # noqa
|
|
306
|
+
logger.debug("Initial cookies loaded and applied")
|
|
307
|
+
|
|
308
|
+
# Checking token expiration
|
|
309
|
+
access_token = self.session.cookies.get("access_token_web")
|
|
310
|
+
if access_token and self._is_token_expired(access_token):
|
|
311
|
+
logger.info("Access token expired, getting fresh cookies")
|
|
312
|
+
await self.refresh_session_cookies()
|
|
313
|
+
else:
|
|
314
|
+
logger.debug("No saved cookies found, refreshing...")
|
|
315
|
+
await self.refresh_session_cookies()
|
|
316
|
+
|
|
317
|
+
self._update_auth_headers_from_cookies()
|
|
318
|
+
|
|
319
|
+
response: Response = await self.session.get(
|
|
320
|
+
url=url,
|
|
321
|
+
params=params,
|
|
322
|
+
impersonate="chrome",
|
|
323
|
+
verify=False,
|
|
324
|
+
)
|
|
325
|
+
logger.debug("First request status: %s", response.status_code)
|
|
326
|
+
|
|
327
|
+
if response.status_code in (HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN):
|
|
328
|
+
logger.warning("Auth failed, getting completely fresh cookies...")
|
|
329
|
+
await self.refresh_session_cookies()
|
|
330
|
+
self._update_auth_headers_from_cookies()
|
|
331
|
+
|
|
332
|
+
response = await self.session.get(
|
|
333
|
+
url=url,
|
|
334
|
+
params=params,
|
|
335
|
+
impersonate="chrome",
|
|
336
|
+
verify=False,
|
|
337
|
+
)
|
|
338
|
+
logger.debug("Retry request status: %s", response.status_code)
|
|
339
|
+
|
|
340
|
+
if response.status_code >= 400:
|
|
341
|
+
raise HTTPError(
|
|
342
|
+
f"HTTP Error {response.status_code}: {response.reason}",
|
|
343
|
+
code=response.status_code, # type: ignore[arg-type]
|
|
344
|
+
response=response,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return response
|
|
348
|
+
|
|
349
|
+
async def close(self):
|
|
350
|
+
"""
|
|
351
|
+
Close the underlying HTTP session.
|
|
352
|
+
"""
|
|
353
|
+
await self.session.close()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CatalogItem:
|
|
5
|
+
"""
|
|
6
|
+
Catalog item representation used in search results.
|
|
7
|
+
|
|
8
|
+
Attributes
|
|
9
|
+
----------
|
|
10
|
+
raw_data : dict
|
|
11
|
+
Raw data dictionary from the API.
|
|
12
|
+
id : int
|
|
13
|
+
Unique item ID.
|
|
14
|
+
title : str
|
|
15
|
+
Item title.
|
|
16
|
+
brand_title : str
|
|
17
|
+
Brand name.
|
|
18
|
+
size_title : str
|
|
19
|
+
Size label.
|
|
20
|
+
currency : str
|
|
21
|
+
Currency code.
|
|
22
|
+
price : float
|
|
23
|
+
Item price amount.
|
|
24
|
+
photo : str
|
|
25
|
+
URL of the main photo.
|
|
26
|
+
url : str
|
|
27
|
+
Item URL on Vinted.
|
|
28
|
+
created_at_ts : datetime
|
|
29
|
+
Item creation datetime (UTC).
|
|
30
|
+
raw_timestamp : int
|
|
31
|
+
Raw timestamp from photo metadata.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, data):
|
|
35
|
+
"""
|
|
36
|
+
Initialize CatalogItem from API data.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
data : dict
|
|
41
|
+
Raw catalog item data.
|
|
42
|
+
"""
|
|
43
|
+
self.raw_data = data
|
|
44
|
+
self.id = data.get("id")
|
|
45
|
+
self.title = data.get("title")
|
|
46
|
+
self.brand_title = data.get("brand_title")
|
|
47
|
+
self.size_title = data.get("size_title")
|
|
48
|
+
price = data.get("price") or {}
|
|
49
|
+
self.currency = price.get("currency_code")
|
|
50
|
+
self.price = price.get("amount")
|
|
51
|
+
photo = data.get("photo") or {}
|
|
52
|
+
self.photo = photo.get("url")
|
|
53
|
+
self.url = data.get("url")
|
|
54
|
+
self.created_at_ts = self._get_created_at_ts(data)
|
|
55
|
+
high_res = photo.get("high_resolution") or {}
|
|
56
|
+
self.raw_timestamp = high_res.get("timestamp")
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _get_created_at_ts(data: dict) -> datetime:
|
|
60
|
+
"""
|
|
61
|
+
Parse creation timestamp from photo metadata.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
data : dict
|
|
66
|
+
Item data with photo details.
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
datetime
|
|
71
|
+
UTC creation datetime or current time if unavailable.
|
|
72
|
+
"""
|
|
73
|
+
photo = data.get("photo") or {}
|
|
74
|
+
high_res = photo.get("high_resolution") or {}
|
|
75
|
+
timestamp = high_res.get("timestamp", 0)
|
|
76
|
+
|
|
77
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
78
|
+
|
|
79
|
+
def __eq__(self, other):
|
|
80
|
+
"""Items are equal if IDs match."""
|
|
81
|
+
return self.id == other.id
|
|
82
|
+
|
|
83
|
+
def __hash__(self):
|
|
84
|
+
"""Hash based on unique ID."""
|
|
85
|
+
return hash(("id", self.id))
|
|
86
|
+
|
|
87
|
+
def is_new_item(self, minutes=1):
|
|
88
|
+
"""
|
|
89
|
+
Determine if the item is new within a time threshold.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
minutes : int
|
|
94
|
+
Time window in minutes to consider item as new.
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
bool
|
|
99
|
+
True if item created within `minutes` from now.
|
|
100
|
+
"""
|
|
101
|
+
delta = datetime.now(timezone.utc) - self.created_at_ts
|
|
102
|
+
return delta.total_seconds() < minutes * 60
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DetailedItem:
|
|
5
|
+
"""
|
|
6
|
+
Detailed representation of a Vinted item with extended information.
|
|
7
|
+
|
|
8
|
+
Attributes
|
|
9
|
+
----------
|
|
10
|
+
raw_data : dict
|
|
11
|
+
Original raw data dictionary from API.
|
|
12
|
+
id : int
|
|
13
|
+
Unique identifier of the item.
|
|
14
|
+
title : str
|
|
15
|
+
Item title.
|
|
16
|
+
description : str
|
|
17
|
+
Item description text.
|
|
18
|
+
brand_title : str
|
|
19
|
+
Brand name.
|
|
20
|
+
brand_slug : str
|
|
21
|
+
Brand slug (URL-friendly).
|
|
22
|
+
size_title : str
|
|
23
|
+
Size label extracted from item attributes.
|
|
24
|
+
currency : str
|
|
25
|
+
Currency code of the price.
|
|
26
|
+
price : float
|
|
27
|
+
Price amount.
|
|
28
|
+
total_item_price : float
|
|
29
|
+
Total price including fees or adjustments.
|
|
30
|
+
photo : str
|
|
31
|
+
URL of the first photo.
|
|
32
|
+
url : str
|
|
33
|
+
URL to the item on Vinted.
|
|
34
|
+
created_at_ts : datetime
|
|
35
|
+
Creation date/time of the item (UTC).
|
|
36
|
+
raw_timestamp : int
|
|
37
|
+
Raw timestamp from the high resolution photo metadata.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, data: dict):
|
|
41
|
+
"""
|
|
42
|
+
Initialize DetailedItem from raw data dictionary.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
data : dict
|
|
47
|
+
Raw response data from Vinted API.
|
|
48
|
+
"""
|
|
49
|
+
self.raw_data = data
|
|
50
|
+
self.id = data.get("id")
|
|
51
|
+
self.title = data.get("title")
|
|
52
|
+
self.description = data.get("description")
|
|
53
|
+
brand_dto = data.get("brand_dto") or {}
|
|
54
|
+
self.brand_title = brand_dto.get("title")
|
|
55
|
+
self.brand_slug = brand_dto.get("slug")
|
|
56
|
+
self.size_title = self._get_size_title(data)
|
|
57
|
+
price_data = data.get("price") or {}
|
|
58
|
+
self.currency = price_data.get("currency_code")
|
|
59
|
+
self.price = price_data.get("amount")
|
|
60
|
+
total_item_price_data = data.get("total_item_price") or {}
|
|
61
|
+
self.total_item_price = total_item_price_data.get("amount")
|
|
62
|
+
self.photo = self._get_first_photo_url(data)
|
|
63
|
+
self.url = data.get("url")
|
|
64
|
+
self.created_at_ts = self._get_created_at_ts(data)
|
|
65
|
+
photos = data.get("photos") or []
|
|
66
|
+
if photos and photos[0] and isinstance(photos[0], dict):
|
|
67
|
+
self.raw_timestamp = (photos[0].get("high_resolution") or {}).get("timestamp")
|
|
68
|
+
else:
|
|
69
|
+
self.raw_timestamp = None
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _get_size_title(data: dict) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Extracts the size title from plugins attributes.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
data : dict
|
|
79
|
+
Raw item data containing plugins info.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
str
|
|
84
|
+
Size label or empty string if not found.
|
|
85
|
+
"""
|
|
86
|
+
for plugin in data.get("plugins", []):
|
|
87
|
+
if plugin.get("name") == "attributes":
|
|
88
|
+
for attr in plugin.get("data", {}).get("attributes", []):
|
|
89
|
+
if attr.get("code") == "size":
|
|
90
|
+
val = attr.get("data", {}).get("value", "")
|
|
91
|
+
return str(val) if val is not None else ""
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _get_first_photo_url(data: dict) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Retrieves URL of the first photo of the item.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
data : dict
|
|
102
|
+
Raw item data.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
str
|
|
107
|
+
URL string or empty if missing.
|
|
108
|
+
"""
|
|
109
|
+
photos = data.get("photos", [])
|
|
110
|
+
return photos[0].get("url", "") if photos else ""
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _get_created_at_ts(data: dict) -> datetime:
|
|
114
|
+
"""
|
|
115
|
+
Parses the creation timestamp from photo metadata.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
data : dict
|
|
120
|
+
Item data containing photos info.
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
datetime
|
|
125
|
+
UTC datetime of creation or current time if missing.
|
|
126
|
+
"""
|
|
127
|
+
timestamp = data.get("photos", [{}])[0].get("high_resolution", {}).get("timestamp", 0)
|
|
128
|
+
return (
|
|
129
|
+
datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
130
|
+
if timestamp
|
|
131
|
+
else datetime.now(tz=timezone.utc)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def __eq__(self, other):
|
|
135
|
+
"""Compare equality by item ID."""
|
|
136
|
+
return self.id == other.id
|
|
137
|
+
|
|
138
|
+
def __hash__(self):
|
|
139
|
+
"""Hash by item ID."""
|
|
140
|
+
return hash(("id", self.id))
|
|
File without changes
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
from urllib.parse import parse_qsl, urlparse
|
|
5
|
+
|
|
6
|
+
from curl_cffi.requests.exceptions import HTTPError
|
|
7
|
+
|
|
8
|
+
from vinted_api_kit.client.vinted_http_client import VintedHttpClient
|
|
9
|
+
from vinted_api_kit.models import CatalogItem, DetailedItem
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ItemService:
|
|
15
|
+
"""
|
|
16
|
+
Provides item-related operations using the VintedHttpClient.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
VALID_ORDERS = {"newest_first", "relevance", "price_high_to_low", "price_low_to_high"}
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: VintedHttpClient):
|
|
22
|
+
"""
|
|
23
|
+
Initialize service with HTTP client.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
client (VintedHttpClient): HTTP client instance.
|
|
27
|
+
"""
|
|
28
|
+
self.client = client
|
|
29
|
+
|
|
30
|
+
async def search_items(
|
|
31
|
+
self,
|
|
32
|
+
url: str,
|
|
33
|
+
per_page: int = 20,
|
|
34
|
+
page: int = 1,
|
|
35
|
+
timestamp: Optional[int] = None,
|
|
36
|
+
raw_data: bool = False,
|
|
37
|
+
order: Optional[str] = None,
|
|
38
|
+
) -> Union[list[CatalogItem], list[dict], None]:
|
|
39
|
+
"""
|
|
40
|
+
Search items on Vinted.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
url (str): URL with search filters.
|
|
44
|
+
per_page (int): Items per page.
|
|
45
|
+
page (int): Page number.
|
|
46
|
+
timestamp (Optional[int]): Unix timestamp override.
|
|
47
|
+
raw_data (bool): Return raw JSON data if True.
|
|
48
|
+
order (str): Sorting order.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of CatalogItem or raw data list.
|
|
52
|
+
"""
|
|
53
|
+
logger.debug(
|
|
54
|
+
"Searching items with url=%s, per_page=%d, page=%d, timestamp=%s, raw_data=%s, order=%s",
|
|
55
|
+
url,
|
|
56
|
+
per_page,
|
|
57
|
+
page,
|
|
58
|
+
timestamp,
|
|
59
|
+
raw_data,
|
|
60
|
+
order,
|
|
61
|
+
)
|
|
62
|
+
if order and order not in self.VALID_ORDERS:
|
|
63
|
+
logger.error(
|
|
64
|
+
"Invalid order parameter '%s'. Valid options: %s",
|
|
65
|
+
order,
|
|
66
|
+
", ".join(self.VALID_ORDERS),
|
|
67
|
+
)
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Invalid order '{order}'. Valid options are: {', '.join(self.VALID_ORDERS)}"
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
self.client.configure_from_url(url)
|
|
73
|
+
api_url = f"{self.client.base_url}/api/v2/catalog/items"
|
|
74
|
+
params = self._parse_url(url, per_page=per_page, page=page)
|
|
75
|
+
params["time"] = timestamp or int(time.time())
|
|
76
|
+
if order:
|
|
77
|
+
params["order"] = order
|
|
78
|
+
logger.debug("Calling client.request with url=%s and params=%s", api_url, params)
|
|
79
|
+
response = await self.client.request(api_url, params=params)
|
|
80
|
+
data = response.json()
|
|
81
|
+
items = data.get("items", [])
|
|
82
|
+
logger.debug("Received %d items from API", len(items) if items else 0)
|
|
83
|
+
|
|
84
|
+
if raw_data:
|
|
85
|
+
from typing import Any, cast
|
|
86
|
+
|
|
87
|
+
return cast(list[dict[str, Any]], items)
|
|
88
|
+
return [CatalogItem(item) for item in items] if items else []
|
|
89
|
+
except Exception:
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
async def item_details(self, url: str, raw_data: bool = False) -> Union[DetailedItem, dict]:
|
|
93
|
+
"""
|
|
94
|
+
Get detailed info of an item by URL.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
url (str): Item URL.
|
|
98
|
+
raw_data (bool): Return raw JSON if True.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
DetailedItem instance or raw dict.
|
|
102
|
+
"""
|
|
103
|
+
logger.debug("Fetching item details for url=%s, raw_data=%s", url, raw_data)
|
|
104
|
+
try:
|
|
105
|
+
self.client.configure_from_url(url)
|
|
106
|
+
product_id = urlparse(url).path.split("/")[2].split("-")[0]
|
|
107
|
+
api_url = f"{self.client.base_url}/api/v2/items/{product_id}/details"
|
|
108
|
+
logger.debug("Requesting item details from %s", api_url)
|
|
109
|
+
|
|
110
|
+
response = await self.client.request(api_url)
|
|
111
|
+
response.raise_for_status()
|
|
112
|
+
data = response.json()
|
|
113
|
+
product_data = data.get("item", [])
|
|
114
|
+
logger.debug("Item details fetched successfully")
|
|
115
|
+
|
|
116
|
+
return DetailedItem(product_data) if not raw_data else product_data
|
|
117
|
+
except HTTPError as err:
|
|
118
|
+
raise err
|
|
119
|
+
except Exception:
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
def _parse_url(self, url: str, per_page: int = 20, page: int = 1) -> dict:
|
|
123
|
+
"""
|
|
124
|
+
Parse and build API query parameters from URL.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
url (str): URL to parse.
|
|
128
|
+
per_page (int): Items per page.
|
|
129
|
+
page (int): Page number.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Dict with query parameters.
|
|
133
|
+
"""
|
|
134
|
+
parsed_url = urlparse(url)
|
|
135
|
+
query_params = parse_qsl(parsed_url.query)
|
|
136
|
+
|
|
137
|
+
catalog_id = self._extract_catalog_id(parsed_url.path)
|
|
138
|
+
catalog_ids_from_query = self._join_query_values(query_params, "catalog[]")
|
|
139
|
+
|
|
140
|
+
params = {
|
|
141
|
+
"search_text": "+".join(self._extract_query_values(query_params, "search_text")),
|
|
142
|
+
"catalog_ids": str(catalog_id) if catalog_id is not None else catalog_ids_from_query,
|
|
143
|
+
"color_ids": self._join_query_values(query_params, "color_ids[]"),
|
|
144
|
+
"brand_ids": self._join_query_values(query_params, "brand_ids[]"),
|
|
145
|
+
"size_ids": self._join_query_values(query_params, "size_ids[]"),
|
|
146
|
+
"material_ids": self._join_query_values(query_params, "material_ids[]"),
|
|
147
|
+
"status_ids": self._join_query_values(query_params, "status[]"),
|
|
148
|
+
"country_ids": self._join_query_values(query_params, "country_ids[]"),
|
|
149
|
+
"city_ids": self._join_query_values(query_params, "city_ids[]"),
|
|
150
|
+
"is_for_swap": ",".join(
|
|
151
|
+
"1" for _ in self._extract_query_values(query_params, "disposal[]")
|
|
152
|
+
),
|
|
153
|
+
"currency": self._join_query_values(query_params, "currency"),
|
|
154
|
+
"price_to": self._join_query_values(query_params, "price_to"),
|
|
155
|
+
"price_from": self._join_query_values(query_params, "price_from"),
|
|
156
|
+
"page": page,
|
|
157
|
+
"per_page": per_page,
|
|
158
|
+
"order": self._join_query_values(query_params, "order"),
|
|
159
|
+
"time": int(time.time()),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
params_cleaned = {k: v for k, v in params.items() if v}
|
|
163
|
+
return params_cleaned
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _extract_catalog_id(path: str) -> Optional[int]:
|
|
167
|
+
"""
|
|
168
|
+
Extract catalog ID from URL path.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
path (str): URL path string.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Catalog ID as int or None if missing.
|
|
175
|
+
"""
|
|
176
|
+
path_parts = path.split("/")
|
|
177
|
+
if len(path_parts) > 2 and path_parts[1] == "catalog":
|
|
178
|
+
catalog_part = path_parts[2]
|
|
179
|
+
catalog_id_str = catalog_part.split("-")[0] if "-" in catalog_part else catalog_part
|
|
180
|
+
try:
|
|
181
|
+
return int(catalog_id_str)
|
|
182
|
+
except ValueError:
|
|
183
|
+
logger.debug("Failed to convert catalog id to int from path: %s", path)
|
|
184
|
+
return None
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _extract_product_id_from_url(url: str) -> str:
|
|
189
|
+
"""
|
|
190
|
+
Extract product ID from item URL.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
url (str): Item URL string.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Product ID string.
|
|
197
|
+
"""
|
|
198
|
+
path = urlparse(url).path
|
|
199
|
+
return path.split("/")[2].split("-")[0]
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _extract_query_values(query_params: list[tuple[str, str]], key: str) -> list[str]:
|
|
203
|
+
"""
|
|
204
|
+
Get all values for a query key.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
query_params (list of tuple): Parsed query pairs.
|
|
208
|
+
key (str): Key to find.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
List of values as strings.
|
|
212
|
+
"""
|
|
213
|
+
return [v for k, v in query_params if k == key]
|
|
214
|
+
|
|
215
|
+
def _join_query_values(
|
|
216
|
+
self, query_params: list[tuple[str, str]], key: str, sep: str = ","
|
|
217
|
+
) -> str:
|
|
218
|
+
"""
|
|
219
|
+
Join multiple query values for a key into a string.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
query_params (list of tuple): Parsed queries.
|
|
223
|
+
key (str): Key to find.
|
|
224
|
+
sep (str): Separator to join strings.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Joined string or empty string if none.
|
|
228
|
+
"""
|
|
229
|
+
values = self._extract_query_values(query_params, key)
|
|
230
|
+
return sep.join(values)
|
vinted_api_kit/utils.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_proxy_for_log(proxy: Optional[dict[str, str]]) -> str:
|
|
5
|
+
"""
|
|
6
|
+
Format proxy address for logging.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
proxy: Optional dict with keys like 'http' mapping to proxy URL.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
A string to show safe proxy information in logs.
|
|
13
|
+
"""
|
|
14
|
+
if not proxy:
|
|
15
|
+
return "local IP (no proxy configured)"
|
|
16
|
+
proxy_value = proxy.get("http", "")
|
|
17
|
+
if "@" in proxy_value:
|
|
18
|
+
return proxy_value.split("@")[-1]
|
|
19
|
+
elif proxy_value:
|
|
20
|
+
return proxy_value
|
|
21
|
+
else:
|
|
22
|
+
return "unknown (invalid proxy format)"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional, Union
|
|
3
|
+
|
|
4
|
+
from vinted_api_kit.client.vinted_http_client import VintedHttpClient
|
|
5
|
+
from vinted_api_kit.models import CatalogItem, DetailedItem
|
|
6
|
+
from vinted_api_kit.services.item_service import ItemService
|
|
7
|
+
from vinted_api_kit.utils import format_proxy_for_log
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VintedApi:
|
|
13
|
+
"""
|
|
14
|
+
Facade class providing async context management for Vinted API client and services.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self, locale=None, proxies=None, client_ip=None, cookies_dir=None, persist_cookies=False
|
|
19
|
+
):
|
|
20
|
+
logger.info(
|
|
21
|
+
"Initializing VintedApi client with locale=%s, proxies=%s",
|
|
22
|
+
locale,
|
|
23
|
+
format_proxy_for_log(proxies),
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
Initialize VintedApi with client configuration.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
locale (str, optional): Locale for API requests.
|
|
30
|
+
proxies (dict, optional): Proxy configuration.
|
|
31
|
+
client_ip (str, optional): Client IP for headers.
|
|
32
|
+
cookies_dir (str, optional): Directory for storing cookies.
|
|
33
|
+
persist_cookies (bool, optional): Whether to save cookies to disk.
|
|
34
|
+
"""
|
|
35
|
+
self._client = VintedHttpClient(
|
|
36
|
+
locale=locale,
|
|
37
|
+
proxies=proxies,
|
|
38
|
+
client_ip=client_ip,
|
|
39
|
+
cookies_dir=cookies_dir,
|
|
40
|
+
persist_cookies=persist_cookies,
|
|
41
|
+
)
|
|
42
|
+
self._items_service = ItemService(self._client)
|
|
43
|
+
|
|
44
|
+
async def __aenter__(self):
|
|
45
|
+
"""
|
|
46
|
+
Enter async context, return self.
|
|
47
|
+
"""
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
async def __aexit__(self, exc_type, exc_value, traceback):
|
|
51
|
+
"""
|
|
52
|
+
Exit async context, close the client session.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
exc_type: Exception type if any.
|
|
56
|
+
exc_value: Exception value if any.
|
|
57
|
+
traceback: Traceback if any.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
False (do not suppress exceptions)
|
|
61
|
+
"""
|
|
62
|
+
if exc_type:
|
|
63
|
+
logger.error(
|
|
64
|
+
"Exception %s occurred: %s",
|
|
65
|
+
exc_type,
|
|
66
|
+
exc_value,
|
|
67
|
+
exc_info=(exc_type, exc_value, traceback),
|
|
68
|
+
)
|
|
69
|
+
await self._client.close()
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
async def search_items(
|
|
73
|
+
self,
|
|
74
|
+
url: str,
|
|
75
|
+
per_page: int = 20,
|
|
76
|
+
page: int = 1,
|
|
77
|
+
timestamp: Optional[int] = None,
|
|
78
|
+
raw_data: bool = False,
|
|
79
|
+
order: Optional[str] = None,
|
|
80
|
+
) -> Union[list[CatalogItem], list[dict], None]:
|
|
81
|
+
"""
|
|
82
|
+
Search items on Vinted.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
url (str): URL with search filters.
|
|
86
|
+
per_page (int): Items per page.
|
|
87
|
+
page (int): Page number.
|
|
88
|
+
timestamp (Optional[int]): Unix timestamp override.
|
|
89
|
+
raw_data (bool): Return raw JSON data if True.
|
|
90
|
+
order (str): Sorting order.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List of CatalogItem or raw data list.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
result = await self._items_service.search_items(
|
|
97
|
+
url, per_page, page, timestamp, raw_data, order
|
|
98
|
+
)
|
|
99
|
+
return result
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error("search_items failed: %s", e)
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
async def item_details(self, url: str, raw_data: bool = False) -> Union[DetailedItem, dict]:
|
|
105
|
+
"""
|
|
106
|
+
Get detailed information of an item by URL.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
url (str): Item URL.
|
|
110
|
+
raw_data (bool): Return raw JSON if True.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
DetailedItem or raw dict.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
result = await self._items_service.item_details(url, raw_data)
|
|
117
|
+
return result
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error("item_details failed: %s", e)
|
|
120
|
+
raise
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vinted-api-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight asynchronous Python client library for accessing Vinted API and scraping item data.
|
|
5
|
+
Project-URL: Homepage, https://github.com/vlymar-dev/vinted-api-kit
|
|
6
|
+
Project-URL: Documentation, https://github.com/vlymar-dev/vinted-api-kit
|
|
7
|
+
Project-URL: Repository, https://github.com/vlymar-dev/vinted-api-kit
|
|
8
|
+
Author-email: Lymar Volodymyr <volodymyr.lymar1@gmail.com>
|
|
9
|
+
Maintainer-email: Lymar Volodymyr <volodymyr.lymar1@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,async,ecommerce,python,vinted,vinted-api,web-scraping
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Information Technology
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Communications
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Topic :: Utilities
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Requires-Dist: curl-cffi<0.13.0,>=0.12.0
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
<div align="center">
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
***Lightweight asynchronous Python client library for accessing Vinted API and scraping item data.***
|
|
38
|
+
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
## ✨ Features
|
|
45
|
+
|
|
46
|
+
- 🚀 **Asynchronous** - Built with asyncio for high performance
|
|
47
|
+
- 🌍 **Multi-locale** - Supports multiple Vinted domains (FR, DE, US, etc.)
|
|
48
|
+
- 🔍 **Item Search** - Search catalog with filters and pagination
|
|
49
|
+
- 📦 **Item Details** - Get complete item information
|
|
50
|
+
- 🍪 **Cookie Persistence** - Automatic session management
|
|
51
|
+
- 🔐 **Proxy Support** - Built-in proxy configuration
|
|
52
|
+
- 📊 **Type Hints** - Full typing support for better IDE experience
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
## 📚 Table of Contents
|
|
56
|
+
|
|
57
|
+
- [Installation](#installation)
|
|
58
|
+
- [Quick Start](#quick-start)
|
|
59
|
+
- [Configuration](#configuration)
|
|
60
|
+
- [Development](#development)
|
|
61
|
+
- [Changelog](#changelog)
|
|
62
|
+
- [License](#license)
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
Install via pip:
|
|
68
|
+
```bash
|
|
69
|
+
pip install vinted-api-kit
|
|
70
|
+
```
|
|
71
|
+
Or using poetry:
|
|
72
|
+
```bash
|
|
73
|
+
poetry add vinted-api-kit
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
## Quick Start
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import asyncio
|
|
81
|
+
from vinted_api_kit import VintedApi, CatalogItem, DetailedItem
|
|
82
|
+
|
|
83
|
+
async def main():
|
|
84
|
+
async with VintedApi(locale="fr") as vinted:
|
|
85
|
+
# Get detailed item information
|
|
86
|
+
item_detail: DetailedItem = await vinted.item_details(
|
|
87
|
+
url="https://www.vinted.fr/items/922704975-adidas-x-15"
|
|
88
|
+
)
|
|
89
|
+
print(f"📦 {item_detail.title}")
|
|
90
|
+
print(f"💰 {item_detail.price}\n")
|
|
91
|
+
|
|
92
|
+
# Search for items
|
|
93
|
+
items: list[CatalogItem] = await vinted.search_items(
|
|
94
|
+
url="https://www.vinted.fr/catalog?search_text=adidas",
|
|
95
|
+
per_page=5
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
print("🔍 Search results:")
|
|
99
|
+
for item in items:
|
|
100
|
+
print(f" • {item.title} - {item.price} {item.currency}")
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
asyncio.run(main())
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
### Basic usage
|
|
110
|
+
```python
|
|
111
|
+
from vinted_api_kit import VintedApi
|
|
112
|
+
|
|
113
|
+
async with VintedApi(locale="fr") as vinted:
|
|
114
|
+
pass
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Advanced configuration
|
|
118
|
+
```python
|
|
119
|
+
from vinted_api_kit import VintedApi
|
|
120
|
+
|
|
121
|
+
async with VintedApi(
|
|
122
|
+
locale="de",
|
|
123
|
+
proxies={"http": "http://proxy:8080"},
|
|
124
|
+
client_ip="192.168.1.1",
|
|
125
|
+
cookies_dir="./cookies",
|
|
126
|
+
persist_cookies=True
|
|
127
|
+
) as vinted:
|
|
128
|
+
pass
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Parameters:**
|
|
132
|
+
- `locale` - Vinted domain locale (`'fr'`, `'de'`, `'us'`, etc.)
|
|
133
|
+
- `proxies` - Proxy configuration (requests format)
|
|
134
|
+
- `client_ip` - Override client IP header
|
|
135
|
+
- `cookies_dir` - Directory for cookie storage
|
|
136
|
+
- `persist_cookies` - Enable/disable cookie persistence
|
|
137
|
+
|
|
138
|
+
These can be set when creating an instance of the `VintedApi` class.
|
|
139
|
+
|
|
140
|
+
No additional environment variables are required by default.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
## 🛠️ Development
|
|
144
|
+
|
|
145
|
+
### Setup
|
|
146
|
+
```shell
|
|
147
|
+
git clone https://github.com/vlymar1/vinted-api-kit.git
|
|
148
|
+
cd vinted-api-kit
|
|
149
|
+
```
|
|
150
|
+
*Install dependencies (you'll need to set up your dev environment)*
|
|
151
|
+
### Testing
|
|
152
|
+
|
|
153
|
+
```shell
|
|
154
|
+
make test-coverage # run tests with coverage
|
|
155
|
+
make test-coverage-view # view coverage report in browser
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Code Quality
|
|
159
|
+
|
|
160
|
+
```shell
|
|
161
|
+
make lint-check # check code with ruff and mypy
|
|
162
|
+
make lint-reformat # format and fix code with ruff
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Cleanup
|
|
166
|
+
|
|
167
|
+
```shell
|
|
168
|
+
make clean # remove cache files and build artifacts
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Development Guidelines:**
|
|
172
|
+
- Follow PEP8 style guidelines
|
|
173
|
+
- Configure ruff in `pyproject.toml` for your preferred rules
|
|
174
|
+
- Set up pre-commit hooks for automatic linting
|
|
175
|
+
- Contributions welcome! Please open issues or pull requests
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
## Changelog
|
|
179
|
+
|
|
180
|
+
See [`CHANGELOG.md`](CHANGELOG.md) for the list of notable changes per version.
|
|
181
|
+
|
|
182
|
+
### How to create and maintain changelog?
|
|
183
|
+
|
|
184
|
+
- Start a `CHANGELOG.md` file at the root of your repo.
|
|
185
|
+
- Follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format for consistent structure.
|
|
186
|
+
- For each release version, record:
|
|
187
|
+
- Added — new features
|
|
188
|
+
- Changed — updates/improvements
|
|
189
|
+
- Fixed — bug fixes
|
|
190
|
+
- Removed — deprecated or removed features
|
|
191
|
+
- Update changelog **before** tagging a new release (e.g., `v1.0.0`).
|
|
192
|
+
- Automate changelog generation optionally by tools such as:
|
|
193
|
+
- [github-changelog-generator](https://github.com/github-changelog-generator/github-changelog-generator)
|
|
194
|
+
- [auto-changelog](https://github.com/CookPete/auto-changelog)
|
|
195
|
+
- Conventional commits combined with [semantic-release](https://semantic-release.gitbook.io/semantic-release/)
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
## Maintainers / Contacts
|
|
204
|
+
|
|
205
|
+
- GitHub: [https://github.com/vlymar1](https://github.com/vlymar1)
|
|
206
|
+
|
|
207
|
+
Feel free to open issues or contact for support and collaborations.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
vinted_api_kit/__init__.py,sha256=oIDo9ySeku2rY-gvm9AMWeaoW3Bf_fH0EDLI693F_-g,159
|
|
2
|
+
vinted_api_kit/utils.py,sha256=00E97ACew8ZixeQFpFVOWEKWPTr5aRP3ciCGol_zXXE,587
|
|
3
|
+
vinted_api_kit/vinted_api.py,sha256=6sXnMiSVpnFw40GLJ-zYBiwMYB_J6b6rBfuGY-CbC0s,3738
|
|
4
|
+
vinted_api_kit/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
vinted_api_kit/client/user_agents.py,sha256=9JWrxaGwVcT57fFQpJJeh5a6oc6wj9D1UcDvi-uQnNs,1121
|
|
6
|
+
vinted_api_kit/client/vinted_http_client.py,sha256=UvtH0z2dpJRRpf5x2VH-SmDoC_tCHLrMpfapaV_gFCw,12686
|
|
7
|
+
vinted_api_kit/models/__init__.py,sha256=2KsOrQSjWGT1yS4-MIu4ueYh5wrsMvlq886iWq2BhYs,121
|
|
8
|
+
vinted_api_kit/models/catalog_item.py,sha256=Vu7ruhNi3ZAyDPnqI9NhluYorPGyAdPaTNHShN6t_88,2755
|
|
9
|
+
vinted_api_kit/models/detailed_item.py,sha256=qFPPKb1FxzzSH60MV94RZiF6Z8nxdbDBVBLuSDp3PJE,4227
|
|
10
|
+
vinted_api_kit/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
vinted_api_kit/services/item_service.py,sha256=TCLKBppR9NQYL4Ea86Vjbsvs4ghVcJiP6NiZsGh8ySE,8073
|
|
12
|
+
vinted_api_kit-0.1.0.dist-info/METADATA,sha256=vyiZc4RWwhSXEBEQ2S7NDn_jnwKHQSx8Inw54pI_Ahs,6089
|
|
13
|
+
vinted_api_kit-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
14
|
+
vinted_api_kit-0.1.0.dist-info/licenses/LICENSE,sha256=5EYzaUx_5tQATzjSjGeBI5mdu2tCD3ZHzm-PDzBlNm8,1072
|
|
15
|
+
vinted_api_kit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Volodymyr Lymar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|