keepa 1.4.0__tar.gz → 1.4.2__tar.gz
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.
- {keepa-1.4.0 → keepa-1.4.2}/PKG-INFO +1 -1
- {keepa-1.4.0 → keepa-1.4.2}/pyproject.toml +1 -1
- {keepa-1.4.0 → keepa-1.4.2}/src/keepa/__init__.py +7 -10
- keepa-1.4.2/src/keepa/constants.py +78 -0
- keepa-1.4.2/src/keepa/keepa_async.py +566 -0
- keepa-1.4.0/src/keepa/interface.py → keepa-1.4.2/src/keepa/keepa_sync.py +35 -1052
- keepa-1.4.2/src/keepa/models/domain.py +29 -0
- keepa-1.4.2/src/keepa/models/product_params.py +1400 -0
- keepa-1.4.2/src/keepa/models/status.py +13 -0
- {keepa-1.4.0 → keepa-1.4.2}/src/keepa/plotting.py +1 -1
- keepa-1.4.2/src/keepa/utils.py +435 -0
- keepa-1.4.0/src/keepa/data_models.py +0 -1132
- {keepa-1.4.0 → keepa-1.4.2}/LICENSE +0 -0
- {keepa-1.4.0 → keepa-1.4.2}/README.rst +0 -0
- {keepa-1.4.0 → keepa-1.4.2}/src/keepa/py.typed +0 -0
- {keepa-1.4.0 → keepa-1.4.2}/src/keepa/query_keys.py +0 -0
|
@@ -8,23 +8,20 @@ try:
|
|
|
8
8
|
except PackageNotFoundError:
|
|
9
9
|
__version__ = "unknown"
|
|
10
10
|
|
|
11
|
-
from keepa.
|
|
12
|
-
from keepa.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
Keepa,
|
|
11
|
+
from keepa.constants import DCODES, KEEPA_ST_ORDINAL, SCODES, csv_indices
|
|
12
|
+
from keepa.keepa_async import AsyncKeepa
|
|
13
|
+
from keepa.keepa_sync import Keepa
|
|
14
|
+
from keepa.models.domain import Domain
|
|
15
|
+
from keepa.models.product_params import ProductParams
|
|
16
|
+
from keepa.plotting import plot_product
|
|
17
|
+
from keepa.utils import (
|
|
19
18
|
convert_offer_history,
|
|
20
|
-
csv_indices,
|
|
21
19
|
format_items,
|
|
22
20
|
keepa_minutes_to_time,
|
|
23
21
|
parse_csv,
|
|
24
22
|
process_used_buybox,
|
|
25
23
|
run_and_get,
|
|
26
24
|
)
|
|
27
|
-
from keepa.plotting import plot_product
|
|
28
25
|
|
|
29
26
|
__all__ = [
|
|
30
27
|
"AsyncKeepa",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Constants for Keepa API interactions."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
# hardcoded ordinal time from
|
|
6
|
+
KEEPA_ST_ORDINAL = np.datetime64("2011-01-01")
|
|
7
|
+
|
|
8
|
+
# Request limit
|
|
9
|
+
REQUEST_LIMIT = 100
|
|
10
|
+
|
|
11
|
+
# Status code dictionary/key
|
|
12
|
+
SCODES = {
|
|
13
|
+
"400": "REQUEST_REJECTED",
|
|
14
|
+
"402": "PAYMENT_REQUIRED",
|
|
15
|
+
"405": "METHOD_NOT_ALLOWED",
|
|
16
|
+
"429": "NOT_ENOUGH_TOKEN",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# domain codes
|
|
20
|
+
# Valid values: [ 1: com | 2: co.uk | 3: de | 4: fr | 5:
|
|
21
|
+
# co.jp | 6: ca | 7: cn | 8: it | 9: es | 10: in | 11: com.mx | 12: com.br ]
|
|
22
|
+
DCODES = [
|
|
23
|
+
"RESERVED",
|
|
24
|
+
"US",
|
|
25
|
+
"GB",
|
|
26
|
+
"DE",
|
|
27
|
+
"FR",
|
|
28
|
+
"JP",
|
|
29
|
+
"CA",
|
|
30
|
+
"CN",
|
|
31
|
+
"IT",
|
|
32
|
+
"ES",
|
|
33
|
+
"IN",
|
|
34
|
+
"MX",
|
|
35
|
+
"BR",
|
|
36
|
+
]
|
|
37
|
+
# developer note: appears like CN (China) has changed to RESERVED2
|
|
38
|
+
|
|
39
|
+
# csv indices. used when parsing csv and stats fields.
|
|
40
|
+
# https://github.com/keepacom/api_backend
|
|
41
|
+
# see api_backend/src/main/java/com/keepa/api/backend/structs/Product.java
|
|
42
|
+
# [index in csv, key name, isfloat(is price or rating)]
|
|
43
|
+
csv_indices: list[tuple[int, str, bool]] = [
|
|
44
|
+
(0, "AMAZON", True),
|
|
45
|
+
(1, "NEW", True),
|
|
46
|
+
(2, "USED", True),
|
|
47
|
+
(3, "SALES", False),
|
|
48
|
+
(4, "LISTPRICE", True),
|
|
49
|
+
(5, "COLLECTIBLE", True),
|
|
50
|
+
(6, "REFURBISHED", True),
|
|
51
|
+
(7, "NEW_FBM_SHIPPING", True),
|
|
52
|
+
(8, "LIGHTNING_DEAL", True),
|
|
53
|
+
(9, "WAREHOUSE", True),
|
|
54
|
+
(10, "NEW_FBA", True),
|
|
55
|
+
(11, "COUNT_NEW", False),
|
|
56
|
+
(12, "COUNT_USED", False),
|
|
57
|
+
(13, "COUNT_REFURBISHED", False),
|
|
58
|
+
(14, "CollectableOffers", False),
|
|
59
|
+
(15, "EXTRA_INFO_UPDATES", False),
|
|
60
|
+
(16, "RATING", True),
|
|
61
|
+
(17, "COUNT_REVIEWS", False),
|
|
62
|
+
(18, "BUY_BOX_SHIPPING", True),
|
|
63
|
+
(19, "USED_NEW_SHIPPING", True),
|
|
64
|
+
(20, "USED_VERY_GOOD_SHIPPING", True),
|
|
65
|
+
(21, "USED_GOOD_SHIPPING", True),
|
|
66
|
+
(22, "USED_ACCEPTABLE_SHIPPING", True),
|
|
67
|
+
(23, "COLLECTIBLE_NEW_SHIPPING", True),
|
|
68
|
+
(24, "COLLECTIBLE_VERY_GOOD_SHIPPING", True),
|
|
69
|
+
(25, "COLLECTIBLE_GOOD_SHIPPING", True),
|
|
70
|
+
(26, "COLLECTIBLE_ACCEPTABLE_SHIPPING", True),
|
|
71
|
+
(27, "REFURBISHED_SHIPPING", True),
|
|
72
|
+
(28, "EBAY_NEW_SHIPPING", True),
|
|
73
|
+
(29, "EBAY_USED_SHIPPING", True),
|
|
74
|
+
(30, "TRADE_IN", True),
|
|
75
|
+
(31, "RENT", False),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
_SELLER_TIME_DATA_KEYS = ["trackedSince", "lastUpdate"]
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""Interface module to download Amazon product and history data from keepa.com asynchronously."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
from tqdm import tqdm
|
|
13
|
+
|
|
14
|
+
from keepa.constants import SCODES
|
|
15
|
+
from keepa.keepa_sync import Keepa
|
|
16
|
+
from keepa.models.domain import Domain
|
|
17
|
+
from keepa.models.product_params import ProductParams
|
|
18
|
+
from keepa.models.status import Status
|
|
19
|
+
from keepa.query_keys import DEAL_REQUEST_KEYS
|
|
20
|
+
from keepa.utils import (
|
|
21
|
+
_domain_to_dcode,
|
|
22
|
+
_parse_seller,
|
|
23
|
+
_parse_stats,
|
|
24
|
+
format_items,
|
|
25
|
+
is_documented_by,
|
|
26
|
+
parse_csv,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Request limit
|
|
33
|
+
REQUEST_LIMIT = 100
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncKeepa:
|
|
37
|
+
r"""
|
|
38
|
+
Asynchronous Python interface to keepa backend.
|
|
39
|
+
|
|
40
|
+
Initializes API with access key. Access key can be obtained by signing up
|
|
41
|
+
for a reoccurring or one time plan. To obtain a key, sign up for one at
|
|
42
|
+
`Keepa Data <https://get.keepa.com/d7vrq>`_.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
accesskey : str
|
|
47
|
+
64 character access key string.
|
|
48
|
+
timeout : float, default: 10.0
|
|
49
|
+
Default timeout when issuing any request. This is not a time
|
|
50
|
+
limit on the entire response download; rather, an exception is
|
|
51
|
+
raised if the server has not issued a response for timeout
|
|
52
|
+
seconds. Setting this to 0.0 disables the timeout, but will
|
|
53
|
+
cause any request to hang indefiantly should keepa.com be down
|
|
54
|
+
|
|
55
|
+
Examples
|
|
56
|
+
--------
|
|
57
|
+
Query for all of Jim Butcher's books using the asynchronous
|
|
58
|
+
``keepa.AsyncKeepa`` class.
|
|
59
|
+
|
|
60
|
+
>>> import asyncio
|
|
61
|
+
>>> import keepa
|
|
62
|
+
>>> product_parms = {"author": "jim butcher"}
|
|
63
|
+
>>> async def main():
|
|
64
|
+
... key = "<REAL_KEEPA_KEY>"
|
|
65
|
+
... api = await keepa.AsyncKeepa().create(key)
|
|
66
|
+
... return await api.product_finder(product_parms)
|
|
67
|
+
...
|
|
68
|
+
>>> asins = asyncio.run(main())
|
|
69
|
+
>>> asins
|
|
70
|
+
['B000HRMAR2',
|
|
71
|
+
'0578799790',
|
|
72
|
+
'B07PW1SVHM',
|
|
73
|
+
...
|
|
74
|
+
'B003MXM744',
|
|
75
|
+
'0133235750',
|
|
76
|
+
'B01MXXLJPZ']
|
|
77
|
+
|
|
78
|
+
Query for product with ASIN ``'B0088PUEPK'`` using the asynchronous
|
|
79
|
+
keepa interface.
|
|
80
|
+
|
|
81
|
+
>>> import asyncio
|
|
82
|
+
>>> import keepa
|
|
83
|
+
>>> async def main():
|
|
84
|
+
... key = "<REAL_KEEPA_KEY>"
|
|
85
|
+
... api = await keepa.AsyncKeepa().create(key)
|
|
86
|
+
... return await api.query("B0088PUEPK")
|
|
87
|
+
...
|
|
88
|
+
>>> response = asyncio.run(main())
|
|
89
|
+
>>> response[0]["title"]
|
|
90
|
+
'Western Digital 1TB WD Blue PC Internal Hard Drive HDD - 7200 RPM,
|
|
91
|
+
SATA 6 Gb/s, 64 MB Cache, 3.5" - WD10EZEX'
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
accesskey: str
|
|
96
|
+
tokens_left: int
|
|
97
|
+
status: Status
|
|
98
|
+
_timeout: float
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
async def create(cls, accesskey: str, timeout: float = 10.0) -> "AsyncKeepa":
|
|
102
|
+
"""Create the async object."""
|
|
103
|
+
self = AsyncKeepa()
|
|
104
|
+
self.accesskey = accesskey
|
|
105
|
+
self.tokens_left = 0
|
|
106
|
+
self._timeout = timeout
|
|
107
|
+
|
|
108
|
+
# don't update the user status on init
|
|
109
|
+
self.status = Status()
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def time_to_refill(self) -> float:
|
|
114
|
+
"""Return the time to refill in seconds."""
|
|
115
|
+
# Get current timestamp in milliseconds from UNIX epoch
|
|
116
|
+
now = int(time.time() * 1000)
|
|
117
|
+
time_at_refill = self.status.timestamp + self.status.refillIn
|
|
118
|
+
|
|
119
|
+
# wait plus one second fudge factor
|
|
120
|
+
time_to_refill = time_at_refill - now + 1000
|
|
121
|
+
if time_to_refill < 0:
|
|
122
|
+
time_to_refill = 0
|
|
123
|
+
|
|
124
|
+
# Account for negative tokens left
|
|
125
|
+
if self.tokens_left < 0:
|
|
126
|
+
time_to_refill += (abs(self.tokens_left) / self.status.refillRate) * 60000
|
|
127
|
+
|
|
128
|
+
# Return value in seconds
|
|
129
|
+
return time_to_refill / 1000.0
|
|
130
|
+
|
|
131
|
+
async def update_status(self) -> None:
|
|
132
|
+
"""Update available tokens."""
|
|
133
|
+
self.status = await self._request("token", {"key": self.accesskey}, wait=False)
|
|
134
|
+
|
|
135
|
+
async def wait_for_tokens(self) -> None:
|
|
136
|
+
"""Check if there are any remaining tokens and waits if none are available."""
|
|
137
|
+
await self.update_status()
|
|
138
|
+
|
|
139
|
+
# Wait if no tokens available
|
|
140
|
+
if self.tokens_left <= 0:
|
|
141
|
+
tdelay = self.time_to_refill
|
|
142
|
+
log.warning("Waiting %.0f seconds for additional tokens", tdelay)
|
|
143
|
+
await asyncio.sleep(tdelay)
|
|
144
|
+
await self.update_status()
|
|
145
|
+
|
|
146
|
+
@is_documented_by(Keepa.query)
|
|
147
|
+
async def query(
|
|
148
|
+
self,
|
|
149
|
+
items: str | Sequence[str],
|
|
150
|
+
stats: int | None = None,
|
|
151
|
+
domain: str = "US",
|
|
152
|
+
history: bool = True,
|
|
153
|
+
offers: int | None = None,
|
|
154
|
+
update: int | None = None,
|
|
155
|
+
to_datetime: bool = True,
|
|
156
|
+
rating: bool = False,
|
|
157
|
+
out_of_stock_as_nan: bool = True,
|
|
158
|
+
stock: bool = False,
|
|
159
|
+
product_code_is_asin: bool = True,
|
|
160
|
+
progress_bar: bool = True,
|
|
161
|
+
buybox: bool = False,
|
|
162
|
+
wait: bool = True,
|
|
163
|
+
days: int | None = None,
|
|
164
|
+
only_live_offers: bool | None = None,
|
|
165
|
+
raw: bool = False,
|
|
166
|
+
videos: bool = False,
|
|
167
|
+
aplus: bool = False,
|
|
168
|
+
extra_params: dict[str, Any] | None = None,
|
|
169
|
+
):
|
|
170
|
+
"""Documented in Keepa.query."""
|
|
171
|
+
if raw:
|
|
172
|
+
raise ValueError("Raw response is only available in the non-async class")
|
|
173
|
+
|
|
174
|
+
if extra_params is None:
|
|
175
|
+
extra_params = {}
|
|
176
|
+
|
|
177
|
+
# Format items into numpy array
|
|
178
|
+
try:
|
|
179
|
+
items = format_items(items)
|
|
180
|
+
except BaseException:
|
|
181
|
+
raise Exception("Invalid product codes input")
|
|
182
|
+
assert len(items), "No valid product codes"
|
|
183
|
+
|
|
184
|
+
nitems = len(items)
|
|
185
|
+
if nitems == 1:
|
|
186
|
+
log.debug("Executing single product query")
|
|
187
|
+
else:
|
|
188
|
+
log.debug("Executing %d item product query", nitems)
|
|
189
|
+
|
|
190
|
+
# check offer input
|
|
191
|
+
if offers:
|
|
192
|
+
if not isinstance(offers, int):
|
|
193
|
+
raise TypeError('Parameter "offers" must be an interger')
|
|
194
|
+
|
|
195
|
+
if offers > 100 or offers < 20:
|
|
196
|
+
raise ValueError('Parameter "offers" must be between 20 and 100')
|
|
197
|
+
|
|
198
|
+
# Report time to completion
|
|
199
|
+
if self.status.refillRate is not None and self.status.refillIn is not None:
|
|
200
|
+
tcomplete = (
|
|
201
|
+
float(nitems - self.tokens_left) / self.status.refillRate
|
|
202
|
+
- (60000 - self.status.refillIn) / 60000.0
|
|
203
|
+
)
|
|
204
|
+
if tcomplete < 0.0:
|
|
205
|
+
tcomplete = 0.5
|
|
206
|
+
log.debug(
|
|
207
|
+
"Estimated time to complete %d request(s) is %.2f minutes",
|
|
208
|
+
nitems,
|
|
209
|
+
tcomplete,
|
|
210
|
+
)
|
|
211
|
+
log.debug("\twith a refill rate of %d token(s) per minute", self.status.refillRate)
|
|
212
|
+
|
|
213
|
+
# product list
|
|
214
|
+
products = []
|
|
215
|
+
|
|
216
|
+
pbar = None
|
|
217
|
+
if progress_bar:
|
|
218
|
+
pbar = tqdm(total=nitems)
|
|
219
|
+
|
|
220
|
+
# Number of requests is dependent on the number of items and
|
|
221
|
+
# request limit. Use available tokens first
|
|
222
|
+
idx = 0 # or number complete
|
|
223
|
+
while idx < nitems:
|
|
224
|
+
nrequest = nitems - idx
|
|
225
|
+
|
|
226
|
+
# cap request
|
|
227
|
+
if nrequest > REQUEST_LIMIT:
|
|
228
|
+
nrequest = REQUEST_LIMIT
|
|
229
|
+
|
|
230
|
+
# request from keepa and increment current position
|
|
231
|
+
item_request = items[idx : idx + nrequest] # noqa: E203
|
|
232
|
+
response = await self._product_query(
|
|
233
|
+
item_request,
|
|
234
|
+
product_code_is_asin,
|
|
235
|
+
stats=stats,
|
|
236
|
+
domain=domain,
|
|
237
|
+
stock=stock,
|
|
238
|
+
offers=offers,
|
|
239
|
+
update=update,
|
|
240
|
+
history=history,
|
|
241
|
+
rating=rating,
|
|
242
|
+
to_datetime=to_datetime,
|
|
243
|
+
out_of_stock_as_nan=out_of_stock_as_nan,
|
|
244
|
+
buybox=buybox,
|
|
245
|
+
wait=wait,
|
|
246
|
+
days=days,
|
|
247
|
+
only_live_offers=only_live_offers,
|
|
248
|
+
videos=videos,
|
|
249
|
+
aplus=aplus,
|
|
250
|
+
**extra_params,
|
|
251
|
+
)
|
|
252
|
+
idx += nrequest
|
|
253
|
+
products.extend(response["products"])
|
|
254
|
+
|
|
255
|
+
if pbar is not None:
|
|
256
|
+
pbar.update(nrequest)
|
|
257
|
+
|
|
258
|
+
return products
|
|
259
|
+
|
|
260
|
+
@is_documented_by(Keepa._product_query)
|
|
261
|
+
async def _product_query(self, items, product_code_is_asin=True, **kwargs):
|
|
262
|
+
"""Documented in Keepa._product_query."""
|
|
263
|
+
# ASINs convert to comma joined string
|
|
264
|
+
assert len(items) <= 100
|
|
265
|
+
|
|
266
|
+
if product_code_is_asin:
|
|
267
|
+
kwargs["asin"] = ",".join(items)
|
|
268
|
+
else:
|
|
269
|
+
kwargs["code"] = ",".join(items)
|
|
270
|
+
|
|
271
|
+
kwargs["key"] = self.accesskey
|
|
272
|
+
kwargs["domain"] = _domain_to_dcode(kwargs["domain"])
|
|
273
|
+
|
|
274
|
+
# Convert bool values to 0 and 1.
|
|
275
|
+
kwargs["stock"] = int(kwargs["stock"])
|
|
276
|
+
kwargs["history"] = int(kwargs["history"])
|
|
277
|
+
kwargs["rating"] = int(kwargs["rating"])
|
|
278
|
+
kwargs["buybox"] = int(kwargs["buybox"])
|
|
279
|
+
|
|
280
|
+
if kwargs["update"] is None:
|
|
281
|
+
del kwargs["update"]
|
|
282
|
+
else:
|
|
283
|
+
kwargs["update"] = int(kwargs["update"])
|
|
284
|
+
|
|
285
|
+
if kwargs["offers"] is None:
|
|
286
|
+
del kwargs["offers"]
|
|
287
|
+
else:
|
|
288
|
+
kwargs["offers"] = int(kwargs["offers"])
|
|
289
|
+
|
|
290
|
+
if kwargs["only_live_offers"] is None:
|
|
291
|
+
del kwargs["only_live_offers"]
|
|
292
|
+
else:
|
|
293
|
+
kwargs["only-live-offers"] = int(kwargs.pop("only_live_offers"))
|
|
294
|
+
# Keepa's param actually doesn't use snake_case.
|
|
295
|
+
# Keeping with snake case for consistency
|
|
296
|
+
|
|
297
|
+
if kwargs["days"] is None:
|
|
298
|
+
del kwargs["days"]
|
|
299
|
+
else:
|
|
300
|
+
assert kwargs["days"] > 0
|
|
301
|
+
|
|
302
|
+
if kwargs["stats"] is None:
|
|
303
|
+
del kwargs["stats"]
|
|
304
|
+
|
|
305
|
+
# videos and aplus must be ints
|
|
306
|
+
kwargs["videos"] = int(kwargs["videos"])
|
|
307
|
+
kwargs["aplus"] = int(kwargs["aplus"])
|
|
308
|
+
|
|
309
|
+
out_of_stock_as_nan = kwargs.pop("out_of_stock_as_nan", True)
|
|
310
|
+
to_datetime = kwargs.pop("to_datetime", True)
|
|
311
|
+
|
|
312
|
+
# Query and replace csv with parsed data if history enabled
|
|
313
|
+
wait = kwargs.get("wait")
|
|
314
|
+
kwargs.pop("wait", None)
|
|
315
|
+
|
|
316
|
+
raw_response = kwargs.pop("raw", False)
|
|
317
|
+
response = await self._request("product", kwargs, wait=wait, raw_response=raw_response)
|
|
318
|
+
if kwargs["history"]:
|
|
319
|
+
if "products" not in response:
|
|
320
|
+
raise RuntimeError("No products in response. Possibly invalid ASINs")
|
|
321
|
+
|
|
322
|
+
for product in response["products"]:
|
|
323
|
+
if product["csv"]: # if data exists
|
|
324
|
+
product["data"] = parse_csv(product["csv"], to_datetime, out_of_stock_as_nan)
|
|
325
|
+
|
|
326
|
+
if kwargs.get("stats", None):
|
|
327
|
+
for product in response["products"]:
|
|
328
|
+
stats = product.get("stats", None)
|
|
329
|
+
if stats:
|
|
330
|
+
product["stats_parsed"] = _parse_stats(stats, to_datetime)
|
|
331
|
+
|
|
332
|
+
return response
|
|
333
|
+
|
|
334
|
+
@is_documented_by(Keepa.best_sellers_query)
|
|
335
|
+
async def best_sellers_query(
|
|
336
|
+
self,
|
|
337
|
+
category: str,
|
|
338
|
+
rank_avg_range: Literal[0, 30, 90, 180] = 0,
|
|
339
|
+
variations: bool = False,
|
|
340
|
+
sublist: bool = False,
|
|
341
|
+
domain: str | Domain = "US",
|
|
342
|
+
wait: bool = True,
|
|
343
|
+
):
|
|
344
|
+
"""Documented by Keepa.best_sellers_query."""
|
|
345
|
+
payload = {
|
|
346
|
+
"key": self.accesskey,
|
|
347
|
+
"domain": _domain_to_dcode(domain),
|
|
348
|
+
"variations": int(variations),
|
|
349
|
+
"sublist": int(sublist),
|
|
350
|
+
"category": category,
|
|
351
|
+
"range": rank_avg_range,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
response = await self._request("bestsellers", payload, wait=wait)
|
|
355
|
+
if "bestSellersList" not in response:
|
|
356
|
+
raise RuntimeError(f"Best sellers search results for {category} not yet available")
|
|
357
|
+
return response["bestSellersList"]["asinList"]
|
|
358
|
+
|
|
359
|
+
@is_documented_by(Keepa.search_for_categories)
|
|
360
|
+
async def search_for_categories(
|
|
361
|
+
self, searchterm, domain: str | Domain = "US", wait: bool = True
|
|
362
|
+
):
|
|
363
|
+
"""Documented by Keepa.search_for_categories."""
|
|
364
|
+
payload = {
|
|
365
|
+
"key": self.accesskey,
|
|
366
|
+
"domain": _domain_to_dcode(domain),
|
|
367
|
+
"type": "category",
|
|
368
|
+
"term": searchterm,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
response = await self._request("search", payload, wait=wait)
|
|
372
|
+
if response["categories"] == {}: # pragma no cover
|
|
373
|
+
raise Exception(
|
|
374
|
+
"Categories search results not yet available " + "or no search terms found."
|
|
375
|
+
)
|
|
376
|
+
else:
|
|
377
|
+
return response["categories"]
|
|
378
|
+
|
|
379
|
+
@is_documented_by(Keepa.category_lookup)
|
|
380
|
+
async def category_lookup(
|
|
381
|
+
self,
|
|
382
|
+
category_id,
|
|
383
|
+
domain: str | Domain = "US",
|
|
384
|
+
include_parents=0,
|
|
385
|
+
wait: bool = True,
|
|
386
|
+
):
|
|
387
|
+
"""Documented by Keepa.category_lookup."""
|
|
388
|
+
payload = {
|
|
389
|
+
"key": self.accesskey,
|
|
390
|
+
"domain": _domain_to_dcode(domain),
|
|
391
|
+
"category": category_id,
|
|
392
|
+
"parents": include_parents,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
response = await self._request("category", payload, wait=wait)
|
|
396
|
+
if response["categories"] == {}: # pragma no cover
|
|
397
|
+
raise Exception("Category lookup results not yet available or no" + "match found.")
|
|
398
|
+
else:
|
|
399
|
+
return response["categories"]
|
|
400
|
+
|
|
401
|
+
@is_documented_by(Keepa.seller_query)
|
|
402
|
+
async def seller_query(
|
|
403
|
+
self,
|
|
404
|
+
seller_id,
|
|
405
|
+
domain: str | Domain = "US",
|
|
406
|
+
to_datetime=True,
|
|
407
|
+
storefront=False,
|
|
408
|
+
update=None,
|
|
409
|
+
wait: bool = True,
|
|
410
|
+
):
|
|
411
|
+
"""Documented by Keepa.sellerer_query."""
|
|
412
|
+
if isinstance(seller_id, list):
|
|
413
|
+
if len(seller_id) > 100:
|
|
414
|
+
err_str = "seller_id can contain at maximum 100 sellers"
|
|
415
|
+
raise RuntimeError(err_str)
|
|
416
|
+
seller = ",".join(seller_id)
|
|
417
|
+
else:
|
|
418
|
+
seller = seller_id
|
|
419
|
+
|
|
420
|
+
payload = {
|
|
421
|
+
"key": self.accesskey,
|
|
422
|
+
"domain": _domain_to_dcode(domain),
|
|
423
|
+
"seller": seller,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if storefront:
|
|
427
|
+
payload["storefront"] = int(storefront)
|
|
428
|
+
if update:
|
|
429
|
+
payload["update"] = update
|
|
430
|
+
|
|
431
|
+
response = await self._request("seller", payload, wait=wait)
|
|
432
|
+
return _parse_seller(response["sellers"], to_datetime)
|
|
433
|
+
|
|
434
|
+
@is_documented_by(Keepa.product_finder)
|
|
435
|
+
async def product_finder(
|
|
436
|
+
self,
|
|
437
|
+
product_parms: dict[str, Any] | ProductParams,
|
|
438
|
+
domain: str | Domain = "US",
|
|
439
|
+
wait: bool = True,
|
|
440
|
+
n_products: int = 50,
|
|
441
|
+
) -> list[str]:
|
|
442
|
+
"""Documented by Keepa.product_finder."""
|
|
443
|
+
if isinstance(product_parms, dict):
|
|
444
|
+
product_parms_valid = ProductParams(**product_parms)
|
|
445
|
+
else:
|
|
446
|
+
product_parms_valid = product_parms
|
|
447
|
+
product_parms_dict = product_parms_valid.model_dump(exclude_none=True)
|
|
448
|
+
product_parms_dict.setdefault("perPage", n_products)
|
|
449
|
+
payload = {
|
|
450
|
+
"key": self.accesskey,
|
|
451
|
+
"domain": _domain_to_dcode(domain),
|
|
452
|
+
"selection": json.dumps(product_parms_dict),
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
response = await self._request("query", payload, wait=wait)
|
|
456
|
+
return response["asinList"]
|
|
457
|
+
|
|
458
|
+
@is_documented_by(Keepa.deals)
|
|
459
|
+
async def deals(self, deal_parms, domain: str | Domain = "US", wait: bool = True):
|
|
460
|
+
"""Documented in Keepa.deals."""
|
|
461
|
+
# verify valid keys
|
|
462
|
+
for key in deal_parms:
|
|
463
|
+
if key not in DEAL_REQUEST_KEYS:
|
|
464
|
+
raise ValueError(f'Invalid key "{key}"')
|
|
465
|
+
|
|
466
|
+
# verify json type
|
|
467
|
+
key_type = DEAL_REQUEST_KEYS[key]
|
|
468
|
+
deal_parms[key] = key_type(deal_parms[key])
|
|
469
|
+
|
|
470
|
+
deal_parms.setdefault("priceTypes", 0)
|
|
471
|
+
|
|
472
|
+
payload = {
|
|
473
|
+
"key": self.accesskey,
|
|
474
|
+
"domain": _domain_to_dcode(domain),
|
|
475
|
+
"selection": json.dumps(deal_parms),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
deals = await self._request("deal", payload, wait=wait)
|
|
479
|
+
return deals["deals"]
|
|
480
|
+
|
|
481
|
+
@is_documented_by(Keepa.download_graph_image)
|
|
482
|
+
async def download_graph_image(
|
|
483
|
+
self,
|
|
484
|
+
asin: str,
|
|
485
|
+
filename: str | Path,
|
|
486
|
+
domain: str | Domain = "US",
|
|
487
|
+
wait: bool = True,
|
|
488
|
+
**graph_kwargs: dict[str, Any],
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Documented in Keepa.download_graph_image."""
|
|
491
|
+
payload = {
|
|
492
|
+
"asin": asin,
|
|
493
|
+
"key": self.accesskey,
|
|
494
|
+
"domain": _domain_to_dcode(domain),
|
|
495
|
+
}
|
|
496
|
+
payload.update(graph_kwargs)
|
|
497
|
+
|
|
498
|
+
async with aiohttp.ClientSession() as session:
|
|
499
|
+
async with session.get(
|
|
500
|
+
"https://api.keepa.com/graphimage",
|
|
501
|
+
params=payload,
|
|
502
|
+
timeout=self._timeout,
|
|
503
|
+
) as resp:
|
|
504
|
+
first_chunk = True
|
|
505
|
+
filename = Path(filename)
|
|
506
|
+
with open(filename, "wb") as f:
|
|
507
|
+
async for chunk in resp.content.iter_chunked(8192):
|
|
508
|
+
if first_chunk:
|
|
509
|
+
if not chunk.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
510
|
+
raise ValueError(
|
|
511
|
+
"Response from api.keepa.com/graphimage is not a valid "
|
|
512
|
+
"PNG image"
|
|
513
|
+
)
|
|
514
|
+
first_chunk = False
|
|
515
|
+
f.write(chunk)
|
|
516
|
+
|
|
517
|
+
async def _request(
|
|
518
|
+
self,
|
|
519
|
+
request_type: str,
|
|
520
|
+
payload: dict[str, Any],
|
|
521
|
+
wait: bool = True,
|
|
522
|
+
raw_response: bool = False,
|
|
523
|
+
is_json: bool = True,
|
|
524
|
+
):
|
|
525
|
+
"""Documented in Keepa._request."""
|
|
526
|
+
while True:
|
|
527
|
+
async with aiohttp.ClientSession() as session:
|
|
528
|
+
async with session.get(
|
|
529
|
+
f"https://api.keepa.com/{request_type}/?",
|
|
530
|
+
params=payload,
|
|
531
|
+
timeout=self._timeout,
|
|
532
|
+
) as raw:
|
|
533
|
+
status_code = str(raw.status)
|
|
534
|
+
|
|
535
|
+
if not is_json:
|
|
536
|
+
return raw
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
response = await raw.json()
|
|
540
|
+
except Exception:
|
|
541
|
+
raise RuntimeError(f"Invalid JSON from Keepa API (status {status_code})")
|
|
542
|
+
|
|
543
|
+
# user status is always returned
|
|
544
|
+
if "tokensLeft" in response:
|
|
545
|
+
self.tokens_left = response["tokensLeft"]
|
|
546
|
+
self.status.tokensLeft = self.tokens_left
|
|
547
|
+
log.info("%d tokens remain", self.tokens_left)
|
|
548
|
+
for key in ["refillIn", "refillRate", "timestamp"]:
|
|
549
|
+
if key in response:
|
|
550
|
+
setattr(self.status, key, response[key])
|
|
551
|
+
|
|
552
|
+
if status_code == "200":
|
|
553
|
+
if raw_response:
|
|
554
|
+
return raw
|
|
555
|
+
return response
|
|
556
|
+
|
|
557
|
+
if status_code == "429" and wait:
|
|
558
|
+
tdelay = self.time_to_refill
|
|
559
|
+
log.warning("Waiting %.0f seconds for additional tokens", tdelay)
|
|
560
|
+
time.sleep(tdelay)
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
# otherwise, it's an error code
|
|
564
|
+
if status_code in SCODES:
|
|
565
|
+
raise RuntimeError(SCODES[status_code])
|
|
566
|
+
raise RuntimeError(f"REQUEST_FAILED. Status code: {status_code}")
|