├── src └── keepa │ ├── py.typed │ ├── models │ ├── status.py │ └── domain.py │ ├── __init__.py │ ├── constants.py │ ├── plotting.py │ ├── utils.py │ ├── keepa_async.py │ ├── query_keys.py │ └── keepa_sync.py ├── requirements_docs.txt ├── docs ├── source │ ├── _static │ │ └── keepa-logo.png │ ├── images │ │ ├── Offer_History.png │ │ ├── Product_Offer_Plot.png │ │ └── Product_Price_Plot.png │ ├── index.rst │ ├── api_methods.rst │ ├── conf.py │ └── product_query.rst └── Makefile ├── .gitignore ├── .readthedocs.yaml ├── codecov.yml ├── .pre-commit-config.yaml ├── pyproject.toml ├── .github └── workflows │ └── testing-and-deployment.yml ├── README.rst ├── LICENSE └── tests ├── test_async_interface.py └── test_interface.py /src/keepa/py.typed: -------------------------------------------------------------------------------- 1 | partial -------------------------------------------------------------------------------- /requirements_docs.txt: -------------------------------------------------------------------------------- 1 | pydata-sphinx-theme==0.15.4 2 | sphinx==7.3.7 3 | -------------------------------------------------------------------------------- /docs/source/_static/keepa-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akaszynski/keepa/HEAD/docs/source/_static/keepa-logo.png -------------------------------------------------------------------------------- /docs/source/images/Offer_History.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akaszynski/keepa/HEAD/docs/source/images/Offer_History.png -------------------------------------------------------------------------------- /docs/source/images/Product_Offer_Plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akaszynski/keepa/HEAD/docs/source/images/Product_Offer_Plot.png -------------------------------------------------------------------------------- /docs/source/images/Product_Price_Plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akaszynski/keepa/HEAD/docs/source/images/Product_Price_Plot.png -------------------------------------------------------------------------------- /src/keepa/models/status.py: -------------------------------------------------------------------------------- 1 | """Status model.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class Status: 8 | """Status model class.""" 9 | 10 | tokensLeft: int | None = None 11 | refillIn: float | None = None 12 | refillRate: float | None = None 13 | timestamp: float | None = None 14 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | keepa Python API Documentation 2 | ****************************** 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | product_query 9 | api_methods 10 | 11 | 12 | .. include:: ../../README.rst 13 | 14 | 15 | Indices and tables 16 | ================== 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | * :ref:`search` 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.pyc 4 | *.c 5 | *.cpp 6 | *.so 7 | *.o 8 | *.cache/ 9 | 10 | # OS generated files # 11 | ###################### 12 | .fuse_hidden* 13 | *~ 14 | 15 | # Pip generated folders # 16 | ######################### 17 | keepa.egg-info/ 18 | build/ 19 | dist/ 20 | keepa/__pycache__/ 21 | 22 | # testing 23 | test-scripts/ 24 | test.sh 25 | .pytest_cache/ 26 | tests/.coverage 27 | tests/htmlcov/ 28 | *,cover 29 | .coverage 30 | .hypothesis 31 | .venv 32 | 33 | # key storage 34 | tests/key 35 | tests/weak_key 36 | 37 | # pycharm IDE 38 | .idea -------------------------------------------------------------------------------- /src/keepa/models/domain.py: -------------------------------------------------------------------------------- 1 | """Module defining the Domain enumeration for Amazon regions.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class Domain(Enum): 7 | """Enumeration for Amazon domain regions. 8 | 9 | Examples 10 | -------- 11 | >>> import keepa 12 | >>> keepa.Domain.US 13 | 14 | 15 | """ 16 | 17 | RESERVED = "RESERVED" 18 | US = "US" 19 | GB = "GB" 20 | DE = "DE" 21 | FR = "FR" 22 | JP = "JP" 23 | CA = "CA" 24 | RESERVED2 = "RESERVED2" 25 | IT = "IT" 26 | ES = "ES" 27 | IN = "IN" 28 | MX = "MX" 29 | BR = "BR" 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python3 -msphinx 7 | SPHINXPROJ = keepa 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: '3.11' 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/source/conf.py 17 | 18 | # If using Sphinx, optionally build your docs in additional formats such as PDF 19 | # formats: 20 | # - pdf 21 | 22 | # Optionally declare the Python requirements required to build your docs 23 | python: 24 | install: 25 | - requirements: requirements_docs.txt 26 | - method: pip 27 | path: . 28 | -------------------------------------------------------------------------------- /docs/source/api_methods.rst: -------------------------------------------------------------------------------- 1 | .. _ref_api_methods: 2 | 3 | keepa.Api Methods 4 | ----------------- 5 | These are the core ``keepa`` classes. 6 | 7 | .. autoclass:: keepa.Keepa 8 | :members: 9 | 10 | Types 11 | ----- 12 | These types and enumerators are used by ``keepa`` for data validation. 13 | 14 | .. autoclass:: keepa.Domain 15 | :members: 16 | :undoc-members: 17 | :member-order: bysource 18 | 19 | .. autoclass:: keepa.ProductParams 20 | :members: 21 | :undoc-members: 22 | :member-order: bysource 23 | :exclude-members: model_computed_fields, model_config, model_fields, construct,dict,from_orm,json,parse_file,parse_obj,parse_raw,schema,schema_json,update_forward_refs,validate,copy,model_construct,model_copy,model_dump,model_dump_json,model_json_schema,model_parametrized_name,model_post_init,model_rebuild,model_validate,model_validate_json,model_validate_strings, model_extra, model_fields_set 24 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | # basic 9 | target: 85% 10 | threshold: 80% 11 | base: auto 12 | flags: 13 | - unit 14 | paths: 15 | - src 16 | # advanced 17 | branches: 18 | - master 19 | if_not_found: success 20 | if_ci_failed: error 21 | informational: false 22 | only_pulls: false 23 | patch: 24 | default: 25 | # basic 26 | target: 90 27 | threshold: 90 28 | base: auto 29 | # advanced 30 | branches: 31 | - master 32 | if_no_uploads: error 33 | if_not_found: success 34 | if_ci_failed: error 35 | only_pulls: false 36 | flags: 37 | - unit 38 | paths: 39 | - src 40 | 41 | 42 | parsers: 43 | gcov: 44 | branch_detection: 45 | conditional: yes 46 | loop: yes 47 | method: no 48 | macro: no 49 | 50 | comment: 51 | layout: reach,diff,flags,tree 52 | behavior: default 53 | require_changes: no 54 | -------------------------------------------------------------------------------- /src/keepa/__init__.py: -------------------------------------------------------------------------------- 1 | """Keepa module.""" 2 | 3 | from importlib.metadata import PackageNotFoundError, version 4 | 5 | # single source versioning from the installed package (stored in pyproject.toml) 6 | try: 7 | __version__ = version("keepa") 8 | except PackageNotFoundError: 9 | __version__ = "unknown" 10 | 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 ( 18 | convert_offer_history, 19 | format_items, 20 | keepa_minutes_to_time, 21 | parse_csv, 22 | process_used_buybox, 23 | run_and_get, 24 | ) 25 | 26 | __all__ = [ 27 | "AsyncKeepa", 28 | "DCODES", 29 | "Domain", 30 | "KEEPA_ST_ORDINAL", 31 | "Keepa", 32 | "ProductParams", 33 | "SCODES", 34 | "__version__", 35 | "convert_offer_history", 36 | "csv_indices", 37 | "format_items", 38 | "keepa_minutes_to_time", 39 | "parse_csv", 40 | "plot_product", 41 | "process_used_buybox", 42 | "run_and_get", 43 | ] 44 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Integration with GitHub Actions 2 | # See https://pre-commit.ci/ 3 | ci: 4 | autofix_prs: true 5 | autoupdate_schedule: quarterly 6 | repos: 7 | - repo: https://github.com/keewis/blackdoc 8 | rev: v0.4.5 9 | hooks: 10 | - id: blackdoc 11 | files: \.py$ 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.14.3 14 | hooks: 15 | - id: ruff-check 16 | args: [--fix, --exit-non-zero-on-fix] 17 | exclude: ^(docs/|tests) 18 | - id: ruff-format 19 | - repo: https://github.com/codespell-project/codespell 20 | rev: v2.4.1 21 | hooks: 22 | - id: codespell 23 | args: [-S ./docs/\*] 24 | - repo: https://github.com/pycqa/pydocstyle 25 | rev: 6.3.0 26 | hooks: 27 | - id: pydocstyle 28 | additional_dependencies: [toml] 29 | exclude: tests/ 30 | - repo: https://github.com/asottile/pyupgrade 31 | rev: v3.21.0 32 | hooks: 33 | - id: pyupgrade 34 | args: [--py39-plus, --keep-runtime-typing] 35 | - repo: https://github.com/pre-commit/pre-commit-hooks 36 | rev: v6.0.0 37 | hooks: 38 | - id: check-merge-conflict 39 | - id: debug-statements 40 | - id: no-commit-to-branch 41 | args: [--branch, main] 42 | - id: requirements-txt-fixer 43 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 44 | rev: v2.15.0 45 | hooks: 46 | - id: pretty-format-toml 47 | args: [--autofix] 48 | - id: pretty-format-yaml 49 | args: [--autofix, --indent, '2'] 50 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "flit_core.buildapi" 3 | requires = ["flit_core >=3,<4"] 4 | 5 | [mypy] 6 | plugins = "pydantic.mypy" 7 | 8 | [project] 9 | authors = [ 10 | {name = "Alex Kaszynski", email = "akascap@gmail.com"} 11 | ] 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Intended Audience :: End Users/Desktop", 15 | "Topic :: Database :: Front-Ends", 16 | "License :: OSI Approved :: Apache Software License", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Programming Language :: Python :: 3.14" 22 | ] 23 | dependencies = [ 24 | "numpy >=1.9.3", 25 | "requests >=2.2", 26 | "tqdm", 27 | "aiohttp", 28 | "pandas <= 3.0", 29 | "pydantic" 30 | ] 31 | description = "Interfaces with keepa.com's API." 32 | keywords = ["keepa"] 33 | name = "keepa" 34 | readme = "README.rst" 35 | requires-python = ">=3.10" 36 | version = "1.5.dev0" 37 | 38 | [project.optional-dependencies] 39 | doc = [ 40 | "sphinx==7.3.7", 41 | "pydata-sphinx-theme==0.15.4", 42 | "numpydoc==1.7.0" 43 | ] 44 | test = [ 45 | "matplotlib", 46 | "pandas", 47 | "pytest-asyncio", 48 | "pytest-cov", 49 | "pytest", 50 | "pytest-rerunfailures" 51 | ] 52 | 53 | [project.urls] 54 | Documentation = "https://keepaapi.readthedocs.io/en/latest/" 55 | Source = "https://github.com/akaszynski/keepa" 56 | 57 | [tool.pytest.ini_options] 58 | addopts = "--cov=keepa --cov-fail-under=85" 59 | asyncio_default_fixture_loop_scope = "function" 60 | testpaths = 'tests' 61 | 62 | [tool.ruff] 63 | line-length = 100 64 | 65 | [tool.ruff.lint] 66 | ignore = [] 67 | select = ["E", "F", "W", "I001"] # pyflakes, pycodestyle, isort 68 | -------------------------------------------------------------------------------- /src/keepa/constants.py: -------------------------------------------------------------------------------- 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"] 79 | -------------------------------------------------------------------------------- /.github/workflows/testing-and-deployment.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | push: 7 | tags: 8 | - '*' 9 | branches: 10 | - main 11 | 12 | jobs: 13 | unit_testing: 14 | name: Build and Testing 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] 19 | 20 | env: 21 | KEEPAKEY: ${{ secrets.KEEPAKEY }} 22 | WEAKKEEPAKEY: ${{ secrets.WEAKKEEPAKEY }} 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | cache: pip 32 | 33 | - name: Install 34 | run: | 35 | pip install .[test] --disable-pip-version-check 36 | python -c "import keepa" 37 | 38 | - name: Validate Keys 39 | run: | 40 | python -c "import os, keepa; keepa.Keepa(os.environ.get('KEEPAKEY'))" 41 | 42 | - name: Unit testing 43 | run: | 44 | pytest -v --cov keepa --cov-report xml 45 | 46 | - uses: codecov/codecov-action@v4 47 | if: matrix.python-version == '3.13' 48 | name: Upload coverage to codecov 49 | 50 | - name: Build wheel 51 | if: matrix.python-version == '3.13' 52 | run: | 53 | pip install build --disable-pip-version-check 54 | python -m build 55 | 56 | - name: Upload wheel 57 | if: matrix.python-version == '3.13' 58 | uses: actions/upload-artifact@v4 59 | with: 60 | name: keepa-wheel 61 | path: dist/ 62 | retention-days: 1 63 | 64 | release: 65 | name: Upload release to PyPI 66 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags') 67 | needs: [unit_testing] 68 | runs-on: ubuntu-latest 69 | environment: 70 | name: pypi 71 | url: https://pypi.org/p/keepa 72 | permissions: 73 | id-token: write # Required for PyPI publishing 74 | contents: write # Required for creating GitHub releases 75 | steps: 76 | - uses: actions/download-artifact@v4 77 | with: 78 | path: dist/ 79 | - name: Flatten directory structure 80 | run: | 81 | mv dist/*/* dist/ 82 | rm -rf dist/keepa-wheel 83 | - name: Display structure of downloaded files 84 | run: ls -R 85 | - name: Publish package distributions to PyPI 86 | uses: pypa/gh-action-pypi-publish@release/v1 87 | - name: Create GitHub Release 88 | uses: softprops/action-gh-release@v2 89 | with: 90 | generate_release_notes: true 91 | files: | 92 | ./**/*.whl 93 | -------------------------------------------------------------------------------- /src/keepa/plotting.py: -------------------------------------------------------------------------------- 1 | """Plotting module product data returned from keepa interface module.""" 2 | 3 | import numpy as np 4 | 5 | from keepa.utils import keepa_minutes_to_time, parse_csv 6 | 7 | 8 | def plot_product( 9 | product, keys=["AMAZON", "USED", "COUNT_USED", "SALES"], price_limit=1000, show=True 10 | ): 11 | """Plot a product using matplotlib. 12 | 13 | Parameters 14 | ---------- 15 | product : list 16 | Single product from keepa.query 17 | 18 | keys : list, optional 19 | Keys to plot. Defaults to ``['AMAZON', 'USED', 'COUNT_USED', 'SALES']``. 20 | 21 | price_limit : float, optional 22 | Prices over this value will not be plotted. Used to ignore 23 | extreme prices. 24 | 25 | show : bool, optional 26 | Show plot. 27 | 28 | """ 29 | try: 30 | import matplotlib.pyplot as plt 31 | except Exception: # pragma: no cover 32 | raise Exception('Plotting not available. Please install "matplotlib"') 33 | 34 | if "data" not in product: 35 | product["data"] = parse_csv[product["csv"]] 36 | 37 | # Use all keys if not specified 38 | if not keys: 39 | keys = product["data"].keys() 40 | 41 | # Create three figures, one for price data, offers, and sales rank 42 | pricefig, priceax = plt.subplots(figsize=(10, 5)) 43 | pricefig.canvas.manager.set_window_title("Product Price Plot") 44 | plt.title(product["title"]) 45 | plt.xlabel("Date") 46 | plt.ylabel("Price") 47 | pricelegend = [] 48 | 49 | offerfig, offerax = plt.subplots(figsize=(10, 5)) 50 | offerfig.canvas.manager.set_window_title("Product Offer Plot") 51 | plt.title(product["title"]) 52 | plt.xlabel("Date") 53 | plt.ylabel("Listings") 54 | offerlegend = [] 55 | 56 | salesfig, salesax = plt.subplots(figsize=(10, 5)) 57 | salesfig.canvas.manager.set_window_title("Product Sales Rank Plot") 58 | plt.title(product["title"]) 59 | plt.xlabel("Date") 60 | plt.ylabel("Sales Rank") 61 | saleslegend = [] 62 | 63 | # Add in last update time 64 | lstupdate = keepa_minutes_to_time(product["lastUpdate"]) 65 | 66 | # Attempt to plot each key 67 | for key in keys: 68 | # Continue if key does not exist 69 | if key not in product["data"]: 70 | continue 71 | 72 | elif "SALES" in key and "time" not in key: 73 | if product["data"][key].size > 1: 74 | x = np.append(product["data"][key + "_time"], lstupdate) 75 | y = np.append(product["data"][key], product["data"][key][-1]).astype(float) 76 | replace_invalid(y) 77 | 78 | if np.all(np.isnan(y)): 79 | continue 80 | 81 | salesax.step(x, y, where="pre") 82 | saleslegend.append(key) 83 | 84 | elif "COUNT_" in key and "time" not in key: 85 | x = np.append(product["data"][key + "_time"], lstupdate) 86 | y = np.append(product["data"][key], product["data"][key][-1]).astype(float) 87 | replace_invalid(y) 88 | 89 | if np.all(np.isnan(y)): 90 | continue 91 | 92 | offerax.step(x, y, where="pre") 93 | offerlegend.append(key) 94 | 95 | elif "time" not in key: 96 | x = np.append(product["data"][key + "_time"], lstupdate) 97 | y = np.append(product["data"][key], product["data"][key][-1]).astype(float) 98 | replace_invalid(y, max_value=price_limit) 99 | 100 | if np.all(np.isnan(y)): 101 | continue 102 | 103 | priceax.step(x, y, where="pre") 104 | pricelegend.append(key) 105 | 106 | # Add in legends or close figure 107 | if pricelegend: 108 | priceax.legend(pricelegend) 109 | else: 110 | plt.close(pricefig) 111 | 112 | if offerlegend: 113 | offerax.legend(offerlegend) 114 | else: 115 | plt.close(offerfig) 116 | 117 | if not saleslegend: 118 | plt.close(salesfig) 119 | 120 | if not plt.get_fignums(): 121 | raise Exception("Nothing to plot") 122 | 123 | if show: 124 | plt.show(block=True) 125 | plt.draw() 126 | 127 | 128 | def replace_invalid(arr, max_value=None): 129 | """Replace invalid data with nan.""" 130 | arr[arr < 0.0] = np.nan 131 | if max_value: 132 | arr[arr > max_value] = np.nan 133 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx configuration file for keepaapi.""" 2 | 3 | # import pydata_sphinx_theme # noqa 4 | from datetime import datetime 5 | 6 | from keepa import __version__ 7 | 8 | # If your documentation needs a minimal Sphinx version, state it here. 9 | # 10 | # needs_sphinx = '1.0' 11 | 12 | # Add any Sphinx extension module names here, as strings. They can be 13 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 14 | # ones. 15 | extensions = [ 16 | "sphinx.ext.autodoc", 17 | "numpydoc", 18 | "sphinx.ext.intersphinx", 19 | ] 20 | 21 | intersphinx_mapping = { 22 | "python": ( 23 | "https://docs.python.org/3.11", 24 | (None, "../intersphinx/python-objects.inv"), 25 | ), 26 | } 27 | 28 | # Add any paths that contain templates here, relative to this directory. 29 | templates_path = ["_templates"] 30 | 31 | # The suffix(es) of source filenames. 32 | # You can specify multiple suffix as a list of string: 33 | # 34 | # source_suffix = ['.rst', '.md'] 35 | source_suffix = ".rst" 36 | 37 | # The master toctree document. 38 | master_doc = "index" 39 | 40 | # General information about the project. 41 | project = "keepa" 42 | copyright = f"2018-{datetime.now().year}, Alex Kaszynski" 43 | author = "Alex Kaszynski" 44 | 45 | # The version info for the project you're documenting, acts as replacement for 46 | # |version| and |release|, also used in various other places throughout the 47 | # built documents. 48 | # 49 | # The full version, including alpha/beta/rc tags. 50 | # The short X.Y version. 51 | release = version = __version__ 52 | 53 | # The language for content autogenerated by Sphinx. Refer to documentation 54 | # for a list of supported languages. 55 | # 56 | # This is also used if you do content translation via gettext catalogs. 57 | # Usually you set "language" from the command line for these cases. 58 | language = "en" 59 | 60 | # List of patterns, relative to source directory, that match files and 61 | # directories to ignore when looking for source files. 62 | # This patterns also effect to html_static_path and html_extra_path 63 | exclude_patterns = [] 64 | 65 | # The name of the Pygments (syntax highlighting) style to use. 66 | pygments_style = "sphinx" 67 | 68 | # If true, `todo` and `todoList` produce output, else they produce nothing. 69 | todo_include_todos = False 70 | 71 | 72 | # -- Options for HTML output ---------------------------------------------- 73 | # The theme to use for HTML and HTML Help pages. See the documentation for 74 | # a list of builtin themes. 75 | # 76 | import pydata_sphinx_theme # noqa 77 | 78 | html_theme = "pydata_sphinx_theme" 79 | html_logo = "./_static/keepa-logo.png" 80 | 81 | html_context = { 82 | "github_user": "akaszynski", 83 | "github_repo": "keepa", 84 | "github_version": "master", 85 | "doc_path": "doc", 86 | } 87 | html_show_sourcelink = False 88 | 89 | html_theme_options = { 90 | "show_prev_next": False, 91 | "github_url": "https://github.com/akaszynski/keepa", 92 | "collapse_navigation": True, 93 | "navigation_with_keys": False, 94 | "use_edit_page_button": True, 95 | "logo": { 96 | "image_light": "keepa-logo.png", 97 | "image_dark": "keepa-logo.png", 98 | }, 99 | } 100 | 101 | 102 | # Theme options are theme-specific and customize the look and feel of a theme 103 | # further. For a list of options available for each theme, see the 104 | # documentation. 105 | # 106 | # html_theme_options = {} 107 | 108 | # Add any paths that contain custom static files (such as style sheets) here, 109 | # relative to this directory. They are copied after the builtin static files, 110 | # so a file named "default.css" will overwrite the builtin "default.css". 111 | html_static_path = ["_static"] 112 | 113 | htmlhelp_basename = "keepadoc" 114 | 115 | latex_elements = { 116 | # The paper size ('letterpaper' or 'a4paper'). 117 | # 118 | # 'papersize': 'letterpaper', 119 | # The font size ('10pt', '11pt' or '12pt'). 120 | # 121 | # 'pointsize': '10pt', 122 | # Additional stuff for the LaTeX preamble. 123 | # 124 | # 'preamble': '', 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | (master_doc, "keepa.tex", "keepa Documentation", "Alex Kaszynski", "manual"), 135 | ] 136 | 137 | 138 | # -- Options for manual page output --------------------------------------- 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [(master_doc, "keepaapi", "keepa Documentation", [author], 1)] 143 | 144 | 145 | # -- Options for Texinfo output ------------------------------------------- 146 | 147 | # Grouping the document tree into Texinfo files. List of tuples 148 | # (source start file, target name, title, author, 149 | # dir menu entry, description, category) 150 | texinfo_documents = [ 151 | ( 152 | master_doc, 153 | "keepa", 154 | "keepa Documentation", 155 | author, 156 | "keepa", 157 | "One line description of project.", 158 | "Miscellaneous", 159 | ), 160 | ] 161 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Python keepa Client Library 2 | =========================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/keepa.svg?logo=python&logoColor=white 5 | :target: https://pypi.org/project/keepa/ 6 | 7 | .. image:: https://github.com/akaszynski/keepa/actions/workflows/testing-and-deployment.yml/badge.svg 8 | :target: https://github.com/akaszynski/keepa/actions/workflows/testing-and-deployment.yml 9 | 10 | .. image:: https://readthedocs.org/projects/keepaapi/badge/?version=latest 11 | :target: https://keepaapi.readthedocs.io/en/latest/?badge=latest 12 | :alt: Documentation Status 13 | 14 | .. image:: https://codecov.io/gh/akaszynski/keepa/branch/main/graph/badge.svg 15 | :target: https://codecov.io/gh/akaszynski/keepa 16 | 17 | .. image:: https://app.codacy.com/project/badge/Grade/9452f99f297c4a6eac14e2d21189ab6f 18 | :target: https://www.codacy.com/gh/akaszynski/keepa/dashboard?utm_source=github.com&utm_medium=referral&utm_content=akaszynski/keepa&utm_campaign=Badge_Grade 19 | 20 | 21 | This Python library allows you to interface with the API at `Keepa 22 | `_ to query for Amazon product information and 23 | history. It also contains a plotting module to allow for plotting of 24 | a product. 25 | 26 | Sign up for `Keepa Data Access `_. 27 | 28 | Documentation can be found at `Keepa Documentation `_. 29 | 30 | 31 | Requirements 32 | ------------ 33 | This library is compatible with Python >= 3.10 and requires: 34 | 35 | - ``numpy`` 36 | - ``aiohttp`` 37 | - ``matplotlib`` 38 | - ``tqdm`` 39 | 40 | Product history can be plotted from the raw data when ``matplotlib`` 41 | is installed. 42 | 43 | Interfacing with the ``keepa`` requires an access key and a monthly 44 | subscription from `Keepa API `_. 45 | 46 | Installation 47 | ------------ 48 | Module can be installed from `PyPi `_ with: 49 | 50 | .. code:: 51 | 52 | pip install keepa 53 | 54 | 55 | Source code can also be downloaded from `GitHub 56 | `_ and installed using:: 57 | 58 | cd keepa 59 | pip install . 60 | 61 | 62 | Brief Example 63 | ------------- 64 | .. code:: python 65 | 66 | import keepa 67 | accesskey = 'XXXXXXXXXXXXXXXX' # enter real access key here from https://get.keepa.com/d7vrq 68 | api = keepa.Keepa(accesskey) 69 | 70 | # Single ASIN query 71 | products = api.query('B0088PUEPK') # returns list of product data 72 | 73 | # Plot result (requires matplotlib) 74 | keepa.plot_product(products[0]) 75 | 76 | .. figure:: https://github.com/akaszynski/keepa/raw/main/docs/source/images/Product_Price_Plot.png 77 | :width: 500pt 78 | 79 | Product Price Plot 80 | 81 | .. figure:: https://github.com/akaszynski/keepa/raw/main/docs/source/images/Product_Offer_Plot.png 82 | :width: 500pt 83 | 84 | Product Offers Plot 85 | 86 | 87 | Brief Example using async 88 | ------------------------- 89 | Here's an example of obtaining a product and plotting its price and 90 | offer history using the ``keepa.AsyncKeepa`` class: 91 | 92 | .. code:: python 93 | 94 | >>> import asyncio 95 | >>> import keepa 96 | >>> product_parms = {'author': 'jim butcher'} 97 | >>> async def main(): 98 | ... key = '' 99 | ... api = await keepa.AsyncKeepa().create(key) 100 | ... return await api.product_finder(product_parms) 101 | >>> asins = asyncio.run(main()) 102 | >>> asins 103 | ['B000HRMAR2', 104 | '0578799790', 105 | 'B07PW1SVHM', 106 | ... 107 | 'B003MXM744', 108 | '0133235750', 109 | 'B01MXXLJPZ'] 110 | 111 | Query for product with ASIN ``'B0088PUEPK'`` using the asynchronous 112 | keepa interface. 113 | 114 | .. code:: python 115 | 116 | >>> import asyncio 117 | >>> import keepa 118 | >>> async def main(): 119 | ... key = '' 120 | ... api = await keepa.AsyncKeepa().create(key) 121 | ... return await api.query('B0088PUEPK') 122 | >>> response = asyncio.run(main()) 123 | >>> response[0]['title'] 124 | 'Western Digital 1TB WD Blue PC Internal Hard Drive HDD - 7200 RPM, 125 | SATA 6 Gb/s, 64 MB Cache, 3.5" - WD10EZEX' 126 | 127 | 128 | Detailed Examples 129 | ----------------- 130 | Import interface and establish connection to server 131 | 132 | .. code:: python 133 | 134 | import keepa 135 | accesskey = 'XXXXXXXXXXXXXXXX' # enter real access key here 136 | api = keepa.Keepa(accesskey) 137 | 138 | 139 | Single ASIN query 140 | 141 | .. code:: python 142 | 143 | products = api.query('059035342X') 144 | 145 | # See help(api.query) for available options when querying the API 146 | 147 | 148 | You can use keepa witch async / await too 149 | 150 | .. code:: python 151 | 152 | import keepa 153 | accesskey = 'XXXXXXXXXXXXXXXX' # enter real access key here 154 | api = await keepa.AsyncKeepa.create(accesskey) 155 | 156 | 157 | Single ASIN query (async) 158 | 159 | .. code:: python 160 | 161 | products = await api.query('059035342X') 162 | 163 | 164 | Multiple ASIN query from List 165 | 166 | .. code:: python 167 | 168 | asins = ['0022841350', '0022841369', '0022841369', '0022841369'] 169 | products = api.query(asins) 170 | 171 | Multiple ASIN query from numpy array 172 | 173 | .. code:: python 174 | 175 | asins = np.asarray(['0022841350', '0022841369', '0022841369', '0022841369']) 176 | products = api.query(asins) 177 | 178 | Products is a list of product data with one entry per successful result from the Keepa server. Each entry is a dictionary containing the same product data available from `Amazon `_. 179 | 180 | .. code:: python 181 | 182 | # Available keys 183 | print(products[0].keys()) 184 | 185 | # Print ASIN and title 186 | print('ASIN is ' + products[0]['asin']) 187 | print('Title is ' + products[0]['title']) 188 | 189 | The raw data is contained within each product result. Raw data is stored as a dictionary with each key paired with its associated time history. 190 | 191 | .. code:: python 192 | 193 | # Access new price history and associated time data 194 | newprice = products[0]['data']['NEW'] 195 | newpricetime = products[0]['data']['NEW_time'] 196 | 197 | # Can be plotted with matplotlib using: 198 | import matplotlib.pyplot as plt 199 | plt.step(newpricetime, newprice, where='pre') 200 | 201 | # Keys can be listed by 202 | print(products[0]['data'].keys()) 203 | 204 | The product history can also be plotted from the module if ``matplotlib`` is installed 205 | 206 | .. code:: python 207 | 208 | keepa.plot_product(products[0]) 209 | 210 | You can obtain the offers history for an ASIN (or multiple ASINs) using the ``offers`` parameter. See the documentation at `Request Products `_ for further details. 211 | 212 | .. code:: python 213 | 214 | products = api.query(asins, offers=20) 215 | product = products[0] 216 | offers = product['offers'] 217 | 218 | # each offer contains the price history of each offer 219 | offer = offers[0] 220 | csv = offer['offerCSV'] 221 | 222 | # convert these values to numpy arrays 223 | times, prices = keepa.convert_offer_history(csv) 224 | 225 | # for a list of active offers, see 226 | indices = product['liveOffersOrder'] 227 | 228 | # with this you can loop through active offers: 229 | indices = product['liveOffersOrder'] 230 | offer_times = [] 231 | offer_prices = [] 232 | for index in indices: 233 | csv = offers[index]['offerCSV'] 234 | times, prices = keepa.convert_offer_history(csv) 235 | offer_times.append(times) 236 | offer_prices.append(prices) 237 | 238 | # you can aggregate these using np.hstack or plot at the history individually 239 | import matplotlib.pyplot as plt 240 | for i in range(len(offer_prices)): 241 | plt.step(offer_times[i], offer_prices[i]) 242 | plt.show() 243 | 244 | If you plan to do a lot of simulatneous query, you might want to speedup query using 245 | ``wait=False`` arguments. 246 | 247 | .. code:: python 248 | 249 | products = await api.query('059035342X', wait=False) 250 | 251 | 252 | Buy Box Statistics 253 | ~~~~~~~~~~~~~~~~~~ 254 | To load used buy box statistics, you have to enable ``offers``. This example 255 | loads in product offers and converts the buy box data into a 256 | ``pandas.DataFrame``. 257 | 258 | .. code:: pycon 259 | 260 | >>> import keepa 261 | >>> key = '' 262 | >>> api = keepa.Keepa(key) 263 | >>> response = api.query('B0088PUEPK', offers=20) 264 | >>> product = response[0] 265 | >>> buybox_info = product['buyBoxUsedHistory'] 266 | >>> df = keepa.process_used_buybox(buybox_info) 267 | datetime user_id condition isFBA 268 | 0 2022-11-02 16:46:00 A1QUAC68EAM09F Used - Like New True 269 | 1 2022-11-13 10:36:00 A18WXU4I7YR6UA Used - Very Good False 270 | 2 2022-11-15 23:50:00 AYUGEV9WZ4X5O Used - Like New False 271 | 3 2022-11-17 06:16:00 A18WXU4I7YR6UA Used - Very Good False 272 | 4 2022-11-17 10:56:00 AYUGEV9WZ4X5O Used - Like New False 273 | .. ... ... ... ... 274 | 115 2023-10-23 10:00:00 AYUGEV9WZ4X5O Used - Like New False 275 | 116 2023-10-25 21:14:00 A1U9HDFCZO1A84 Used - Like New False 276 | 117 2023-10-26 04:08:00 AYUGEV9WZ4X5O Used - Like New False 277 | 118 2023-10-27 08:14:00 A1U9HDFCZO1A84 Used - Like New False 278 | 119 2023-10-27 12:34:00 AYUGEV9WZ4X5O Used - Like New False 279 | 280 | Contributing 281 | ------------ 282 | Contribute to this repository by forking this repository and installing in 283 | development mode with:: 284 | 285 | git clone https://github.com//keepa 286 | pip install -e .[test] 287 | 288 | You can then add your feature or commit your bug fix and then run your unit 289 | testing with:: 290 | 291 | pytest 292 | 293 | Unit testing will automatically enforce minimum code coverage standards. 294 | 295 | Next, to ensure your code meets minimum code styling standards, run:: 296 | 297 | pre-commit run --all-files 298 | 299 | Finally, `create a pull request`_ from your fork and I'll be sure to review it. 300 | 301 | 302 | Credits 303 | ------- 304 | This Python module, written by Alex Kaszynski and several contribitors, is 305 | based on Java code written by Marius Johann, CEO Keepa. Java source is can be 306 | found at `keepacom/api_backend `_. 307 | 308 | 309 | License 310 | ------- 311 | Apache License, please see license file. Work is credited to both Alex 312 | Kaszynski and Marius Johann. 313 | 314 | 315 | .. _create a pull request: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request 316 | -------------------------------------------------------------------------------- /docs/source/product_query.rst: -------------------------------------------------------------------------------- 1 | Queries 2 | ======= 3 | Interfacing with the ``keepa`` requires a valid access key. This 4 | requires a monthly subscription from `Pricing 5 | `_. Here's a brief description of the 6 | subscription model from their website. 7 | 8 | All plans are prepaid for 1 month with a subscription model. A 9 | subscription can be canceled at any time. Multiple plans can be active 10 | on the same account and an upgrade is possible at any time, a 11 | downgrade once per month. The plans differentiate by the number of 12 | tokens generated per minute. For example: With a single token you can 13 | retrieve the complete data set for one product. Unused tokens expire 14 | after one hour. You can find more information on how our plans work in 15 | our documentation. 16 | 17 | 18 | Connecting to Keepa 19 | ~~~~~~~~~~~~~~~~~~~ 20 | Import interface and establish connection to server: 21 | 22 | .. code:: python 23 | 24 | import keepa 25 | accesskey = 'XXXXXXXXXXXXXXXX' # enter real access key here 26 | api = keepa.Keepa(accesskey) 27 | 28 | 29 | Product History Query 30 | ~~~~~~~~~~~~~~~~~~~~~ 31 | The product data for a single ASIN can be queried using: 32 | 33 | .. code:: python 34 | 35 | products = api.query('059035342X') 36 | product = products[0] 37 | 38 | where ``products`` is always a list of products, even with a single request. 39 | 40 | You can query using ISBN-10 or ASIN like the above example by default, or by using UPC, 41 | EAN, and ISBN-13 codes by setting ``product_code_is_asin`` to ``False``: 42 | 43 | .. code:: python 44 | 45 | products = api.query('978-0786222728', product_code_is_asin=False) 46 | 47 | 48 | Multiple products can be queried using a list or ``numpy`` array: 49 | 50 | .. code:: python 51 | 52 | asins = ['0022841350', '0022841369', '0022841369', '0022841369'] 53 | asins = np.asarray(['0022841350', '0022841369', '0022841369', '0022841369']) 54 | products = api.query(asins) 55 | product = products[0] 56 | 57 | The ``products`` variable is a list of product data with one entry per successful result from the Keepa server. Each entry is a dictionary containing the same product data available from `Amazon `_: 58 | 59 | .. code:: python 60 | 61 | # Available keys 62 | print(products[0].keys()) 63 | 64 | # Print ASIN and title 65 | print('ASIN is ' + products[0]['asin']) 66 | print('Title is ' + products[0]['title']) 67 | 68 | When the parameter ``history`` is ``True`` (enabled by default), each 69 | product contains a The raw data is contained within each product 70 | result. Raw data is stored as a dictionary with each key paired with 71 | its associated time history. 72 | 73 | .. code:: python 74 | 75 | # Access new price history and associated time data 76 | newprice = product['data']['NEW'] 77 | newpricetime = product['data']['NEW_time'] 78 | 79 | # print the first 10 prices 80 | print('%20s %s' % ('Date', 'Price')) 81 | for i in range(10): 82 | print('%20s $%.2f' % (newpricetime[i], newprice[i])) 83 | 84 | .. code:: 85 | 86 | Date Price 87 | 2014-07-31 05:00:00 $55.00 88 | 2014-08-02 11:00:00 $56.19 89 | 2014-08-04 02:00:00 $56.22 90 | 2014-08-04 06:00:00 $54.99 91 | 2014-08-08 01:00:00 $49.99 92 | 2014-08-08 16:00:00 $55.66 93 | 2014-08-10 02:00:00 $49.99 94 | 2014-08-10 07:00:00 $55.66 95 | 2014-08-10 18:00:00 $57.00 96 | 2014-08-10 20:00:00 $52.51 97 | 98 | Each time a user makes a query to keepa as well as other points in 99 | time, an entry is stored on their servers. This means that there will 100 | sometimes be gaps in the history followed by closely spaced entries 101 | like in this example data. 102 | 103 | The data dictionary contains keys for each type of history available 104 | for the product. These keys include: 105 | 106 | AMAZON 107 | Amazon price history 108 | 109 | NEW 110 | Marketplace/3rd party New price history - Amazon is considered to be part of the marketplace as well, so if Amazon has the overall lowest new (!) price, the marketplace new price in the corresponding time interval will be identical to the Amazon price (except if there is only one marketplace offer). Shipping and Handling costs not included! 111 | 112 | USED 113 | Marketplace/3rd party Used price history 114 | 115 | SALES 116 | Sales Rank history. Not every product has a Sales Rank. 117 | 118 | LISTPRICE 119 | List Price history 120 | 121 | COLLECTIBLE 122 | Collectible Price history 123 | 124 | REFURBISHED 125 | Refurbished Price history 126 | 127 | NEW_FBM_SHIPPING 128 | 3rd party (not including Amazon) New price history including shipping costs, only fulfilled by merchant (FBM). 129 | 130 | LIGHTNING_DEAL 131 | 3rd party (not including Amazon) New price history including shipping costs, only fulfilled by merchant (FBM). 132 | 133 | WAREHOUSE 134 | Amazon Warehouse Deals price history. Mostly of used condition, rarely new. 135 | 136 | NEW_FBA 137 | Price history of the lowest 3rd party (not including Amazon/Warehouse) New offer that is fulfilled by Amazon 138 | 139 | COUNT_NEW 140 | New offer count history 141 | 142 | COUNT_USED 143 | Used offer count history 144 | 145 | COUNT_REFURBISHED 146 | Refurbished offer count history 147 | 148 | COUNT_COLLECTIBLE 149 | Collectible offer count history 150 | 151 | RATING 152 | The product's rating history. A rating is an integer from 0 to 50 (e.g. 45 = 4.5 stars) 153 | 154 | COUNT_REVIEWS 155 | The product's review count history. 156 | 157 | BUY_BOX_SHIPPING 158 | The price history of the buy box. If no offer qualified for the buy box the price has the value -1. Including shipping costs. 159 | 160 | USED_NEW_SHIPPING 161 | "Used - Like New" price history including shipping costs. 162 | 163 | USED_VERY_GOOD_SHIPPING 164 | "Used - Very Good" price history including shipping costs. 165 | 166 | USED_GOOD_SHIPPING 167 | "Used - Good" price history including shipping costs. 168 | 169 | USED_ACCEPTABLE_SHIPPING 170 | "Used - Acceptable" price history including shipping costs. 171 | 172 | COLLECTIBLE_NEW_SHIPPING 173 | "Collectible - Like New" price history including shipping costs. 174 | 175 | COLLECTIBLE_VERY_GOOD_SHIPPING 176 | "Collectible - Very Good" price history including shipping costs. 177 | 178 | COLLECTIBLE_GOOD_SHIPPING 179 | "Collectible - Good" price history including shipping costs. 180 | 181 | COLLECTIBLE_ACCEPTABLE_SHIPPING 182 | "Collectible - Acceptable" price history including shipping costs. 183 | 184 | REFURBISHED_SHIPPING 185 | Refurbished price history including shipping costs. 186 | 187 | TRADE_IN 188 | The trade in price history. Amazon trade-in is not available for every locale. 189 | 190 | 191 | Each data key has a corresponding ``_time`` key containing the time 192 | values of each key. These can be plotted with: 193 | 194 | .. code:: python 195 | 196 | import matplotlib.pyplot as plt 197 | key = 'TRADE_IN' 198 | history = product['data'] 199 | plt.step(history[key], history[key + '_time'], where='pre') 200 | 201 | Historical data should be plotted as a step plot since the data is 202 | discontinuous. Values are unknown between each entry. 203 | 204 | The product history can also be plotted from the module if 205 | ``matplotlib`` is installed 206 | 207 | .. code:: python 208 | 209 | keepa.plot_product(product) 210 | 211 | 212 | Offer Queries 213 | ~~~~~~~~~~~~~ 214 | You can obtain the offers history for an ASIN (or multiple ASINs) using the ``offers`` parameter. See the documentation at `Request Products `_ for further details. Offer queries use more tokens than a normal request. Here's an example query 215 | 216 | .. code:: python 217 | 218 | asin = '1454857935' 219 | products = api.query(asin, offers=20) 220 | product = products[0] 221 | offers = product['offers'] 222 | 223 | # each offer contains the price history of each offer 224 | offer = offers[0] 225 | csv = offer['offerCSV'] 226 | 227 | # convert these values to numpy arrays 228 | times, prices = keepa.convert_offer_history(csv) 229 | 230 | # print the first 10 prices 231 | print('%20s %s' % ('Date', 'Price')) 232 | for i in range(10): 233 | print('%20s $%.2f' % (times[i], prices[i])) 234 | 235 | .. code:: 236 | 237 | Date Price 238 | 2017-01-17 11:22:00 $155.41 239 | 2017-04-07 10:40:00 $165.51 240 | 2017-06-30 18:56:00 $171.94 241 | 2017-09-13 03:30:00 $234.99 242 | 2017-09-16 12:16:00 $170.95 243 | 2018-01-30 08:44:00 $259.21 244 | 2018-02-01 08:40:00 $255.97 245 | 2018-02-02 08:36:00 $211.91 246 | 2018-02-03 08:32:00 $203.48 247 | 2018-02-04 08:40:00 $217.37 248 | 249 | Not all offers are active and some are only historical. The following 250 | example plots the history of active offers for a single Amazon product. 251 | 252 | .. code:: python 253 | 254 | # for a list of active offers, use 255 | indices = product['liveOffersOrder'] 256 | 257 | # with this you can loop through active offers: 258 | indices = product['liveOffersOrder'] 259 | offer_times = [] 260 | offer_prices = [] 261 | for index in indices: 262 | csv = offers[index]['offerCSV'] 263 | times, prices = keepa.convert_offer_history(csv) 264 | offer_times.append(times) 265 | offer_prices.append(prices)p 266 | 267 | # you can aggregate these using np.hstack or plot at the history individually 268 | import matplotlib.pyplot as plt 269 | for i in range(len(offer_prices)): 270 | plt.step(offer_times[i], offer_prices[i]) 271 | 272 | plt.xlabel('Date') 273 | plt.ylabel('Offer Price') 274 | plt.show() 275 | 276 | 277 | .. figure:: ./images/Offer_History.png 278 | :width: 350pt 279 | 280 | 281 | Category Queries 282 | ~~~~~~~~~~~~~~~~ 283 | You can retrieve an ASIN list of the most popular products based on 284 | sales in a specific category or product group. Here's an example that 285 | assumes you've already setup your api. 286 | 287 | .. code:: python 288 | 289 | # get category id numbers for chairs 290 | if test_categories: 291 | categories = api.search_for_categories('chairs') 292 | 293 | # print the first 5 catIds 294 | catids = list(categories.keys()) 295 | for catid in catids[:5]: 296 | print(catid, categories[catid]['name']) 297 | 298 | # query the best sellers for "Arm Chairs" 299 | bestsellers = api.best_sellers_query('402283011') 300 | 301 | print('\nBest Sellers:') 302 | for bestseller in bestsellers: 303 | print(bestseller) 304 | 305 | .. code:: 306 | 307 | 8728936011 Stools, Chairs & Seat Cushions 308 | 16053799011 Mamagreen Outdoor Dining Chairs 309 | 8297445011 Medical Chairs 310 | 3290537011 kitchen chairs 311 | 5769032011 Office Chairs 312 | 313 | Best Sellers: 314 | B00HGE0MT2 315 | B006W6U006 316 | B006Z8RD60 317 | B006Z8S6UC 318 | B009UVKXY8 319 | B009FXIVMC 320 | B0077LGFTK 321 | B0078NISRY 322 | B00ESI56B8 323 | B00EOQ5W8G 324 | 325 | 326 | Product Search 327 | ~~~~~~~~~~~~~~ 328 | You can search for products using ``keepa`` using the ``product_finder`` method. There are many parameters you can search using. See ``help(api.product_finder)`` or check the description of the function at :ref:`ref_api_methods`. 329 | 330 | .. code:: python 331 | 332 | Query for all of Jim Butcher's books: 333 | 334 | import keepa 335 | api = keepa.Keepa('ENTER_ACTUAL_KEY_HERE') 336 | product_parms = {'author': 'jim butcher'} 337 | products = api.product_finder(product_parms) 338 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /tests/test_async_interface.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the asynchronous interface to the keepa backend. 3 | """ 4 | 5 | from pathlib import Path 6 | import datetime 7 | import os 8 | import warnings 9 | 10 | import numpy as np 11 | import pandas as pd 12 | import pytest 13 | import pytest_asyncio 14 | 15 | import keepa 16 | 17 | # reduce the request limit for testing 18 | keepa.keepa_async.REQLIM = 2 19 | 20 | path = os.path.dirname(os.path.realpath(__file__)) 21 | keyfile = os.path.join(path, "key") 22 | weak_keyfile = os.path.join(path, "weak_key") 23 | 24 | if os.path.isfile(keyfile): 25 | with open(keyfile) as f: 26 | TESTINGKEY = f.read() 27 | with open(weak_keyfile) as f: 28 | WEAKTESTINGKEY = f.read() 29 | else: 30 | # from travis-ci or appveyor 31 | TESTINGKEY = os.environ["KEEPAKEY"] 32 | WEAKTESTINGKEY = os.environ["WEAKKEEPAKEY"] 33 | 34 | # Dead Man's Hand (The Unorthodox Chronicles) 35 | # just need an active product with a buybox 36 | PRODUCT_ASIN = "0593440412" 37 | HARD_DRIVE_PRODUCT_ASIN = "B0088PUEPK" 38 | 39 | # ASINs of a bunch of chairs 40 | # categories = API.search_for_categories('chairs') 41 | # asins = [] 42 | # for category in categories: 43 | # asins.extend(API.best_sellers_query(category)) 44 | # PRODUCT_ASINS = asins[:40] 45 | 46 | PRODUCT_ASINS = [ 47 | "B00IAPNWG6", 48 | "B01CUJMSB2", 49 | "B01CUJMRLI", 50 | "B00BMPT7CE", 51 | "B00IAPNWE8", 52 | "B0127O51FK", 53 | "B01CUJMT3E", 54 | "B01A5ZIXKI", 55 | "B00KQPBF1W", 56 | "B000J3UZ58", 57 | "B00196LLDO", 58 | "B002VWK2EE", 59 | "B00E2I3BPM", 60 | "B004FRSUO2", 61 | "B00CM1TJ1G", 62 | "B00VS4514C", 63 | "B075G1B1PK", 64 | "B00R9EAH8U", 65 | "B004L2JKTU", 66 | "B008SIDW2E", 67 | "B078XL8CCW", 68 | "B000VXII46", 69 | "B07D1CJ8CK", 70 | "B07B5HZ7D9", 71 | "B002VWK2EO", 72 | "B000VXII5A", 73 | "B004N1AA5W", 74 | "B002VWKP3W", 75 | "B00CM9OM0G", 76 | "B002VWKP4G", 77 | "B004N18JDC", 78 | "B07MDHF4CP", 79 | "B002VWKP3C", 80 | "B07FTVSNL2", 81 | "B002VWKP5A", 82 | "B002O0LBFW", 83 | "B07BM1Q64Q", 84 | "B004N18JM8", 85 | "B004N1AA02", 86 | "B002VWK2EY", 87 | ] 88 | 89 | 90 | # open connection to keepa 91 | @pytest_asyncio.fixture() 92 | async def api(): 93 | keepa_api = await keepa.AsyncKeepa.create(TESTINGKEY) 94 | yield keepa_api 95 | 96 | 97 | @pytest.mark.asyncio 98 | async def test_deals(api): 99 | deal_parms = { 100 | "page": 0, 101 | "domainId": 1, 102 | "excludeCategories": [1064954, 11091801], 103 | "includeCategories": [16310101], 104 | } 105 | deals = await api.deals(deal_parms) 106 | assert isinstance(deals, dict) 107 | assert isinstance(deals["dr"], list) 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_product_finder_categories(api): 112 | product_parms = {"categories_include": ["1055398"]} 113 | products = await api.product_finder(product_parms) 114 | assert products 115 | 116 | 117 | @pytest.mark.asyncio 118 | async def test_extra_params(api): 119 | # simply ensure that extra parameters are passed. Since this is a duplicate 120 | # parameter, it's expected to fail. 121 | with pytest.raises(TypeError): 122 | await api.query("B0DJHC1PL8", extra_params={"rating": 1}) 123 | 124 | 125 | @pytest.mark.asyncio 126 | async def test_product_finder_query(api): 127 | product_parms = { 128 | "author": "jim butcher", 129 | "page": 1, 130 | "perPage": 50, 131 | "categories_exclude": ["1055398"], 132 | } 133 | asins = await api.product_finder(product_parms) 134 | assert asins 135 | 136 | # using ProductParams 137 | product_parms = keepa.ProductParams( 138 | author="jim butcher", 139 | page=1, 140 | perPage=50, 141 | categories_exclude=["1055398"], 142 | ) 143 | asins = api.product_finder(product_parms) 144 | assert asins 145 | 146 | 147 | # def test_throttling(api): 148 | # api = keepa.Keepa(WEAKTESTINGKEY) 149 | # keepa.interface.REQLIM = 20 150 | 151 | # # exhaust tokens 152 | # while api.tokens_left > 0: 153 | # api.query(PRODUCT_ASINS[:5]) 154 | 155 | # # this must trigger a wait... 156 | # t_start = time.time() 157 | # products = api.query(PRODUCT_ASINS) 158 | # assert (time.time() - t_start) > 1 159 | # keepa.interface.REQLIM = 2 160 | 161 | 162 | @pytest.mark.asyncio 163 | async def test_productquery_raw(api): 164 | with pytest.raises(ValueError): 165 | await api.query(PRODUCT_ASIN, history=False, raw=True) 166 | 167 | 168 | @pytest.mark.asyncio 169 | async def test_productquery_nohistory(api): 170 | pre_update_tokens = api.tokens_left 171 | request = await api.query(PRODUCT_ASIN, history=False) 172 | assert api.tokens_left != pre_update_tokens 173 | 174 | product = request[0] 175 | assert product["csv"] is None 176 | assert product["asin"] == PRODUCT_ASIN 177 | 178 | 179 | @pytest.mark.asyncio 180 | async def test_not_an_asin(api): 181 | with pytest.raises(RuntimeError, match="invalid ASINs"): 182 | asins = ["XXXXXXXXXX"] 183 | await api.query(asins) 184 | 185 | 186 | @pytest.mark.asyncio 187 | async def test_isbn13(api): 188 | isbn13 = "9780786222728" 189 | await api.query(isbn13, product_code_is_asin=False, history=False) 190 | 191 | 192 | @pytest.mark.asyncio 193 | @pytest.mark.xfail # will fail if not run in a while due to timeout 194 | async def test_buybox(api): 195 | request = await api.query(PRODUCT_ASIN, history=True, buybox=True) 196 | product = request[0] 197 | assert "BUY_BOX_SHIPPING" in product["data"] 198 | 199 | 200 | @pytest.mark.asyncio 201 | async def test_productquery_update(api): 202 | request = await api.query(PRODUCT_ASIN, update=0, stats=90, rating=True) 203 | product = request[0] 204 | 205 | # should be live data 206 | now = datetime.datetime.now() 207 | delta = now - product["data"]["USED_time"][-1] 208 | assert delta.days <= 60 209 | 210 | # check for empty arrays 211 | history = product["data"] 212 | for key in history: 213 | if isinstance(history[key], pd.DataFrame): 214 | assert history[key].any().value 215 | else: 216 | assert history[key].any() 217 | 218 | # should be a key pair 219 | if "time" not in key and key[:3] != "df_": 220 | assert history[key].size == history[key + "_time"].size 221 | 222 | # check for stats 223 | assert "stats" in product 224 | 225 | # no offers requested by default 226 | assert "offers" not in product or product["offers"] is None 227 | 228 | 229 | @pytest.mark.asyncio 230 | @pytest.mark.flaky(reruns=3) 231 | async def test_productquery_offers(api): 232 | request = await api.query(PRODUCT_ASIN, offers=20) 233 | product = request[0] 234 | 235 | offers = product["offers"] 236 | for offer in offers: 237 | assert offer["lastSeen"] 238 | assert not len(offer["offerCSV"]) % 3 239 | 240 | # also test offer conversion 241 | offer = offers[1] 242 | times, prices = keepa.convert_offer_history(offer["offerCSV"]) 243 | assert times.dtype == datetime.datetime 244 | assert prices.dtype == np.double 245 | assert len(times) 246 | assert len(prices) 247 | 248 | 249 | @pytest.mark.asyncio 250 | async def test_productquery_offers_invalid(api): 251 | with pytest.raises(ValueError): 252 | await api.query(PRODUCT_ASIN, offers=2000) 253 | 254 | 255 | @pytest.mark.asyncio 256 | async def test_productquery_offers_multiple(api): 257 | products = await api.query(PRODUCT_ASINS) 258 | 259 | asins = np.unique([product["asin"] for product in products]) 260 | assert len(asins) == len(PRODUCT_ASINS) 261 | assert np.isin(asins, PRODUCT_ASINS).all() 262 | 263 | 264 | @pytest.mark.asyncio 265 | async def test_domain(api): 266 | request = await api.query(PRODUCT_ASIN, history=False, domain="DE") 267 | product = request[0] 268 | assert product["asin"] == PRODUCT_ASIN 269 | 270 | 271 | @pytest.mark.asyncio 272 | async def test_invalid_domain(api): 273 | with pytest.raises(ValueError): 274 | await api.query(PRODUCT_ASIN, history=False, domain="XX") 275 | 276 | 277 | @pytest.mark.asyncio 278 | async def test_bestsellers(api): 279 | categories = await api.search_for_categories("chairs") 280 | category = list(categories.items())[0][0] 281 | asins = await api.best_sellers_query(category) 282 | valid_asins = keepa.format_items(asins) 283 | assert len(asins) == valid_asins.size 284 | 285 | 286 | @pytest.mark.asyncio 287 | @pytest.mark.xfail # will fail if not run in a while due to timeout 288 | async def test_buybox_used(api): 289 | # history must be true to get used buybox 290 | request = await api.query(HARD_DRIVE_PRODUCT_ASIN, history=True, offers=20) 291 | df = keepa.process_used_buybox(request[0]["buyBoxUsedHistory"]) 292 | assert isinstance(df, pd.DataFrame) 293 | 294 | 295 | @pytest.mark.asyncio 296 | async def test_categories(api): 297 | categories = await api.search_for_categories("chairs") 298 | catids = list(categories.keys()) 299 | for catid in catids: 300 | assert "chairs" in categories[catid]["name"].lower() 301 | 302 | 303 | @pytest.mark.asyncio 304 | async def test_categorylookup(api): 305 | categories = await api.category_lookup(0) 306 | for cat_id in categories: 307 | assert categories[cat_id]["name"] 308 | 309 | 310 | @pytest.mark.asyncio 311 | async def test_invalid_category(api): 312 | with pytest.raises(Exception): 313 | await api.category_lookup(-1) 314 | 315 | 316 | @pytest.mark.asyncio 317 | async def test_stock(api): 318 | request = await api.query(PRODUCT_ASIN, history=False, stock=True, offers=20) 319 | 320 | # all live offers should have stock 321 | product = request[0] 322 | assert product["offersSuccessful"] 323 | live = product["liveOffersOrder"] 324 | if live is not None: 325 | for offer in product["offers"]: 326 | if offer["offerId"] in live: 327 | if "stockCSV" in offer: 328 | if not offer["stockCSV"][-1]: 329 | warnings.warn(f"No live offers for {PRODUCT_ASIN}") 330 | else: 331 | warnings.warn(f"No live offers for {PRODUCT_ASIN}") 332 | 333 | 334 | @pytest.mark.asyncio 335 | async def test_to_datetime_parm(api): 336 | request = await api.query(PRODUCT_ASIN, to_datetime=True) 337 | product = request[0] 338 | times = product["data"]["AMAZON_time"] 339 | assert isinstance(times[0], datetime.datetime) 340 | 341 | request = await api.query(PRODUCT_ASIN, to_datetime=False) 342 | product = request[0] 343 | times = product["data"]["AMAZON_time"] 344 | assert times[0].dtype == " None: 349 | filename = tmp_path / "out.png" 350 | await api.download_graph_image(PRODUCT_ASIN, filename) 351 | 352 | data = filename.read_bytes() 353 | assert data.startswith(b"\x89PNG\r\n\x1a\n") 354 | 355 | 356 | @pytest.mark.asyncio 357 | async def test_plotting(api): 358 | request = await api.query(PRODUCT_ASIN, history=True) 359 | product = request[0] 360 | keepa.plot_product(product, show=False) 361 | 362 | 363 | @pytest.mark.asyncio 364 | async def test_empty(api): 365 | import matplotlib.pyplot as plt 366 | 367 | plt.close("all") 368 | products = await api.query(["B01I6KT07E", "B01G5BJHVK", "B017LJP1MO"]) 369 | with pytest.raises(Exception): 370 | keepa.plot_product(products[0], show=False) 371 | 372 | 373 | @pytest.mark.asyncio 374 | async def test_seller_query(api): 375 | seller_id = "A2L77EE7U53NWQ" 376 | seller_info = await api.seller_query(seller_id) 377 | assert len(seller_info) == 1 378 | assert seller_id in seller_info 379 | 380 | 381 | @pytest.mark.asyncio 382 | async def test_seller_query_list(api): 383 | seller_id = ["A2L77EE7U53NWQ", "AMMEOJ0MXANX1"] 384 | seller_info = await api.seller_query(seller_id) 385 | assert len(seller_info) == len(seller_id) 386 | assert set(seller_info).issubset(seller_id) 387 | 388 | 389 | @pytest.mark.asyncio 390 | async def test_seller_query_long_list(api): 391 | seller_id = ["A2L77EE7U53NWQ"] * 200 392 | with pytest.raises(RuntimeError): 393 | await api.seller_query(seller_id) 394 | -------------------------------------------------------------------------------- /src/keepa/utils.py: -------------------------------------------------------------------------------- 1 | """Shared utilities module for ``keepa``.""" 2 | 3 | import asyncio 4 | import datetime 5 | from typing import Any 6 | 7 | import numpy as np 8 | import pandas as pd 9 | 10 | from keepa.constants import _SELLER_TIME_DATA_KEYS, DCODES, KEEPA_ST_ORDINAL, csv_indices 11 | from keepa.models.domain import Domain 12 | 13 | 14 | def is_documented_by(original): 15 | """Avoid copying the documentation.""" 16 | 17 | def wrapper(target): 18 | target.__doc__ = original.__doc__ 19 | return target 20 | 21 | return wrapper 22 | 23 | 24 | def _normalize_value(v: int, isfloat: bool, key: str) -> float | None: 25 | """Normalize a single value based on its type and key context.""" 26 | if v < 0: 27 | return None 28 | if isfloat: 29 | v = float(v) / 100 30 | if key == "RATING": 31 | v *= 10 32 | return v 33 | 34 | 35 | def _is_stat_value_skippable(key: str, value: Any) -> bool: 36 | """Determine if the stat value is skippable.""" 37 | if key in { 38 | "buyBoxSellerId", 39 | "sellerIdsLowestFBA", 40 | "sellerIdsLowestFBM", 41 | "buyBoxShippingCountry", 42 | "buyBoxAvailabilityMessage", 43 | }: 44 | return True 45 | 46 | # -1 or -2 --> not exist 47 | if isinstance(value, int) and value < 0: 48 | return True 49 | 50 | return False 51 | 52 | 53 | def _parse_stat_value_list( 54 | value_list: list, to_datetime: bool 55 | ) -> dict[str, float | tuple[Any, float]]: 56 | """Parse a list of stat values into a structured dict.""" 57 | convert_time = any(isinstance(v, list) for v in value_list if v is not None) 58 | result = {} 59 | 60 | for ind, key, isfloat in csv_indices: 61 | item = value_list[ind] if ind < len(value_list) else None 62 | if item is None: 63 | continue 64 | 65 | if convert_time: 66 | ts, val = item 67 | val = _normalize_value(val, isfloat, key) 68 | if val is not None: 69 | ts = keepa_minutes_to_time([ts], to_datetime)[0] 70 | result[key] = (ts, val) 71 | else: 72 | val = _normalize_value(item, isfloat, key) 73 | if val is not None: 74 | result[key] = val 75 | 76 | return result 77 | 78 | 79 | def _parse_stats(stats: dict[str, None, int, list[int]], to_datetime: bool): 80 | """Parse numeric stats object. 81 | 82 | There is no need to parse strings or list of strings. Keepa stats object 83 | response documentation: 84 | https://keepa.com/#!discuss/t/statistics-object/1308 85 | """ 86 | stats_parsed = {} 87 | 88 | for stat_key, stat_value in stats.items(): 89 | if _is_stat_value_skippable(stat_key, stat_value): 90 | continue 91 | 92 | if stat_value is not None: 93 | if stat_key == "lastOffersUpdate": 94 | stats_parsed[stat_key] = keepa_minutes_to_time([stat_value], to_datetime)[0] 95 | elif isinstance(stat_value, list) and len(stat_value) > 0: 96 | stat_value_dict = _parse_stat_value_list(stat_value, to_datetime) 97 | if stat_value_dict: 98 | stats_parsed[stat_key] = stat_value_dict 99 | else: 100 | stats_parsed[stat_key] = stat_value 101 | 102 | return stats_parsed 103 | 104 | 105 | def _parse_seller(seller_raw_response, to_datetime): 106 | sellers = list(seller_raw_response.values()) 107 | for seller in sellers: 108 | 109 | def convert_time_data(key): 110 | date_val = seller.get(key, None) 111 | if date_val is not None: 112 | return (key, keepa_minutes_to_time([date_val], to_datetime)[0]) 113 | else: 114 | return None 115 | 116 | seller.update( 117 | filter(lambda p: p is not None, map(convert_time_data, _SELLER_TIME_DATA_KEYS)) 118 | ) 119 | 120 | return dict(map(lambda seller: (seller["sellerId"], seller), sellers)) 121 | 122 | 123 | def parse_csv(csv, to_datetime: bool = True, out_of_stock_as_nan: bool = True) -> dict[str, Any]: 124 | """ 125 | Parse csv list from keepa into a python dictionary. 126 | 127 | Parameters 128 | ---------- 129 | csv : list 130 | csv list from keepa 131 | to_datetime : bool, default: True 132 | Modifies numpy minutes to datetime.datetime values. 133 | Default True. 134 | out_of_stock_as_nan : bool, optional 135 | When True, prices are NAN when price category is out of stock. 136 | When False, prices are -0.01 137 | Default True 138 | 139 | Returns 140 | ------- 141 | product_data : dict 142 | Dictionary containing the following fields with timestamps: 143 | 144 | AMAZON: Amazon price history 145 | 146 | NEW: Marketplace/3rd party New price history - Amazon is 147 | considered to be part of the marketplace as well, so if 148 | Amazon has the overall lowest new (!) price, the 149 | marketplace new price in the corresponding time interval 150 | will be identical to the Amazon price (except if there is 151 | only one marketplace offer). Shipping and Handling costs 152 | not included! 153 | 154 | USED: Marketplace/3rd party Used price history 155 | 156 | SALES: Sales Rank history. Not every product has a Sales Rank. 157 | 158 | LISTPRICE: List Price history 159 | 160 | 5 COLLECTIBLE: Collectible Price history 161 | 162 | 6 REFURBISHED: Refurbished Price history 163 | 164 | 7 NEW_FBM_SHIPPING: 3rd party (not including Amazon) New price 165 | history including shipping costs, only fulfilled by 166 | merchant (FBM). 167 | 168 | 8 LIGHTNING_DEAL: 3rd party (not including Amazon) New price 169 | history including shipping costs, only fulfilled by 170 | merchant (FBM). 171 | 172 | 9 WAREHOUSE: Amazon Warehouse Deals price history. Mostly of 173 | used condition, rarely new. 174 | 175 | 10 NEW_FBA: Price history of the lowest 3rd party (not 176 | including Amazon/Warehouse) New offer that is fulfilled 177 | by Amazon 178 | 179 | 11 COUNT_NEW: New offer count history 180 | 181 | 12 COUNT_USED: Used offer count history 182 | 183 | 13 COUNT_REFURBISHED: Refurbished offer count history 184 | 185 | 14 COUNT_COLLECTIBLE: Collectible offer count history 186 | 187 | 16 RATING: The product's rating history. A rating is an 188 | integer from 0 to 50 (e.g. 45 = 4.5 stars) 189 | 190 | 17 COUNT_REVIEWS: The product's review count history. 191 | 192 | 18 BUY_BOX_SHIPPING: The price history of the buy box. If no 193 | offer qualified for the buy box the price has the value 194 | -1. Including shipping costs. The ``buybox`` parameter 195 | must be True for this field to be in the data. 196 | 197 | 19 USED_NEW_SHIPPING: "Used - Like New" price history 198 | including shipping costs. 199 | 200 | 20 USED_VERY_GOOD_SHIPPING: "Used - Very Good" price history 201 | including shipping costs. 202 | 203 | 21 USED_GOOD_SHIPPING: "Used - Good" price history including 204 | shipping costs. 205 | 206 | 22 USED_ACCEPTABLE_SHIPPING: "Used - Acceptable" price history 207 | including shipping costs. 208 | 209 | 23 COLLECTIBLE_NEW_SHIPPING: "Collectible - Like New" price 210 | history including shipping costs. 211 | 212 | 24 COLLECTIBLE_VERY_GOOD_SHIPPING: "Collectible - Very Good" 213 | price history including shipping costs. 214 | 215 | 25 COLLECTIBLE_GOOD_SHIPPING: "Collectible - Good" price 216 | history including shipping costs. 217 | 218 | 26 COLLECTIBLE_ACCEPTABLE_SHIPPING: "Collectible - Acceptable" 219 | price history including shipping costs. 220 | 221 | 27 REFURBISHED_SHIPPING: Refurbished price history including 222 | shipping costs. 223 | 224 | 30 TRADE_IN: The trade in price history. Amazon trade-in is 225 | not available for every locale. 226 | 227 | 31 RENT: Rental price history. Requires use of the rental 228 | and offers parameter. Amazon Rental is only available 229 | for Amazon US. 230 | 231 | Notes 232 | ----- 233 | Negative prices 234 | 235 | """ 236 | product_data = {} 237 | 238 | for ind, key, isfloat in csv_indices: 239 | if csv[ind]: # Check if entry it exists 240 | if "SHIPPING" in key: # shipping price is included 241 | # Data goes [time0, value0, shipping0, time1, value1, 242 | # shipping1, ...] 243 | times = csv[ind][::3] 244 | values = np.array(csv[ind][1::3]) 245 | values += np.array(csv[ind][2::3]) 246 | else: 247 | # Data goes [time0, value0, time1, value1, ...] 248 | times = csv[ind][::2] 249 | values = np.array(csv[ind][1::2]) 250 | 251 | # Convert to float price if applicable 252 | if isfloat: 253 | nan_mask = values < 0 254 | values = values.astype(float) / 100 255 | if out_of_stock_as_nan: 256 | values[nan_mask] = np.nan 257 | 258 | if key == "RATING": 259 | values *= 10 260 | 261 | timeval = keepa_minutes_to_time(times, to_datetime) 262 | 263 | product_data["%s_time" % key] = timeval 264 | product_data[key] = values 265 | 266 | # combine time and value into a data frame using time as index 267 | product_data[f"df_{key}"] = pd.DataFrame({"value": values}, index=timeval) 268 | 269 | return product_data 270 | 271 | 272 | def format_items(items): 273 | """Check if the input items are valid and formats them.""" 274 | if isinstance(items, list) or isinstance(items, np.ndarray): 275 | return np.unique(items) 276 | elif isinstance(items, str): 277 | return np.asarray([items]) 278 | 279 | 280 | def _domain_to_dcode(domain: str | Domain) -> int: 281 | """Convert a domain to a domain code.""" 282 | if isinstance(domain, Domain): 283 | domain_str = domain.value 284 | else: 285 | domain_str = domain 286 | 287 | if domain_str not in DCODES: 288 | raise ValueError(f"Invalid domain code {domain}. Should be one of the following:\n{DCODES}") 289 | return DCODES.index(domain_str) 290 | 291 | 292 | def convert_offer_history(csv, to_datetime=True): 293 | """Convert an offer history to human readable values. 294 | 295 | Parameters 296 | ---------- 297 | csv : list 298 | Offer list csv obtained from ``['offerCSV']`` 299 | 300 | to_datetime : bool, optional 301 | Modifies ``numpy`` minutes to ``datetime.datetime`` values. 302 | Default ``True``. 303 | 304 | Returns 305 | ------- 306 | times : numpy.ndarray 307 | List of time values for an offer history. 308 | 309 | prices : numpy.ndarray 310 | Price (including shipping) of an offer for each time at an 311 | index of times. 312 | 313 | """ 314 | # convert these values to numpy arrays 315 | times = csv[::3] 316 | values = np.array(csv[1::3]) 317 | values += np.array(csv[2::3]) # add in shipping 318 | 319 | # convert to dollars and datetimes 320 | times = keepa_minutes_to_time(times, to_datetime) 321 | prices = values / 100.0 322 | return times, prices 323 | 324 | 325 | def _str_to_bool(string: str) -> bool: 326 | if string: 327 | return bool(int(string)) 328 | return False 329 | 330 | 331 | def process_used_buybox(buybox_info: list[str]) -> pd.DataFrame: 332 | """ 333 | Process used buybox information to create a Pandas DataFrame. 334 | 335 | Parameters 336 | ---------- 337 | buybox_info : list of str 338 | A list containing information about used buybox in a specific order: 339 | [Keepa time minutes, seller id, condition, isFBA, ...] 340 | 341 | Returns 342 | ------- 343 | pd.DataFrame 344 | A DataFrame containing four columns: 345 | - 'datetime': Datetime objects converted from Keepa time minutes. 346 | - 'user_id': String representing the seller ID. 347 | - 'condition': String representing the condition of the product. 348 | - 'isFBA': Boolean indicating whether the offer is Fulfilled by Amazon. 349 | 350 | Notes 351 | ----- 352 | The `condition` is mapped from its code to a descriptive string. 353 | The `isFBA` field is converted to a boolean. 354 | 355 | Examples 356 | -------- 357 | Load in product offers and convert the buy box data into a 358 | ``pandas.DataFrame``. 359 | 360 | >>> import keepa 361 | >>> key = "" 362 | >>> api = keepa.Keepa(key) 363 | >>> response = api.query("B0088PUEPK", offers=20) 364 | >>> product = response[0] 365 | >>> buybox_info = product["buyBoxUsedHistory"] 366 | >>> df = keepa.process_used_buybox(buybox_info) 367 | datetime user_id condition isFBA 368 | 0 2022-11-02 16:46:00 A1QUAC68EAM09F Used - Like New True 369 | 1 2022-11-13 10:36:00 A18WXU4I7YR6UA Used - Very Good False 370 | 2 2022-11-15 23:50:00 AYUGEV9WZ4X5O Used - Like New False 371 | 3 2022-11-17 06:16:00 A18WXU4I7YR6UA Used - Very Good False 372 | 4 2022-11-17 10:56:00 AYUGEV9WZ4X5O Used - Like New False 373 | .. ... ... ... ... 374 | 115 2023-10-23 10:00:00 AYUGEV9WZ4X5O Used - Like New False 375 | 116 2023-10-25 21:14:00 A1U9HDFCZO1A84 Used - Like New False 376 | 117 2023-10-26 04:08:00 AYUGEV9WZ4X5O Used - Like New False 377 | 118 2023-10-27 08:14:00 A1U9HDFCZO1A84 Used - Like New False 378 | 119 2023-10-27 12:34:00 AYUGEV9WZ4X5O Used - Like New False 379 | 380 | """ 381 | datetime_arr = [] 382 | user_id_arr = [] 383 | condition_map = { 384 | "": "Unknown", 385 | "2": "Used - Like New", 386 | "3": "Used - Very Good", 387 | "4": "Used - Good", 388 | "5": "Used - Acceptable", 389 | } 390 | condition_arr = [] 391 | isFBA_arr = [] 392 | 393 | for i in range(0, len(buybox_info), 4): 394 | keepa_time = int(buybox_info[i]) 395 | datetime_arr.append(keepa_minutes_to_time([keepa_time])[0]) 396 | user_id_arr.append(buybox_info[i + 1]) 397 | condition_arr.append(condition_map[buybox_info[i + 2]]) 398 | isFBA_arr.append(_str_to_bool(buybox_info[i + 3])) 399 | 400 | df = pd.DataFrame( 401 | { 402 | "datetime": datetime_arr, 403 | "user_id": user_id_arr, 404 | "condition": condition_arr, 405 | "isFBA": isFBA_arr, 406 | } 407 | ) 408 | 409 | return df 410 | 411 | 412 | def keepa_minutes_to_time(minutes, to_datetime=True): 413 | """Accept an array or list of minutes and converts it to a numpy datetime array. 414 | 415 | Assumes that keepa time is from keepa minutes from ordinal. 416 | """ 417 | # Convert to timedelta64 and shift 418 | dt = np.array(minutes, dtype="timedelta64[m]") 419 | dt = dt + KEEPA_ST_ORDINAL # shift from ordinal 420 | 421 | # Convert to datetime if requested 422 | if to_datetime: 423 | return dt.astype(datetime.datetime) 424 | return dt 425 | 426 | 427 | def run_and_get(coro): 428 | """Attempt to run an async request.""" 429 | try: 430 | loop = asyncio.get_event_loop() 431 | except RuntimeError: 432 | loop = asyncio.new_event_loop() 433 | task = loop.create_task(coro) 434 | loop.run_until_complete(task) 435 | return task.result() 436 | -------------------------------------------------------------------------------- /tests/test_interface.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the synchronous interface to the keepa backend. 3 | """ 4 | 5 | from pathlib import Path 6 | import datetime 7 | from itertools import chain 8 | import os 9 | import warnings 10 | 11 | import numpy as np 12 | import pandas as pd 13 | import pytest 14 | import requests 15 | 16 | import keepa 17 | from keepa import keepa_minutes_to_time 18 | from keepa import Keepa 19 | 20 | # reduce the request limit for testing 21 | keepa.keepa_sync.REQLIM = 2 22 | 23 | path = os.path.dirname(os.path.realpath(__file__)) 24 | keyfile = os.path.join(path, "key") 25 | weak_keyfile = os.path.join(path, "weak_key") 26 | 27 | if os.path.isfile(keyfile): 28 | with open(keyfile) as f: 29 | TESTINGKEY = f.read() 30 | with open(weak_keyfile) as f: 31 | WEAKTESTINGKEY = f.read() 32 | else: 33 | # from travis-ci or appveyor 34 | TESTINGKEY = os.environ["KEEPAKEY"] 35 | WEAKTESTINGKEY = os.environ["WEAKKEEPAKEY"] 36 | 37 | # Dead Man's Hand (The Unorthodox Chronicles) 38 | # just need an active product with a buybox 39 | PRODUCT_ASIN = "0593440412" 40 | HARD_DRIVE_PRODUCT_ASIN = "B0088PUEPK" 41 | VIDEO_ASIN = "B0060CU5DE" 42 | 43 | # ASINs of a bunch of chairs generated with 44 | # categories = API.search_for_categories('chairs') 45 | # asins = [] 46 | # for category in categories: 47 | # asins.extend(API.best_sellers_query(category)) 48 | # PRODUCT_ASINS = asins[:40] 49 | 50 | 51 | PRODUCT_ASINS = [ 52 | "B00IAPNWG6", 53 | "B01CUJMSB2", 54 | "B01CUJMRLI", 55 | "B00BMPT7CE", 56 | "B00IAPNWE8", 57 | "B0127O51FK", 58 | "B01CUJMT3E", 59 | "B01A5ZIXKI", 60 | "B00KQPBF1W", 61 | "B000J3UZ58", 62 | "B00196LLDO", 63 | "B002VWK2EE", 64 | "B00E2I3BPM", 65 | "B004FRSUO2", 66 | "B00CM1TJ1G", 67 | "B00VS4514C", 68 | "B075G1B1PK", 69 | "B00R9EAH8U", 70 | "B004L2JKTU", 71 | "B008SIDW2E", 72 | "B078XL8CCW", 73 | "B000VXII46", 74 | "B07D1CJ8CK", 75 | "B07B5HZ7D9", 76 | "B002VWK2EO", 77 | "B000VXII5A", 78 | "B004N1AA5W", 79 | "B002VWKP3W", 80 | "B00CM9OM0G", 81 | "B002VWKP4G", 82 | "B004N18JDC", 83 | "B07MDHF4CP", 84 | "B002VWKP3C", 85 | "B07FTVSNL2", 86 | "B002VWKP5A", 87 | "B002O0LBFW", 88 | "B07BM1Q64Q", 89 | "B004N18JM8", 90 | "B004N1AA02", 91 | "B002VWK2EY", 92 | ] 93 | 94 | 95 | # open connection to keepa 96 | @pytest.fixture(scope="module") 97 | def api() -> Keepa: 98 | return Keepa(TESTINGKEY) 99 | 100 | 101 | def test_deals(api: Keepa) -> None: 102 | deal_parms = { 103 | "page": 0, 104 | "domainId": 1, 105 | "excludeCategories": [1064954, 11091801], 106 | "includeCategories": [16310101], 107 | } 108 | deals = api.deals(deal_parms) 109 | assert isinstance(deals, dict) 110 | assert isinstance(deals["dr"], list) 111 | 112 | 113 | def test_invalidkey(): 114 | with pytest.raises(Exception): 115 | keepa.Api("thisisnotavalidkey") 116 | 117 | 118 | def test_deadkey(): 119 | with pytest.raises(Exception): 120 | # this key returns "payment required" 121 | deadkey = "8ueigrvvnsp5too0atlb5f11veinerkud47p686ekr7vgr9qtj1t1tle15fffkkm" 122 | keepa.Api(deadkey) 123 | 124 | 125 | def test_extra_params(api: keepa.Keepa) -> None: 126 | # simply ensure that extra parameters are passed. Since this is a duplicate 127 | # parameter, it's expected to fail. 128 | with pytest.raises(TypeError): 129 | api.query("B0DJHC1PL8", extra_params={"rating": 1}) 130 | 131 | 132 | def test_product_finder_categories(api): 133 | product_parms = {"categories_include": ["1055398"]} 134 | products = api.product_finder(product_parms) 135 | assert products 136 | 137 | 138 | def test_product_finder_query(api: keepa.Keepa) -> None: 139 | """Test product finder and ensure perPage overrides n_products.""" 140 | per_page_n_products = 50 141 | product_parms = { 142 | "author": "jim butcher", 143 | "page": 1, 144 | "perPage": per_page_n_products, 145 | "categories_exclude": ["1055398"], 146 | } 147 | asins = api.product_finder(product_parms, n_products=100) 148 | assert asins 149 | assert len(asins) == per_page_n_products 150 | 151 | 152 | # def test_throttling(api): 153 | # api = keepa.Keepa(WEAKTESTINGKEY) 154 | # keepa.interface.REQLIM = 20 155 | 156 | # # exhaust tokens 157 | # while api.tokens_left > 0: 158 | # api.query(PRODUCT_ASINS[:5]) 159 | 160 | # # this must trigger a wait... 161 | # t_start = time.time() 162 | # products = api.query(PRODUCT_ASINS) 163 | # assert (time.time() - t_start) > 1 164 | # keepa.interface.REQLIM = 2 165 | 166 | 167 | def test_productquery_raw(api): 168 | request = api.query(PRODUCT_ASIN, history=False, raw=True) 169 | raw = request[0] 170 | assert isinstance(raw, requests.Response) 171 | assert PRODUCT_ASIN in raw.text 172 | 173 | 174 | def test_productquery_nohistory(api): 175 | pre_update_tokens = api.tokens_left 176 | request = api.query(PRODUCT_ASIN, history=False) 177 | assert api.tokens_left != pre_update_tokens 178 | 179 | product = request[0] 180 | assert product["csv"] is None 181 | assert product["asin"] == PRODUCT_ASIN 182 | 183 | 184 | def test_not_an_asin(api): 185 | with pytest.raises(RuntimeError, match="invalid ASINs"): 186 | asins = ["XXXXXXXXXX"] 187 | api.query(asins) 188 | 189 | 190 | def test_isbn13(api): 191 | isbn13 = "9780786222728" 192 | api.query(isbn13, product_code_is_asin=False, history=False) 193 | 194 | 195 | def test_buybox(api: keepa.Keepa) -> None: 196 | request = api.query(PRODUCT_ASIN, history=True, buybox=True) 197 | product = request[0] 198 | assert "BUY_BOX_SHIPPING" in product["data"] 199 | 200 | 201 | def test_productquery_update(api): 202 | request = api.query(PRODUCT_ASIN, update=0, stats=90, rating=True) 203 | product = request[0] 204 | 205 | # should be live data 206 | now = datetime.datetime.now() 207 | delta = now - product["data"]["USED_time"][-1] 208 | assert delta.days <= 60 209 | 210 | # check for empty arrays 211 | history = product["data"] 212 | for key in history: 213 | if isinstance(history[key], pd.DataFrame): 214 | assert history[key].any().value 215 | else: 216 | assert history[key].any() 217 | 218 | # should be a key pair 219 | if "time" not in key and key[:3] != "df_": 220 | assert history[key].size == history[key + "_time"].size 221 | 222 | # check for stats 223 | assert "stats" in product 224 | 225 | # no offers requested by default 226 | assert "offers" not in product or product["offers"] is None 227 | 228 | 229 | def test_productquery_offers(api): 230 | request = api.query(PRODUCT_ASIN, offers=20) 231 | product = request[0] 232 | 233 | offers = product["offers"] 234 | for offer in offers: 235 | assert offer["lastSeen"] 236 | assert not len(offer["offerCSV"]) % 3 237 | 238 | # also test offer conversion 239 | offer = offers[1] 240 | times, prices = keepa.convert_offer_history(offer["offerCSV"]) 241 | assert times.dtype == datetime.datetime 242 | assert prices.dtype == np.double 243 | assert len(times) 244 | assert len(prices) 245 | 246 | 247 | def test_productquery_only_live_offers(api): 248 | """Tests that no historical offer data was returned from response if only_live_offers param was specified.""" 249 | max_offers = 20 250 | request = api.query(PRODUCT_ASIN, offers=max_offers, only_live_offers=True, history=False) 251 | 252 | # there may not be any offers 253 | product_offers = request[0]["offers"] 254 | if product_offers is not None: 255 | # All offers are live and have similar times 256 | last_seen_values = [offer["lastSeen"] for offer in product_offers] 257 | assert np.diff(np.abs(last_seen_values)).mean() < 60 * 24 # within one day 258 | else: 259 | warnings.warn(f"No live offers for {PRODUCT_ASIN}") 260 | 261 | 262 | def test_productquery_days(api, max_days: int = 5): 263 | """Tests that 'days' param limits historical data to X days. 264 | 265 | This includes the csv, buyBoxSellerIdHistory, salesRanks, offers and 266 | offers.offerCSV fields. Each field may contain one day which seems out of 267 | specified range. This means the value of the field has been unchanged since 268 | that date, and was still active at least until the max_days cutoff. 269 | """ 270 | 271 | request = api.query(PRODUCT_ASIN, days=max_days, history=True, offers=20) 272 | product = request[0] 273 | 274 | def convert(minutes): 275 | """Convert keepaminutes to time.""" 276 | times = {keepa_minutes_to_time(keepa_minute).date() for keepa_minute in minutes} 277 | return list(times) 278 | 279 | # Converting each field's list of keepa minutes into flat list of unique days. 280 | sales_ranks = convert(chain.from_iterable(product["salesRanks"].values()))[0::2] 281 | offers = convert(offer["lastSeen"] for offer in product["offers"]) 282 | buy_box_seller_id_history = convert(product["buyBoxSellerIdHistory"][0::2]) 283 | offers_csv = list(convert(offer["offerCSV"][0::3]) for offer in product["offers"]) 284 | df_dates = list( 285 | list(df.axes[0]) for df_name, df in product["data"].items() if "df_" in df_name and any(df) 286 | ) 287 | df_dates = list( 288 | list(datetime.date(year=ts.year, month=ts.month, day=ts.day) for ts in stamps) 289 | for stamps in df_dates 290 | ) 291 | 292 | # Check for out of range days. 293 | today = datetime.date.today() 294 | 295 | def is_out_of_range(d): 296 | return (today - d).days > max_days 297 | 298 | for field_days in [ 299 | sales_ranks, 300 | offers, 301 | buy_box_seller_id_history, 302 | *df_dates, 303 | *offers_csv, 304 | ]: 305 | field_days.sort() 306 | 307 | # let oldest day be out of range 308 | field_days = field_days[1:] if is_out_of_range(field_days[0]) else field_days 309 | for day in field_days: 310 | if is_out_of_range(day): 311 | warnings.warn(f'Day "{day}" is older than {max_days} from today') 312 | 313 | 314 | def test_productquery_offers_invalid(api): 315 | with pytest.raises(ValueError): 316 | api.query(PRODUCT_ASIN, offers=2000) 317 | 318 | 319 | def test_productquery_offers_multiple(api): 320 | products = api.query(PRODUCT_ASINS) 321 | 322 | asins = np.unique([product["asin"] for product in products]) 323 | assert len(asins) == len(PRODUCT_ASINS) 324 | assert np.isin(asins, PRODUCT_ASINS).all() 325 | 326 | 327 | def test_domain(api: Keepa) -> None: 328 | """A domain different than the default.""" 329 | asin = "3492704794" 330 | request = api.query(asin, history=False, domain=keepa.Domain.DE) 331 | product = request[0] 332 | assert product["asin"] == asin 333 | 334 | 335 | def test_invalid_domain(api): 336 | with pytest.raises(ValueError): 337 | api.query(PRODUCT_ASIN, domain="XX") 338 | 339 | 340 | def test_bestsellers(api): 341 | categories = api.search_for_categories("chairs") 342 | category = list(categories.items())[0][0] 343 | asins = api.best_sellers_query(category) 344 | valid_asins = keepa.format_items(asins) 345 | assert len(asins) == valid_asins.size 346 | 347 | 348 | @pytest.mark.xfail # will fail if not run in a while due to timeout 349 | def test_buybox_used(api): 350 | request = api.query(HARD_DRIVE_PRODUCT_ASIN, history=True, offers=20) 351 | df = keepa.process_used_buybox(request[0]["buyBoxUsedHistory"]) 352 | assert isinstance(df, pd.DataFrame) 353 | 354 | 355 | def test_categories(api): 356 | categories = api.search_for_categories("chairs") 357 | catids = list(categories.keys()) 358 | for catid in catids: 359 | assert "chairs" in categories[catid]["name"].lower() 360 | 361 | 362 | def test_categorylookup(api): 363 | categories = api.category_lookup(0) 364 | for cat_id in categories: 365 | assert categories[cat_id]["name"] 366 | 367 | 368 | def test_invalid_category(api): 369 | with pytest.raises(Exception): 370 | api.category_lookup(-1) 371 | 372 | 373 | def test_stock(api): 374 | request = api.query(PRODUCT_ASIN, history=False, stock=True, offers=20) 375 | 376 | # all live offers must have stock 377 | product = request[0] 378 | assert product["offersSuccessful"] 379 | live = product["liveOffersOrder"] 380 | if live is not None: 381 | for offer in product["offers"]: 382 | if offer["offerId"] in live: 383 | if "stockCSV" in offer: 384 | if not offer["stockCSV"][-1]: 385 | warnings.warn(f"No live offers for {PRODUCT_ASIN}") 386 | else: 387 | warnings.warn(f"No live offers for {PRODUCT_ASIN}") 388 | 389 | 390 | def test_keepatime(api): 391 | keepa_st_ordinal = datetime.datetime(2011, 1, 1) 392 | assert keepa_st_ordinal == keepa.keepa_minutes_to_time(0) 393 | assert keepa.keepa_minutes_to_time(0, to_datetime=False) 394 | 395 | 396 | def test_to_datetime_parm(api: Keepa) -> None: 397 | product = api.query(PRODUCT_ASIN, to_datetime=True)[0] 398 | times = product["data"]["AMAZON_time"] 399 | assert isinstance(times[0], datetime.datetime) 400 | 401 | product = api.query(PRODUCT_ASIN, to_datetime=False)[0] 402 | times = product["data"]["AMAZON_time"] 403 | assert times[0].dtype == " None: 407 | filename = tmp_path / "out.png" 408 | api.download_graph_image( 409 | asin=PRODUCT_ASIN, 410 | filename=filename, 411 | domain="US", 412 | amazon=1, 413 | new=1, 414 | used=1, 415 | bb=1, 416 | fba=1, 417 | range=365, 418 | width=800, 419 | height=400, 420 | cBackground="ffffff", 421 | cAmazon="FFA500", 422 | cNew="8888dd", 423 | cUsed="444444", 424 | cBB="ff00b4", 425 | cFBA="ff5722", 426 | ) 427 | 428 | data = filename.read_bytes() 429 | assert data.startswith(b"\x89PNG\r\n\x1a\n") 430 | 431 | 432 | def test_plotting(api: Keepa) -> None: 433 | request = api.query(PRODUCT_ASIN, history=True) 434 | product = request[0] 435 | keepa.plot_product(product, show=False) 436 | 437 | 438 | def test_empty(api): 439 | import matplotlib.pyplot as plt 440 | 441 | plt.close("all") 442 | products = api.query(["B01I6KT07E", "B01G5BJHVK", "B017LJP1MO"]) 443 | with pytest.raises(Exception): 444 | keepa.plot_product(products[0], show=False) 445 | 446 | 447 | def test_seller_query(api): 448 | seller_id = "A2L77EE7U53NWQ" 449 | seller_info = api.seller_query(seller_id) 450 | assert len(seller_info) == 1 451 | assert seller_id in seller_info 452 | 453 | 454 | def test_seller_query_list(api): 455 | seller_id = ["A2L77EE7U53NWQ", "AMMEOJ0MXANX1"] 456 | seller_info = api.seller_query(seller_id) 457 | assert len(seller_info) == len(seller_id) 458 | assert set(seller_info).issubset(seller_id) 459 | 460 | 461 | def test_seller_query_long_list(api): 462 | seller_id = ["A2L77EE7U53NWQ"] * 200 463 | with pytest.raises(RuntimeError): 464 | api.seller_query(seller_id) 465 | 466 | 467 | def test_video_query(api: keepa.Keepa) -> None: 468 | """Test if the videos query parameter works.""" 469 | response = api.query("B00UFMKSDW", history=False, videos=False) 470 | product = response[0] 471 | assert "videos" not in product 472 | 473 | response = api.query("B00UFMKSDW", history=False, videos=True) 474 | product = response[0] 475 | assert "videos" in product 476 | 477 | 478 | def test_aplus(api: keepa.Keepa) -> None: 479 | product_nominal = api.query("B0DDDD8WD6", history=False, aplus=False)[0] 480 | assert "aPlus" not in product_nominal 481 | product_aplus = api.query("B0DDDD8WD6", history=False, aplus=True)[0] 482 | assert "aPlus" in product_aplus 483 | -------------------------------------------------------------------------------- /src/keepa/keepa_async.py: -------------------------------------------------------------------------------- 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 `_ 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 = "" 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 = "" 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}") 567 | -------------------------------------------------------------------------------- /src/keepa/query_keys.py: -------------------------------------------------------------------------------- 1 | """Keepa query keys.""" 2 | 3 | DEAL_REQUEST_KEYS = { 4 | "page": int, 5 | "domainId": int, 6 | "excludeCategories": list, 7 | "includeCategories": list, 8 | "priceTypes": list, 9 | "deltaRange": list, 10 | "deltaPercentRange": list, 11 | "deltaLastRange": list, 12 | "salesRankRange": list, 13 | "currentRange": list, 14 | "minRating": int, 15 | "isLowest": bool, 16 | "isLowestOffer": bool, 17 | "isOutOfStock": bool, 18 | "titleSearch": str, 19 | "isRangeEnabled": bool, 20 | "isFilterEnabled": bool, 21 | "hasReviews": bool, 22 | "filterErotic": bool, 23 | "singleVariation": bool, 24 | "sortType": int, 25 | "dateRange": int, 26 | "isPrimeExclusive": bool, 27 | "mustHaveAmazonOffer": bool, 28 | "mustNotHaveAmazonOffer": bool, 29 | "warehouseConditions": list, 30 | "hasAmazonOffer": bool, 31 | } 32 | 33 | 34 | PRODUCT_REQUEST_KEYS = { 35 | "author": list, 36 | "availabilityAmazon": int, 37 | "avg180_AMAZON_lte": int, 38 | "avg180_AMAZON_gte": int, 39 | "avg180_BUY_BOX_SHIPPING_lte": int, 40 | "avg180_BUY_BOX_SHIPPING_gte": int, 41 | "avg180_COLLECTIBLE_lte": int, 42 | "avg180_COLLECTIBLE_gte": int, 43 | "avg180_COUNT_COLLECTIBLE_lte": int, 44 | "avg180_COUNT_COLLECTIBLE_gte": int, 45 | "avg180_COUNT_NEW_lte": int, 46 | "avg180_COUNT_NEW_gte": int, 47 | "avg180_COUNT_REFURBISHED_lte": int, 48 | "avg180_COUNT_REFURBISHED_gte": int, 49 | "avg180_COUNT_REVIEWS_lte": int, 50 | "avg180_COUNT_REVIEWS_gte": int, 51 | "avg180_COUNT_USED_lte": int, 52 | "avg180_COUNT_USED_gte": int, 53 | "avg180_EBAY_NEW_SHIPPING_lte": int, 54 | "avg180_EBAY_NEW_SHIPPING_gte": int, 55 | "avg180_EBAY_USED_SHIPPING_lte": int, 56 | "avg180_EBAY_USED_SHIPPING_gte": int, 57 | "avg180_LIGHTNING_DEAL_lte": int, 58 | "avg180_LIGHTNING_DEAL_gte": int, 59 | "avg180_LISTPRICE_lte": int, 60 | "avg180_LISTPRICE_gte": int, 61 | "avg180_NEW_lte": int, 62 | "avg180_NEW_gte": int, 63 | "avg180_NEW_FBA_lte": int, 64 | "avg180_NEW_FBA_gte": int, 65 | "avg180_NEW_FBM_SHIPPING_lte": int, 66 | "avg180_NEW_FBM_SHIPPING_gte": int, 67 | "avg180_RATING_lte": int, 68 | "avg180_RATING_gte": int, 69 | "avg180_REFURBISHED_lte": int, 70 | "avg180_REFURBISHED_gte": int, 71 | "avg180_REFURBISHED_SHIPPING_lte": int, 72 | "avg180_REFURBISHED_SHIPPING_gte": int, 73 | "avg180_RENT_lte": int, 74 | "avg180_RENT_gte": int, 75 | "avg180_SALES_lte": int, 76 | "avg180_SALES_gte": int, 77 | "avg180_TRADE_IN_lte": int, 78 | "avg180_TRADE_IN_gte": int, 79 | "avg180_USED_lte": int, 80 | "avg180_USED_gte": int, 81 | "avg180_USED_ACCEPTABLE_SHIPPING_lte": int, 82 | "avg180_USED_ACCEPTABLE_SHIPPING_gte": int, 83 | "avg180_USED_GOOD_SHIPPING_lte": int, 84 | "avg180_USED_GOOD_SHIPPING_gte": int, 85 | "avg180_USED_NEW_SHIPPING_lte": int, 86 | "avg180_USED_NEW_SHIPPING_gte": int, 87 | "avg180_USED_VERY_GOOD_SHIPPING_lte": int, 88 | "avg180_USED_VERY_GOOD_SHIPPING_gte": int, 89 | "avg180_WAREHOUSE_lte": int, 90 | "avg180_WAREHOUSE_gte": int, 91 | "avg1_AMAZON_lte": int, 92 | "avg1_AMAZON_gte": int, 93 | "avg1_BUY_BOX_SHIPPING_lte": int, 94 | "avg1_BUY_BOX_SHIPPING_gte": int, 95 | "avg1_COLLECTIBLE_lte": int, 96 | "avg1_COLLECTIBLE_gte": int, 97 | "avg1_COUNT_COLLECTIBLE_lte": int, 98 | "avg1_COUNT_COLLECTIBLE_gte": int, 99 | "avg1_COUNT_NEW_lte": int, 100 | "avg1_COUNT_NEW_gte": int, 101 | "avg1_COUNT_REFURBISHED_lte": int, 102 | "avg1_COUNT_REFURBISHED_gte": int, 103 | "avg1_COUNT_REVIEWS_lte": int, 104 | "avg1_COUNT_REVIEWS_gte": int, 105 | "avg1_COUNT_USED_lte": int, 106 | "avg1_COUNT_USED_gte": int, 107 | "avg1_EBAY_NEW_SHIPPING_lte": int, 108 | "avg1_EBAY_NEW_SHIPPING_gte": int, 109 | "avg1_EBAY_USED_SHIPPING_lte": int, 110 | "avg1_EBAY_USED_SHIPPING_gte": int, 111 | "avg1_LIGHTNING_DEAL_lte": int, 112 | "avg1_LIGHTNING_DEAL_gte": int, 113 | "avg1_LISTPRICE_lte": int, 114 | "avg1_LISTPRICE_gte": int, 115 | "avg1_NEW_lte": int, 116 | "avg1_NEW_gte": int, 117 | "avg1_NEW_FBA_lte": int, 118 | "avg1_NEW_FBA_gte": int, 119 | "avg1_NEW_FBM_SHIPPING_lte": int, 120 | "avg1_NEW_FBM_SHIPPING_gte": int, 121 | "avg1_RATING_lte": int, 122 | "avg1_RATING_gte": int, 123 | "avg1_REFURBISHED_lte": int, 124 | "avg1_REFURBISHED_gte": int, 125 | "avg1_REFURBISHED_SHIPPING_lte": int, 126 | "avg1_REFURBISHED_SHIPPING_gte": int, 127 | "avg1_RENT_lte": int, 128 | "avg1_RENT_gte": int, 129 | "avg1_SALES_lte": int, 130 | "avg1_SALES_gte": int, 131 | "avg1_TRADE_IN_lte": int, 132 | "avg1_TRADE_IN_gte": int, 133 | "avg1_USED_lte": int, 134 | "avg1_USED_gte": int, 135 | "avg1_USED_ACCEPTABLE_SHIPPING_lte": int, 136 | "avg1_USED_ACCEPTABLE_SHIPPING_gte": int, 137 | "avg1_USED_GOOD_SHIPPING_lte": int, 138 | "avg1_USED_GOOD_SHIPPING_gte": int, 139 | "avg1_USED_NEW_SHIPPING_lte": int, 140 | "avg1_USED_NEW_SHIPPING_gte": int, 141 | "avg1_USED_VERY_GOOD_SHIPPING_lte": int, 142 | "avg1_USED_VERY_GOOD_SHIPPING_gte": int, 143 | "avg1_WAREHOUSE_lte": int, 144 | "avg1_WAREHOUSE_gte": int, 145 | "avg30_AMAZON_lte": int, 146 | "avg30_AMAZON_gte": int, 147 | "avg30_BUY_BOX_SHIPPING_lte": int, 148 | "avg30_BUY_BOX_SHIPPING_gte": int, 149 | "avg30_COLLECTIBLE_lte": int, 150 | "avg30_COLLECTIBLE_gte": int, 151 | "avg30_COUNT_COLLECTIBLE_lte": int, 152 | "avg30_COUNT_COLLECTIBLE_gte": int, 153 | "avg30_COUNT_NEW_lte": int, 154 | "avg30_COUNT_NEW_gte": int, 155 | "avg30_COUNT_REFURBISHED_lte": int, 156 | "avg30_COUNT_REFURBISHED_gte": int, 157 | "avg30_COUNT_REVIEWS_lte": int, 158 | "avg30_COUNT_REVIEWS_gte": int, 159 | "avg30_COUNT_USED_lte": int, 160 | "avg30_COUNT_USED_gte": int, 161 | "avg30_EBAY_NEW_SHIPPING_lte": int, 162 | "avg30_EBAY_NEW_SHIPPING_gte": int, 163 | "avg30_EBAY_USED_SHIPPING_lte": int, 164 | "avg30_EBAY_USED_SHIPPING_gte": int, 165 | "avg30_LIGHTNING_DEAL_lte": int, 166 | "avg30_LIGHTNING_DEAL_gte": int, 167 | "avg30_LISTPRICE_lte": int, 168 | "avg30_LISTPRICE_gte": int, 169 | "avg30_NEW_lte": int, 170 | "avg30_NEW_gte": int, 171 | "avg30_NEW_FBA_lte": int, 172 | "avg30_NEW_FBA_gte": int, 173 | "avg30_NEW_FBM_SHIPPING_lte": int, 174 | "avg30_NEW_FBM_SHIPPING_gte": int, 175 | "avg30_RATING_lte": int, 176 | "avg30_RATING_gte": int, 177 | "avg30_REFURBISHED_lte": int, 178 | "avg30_REFURBISHED_gte": int, 179 | "avg30_REFURBISHED_SHIPPING_lte": int, 180 | "avg30_REFURBISHED_SHIPPING_gte": int, 181 | "avg30_RENT_lte": int, 182 | "avg30_RENT_gte": int, 183 | "avg30_SALES_lte": int, 184 | "avg30_SALES_gte": int, 185 | "avg30_TRADE_IN_lte": int, 186 | "avg30_TRADE_IN_gte": int, 187 | "avg30_USED_lte": int, 188 | "avg30_USED_gte": int, 189 | "avg30_USED_ACCEPTABLE_SHIPPING_lte": int, 190 | "avg30_USED_ACCEPTABLE_SHIPPING_gte": int, 191 | "avg30_USED_GOOD_SHIPPING_lte": int, 192 | "avg30_USED_GOOD_SHIPPING_gte": int, 193 | "avg30_USED_NEW_SHIPPING_lte": int, 194 | "avg30_USED_NEW_SHIPPING_gte": int, 195 | "avg30_USED_VERY_GOOD_SHIPPING_lte": int, 196 | "avg30_USED_VERY_GOOD_SHIPPING_gte": int, 197 | "avg30_WAREHOUSE_lte": int, 198 | "avg30_WAREHOUSE_gte": int, 199 | "avg7_AMAZON_lte": int, 200 | "avg7_AMAZON_gte": int, 201 | "avg7_BUY_BOX_SHIPPING_lte": int, 202 | "avg7_BUY_BOX_SHIPPING_gte": int, 203 | "avg7_COLLECTIBLE_lte": int, 204 | "avg7_COLLECTIBLE_gte": int, 205 | "avg7_COUNT_COLLECTIBLE_lte": int, 206 | "avg7_COUNT_COLLECTIBLE_gte": int, 207 | "avg7_COUNT_NEW_lte": int, 208 | "avg7_COUNT_NEW_gte": int, 209 | "avg7_COUNT_REFURBISHED_lte": int, 210 | "avg7_COUNT_REFURBISHED_gte": int, 211 | "avg7_COUNT_REVIEWS_lte": int, 212 | "avg7_COUNT_REVIEWS_gte": int, 213 | "avg7_COUNT_USED_lte": int, 214 | "avg7_COUNT_USED_gte": int, 215 | "avg7_EBAY_NEW_SHIPPING_lte": int, 216 | "avg7_EBAY_NEW_SHIPPING_gte": int, 217 | "avg7_EBAY_USED_SHIPPING_lte": int, 218 | "avg7_EBAY_USED_SHIPPING_gte": int, 219 | "avg7_LIGHTNING_DEAL_lte": int, 220 | "avg7_LIGHTNING_DEAL_gte": int, 221 | "avg7_LISTPRICE_lte": int, 222 | "avg7_LISTPRICE_gte": int, 223 | "avg7_NEW_lte": int, 224 | "avg7_NEW_gte": int, 225 | "avg7_NEW_FBA_lte": int, 226 | "avg7_NEW_FBA_gte": int, 227 | "avg7_NEW_FBM_SHIPPING_lte": int, 228 | "avg7_NEW_FBM_SHIPPING_gte": int, 229 | "avg7_RATING_lte": int, 230 | "avg7_RATING_gte": int, 231 | "avg7_REFURBISHED_lte": int, 232 | "avg7_REFURBISHED_gte": int, 233 | "avg7_REFURBISHED_SHIPPING_lte": int, 234 | "avg7_REFURBISHED_SHIPPING_gte": int, 235 | "avg7_RENT_lte": int, 236 | "avg7_RENT_gte": int, 237 | "avg7_SALES_lte": int, 238 | "avg7_SALES_gte": int, 239 | "avg7_TRADE_IN_lte": int, 240 | "avg7_TRADE_IN_gte": int, 241 | "avg7_USED_lte": int, 242 | "avg7_USED_gte": int, 243 | "avg7_USED_ACCEPTABLE_SHIPPING_lte": int, 244 | "avg7_USED_ACCEPTABLE_SHIPPING_gte": int, 245 | "avg7_USED_GOOD_SHIPPING_lte": int, 246 | "avg7_USED_GOOD_SHIPPING_gte": int, 247 | "avg7_USED_NEW_SHIPPING_lte": int, 248 | "avg7_USED_NEW_SHIPPING_gte": int, 249 | "avg7_USED_VERY_GOOD_SHIPPING_lte": int, 250 | "avg7_USED_VERY_GOOD_SHIPPING_gte": int, 251 | "avg7_WAREHOUSE_lte": int, 252 | "avg7_WAREHOUSE_gte": int, 253 | "avg90_AMAZON_lte": int, 254 | "avg90_AMAZON_gte": int, 255 | "avg90_BUY_BOX_SHIPPING_lte": int, 256 | "avg90_BUY_BOX_SHIPPING_gte": int, 257 | "avg90_COLLECTIBLE_lte": int, 258 | "avg90_COLLECTIBLE_gte": int, 259 | "avg90_COUNT_COLLECTIBLE_lte": int, 260 | "avg90_COUNT_COLLECTIBLE_gte": int, 261 | "avg90_COUNT_NEW_lte": int, 262 | "avg90_COUNT_NEW_gte": int, 263 | "avg90_COUNT_REFURBISHED_lte": int, 264 | "avg90_COUNT_REFURBISHED_gte": int, 265 | "avg90_COUNT_REVIEWS_lte": int, 266 | "avg90_COUNT_REVIEWS_gte": int, 267 | "avg90_COUNT_USED_lte": int, 268 | "avg90_COUNT_USED_gte": int, 269 | "avg90_EBAY_NEW_SHIPPING_lte": int, 270 | "avg90_EBAY_NEW_SHIPPING_gte": int, 271 | "avg90_EBAY_USED_SHIPPING_lte": int, 272 | "avg90_EBAY_USED_SHIPPING_gte": int, 273 | "avg90_LIGHTNING_DEAL_lte": int, 274 | "avg90_LIGHTNING_DEAL_gte": int, 275 | "avg90_LISTPRICE_lte": int, 276 | "avg90_LISTPRICE_gte": int, 277 | "avg90_NEW_lte": int, 278 | "avg90_NEW_gte": int, 279 | "avg90_NEW_FBA_lte": int, 280 | "avg90_NEW_FBA_gte": int, 281 | "avg90_NEW_FBM_SHIPPING_lte": int, 282 | "avg90_NEW_FBM_SHIPPING_gte": int, 283 | "avg90_RATING_lte": int, 284 | "avg90_RATING_gte": int, 285 | "avg90_REFURBISHED_lte": int, 286 | "avg90_REFURBISHED_gte": int, 287 | "avg90_REFURBISHED_SHIPPING_lte": int, 288 | "avg90_REFURBISHED_SHIPPING_gte": int, 289 | "avg90_RENT_lte": int, 290 | "avg90_RENT_gte": int, 291 | "avg90_SALES_lte": int, 292 | "avg90_SALES_gte": int, 293 | "avg90_TRADE_IN_lte": int, 294 | "avg90_TRADE_IN_gte": int, 295 | "avg90_USED_lte": int, 296 | "avg90_USED_gte": int, 297 | "avg90_USED_ACCEPTABLE_SHIPPING_lte": int, 298 | "avg90_USED_ACCEPTABLE_SHIPPING_gte": int, 299 | "avg90_USED_GOOD_SHIPPING_lte": int, 300 | "avg90_USED_GOOD_SHIPPING_gte": int, 301 | "avg90_USED_NEW_SHIPPING_lte": int, 302 | "avg90_USED_NEW_SHIPPING_gte": int, 303 | "avg90_USED_VERY_GOOD_SHIPPING_lte": int, 304 | "avg90_USED_VERY_GOOD_SHIPPING_gte": int, 305 | "avg90_WAREHOUSE_lte": int, 306 | "avg90_WAREHOUSE_gte": int, 307 | "backInStock_AMAZON": bool, 308 | "backInStock_BUY_BOX_SHIPPING": bool, 309 | "backInStock_COLLECTIBLE": bool, 310 | "backInStock_COUNT_COLLECTIBLE": bool, 311 | "backInStock_COUNT_NEW": bool, 312 | "backInStock_COUNT_REFURBISHED": bool, 313 | "backInStock_COUNT_REVIEWS": bool, 314 | "backInStock_COUNT_USED": bool, 315 | "backInStock_EBAY_NEW_SHIPPING": bool, 316 | "backInStock_EBAY_USED_SHIPPING": bool, 317 | "backInStock_LIGHTNING_DEAL": bool, 318 | "backInStock_LISTPRICE": bool, 319 | "backInStock_NEW": bool, 320 | "backInStock_NEW_FBA": bool, 321 | "backInStock_NEW_FBM_SHIPPING": bool, 322 | "backInStock_RATING": bool, 323 | "backInStock_REFURBISHED": bool, 324 | "backInStock_REFURBISHED_SHIPPING": bool, 325 | "backInStock_RENT": bool, 326 | "backInStock_SALES": bool, 327 | "backInStock_TRADE_IN": bool, 328 | "backInStock_USED": bool, 329 | "backInStock_USED_ACCEPTABLE_SHIPPING": bool, 330 | "backInStock_USED_GOOD_SHIPPING": bool, 331 | "backInStock_USED_NEW_SHIPPING": bool, 332 | "backInStock_USED_VERY_GOOD_SHIPPING": bool, 333 | "backInStock_WAREHOUSE": bool, 334 | "binding": list, 335 | "brand": list, 336 | "buyBoxSellerId": str, 337 | "categories_include": list, 338 | "categories_exclude": list, 339 | "color": list, 340 | "couponOneTimeAbsolute_lte": int, 341 | "couponOneTimeAbsolute_gte": int, 342 | "couponOneTimePercent_lte": int, 343 | "couponOneTimePercent_gte": int, 344 | "couponSNSAbsolute_lte": int, 345 | "couponSNSAbsolute_gte": int, 346 | "couponSNSPercent_lte": int, 347 | "couponSNSPercent_gte": int, 348 | "current_AMAZON_lte": int, 349 | "current_AMAZON_gte": int, 350 | "current_BUY_BOX_SHIPPING_lte": int, 351 | "current_BUY_BOX_SHIPPING_gte": int, 352 | "current_COLLECTIBLE_lte": int, 353 | "current_COLLECTIBLE_gte": int, 354 | "current_COUNT_COLLECTIBLE_lte": int, 355 | "current_COUNT_COLLECTIBLE_gte": int, 356 | "current_COUNT_NEW_lte": int, 357 | "current_COUNT_NEW_gte": int, 358 | "current_COUNT_REFURBISHED_lte": int, 359 | "current_COUNT_REFURBISHED_gte": int, 360 | "current_COUNT_REVIEWS_lte": int, 361 | "current_COUNT_REVIEWS_gte": int, 362 | "current_COUNT_USED_lte": int, 363 | "current_COUNT_USED_gte": int, 364 | "current_EBAY_NEW_SHIPPING_lte": int, 365 | "current_EBAY_NEW_SHIPPING_gte": int, 366 | "current_EBAY_USED_SHIPPING_lte": int, 367 | "current_EBAY_USED_SHIPPING_gte": int, 368 | "current_LIGHTNING_DEAL_lte": int, 369 | "current_LIGHTNING_DEAL_gte": int, 370 | "current_LISTPRICE_lte": int, 371 | "current_LISTPRICE_gte": int, 372 | "current_NEW_lte": int, 373 | "current_NEW_gte": int, 374 | "current_NEW_FBA_lte": int, 375 | "current_NEW_FBA_gte": int, 376 | "current_NEW_FBM_SHIPPING_lte": int, 377 | "current_NEW_FBM_SHIPPING_gte": int, 378 | "current_RATING_lte": int, 379 | "current_RATING_gte": int, 380 | "current_REFURBISHED_lte": int, 381 | "current_REFURBISHED_gte": int, 382 | "current_REFURBISHED_SHIPPING_lte": int, 383 | "current_REFURBISHED_SHIPPING_gte": int, 384 | "current_RENT_lte": int, 385 | "current_RENT_gte": int, 386 | "current_SALES_lte": int, 387 | "current_SALES_gte": int, 388 | "current_TRADE_IN_lte": int, 389 | "current_TRADE_IN_gte": int, 390 | "current_USED_lte": int, 391 | "current_USED_gte": int, 392 | "current_USED_ACCEPTABLE_SHIPPING_lte": int, 393 | "current_USED_ACCEPTABLE_SHIPPING_gte": int, 394 | "current_USED_GOOD_SHIPPING_lte": int, 395 | "current_USED_GOOD_SHIPPING_gte": int, 396 | "current_USED_NEW_SHIPPING_lte": int, 397 | "current_USED_NEW_SHIPPING_gte": int, 398 | "current_USED_VERY_GOOD_SHIPPING_lte": int, 399 | "current_USED_VERY_GOOD_SHIPPING_gte": int, 400 | "current_WAREHOUSE_lte": int, 401 | "current_WAREHOUSE_gte": int, 402 | "delta1_AMAZON_lte": int, 403 | "delta1_AMAZON_gte": int, 404 | "delta1_BUY_BOX_SHIPPING_lte": int, 405 | "delta1_BUY_BOX_SHIPPING_gte": int, 406 | "delta1_COLLECTIBLE_lte": int, 407 | "delta1_COLLECTIBLE_gte": int, 408 | "delta1_COUNT_COLLECTIBLE_lte": int, 409 | "delta1_COUNT_COLLECTIBLE_gte": int, 410 | "delta1_COUNT_NEW_lte": int, 411 | "delta1_COUNT_NEW_gte": int, 412 | "delta1_COUNT_REFURBISHED_lte": int, 413 | "delta1_COUNT_REFURBISHED_gte": int, 414 | "delta1_COUNT_REVIEWS_lte": int, 415 | "delta1_COUNT_REVIEWS_gte": int, 416 | "delta1_COUNT_USED_lte": int, 417 | "delta1_COUNT_USED_gte": int, 418 | "delta1_EBAY_NEW_SHIPPING_lte": int, 419 | "delta1_EBAY_NEW_SHIPPING_gte": int, 420 | "delta1_EBAY_USED_SHIPPING_lte": int, 421 | "delta1_EBAY_USED_SHIPPING_gte": int, 422 | "delta1_LIGHTNING_DEAL_lte": int, 423 | "delta1_LIGHTNING_DEAL_gte": int, 424 | "delta1_LISTPRICE_lte": int, 425 | "delta1_LISTPRICE_gte": int, 426 | "delta1_NEW_lte": int, 427 | "delta1_NEW_gte": int, 428 | "delta1_NEW_FBA_lte": int, 429 | "delta1_NEW_FBA_gte": int, 430 | "delta1_NEW_FBM_SHIPPING_lte": int, 431 | "delta1_NEW_FBM_SHIPPING_gte": int, 432 | "delta1_RATING_lte": int, 433 | "delta1_RATING_gte": int, 434 | "delta1_REFURBISHED_lte": int, 435 | "delta1_REFURBISHED_gte": int, 436 | "delta1_REFURBISHED_SHIPPING_lte": int, 437 | "delta1_REFURBISHED_SHIPPING_gte": int, 438 | "delta1_RENT_lte": int, 439 | "delta1_RENT_gte": int, 440 | "delta1_SALES_lte": int, 441 | "delta1_SALES_gte": int, 442 | "delta1_TRADE_IN_lte": int, 443 | "delta1_TRADE_IN_gte": int, 444 | "delta1_USED_lte": int, 445 | "delta1_USED_gte": int, 446 | "delta1_USED_ACCEPTABLE_SHIPPING_lte": int, 447 | "delta1_USED_ACCEPTABLE_SHIPPING_gte": int, 448 | "delta1_USED_GOOD_SHIPPING_lte": int, 449 | "delta1_USED_GOOD_SHIPPING_gte": int, 450 | "delta1_USED_NEW_SHIPPING_lte": int, 451 | "delta1_USED_NEW_SHIPPING_gte": int, 452 | "delta1_USED_VERY_GOOD_SHIPPING_lte": int, 453 | "delta1_USED_VERY_GOOD_SHIPPING_gte": int, 454 | "delta1_WAREHOUSE_lte": int, 455 | "delta1_WAREHOUSE_gte": int, 456 | "delta30_AMAZON_lte": int, 457 | "delta30_AMAZON_gte": int, 458 | "delta30_BUY_BOX_SHIPPING_lte": int, 459 | "delta30_BUY_BOX_SHIPPING_gte": int, 460 | "delta30_COLLECTIBLE_lte": int, 461 | "delta30_COLLECTIBLE_gte": int, 462 | "delta30_COUNT_COLLECTIBLE_lte": int, 463 | "delta30_COUNT_COLLECTIBLE_gte": int, 464 | "delta30_COUNT_NEW_lte": int, 465 | "delta30_COUNT_NEW_gte": int, 466 | "delta30_COUNT_REFURBISHED_lte": int, 467 | "delta30_COUNT_REFURBISHED_gte": int, 468 | "delta30_COUNT_REVIEWS_lte": int, 469 | "delta30_COUNT_REVIEWS_gte": int, 470 | "delta30_COUNT_USED_lte": int, 471 | "delta30_COUNT_USED_gte": int, 472 | "delta30_EBAY_NEW_SHIPPING_lte": int, 473 | "delta30_EBAY_NEW_SHIPPING_gte": int, 474 | "delta30_EBAY_USED_SHIPPING_lte": int, 475 | "delta30_EBAY_USED_SHIPPING_gte": int, 476 | "delta30_LIGHTNING_DEAL_lte": int, 477 | "delta30_LIGHTNING_DEAL_gte": int, 478 | "delta30_LISTPRICE_lte": int, 479 | "delta30_LISTPRICE_gte": int, 480 | "delta30_NEW_lte": int, 481 | "delta30_NEW_gte": int, 482 | "delta30_NEW_FBA_lte": int, 483 | "delta30_NEW_FBA_gte": int, 484 | "delta30_NEW_FBM_SHIPPING_lte": int, 485 | "delta30_NEW_FBM_SHIPPING_gte": int, 486 | "delta30_RATING_lte": int, 487 | "delta30_RATING_gte": int, 488 | "delta30_REFURBISHED_lte": int, 489 | "delta30_REFURBISHED_gte": int, 490 | "delta30_REFURBISHED_SHIPPING_lte": int, 491 | "delta30_REFURBISHED_SHIPPING_gte": int, 492 | "delta30_RENT_lte": int, 493 | "delta30_RENT_gte": int, 494 | "delta30_SALES_lte": int, 495 | "delta30_SALES_gte": int, 496 | "delta30_TRADE_IN_lte": int, 497 | "delta30_TRADE_IN_gte": int, 498 | "delta30_USED_lte": int, 499 | "delta30_USED_gte": int, 500 | "delta30_USED_ACCEPTABLE_SHIPPING_lte": int, 501 | "delta30_USED_ACCEPTABLE_SHIPPING_gte": int, 502 | "delta30_USED_GOOD_SHIPPING_lte": int, 503 | "delta30_USED_GOOD_SHIPPING_gte": int, 504 | "delta30_USED_NEW_SHIPPING_lte": int, 505 | "delta30_USED_NEW_SHIPPING_gte": int, 506 | "delta30_USED_VERY_GOOD_SHIPPING_lte": int, 507 | "delta30_USED_VERY_GOOD_SHIPPING_gte": int, 508 | "delta30_WAREHOUSE_lte": int, 509 | "delta30_WAREHOUSE_gte": int, 510 | "delta7_AMAZON_lte": int, 511 | "delta7_AMAZON_gte": int, 512 | "delta7_BUY_BOX_SHIPPING_lte": int, 513 | "delta7_BUY_BOX_SHIPPING_gte": int, 514 | "delta7_COLLECTIBLE_lte": int, 515 | "delta7_COLLECTIBLE_gte": int, 516 | "delta7_COUNT_COLLECTIBLE_lte": int, 517 | "delta7_COUNT_COLLECTIBLE_gte": int, 518 | "delta7_COUNT_NEW_lte": int, 519 | "delta7_COUNT_NEW_gte": int, 520 | "delta7_COUNT_REFURBISHED_lte": int, 521 | "delta7_COUNT_REFURBISHED_gte": int, 522 | "delta7_COUNT_REVIEWS_lte": int, 523 | "delta7_COUNT_REVIEWS_gte": int, 524 | "delta7_COUNT_USED_lte": int, 525 | "delta7_COUNT_USED_gte": int, 526 | "delta7_EBAY_NEW_SHIPPING_lte": int, 527 | "delta7_EBAY_NEW_SHIPPING_gte": int, 528 | "delta7_EBAY_USED_SHIPPING_lte": int, 529 | "delta7_EBAY_USED_SHIPPING_gte": int, 530 | "delta7_LIGHTNING_DEAL_lte": int, 531 | "delta7_LIGHTNING_DEAL_gte": int, 532 | "delta7_LISTPRICE_lte": int, 533 | "delta7_LISTPRICE_gte": int, 534 | "delta7_NEW_lte": int, 535 | "delta7_NEW_gte": int, 536 | "delta7_NEW_FBA_lte": int, 537 | "delta7_NEW_FBA_gte": int, 538 | "delta7_NEW_FBM_SHIPPING_lte": int, 539 | "delta7_NEW_FBM_SHIPPING_gte": int, 540 | "delta7_RATING_lte": int, 541 | "delta7_RATING_gte": int, 542 | "delta7_REFURBISHED_lte": int, 543 | "delta7_REFURBISHED_gte": int, 544 | "delta7_REFURBISHED_SHIPPING_lte": int, 545 | "delta7_REFURBISHED_SHIPPING_gte": int, 546 | "delta7_RENT_lte": int, 547 | "delta7_RENT_gte": int, 548 | "delta7_SALES_lte": int, 549 | "delta7_SALES_gte": int, 550 | "delta7_TRADE_IN_lte": int, 551 | "delta7_TRADE_IN_gte": int, 552 | "delta7_USED_lte": int, 553 | "delta7_USED_gte": int, 554 | "delta7_USED_ACCEPTABLE_SHIPPING_lte": int, 555 | "delta7_USED_ACCEPTABLE_SHIPPING_gte": int, 556 | "delta7_USED_GOOD_SHIPPING_lte": int, 557 | "delta7_USED_GOOD_SHIPPING_gte": int, 558 | "delta7_USED_NEW_SHIPPING_lte": int, 559 | "delta7_USED_NEW_SHIPPING_gte": int, 560 | "delta7_USED_VERY_GOOD_SHIPPING_lte": int, 561 | "delta7_USED_VERY_GOOD_SHIPPING_gte": int, 562 | "delta7_WAREHOUSE_lte": int, 563 | "delta7_WAREHOUSE_gte": int, 564 | "delta90_AMAZON_lte": int, 565 | "delta90_AMAZON_gte": int, 566 | "delta90_BUY_BOX_SHIPPING_lte": int, 567 | "delta90_BUY_BOX_SHIPPING_gte": int, 568 | "delta90_COLLECTIBLE_lte": int, 569 | "delta90_COLLECTIBLE_gte": int, 570 | "delta90_COUNT_COLLECTIBLE_lte": int, 571 | "delta90_COUNT_COLLECTIBLE_gte": int, 572 | "delta90_COUNT_NEW_lte": int, 573 | "delta90_COUNT_NEW_gte": int, 574 | "delta90_COUNT_REFURBISHED_lte": int, 575 | "delta90_COUNT_REFURBISHED_gte": int, 576 | "delta90_COUNT_REVIEWS_lte": int, 577 | "delta90_COUNT_REVIEWS_gte": int, 578 | "delta90_COUNT_USED_lte": int, 579 | "delta90_COUNT_USED_gte": int, 580 | "delta90_EBAY_NEW_SHIPPING_lte": int, 581 | "delta90_EBAY_NEW_SHIPPING_gte": int, 582 | "delta90_EBAY_USED_SHIPPING_lte": int, 583 | "delta90_EBAY_USED_SHIPPING_gte": int, 584 | "delta90_LIGHTNING_DEAL_lte": int, 585 | "delta90_LIGHTNING_DEAL_gte": int, 586 | "delta90_LISTPRICE_lte": int, 587 | "delta90_LISTPRICE_gte": int, 588 | "delta90_NEW_lte": int, 589 | "delta90_NEW_gte": int, 590 | "delta90_NEW_FBA_lte": int, 591 | "delta90_NEW_FBA_gte": int, 592 | "delta90_NEW_FBM_SHIPPING_lte": int, 593 | "delta90_NEW_FBM_SHIPPING_gte": int, 594 | "delta90_RATING_lte": int, 595 | "delta90_RATING_gte": int, 596 | "delta90_REFURBISHED_lte": int, 597 | "delta90_REFURBISHED_gte": int, 598 | "delta90_REFURBISHED_SHIPPING_lte": int, 599 | "delta90_REFURBISHED_SHIPPING_gte": int, 600 | "delta90_RENT_lte": int, 601 | "delta90_RENT_gte": int, 602 | "delta90_SALES_lte": int, 603 | "delta90_SALES_gte": int, 604 | "delta90_TRADE_IN_lte": int, 605 | "delta90_TRADE_IN_gte": int, 606 | "delta90_USED_lte": int, 607 | "delta90_USED_gte": int, 608 | "delta90_USED_ACCEPTABLE_SHIPPING_lte": int, 609 | "delta90_USED_ACCEPTABLE_SHIPPING_gte": int, 610 | "delta90_USED_GOOD_SHIPPING_lte": int, 611 | "delta90_USED_GOOD_SHIPPING_gte": int, 612 | "delta90_USED_NEW_SHIPPING_lte": int, 613 | "delta90_USED_NEW_SHIPPING_gte": int, 614 | "delta90_USED_VERY_GOOD_SHIPPING_lte": int, 615 | "delta90_USED_VERY_GOOD_SHIPPING_gte": int, 616 | "delta90_WAREHOUSE_lte": int, 617 | "delta90_WAREHOUSE_gte": int, 618 | "deltaLast_AMAZON_lte": int, 619 | "deltaLast_AMAZON_gte": int, 620 | "deltaLast_BUY_BOX_SHIPPING_lte": int, 621 | "deltaLast_BUY_BOX_SHIPPING_gte": int, 622 | "deltaLast_COLLECTIBLE_lte": int, 623 | "deltaLast_COLLECTIBLE_gte": int, 624 | "deltaLast_COUNT_COLLECTIBLE_lte": int, 625 | "deltaLast_COUNT_COLLECTIBLE_gte": int, 626 | "deltaLast_COUNT_NEW_lte": int, 627 | "deltaLast_COUNT_NEW_gte": int, 628 | "deltaLast_COUNT_REFURBISHED_lte": int, 629 | "deltaLast_COUNT_REFURBISHED_gte": int, 630 | "deltaLast_COUNT_REVIEWS_lte": int, 631 | "deltaLast_COUNT_REVIEWS_gte": int, 632 | "deltaLast_COUNT_USED_lte": int, 633 | "deltaLast_COUNT_USED_gte": int, 634 | "deltaLast_EBAY_NEW_SHIPPING_lte": int, 635 | "deltaLast_EBAY_NEW_SHIPPING_gte": int, 636 | "deltaLast_EBAY_USED_SHIPPING_lte": int, 637 | "deltaLast_EBAY_USED_SHIPPING_gte": int, 638 | "deltaLast_LIGHTNING_DEAL_lte": int, 639 | "deltaLast_LIGHTNING_DEAL_gte": int, 640 | "deltaLast_LISTPRICE_lte": int, 641 | "deltaLast_LISTPRICE_gte": int, 642 | "deltaLast_NEW_lte": int, 643 | "deltaLast_NEW_gte": int, 644 | "deltaLast_NEW_FBA_lte": int, 645 | "deltaLast_NEW_FBA_gte": int, 646 | "deltaLast_NEW_FBM_SHIPPING_lte": int, 647 | "deltaLast_NEW_FBM_SHIPPING_gte": int, 648 | "deltaLast_RATING_lte": int, 649 | "deltaLast_RATING_gte": int, 650 | "deltaLast_REFURBISHED_lte": int, 651 | "deltaLast_REFURBISHED_gte": int, 652 | "deltaLast_REFURBISHED_SHIPPING_lte": int, 653 | "deltaLast_REFURBISHED_SHIPPING_gte": int, 654 | "deltaLast_RENT_lte": int, 655 | "deltaLast_RENT_gte": int, 656 | "deltaLast_SALES_lte": int, 657 | "deltaLast_SALES_gte": int, 658 | "deltaLast_TRADE_IN_lte": int, 659 | "deltaLast_TRADE_IN_gte": int, 660 | "deltaLast_USED_lte": int, 661 | "deltaLast_USED_gte": int, 662 | "deltaLast_USED_ACCEPTABLE_SHIPPING_lte": int, 663 | "deltaLast_USED_ACCEPTABLE_SHIPPING_gte": int, 664 | "deltaLast_USED_GOOD_SHIPPING_lte": int, 665 | "deltaLast_USED_GOOD_SHIPPING_gte": int, 666 | "deltaLast_USED_NEW_SHIPPING_lte": int, 667 | "deltaLast_USED_NEW_SHIPPING_gte": int, 668 | "deltaLast_USED_VERY_GOOD_SHIPPING_lte": int, 669 | "deltaLast_USED_VERY_GOOD_SHIPPING_gte": int, 670 | "deltaLast_WAREHOUSE_lte": int, 671 | "deltaLast_WAREHOUSE_gte": int, 672 | "deltaPercent1_AMAZON_lte": int, 673 | "deltaPercent1_AMAZON_gte": int, 674 | "deltaPercent1_BUY_BOX_SHIPPING_lte": int, 675 | "deltaPercent1_BUY_BOX_SHIPPING_gte": int, 676 | "deltaPercent1_COLLECTIBLE_lte": int, 677 | "deltaPercent1_COLLECTIBLE_gte": int, 678 | "deltaPercent1_COUNT_COLLECTIBLE_lte": int, 679 | "deltaPercent1_COUNT_COLLECTIBLE_gte": int, 680 | "deltaPercent1_COUNT_NEW_lte": int, 681 | "deltaPercent1_COUNT_NEW_gte": int, 682 | "deltaPercent1_COUNT_REFURBISHED_lte": int, 683 | "deltaPercent1_COUNT_REFURBISHED_gte": int, 684 | "deltaPercent1_COUNT_REVIEWS_lte": int, 685 | "deltaPercent1_COUNT_REVIEWS_gte": int, 686 | "deltaPercent1_COUNT_USED_lte": int, 687 | "deltaPercent1_COUNT_USED_gte": int, 688 | "deltaPercent1_EBAY_NEW_SHIPPING_lte": int, 689 | "deltaPercent1_EBAY_NEW_SHIPPING_gte": int, 690 | "deltaPercent1_EBAY_USED_SHIPPING_lte": int, 691 | "deltaPercent1_EBAY_USED_SHIPPING_gte": int, 692 | "deltaPercent1_LIGHTNING_DEAL_lte": int, 693 | "deltaPercent1_LIGHTNING_DEAL_gte": int, 694 | "deltaPercent1_LISTPRICE_lte": int, 695 | "deltaPercent1_LISTPRICE_gte": int, 696 | "deltaPercent1_NEW_lte": int, 697 | "deltaPercent1_NEW_gte": int, 698 | "deltaPercent1_NEW_FBA_lte": int, 699 | "deltaPercent1_NEW_FBA_gte": int, 700 | "deltaPercent1_NEW_FBM_SHIPPING_lte": int, 701 | "deltaPercent1_NEW_FBM_SHIPPING_gte": int, 702 | "deltaPercent1_RATING_lte": int, 703 | "deltaPercent1_RATING_gte": int, 704 | "deltaPercent1_REFURBISHED_lte": int, 705 | "deltaPercent1_REFURBISHED_gte": int, 706 | "deltaPercent1_REFURBISHED_SHIPPING_lte": int, 707 | "deltaPercent1_REFURBISHED_SHIPPING_gte": int, 708 | "deltaPercent1_RENT_lte": int, 709 | "deltaPercent1_RENT_gte": int, 710 | "deltaPercent1_SALES_lte": int, 711 | "deltaPercent1_SALES_gte": int, 712 | "deltaPercent1_TRADE_IN_lte": int, 713 | "deltaPercent1_TRADE_IN_gte": int, 714 | "deltaPercent1_USED_lte": int, 715 | "deltaPercent1_USED_gte": int, 716 | "deltaPercent1_USED_ACCEPTABLE_SHIPPING_lte": int, 717 | "deltaPercent1_USED_ACCEPTABLE_SHIPPING_gte": int, 718 | "deltaPercent1_USED_GOOD_SHIPPING_lte": int, 719 | "deltaPercent1_USED_GOOD_SHIPPING_gte": int, 720 | "deltaPercent1_USED_NEW_SHIPPING_lte": int, 721 | "deltaPercent1_USED_NEW_SHIPPING_gte": int, 722 | "deltaPercent1_USED_VERY_GOOD_SHIPPING_lte": int, 723 | "deltaPercent1_USED_VERY_GOOD_SHIPPING_gte": int, 724 | "deltaPercent1_WAREHOUSE_lte": int, 725 | "deltaPercent1_WAREHOUSE_gte": int, 726 | "deltaPercent30_AMAZON_lte": int, 727 | "deltaPercent30_AMAZON_gte": int, 728 | "deltaPercent30_BUY_BOX_SHIPPING_lte": int, 729 | "deltaPercent30_BUY_BOX_SHIPPING_gte": int, 730 | "deltaPercent30_COLLECTIBLE_lte": int, 731 | "deltaPercent30_COLLECTIBLE_gte": int, 732 | "deltaPercent30_COUNT_COLLECTIBLE_lte": int, 733 | "deltaPercent30_COUNT_COLLECTIBLE_gte": int, 734 | "deltaPercent30_COUNT_NEW_lte": int, 735 | "deltaPercent30_COUNT_NEW_gte": int, 736 | "deltaPercent30_COUNT_REFURBISHED_lte": int, 737 | "deltaPercent30_COUNT_REFURBISHED_gte": int, 738 | "deltaPercent30_COUNT_REVIEWS_lte": int, 739 | "deltaPercent30_COUNT_REVIEWS_gte": int, 740 | "deltaPercent30_COUNT_USED_lte": int, 741 | "deltaPercent30_COUNT_USED_gte": int, 742 | "deltaPercent30_EBAY_NEW_SHIPPING_lte": int, 743 | "deltaPercent30_EBAY_NEW_SHIPPING_gte": int, 744 | "deltaPercent30_EBAY_USED_SHIPPING_lte": int, 745 | "deltaPercent30_EBAY_USED_SHIPPING_gte": int, 746 | "deltaPercent30_LIGHTNING_DEAL_lte": int, 747 | "deltaPercent30_LIGHTNING_DEAL_gte": int, 748 | "deltaPercent30_LISTPRICE_lte": int, 749 | "deltaPercent30_LISTPRICE_gte": int, 750 | "deltaPercent30_NEW_lte": int, 751 | "deltaPercent30_NEW_gte": int, 752 | "deltaPercent30_NEW_FBA_lte": int, 753 | "deltaPercent30_NEW_FBA_gte": int, 754 | "deltaPercent30_NEW_FBM_SHIPPING_lte": int, 755 | "deltaPercent30_NEW_FBM_SHIPPING_gte": int, 756 | "deltaPercent30_RATING_lte": int, 757 | "deltaPercent30_RATING_gte": int, 758 | "deltaPercent30_REFURBISHED_lte": int, 759 | "deltaPercent30_REFURBISHED_gte": int, 760 | "deltaPercent30_REFURBISHED_SHIPPING_lte": int, 761 | "deltaPercent30_REFURBISHED_SHIPPING_gte": int, 762 | "deltaPercent30_RENT_lte": int, 763 | "deltaPercent30_RENT_gte": int, 764 | "deltaPercent30_SALES_lte": int, 765 | "deltaPercent30_SALES_gte": int, 766 | "deltaPercent30_TRADE_IN_lte": int, 767 | "deltaPercent30_TRADE_IN_gte": int, 768 | "deltaPercent30_USED_lte": int, 769 | "deltaPercent30_USED_gte": int, 770 | "deltaPercent30_USED_ACCEPTABLE_SHIPPING_lte": int, 771 | "deltaPercent30_USED_ACCEPTABLE_SHIPPING_gte": int, 772 | "deltaPercent30_USED_GOOD_SHIPPING_lte": int, 773 | "deltaPercent30_USED_GOOD_SHIPPING_gte": int, 774 | "deltaPercent30_USED_NEW_SHIPPING_lte": int, 775 | "deltaPercent30_USED_NEW_SHIPPING_gte": int, 776 | "deltaPercent30_USED_VERY_GOOD_SHIPPING_lte": int, 777 | "deltaPercent30_USED_VERY_GOOD_SHIPPING_gte": int, 778 | "deltaPercent30_WAREHOUSE_lte": int, 779 | "deltaPercent30_WAREHOUSE_gte": int, 780 | "deltaPercent7_AMAZON_lte": int, 781 | "deltaPercent7_AMAZON_gte": int, 782 | "deltaPercent7_BUY_BOX_SHIPPING_lte": int, 783 | "deltaPercent7_BUY_BOX_SHIPPING_gte": int, 784 | "deltaPercent7_COLLECTIBLE_lte": int, 785 | "deltaPercent7_COLLECTIBLE_gte": int, 786 | "deltaPercent7_COUNT_COLLECTIBLE_lte": int, 787 | "deltaPercent7_COUNT_COLLECTIBLE_gte": int, 788 | "deltaPercent7_COUNT_NEW_lte": int, 789 | "deltaPercent7_COUNT_NEW_gte": int, 790 | "deltaPercent7_COUNT_REFURBISHED_lte": int, 791 | "deltaPercent7_COUNT_REFURBISHED_gte": int, 792 | "deltaPercent7_COUNT_REVIEWS_lte": int, 793 | "deltaPercent7_COUNT_REVIEWS_gte": int, 794 | "deltaPercent7_COUNT_USED_lte": int, 795 | "deltaPercent7_COUNT_USED_gte": int, 796 | "deltaPercent7_EBAY_NEW_SHIPPING_lte": int, 797 | "deltaPercent7_EBAY_NEW_SHIPPING_gte": int, 798 | "deltaPercent7_EBAY_USED_SHIPPING_lte": int, 799 | "deltaPercent7_EBAY_USED_SHIPPING_gte": int, 800 | "deltaPercent7_LIGHTNING_DEAL_lte": int, 801 | "deltaPercent7_LIGHTNING_DEAL_gte": int, 802 | "deltaPercent7_LISTPRICE_lte": int, 803 | "deltaPercent7_LISTPRICE_gte": int, 804 | "deltaPercent7_NEW_lte": int, 805 | "deltaPercent7_NEW_gte": int, 806 | "deltaPercent7_NEW_FBA_lte": int, 807 | "deltaPercent7_NEW_FBA_gte": int, 808 | "deltaPercent7_NEW_FBM_SHIPPING_lte": int, 809 | "deltaPercent7_NEW_FBM_SHIPPING_gte": int, 810 | "deltaPercent7_RATING_lte": int, 811 | "deltaPercent7_RATING_gte": int, 812 | "deltaPercent7_REFURBISHED_lte": int, 813 | "deltaPercent7_REFURBISHED_gte": int, 814 | "deltaPercent7_REFURBISHED_SHIPPING_lte": int, 815 | "deltaPercent7_REFURBISHED_SHIPPING_gte": int, 816 | "deltaPercent7_RENT_lte": int, 817 | "deltaPercent7_RENT_gte": int, 818 | "deltaPercent7_SALES_lte": int, 819 | "deltaPercent7_SALES_gte": int, 820 | "deltaPercent7_TRADE_IN_lte": int, 821 | "deltaPercent7_TRADE_IN_gte": int, 822 | "deltaPercent7_USED_lte": int, 823 | "deltaPercent7_USED_gte": int, 824 | "deltaPercent7_USED_ACCEPTABLE_SHIPPING_lte": int, 825 | "deltaPercent7_USED_ACCEPTABLE_SHIPPING_gte": int, 826 | "deltaPercent7_USED_GOOD_SHIPPING_lte": int, 827 | "deltaPercent7_USED_GOOD_SHIPPING_gte": int, 828 | "deltaPercent7_USED_NEW_SHIPPING_lte": int, 829 | "deltaPercent7_USED_NEW_SHIPPING_gte": int, 830 | "deltaPercent7_USED_VERY_GOOD_SHIPPING_lte": int, 831 | "deltaPercent7_USED_VERY_GOOD_SHIPPING_gte": int, 832 | "deltaPercent7_WAREHOUSE_lte": int, 833 | "deltaPercent7_WAREHOUSE_gte": int, 834 | "deltaPercent90_AMAZON_lte": int, 835 | "deltaPercent90_AMAZON_gte": int, 836 | "deltaPercent90_BUY_BOX_SHIPPING_lte": int, 837 | "deltaPercent90_BUY_BOX_SHIPPING_gte": int, 838 | "deltaPercent90_COLLECTIBLE_lte": int, 839 | "deltaPercent90_COLLECTIBLE_gte": int, 840 | "deltaPercent90_COUNT_COLLECTIBLE_lte": int, 841 | "deltaPercent90_COUNT_COLLECTIBLE_gte": int, 842 | "deltaPercent90_COUNT_NEW_lte": int, 843 | "deltaPercent90_COUNT_NEW_gte": int, 844 | "deltaPercent90_COUNT_REFURBISHED_lte": int, 845 | "deltaPercent90_COUNT_REFURBISHED_gte": int, 846 | "deltaPercent90_COUNT_REVIEWS_lte": int, 847 | "deltaPercent90_COUNT_REVIEWS_gte": int, 848 | "deltaPercent90_COUNT_USED_lte": int, 849 | "deltaPercent90_COUNT_USED_gte": int, 850 | "deltaPercent90_EBAY_NEW_SHIPPING_lte": int, 851 | "deltaPercent90_EBAY_NEW_SHIPPING_gte": int, 852 | "deltaPercent90_EBAY_USED_SHIPPING_lte": int, 853 | "deltaPercent90_EBAY_USED_SHIPPING_gte": int, 854 | "deltaPercent90_LIGHTNING_DEAL_lte": int, 855 | "deltaPercent90_LIGHTNING_DEAL_gte": int, 856 | "deltaPercent90_LISTPRICE_lte": int, 857 | "deltaPercent90_LISTPRICE_gte": int, 858 | "deltaPercent90_NEW_lte": int, 859 | "deltaPercent90_NEW_gte": int, 860 | "deltaPercent90_NEW_FBA_lte": int, 861 | "deltaPercent90_NEW_FBA_gte": int, 862 | "deltaPercent90_NEW_FBM_SHIPPING_lte": int, 863 | "deltaPercent90_NEW_FBM_SHIPPING_gte": int, 864 | "deltaPercent90_RATING_lte": int, 865 | "deltaPercent90_RATING_gte": int, 866 | "deltaPercent90_REFURBISHED_lte": int, 867 | "deltaPercent90_REFURBISHED_gte": int, 868 | "deltaPercent90_REFURBISHED_SHIPPING_lte": int, 869 | "deltaPercent90_REFURBISHED_SHIPPING_gte": int, 870 | "deltaPercent90_RENT_lte": int, 871 | "deltaPercent90_RENT_gte": int, 872 | "deltaPercent90_SALES_lte": int, 873 | "deltaPercent90_SALES_gte": int, 874 | "deltaPercent90_TRADE_IN_lte": int, 875 | "deltaPercent90_TRADE_IN_gte": int, 876 | "deltaPercent90_USED_lte": int, 877 | "deltaPercent90_USED_gte": int, 878 | "deltaPercent90_USED_ACCEPTABLE_SHIPPING_lte": int, 879 | "deltaPercent90_USED_ACCEPTABLE_SHIPPING_gte": int, 880 | "deltaPercent90_USED_GOOD_SHIPPING_lte": int, 881 | "deltaPercent90_USED_GOOD_SHIPPING_gte": int, 882 | "deltaPercent90_USED_NEW_SHIPPING_lte": int, 883 | "deltaPercent90_USED_NEW_SHIPPING_gte": int, 884 | "deltaPercent90_USED_VERY_GOOD_SHIPPING_lte": int, 885 | "deltaPercent90_USED_VERY_GOOD_SHIPPING_gte": int, 886 | "deltaPercent90_WAREHOUSE_lte": int, 887 | "deltaPercent90_WAREHOUSE_gte": int, 888 | "department": list, 889 | "edition": list, 890 | "fbaFees_lte": int, 891 | "fbaFees_gte": int, 892 | "format": list, 893 | "genre": list, 894 | "hasParentASIN": bool, 895 | "hasReviews": bool, 896 | "hazardousMaterialType_lte": int, 897 | "hazardousMaterialType_gte": int, 898 | "isAdultProduct": bool, 899 | "isEligibleForSuperSaverShipping": bool, 900 | "isEligibleForTradeIn": bool, 901 | "isHighestOffer": bool, 902 | "isHighest_AMAZON": bool, 903 | "isHighest_BUY_BOX_SHIPPING": bool, 904 | "isHighest_COLLECTIBLE": bool, 905 | "isHighest_COUNT_COLLECTIBLE": bool, 906 | "isHighest_COUNT_NEW": bool, 907 | "isHighest_COUNT_REFURBISHED": bool, 908 | "isHighest_COUNT_REVIEWS": bool, 909 | "isHighest_COUNT_USED": bool, 910 | "isHighest_EBAY_NEW_SHIPPING": bool, 911 | "isHighest_EBAY_USED_SHIPPING": bool, 912 | "isHighest_LIGHTNING_DEAL": bool, 913 | "isHighest_LISTPRICE": bool, 914 | "isHighest_NEW": bool, 915 | "isHighest_NEW_FBA": bool, 916 | "isHighest_NEW_FBM_SHIPPING": bool, 917 | "isHighest_RATING": bool, 918 | "isHighest_REFURBISHED": bool, 919 | "isHighest_REFURBISHED_SHIPPING": bool, 920 | "isHighest_RENT": bool, 921 | "isHighest_SALES": bool, 922 | "isHighest_TRADE_IN": bool, 923 | "isHighest_USED": bool, 924 | "isHighest_USED_ACCEPTABLE_SHIPPING": bool, 925 | "isHighest_USED_GOOD_SHIPPING": bool, 926 | "isHighest_USED_NEW_SHIPPING": bool, 927 | "isHighest_USED_VERY_GOOD_SHIPPING": bool, 928 | "isHighest_WAREHOUSE": bool, 929 | "isLowestOffer": bool, 930 | "isLowest_AMAZON": bool, 931 | "isLowest_BUY_BOX_SHIPPING": bool, 932 | "isLowest_COLLECTIBLE": bool, 933 | "isLowest_COUNT_COLLECTIBLE": bool, 934 | "isLowest_COUNT_NEW": bool, 935 | "isLowest_COUNT_REFURBISHED": bool, 936 | "isLowest_COUNT_REVIEWS": bool, 937 | "isLowest_COUNT_USED": bool, 938 | "isLowest_EBAY_NEW_SHIPPING": bool, 939 | "isLowest_EBAY_USED_SHIPPING": bool, 940 | "isLowest_LIGHTNING_DEAL": bool, 941 | "isLowest_LISTPRICE": bool, 942 | "isLowest_NEW": bool, 943 | "isLowest_NEW_FBA": bool, 944 | "isLowest_NEW_FBM_SHIPPING": bool, 945 | "isLowest_RATING": bool, 946 | "isLowest_REFURBISHED": bool, 947 | "isLowest_REFURBISHED_SHIPPING": bool, 948 | "isLowest_RENT": bool, 949 | "isLowest_SALES": bool, 950 | "isLowest_TRADE_IN": bool, 951 | "isLowest_USED": bool, 952 | "isLowest_USED_ACCEPTABLE_SHIPPING": bool, 953 | "isLowest_USED_GOOD_SHIPPING": bool, 954 | "isLowest_USED_NEW_SHIPPING": bool, 955 | "isLowest_USED_VERY_GOOD_SHIPPING": bool, 956 | "isLowest_WAREHOUSE": bool, 957 | "isPrimeExclusive": bool, 958 | "isSNS": bool, 959 | "label": list, 960 | "languages": list, 961 | "lastOffersUpdate_lte": int, 962 | "lastOffersUpdate_gte": int, 963 | "lastPriceChange_lte": int, 964 | "lastPriceChange_gte": int, 965 | "lastRatingUpdate_lte": int, 966 | "lastRatingUpdate_gte": int, 967 | "lastUpdate_lte": int, 968 | "lastUpdate_gte": int, 969 | "lightningEnd_lte": int, 970 | "lightningEnd_gte": int, 971 | "lightningStart_lte": int, 972 | "lightningStart_gte": int, 973 | "listedSince_lte": int, 974 | "listedSince_gte": int, 975 | "manufacturer": list, 976 | "model": list, 977 | "newPriceIsMAP": bool, 978 | "nextUpdate_lte": int, 979 | "nextUpdate_gte": int, 980 | "numberOfItems_lte": int, 981 | "numberOfItems_gte": int, 982 | "numberOfPages_lte": int, 983 | "numberOfPages_gte": int, 984 | "numberOfTrackings_lte": int, 985 | "numberOfTrackings_gte": int, 986 | "offerCountFBA_lte": int, 987 | "offerCountFBA_gte": int, 988 | "offerCountFBM_lte": int, 989 | "offerCountFBM_gte": int, 990 | "outOfStockPercentageInInterval_lte": int, 991 | "outOfStockPercentageInInterval_gte": int, 992 | "packageDimension_lte": int, 993 | "packageDimension_gte": int, 994 | "packageHeight_lte": int, 995 | "packageHeight_gte": int, 996 | "packageLength_lte": int, 997 | "packageLength_gte": int, 998 | "packageQuantity_lte": int, 999 | "packageQuantity_gte": int, 1000 | "packageWeight_lte": int, 1001 | "packageWeight_gte": int, 1002 | "packageWidth_lte": int, 1003 | "packageWidth_gte": int, 1004 | "page": int, 1005 | "partNumber": list, 1006 | "perPage": int, 1007 | "platform": list, 1008 | "productGroup": list, 1009 | "productType": list, 1010 | "promotions": int, 1011 | "publicationDate_lte": int, 1012 | "publicationDate_gte": int, 1013 | "publisher": list, 1014 | "releaseDate_lte": int, 1015 | "releaseDate_gte": int, 1016 | "rootCategory": int, 1017 | "salesRankDrops180_lte": int, 1018 | "salesRankDrops180_gte": int, 1019 | "salesRankDrops90_lte": int, 1020 | "salesRankDrops90_gte": int, 1021 | "salesRankDrops30_lte": int, 1022 | "salesRankDrops30_gte": int, 1023 | "sellerIds": str, 1024 | "sellerIdsLowestFBA": str, 1025 | "sellerIdsLowestFBM": str, 1026 | "size": list, 1027 | "stockAmazon_lte": int, 1028 | "stockAmazon_gte": int, 1029 | "stockBuyBox_lte": int, 1030 | "stockBuyBox_gte": int, 1031 | "studio": list, 1032 | "title": str, 1033 | "title_flag": str, 1034 | "trackingSince_lte": int, 1035 | "trackingSince_gte": int, 1036 | "type": list, 1037 | "mpn": list, 1038 | "outOfStockPercentage90_lte": int, 1039 | "outOfStockPercentage90_gte": int, 1040 | "itemHeight_lte": int, 1041 | "itemHeight_gte": int, 1042 | "itemLength_lte": int, 1043 | "itemLength_gte": int, 1044 | "itemWidth_lte": int, 1045 | "itemWidth_gte": int, 1046 | "itemWeight_lte": int, 1047 | "itemWeight_gte": int, 1048 | "singleVariation": bool, 1049 | "sort": list, 1050 | } 1051 | -------------------------------------------------------------------------------- /src/keepa/keepa_sync.py: -------------------------------------------------------------------------------- 1 | """Interface module to download Amazon product and history data from keepa.com.""" 2 | 3 | import json 4 | import logging 5 | import time 6 | from collections.abc import Sequence 7 | from pathlib import Path 8 | from typing import Any, Literal 9 | 10 | import requests 11 | from tqdm import tqdm 12 | 13 | from keepa.constants import SCODES 14 | from keepa.models.domain import Domain 15 | from keepa.models.product_params import ProductParams 16 | from keepa.models.status import Status 17 | from keepa.query_keys import DEAL_REQUEST_KEYS 18 | from keepa.utils import _domain_to_dcode, _parse_seller, _parse_stats, format_items, parse_csv 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | REQUEST_LIMIT = 100 23 | 24 | 25 | class Keepa: 26 | r""" 27 | Synchronous Python interface to keepa data backend. 28 | 29 | Initializes API with access key. Access key can be obtained by signing up 30 | for a reoccurring or one time plan. To obtain a key, sign up for one at 31 | `Keepa Data `_ 32 | 33 | Parameters 34 | ---------- 35 | accesskey : str 36 | 64 character access key string. 37 | timeout : float, default: 10.0 38 | Default timeout when issuing any request. This is not a time limit on 39 | the entire response download; rather, an exception is raised if the 40 | server has not issued a response for timeout seconds. Setting this to 41 | 0.0 disables the timeout, but will cause any request to hang 42 | indefiantly should keepa.com be down 43 | logging_level: string, default: "DEBUG" 44 | Logging level to use. Default is "DEBUG". Other options are "INFO", 45 | "WARNING", "ERROR", and "CRITICAL". 46 | 47 | Examples 48 | -------- 49 | Create the api object. 50 | 51 | >>> import keepa 52 | >>> key = "" 53 | >>> api = keepa.Keepa(key) 54 | 55 | Request data from two ASINs. 56 | 57 | >>> products = api.query(["0439064872", "1426208081"]) 58 | 59 | Print item details. 60 | 61 | >>> print("Item 1") 62 | >>> print("\t ASIN: {:s}".format(products[0]["asin"])) 63 | >>> print("\t Title: {:s}".format(products[0]["title"])) 64 | Item 1 65 | ASIN: 0439064872 66 | Title: Harry Potter and the Chamber of Secrets (2) 67 | 68 | Print item price. 69 | 70 | >>> usedprice = products[0]["data"]["USED"] 71 | >>> usedtimes = products[0]["data"]["USED_time"] 72 | >>> print("\t Used price: ${:.2f}".format(usedprice[-1])) 73 | >>> print("\t as of: {:s}".format(str(usedtimes[-1]))) 74 | Used price: $0.52 75 | as of: 2023-01-03 04:46:00 76 | 77 | """ 78 | 79 | accesskey: str 80 | tokens_left: int 81 | status: Status 82 | _timeout: float 83 | 84 | def __init__(self, accesskey: str, timeout: float = 10.0, logging_level: str = "DEBUG") -> None: 85 | """Initialize server connection.""" 86 | self.accesskey = accesskey 87 | self.tokens_left = 0 88 | self._timeout = timeout 89 | 90 | # Set up logging 91 | levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] 92 | if logging_level not in levels: 93 | raise TypeError("logging_level must be one of: " + ", ".join(levels)) 94 | log.setLevel(logging_level) 95 | 96 | # Don't check available tokens on init 97 | log.info("Using key ending in %s", accesskey[-6:]) 98 | self.status = Status() 99 | 100 | @property 101 | def time_to_refill(self) -> float: 102 | """ 103 | Return the time to refill in seconds. 104 | 105 | Examples 106 | -------- 107 | Return the time to refill. If you have tokens available, this time 108 | should be 0.0 seconds. 109 | 110 | >>> import keepa 111 | >>> key = "" 112 | >>> api = keepa.Keepa(key) 113 | >>> api.time_to_refill 114 | 0.0 115 | 116 | """ 117 | # Get current timestamp in milliseconds from UNIX epoch 118 | now = int(time.time() * 1000) 119 | time_at_refill = self.status.timestamp + self.status.refillIn 120 | 121 | # wait plus one second fudge factor 122 | time_to_refill = time_at_refill - now + 1000 123 | if time_to_refill < 0: 124 | time_to_refill = 0 125 | 126 | # Account for negative tokens left 127 | if self.tokens_left < 0: 128 | time_to_refill += (abs(self.tokens_left) / self.status.refillRate) * 60000 129 | 130 | # Return value in seconds 131 | return time_to_refill / 1000.0 132 | 133 | def update_status(self) -> dict[str, Any]: 134 | """Update available tokens.""" 135 | status = self._request("token", {"key": self.accesskey}, wait=False) 136 | self.status = status 137 | return status 138 | 139 | def wait_for_tokens(self) -> None: 140 | """Check if there are any remaining tokens and waits if none are available.""" 141 | self.update_status() 142 | 143 | # Wait if no tokens available 144 | if self.tokens_left <= 0: 145 | tdelay = self.time_to_refill 146 | log.warning("Waiting %.0f seconds for additional tokens" % tdelay) 147 | time.sleep(tdelay) 148 | self.update_status() 149 | 150 | def download_graph_image( 151 | self, 152 | asin: str, 153 | filename: str | Path, 154 | domain: str | Domain = "US", 155 | wait: bool = True, 156 | **graph_kwargs: dict[str, Any], 157 | ) -> None: 158 | """ 159 | Download the graph image of an ASIN from keepa. 160 | 161 | See `Graph Image API 162 | `_ for more 163 | details. 164 | 165 | Parameters 166 | ---------- 167 | asin : str 168 | The ASIN of the product. 169 | filename : str | pathlib.Path 170 | Path to save the png to. 171 | domain : str | keepa.Domain, default: 'US' 172 | A valid Amazon domain. See :class:`keepa.Domain`. 173 | wait : bool, default: True 174 | Wait for available tokens before querying the keepa backend. 175 | **graph_kwargs : dict[str, Any], optional 176 | Optional graph keyword arguments. See `Graph Image API 177 | `_ for more 178 | details. 179 | 180 | Notes 181 | ----- 182 | Graph images are cached for 90 minutes on a per-user basis. The cache 183 | invalidates if any parameter changes. Submitting the exact same request 184 | within this time frame will not consume any tokens. 185 | 186 | Examples 187 | -------- 188 | Download a keepa graph image showing the current Amazon price, new 189 | price, and the sales rank of a product with ASIN ``"B09YNQCQKR"``. 190 | 191 | >>> from keepa import Keepa 192 | >>> api = Keepa("") 193 | >>> api.download_graph_image( 194 | ... asin="B09YNQCQKR", 195 | ... filename="product_graph.png", 196 | ... amazon=1, 197 | ... new=1, 198 | ... salesrank=1, 199 | ... ) 200 | 201 | Show Amazon price, new and used graphs, buy box and FBA, for last 365 202 | days, with custom width/height and custom colors. See 203 | `_ for more 204 | details. 205 | 206 | api.download_graph_image( 207 | asin="B09YNQCQKR", 208 | filename="product_graph_365.png", 209 | domain="US", 210 | amazon=1, 211 | new=1, 212 | used=1, 213 | bb=1, 214 | fba=1, 215 | range=365, 216 | width=800, 217 | height=400, 218 | cBackground="ffffff", 219 | cAmazon="FFA500", 220 | cNew="8888dd", 221 | cUsed="444444", 222 | cBB="ff00b4", 223 | cFBA="ff5722" 224 | ) 225 | 226 | """ 227 | payload = { 228 | "asin": asin, 229 | "key": self.accesskey, 230 | "domain": _domain_to_dcode(domain), 231 | } 232 | payload.update(graph_kwargs) 233 | 234 | resp = self._request("graphimage", payload, wait=wait, is_json=False) 235 | 236 | first_chunk = True 237 | filename = Path(filename) 238 | with open(filename, "wb") as f: 239 | for chunk in resp.iter_content(8192): 240 | if first_chunk: 241 | if not chunk.startswith(b"\x89PNG\r\n\x1a\n"): 242 | raise ValueError( 243 | "Response from api.keepa.com/graphimage is not a valid PNG image" 244 | ) 245 | first_chunk = False 246 | f.write(chunk) 247 | 248 | def query( 249 | self, 250 | items: str | Sequence[str], 251 | stats: int | None = None, 252 | domain: str | Domain = "US", 253 | history: bool = True, 254 | offers: int | None = None, 255 | update: int | None = None, 256 | to_datetime: bool = True, 257 | rating: bool = False, 258 | out_of_stock_as_nan: bool = True, 259 | stock: bool = False, 260 | product_code_is_asin: bool = True, 261 | progress_bar: bool = True, 262 | buybox: bool = False, 263 | wait: bool = True, 264 | days: int | None = None, 265 | only_live_offers: bool | None = None, 266 | raw: bool = False, 267 | videos: bool = False, 268 | aplus: bool = False, 269 | extra_params: dict[str, Any] | None = {}, 270 | ) -> list[dict[str, Any]]: 271 | """ 272 | Perform a product query of a list, array, or single ASIN. 273 | 274 | Returns a list of product data with one entry for each product. 275 | 276 | Parameters 277 | ---------- 278 | items : str, Sequence[str] 279 | A list, array, or single asin, UPC, EAN, or ISBN-13 identifying a 280 | product. ASINs should be 10 characters and match a product on 281 | Amazon. Items not matching Amazon product or duplicate Items will 282 | return no data. When using non-ASIN items, set 283 | ``product_code_is_asin`` to ``False``. 284 | 285 | stats : int or date, optional 286 | No extra token cost. If specified the product object will 287 | have a stats field with quick access to current prices, 288 | min/max prices and the weighted mean values. If the offers 289 | parameter was used it will also provide stock counts and 290 | buy box information. 291 | 292 | You can provide the stats parameter in two forms: 293 | 294 | Last x days (positive integer value): calculates the stats 295 | of the last x days, where x is the value of the stats 296 | parameter. Interval: You can provide a date range for the 297 | stats calculation. You can specify the range via two 298 | timestamps (unix epoch time milliseconds) or two date 299 | strings (ISO8601, with or without time in UTC). 300 | 301 | domain : str | keepa.Domain, default: 'US' 302 | A valid Amazon domain. See :class:`keepa.Domain`. 303 | 304 | history : bool, optional 305 | When set to True includes the price, sales, and offer 306 | history of a product. Set to False to reduce request time 307 | if data is not required. Default True 308 | 309 | offers : int, optional 310 | Adds available offers to product data. Default 0. Must be between 311 | 20 and 100. Enabling this also enables the ``"buyBoxUsedHistory"``. 312 | 313 | update : int, optional 314 | If data is older than the input integer, keepa will update their 315 | database and return live data. If set to 0 (live data), request may 316 | cost an additional token. Default (``None``) will not update. 317 | 318 | to_datetime : bool, default: True 319 | When ``True`` casts the time values of the product data 320 | (e.g. ``"AMAZON_TIME"``) to ``datetime.datetime``. For example 321 | ``datetime.datetime(2025, 10, 24, 10, 40)``. When ``False``, the 322 | values are represented as ``numpy`` ``"`_ 415 | and not yet supported in this function. For example, 416 | `extra_params={'rental': 1}`. 417 | 418 | Returns 419 | ------- 420 | list 421 | List of products when ``raw=False``. Each product within the list 422 | is a dictionary. The keys of each item may vary, so see the keys 423 | within each product for further details. 424 | 425 | Each product should contain at a minimum a "data" key containing a 426 | formatted dictionary. For the available fields see the notes 427 | section. 428 | 429 | When ``raw=True``, a list of unparsed responses are 430 | returned as :class:`requests.models.Response`. 431 | 432 | See: https://keepa.com/#!discuss/t/product-object/116 433 | 434 | Notes 435 | ----- 436 | The following are some of the fields a product dictionary. For a full 437 | list and description, please see: 438 | `product-object `_ 439 | 440 | AMAZON 441 | Amazon price history 442 | 443 | NEW 444 | Marketplace/3rd party New price history - Amazon is 445 | considered to be part of the marketplace as well, so if 446 | Amazon has the overall lowest new (!) price, the 447 | marketplace new price in the corresponding time interval 448 | will be identical to the Amazon price (except if there is 449 | only one marketplace offer). Shipping and Handling costs 450 | not included! 451 | 452 | USED 453 | Marketplace/3rd party Used price history 454 | 455 | SALES 456 | Sales Rank history. Not every product has a Sales Rank. 457 | 458 | LISTPRICE 459 | List Price history 460 | 461 | COLLECTIBLE 462 | Collectible Price history 463 | 464 | REFURBISHED 465 | Refurbished Price history 466 | 467 | NEW_FBM_SHIPPING 468 | 3rd party (not including Amazon) New price history 469 | including shipping costs, only fulfilled by merchant 470 | (FBM). 471 | 472 | LIGHTNING_DEAL 473 | 3rd party (not including Amazon) New price history 474 | including shipping costs, only fulfilled by merchant 475 | (FBM). 476 | 477 | WAREHOUSE 478 | Amazon Warehouse Deals price history. Mostly of used 479 | condition, rarely new. 480 | 481 | NEW_FBA 482 | Price history of the lowest 3rd party (not including 483 | Amazon/Warehouse) New offer that is fulfilled by Amazon 484 | 485 | COUNT_NEW 486 | New offer count history 487 | 488 | COUNT_USED 489 | Used offer count history 490 | 491 | COUNT_REFURBISHED 492 | Refurbished offer count history 493 | 494 | COUNT_COLLECTIBLE 495 | Collectible offer count history 496 | 497 | RATING 498 | The product's rating history. A rating is an integer from 499 | 0 to 50 (e.g. 45 = 4.5 stars) 500 | 501 | COUNT_REVIEWS 502 | The product's review count history. 503 | 504 | BUY_BOX_SHIPPING 505 | The price history of the buy box. If no offer qualified 506 | for the buy box the price has the value -1. Including 507 | shipping costs. 508 | 509 | USED_NEW_SHIPPING 510 | "Used - Like New" price history including shipping costs. 511 | 512 | USED_VERY_GOOD_SHIPPING 513 | "Used - Very Good" price history including shipping costs. 514 | 515 | USED_GOOD_SHIPPING 516 | "Used - Good" price history including shipping costs. 517 | 518 | USED_ACCEPTABLE_SHIPPING 519 | "Used - Acceptable" price history including shipping costs. 520 | 521 | COLLECTIBLE_NEW_SHIPPING 522 | "Collectible - Like New" price history including shipping 523 | costs. 524 | 525 | COLLECTIBLE_VERY_GOOD_SHIPPING 526 | "Collectible - Very Good" price history including shipping 527 | costs. 528 | 529 | COLLECTIBLE_GOOD_SHIPPING 530 | "Collectible - Good" price history including shipping 531 | costs. 532 | 533 | COLLECTIBLE_ACCEPTABLE_SHIPPING 534 | "Collectible - Acceptable" price history including 535 | shipping costs. 536 | 537 | REFURBISHED_SHIPPING 538 | Refurbished price history including shipping costs. 539 | 540 | TRADE_IN 541 | The trade in price history. Amazon trade-in is not 542 | available for every locale. 543 | 544 | BUY_BOX_SHIPPING 545 | The price history of the buy box. If no offer qualified 546 | for the buy box the price has the value -1. Including 547 | shipping costs. The ``buybox`` parameter must be True for 548 | this field to be in the data. 549 | 550 | Examples 551 | -------- 552 | Query for product with ASIN ``'B0088PUEPK'`` using the synchronous 553 | keepa interface. 554 | 555 | >>> import keepa 556 | >>> key = "" 557 | >>> api = keepa.Keepa(key) 558 | >>> response = api.query("B0088PUEPK") 559 | >>> response[0]["title"] 560 | 'Western Digital 1TB WD Blue PC Internal Hard Drive HDD - 7200 RPM, 561 | SATA 6 Gb/s, 64 MB Cache, 3.5" - WD10EZEX' 562 | 563 | Query for product with ASIN ``'B0088PUEPK'`` using the asynchronous 564 | keepa interface. 565 | 566 | >>> import asyncio 567 | >>> import keepa 568 | >>> async def main(): 569 | ... key = "" 570 | ... api = await keepa.AsyncKeepa().create(key) 571 | ... return await api.query("B0088PUEPK") 572 | ... 573 | >>> response = asyncio.run(main()) 574 | >>> response[0]["title"] 575 | 'Western Digital 1TB WD Blue PC Internal Hard Drive HDD - 7200 RPM, 576 | SATA 6 Gb/s, 64 MB Cache, 3.5" - WD10EZEX' 577 | 578 | Load in product offers and convert the buy box data into a 579 | ``pandas.DataFrame``. 580 | 581 | >>> import keepa 582 | >>> key = "" 583 | >>> api = keepa.Keepa(key) 584 | >>> response = api.query("B0088PUEPK", offers=20) 585 | >>> product = response[0] 586 | >>> buybox_info = product["buyBoxUsedHistory"] 587 | >>> df = keepa.process_used_buybox(buybox_info) 588 | datetime user_id condition isFBA 589 | 0 2022-11-02 16:46:00 A1QUAC68EAM09F Used - Like New True 590 | 1 2022-11-13 10:36:00 A18WXU4I7YR6UA Used - Very Good False 591 | 2 2022-11-15 23:50:00 AYUGEV9WZ4X5O Used - Like New False 592 | 3 2022-11-17 06:16:00 A18WXU4I7YR6UA Used - Very Good False 593 | 4 2022-11-17 10:56:00 AYUGEV9WZ4X5O Used - Like New False 594 | .. ... ... ... ... 595 | 115 2023-10-23 10:00:00 AYUGEV9WZ4X5O Used - Like New False 596 | 116 2023-10-25 21:14:00 A1U9HDFCZO1A84 Used - Like New False 597 | 117 2023-10-26 04:08:00 AYUGEV9WZ4X5O Used - Like New False 598 | 118 2023-10-27 08:14:00 A1U9HDFCZO1A84 Used - Like New False 599 | 119 2023-10-27 12:34:00 AYUGEV9WZ4X5O Used - Like New False 600 | 601 | Query a video with the "videos" metadata. 602 | 603 | >>> response = api.query("B00UFMKSDW", history=False, videos=True) 604 | >>> product = response[0] 605 | >>> "videos" in product 606 | True 607 | 608 | 609 | """ 610 | # Format items into numpy array 611 | try: 612 | items = format_items(items) 613 | except BaseException: 614 | raise ValueError("Invalid product codes input") 615 | if not len(items): 616 | raise ValueError("No valid product codes") 617 | 618 | nitems = len(items) 619 | if nitems == 1: 620 | log.debug("Executing single product query") 621 | else: 622 | log.debug("Executing %d item product query", nitems) 623 | 624 | if extra_params is None: 625 | extra_params = {} 626 | 627 | # check offer input 628 | if offers: 629 | if not isinstance(offers, int): 630 | raise TypeError('Parameter "offers" must be an interger') 631 | 632 | if offers > 100 or offers < 20: 633 | raise ValueError('Parameter "offers" must be between 20 and 100') 634 | 635 | # Report time to completion 636 | if self.status.refillRate is not None: 637 | tcomplete = ( 638 | float(nitems - self.tokens_left) / self.status.refillRate 639 | - (60000 - self.status.refillIn) / 60000.0 640 | ) 641 | if tcomplete < 0.0: 642 | tcomplete = 0.5 643 | log.debug( 644 | "Estimated time to complete %d request(s) is %.2f minutes", 645 | nitems, 646 | tcomplete, 647 | ) 648 | log.debug("\twith a refill rate of %d token(s) per minute", self.status.refillRate) 649 | 650 | # product list 651 | products = [] 652 | 653 | pbar = None 654 | if progress_bar: 655 | pbar = tqdm(total=nitems) 656 | 657 | # Number of requests is dependent on the number of items and 658 | # request limit. Use available tokens first 659 | idx = 0 # or number complete 660 | while idx < nitems: 661 | nrequest = nitems - idx 662 | 663 | # cap request 664 | if nrequest > REQUEST_LIMIT: 665 | nrequest = REQUEST_LIMIT 666 | 667 | # request from keepa and increment current position 668 | item_request = items[idx : idx + nrequest] # noqa: E203 669 | response = self._product_query( 670 | item_request, 671 | product_code_is_asin, 672 | stats=stats, 673 | domain=domain, 674 | stock=stock, 675 | offers=offers, 676 | update=update, 677 | history=history, 678 | rating=rating, 679 | to_datetime=to_datetime, 680 | out_of_stock_as_nan=out_of_stock_as_nan, 681 | buybox=buybox, 682 | wait=wait, 683 | days=days, 684 | only_live_offers=only_live_offers, 685 | raw=raw, 686 | videos=videos, 687 | aplus=aplus, 688 | **extra_params, 689 | ) 690 | idx += nrequest 691 | if raw: 692 | products.append(response) 693 | else: 694 | products.extend(response["products"]) 695 | 696 | if pbar is not None: 697 | pbar.update(nrequest) 698 | 699 | return products 700 | 701 | def _product_query(self, items, product_code_is_asin=True, **kwargs): 702 | """Send query to keepa server and returns parsed JSON result. 703 | 704 | Parameters 705 | ---------- 706 | items : np.ndarray 707 | Array of asins. If UPC, EAN, or ISBN-13, as_asin must be 708 | False. Must be between 1 and 100 ASINs 709 | 710 | as_asin : bool, optional 711 | Interpret product codes as ASINs only. 712 | 713 | stats : int or date format 714 | Set the stats time for get sales rank inside this range 715 | 716 | domain : str | keepa.Domain, default: 'US' 717 | A valid Amazon domain. See :class:`keepa.Domain`. 718 | 719 | offers : bool, optional 720 | Adds product offers to product data. 721 | 722 | update : int, optional 723 | If data is older than the input integer, keepa will update 724 | their database and return live data. If set to 0 (live 725 | data), then request may cost an additional token. 726 | 727 | history : bool, optional 728 | When set to True includes the price, sales, and offer 729 | history of a product. Set to False to reduce request time 730 | if data is not required. 731 | 732 | Returns 733 | ------- 734 | products : list 735 | List of products. Length equal to number of successful 736 | ASINs. 737 | 738 | refillIn : float 739 | Time in milliseconds to the next refill of tokens. 740 | 741 | refilRate : float 742 | Number of tokens refilled per minute 743 | 744 | timestamp : float 745 | 746 | tokensLeft : int 747 | Remaining tokens 748 | 749 | tz : int 750 | Timezone. 0 is UTC 751 | 752 | """ 753 | # ASINs convert to comma joined string 754 | assert len(items) <= 100 755 | 756 | if product_code_is_asin: 757 | kwargs["asin"] = ",".join(items) 758 | else: 759 | kwargs["code"] = ",".join(items) 760 | 761 | kwargs["key"] = self.accesskey 762 | kwargs["domain"] = _domain_to_dcode(kwargs["domain"]) 763 | 764 | # Convert bool values to 0 and 1. 765 | kwargs["stock"] = int(kwargs["stock"]) 766 | kwargs["history"] = int(kwargs["history"]) 767 | kwargs["rating"] = int(kwargs["rating"]) 768 | kwargs["buybox"] = int(kwargs["buybox"]) 769 | kwargs["videos"] = int(kwargs["videos"]) 770 | kwargs["aplus"] = int(kwargs["aplus"]) 771 | 772 | if kwargs["update"] is None: 773 | del kwargs["update"] 774 | else: 775 | kwargs["update"] = int(kwargs["update"]) 776 | 777 | if kwargs["offers"] is None: 778 | del kwargs["offers"] 779 | else: 780 | kwargs["offers"] = int(kwargs["offers"]) 781 | 782 | if kwargs["only_live_offers"] is None: 783 | del kwargs["only_live_offers"] 784 | else: 785 | # Keepa's param actually doesn't use snake_case. 786 | kwargs["only-live-offers"] = int(kwargs.pop("only_live_offers")) 787 | 788 | if kwargs["days"] is None: 789 | del kwargs["days"] 790 | else: 791 | assert kwargs["days"] > 0 792 | 793 | if kwargs["stats"] is None: 794 | del kwargs["stats"] 795 | 796 | out_of_stock_as_nan = kwargs.pop("out_of_stock_as_nan", True) 797 | to_datetime = kwargs.pop("to_datetime", True) 798 | 799 | # Query and replace csv with parsed data if history enabled 800 | wait = kwargs.get("wait") 801 | kwargs.pop("wait", None) 802 | raw_response = kwargs.pop("raw", False) 803 | response = self._request("product", kwargs, wait=wait, raw_response=raw_response) 804 | 805 | if kwargs["history"] and not raw_response: 806 | if "products" not in response: 807 | raise RuntimeError("No products in response. Possibly invalid ASINs") 808 | 809 | for product in response["products"]: 810 | if product["csv"]: # if data exists 811 | product["data"] = parse_csv(product["csv"], to_datetime, out_of_stock_as_nan) 812 | 813 | if kwargs.get("stats", None) and not raw_response: 814 | for product in response["products"]: 815 | stats = product.get("stats", None) 816 | if stats: 817 | product["stats_parsed"] = _parse_stats(stats, to_datetime) 818 | 819 | return response 820 | 821 | def best_sellers_query( 822 | self, 823 | category: str, 824 | rank_avg_range: Literal[0, 30, 90, 180] = 0, 825 | variations: bool = False, 826 | sublist: bool = False, 827 | domain: str | Domain = "US", 828 | wait: bool = True, 829 | ): 830 | """ 831 | Retrieve an ASIN list of the most popular products. 832 | 833 | This is based on sales in a specific category or product group. See 834 | "search_for_categories" for information on how to get a category. 835 | 836 | Root category lists (e.g. "Home & Kitchen") or product group lists 837 | contain up to 100,000 ASINs. 838 | 839 | Sub-category lists (e.g. "Home Entertainment Furniture") contain up to 840 | 3,000 ASINs. As we only have access to the product's primary sales rank 841 | and not the ones of all categories it is listed in, the sub-category 842 | lists are created by us based on the product's primary sales rank and 843 | do not reflect the actual ordering on Amazon. 844 | 845 | Lists are ordered, starting with the best selling product. 846 | 847 | Lists are updated daily. If a product does not have an accessible 848 | sales rank it will not be included in the lists. This in particular 849 | affects many products in the Clothing and Sports & Outdoors categories. 850 | 851 | We can not correctly identify the sales rank reference category in all 852 | cases, so some products may be misplaced. 853 | 854 | See the keepa documentation at `Request Best Sellers 855 | `_ for additional 856 | details. 857 | 858 | Parameters 859 | ---------- 860 | category : str 861 | The category node id of the category you want to request 862 | the best sellers list for. You can find category node ids 863 | via the category search :meth:`Keepa.search_for_categories`. 864 | rank_avg_range : int, default: 0 865 | Optionally specify to retrieve a best seller list based on a sales 866 | rank average instead of the current sales rank. Valid values: 867 | 868 | * 0: Use current rank 869 | * 30: 30-day average 870 | * 90: 90-day average 871 | * 180: 180-day average 872 | variations : bool, default: False 873 | Restrict list entries to a single variation for items with multiple 874 | variations. The variation returned will be the one with the highest 875 | monthly units sold (if that data point is available). When 876 | ``False`` (default), do not include variations. When ``True``, 877 | return all variations. 878 | 879 | By default we return one variation per parent. If the variations 880 | share the same sales rank, the representative is the variation with 881 | the highest monthly units sold. If monthly sold data is missing or 882 | tied, the representative falls back to randomly picked one. 883 | sublist : bool, default: False 884 | By default (``False``), the best seller list for sub-categories is created 885 | based on the product’s primary sales rank, if available. To request 886 | a best seller list based on the sub-category sales rank 887 | (classification rank), set this parameter to ``True``. Note that 888 | not all products have a primary sales rank or a sub-category sales 889 | rank and not all sub-category levels have sales ranks. 890 | domain : str | keepa.Domain, default: 'US' 891 | A valid Amazon domain. See :class:`keepa.Domain`. 892 | wait : bool, default: True 893 | Wait for available tokens before querying the keepa backend. 894 | 895 | Returns 896 | ------- 897 | list 898 | List of best seller ASINs 899 | 900 | Examples 901 | -------- 902 | Query for the best sellers among the ``"movies"`` category. 903 | 904 | >>> import keepa 905 | >>> key = "" 906 | >>> api = keepa.Keepa(key) 907 | >>> categories = api.search_for_categories("movies") 908 | >>> category = list(categories.items())[0][0] 909 | >>> asins = api.best_sellers_query(category) 910 | >>> asins 911 | ['B0BF3P5XZS', 912 | 'B08JQN5VDT', 913 | 'B09SP8JPPK', 914 | '0999296345', 915 | 'B07HPG684T', 916 | '1984825577', 917 | ... 918 | 919 | Query for the best sellers among the ``"movies"`` category using the 920 | asynchronous keepa interface. 921 | 922 | >>> import asyncio 923 | >>> import keepa 924 | >>> async def main(): 925 | ... key = "" 926 | ... api = await keepa.AsyncKeepa().create(key) 927 | ... categories = await api.search_for_categories("movies") 928 | ... category = list(categories.items())[0][0] 929 | ... return await api.best_sellers_query(category) 930 | ... 931 | >>> asins = asyncio.run(main()) 932 | >>> asins 933 | ['B0BF3P5XZS', 934 | 'B08JQN5VDT', 935 | 'B09SP8JPPK', 936 | '0999296345', 937 | 'B07HPG684T', 938 | '1984825577', 939 | ... 940 | 941 | """ 942 | payload = { 943 | "key": self.accesskey, 944 | "domain": _domain_to_dcode(domain), 945 | "variations": int(variations), 946 | "sublist": int(sublist), 947 | "category": category, 948 | "range": rank_avg_range, 949 | } 950 | 951 | response = self._request("bestsellers", payload, wait=wait) 952 | if "bestSellersList" not in response: 953 | raise RuntimeError(f"Best sellers search results for {category} not yet available") 954 | 955 | return response["bestSellersList"]["asinList"] 956 | 957 | def search_for_categories( 958 | self, searchterm: str, domain: str | Domain = "US", wait: bool = True 959 | ) -> list: 960 | """ 961 | Search for categories from Amazon. 962 | 963 | Parameters 964 | ---------- 965 | searchterm : str 966 | Input search term. 967 | domain : str | keepa.Domain, default: 'US' 968 | A valid Amazon domain. See :class:`keepa.Domain`. 969 | wait : bool, default: True 970 | Wait for available tokens before querying the keepa backend. 971 | 972 | Returns 973 | ------- 974 | dict[str, Any] 975 | The response contains a categories dictionary with all matching 976 | categories. 977 | 978 | Examples 979 | -------- 980 | Print all categories from science. 981 | 982 | >>> import keepa 983 | >>> key = "" 984 | >>> api = keepa.Keepa(key) 985 | >>> categories = api.search_for_categories("science") 986 | >>> for cat_id in categories: 987 | ... print(cat_id, categories[cat_id]["name"]) 988 | ... 989 | 9091159011 Behavioral Sciences 990 | 8407535011 Fantasy, Horror & Science Fiction 991 | 8407519011 Sciences & Technology 992 | 12805 Science & Religion 993 | 13445 Astrophysics & Space Science 994 | 12038 Science Fiction & Fantasy 995 | 3207 Science, Nature & How It Works 996 | 144 Science Fiction & Fantasy 997 | 998 | """ 999 | payload = { 1000 | "key": self.accesskey, 1001 | "domain": _domain_to_dcode(domain), 1002 | "type": "category", 1003 | "term": searchterm, 1004 | } 1005 | 1006 | response = self._request("search", payload, wait=wait) 1007 | if response["categories"] == {}: # pragma no cover 1008 | raise RuntimeError( 1009 | "Categories search results not yet available or no search terms found." 1010 | ) 1011 | return response["categories"] 1012 | 1013 | def category_lookup( 1014 | self, 1015 | category_id: int, 1016 | domain: str | Domain = "US", 1017 | include_parents=False, 1018 | wait: bool = True, 1019 | ) -> dict[str, Any]: 1020 | """ 1021 | Return root categories given a categoryId. 1022 | 1023 | Parameters 1024 | ---------- 1025 | category_id : int 1026 | ID for specific category or 0 to return a list of root categories. 1027 | domain : str | keepa.Domain, default: 'US' 1028 | A valid Amazon domain. See :class:`keepa.Domain`. 1029 | include_parents : bool, default: False 1030 | Include parents. 1031 | wait : bool, default: True 1032 | Wait for available tokens before querying the keepa backend. 1033 | 1034 | Returns 1035 | ------- 1036 | dict[str, Any] 1037 | Output format is the same as :meth:`Keepa.`search_for_categories`. 1038 | 1039 | Examples 1040 | -------- 1041 | Use 0 to return all root categories. 1042 | 1043 | >>> import keepa 1044 | >>> key = "" 1045 | >>> api = keepa.Keepa(key) 1046 | >>> categories = api.category_lookup(0) 1047 | 1048 | Output the first category. 1049 | 1050 | >>> list(categories.values())[0] 1051 | {'domainId': 1, 1052 | 'catId': 133140011, 1053 | 'name': 'Kindle Store', 1054 | 'children': [133141011, 1055 | 133143011, 1056 | 6766606011, 1057 | 7529231011, 1058 | 118656435011, 1059 | 2268072011, 1060 | 119757513011, 1061 | 358606011, 1062 | 3000677011, 1063 | 1293747011], 1064 | 'parent': 0, 1065 | 'highestRank': 6984155, 1066 | 'productCount': 6417325, 1067 | 'contextFreeName': 'Kindle Store', 1068 | 'lowestRank': 1, 1069 | 'matched': True} 1070 | 1071 | """ 1072 | payload = { 1073 | "key": self.accesskey, 1074 | "domain": _domain_to_dcode(domain), 1075 | "category": category_id, 1076 | "parents": int(include_parents), 1077 | } 1078 | 1079 | response = self._request("category", payload, wait=wait) 1080 | if response["categories"] == {}: # pragma no cover 1081 | raise Exception("Category lookup results not yet available or no match found.") 1082 | return response["categories"] 1083 | 1084 | def seller_query( 1085 | self, 1086 | seller_id: str | list[str], 1087 | domain: str | Domain = "US", 1088 | to_datetime: bool = True, 1089 | storefront: bool = False, 1090 | update: int | None = None, 1091 | wait: bool = True, 1092 | ): 1093 | """ 1094 | Receive seller information for a given seller id or ids. 1095 | 1096 | If a seller is not found no tokens will be consumed. 1097 | 1098 | Token cost: 1 per requested seller 1099 | 1100 | Parameters 1101 | ---------- 1102 | seller_id : str or list[str] 1103 | The seller id of the merchant you want to request. For batch 1104 | requests, you may submit a list of 100 seller_ids. The seller id 1105 | can also be found on Amazon on seller profile pages in the seller 1106 | parameter of the URL as well as in the offers results from a 1107 | product query. 1108 | domain : str | keepa.Domain, default: 'US' 1109 | A valid Amazon domain. See :class:`keepa.Domain`. 1110 | to_datetime : bool, default: True 1111 | When ``True`` casts the time values to ``datetime.datetime``. For 1112 | example ``datetime.datetime(2025, 10, 24, 10, 40)``. When 1113 | ``False``, the values are represented as ``numpy`` ``">> import keepa 1156 | >>> key = "" 1157 | >>> api = keepa.Keepa(key) 1158 | >>> seller_info = api.seller_query("A2L77EE7U53NWQ", "US") 1159 | >>> seller_info["A2L77EE7U53NWQ"]["sellerName"] 1160 | 'Amazon Warehouse' 1161 | 1162 | Notes 1163 | ----- 1164 | Seller data is not available for Amazon China. 1165 | 1166 | """ 1167 | if isinstance(seller_id, list): 1168 | if len(seller_id) > 100: 1169 | err_str = "seller_id can contain at maximum 100 sellers" 1170 | raise RuntimeError(err_str) 1171 | seller = ",".join(seller_id) 1172 | else: 1173 | seller = seller_id 1174 | 1175 | payload = { 1176 | "key": self.accesskey, 1177 | "domain": _domain_to_dcode(domain), 1178 | "seller": seller, 1179 | } 1180 | 1181 | if storefront: 1182 | payload["storefront"] = int(storefront) 1183 | if update is not False: 1184 | payload["update"] = update 1185 | 1186 | response = self._request("seller", payload, wait=wait) 1187 | return _parse_seller(response["sellers"], to_datetime) 1188 | 1189 | def product_finder( 1190 | self, 1191 | product_parms: dict[str, Any] | ProductParams, 1192 | domain: str | Domain = "US", 1193 | wait: bool = True, 1194 | n_products: int = 50, 1195 | ) -> list[str]: 1196 | """ 1197 | Query the keepa product database to find products matching criteria. 1198 | 1199 | Almost all product fields can be searched for and sorted. 1200 | 1201 | Parameters 1202 | ---------- 1203 | product_parms : dict, ProductParams 1204 | Dictionary or :class:`keepa.ProductParams`. 1205 | domain : str | keepa.Domain, default: 'US' 1206 | A valid Amazon domain. See :class:`keepa.Domain`. 1207 | wait : bool, default: True 1208 | Wait for available tokens before querying the keepa backend. 1209 | n_products : int, default: 50 1210 | Maximum number of matching products returned by keepa. This can be 1211 | overridden by the 'perPage' key in ``product_parms``. 1212 | 1213 | Returns 1214 | ------- 1215 | list[str] 1216 | List of ASINs matching the product parameters. 1217 | 1218 | Notes 1219 | ----- 1220 | When using the ``'sort'`` key in the ``product_parms`` parameter, use a 1221 | compatible key along with the type of sort. For example: 1222 | ``["current_SALES", "asc"]`` 1223 | 1224 | Examples 1225 | -------- 1226 | Query for the first 100 of Jim Butcher's books using the synchronous 1227 | ``keepa.Keepa`` class. Sort by current sales. 1228 | 1229 | >>> import keepa 1230 | >>> api = keepa.Keepa("") 1231 | >>> product_parms = { 1232 | ... "author": "jim butcher", 1233 | ... "sort": ["current_SALES", "asc"], 1234 | ... } 1235 | >>> asins = api.product_finder(product_parms, n_products=100) 1236 | >>> asins 1237 | ['B000HRMAR2', 1238 | '0578799790', 1239 | 'B07PW1SVHM', 1240 | ... 1241 | 'B003MXM744', 1242 | '0133235750', 1243 | 'B01MXXLJPZ'] 1244 | 1245 | Alternatively, use the :class:`keepa.ProductParams`: 1246 | 1247 | >>> product_parms = keepa.ProductParams( 1248 | ... author="jim butcher", 1249 | ... sort=["current_SALES", "asc"], 1250 | ... ) 1251 | >>> asins = api.product_finder(product_parms, n_products=100) 1252 | 1253 | Query for all of Jim Butcher's books using the asynchronous 1254 | ``keepa.AsyncKeepa`` class. 1255 | 1256 | >>> import asyncio 1257 | >>> import keepa 1258 | >>> product_parms = {"author": "jim butcher"} 1259 | >>> async def main(): 1260 | ... key = "" 1261 | ... api = await keepa.AsyncKeepa().create(key) 1262 | ... return await api.product_finder(product_parms) 1263 | ... 1264 | >>> asins = asyncio.run(main()) 1265 | >>> asins 1266 | ['B000HRMAR2', 1267 | '0578799790', 1268 | 'B07PW1SVHM', 1269 | ... 1270 | 'B003MXM744', 1271 | '0133235750', 1272 | 'B01MXXLJPZ'] 1273 | 1274 | """ 1275 | if isinstance(product_parms, dict): 1276 | product_parms_valid = ProductParams(**product_parms) 1277 | else: 1278 | product_parms_valid = product_parms 1279 | product_parms_dict = product_parms_valid.model_dump(exclude_none=True) 1280 | product_parms_dict.setdefault("perPage", n_products) 1281 | payload = { 1282 | "key": self.accesskey, 1283 | "domain": _domain_to_dcode(domain), 1284 | "selection": json.dumps(product_parms_dict), 1285 | } 1286 | 1287 | response = self._request("query", payload, wait=wait) 1288 | return response["asinList"] 1289 | 1290 | def deals( 1291 | self, deal_parms: dict[str, Any], domain: str | Domain = "US", wait: bool = True 1292 | ) -> dict[str, Any]: 1293 | """Query the Keepa API for product deals. 1294 | 1295 | You can find products that recently changed and match your 1296 | search criteria. A single request will return a maximum of 1297 | 150 deals. Try out the deals page to first get accustomed to 1298 | the options: 1299 | https://keepa.com/#!deals 1300 | 1301 | For more details please visit: 1302 | https://keepa.com/#!discuss/t/browsing-deals/338 1303 | 1304 | Parameters 1305 | ---------- 1306 | deal_parms : dict 1307 | Dictionary containing one or more of the following keys: 1308 | 1309 | - ``"page"``: int 1310 | - ``"domainId"``: int 1311 | - ``"excludeCategories"``: list 1312 | - ``"includeCategories"``: list 1313 | - ``"priceTypes"``: list 1314 | - ``"deltaRange"``: list 1315 | - ``"deltaPercentRange"``: list 1316 | - ``"deltaLastRange"``: list 1317 | - ``"salesRankRange"``: list 1318 | - ``"currentRange"``: list 1319 | - ``"minRating"``: int 1320 | - ``"isLowest"``: bool 1321 | - ``"isLowestOffer"``: bool 1322 | - ``"isOutOfStock"``: bool 1323 | - ``"titleSearch"``: String 1324 | - ``"isRangeEnabled"``: bool 1325 | - ``"isFilterEnabled"``: bool 1326 | - ``"hasReviews"``: bool 1327 | - ``"filterErotic"``: bool 1328 | - ``"sortType"``: int 1329 | - ``"dateRange"``: int 1330 | domain : str | keepa.Domain, default: 'US' 1331 | A valid Amazon domain. See :class:`keepa.Domain`. 1332 | wait : bool, default: True 1333 | Wait for available tokens before querying the keepa backend. 1334 | 1335 | Returns 1336 | ------- 1337 | dict 1338 | Dictionary containing the deals including the following keys: 1339 | 1340 | * ``'dr'`` - Ordered array of all deal objects matching your query. 1341 | * ``'categoryIds'`` - Contains all root categoryIds of the matched 1342 | deal products. 1343 | * ``'categoryNames'`` - Contains all root category names of the 1344 | matched deal products. 1345 | * ``'categoryCount'`` - Contains how many deal products in the 1346 | respective root category are found. 1347 | 1348 | Examples 1349 | -------- 1350 | Return deals from category 16310101 using the synchronous 1351 | ``keepa.Keepa`` class 1352 | 1353 | >>> import keepa 1354 | >>> key = "" 1355 | >>> api = keepa.Keepa(key) 1356 | >>> deal_parms = { 1357 | ... "page": 0, 1358 | ... "domainId": 1, 1359 | ... "excludeCategories": [1064954, 11091801], 1360 | ... "includeCategories": [16310101], 1361 | ... } 1362 | >>> deals = api.deals(deal_parms) 1363 | 1364 | Get the title of the first deal. 1365 | 1366 | >>> deals["dr"][0]["title"] 1367 | 'Orange Cream Rooibos, Tea Bags - Vanilla, Orange | Caffeine-Free, 1368 | Antioxidant-rich, Hot & Iced | The Spice Hut, First Sip Of Tea' 1369 | 1370 | Conduct the same query with the asynchronous ``keepa.AsyncKeepa`` 1371 | class. 1372 | 1373 | >>> import asyncio 1374 | >>> import keepa 1375 | >>> deal_parms = { 1376 | ... "page": 0, 1377 | ... "domainId": 1, 1378 | ... "excludeCategories": [1064954, 11091801], 1379 | ... "includeCategories": [16310101], 1380 | ... } 1381 | >>> async def main(): 1382 | ... key = "" 1383 | ... api = await keepa.AsyncKeepa().create(key) 1384 | ... categories = await api.search_for_categories("movies") 1385 | ... return await api.deals(deal_parms) 1386 | ... 1387 | >>> asins = asyncio.run(main()) 1388 | >>> asins 1389 | ['B0BF3P5XZS', 1390 | 'B08JQN5VDT', 1391 | 'B09SP8JPPK', 1392 | '0999296345', 1393 | 'B07HPG684T', 1394 | '1984825577', 1395 | ... 1396 | 1397 | """ 1398 | # verify valid keys 1399 | for key in deal_parms: 1400 | if key not in DEAL_REQUEST_KEYS: 1401 | raise ValueError(f'Invalid key "{key}"') 1402 | 1403 | # verify json type 1404 | key_type = DEAL_REQUEST_KEYS[key] 1405 | deal_parms[key] = key_type(deal_parms[key]) 1406 | 1407 | deal_parms.setdefault("priceTypes", 0) 1408 | 1409 | payload = { 1410 | "key": self.accesskey, 1411 | "domain": _domain_to_dcode(domain), 1412 | "selection": json.dumps(deal_parms), 1413 | } 1414 | 1415 | return self._request("deal", payload, wait=wait)["deals"] 1416 | 1417 | def _request( 1418 | self, 1419 | request_type: str, 1420 | payload: dict[str, Any], 1421 | wait: bool = True, 1422 | raw_response: bool = False, 1423 | is_json: bool = True, 1424 | ): 1425 | """ 1426 | Query keepa api server. 1427 | 1428 | Parses raw response from keepa into a json format. Handles errors and 1429 | waits for available tokens if allowed. 1430 | """ 1431 | while True: 1432 | raw = requests.get( 1433 | f"https://api.keepa.com/{request_type}/?", 1434 | payload, 1435 | timeout=self._timeout, 1436 | ) 1437 | status_code = str(raw.status_code) 1438 | 1439 | if is_json: 1440 | try: 1441 | response = raw.json() 1442 | except Exception: 1443 | raise RuntimeError(f"Invalid JSON from Keepa API (status {status_code})") 1444 | else: 1445 | return raw 1446 | 1447 | # user status is always returned 1448 | if "tokensLeft" in response: 1449 | self.tokens_left = response["tokensLeft"] 1450 | self.status.tokensLeft = self.tokens_left 1451 | log.info("%d tokens remain", self.tokens_left) 1452 | for key in ["refillIn", "refillRate", "timestamp"]: 1453 | if key in response: 1454 | setattr(self.status, key, response[key]) 1455 | 1456 | if status_code == "200": 1457 | if raw_response: 1458 | return raw 1459 | return response 1460 | 1461 | if status_code == "429" and wait: 1462 | tdelay = self.time_to_refill 1463 | log.warning("Waiting %.0f seconds for additional tokens", tdelay) 1464 | time.sleep(tdelay) 1465 | continue 1466 | 1467 | # otherwise, it's an error code 1468 | if status_code in SCODES: 1469 | raise RuntimeError(SCODES[status_code]) 1470 | raise RuntimeError(f"REQUEST_FAILED. Status code: {status_code}") 1471 | --------------------------------------------------------------------------------