diff --git a/docs/source/deal_queries.rst b/docs/source/deal_queries.rst index f97949d..47129d7 100644 --- a/docs/source/deal_queries.rst +++ b/docs/source/deal_queries.rst @@ -34,6 +34,25 @@ Use :class:`keepa.models.backend.DealRequest` to construct the request and response = api.deals(request, typed=True) asins = [deal.asin for deal in response.dr or [] if deal is not None] +Deal Deltas and Previous Values +------------------------------- +Deal arrays such as ``current``, ``delta``, ``deltaPercent``, and +``deltaLast`` are indexed by Keepa's product CSV type order. Use +``keepa.csv_indices`` to map a price type name to the array position. + +.. code-block:: python + + deal = response.dr[0] + csv_index = next(index for index, name, _ in keepa.csv_indices if name == "NEW") + + current_new = deal.current[csv_index] + change_from_last = deal.deltaLast[csv_index] + previous_new = current_new - change_from_last + +Only compute a previous value when both array entries are present and Keepa +supplies a signed delta for that price type. Keepa uses sentinel values such +as ``-1`` for unavailable prices, so filter those values before arithmetic. + Async Usage ----------- diff --git a/docs/source/index.rst b/docs/source/index.rst index d6fd033..7729df5 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -114,7 +114,7 @@ when you want products that recently changed and match deal filters. params = keepa.ProductParams( author="jim butcher", current_SALES_lte=50_000, - sort=["current_SALES", "asc"], + sort=[["current_SALES", "asc"]], perPage=100, ) asins = api.product_finder(params) diff --git a/docs/source/offer_queries.rst b/docs/source/offer_queries.rst index 9def21e..3489397 100644 --- a/docs/source/offer_queries.rst +++ b/docs/source/offer_queries.rst @@ -37,6 +37,25 @@ offers. Not every historical offer is active, and the field can be absent. for offer in active_offers: times, prices = keepa.convert_offer_history(offer["offerCSV"]) +Offer Stock +----------- +Set ``stock=True`` together with ``offers`` when you need current offer stock +data. Keepa can only collect stock for live offers, and the backend caps +reported offer stock at 10 units. + +.. code-block:: python + + product = api.query("1454857935", offers=20, stock=True)[0] + + for index in product.get("liveOffersOrder", []): + offer = product["offers"][index] + stock_history = offer.get("stockCSV") + latest_stock = offer.get("stock") + +Existing ``stockCSV`` history can be present even when ``stock=True`` is not +set, but ``stock=True`` asks Keepa to refresh stock for the current live +offers and consumes additional tokens. + .. figure:: images/Offer_History.png :alt: Active marketplace offer histories :width: 700px diff --git a/docs/source/product_finder.rst b/docs/source/product_finder.rst index ce557f2..7c0bafa 100644 --- a/docs/source/product_finder.rst +++ b/docs/source/product_finder.rst @@ -11,11 +11,14 @@ Basic Search parameters = { "author": "jim butcher", - "sort": ["current_SALES", "asc"], + "sort": [["current_SALES", "asc"]], "perPage": 100, } asins = api.product_finder(parameters) +Sort values follow the backend schema: pass a list of ``[field, direction]`` +pairs, where direction is ``"asc"`` or ``"desc"``. + Validated Parameters -------------------- :class:`keepa.ProductParams` validates backend field names before a request @@ -25,7 +28,7 @@ consumes tokens. Unknown names raise a validation error. parameters = keepa.ProductParams( author="jim butcher", - sort=["current_SALES", "asc"], + sort=[["current_SALES", "asc"]], perPage=100, ) asins = api.product_finder(parameters) @@ -47,6 +50,26 @@ The backend exposes more than a thousand filters. Inspect them through ``ProductParams.model_json_schema()``, or ``keepa.backend_models.ProductFinderRequest.model_json_schema()``. +Backend Filter Shapes +--------------------- +Keep product-finder field names exactly as Keepa documents them. Range filters +use suffixes such as ``_gte`` and ``_lte``; list-like filters accept either a +single string or a list of strings when the backend supports multiple values. + +.. code-block:: python + + request = keepa.ProductParams( + buyBoxSellerId=["A2L77EE7U53NWQ", "ATVPDKIKX0DER"], + partNumber=["MX-1000", "MX-1001"], + current_SALES_lte=50000, + sort=[["current_SALES", "desc"]], + ) + asins = api.product_finder(request) + +This preserves backend payloads for fields such as ``buyBoxSellerId``, +``partNumber``, ``categories_include``, and ``sort`` instead of rewriting them +into client-specific names. + Result Limits and Pages ----------------------- ``n_products`` sets ``perPage`` only when the supplied parameters do not diff --git a/docs/source/product_history.rst b/docs/source/product_history.rst index c4ff420..951c497 100644 --- a/docs/source/product_history.rst +++ b/docs/source/product_history.rst @@ -7,7 +7,9 @@ under ``product["data"]``. Each available value array has a matching History Availability -------------------- Products do not necessarily contain every history type. Use ``get`` when -availability is not guaranteed. +availability is not guaranteed. A key can be absent when Keepa has no history +for that product, when the product type does not support that history, or when +the request disables history parsing with ``history=False``. .. code-block:: python @@ -37,6 +39,10 @@ Key Meaning ``COUNT_REVIEWS`` Review count history ========================== ================================================= +If ``NEW_FBA`` or ``NEW_FBM_SHIPPING`` is absent for a product, query with +``history=True`` and treat the missing key as unavailable backend data rather +than a client parsing failure. + Plotting -------- History values are discontinuous and are best represented as step plots. diff --git a/docs/source/product_query.rst b/docs/source/product_query.rst index 957986d..873e437 100644 --- a/docs/source/product_query.rst +++ b/docs/source/product_query.rst @@ -5,6 +5,20 @@ Keepa requests require a paid API key from `Keepa Data Access by default; use ``wait=False`` only when your application manages token availability itself. +Timeouts and Token Use +====================== +``Keepa(accesskey, timeout=...)`` controls how long the client waits for the +backend to send a response; it is not a total runtime limit for a large batch. +The client chunks product queries into backend-sized requests, but token cost +is calculated by Keepa and can be more than one token per ASIN when options +such as ``offers``, ``stock``, ``buybox``, ``rating``, ``stats``, or forced +updates are enabled. + +For large batches, keep ``wait=True``, use smaller chunks when debugging, and +inspect ``api.tokens_left`` or ``api.status`` between calls. If a request is +timing out, reduce expensive options first and then increase ``timeout`` only +when the backend legitimately needs longer to produce the requested data. + .. code-block:: python import keepa diff --git a/src/keepa/keepa_sync.py b/src/keepa/keepa_sync.py index da8236e..1aab0ac 100644 --- a/src/keepa/keepa_sync.py +++ b/src/keepa/keepa_sync.py @@ -1321,8 +1321,8 @@ def product_finder( Notes ----- When using the ``'sort'`` key in the ``product_parms`` parameter, use a - compatible key along with the type of sort. For example: - ``["current_SALES", "asc"]`` + list of ``[field, direction]`` pairs. For example: + ``[["current_SALES", "asc"]]`` Examples -------- @@ -1333,7 +1333,7 @@ def product_finder( >>> api = keepa.Keepa("") >>> product_parms = { ... "author": "jim butcher", - ... "sort": ["current_SALES", "asc"], + ... "sort": [["current_SALES", "asc"]], ... } >>> asins = api.product_finder(product_parms, n_products=100) >>> asins @@ -1349,7 +1349,7 @@ def product_finder( >>> product_parms = keepa.ProductParams( ... author="jim butcher", - ... sort=["current_SALES", "asc"], + ... sort=[["current_SALES", "asc"]], ... ) >>> asins = api.product_finder(product_parms, n_products=100) diff --git a/tests/test_backend_models.py b/tests/test_backend_models.py index dc30767..3460d4c 100644 --- a/tests/test_backend_models.py +++ b/tests/test_backend_models.py @@ -201,6 +201,36 @@ def fake_request(request_type: str, payload: dict[str, Any], **kwargs: Any) -> d assert asins == ["B000HRMAR2"] +def test_product_finder_preserves_backend_filter_shapes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _ready_api() + + def fake_request(request_type: str, payload: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + assert request_type == "query" + selection = json.loads(payload["selection"]) + assert selection["sort"] == [["current_SALES", "desc"]] + assert selection["buyBoxSellerId"] == ["A2L77EE7U53NWQ", "ATVPDKIKX0DER"] + assert selection["partNumber"] == ["MX-1000", "MX-1001"] + assert selection["categories_include"] == ["2619533011"] + assert selection["perPage"] == 75 + return {"asinList": ["B000HRMAR2"]} + + monkeypatch.setattr(api, "_request", fake_request) + + asins = api.product_finder( + { + "sort": [["current_SALES", "desc"]], + "buyBoxSellerId": ["A2L77EE7U53NWQ", "ATVPDKIKX0DER"], + "partNumber": ["MX-1000", "MX-1001"], + "categories_include": ["2619533011"], + "perPage": 75, + } + ) + + assert asins == ["B000HRMAR2"] + + def test_best_sellers_typed_response(monkeypatch: pytest.MonkeyPatch) -> None: api = _ready_api() diff --git a/tests/test_backend_schema.py b/tests/test_backend_schema.py index e289609..deb112b 100644 --- a/tests/test_backend_schema.py +++ b/tests/test_backend_schema.py @@ -445,14 +445,22 @@ def test_product_params_accept_backend_fields_and_reject_unknown_fields() -> Non availabilityAmazonMinDelayInDays_gte=2, buyBoxEligibleOfferCountsNewFBA_lte=[[1, 2]], buyBoxStatsTopSellerId365="A2L77EE7U53NWQ", + buyBoxSellerId=["A2L77EE7U53NWQ", "ATVPDKIKX0DER"], + categories_include=["2619533011"], hasAPlus=True, historicalSellerIds=["A2L77EE7U53NWQ"], + partNumber=["MX-1000", "MX-1001"], + sort=[["current_SALES", "desc"]], websiteDisplayGroup="kitchen_display_on_website", srAvg211_lte=1000, ) dumped = params.model_dump(exclude_none=True) assert dumped["activeIngredients"] == ["ceramide"] + assert dumped["buyBoxSellerId"] == ["A2L77EE7U53NWQ", "ATVPDKIKX0DER"] + assert dumped["categories_include"] == ["2619533011"] + assert dumped["partNumber"] == ["MX-1000", "MX-1001"] + assert dumped["sort"] == [["current_SALES", "desc"]] assert dumped["srAvg211_lte"] == 1000 with pytest.raises(ValueError):