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 +14 -0
- vntd/client.py +124 -0
- vntd/exceptions.py +18 -0
- vntd/mixin/__init__.py +6 -0
- vntd/mixin/ad.py +54 -0
- vntd/mixin/search.py +44 -0
- vntd/mixin/session.py +124 -0
- vntd/mixin/user.py +13 -0
- vntd/model/__init__.py +14 -0
- vntd/model/ad.py +109 -0
- vntd/model/enums.py +14 -0
- vntd/model/proxy.py +17 -0
- vntd/model/search.py +40 -0
- vntd/model/user.py +51 -0
- vntd/py.typed +1 -0
- vntd/utils.py +73 -0
- vntd-1.0.0.dist-info/METADATA +106 -0
- vntd-1.0.0.dist-info/RECORD +19 -0
- vntd-1.0.0.dist-info/WHEEL +4 -0
vntd/__init__.py
ADDED
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
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
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,,
|