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.
@@ -0,0 +1,6 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .models import CatalogItem, DetailedItem
4
+ from .vinted_api import VintedApi
5
+
6
+ __all__ = ["VintedApi", "CatalogItem", "DetailedItem"]
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,4 @@
1
+ from .catalog_item import CatalogItem
2
+ from .detailed_item import DetailedItem
3
+
4
+ __all__ = ["CatalogItem", "DetailedItem"]
@@ -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)
@@ -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
+ ![Vinted Api Kit](./assets/logo.png)
36
+
37
+ ***Lightweight asynchronous Python client library for accessing Vinted API and scraping item data.***
38
+
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.