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 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
+ [![PyPI](https://img.shields.io/pypi/v/trawl-api)](https://pypi.org/project/trawl-api/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/trawl-api)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.