trawl-api 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.
- trawl_api/__init__.py +63 -0
- trawl_api/_client.py +251 -0
- trawl_api/_errors.py +60 -0
- trawl_api/_models.py +189 -0
- trawl_api/_version.py +1 -0
- trawl_api/ebay.py +203 -0
- trawl_api/py.typed +0 -0
- trawl_api-0.1.0.dist-info/METADATA +273 -0
- trawl_api-0.1.0.dist-info/RECORD +11 -0
- trawl_api-0.1.0.dist-info/WHEEL +4 -0
- trawl_api-0.1.0.dist-info/licenses/LICENSE +21 -0
trawl_api/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Python client for the trawl API (https://trawl.dev)."""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncTrawl, Trawl
|
|
4
|
+
from ._errors import (
|
|
5
|
+
APIConnectionError,
|
|
6
|
+
APIResponseValidationError,
|
|
7
|
+
APIStatusError,
|
|
8
|
+
APITimeoutError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
BadRequestError,
|
|
11
|
+
InsufficientCreditsError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ServerError,
|
|
15
|
+
TrawlError,
|
|
16
|
+
)
|
|
17
|
+
from ._models import (
|
|
18
|
+
APIResponse,
|
|
19
|
+
Attribute,
|
|
20
|
+
CategoriesResponse,
|
|
21
|
+
Category,
|
|
22
|
+
Feedback,
|
|
23
|
+
Grading,
|
|
24
|
+
Item,
|
|
25
|
+
RateLimit,
|
|
26
|
+
Sale,
|
|
27
|
+
Seller,
|
|
28
|
+
SoldListing,
|
|
29
|
+
SoldResponse,
|
|
30
|
+
)
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
from .ebay import Condition, Site
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"APIConnectionError",
|
|
36
|
+
"APIResponse",
|
|
37
|
+
"APIResponseValidationError",
|
|
38
|
+
"APIStatusError",
|
|
39
|
+
"APITimeoutError",
|
|
40
|
+
"AsyncTrawl",
|
|
41
|
+
"Attribute",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"BadRequestError",
|
|
44
|
+
"CategoriesResponse",
|
|
45
|
+
"Category",
|
|
46
|
+
"Condition",
|
|
47
|
+
"Feedback",
|
|
48
|
+
"Grading",
|
|
49
|
+
"InsufficientCreditsError",
|
|
50
|
+
"Item",
|
|
51
|
+
"NotFoundError",
|
|
52
|
+
"RateLimit",
|
|
53
|
+
"RateLimitError",
|
|
54
|
+
"Sale",
|
|
55
|
+
"Seller",
|
|
56
|
+
"ServerError",
|
|
57
|
+
"Site",
|
|
58
|
+
"SoldListing",
|
|
59
|
+
"SoldResponse",
|
|
60
|
+
"Trawl",
|
|
61
|
+
"TrawlError",
|
|
62
|
+
"__version__",
|
|
63
|
+
]
|
trawl_api/_client.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from ._errors import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APIResponseValidationError,
|
|
15
|
+
APIStatusError,
|
|
16
|
+
APITimeoutError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
BadRequestError,
|
|
19
|
+
InsufficientCreditsError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
ServerError,
|
|
23
|
+
TrawlError,
|
|
24
|
+
)
|
|
25
|
+
from ._models import APIResponse, rate_limit_from_headers
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
from .ebay import AsyncEbay, Ebay
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.trawl.dev"
|
|
30
|
+
# A 20-page search with details is one response of up to 2,000 listings.
|
|
31
|
+
DEFAULT_TIMEOUT = 60.0
|
|
32
|
+
DEFAULT_MAX_RETRIES = 2
|
|
33
|
+
|
|
34
|
+
_RETRY_STATUSES = frozenset({500, 502, 503, 504})
|
|
35
|
+
_MAX_RETRY_AFTER = 30.0
|
|
36
|
+
_STATUS_ERRORS: dict[int, type[APIStatusError]] = {
|
|
37
|
+
400: BadRequestError,
|
|
38
|
+
401: AuthenticationError,
|
|
39
|
+
403: AuthenticationError,
|
|
40
|
+
404: NotFoundError,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Params = list[tuple[str, str]]
|
|
44
|
+
ResponseT = TypeVar("ResponseT", bound=APIResponse)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _retry_after(response: httpx.Response) -> float | None:
|
|
48
|
+
value = response.headers.get("retry-after")
|
|
49
|
+
if value is None:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
return max(0.0, float(value))
|
|
53
|
+
except ValueError:
|
|
54
|
+
return 1.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _error_message(response: httpx.Response) -> str:
|
|
58
|
+
try:
|
|
59
|
+
body: Any = response.json()
|
|
60
|
+
except ValueError:
|
|
61
|
+
body = None
|
|
62
|
+
if isinstance(body, dict) and isinstance(body.get("error"), str):
|
|
63
|
+
return body["error"]
|
|
64
|
+
return response.text.strip() or f"HTTP {response.status_code}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _status_error(response: httpx.Response) -> APIStatusError:
|
|
68
|
+
message = _error_message(response)
|
|
69
|
+
status = response.status_code
|
|
70
|
+
if status == 429:
|
|
71
|
+
retry_after = _retry_after(response)
|
|
72
|
+
if retry_after is None:
|
|
73
|
+
return InsufficientCreditsError(message, response)
|
|
74
|
+
return RateLimitError(message, response, retry_after=retry_after)
|
|
75
|
+
if status >= 500:
|
|
76
|
+
return ServerError(message, response)
|
|
77
|
+
return _STATUS_ERRORS.get(status, APIStatusError)(message, response)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse(response: httpx.Response, model: type[ResponseT]) -> ResponseT:
|
|
81
|
+
try:
|
|
82
|
+
parsed = model.model_validate(response.json())
|
|
83
|
+
except (ValueError, ValidationError) as exc:
|
|
84
|
+
raise APIResponseValidationError(
|
|
85
|
+
f"The API's answer could not be read as {model.__name__}. "
|
|
86
|
+
"Upgrading trawl-api may fix this: pip install -U trawl-api"
|
|
87
|
+
) from exc
|
|
88
|
+
parsed._rate_limit = rate_limit_from_headers(response.headers)
|
|
89
|
+
return parsed
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _BaseClient:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
api_key: str | None,
|
|
96
|
+
base_url: str | None,
|
|
97
|
+
timeout: float,
|
|
98
|
+
max_retries: int,
|
|
99
|
+
) -> None:
|
|
100
|
+
api_key = api_key or os.environ.get("TRAWL_API_KEY")
|
|
101
|
+
if not api_key:
|
|
102
|
+
raise TrawlError(
|
|
103
|
+
"No API key. Pass api_key=... or set the TRAWL_API_KEY environment variable. "
|
|
104
|
+
"Create a key at https://trawl.dev/console/keys"
|
|
105
|
+
)
|
|
106
|
+
self.api_key = api_key
|
|
107
|
+
self.base_url = (base_url or os.environ.get("TRAWL_BASE_URL") or DEFAULT_BASE_URL).rstrip(
|
|
108
|
+
"/"
|
|
109
|
+
)
|
|
110
|
+
self.timeout = timeout
|
|
111
|
+
self.max_retries = max_retries
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def _headers(self) -> dict[str, str]:
|
|
115
|
+
return {
|
|
116
|
+
"x-api-key": self.api_key,
|
|
117
|
+
"accept": "application/json",
|
|
118
|
+
"user-agent": f"trawl-python/{__version__}",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def _retry_delay(self, attempt: int, response: httpx.Response | None) -> float | None:
|
|
122
|
+
"""Seconds to wait before another attempt, or None when the failure is final."""
|
|
123
|
+
if attempt >= self.max_retries:
|
|
124
|
+
return None
|
|
125
|
+
if response is not None:
|
|
126
|
+
retry_after = _retry_after(response)
|
|
127
|
+
if response.status_code == 429:
|
|
128
|
+
# Without Retry-After the credits are spent, and waiting does not help.
|
|
129
|
+
return None if retry_after is None else min(retry_after, _MAX_RETRY_AFTER)
|
|
130
|
+
if response.status_code not in _RETRY_STATUSES:
|
|
131
|
+
return None
|
|
132
|
+
if retry_after is not None:
|
|
133
|
+
return min(retry_after, _MAX_RETRY_AFTER)
|
|
134
|
+
return min(0.5 * 2**attempt, 8.0) * random.uniform(0.75, 1.25)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Trawl(_BaseClient):
|
|
138
|
+
"""The trawl API client.
|
|
139
|
+
|
|
140
|
+
client = Trawl() # reads TRAWL_API_KEY
|
|
141
|
+
sold = client.ebay.sold("iphone 15 pro 256gb", condition="used")
|
|
142
|
+
|
|
143
|
+
Failed requests are retried `max_retries` times on connection errors, 5xx
|
|
144
|
+
answers and per-second rate limits; errors are never billed.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
api_key: str | None = None,
|
|
150
|
+
*,
|
|
151
|
+
base_url: str | None = None,
|
|
152
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
153
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
154
|
+
http_client: httpx.Client | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
157
|
+
self._http = http_client or httpx.Client()
|
|
158
|
+
self._owns_http = http_client is None
|
|
159
|
+
self.ebay = Ebay(self)
|
|
160
|
+
|
|
161
|
+
def _get(self, path: str, params: Params, model: type[ResponseT]) -> ResponseT:
|
|
162
|
+
attempt = 0
|
|
163
|
+
while True:
|
|
164
|
+
response: httpx.Response | None = None
|
|
165
|
+
cause: Exception | None = None
|
|
166
|
+
error: TrawlError
|
|
167
|
+
try:
|
|
168
|
+
response = self._http.get(
|
|
169
|
+
self.base_url + path,
|
|
170
|
+
params=tuple(params),
|
|
171
|
+
headers=self._headers,
|
|
172
|
+
timeout=self.timeout,
|
|
173
|
+
)
|
|
174
|
+
except httpx.TimeoutException as exc:
|
|
175
|
+
error, cause = APITimeoutError("The request timed out."), exc
|
|
176
|
+
except httpx.TransportError as exc:
|
|
177
|
+
error, cause = APIConnectionError(f"Could not reach the API: {exc}"), exc
|
|
178
|
+
else:
|
|
179
|
+
if response.is_success:
|
|
180
|
+
return _parse(response, model)
|
|
181
|
+
error = _status_error(response)
|
|
182
|
+
delay = self._retry_delay(attempt, response)
|
|
183
|
+
if delay is None:
|
|
184
|
+
raise error from cause
|
|
185
|
+
time.sleep(delay)
|
|
186
|
+
attempt += 1
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
if self._owns_http:
|
|
190
|
+
self._http.close()
|
|
191
|
+
|
|
192
|
+
def __enter__(self) -> Trawl:
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
196
|
+
self.close()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class AsyncTrawl(_BaseClient):
|
|
200
|
+
"""The trawl API client for asyncio. Same surface as `Trawl`, awaited."""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
api_key: str | None = None,
|
|
205
|
+
*,
|
|
206
|
+
base_url: str | None = None,
|
|
207
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
208
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
209
|
+
http_client: httpx.AsyncClient | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
212
|
+
self._http = http_client or httpx.AsyncClient()
|
|
213
|
+
self._owns_http = http_client is None
|
|
214
|
+
self.ebay = AsyncEbay(self)
|
|
215
|
+
|
|
216
|
+
async def _get(self, path: str, params: Params, model: type[ResponseT]) -> ResponseT:
|
|
217
|
+
attempt = 0
|
|
218
|
+
while True:
|
|
219
|
+
response: httpx.Response | None = None
|
|
220
|
+
cause: Exception | None = None
|
|
221
|
+
error: TrawlError
|
|
222
|
+
try:
|
|
223
|
+
response = await self._http.get(
|
|
224
|
+
self.base_url + path,
|
|
225
|
+
params=tuple(params),
|
|
226
|
+
headers=self._headers,
|
|
227
|
+
timeout=self.timeout,
|
|
228
|
+
)
|
|
229
|
+
except httpx.TimeoutException as exc:
|
|
230
|
+
error, cause = APITimeoutError("The request timed out."), exc
|
|
231
|
+
except httpx.TransportError as exc:
|
|
232
|
+
error, cause = APIConnectionError(f"Could not reach the API: {exc}"), exc
|
|
233
|
+
else:
|
|
234
|
+
if response.is_success:
|
|
235
|
+
return _parse(response, model)
|
|
236
|
+
error = _status_error(response)
|
|
237
|
+
delay = self._retry_delay(attempt, response)
|
|
238
|
+
if delay is None:
|
|
239
|
+
raise error from cause
|
|
240
|
+
await asyncio.sleep(delay)
|
|
241
|
+
attempt += 1
|
|
242
|
+
|
|
243
|
+
async def close(self) -> None:
|
|
244
|
+
if self._owns_http:
|
|
245
|
+
await self._http.aclose()
|
|
246
|
+
|
|
247
|
+
async def __aenter__(self) -> AsyncTrawl:
|
|
248
|
+
return self
|
|
249
|
+
|
|
250
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
251
|
+
await self.close()
|
trawl_api/_errors.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TrawlError(Exception):
|
|
7
|
+
"""Base class for every error this package raises."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIConnectionError(TrawlError):
|
|
11
|
+
"""The request never got an answer (DNS, connection reset, TLS)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APITimeoutError(APIConnectionError):
|
|
15
|
+
"""The request ran past the client's timeout."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class APIResponseValidationError(TrawlError):
|
|
19
|
+
"""The API answered 2xx with a body this version of the package cannot read."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIStatusError(TrawlError):
|
|
23
|
+
"""The API answered with an error status. `message` is the API's own explanation."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, response: httpx.Response) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.response = response
|
|
29
|
+
self.status_code = response.status_code
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BadRequestError(APIStatusError):
|
|
33
|
+
"""400: a parameter failed validation; the message names the field and the rule."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthenticationError(APIStatusError):
|
|
37
|
+
"""403: the API key is missing, invalid or deleted."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotFoundError(APIStatusError):
|
|
41
|
+
"""404: nothing found, e.g. an item whose details are not available yet. Not billed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RateLimitError(APIStatusError):
|
|
45
|
+
"""429 with Retry-After: the plan's per-second rate was exceeded. Wait and retry."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: str, response: httpx.Response, *, retry_after: float) -> None:
|
|
48
|
+
super().__init__(message, response)
|
|
49
|
+
self.retry_after = retry_after
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Deliberately not a RateLimitError: code that catches a rate limit to sleep and
|
|
53
|
+
# retry would loop forever on spent credits, which only a new window or a plan fixes.
|
|
54
|
+
class InsufficientCreditsError(APIStatusError):
|
|
55
|
+
"""429 without Retry-After: the month's credits are spent, or too few remain to
|
|
56
|
+
cover the request's max_pages. Lower max_pages, upgrade, or wait for the reset."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ServerError(APIStatusError):
|
|
60
|
+
"""5xx: a failure on trawl's side. Safe to retry."""
|
trawl_api/_models.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class RateLimit:
|
|
13
|
+
"""The account's allowance, read from the response's X-RateLimit-* headers."""
|
|
14
|
+
|
|
15
|
+
limit: int | None
|
|
16
|
+
"""Credits included in the plan per month."""
|
|
17
|
+
remaining: int | None
|
|
18
|
+
"""Credits left in this billing window."""
|
|
19
|
+
reset: datetime | None
|
|
20
|
+
"""When the window resets (the billing period's end)."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def rate_limit_from_headers(headers: Mapping[str, str]) -> RateLimit | None:
|
|
24
|
+
def number(name: str) -> int | None:
|
|
25
|
+
try:
|
|
26
|
+
return int(headers[name])
|
|
27
|
+
except (KeyError, ValueError):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
limit = number("x-ratelimit-limit")
|
|
31
|
+
remaining = number("x-ratelimit-remaining")
|
|
32
|
+
reset_at = number("x-ratelimit-reset")
|
|
33
|
+
if limit is None and remaining is None and reset_at is None:
|
|
34
|
+
return None
|
|
35
|
+
reset = None
|
|
36
|
+
if reset_at is not None:
|
|
37
|
+
# A Unix timestamp; read milliseconds as well as seconds.
|
|
38
|
+
seconds = reset_at / 1000 if reset_at > 100_000_000_000 else reset_at
|
|
39
|
+
reset = datetime.fromtimestamp(seconds, tz=timezone.utc)
|
|
40
|
+
return RateLimit(limit=limit, remaining=remaining, reset=reset)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Model(BaseModel):
|
|
44
|
+
# extra="allow": a field the API adds later is kept on the object instead of
|
|
45
|
+
# breaking every installed version of the package.
|
|
46
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class APIResponse(_Model):
|
|
50
|
+
credits_charged: int = 0
|
|
51
|
+
"""Credits this call cost. 0 for a search that matched nothing."""
|
|
52
|
+
|
|
53
|
+
_rate_limit: RateLimit | None = PrivateAttr(default=None)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def rate_limit(self) -> RateLimit | None:
|
|
57
|
+
"""The account's allowance after this call, when the API sent it."""
|
|
58
|
+
return self._rate_limit
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Grading(_Model):
|
|
62
|
+
graded: bool | None = None
|
|
63
|
+
grader: str | None = None
|
|
64
|
+
grade: str | None = None
|
|
65
|
+
condition: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Attribute(_Model):
|
|
69
|
+
key: str
|
|
70
|
+
value: str
|
|
71
|
+
values: list[str] = Field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Seller(_Model):
|
|
75
|
+
username: str
|
|
76
|
+
feedback_percent: float | None = None
|
|
77
|
+
feedback_count: int | None = None
|
|
78
|
+
items_sold: int | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Feedback(_Model):
|
|
82
|
+
username: str | None = None
|
|
83
|
+
rating: str | None = None
|
|
84
|
+
comment: str | None = None
|
|
85
|
+
age_text: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Sale(_Model):
|
|
89
|
+
date_sold: datetime
|
|
90
|
+
sale_price: float
|
|
91
|
+
shipping_price: float | None = None
|
|
92
|
+
currency: str | None = None
|
|
93
|
+
condition: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Item(APIResponse):
|
|
97
|
+
"""One listing in full, as GET /item answers it.
|
|
98
|
+
|
|
99
|
+
A listing eBay has removed carries only `site`, `item_id` and
|
|
100
|
+
`listing_state == "removed"`; every other field is then empty.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
site: str | None = None
|
|
104
|
+
item_id: str | None = None
|
|
105
|
+
title: str | None = None
|
|
106
|
+
condition: str | None = None
|
|
107
|
+
condition_raw: str | None = None
|
|
108
|
+
condition_description: str | None = None
|
|
109
|
+
grading: Grading | None = None
|
|
110
|
+
listing_state: str | None = None
|
|
111
|
+
sold_at: datetime | None = None
|
|
112
|
+
last_updated: datetime | None = None
|
|
113
|
+
sale_price: float | None = None
|
|
114
|
+
currency: str | None = None
|
|
115
|
+
buying_format: str | None = None
|
|
116
|
+
bids: int | None = None
|
|
117
|
+
best_offer_available: bool | None = None
|
|
118
|
+
best_offer_accepted: bool | None = None
|
|
119
|
+
shipping_service: str | None = None
|
|
120
|
+
returns_text: str | None = None
|
|
121
|
+
seller_accepts_returns: bool | None = None
|
|
122
|
+
location: str | None = None
|
|
123
|
+
location_country: str | None = None
|
|
124
|
+
epid: str | None = None
|
|
125
|
+
category_id: str | None = Field(default=None, alias="categoryId")
|
|
126
|
+
item_link: str | None = None
|
|
127
|
+
images: list[str] = Field(default_factory=list)
|
|
128
|
+
attributes: list[Attribute] = Field(default_factory=list)
|
|
129
|
+
description_url: str | None = None
|
|
130
|
+
description_text: str | None = None
|
|
131
|
+
seller: Seller | None = None
|
|
132
|
+
feedback: list[Feedback] = Field(default_factory=list)
|
|
133
|
+
sales: list[Sale] = Field(default_factory=list)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def is_removed(self) -> bool:
|
|
137
|
+
return self.listing_state == "removed"
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def specifics(self) -> dict[str, str]:
|
|
141
|
+
"""The item specifics as a dict: `item.specifics["Brand"]`."""
|
|
142
|
+
return {attribute.key: attribute.value for attribute in self.attributes}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SoldListing(_Model):
|
|
146
|
+
title: str
|
|
147
|
+
sale_price: float
|
|
148
|
+
shipping_price: float | None = None
|
|
149
|
+
currency: str | None = None
|
|
150
|
+
condition: str | None = None
|
|
151
|
+
condition_raw: str | None = None
|
|
152
|
+
date_sold: datetime
|
|
153
|
+
buying_format: str | None = None
|
|
154
|
+
bids: int | None = None
|
|
155
|
+
best_offer_available: bool | None = None
|
|
156
|
+
location: str | None = None
|
|
157
|
+
item_id: str
|
|
158
|
+
epid: str | None = None
|
|
159
|
+
category_id: str | None = Field(default=None, alias="categoryId")
|
|
160
|
+
item_link: str | None = None
|
|
161
|
+
image_url: str | None = None
|
|
162
|
+
details: Item | None = None
|
|
163
|
+
"""The listing's full /item data. Only with `details=True`."""
|
|
164
|
+
listing_state: str | None = None
|
|
165
|
+
""""removed" when eBay has taken the listing down. Only with `details=True`."""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class SoldResponse(APIResponse):
|
|
169
|
+
site: str
|
|
170
|
+
currency: str | None = None
|
|
171
|
+
query: list[str] = Field(default_factory=list)
|
|
172
|
+
filters: dict[str, Any] = Field(default_factory=dict)
|
|
173
|
+
max_pages: int | None = None
|
|
174
|
+
count: int = 0
|
|
175
|
+
took_ms: int | None = None
|
|
176
|
+
results: list[SoldListing] = Field(default_factory=list)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Category(_Model):
|
|
180
|
+
category_id: str = Field(alias="categoryId")
|
|
181
|
+
name: str
|
|
182
|
+
group: str | None = None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class CategoriesResponse(APIResponse):
|
|
186
|
+
site: str
|
|
187
|
+
total: int | None = None
|
|
188
|
+
count: int = 0
|
|
189
|
+
categories: list[Category] = Field(default_factory=list)
|
trawl_api/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
trawl_api/ebay.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""The eBay API: sold listings, one listing in full, and category lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Literal
|
|
8
|
+
|
|
9
|
+
from ._models import CategoriesResponse, Item, SoldResponse
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ._client import AsyncTrawl, Trawl
|
|
13
|
+
|
|
14
|
+
Site = Literal["EBAY_US", "EBAY_GB"]
|
|
15
|
+
Condition = Literal["new", "used", "parts", "other"]
|
|
16
|
+
AttrFilter = Mapping[str, "str | Sequence[str]"] | Sequence[str] | str
|
|
17
|
+
|
|
18
|
+
_PREFIX = "/ebay/v1"
|
|
19
|
+
_Params = list[tuple[str, str]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _day(value: date | str) -> str:
|
|
23
|
+
if isinstance(value, datetime):
|
|
24
|
+
return value.date().isoformat()
|
|
25
|
+
if isinstance(value, date):
|
|
26
|
+
return value.isoformat()
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _joined(value: str | Sequence[str]) -> str:
|
|
31
|
+
return value if isinstance(value, str) else ",".join(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _attr_params(attr: AttrFilter) -> _Params:
|
|
35
|
+
# The API takes one attr=Key:Value per filter, the parameter repeated.
|
|
36
|
+
if isinstance(attr, str):
|
|
37
|
+
return [("attr", attr)]
|
|
38
|
+
if isinstance(attr, Mapping):
|
|
39
|
+
params: _Params = []
|
|
40
|
+
for key, value in attr.items():
|
|
41
|
+
for one in [value] if isinstance(value, str) else value:
|
|
42
|
+
params.append(("attr", f"{key}:{one}"))
|
|
43
|
+
return params
|
|
44
|
+
return [("attr", one) for one in attr]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sold_params(
|
|
48
|
+
query: str,
|
|
49
|
+
exclude: str | Sequence[str] | None,
|
|
50
|
+
site: Site | None,
|
|
51
|
+
category: str | int | None,
|
|
52
|
+
condition: Condition | Sequence[Condition] | None,
|
|
53
|
+
attr: AttrFilter | None,
|
|
54
|
+
min_price: float | None,
|
|
55
|
+
max_price: float | None,
|
|
56
|
+
date_from: date | str | None,
|
|
57
|
+
date_to: date | str | None,
|
|
58
|
+
max_pages: int | None,
|
|
59
|
+
details: bool,
|
|
60
|
+
) -> _Params:
|
|
61
|
+
params: _Params = [("query", query)]
|
|
62
|
+
if exclude:
|
|
63
|
+
params.append(("exclude", _joined(exclude)))
|
|
64
|
+
if site:
|
|
65
|
+
params.append(("site", site))
|
|
66
|
+
if category is not None:
|
|
67
|
+
params.append(("category", str(category)))
|
|
68
|
+
if condition:
|
|
69
|
+
params.append(("condition", _joined(condition)))
|
|
70
|
+
if attr:
|
|
71
|
+
params.extend(_attr_params(attr))
|
|
72
|
+
if min_price is not None:
|
|
73
|
+
params.append(("min_price", str(min_price)))
|
|
74
|
+
if max_price is not None:
|
|
75
|
+
params.append(("max_price", str(max_price)))
|
|
76
|
+
if date_from is not None:
|
|
77
|
+
params.append(("date_from", _day(date_from)))
|
|
78
|
+
if date_to is not None:
|
|
79
|
+
params.append(("date_to", _day(date_to)))
|
|
80
|
+
if max_pages is not None:
|
|
81
|
+
params.append(("max_pages", str(max_pages)))
|
|
82
|
+
if details:
|
|
83
|
+
params.append(("details", "1"))
|
|
84
|
+
return params
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _item_params(item_id: str | int, site: Site | None) -> _Params:
|
|
88
|
+
params: _Params = [("item_id", str(item_id))]
|
|
89
|
+
if site:
|
|
90
|
+
params.append(("site", site))
|
|
91
|
+
return params
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _categories_params(query: str | int, site: Site | None) -> _Params:
|
|
95
|
+
params: _Params = [("query", str(query))]
|
|
96
|
+
if site:
|
|
97
|
+
params.append(("site", site))
|
|
98
|
+
return params
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Ebay:
|
|
102
|
+
def __init__(self, client: Trawl) -> None:
|
|
103
|
+
self._client = client
|
|
104
|
+
|
|
105
|
+
def sold(
|
|
106
|
+
self,
|
|
107
|
+
query: str,
|
|
108
|
+
*,
|
|
109
|
+
exclude: str | Sequence[str] | None = None,
|
|
110
|
+
site: Site | None = None,
|
|
111
|
+
category: str | int | None = None,
|
|
112
|
+
condition: Condition | Sequence[Condition] | None = None,
|
|
113
|
+
attr: AttrFilter | None = None,
|
|
114
|
+
min_price: float | None = None,
|
|
115
|
+
max_price: float | None = None,
|
|
116
|
+
date_from: date | str | None = None,
|
|
117
|
+
date_to: date | str | None = None,
|
|
118
|
+
max_pages: int | None = None,
|
|
119
|
+
details: bool = False,
|
|
120
|
+
) -> SoldResponse:
|
|
121
|
+
"""Sold listings whose title contains every word of `query`, newest first.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
query: Words that must all appear in the listing title, in any order.
|
|
125
|
+
exclude: Words that must not appear in the title.
|
|
126
|
+
site: Marketplace, "EBAY_US" (the default) or "EBAY_GB".
|
|
127
|
+
category: An eBay leaf category id; find ids with `categories()`.
|
|
128
|
+
condition: One or several of "new", "used", "parts", "other".
|
|
129
|
+
attr: Item specifics to match, e.g. `{"Brand": "Apple", "Grade": ["9", "10"]}`.
|
|
130
|
+
Different keys must all match; several values for one key match any.
|
|
131
|
+
min_price: Minimum sale price in the marketplace's own currency.
|
|
132
|
+
max_price: Maximum sale price in the marketplace's own currency.
|
|
133
|
+
date_from: Earliest sale date, inclusive (a `date` or "YYYY-MM-DD").
|
|
134
|
+
date_to: Latest sale date, inclusive.
|
|
135
|
+
max_pages: Pages of 100 results to return in this one response, 1-20
|
|
136
|
+
(1 when omitted). Also the most credits the call can cost.
|
|
137
|
+
details: Include each result's full listing details (`listing.details`).
|
|
138
|
+
|
|
139
|
+
Costs 1 credit per page of results returned, 2 with `details=True`;
|
|
140
|
+
a search that matches nothing is free.
|
|
141
|
+
"""
|
|
142
|
+
params = _sold_params(
|
|
143
|
+
query, exclude, site, category, condition, attr,
|
|
144
|
+
min_price, max_price, date_from, date_to, max_pages, details,
|
|
145
|
+
) # fmt: skip
|
|
146
|
+
return self._client._get(f"{_PREFIX}/sold", params, SoldResponse)
|
|
147
|
+
|
|
148
|
+
def item(self, item_id: str | int, *, site: Site | None = None) -> Item:
|
|
149
|
+
"""One sold listing in full, by the `item_id` of any `sold()` result.
|
|
150
|
+
|
|
151
|
+
Costs 1 credit. Details become available a few minutes after a sale;
|
|
152
|
+
until then the call raises `NotFoundError`, which is never billed. A
|
|
153
|
+
listing eBay has removed answers free, with `item.is_removed` true.
|
|
154
|
+
"""
|
|
155
|
+
return self._client._get(f"{_PREFIX}/item", _item_params(item_id, site), Item)
|
|
156
|
+
|
|
157
|
+
def categories(self, query: str | int, *, site: Site | None = None) -> CategoriesResponse:
|
|
158
|
+
"""eBay leaf categories by name, busiest first; a numeric query looks up that id.
|
|
159
|
+
|
|
160
|
+
Costs 1 credit, nothing if no category matches. Category ids differ per
|
|
161
|
+
marketplace, so pass the same `site` you will search with.
|
|
162
|
+
"""
|
|
163
|
+
return self._client._get(
|
|
164
|
+
f"{_PREFIX}/categories", _categories_params(query, site), CategoriesResponse
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class AsyncEbay:
|
|
169
|
+
def __init__(self, client: AsyncTrawl) -> None:
|
|
170
|
+
self._client = client
|
|
171
|
+
|
|
172
|
+
async def sold(
|
|
173
|
+
self,
|
|
174
|
+
query: str,
|
|
175
|
+
*,
|
|
176
|
+
exclude: str | Sequence[str] | None = None,
|
|
177
|
+
site: Site | None = None,
|
|
178
|
+
category: str | int | None = None,
|
|
179
|
+
condition: Condition | Sequence[Condition] | None = None,
|
|
180
|
+
attr: AttrFilter | None = None,
|
|
181
|
+
min_price: float | None = None,
|
|
182
|
+
max_price: float | None = None,
|
|
183
|
+
date_from: date | str | None = None,
|
|
184
|
+
date_to: date | str | None = None,
|
|
185
|
+
max_pages: int | None = None,
|
|
186
|
+
details: bool = False,
|
|
187
|
+
) -> SoldResponse:
|
|
188
|
+
"""Sold listings matching `query`, newest first. See `Ebay.sold`."""
|
|
189
|
+
params = _sold_params(
|
|
190
|
+
query, exclude, site, category, condition, attr,
|
|
191
|
+
min_price, max_price, date_from, date_to, max_pages, details,
|
|
192
|
+
) # fmt: skip
|
|
193
|
+
return await self._client._get(f"{_PREFIX}/sold", params, SoldResponse)
|
|
194
|
+
|
|
195
|
+
async def item(self, item_id: str | int, *, site: Site | None = None) -> Item:
|
|
196
|
+
"""One sold listing in full. See `Ebay.item`."""
|
|
197
|
+
return await self._client._get(f"{_PREFIX}/item", _item_params(item_id, site), Item)
|
|
198
|
+
|
|
199
|
+
async def categories(self, query: str | int, *, site: Site | None = None) -> CategoriesResponse:
|
|
200
|
+
"""eBay leaf categories by name or id. See `Ebay.categories`."""
|
|
201
|
+
return await self._client._get(
|
|
202
|
+
f"{_PREFIX}/categories", _categories_params(query, site), CategoriesResponse
|
|
203
|
+
)
|
trawl_api/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: trawl-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python eBay scraper API: sold listings, prices and item details from eBay as JSON
|
|
5
|
+
Project-URL: Homepage, https://trawl.dev
|
|
6
|
+
Project-URL: Documentation, https://trawl.dev/docs
|
|
7
|
+
Project-URL: Pricing, https://trawl.dev/pricing
|
|
8
|
+
Project-URL: Repository, https://github.com/trawl-inc/trawl-python
|
|
9
|
+
Project-URL: Changelog, https://github.com/trawl-inc/trawl-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author: trawl
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: ebay,ebay api,ebay price data,ebay scraper,ebay sold listings,scraper,sold prices,trawl
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: httpx<1,>=0.25
|
|
29
|
+
Requires-Dist: pydantic<3,>=2.5
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# trawl-api: Python eBay scraper API for sold listings and prices
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/trawl-api/)
|
|
35
|
+
[](https://pypi.org/project/trawl-api/)
|
|
36
|
+
|
|
37
|
+
The official Python client for [trawl](https://trawl.dev), a data API for eBay. Search 300+
|
|
38
|
+
million completed eBay sales on the US and UK marketplaces and get each one back as typed
|
|
39
|
+
Python objects: final price, sale date, condition, shipping, seller, item specifics, images
|
|
40
|
+
and description. One API key, no proxies, no HTML parsing, no browser to run.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install trawl-api
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.10 or newer. [Create a free account](https://trawl.dev/signup) to get an
|
|
47
|
+
API key; the free plan needs no card.
|
|
48
|
+
|
|
49
|
+
## Quickstart
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from trawl_api import Trawl
|
|
53
|
+
|
|
54
|
+
client = Trawl() # reads the TRAWL_API_KEY environment variable
|
|
55
|
+
|
|
56
|
+
sold = client.ebay.sold("iphone 15 pro 256gb", condition="used")
|
|
57
|
+
|
|
58
|
+
for listing in sold.results[:5]:
|
|
59
|
+
print(listing.date_sold.date(), listing.sale_price, listing.title)
|
|
60
|
+
|
|
61
|
+
print(f"{sold.count} results, {sold.credits_charged} credit(s) charged")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The key can also be passed directly: `Trawl(api_key="sk_live_...")`. Keep it on your server
|
|
65
|
+
and out of source control.
|
|
66
|
+
|
|
67
|
+
## What you can ask
|
|
68
|
+
|
|
69
|
+
| Method | Answers | Costs |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `client.ebay.sold(query, ...)` | Sold listings matching a query, newest first, up to 2,000 in one response | 1 credit per page of 100 results (2 with `details=True`); free when nothing matches |
|
|
72
|
+
| `client.ebay.item(item_id)` | One sold listing in full: specifics, seller, every image, description, recorded sales | 1 credit |
|
|
73
|
+
| `client.ebay.categories(query)` | eBay categories by name or id, for the `category` filter | 1 credit; free when nothing matches |
|
|
74
|
+
|
|
75
|
+
The full parameter and field reference is at [trawl.dev/docs](https://trawl.dev/docs).
|
|
76
|
+
|
|
77
|
+
## Tutorials
|
|
78
|
+
|
|
79
|
+
Each of these is a complete script in
|
|
80
|
+
[`examples/`](https://github.com/trawl-inc/trawl-python/tree/main/examples).
|
|
81
|
+
|
|
82
|
+
### Get the sold prices of a product
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from trawl_api import Trawl
|
|
86
|
+
|
|
87
|
+
client = Trawl()
|
|
88
|
+
sold = client.ebay.sold(
|
|
89
|
+
"iphone 15 pro 256gb",
|
|
90
|
+
condition="used",
|
|
91
|
+
exclude=["case", "cracked"], # words that must not be in the title
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
for listing in sold.results:
|
|
95
|
+
print(
|
|
96
|
+
f"{listing.date_sold:%Y-%m-%d} {listing.currency}{listing.sale_price:.2f} {listing.title}"
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Average sold price over the last 90 days
|
|
101
|
+
|
|
102
|
+
`max_pages` sets how many pages of 100 results come back in the one response, and is also the
|
|
103
|
+
most credits the call can cost.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from datetime import date, timedelta
|
|
107
|
+
from statistics import mean, median
|
|
108
|
+
|
|
109
|
+
from trawl_api import Trawl
|
|
110
|
+
|
|
111
|
+
client = Trawl()
|
|
112
|
+
sold = client.ebay.sold(
|
|
113
|
+
"nintendo switch oled",
|
|
114
|
+
condition="used",
|
|
115
|
+
date_from=date.today() - timedelta(days=90),
|
|
116
|
+
max_pages=5,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
prices = [listing.sale_price for listing in sold.results]
|
|
120
|
+
print(
|
|
121
|
+
f"{len(prices)} sales, average {mean(prices):.2f}, median {median(prices):.2f} {sold.currency}"
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Filter by price, marketplace and item specifics
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
sold = client.ebay.sold(
|
|
129
|
+
"charizard",
|
|
130
|
+
site="EBAY_GB", # ebay.co.uk; prices are then in GBP
|
|
131
|
+
min_price=100,
|
|
132
|
+
max_price=2000,
|
|
133
|
+
attr={"Set": "Base Set", "Grade": ["9", "10"]}, # Grade 9 or 10, from Base Set
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Export sold listings to CSV or pandas
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import csv
|
|
141
|
+
|
|
142
|
+
from trawl_api import Trawl
|
|
143
|
+
|
|
144
|
+
COLUMNS = ["date_sold", "title", "sale_price", "currency", "condition", "item_id", "item_link"]
|
|
145
|
+
|
|
146
|
+
client = Trawl()
|
|
147
|
+
sold = client.ebay.sold("charizard base set holo", max_pages=10)
|
|
148
|
+
|
|
149
|
+
with open("sold.csv", "w", newline="", encoding="utf-8") as file:
|
|
150
|
+
writer = csv.DictWriter(file, fieldnames=COLUMNS)
|
|
151
|
+
writer.writeheader()
|
|
152
|
+
for listing in sold.results:
|
|
153
|
+
writer.writerow(listing.model_dump(mode="json", include=set(COLUMNS)))
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
For pandas: `pd.DataFrame(listing.model_dump() for listing in sold.results)`.
|
|
157
|
+
|
|
158
|
+
### Get one listing's full details
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from trawl_api import NotFoundError, Trawl
|
|
162
|
+
|
|
163
|
+
client = Trawl()
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
item = client.ebay.item("256637082114")
|
|
167
|
+
except NotFoundError:
|
|
168
|
+
# Details arrive a few minutes after a sale. A 404 is never billed.
|
|
169
|
+
raise SystemExit("Details are not available yet.")
|
|
170
|
+
|
|
171
|
+
print(item.title, item.sale_price, item.currency)
|
|
172
|
+
print(item.specifics["Brand"]) # item specifics as a dict
|
|
173
|
+
print(item.seller.username, item.seller.feedback_percent)
|
|
174
|
+
print(len(item.images), "images")
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
To get the details of every result of a search in one call, pass `details=True` to `sold()`
|
|
178
|
+
and read `listing.details`.
|
|
179
|
+
|
|
180
|
+
### Find a category and search inside it
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
found = client.ebay.categories("trading card singles", site="EBAY_US")
|
|
184
|
+
for category in found.categories:
|
|
185
|
+
print(category.category_id, category.name, category.group)
|
|
186
|
+
|
|
187
|
+
sold = client.ebay.sold("charizard", category=found.categories[0].category_id)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Category ids differ per marketplace, so look them up with the same `site` you search with.
|
|
191
|
+
|
|
192
|
+
## Credits
|
|
193
|
+
|
|
194
|
+
Every answer says what it cost, and how much of the month's allowance is left:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
sold = client.ebay.sold("rolex submariner", max_pages=3)
|
|
198
|
+
|
|
199
|
+
sold.credits_charged # 3 when three pages came back, 0 when nothing matched
|
|
200
|
+
sold.rate_limit.remaining # credits left in this billing window
|
|
201
|
+
sold.rate_limit.reset # when the allowance resets
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Errors are never billed.
|
|
205
|
+
|
|
206
|
+
## Errors
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
import trawl_api
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
sold = client.ebay.sold("rolex submariner", min_price=2000)
|
|
213
|
+
except trawl_api.BadRequestError as error:
|
|
214
|
+
print(error) # the API names the field and the rule it broke
|
|
215
|
+
except trawl_api.InsufficientCreditsError as error:
|
|
216
|
+
print(error) # the month's credits are spent, or too few remain
|
|
217
|
+
except trawl_api.RateLimitError as error:
|
|
218
|
+
print(error.retry_after) # seconds to wait
|
|
219
|
+
except trawl_api.APIStatusError as error:
|
|
220
|
+
print(error.status_code, error)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
| Exception | When |
|
|
224
|
+
|---|---|
|
|
225
|
+
| `BadRequestError` | 400, a parameter failed validation |
|
|
226
|
+
| `AuthenticationError` | 403, the key is missing, invalid or deleted |
|
|
227
|
+
| `NotFoundError` | 404, e.g. an item whose details are not available yet |
|
|
228
|
+
| `RateLimitError` | 429 with `Retry-After`, the plan's per-second rate was exceeded |
|
|
229
|
+
| `InsufficientCreditsError` | 429 without `Retry-After`, not enough credits for the request |
|
|
230
|
+
| `ServerError` | 5xx, a failure on trawl's side |
|
|
231
|
+
| `APIConnectionError`, `APITimeoutError` | the request got no answer |
|
|
232
|
+
|
|
233
|
+
All of them inherit from `trawl_api.TrawlError`. Connection errors, 5xx answers and
|
|
234
|
+
per-second rate limits are retried twice with backoff before they are raised; change that
|
|
235
|
+
with `Trawl(max_retries=...)`.
|
|
236
|
+
|
|
237
|
+
## Async
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
import asyncio
|
|
241
|
+
|
|
242
|
+
from trawl_api import AsyncTrawl
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def main():
|
|
246
|
+
async with AsyncTrawl() as client:
|
|
247
|
+
sold = await client.ebay.sold("iphone 15 pro 256gb")
|
|
248
|
+
print(sold.count)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
asyncio.run(main())
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Configuration
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
client = Trawl(
|
|
258
|
+
api_key="sk_live_...", # default: the TRAWL_API_KEY environment variable
|
|
259
|
+
timeout=60.0, # seconds
|
|
260
|
+
max_retries=2,
|
|
261
|
+
)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Responses are [pydantic](https://docs.pydantic.dev) models: `model_dump()` gives a dict,
|
|
265
|
+
`model_dump_json()` a JSON string, and fields the API adds later are kept on the object.
|
|
266
|
+
|
|
267
|
+
## Links
|
|
268
|
+
|
|
269
|
+
- [Documentation](https://trawl.dev/docs)
|
|
270
|
+
- [Pricing](https://trawl.dev/pricing)
|
|
271
|
+
- [Changelog](https://github.com/trawl-inc/trawl-python/blob/main/CHANGELOG.md)
|
|
272
|
+
|
|
273
|
+
trawl is an independent service and is not affiliated with or endorsed by eBay Inc.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
trawl_api/__init__.py,sha256=5KIipVdHcogrOtITiQVF5aXcwPrmWth9p8B_JQqae-A,1205
|
|
2
|
+
trawl_api/_client.py,sha256=Vrl8Y7euQkHp4D46_hq8AHHbwlQL8kV17z3eg8kotHY,8401
|
|
3
|
+
trawl_api/_errors.py,sha256=BvrXcZnxKQnYAtJsWVVCTbgGvBtrsuyPwEEfNZ7YveE,1978
|
|
4
|
+
trawl_api/_models.py,sha256=oy-a0yGNVuJGjUn-WAHT4NJTTIBBlSbyrgOzGKOkkp0,5852
|
|
5
|
+
trawl_api/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
6
|
+
trawl_api/ebay.py,sha256=2_bQShPcGI4nV7WHuSdLFsw99lq0bsPwlZ5Sv4Ejt_U,7732
|
|
7
|
+
trawl_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
trawl_api-0.1.0.dist-info/METADATA,sha256=lHc4nLQRVq4M3vG0uvjXSvvuD47wHOxnzk4HTQqFkTM,8813
|
|
9
|
+
trawl_api-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
trawl_api-0.1.0.dist-info/licenses/LICENSE,sha256=SnfM7NJTMZQQ-YkAct4d2KlWjM-cqIiRY04dAkLcfiI,1062
|
|
11
|
+
trawl_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 trawl
|
|
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.
|