vntd 1.0.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.
vntd/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ __version__ = "1.0.0"
2
+
3
+ from .client import Client
4
+ from .model import Proxy, Search, Ad, User, Sort, SellerType
5
+
6
+ __all__ = [
7
+ "Client",
8
+ "Proxy",
9
+ "Search",
10
+ "Ad",
11
+ "User",
12
+ "Sort",
13
+ "SellerType",
14
+ ]
vntd/client.py ADDED
@@ -0,0 +1,124 @@
1
+ from curl_cffi import BrowserTypeLiteral
2
+ import curl_cffi
3
+
4
+ from .mixin import SessionMixin, SearchMixin, UserMixin, AdMixin
5
+ from .model import Proxy
6
+ from .exceptions import AccessDeniedError, RequestError, NotFoundError
7
+
8
+
9
+ class Client(SessionMixin, SearchMixin, UserMixin, AdMixin):
10
+ def __init__(
11
+ self,
12
+ base_url: str = "https://www.vinted.fr",
13
+ proxy: Proxy | None = None,
14
+ impersonate: BrowserTypeLiteral = None,
15
+ user_agent: str | None = None,
16
+ user_agents: list[str] | None = None,
17
+ request_verify: bool = True,
18
+ timeout: float = 30.0,
19
+ max_retries: int = 3,
20
+ ):
21
+ """
22
+ Initializes a Vinted Client instance with optional proxy, browser impersonation,
23
+ and SSL verification settings.
24
+
25
+ Args:
26
+ base_url (str, optional): Base Vinted domain to target (e.g., "https://www.vinted.fr"). Defaults to "https://www.vinted.fr".
27
+ proxy (Proxy | None, optional): Proxy configuration to use for the client. If provided, it will be applied to all requests. Defaults to None.
28
+ impersonate (BrowserTypeLiteral, optional): Browser type to impersonate for requests (e.g., "firefox", "chrome", "edge", "safari"). If None, a random browser type will be chosen.
29
+ user_agent (str | None, optional): Explicit User-Agent string to use for requests.
30
+ user_agents (list[str] | None, optional): List of User-Agent strings to rotate from.
31
+ request_verify (bool, optional): Whether to verify SSL certificates when sending requests. Defaults to True.
32
+ timeout (float, optional): Maximum time in seconds to wait for a request before timing out. Defaults to 30.
33
+ max_retries (int, optional): Maximum number of times to retry a request in case of anti-bot failures. Defaults to 3.
34
+ """
35
+ self.base_url = base_url.rstrip("/")
36
+
37
+ super().__init__(
38
+ base_url=self.base_url,
39
+ proxy=proxy,
40
+ impersonate=impersonate,
41
+ user_agent=user_agent,
42
+ user_agents=user_agents,
43
+ request_verify=request_verify,
44
+ )
45
+
46
+ self.request_verify = request_verify
47
+ self.timeout = timeout
48
+ self.max_retries = max_retries
49
+
50
+ def _fetch(
51
+ self,
52
+ method: str,
53
+ url: str,
54
+ payload: dict | None = None,
55
+ params: dict | None = None,
56
+ max_retries: int = -1,
57
+ expect_json: bool = True,
58
+ ):
59
+ """
60
+ Internal method to send an HTTP request using the configured session.
61
+
62
+ Args:
63
+ method (str): HTTP method to use (e.g., "GET", "POST").
64
+ url (str): Full URL of the API endpoint.
65
+ payload (dict | None, optional): JSON payload to send with the request. Used for POST/PUT methods. Defaults to None.
66
+ params (dict | None, optional): Query string parameters. Defaults to None.
67
+ max_retries (int, optional): Number of times to retry the request in case of failure. Defaults to 3.
68
+ expect_json (bool, optional): Whether to parse the response as JSON. Defaults to True.
69
+
70
+ Raises:
71
+ AccessDeniedError: Raised when the request is blocked by anti-bot protection (HTTP 403/429).
72
+ RequestError: Raised for any other non-successful HTTP response.
73
+
74
+ Returns:
75
+ dict | str: Parsed JSON response from the server, or raw text if expect_json is False.
76
+ """
77
+ if max_retries == -1:
78
+ max_retries = self.max_retries
79
+
80
+ response: curl_cffi.Response = self.session.request(
81
+ method=method,
82
+ url=url,
83
+ params=params,
84
+ json=payload,
85
+ verify=self.request_verify,
86
+ timeout=self.timeout,
87
+ )
88
+ if response.ok:
89
+ return response.json() if expect_json else response.text
90
+ elif response.status_code in (403, 429):
91
+ if max_retries > 0:
92
+ self.session = self._init_session(
93
+ base_url=self.base_url,
94
+ proxy=self._proxy,
95
+ impersonate=self._impersonate,
96
+ user_agent=self._user_agent,
97
+ user_agents=self._user_agents,
98
+ request_verify=self.request_verify,
99
+ ) # Re-init session
100
+ return self._fetch(
101
+ method=method,
102
+ url=url,
103
+ payload=payload,
104
+ params=params,
105
+ max_retries=max_retries - 1,
106
+ expect_json=expect_json,
107
+ )
108
+ raise AccessDeniedError(
109
+ "Access blocked by anti-bot protection. Try reducing request frequency or changing proxy."
110
+ )
111
+ elif response.status_code in (404, 410):
112
+ raise NotFoundError("Unable to find the requested resource.")
113
+ else:
114
+ raise RequestError(
115
+ f"Request failed with status code {response.status_code}."
116
+ )
117
+
118
+ def _fetch_text(self, url: str, max_retries: int = -1) -> str:
119
+ return self._fetch(
120
+ method="GET",
121
+ url=url,
122
+ max_retries=max_retries,
123
+ expect_json=False,
124
+ )
vntd/exceptions.py ADDED
@@ -0,0 +1,18 @@
1
+ class VintedError(Exception):
2
+ """Base exception for all errors raised by the Vinted client."""
3
+
4
+
5
+ class InvalidValue(VintedError):
6
+ """Raised when a provided value is invalid or improperly formatted."""
7
+
8
+
9
+ class RequestError(VintedError):
10
+ """Raised when an HTTP request fails with a non-success status code."""
11
+
12
+
13
+ class AccessDeniedError(RequestError):
14
+ """Raised when access is blocked by anti-bot protection."""
15
+
16
+
17
+ class NotFoundError(VintedError):
18
+ """Raised when a user or item is not found."""
vntd/mixin/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .session import SessionMixin
2
+ from .search import SearchMixin
3
+ from .user import UserMixin
4
+ from .ad import AdMixin
5
+
6
+ __all__ = ["SessionMixin", "SearchMixin", "UserMixin", "AdMixin"]
vntd/mixin/ad.py ADDED
@@ -0,0 +1,54 @@
1
+ from html import unescape
2
+ import json
3
+ import re
4
+
5
+ from ..model import Ad
6
+ from ..exceptions import InvalidValue, NotFoundError
7
+
8
+
9
+ class AdMixin:
10
+ def get_ad(self, ad_id: str | int) -> Ad:
11
+ """
12
+ Retrieve detailed information about a Vinted item using its ID or URL.
13
+ """
14
+ item_id = self._extract_item_id(ad_id)
15
+ url = f"{self.base_url}/items/{item_id}"
16
+
17
+ html = self._fetch_text(url)
18
+ ld_data = self._extract_json_ld(html)
19
+ if not ld_data:
20
+ raise NotFoundError("Unable to find item details.")
21
+
22
+ seller_id = self._extract_seller_id(html)
23
+ return Ad._build_from_item_page(
24
+ raw=ld_data, item_id=item_id, url=url, seller_id=seller_id, client=self
25
+ )
26
+
27
+ def _extract_item_id(self, ad_id: str | int) -> int:
28
+ if isinstance(ad_id, int):
29
+ return ad_id
30
+
31
+ if isinstance(ad_id, str):
32
+ match = re.search(r"/items/(\d+)", ad_id)
33
+ if match:
34
+ return int(match.group(1))
35
+ match = re.search(r"^(\d+)", ad_id)
36
+ if match:
37
+ return int(match.group(1))
38
+
39
+ raise InvalidValue("ad_id must be a Vinted item ID or item URL.")
40
+
41
+ def _extract_json_ld(self, html: str) -> dict | None:
42
+ match = re.search(
43
+ r'<script type="application/ld\+json">(.*?)</script>',
44
+ html,
45
+ re.DOTALL,
46
+ )
47
+ if not match:
48
+ return None
49
+ raw_json = unescape(match.group(1)).strip()
50
+ return json.loads(raw_json)
51
+
52
+ def _extract_seller_id(self, html: str) -> int | None:
53
+ match = re.search(r'\\"seller_id\\":(\d+)', html)
54
+ return int(match.group(1)) if match else None
vntd/mixin/search.py ADDED
@@ -0,0 +1,44 @@
1
+ from ..model import Sort, SellerType, Search
2
+ from ..utils import build_search_params_with_args, build_search_params_with_url
3
+
4
+
5
+ class SearchMixin:
6
+ def search(
7
+ self,
8
+ url: str | None = None,
9
+ text: str | None = None,
10
+ sort: Sort = Sort.RELEVANCE,
11
+ page: int = 1,
12
+ limit: int = 24,
13
+ seller_type: SellerType = SellerType.ALL,
14
+ user_id: int | None = None,
15
+ price: tuple[int | None, int | None] | list[int | None] | None = None,
16
+ **filters,
17
+ ) -> Search:
18
+ """
19
+ Perform an item search on Vinted with the specified criteria.
20
+
21
+ You can either:
22
+ - Provide a full `url` from a Vinted search to replicate the search directly.
23
+ - Or use the individual parameters (`text`, `sort`, `price`, etc.) to construct a custom search.
24
+ """
25
+ if url:
26
+ params = build_search_params_with_url(url=url, limit=limit, page=page)
27
+ else:
28
+ params = build_search_params_with_args(
29
+ text=text,
30
+ sort=sort,
31
+ page=page,
32
+ limit=limit,
33
+ seller_type=seller_type,
34
+ user_id=user_id,
35
+ price=price,
36
+ **filters,
37
+ )
38
+
39
+ body = self._fetch(
40
+ method="GET",
41
+ url=f"{self.base_url}/api/v2/catalog/items",
42
+ params=params,
43
+ )
44
+ return Search._build(raw=body, client=self)
vntd/mixin/session.py ADDED
@@ -0,0 +1,124 @@
1
+ from curl_cffi import requests, BrowserTypeLiteral
2
+ import random
3
+
4
+ from fake_useragent import UserAgent
5
+
6
+ from ..model import Proxy
7
+ from ..exceptions import InvalidValue
8
+
9
+
10
+ DEFAULT_USER_AGENTS = [
11
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
12
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
13
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
14
+ ]
15
+
16
+ _UA = UserAgent(platforms=["desktop"])
17
+
18
+ _BROWSER_MAP: dict[str | None, list[str]] = {
19
+ "chrome": ["Chrome"],
20
+ "firefox": ["Firefox"],
21
+ "safari": ["Safari"],
22
+ "edge": ["Edge"],
23
+ }
24
+
25
+
26
+ class SessionMixin:
27
+ def __init__(
28
+ self,
29
+ base_url: str,
30
+ proxy: Proxy | None = None,
31
+ impersonate: BrowserTypeLiteral = None,
32
+ user_agent: str | None = None,
33
+ user_agents: list[str] | None = None,
34
+ request_verify: bool = True,
35
+ **kwargs,
36
+ ):
37
+ self.base_url = base_url.rstrip("/")
38
+ self._user_agent = user_agent
39
+ self._user_agents = user_agents
40
+ self.session = self._init_session(
41
+ base_url=self.base_url,
42
+ proxy=proxy,
43
+ impersonate=impersonate,
44
+ user_agent=user_agent,
45
+ user_agents=user_agents,
46
+ request_verify=request_verify,
47
+ )
48
+ self._proxy = proxy
49
+ self._impersonate = impersonate
50
+ super().__init__(**kwargs)
51
+
52
+ def _select_user_agent(
53
+ self,
54
+ user_agent: str | None,
55
+ user_agents: list[str] | None,
56
+ impersonate: BrowserTypeLiteral = None,
57
+ ) -> str:
58
+ if user_agent and user_agents:
59
+ raise InvalidValue("Provide either user_agent or user_agents, not both.")
60
+ if user_agent:
61
+ return user_agent
62
+ if user_agents:
63
+ if not all(isinstance(value, str) for value in user_agents):
64
+ raise InvalidValue("user_agents must be a list of strings.")
65
+ return random.choice(user_agents)
66
+
67
+ # Try fake-useragent with browser-coherent selection
68
+ try:
69
+ browsers = _BROWSER_MAP.get(impersonate, list(_BROWSER_MAP.values())[0])
70
+ browser = random.choice(browsers)
71
+ return getattr(_UA, browser.lower(), _UA.random)
72
+ except Exception:
73
+ pass
74
+
75
+ return random.choice(DEFAULT_USER_AGENTS)
76
+
77
+ def _init_session(
78
+ self,
79
+ base_url: str,
80
+ proxy: Proxy | None = None,
81
+ impersonate: BrowserTypeLiteral = None,
82
+ user_agent: str | None = None,
83
+ user_agents: list[str] | None = None,
84
+ request_verify: bool = True,
85
+ ) -> requests.Session:
86
+ """
87
+ Initializes an HTTP session with optional proxy configuration and browser impersonation.
88
+ """
89
+ if impersonate is None: # Pick a random browser client
90
+ impersonate = random.choice(["safari", "chrome", "firefox"])
91
+
92
+ session = requests.Session(impersonate=impersonate)
93
+
94
+ session.headers.update(
95
+ {
96
+ "User-Agent": self._select_user_agent(
97
+ user_agent, user_agents, impersonate
98
+ ),
99
+ "Accept": "application/json, text/plain, */*",
100
+ "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
101
+ "Origin": base_url,
102
+ "Referer": f"{base_url}/",
103
+ }
104
+ )
105
+ if proxy:
106
+ session.proxies = {"http": proxy.url, "https": proxy.url}
107
+
108
+ session.get(f"{base_url}/", verify=request_verify) # Init cookies
109
+ return session
110
+
111
+ @property
112
+ def proxy(self) -> Proxy:
113
+ return self._proxy
114
+
115
+ @proxy.setter
116
+ def proxy(self, value: Proxy):
117
+ if value:
118
+ if isinstance(value, Proxy):
119
+ self.session.proxies = {"http": value.url, "https": value.url}
120
+ else:
121
+ raise TypeError("Proxy must be an instance of the vntd.Proxy")
122
+ else:
123
+ self.session.proxies = {}
124
+ self._proxy = value
vntd/mixin/user.py ADDED
@@ -0,0 +1,13 @@
1
+ from ..model import User
2
+
3
+
4
+ class UserMixin:
5
+ def get_user(self, user_id: str | int) -> User:
6
+ """
7
+ Retrieve information about a Vinted user based on their user ID.
8
+ """
9
+ user_data = self._fetch(
10
+ method="GET",
11
+ url=f"{self.base_url}/api/v2/users/{user_id}",
12
+ )
13
+ return User._build(raw=user_data.get("user", {}))
vntd/model/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from .proxy import Proxy
2
+ from .search import Search
3
+ from .ad import Ad
4
+ from .user import User
5
+ from .enums import Sort, SellerType
6
+
7
+ __all__ = [
8
+ "Proxy",
9
+ "Search",
10
+ "Ad",
11
+ "User",
12
+ "Sort",
13
+ "SellerType",
14
+ ]
vntd/model/ad.py ADDED
@@ -0,0 +1,109 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+ from .user import User
5
+
6
+
7
+ @dataclass
8
+ class Ad:
9
+ id: int
10
+ title: str
11
+ description: str | None
12
+ price: float | None
13
+ currency: str | None
14
+ brand: str | None
15
+ size: str | None
16
+ status: str | None
17
+ url: str
18
+ images: list[str]
19
+ favorite_count: int | None
20
+ view_count: int | None
21
+ category: str | None
22
+ color: str | None
23
+
24
+ _client: Any = field(repr=False)
25
+ _user_id: int | None = field(repr=False)
26
+ _user: User | None = field(default=None, repr=False)
27
+
28
+ @staticmethod
29
+ def _build_from_search(raw: dict, client: Any) -> "Ad":
30
+ raw_price = raw.get("price", {})
31
+ price_amount = raw_price.get("amount")
32
+ price = float(price_amount) if price_amount is not None else None
33
+
34
+ photos = raw.get("photos", [])
35
+ images = [photo.get("url") for photo in photos if photo.get("url")]
36
+ if not images and raw.get("photo", {}).get("url"):
37
+ images = [raw.get("photo", {}).get("url")]
38
+
39
+ raw_user = raw.get("user", {})
40
+ return Ad(
41
+ id=raw.get("id"),
42
+ title=raw.get("title"),
43
+ description=None,
44
+ price=price,
45
+ currency=raw_price.get("currency_code"),
46
+ brand=raw.get("brand_title"),
47
+ size=raw.get("size_title"),
48
+ status=raw.get("status"),
49
+ url=raw.get("url"),
50
+ images=images,
51
+ favorite_count=raw.get("favourite_count"),
52
+ view_count=raw.get("view_count"),
53
+ category=None,
54
+ color=None,
55
+ _client=client,
56
+ _user_id=raw_user.get("id"),
57
+ _user=None,
58
+ )
59
+
60
+ @staticmethod
61
+ def _build_from_item_page(
62
+ raw: dict, item_id: int, url: str, seller_id: int | None, client: Any
63
+ ) -> "Ad":
64
+ images = raw.get("image") or []
65
+ if isinstance(images, str):
66
+ images = [images]
67
+
68
+ offers = raw.get("offers", {})
69
+ if isinstance(offers, list):
70
+ offers = offers[0] if offers else {}
71
+
72
+ price_value = offers.get("price")
73
+ price = float(price_value) if price_value is not None else None
74
+
75
+ brand = raw.get("brand", {})
76
+ if isinstance(brand, dict):
77
+ brand = brand.get("name")
78
+ elif isinstance(brand, list):
79
+ brand = brand[0].get("name") if brand else None
80
+
81
+ return Ad(
82
+ id=item_id,
83
+ title=raw.get("name"),
84
+ description=raw.get("description"),
85
+ price=price,
86
+ currency=offers.get("priceCurrency"),
87
+ brand=brand,
88
+ size=None,
89
+ status=offers.get("itemCondition"),
90
+ url=url,
91
+ images=images,
92
+ favorite_count=None,
93
+ view_count=None,
94
+ category=raw.get("category"),
95
+ color=raw.get("color"),
96
+ _client=client,
97
+ _user_id=seller_id,
98
+ _user=None,
99
+ )
100
+
101
+ @property
102
+ def subject(self) -> str:
103
+ return self.title
104
+
105
+ @property
106
+ def user(self) -> User | None:
107
+ if self._user is None and self._user_id is not None:
108
+ self._user = self._client.get_user(user_id=self._user_id)
109
+ return self._user
vntd/model/enums.py ADDED
@@ -0,0 +1,14 @@
1
+ from enum import Enum
2
+
3
+
4
+ class SellerType(Enum):
5
+ BUSINESS = "business"
6
+ INDIVIDUAL = "individual"
7
+ ALL = "all"
8
+
9
+
10
+ class Sort(Enum):
11
+ RELEVANCE = "relevance"
12
+ NEWEST = "newest_first"
13
+ PRICE_LOW_TO_HIGH = "price_low_to_high"
14
+ PRICE_HIGH_TO_LOW = "price_high_to_low"
vntd/model/proxy.py ADDED
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Proxy:
6
+ host: str
7
+ port: str | int
8
+ username: str | None = None
9
+ password: str | None = None
10
+ scheme: str = "http"
11
+
12
+ @property
13
+ def url(self):
14
+ if self.username and self.password:
15
+ return f"{self.scheme}://{self.username}:{self.password}@{self.host}:{self.port}"
16
+ else:
17
+ return f"{self.scheme}://{self.host}:{self.port}"
vntd/model/search.py ADDED
@@ -0,0 +1,40 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from .ad import Ad
5
+
6
+
7
+ @dataclass
8
+ class Pagination:
9
+ current_page: int
10
+ total_pages: int
11
+ total_entries: int
12
+ per_page: int
13
+
14
+
15
+ @dataclass
16
+ class Search:
17
+ pagination: Pagination
18
+ ads: list[Ad]
19
+
20
+ @property
21
+ def total(self) -> int:
22
+ return self.pagination.total_entries
23
+
24
+ @staticmethod
25
+ def _build(raw: dict, client: Any) -> "Search":
26
+ ads: list[Ad] = [
27
+ Ad._build_from_search(raw=ad, client=client)
28
+ for ad in raw.get("items", [])
29
+ ]
30
+
31
+ pagination = raw.get("pagination", {})
32
+ return Search(
33
+ pagination=Pagination(
34
+ current_page=pagination.get("current_page"),
35
+ total_pages=pagination.get("total_pages"),
36
+ total_entries=pagination.get("total_entries"),
37
+ per_page=pagination.get("per_page"),
38
+ ),
39
+ ads=ads,
40
+ )
vntd/model/user.py ADDED
@@ -0,0 +1,51 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class User:
6
+ id: int
7
+ login: str
8
+ profile_url: str | None
9
+ business: bool
10
+ feedback_count: int | None
11
+ feedback_reputation: float | None
12
+ item_count: int | None
13
+ total_items_count: int | None
14
+ followers_count: int | None
15
+ following_count: int | None
16
+ country_code: str | None
17
+ city: str | None
18
+ about: str | None
19
+ photo_url: str | None
20
+
21
+ @staticmethod
22
+ def _build(raw: dict) -> "User":
23
+ photo = raw.get("photo", {}) or {}
24
+ return User(
25
+ id=raw.get("id"),
26
+ login=raw.get("login"),
27
+ profile_url=raw.get("profile_url"),
28
+ business=bool(raw.get("business")),
29
+ feedback_count=raw.get("feedback_count"),
30
+ feedback_reputation=raw.get("feedback_reputation"),
31
+ item_count=raw.get("item_count"),
32
+ total_items_count=raw.get("total_items_count"),
33
+ followers_count=raw.get("followers_count"),
34
+ following_count=raw.get("following_count"),
35
+ country_code=raw.get("country_code"),
36
+ city=raw.get("city"),
37
+ about=raw.get("about"),
38
+ photo_url=photo.get("url"),
39
+ )
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ return self.login
44
+
45
+ @property
46
+ def is_pro(self) -> bool:
47
+ return self.business
48
+
49
+ @property
50
+ def feedback_score(self) -> float | None:
51
+ return self.feedback_reputation * 5 if self.feedback_reputation else None
vntd/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561
vntd/utils.py ADDED
@@ -0,0 +1,73 @@
1
+ from urllib.parse import parse_qs, urlparse
2
+
3
+ from .model import Sort, SellerType
4
+ from .exceptions import InvalidValue
5
+
6
+
7
+ def _normalize_list(value) -> str:
8
+ if isinstance(value, (list, tuple, set)):
9
+ return ",".join(str(item) for item in value)
10
+ return str(value)
11
+
12
+
13
+ def build_search_params_with_url(url: str, limit: int = 24, page: int = 1) -> dict:
14
+ parsed = urlparse(url)
15
+ query = parse_qs(parsed.query)
16
+
17
+ params = {}
18
+ for key, values in query.items():
19
+ if not values:
20
+ continue
21
+ params[key] = ",".join(values) if len(values) > 1 else values[0]
22
+
23
+ params["page"] = page
24
+ params["per_page"] = limit
25
+ return params
26
+
27
+
28
+ def build_search_params_with_args(
29
+ text: str | None = None,
30
+ sort: Sort = Sort.RELEVANCE,
31
+ page: int = 1,
32
+ limit: int = 24,
33
+ seller_type: SellerType = SellerType.ALL,
34
+ user_id: int | None = None,
35
+ price: tuple[int | None, int | None] | list[int | None] | None = None,
36
+ **filters,
37
+ ) -> dict:
38
+ params: dict = {
39
+ "page": page,
40
+ "per_page": limit,
41
+ }
42
+
43
+ if text:
44
+ params["search_text"] = text
45
+
46
+ if sort:
47
+ params["order"] = sort.value
48
+
49
+ if seller_type == SellerType.BUSINESS:
50
+ params["is_business"] = 1
51
+ elif seller_type == SellerType.INDIVIDUAL:
52
+ params["is_business"] = 0
53
+
54
+ if user_id is not None:
55
+ params["user_id"] = user_id
56
+
57
+ if price is not None:
58
+ if not isinstance(price, (list, tuple)) or len(price) != 2:
59
+ raise InvalidValue("price must be a (min, max) tuple.")
60
+ min_price, max_price = price
61
+ if min_price is not None:
62
+ params["price_from"] = min_price
63
+ if max_price is not None:
64
+ params["price_to"] = max_price
65
+ if limit*page > 960:
66
+ print("Warning: max item index exceeds 960, which seems to be the maximum provided by the Vinted API. You may receive an error")
67
+
68
+ for key, value in filters.items():
69
+ if value is None:
70
+ continue
71
+ params[key] = _normalize_list(value)
72
+
73
+ return params
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: vntd
3
+ Version: 1.0.0
4
+ Summary: Unofficial client for Vinted API
5
+ Keywords: vntd,vinted,wrapper,api
6
+ Author: louis.mltp
7
+ Author-email: louis.mltp <107575190+MINLEGO@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: curl-cffi>=0.15.0
20
+ Requires-Dist: fake-useragent>=2.2.0
21
+ Requires-Python: >=3.10
22
+ Project-URL: Homepage, https://github.com/MINLEGO/vntd
23
+ Project-URL: Repository, https://github.com/MINLEGO/vntd
24
+ Project-URL: Changelog, https://github.com/MINLEGO/vntd/blob/main/CHANGELOG.md
25
+ Description-Content-Type: text/markdown
26
+
27
+ # vntd
28
+
29
+ **Unofficial client for the Vinted API.**
30
+ **Fork of [lbc](https://pypi.org/project/lbc/) by [etienne-hd](https://pypi.org/user/etienne-hd/)**
31
+
32
+ ```python
33
+ import vntd
34
+
35
+ client = vntd.Client()
36
+
37
+ result = client.search(
38
+ text="robe",
39
+ page=1,
40
+ limit=20,
41
+ sort=vntd.Sort.NEWEST,
42
+ price=(5, 30),
43
+ )
44
+
45
+ for ad in result.ads:
46
+ print(ad.url, ad.title, ad.price, ad.user)
47
+ ```
48
+
49
+ *vntd is not affiliated with, endorsed by, or in any way associated with Vinted or its services. Use at your own risk.*
50
+
51
+ ## Installation
52
+ Required **Python 3.10+**
53
+ ```bash
54
+ pip install vntd
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ### Client
60
+ ```python
61
+ import vntd
62
+
63
+ client = vntd.Client()
64
+ ```
65
+
66
+ ### Search
67
+ ```python
68
+ result = client.search(
69
+ text="robe",
70
+ page=1,
71
+ limit=24,
72
+ sort=vntd.Sort.RELEVANCE,
73
+ price=(10, 40),
74
+ brand_ids=[53, 54],
75
+ )
76
+ ```
77
+
78
+ ### Search with URL
79
+ ```python
80
+ result = client.search(
81
+ url="https://www.vinted.fr/catalog?search_text=robe&order=newest_first&price_from=5&price_to=30",
82
+ page=1,
83
+ limit=24,
84
+ )
85
+ ```
86
+
87
+ ### Get Item
88
+ ```python
89
+ ad = client.get_ad("8975084387")
90
+ print(ad.title, ad.price, ad.user)
91
+ ```
92
+
93
+ ### Get User
94
+ ```python
95
+ user = client.get_user(80325437)
96
+ print(user.login, user.feedback_score, user.item_count)
97
+ ```
98
+
99
+ ### Proxy
100
+ ```python
101
+ proxy = vntd.Proxy(host="127.0.0.1", port=12345)
102
+ client = vntd.Client(proxy=proxy)
103
+ ```
104
+
105
+ ## Notes
106
+ Vinted does not expose Leboncoin-style radius/region filters or professional store data (SIRET). Use Vinted-native filters such as `price`, `brand_ids`, and `seller_type` instead.
@@ -0,0 +1,19 @@
1
+ vntd/__init__.py,sha256=yuaI1vh5CS7Byi9f-05W-iAO22UUHxZjz2RQJKOF2CA,233
2
+ vntd/client.py,sha256=JVnMvMo0-cSgeP2_84XgkIr7AmQhJCvSFYa93UdD_eI,5374
3
+ vntd/exceptions.py,sha256=3b9PvQpql4fhwD0L8QJ9EgLdvYZYzOVelyzm_Qy3zGU,530
4
+ vntd/mixin/__init__.py,sha256=Dwt_W5FYQgSdtvuycHky1uTdDKu5hZgWGi6ygaT29ow,191
5
+ vntd/mixin/ad.py,sha256=L0f3fkYY1DBgDXgiUQEzMhvv4mZujgdj6jBJUbu2JIQ,1770
6
+ vntd/mixin/search.py,sha256=rE9NaQ9Cpi1cw7SPtTj2LLl9vADCAZyM2f9r2o9ici4,1501
7
+ vntd/mixin/session.py,sha256=zSmmJE96453Ed98vpvtl7tSKdxUmUeptNl4fyJplRBM,4243
8
+ vntd/mixin/user.py,sha256=6hB1tTYS8FGdz472m8m9KAmFZ2_ZLOGBsEoOCaxtP6g,392
9
+ vntd/model/__init__.py,sha256=dcSlH2oJpYszxV3JdhgoUN1jbDLm8P2hy61JyAIggW8,238
10
+ vntd/model/ad.py,sha256=u9SOtg1O313lHtVCIOE-NjI-O39z-S3FMS5wqZNd74M,3366
11
+ vntd/model/enums.py,sha256=LMjg4E_1vDBnJYuwxD17Tv-PsVeXyRUSAdbRWsWc6c0,298
12
+ vntd/model/proxy.py,sha256=rsSfbY62DTRnXPLO-LZO-GVffFcDql-WfW5b71valcY,448
13
+ vntd/model/search.py,sha256=nSjUiHMNOGGQBW_pnzxgsMgEa4cTDetJWnfLw58IwNA,987
14
+ vntd/model/user.py,sha256=9tlqPvkvsZ3nwSzKErwSLokUC1akLLTX9NodBlfWwPo,1549
15
+ vntd/py.typed,sha256=TBDV9m9vjfnp9vsgTeJmpoNseoJEfHwvZaBLvLMfzz8,27
16
+ vntd/utils.py,sha256=rK4zKyCpl6_E6eBSc5w60Ngkik-9yxFcelXvjDyKmh0,2134
17
+ vntd-1.0.0.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
18
+ vntd-1.0.0.dist-info/METADATA,sha256=OMuoaBf3XFvkMfpN3ny_kYQTHTfHtK3vx8gXNmmw_go,2642
19
+ vntd-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any