├── tests ├── __init__.py ├── integration │ ├── __init__.py │ ├── private-resources │ │ ├── .gitignore │ │ ├── example-details-112-6539663-7312263.json │ │ └── example-history-112-6539663-7312263.json │ ├── test_integration_generic.py │ ├── test_integration_auth.py │ └── test_integration_json.py ├── unit │ ├── entity │ │ ├── __init__.py │ │ ├── test_parsable.py │ │ ├── test_item.py │ │ ├── test_transaction.py │ │ └── test_order.py │ ├── test_util.py │ └── test_conf.py ├── resources │ ├── auth │ │ ├── captcha_easy.jpg │ │ ├── captcha_easy_2.jpg │ │ ├── captcha_hard.jpg │ │ ├── post-signin-js-bot-challenge.html │ │ ├── captcha-field-keywords.html │ │ ├── post-signin-captcha-3.html │ │ └── post-signin-captcha-2.html │ ├── transactions │ │ ├── transaction-snippet.html │ │ ├── transaction-refund-snippet.html │ │ ├── get-transactions-snippet.html │ │ └── transaction-form-tag.html │ ├── 500.html │ └── orders │ │ └── order-missing-grand-total-snippet.html └── integrationtestcase.py ├── amazonorders ├── entity │ ├── __init__.py │ ├── seller.py │ ├── recipient.py │ ├── shipment.py │ ├── item.py │ ├── transaction.py │ └── parsable.py ├── __init__.py ├── banner.txt ├── exception.py ├── constants.py ├── util.py ├── transactions.py ├── conf.py └── orders.py ├── output └── .gitignore ├── .gitattributes ├── docs ├── _html │ ├── logo.png │ ├── robots.txt │ └── sitemap-index.xml ├── _static │ └── custom.css ├── _templates │ ├── usefullinks.html │ ├── sidebartoc.html │ └── layout.html ├── api.rst ├── index.rst ├── conf.py └── troubleshooting.rst ├── MANIFEST.in ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── enhancement.yml │ └── bug-report.yml ├── dependabot.yml ├── workflows │ ├── label-commenter.yml │ ├── automerge.yml │ ├── codeql-analysis.yml │ ├── stale.yml │ ├── validate.yml │ ├── build.yml │ ├── release.yml │ └── integration.yml └── label-commenter-config.yml ├── .gitignore ├── .readthedocs.yml ├── SECURITY.md ├── LICENSE ├── CONTRIBUTING.rst ├── Makefile ├── pyproject.toml ├── scripts └── build-test-resources.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /amazonorders/entity/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/entity/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /output/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/resources/** linguist-vendored -------------------------------------------------------------------------------- /tests/integration/private-resources/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /docs/_html/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexdlaird/amazon-orders/HEAD/docs/_html/logo.png -------------------------------------------------------------------------------- /amazonorders/__init__.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | __version__ = "4.0.18" 4 | -------------------------------------------------------------------------------- /tests/resources/auth/captcha_easy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexdlaird/amazon-orders/HEAD/tests/resources/auth/captcha_easy.jpg -------------------------------------------------------------------------------- /tests/resources/auth/captcha_easy_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexdlaird/amazon-orders/HEAD/tests/resources/auth/captcha_easy_2.jpg -------------------------------------------------------------------------------- /tests/resources/auth/captcha_hard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexdlaird/amazon-orders/HEAD/tests/resources/auth/captcha_hard.jpg -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Including text resources 2 | include LICENSE README.md CHANGELOG.md 3 | 4 | # Include files 5 | include amazonorders/banner.txt 6 | 7 | # Exclude build 8 | prune build -------------------------------------------------------------------------------- /docs/_html/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | 3 | Disallow: / 4 | 5 | Allow: /en/stable 6 | 7 | Allow: /en/latest 8 | 9 | Allow: /en/develop 10 | 11 | Sitemap: https://amazon-orders.readthedocs.io/sitemap-index.xml -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Community Support 4 | url: https://github.com/alexdlaird/amazon-orders/discussions 5 | about: Please put integration questions and suggestions here. -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "monday" 8 | time: "05:00" 9 | timezone: "America/Chicago" 10 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | .hide-header > h1:first-child { 2 | position: absolute; 3 | width: 1px; 4 | height: 1px; 5 | padding: 0; 6 | overflow: hidden; 7 | clip: rect(0, 0, 0, 0); 8 | white-space: nowrap; 9 | border: 0; 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | *.iml 4 | *.pyc 5 | .coverage 6 | *.log 7 | *.pytest_cache 8 | *.mypy_cache 9 | venv 10 | dist/ 11 | MANIFEST 12 | *.egg-info 13 | build 14 | .env 15 | 16 | .dmypy.json 17 | 18 | tests/.config/ 19 | tests/.integration-config/ 20 | scripts/*.html -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | formats: all 4 | 5 | sphinx: 6 | configuration: docs/conf.py 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | 15 | build: 16 | os: "ubuntu-22.04" 17 | tools: 18 | python: "3.12" -------------------------------------------------------------------------------- /docs/_html/sitemap-index.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | https://amazon-orders.readthedocs.io 6 | weekly 7 | 1 8 | 9 | -------------------------------------------------------------------------------- /.github/workflows/label-commenter.yml: -------------------------------------------------------------------------------- 1 | name: "Label Commenter" 2 | 3 | on: 4 | issues: 5 | types: 6 | - labeled 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | contents: none 12 | 13 | jobs: 14 | comment: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | ref: main 20 | 21 | - name: Label Commenter 22 | uses: peaceiris/actions-label-commenter@v1 23 | with: 24 | github_token: ${{ secrets.GITHUB_TOKEN }} 25 | config_file: .github/label-commenter-config.yml 26 | -------------------------------------------------------------------------------- /docs/_templates/usefullinks.html: -------------------------------------------------------------------------------- 1 |

Useful Links

2 | -------------------------------------------------------------------------------- /amazonorders/banner.txt: -------------------------------------------------------------------------------- 1 | 2 | ======================================================================= 3 | ___ _____ _ 4 | / _ \ | _ | | | 5 | / /_\ \_ __ ___ __ _ _______ _ __ | | | |_ __ __| | ___ _ __ ___ 6 | | _ | '_ ` _ \ / _` |_ / _ \| '_ \ | | | | '__/ _` |/ _ \ '__/ __| 7 | | | | | | | | | | (_| |/ / (_) | | | | \ \_/ / | | (_| | __/ | \__ \\ 8 | \_| |_/_| |_| |_|\__,_/___\___/|_| |_| \___/|_| \__,_|\___|_| |___/ 9 | ======================================================================= 10 | v{version} 11 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | |---------|--------------------| 7 | | 4.0.x | :white_check_mark: | 8 | | < 4.0 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | If you suspect you have found a security vulnerability, send an email 13 | to [contact@alexlaird.com](mailto:contact@alexlaird.com). 14 | Please include a clear and concise description of what the vulnerability is, where it is exposed in the code, and (if 15 | known) what best practices might apply to patching it. 16 | 17 | All reports will be reviewed in a timely manner. If the issue is confirmed, a patch will be released as soon as 18 | possible. 19 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Auto-Merge Dependabot PRs 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | issues: none 10 | 11 | jobs: 12 | automerge: 13 | name: Auto-Merge Dependabot PRs 14 | 15 | runs-on: ubuntu-latest 16 | 17 | if: github.actor == 'dependabot[bot]' 18 | steps: 19 | - name: Get Dependabot metadata 20 | uses: dependabot/fetch-metadata@v2 21 | with: 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | - name: Enable auto-merge 24 | run: | 25 | gh pr review --approve "$PR_URL" 26 | gh pr merge --squash --auto "$PR_URL" 27 | env: 28 | PR_URL: ${{ github.event.pull_request.html_url }} 29 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | security-events: write 11 | contents: read 12 | actions: read 13 | issues: none 14 | pull-requests: none 15 | 16 | jobs: 17 | analyse: 18 | name: Analyse 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | # Initializes the CodeQL tools for scanning. 27 | - name: Initialize CodeQL 28 | uses: github/codeql-action/init@v3 29 | 30 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java) 31 | - name: Autobuild 32 | uses: github/codeql-action/autobuild@v3 33 | 34 | - name: Perform CodeQL Analysis 35 | uses: github/codeql-action/analyze@v3 36 | -------------------------------------------------------------------------------- /docs/_templates/sidebartoc.html: -------------------------------------------------------------------------------- 1 |

Getting Around

2 | -------------------------------------------------------------------------------- /tests/unit/entity/test_parsable.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | from bs4 import BeautifulSoup 5 | 6 | from amazonorders.entity.parsable import Parsable 7 | from tests.unittestcase import UnitTestCase 8 | 9 | 10 | class TestItem(UnitTestCase): 11 | def test_to_currency(self): 12 | # GIVEN 13 | html = "" 14 | parsed = BeautifulSoup(html, self.test_config.bs4_parser) 15 | 16 | # WHEN 17 | parsable = Parsable(parsed, self.test_config) 18 | 19 | # THEN 20 | self.assertIsNone(parsable.to_currency(None)) 21 | self.assertIsNone(parsable.to_currency("")) 22 | self.assertEqual(parsable.to_currency(1234.99), 1234.99) 23 | self.assertEqual(parsable.to_currency(1234), 1234) 24 | self.assertEqual(parsable.to_currency("1,234.99"), 1234.99) 25 | self.assertEqual(parsable.to_currency("$1,234.99"), 1234.99) 26 | self.assertIsNone(parsable.to_currency("not currency")) 27 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Stale Check" 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | contents: none 11 | 12 | jobs: 13 | stale: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/stale@v9 19 | with: 20 | repo-token: ${{ secrets.GITHUB_TOKEN }} 21 | stale-issue-message: "This issue is stale because it has remained open with no further activity on it. Comment with additional context or the issue will be closed automatically. Thank you for your contribution!" 22 | stale-pr-message: "This PR is stale because it has remained open with no further activity on it. If is is still being actively worked by you, label it as \"wip\", otherwise it will be closed automatically. Thank you for your contribution!" 23 | stale-issue-label: "stale" 24 | exempt-issue-labels: "ready,help wanted,wip,security" 25 | stale-pr-label: "stale" 26 | exempt-pr-labels: "wip,security" 27 | days-before-stale: 7 28 | days-before-close: 7 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 Alex Laird 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | If you find issues, `report them on GitHub `_. 5 | 6 | If you would like to contribute to the code, the process is pretty simple: 7 | 8 | 1. Familiarise yourself with this package and its dependencies. 9 | 2. Fork `the repository on GitHub `_ and start implementing changes. 10 | 3. Write a test that plainly validates the changes made. 11 | 4. Build and test locally with ``make local``, ``make test``, and ``make test-integration``. 12 | 5. Ensure no linting errors were introduced by running ``make check``. 13 | 6. Submit a `pull requests `_ to get the changes merged. 14 | 15 | Also be sure to review the `Code of Conduct `_ before 16 | submitting issues or pull requests. 17 | 18 | Want to contribute financially? If you've found ``amazon-orders`` useful, `sponsorship `_ 19 | would also be greatly appreciated! -------------------------------------------------------------------------------- /amazonorders/entity/seller.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import logging 5 | from typing import Optional 6 | 7 | from bs4 import Tag 8 | 9 | from amazonorders.conf import AmazonOrdersConfig 10 | from amazonorders.entity.parsable import Parsable 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Seller(Parsable): 16 | """ 17 | An Amazon Seller of an Amazon :class:`~amazonorders.entity.item.Item`. 18 | """ 19 | 20 | def __init__(self, 21 | parsed: Tag, 22 | config: AmazonOrdersConfig) -> None: 23 | super().__init__(parsed, config) 24 | 25 | #: The Seller name. 26 | self.name: str = self.safe_simple_parse(self.config.selectors.FIELD_SELLER_NAME_SELECTOR, 27 | prefix_split="Sold by:") 28 | #: The Seller link. 29 | self.link: Optional[str] = self.safe_simple_parse(selector=self.config.selectors.FIELD_SELLER_LINK_SELECTOR, 30 | attr_name="href") 31 | 32 | def __repr__(self) -> str: 33 | return f"" 34 | 35 | def __str__(self) -> str: # pragma: no cover 36 | return f"Seller: {self.name}" 37 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: 7 | contents: read 8 | issues: none 9 | pull-requests: none 10 | 11 | jobs: 12 | validate: 13 | name: Validate 14 | 15 | env: 16 | FORCE_COLOR: 1 17 | PYTHONUNBUFFERED: 1 18 | PYTHONDONTWRITEBYTECODE: 1 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Python 3.12 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.12" 28 | - name: Install jpeg 29 | run: sudo apt-get install -y libjpeg-dev zlib1g-dev 30 | - name: Build test coverage 31 | run: make install test 32 | - name: Build docs 33 | run: make docs 34 | - name: Check code 35 | run: make check 36 | - name: Validate package 37 | run: make local 38 | - name: Upload test coverage 39 | if: ${{ !cancelled() }} 40 | uses: codecov/codecov-action@v4 41 | with: 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | directory: ./build/coverage 44 | - name: Upload test results 45 | if: ${{ !cancelled() }} 46 | uses: codecov/test-results-action@v1 47 | with: 48 | token: ${{ secrets.CODECOV_TOKEN }} 49 | directory: ./build/test-results -------------------------------------------------------------------------------- /tests/resources/transactions/transaction-snippet.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | My Payment Method 5 |
6 |
7 | -CA$12.34 8 |
9 |
10 |
11 |
12 | 15 |
16 |
17 |
18 |
19 |
20 | AMZN Mktp COM 21 |
22 |
23 |
24 |
-------------------------------------------------------------------------------- /amazonorders/exception.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | from typing import Any, Dict, Optional, Union 5 | 6 | 7 | class AmazonOrdersError(Exception): 8 | """ 9 | Raised when a general ``amazon-orders`` error has occurred. 10 | """ 11 | 12 | def __init__(self, 13 | error: Union[str, BaseException], 14 | meta: Optional[Dict[str, Any]] = None) -> None: 15 | super(AmazonOrdersError, self).__init__(error) 16 | 17 | #: Metadata for context around the error was raised. 18 | self.meta: Optional[Dict[str, Any]] = meta 19 | 20 | 21 | class AmazonOrdersNotFoundError(AmazonOrdersError): 22 | """ 23 | Raised when an Amazon page is not found. 24 | """ 25 | pass 26 | 27 | 28 | class AmazonOrdersAuthError(AmazonOrdersError): 29 | """ 30 | Raised when an ``amazon-orders`` authentication error has occurred. 31 | """ 32 | pass 33 | 34 | 35 | class AmazonOrdersAuthRedirectError(AmazonOrdersAuthError): 36 | """ 37 | Raised when an ``amazon-orders`` session that was previously authenticated redirects to login, indicating the 38 | likely need to reauthenticate. 39 | """ 40 | pass 41 | 42 | 43 | class AmazonOrdersEntityError(AmazonOrdersError): 44 | """ 45 | Raised when an ``amazon-orders`` entity parsing error has occurred. 46 | """ 47 | pass 48 | -------------------------------------------------------------------------------- /tests/resources/transactions/transaction-refund-snippet.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | My Payment Method 5 |
6 |
7 | +CA$12.34 8 |
9 |
10 |
11 | 16 |
17 |
18 |
19 |
20 | AMZN Mktp COM 21 |
22 |
23 |
24 |
-------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | run-name: Build ${{ github.ref_name }} 4 | 5 | on: 6 | push: 7 | branches: [ main, develop ] 8 | pull_request: 9 | branches: [ main, develop ] 10 | workflow_dispatch: 11 | workflow_call: 12 | 13 | permissions: 14 | contents: read 15 | issues: none 16 | pull-requests: none 17 | 18 | jobs: 19 | build: 20 | name: Build 21 | 22 | env: 23 | FORCE_COLOR: 1 24 | PYTHONUNBUFFERED: 1 25 | PYTHONDONTWRITEBYTECODE: 1 26 | 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | os: [ ubuntu-latest, macos-latest, windows-latest ] 31 | python-version: [ "3.12" ] 32 | include: 33 | - python-version: "3.11" 34 | os: ubuntu-latest 35 | - python-version: "3.9" 36 | os: ubuntu-latest 37 | 38 | runs-on: ${{ matrix.os }} 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | - name: Set up Python ${{ matrix.python-version }} 43 | uses: actions/setup-python@v5 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | - name: Install jpeg 47 | if: matrix.os == 'ubuntu-latest' 48 | run: sudo apt-get install -y libjpeg-dev zlib1g-dev 49 | - name: Install dependencies 50 | run: make install 51 | - name: Test 52 | run: make test 53 | 54 | validate: 55 | name: Validate 56 | uses: ./.github/workflows/validate.yml 57 | secrets: inherit -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Enhancement 2 | description: New feature request? Docs need improvement? Let us know. 3 | labels: ["enhancement"] 4 | 5 | body: 6 | 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Consider [supporting the project](https://github.com/sponsors/alexdlaird) ❤ to have your request prioritized. 11 | 12 | - type: checkboxes 13 | id: ack 14 | attributes: 15 | label: Acknowledgements 16 | options: 17 | - label: I have written a descriptive issue title 18 | required: true 19 | - label: I have searched [`amazon-orders`'s documentation](https://amazon-orders.readthedocs.io/en/latest/) to see if the feature is already support 20 | required: true 21 | - label: I have searched [Issues](issues?q=is%3Aissue) to see if the feature has already been requested 22 | required: true 23 | - label: I have [enabled `debug` mode](https://amazon-orders.readthedocs.io/troubleshooting.html#enable-debug-mode) and am attaching relevant console logs and HTML files 24 | 25 | - type: textarea 26 | id: describe-the-enhancement 27 | attributes: 28 | label: Describe the Enhancement 29 | description: A clear and concise description of the how and why of the feature you'd like to see, or what specifically should be added to the documentation. If possible, include possible code snippets of interfaces you'd like to see and how you might interact with them. 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | id: describe-workarounds 35 | attributes: 36 | label: Describe Alternative Solutions/Workarounds 37 | description: A clear and concise description of any alternative solutions or features you've considered. If applicable, please provide snippets of any current workarounds you use. 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | run-name: Release ${{ github.ref_name }} 4 | 5 | on: 6 | release: 7 | types: [ created ] 8 | 9 | permissions: 10 | contents: read 11 | issues: none 12 | pull-requests: none 13 | 14 | jobs: 15 | validate-version: 16 | name: Validate Version 17 | 18 | env: 19 | FORCE_COLOR: 1 20 | PYTHONUNBUFFERED: 1 21 | 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Python 3.12 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.12" 30 | - name: Validate version 31 | run: make validate-release VERSION=${GITHUB_REF#refs/tags/} 32 | 33 | build: 34 | name: Build 35 | uses: alexdlaird/amazon-orders/.github/workflows/build.yml@main 36 | secrets: inherit 37 | 38 | integration: 39 | name: Integration Test 40 | if: ${{ vars.AMAZON_INTEGRATION_SKIP_ON_RELEASE != 'true' }} 41 | uses: alexdlaird/amazon-orders/.github/workflows/integration.yml@main 42 | secrets: inherit 43 | needs: validate-version 44 | 45 | Release: 46 | name: Release 47 | needs: [build, validate-version, integration] 48 | if: ${{ always() && ! failure() && ! cancelled() }} 49 | 50 | env: 51 | FORCE_COLOR: 1 52 | PYTHONUNBUFFERED: 1 53 | 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Set up Python 3.12 59 | uses: actions/setup-python@v5 60 | with: 61 | python-version: "3.12" 62 | - name: Install jpeg 63 | run: sudo apt-get install -y libjpeg-dev zlib1g-dev 64 | - name: Publish 65 | env: 66 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 67 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 68 | run: make upload -------------------------------------------------------------------------------- /tests/integration/private-resources/example-details-112-6539663-7312263.json: -------------------------------------------------------------------------------- 1 | { 2 | "func": "get_order", 3 | "grand_total": 45.25, 4 | "order_number": "112-6539663-7312263", 5 | "order_details_link": "isNotNone", 6 | "order_placed_date": "2024-05-12", 7 | "recipient": { 8 | "name": "Alex Laird", 9 | "address": "isNotNone" 10 | }, 11 | "payment_method": "American Express", 12 | "payment_method_last_4": "isNotNone", 13 | "subtotal": 42.29, 14 | "shipping_total": 0.0, 15 | "subscription_discount": "isNone", 16 | "total_before_tax": 42.29, 17 | "estimated_tax": 2.96, 18 | "shipments": [ 19 | { 20 | "items": [ 21 | { 22 | "title": "Go Green Power Inc. GG-13725BK 16/3 Heavy Duty Extension Cord, Outdoor Extension Cord, Black, 25 ft" 23 | } 24 | ], 25 | "delivery_status": "Delivered May 13, 2024" 26 | }, 27 | { 28 | "items": [ 29 | { 30 | "title": "kimoe 5LB 100% Natural Non-GMO Dried mealworms-High-Protein for Birds, Chicken,Ducks" 31 | } 32 | ], 33 | "delivery_status": "Delivered May 18, 2024" 34 | } 35 | ], 36 | "items": [ 37 | { 38 | "title": "Go Green Power Inc. GG-13725BK 16/3 Heavy Duty Extension Cord, Outdoor Extension Cord, Black, 25 ft", 39 | "seller": { 40 | "name": "Amazon.com Services, Inc", 41 | "link": "isNone" 42 | } 43 | }, 44 | { 45 | "title": "kimoe 5LB 100% Natural Non-GMO Dried mealworms-High-Protein for Birds, Chicken,Ducks", 46 | "seller": { 47 | "name": "kimoe store", 48 | "link": "isNotNone" 49 | } 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /amazonorders/entity/recipient.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import logging 5 | from typing import Optional 6 | 7 | from bs4 import Tag 8 | 9 | from amazonorders.conf import AmazonOrdersConfig 10 | from amazonorders.entity.parsable import Parsable 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Recipient(Parsable): 16 | """ 17 | The person receiving an Amazon :class:`~amazonorders.entity.order.Order`. 18 | """ 19 | 20 | def __init__(self, 21 | parsed: Tag, 22 | config: AmazonOrdersConfig) -> None: 23 | super().__init__(parsed, config) 24 | 25 | #: The Recipient name. 26 | self.name: str = self.safe_simple_parse(selector=self.config.selectors.FIELD_RECIPIENT_NAME_SELECTOR, 27 | required=True) 28 | #: The Recipient address. 29 | self.address: Optional[str] = self.safe_parse(self._parse_address) 30 | 31 | def __repr__(self) -> str: 32 | return f"" 33 | 34 | def __str__(self) -> str: # pragma: no cover 35 | return f"Recipient: {self.name}" 36 | 37 | def _parse_address(self) -> Optional[str]: 38 | value = self.simple_parse(self.config.selectors.FIELD_RECIPIENT_ADDRESS1_SELECTOR) 39 | 40 | if value: 41 | values = [ 42 | value, 43 | self.simple_parse(self.config.selectors.FIELD_RECIPIENT_ADDRESS2_SELECTOR), 44 | self.simple_parse(self.config.selectors.FIELD_RECIPIENT_ADDRESS_CITY_STATE_POSTAL_SELECTOR), 45 | self.simple_parse(self.config.selectors.FIELD_RECIPIENT_ADDRESS_COUNTRY_SELECTOR), 46 | ] 47 | value = "\n".join(filter(None, values)) 48 | else: 49 | value = self.simple_parse(self.config.selectors.FIELD_RECIPIENT_ADDRESS_FALLBACK_SELECTOR) 50 | 51 | return value 52 | -------------------------------------------------------------------------------- /.github/label-commenter-config.yml: -------------------------------------------------------------------------------- 1 | labels: 2 | - name: invalid-forum 3 | labeled: 4 | issue: 5 | body: | 6 | This is a bug tracker and not a support forum. 7 | 8 | While we'd love to be able to support every person on their projects, that would simply be infeasible. Luckily, that's what the helpful developer community over at Stack Overflow is for! Please [create a question there](https://stackoverflow.com/questions/ask?tags=python) if what you're looking for is a programming question or you need help with an integration. 9 | 10 | If you [support the project](https://github.com/sponsors/alexdlaird), contact me directly and I'd be happy to help you troubleshoot. 11 | action: close 12 | - name: more-info-needed 13 | labeled: 14 | issue: 15 | body: | 16 | Thanks for submitting your issue! It looks like there is not enough detail given for us to be able to troubleshoot. 17 | 18 | Because Amazon can render slightly different versions of its pages to each user, we rely extensively on users troubleshooting their own issues and [submitting fixes via PRs](https://github.com/alexdlaird/amazon-orders/blob/main/CONTRIBUTING.rst). There is simply no way for us to resolve many of these edge case issues ourselves, since one Amazon account cannot necessarily replicate another Amazon account's exact same experience. We hope you'll start working on a PR for us to evaluate! 19 | 20 | If you want us to troubleshoot the issue, we need more information. To investigate issues, we rely on user-submitted examples of the HTML rendered for pages from your account. Follow the steps in the docs to [enable debug mode](https://amazon-orders.readthedocs.io/troubleshooting.html#enable-debug-mode), attach relevant console logs, and upload relevant HTML files to this ticket (remember to obfuscate any sensitive data first). 21 | 22 | Once we have more information, or a PR to evaluate, we will follow up further on this ticket. Thanks! -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {%- block extrahead %} 4 | {{ super() }} 5 | 6 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 34 | {% endblock %} 35 | 36 | {% block footer %} 37 | {{ super() }} 38 | 39 | Fork me on GitHub 44 | 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /tests/unit/entity/test_item.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | from amazonorders.conf import AmazonOrdersConfig 5 | from bs4 import BeautifulSoup 6 | 7 | from amazonorders.entity.item import Item 8 | from tests.unittestcase import UnitTestCase 9 | 10 | 11 | class TestItem(UnitTestCase): 12 | def test_price_stripped(self): 13 | # GIVEN 14 | html = """ 15 |
16 | 21 |
22 | 23 |
Return window closed on Feb 2, 2019
24 |
25 |
26 |
27 | 28 | $1,234.99 29 | 30 |
31 |
32 | 33 | """ 34 | parsed = BeautifulSoup(html, self.test_config.bs4_parser) 35 | 36 | # WHEN 37 | item = Item(parsed, self.test_config) 38 | 39 | # THEN 40 | self.assertEqual(item.title, "Item Title") 41 | self.assertEqual(item.price, 1234.99) 42 | 43 | def test_title_starts_with_ampersand_use_lxml(self): 44 | # GIVEN 45 | lxml_config = AmazonOrdersConfig(data={ 46 | "output_dir": self.test_output_dir, 47 | "cookie_jar_path": self.test_cookie_jar_path, 48 | "bs4_parser": "lxml" 49 | }) 50 | html = """ 51 |
52 |
53 | &And Per Se Lined 54 |
55 |
56 | """ 57 | parsed = BeautifulSoup(html, lxml_config.bs4_parser) 58 | 59 | # WHEN 60 | item = Item(parsed, lxml_config) 61 | 62 | # THEN 63 | self.assertEqual(item.title, "&And Per Se Lined") 64 | -------------------------------------------------------------------------------- /tests/resources/auth/post-signin-js-bot-challenge.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 13 | 21 | 22 | 23 | 24 |
25 | 39 | 44 | 45 | -------------------------------------------------------------------------------- /tests/unit/test_util.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | from amazonorders.util import to_type, cleanup_html_text 5 | from tests.unittestcase import UnitTestCase 6 | 7 | 8 | class TestUtil(UnitTestCase): 9 | def test_to_type(self): 10 | self.assertIsNone(to_type(None)) 11 | 12 | self.assertEqual(to_type("0.0"), 0.0) 13 | self.assertEqual(to_type("0.1"), 0.1) 14 | self.assertEqual(to_type("0"), 0) 15 | self.assertEqual(to_type("1.0"), 1.0) 16 | self.assertEqual(to_type("1.1"), 1.1) 17 | self.assertEqual(to_type("1"), 1) 18 | 19 | self.assertEqual(to_type("True"), True) 20 | self.assertEqual(to_type("False"), False) 21 | 22 | self.assertIsNone(to_type("")) 23 | self.assertEqual(to_type(" "), " ") 24 | self.assertEqual(to_type("None"), "None") 25 | 26 | def test_cleanup_html_text(self): 27 | self.assertEqual(cleanup_html_text("""This is a paragraph. 28 | 29 | 30 | So much space. More space. 31 | This sentence will have period added 32 | So will this one with two spaces 33 | 34 | And then some more. 35 | 36 | And that's all""" # noqa: W293 37 | ), 38 | "This is a paragraph. So much space. More space. This sentence will have period " 39 | "added. So will this one with two spaces. And then some more. And that's all.") 40 | self.assertEqual(cleanup_html_text(""" There was a problem 41 | 42 | The One Time Password (OTP) you entered is not valid. 43 | 44 | Please try again 45 | 46 | """ # noqa: W293 47 | ), 48 | "There was a problem. The One Time Password (OTP) you entered is not valid. " 49 | "Please try again.") 50 | self.assertEqual(cleanup_html_text(""" 51 | 52 | This has leading newlines. 53 | 54 | They should be removed 55 | 56 | """ # noqa: W293 57 | ), "This has leading newlines. They should be removed.") 58 | -------------------------------------------------------------------------------- /amazonorders/entity/shipment.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import logging 5 | from typing import List, Optional, TypeVar 6 | 7 | from bs4 import Tag 8 | 9 | from amazonorders import util 10 | from amazonorders.conf import AmazonOrdersConfig 11 | from amazonorders.entity.item import Item 12 | from amazonorders.entity.parsable import Parsable 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | ShipmentEntity = TypeVar("ShipmentEntity", bound="Shipment") 17 | 18 | 19 | class Shipment(Parsable): 20 | """ 21 | An Amazon Shipment, which should contain one or more :class:`~amazonorders.entity.item.Item`'s. 22 | """ 23 | 24 | def __init__(self, 25 | parsed: Tag, 26 | config: AmazonOrdersConfig) -> None: 27 | super().__init__(parsed, config) 28 | 29 | #: The Shipment Items. 30 | self.items: List[Item] = self._parse_items() 31 | #: The Shipment delivery status. 32 | self.delivery_status: Optional[str] = self.safe_simple_parse( 33 | selector=self.config.selectors.FIELD_SHIPMENT_DELIVERY_STATUS_SELECTOR) 34 | #: The Shipment tracking link. 35 | self.tracking_link: Optional[str] = self.safe_simple_parse( 36 | selector=self.config.selectors.FIELD_SHIPMENT_TRACKING_LINK_SELECTOR, 37 | attr_name="href") 38 | 39 | def __repr__(self) -> str: 40 | return f"" 41 | 42 | def __str__(self) -> str: # pragma: no cover 43 | return f"Shipment: {self.items}" 44 | 45 | def __lt__(self, 46 | other: ShipmentEntity) -> bool: 47 | if self.delivery_status: 48 | return self.delivery_status < str(other.delivery_status if other.delivery_status else "") 49 | else: 50 | return str(self.items) < str(other.items) 51 | 52 | def _parse_items(self) -> List[Item]: 53 | if not self.parsed: 54 | return [] 55 | 56 | items: List[Item] = [self.config.item_cls(x, self.config) 57 | for x in util.select(self.parsed, 58 | self.config.selectors.ITEM_ENTITY_SELECTOR)] 59 | items.sort() 60 | return items 61 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_call: 7 | workflow_dispatch: 8 | schedule: 9 | - cron: "0 7 * * *" 10 | 11 | permissions: 12 | contents: read 13 | issues: none 14 | pull-requests: none 15 | 16 | concurrency: 17 | group: amazon-orders-integration-singleton 18 | 19 | jobs: 20 | test: 21 | name: Integration Test 22 | 23 | env: 24 | FORCE_COLOR: 1 25 | PYTHONUNBUFFERED: 1 26 | PYTHONDONTWRITEBYTECODE: 1 27 | DEBUG: "True" 28 | AMAZON_USERNAME: ${{ secrets.AMAZON_USERNAME }} 29 | AMAZON_PASSWORD: ${{ secrets.AMAZON_PASSWORD }} 30 | AMAZON_OTP_SECRET_KEY: ${{ secrets.AMAZON_OTP_SECRET_KEY }} 31 | # The following variables are not required, defaults will be used if they are not defined 32 | AMAZON_BASE_URL: ${{ vars.AMAZON_BASE_URL }} 33 | AMAZON_INTEGRATION_TEST_JSON: ${{ vars.AMAZON_INTEGRATION_TEST_JSON }} 34 | AMAZON_INTEGRATION_TEST_AUTH: ${{ vars.AMAZON_INTEGRATION_TEST_AUTH }} 35 | AMAZON_INTEGRATION_TEST_AUTH_WRONG_PASSWORD: ${{ vars.AMAZON_INTEGRATION_TEST_AUTH_WRONG_PASSWORD }} 36 | AMAZON_INTEGRATION_TEST_YEAR: ${{ vars.AMAZON_INTEGRATION_TEST_YEAR }} 37 | AMAZON_START_INDEX: ${{ vars.AMAZON_START_INDEX }} 38 | AMAZON_TRANSACTIONS_DAYS: ${{ vars.AMAZON_TRANSACTIONS_DAYS }} 39 | AMAZON_FULL_DETAILS_LOOP_COUNT: ${{ vars.AMAZON_FULL_DETAILS_LOOP_COUNT }} 40 | 41 | runs-on: ubuntu-latest 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Set up Python 3.12 46 | uses: actions/setup-python@v5 47 | with: 48 | python-version: "3.12" 49 | - name: Install jpeg 50 | run: sudo apt-get install -y libjpeg-dev zlib1g-dev 51 | - name: Integration test 52 | run: make test-integration 53 | - name: Upload test output 54 | if: ${{ always() }} 55 | uses: actions/upload-artifact@v4 56 | with: 57 | name: integration-test-output 58 | include-hidden-files: true 59 | path: tests/.integration-config/ 60 | retention-days: 7 61 | - name: Upload test results 62 | if: ${{ !cancelled() }} 63 | uses: codecov/test-results-action@v1 64 | with: 65 | token: ${{ secrets.CODECOV_TOKEN }} 66 | directory: ./build/test-results 67 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | API Documentation 3 | ================= 4 | 5 | This part of the documentation covers the interfaces used to develop with ``amazon-orders``. 6 | 7 | Main Interface 8 | -------------- 9 | 10 | .. automodule:: amazonorders.orders 11 | :members: 12 | :private-members: 13 | :show-inheritance: 14 | 15 | .. automodule:: amazonorders.transactions 16 | :members: 17 | :private-members: 18 | :show-inheritance: 19 | 20 | Session Management 21 | ------------------ 22 | 23 | .. automodule:: amazonorders.session 24 | :members: 25 | :private-members: 26 | :show-inheritance: 27 | 28 | .. automodule:: amazonorders.forms 29 | :members: 30 | :private-members: 31 | :show-inheritance: 32 | 33 | Configuration 34 | ------------- 35 | .. automodule:: amazonorders.conf 36 | :members: 37 | :private-members: 38 | :show-inheritance: 39 | 40 | .. automodule:: amazonorders.constants 41 | :members: 42 | :private-members: 43 | :show-inheritance: 44 | 45 | .. automodule:: amazonorders.selectors 46 | :members: 47 | :private-members: 48 | :show-inheritance: 49 | 50 | Entities 51 | -------- 52 | 53 | .. automodule:: amazonorders.entity.parsable 54 | :members: 55 | :private-members: 56 | :show-inheritance: 57 | 58 | .. automodule:: amazonorders.entity.item 59 | :members: 60 | :private-members: 61 | :show-inheritance: 62 | 63 | .. automodule:: amazonorders.entity.order 64 | :members: 65 | :private-members: 66 | :show-inheritance: 67 | 68 | .. automodule:: amazonorders.entity.recipient 69 | :members: 70 | :private-members: 71 | :show-inheritance: 72 | 73 | .. automodule:: amazonorders.entity.seller 74 | :members: 75 | :private-members: 76 | :show-inheritance: 77 | 78 | .. automodule:: amazonorders.entity.shipment 79 | :members: 80 | :private-members: 81 | :show-inheritance: 82 | 83 | .. automodule:: amazonorders.entity.transaction 84 | :members: 85 | :private-members: 86 | :show-inheritance: 87 | 88 | Exceptions 89 | ---------- 90 | 91 | .. automodule:: amazonorders.exception 92 | :members: 93 | :private-members: 94 | :show-inheritance: 95 | 96 | Utility Functions 97 | ----------------- 98 | 99 | .. automodule:: amazonorders.util 100 | :members: 101 | :private-members: 102 | :show-inheritance: -------------------------------------------------------------------------------- /tests/integration/private-resources/example-history-112-6539663-7312263.json: -------------------------------------------------------------------------------- 1 | { 2 | "func": "get_order_history", 3 | "year": 2024, 4 | "start_index": 20, 5 | "full_details": true, 6 | "orders_len": 10, 7 | "orders": { 8 | "4": { 9 | "grand_total": 45.25, 10 | "order_number": "112-6539663-7312263", 11 | "order_details_link": "isNotNone", 12 | "order_placed_date": "2024-05-12", 13 | "recipient": { 14 | "name": "Alex Laird", 15 | "address": "isNotNone" 16 | }, 17 | "payment_method": "American Express", 18 | "payment_method_last_4": "isNotNone", 19 | "subtotal": 42.29, 20 | "shipping_total": 0.0, 21 | "subscription_discount": "isNone", 22 | "total_before_tax": 42.29, 23 | "estimated_tax": 2.96, 24 | "shipments": [ 25 | { 26 | "items": [ 27 | { 28 | "title": "Go Green Power Inc. GG-13725BK 16/3 Heavy Duty Extension Cord, Outdoor Extension Cord, Black, 25 ft" 29 | } 30 | ], 31 | "delivery_status": "Delivered May 13" 32 | }, 33 | { 34 | "items": [ 35 | { 36 | "title": "kimoe 5LB 100% Natural Non-GMO Dried mealworms-High-Protein for Birds, Chicken,Ducks" 37 | } 38 | ], 39 | "delivery_status": "Delivered May 18" 40 | } 41 | ], 42 | "items": [ 43 | { 44 | "title": "Go Green Power Inc. GG-13725BK 16/3 Heavy Duty Extension Cord, Outdoor Extension Cord, Black, 25 ft", 45 | "return_eligible_date": "2024-06-12", 46 | "seller": { 47 | "name": "Amazon.com Services, Inc", 48 | "link": "isNone" 49 | } 50 | }, 51 | { 52 | "title": "kimoe 5LB 100% Natural Non-GMO Dried mealworms-High-Protein for Birds, Chicken,Ducks", 53 | "seller": { 54 | "name": "kimoe store", 55 | "link": "isNotNone" 56 | } 57 | } 58 | ] 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Found a bug? Help us squash it. 3 | labels: ["bug"] 4 | 5 | body: 6 | 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Consider [supporting the project](https://github.com/sponsors/alexdlaird) ❤ to have your request prioritized. 11 | 12 | - type: checkboxes 13 | id: ack 14 | attributes: 15 | label: Acknowledgements 16 | options: 17 | - label: I have written a descriptive issue title 18 | required: true 19 | - label: I have searched [`amazon-orders`'s documentation](https://amazon-orders.readthedocs.io/en/latest/) for an answer 20 | required: true 21 | - label: I have searched [Issues](issues?q=is%3Aissue) to see if the bug has already been reported 22 | required: true 23 | - label: I have searched [Stack Overflow](https://stackoverflow.com/questions/) to ensure the issue I'm experiencing has not already been discussed 24 | required: true 25 | - label: I have [enabled `debug` mode](https://amazon-orders.readthedocs.io/troubleshooting.html#enable-debug-mode) and am attaching relevant console logs and HTML files 26 | 27 | - type: input 28 | id: os-version 29 | attributes: 30 | label: Operating System 31 | placeholder: macOS 10.14.5 32 | validations: 33 | required: true 34 | 35 | - type: input 36 | id: python-version 37 | attributes: 38 | label: Python Version 39 | placeholder: 3.9.2 40 | validations: 41 | required: true 42 | 43 | - type: input 44 | id: library-version 45 | attributes: 46 | label: amazon-orders Version 47 | placeholder: 1.0.3 48 | validations: 49 | required: true 50 | 51 | - type: input 52 | id: amazon-url 53 | attributes: 54 | label: What URL does your browser take you to when logging in to Amazon? 55 | placeholder: https://www.amazon.com/ap/signin 56 | validations: 57 | required: true 58 | 59 | - type: textarea 60 | id: describe-the-bug 61 | attributes: 62 | label: Describe the Bug 63 | description: A clear and concise description of what the bug is, including exceptions thrown. 64 | validations: 65 | required: true 66 | 67 | - type: textarea 68 | id: steps-to-reproduce 69 | attributes: 70 | label: Steps to Reproduce 71 | description: A snippet of the code used to reproduce the behavior. 72 | validations: 73 | required: true 74 | 75 | - type: textarea 76 | id: expected-behavior 77 | attributes: 78 | label: Expected Behavior 79 | description: A brief description of what you expected to happen. 80 | -------------------------------------------------------------------------------- /tests/integrationtestcase.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import os 5 | import sys 6 | import time 7 | 8 | from amazonorders import conf 9 | from amazonorders.conf import AmazonOrdersConfig 10 | from amazonorders.orders import AmazonOrders 11 | from amazonorders.session import AmazonSession 12 | from amazonorders.transactions import AmazonTransactions 13 | from tests.testcase import TestCase 14 | 15 | 16 | class IntegrationTestCase(TestCase): 17 | """ 18 | This test class will prompt for challenges (2FA, Captcha) as necessary. To run fully automated (assuming 19 | no Captcha prompts are encountered), set all authentication environment variables: 20 | 21 | - AMAZON_USERNAME 22 | - AMAZON_PASSWORD 23 | - AMAZON_OTP_SECRET_KEY (optional, if 2FA is enabled for the account) 24 | """ 25 | 26 | amazon_session = None 27 | 28 | @classmethod 29 | def setUpClass(cls): 30 | cls.set_up_class_conf() 31 | 32 | cls.debug = os.environ.get("DEBUG", "False") == "True" 33 | cls.amazon_session = AmazonSession(debug=cls.debug, 34 | config=cls.test_config) 35 | cls.amazon_session.login() 36 | 37 | cls.amazon_orders = AmazonOrders(cls.amazon_session) 38 | cls.amazon_transactions = AmazonTransactions(cls.amazon_session) 39 | 40 | @classmethod 41 | def tearDownClass(cls): 42 | print(f"... sleeping {cls.teardown_sleep_time} seconds to slow down between integration suites ...") 43 | time.sleep(cls.teardown_sleep_time) 44 | 45 | @classmethod 46 | def set_up_class_conf(cls): 47 | if not (os.environ.get("AMAZON_USERNAME") and os.environ.get("AMAZON_PASSWORD")): 48 | print("AMAZON_USERNAME and AMAZON_PASSWORD environment variables must be set to run integration tests") 49 | 50 | sys.exit(1) 51 | 52 | cls.teardown_sleep_time = os.environ.get("AMAZON_INTEGRATION_TEST_TEARDOWN_SLEEP", 40) 53 | 54 | conf.DEFAULT_CONFIG_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), ".integration-config") 55 | test_output_dir = os.path.join(conf.DEFAULT_CONFIG_DIR, "output") 56 | test_cookie_jar_path = os.path.join(conf.DEFAULT_CONFIG_DIR, "cookies.json") 57 | cls.test_config = AmazonOrdersConfig(data={ 58 | "output_dir": test_output_dir, 59 | "cookie_jar_path": test_cookie_jar_path 60 | }) 61 | 62 | if os.path.exists(test_cookie_jar_path): 63 | os.remove(test_cookie_jar_path) 64 | 65 | def setUp(self): 66 | self.assertTrue(self.amazon_session.is_authenticated) 67 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all install nopyc clean test test-integration build-test-resources docs check local validate-release upload 2 | 3 | SHELL := /usr/bin/env bash 4 | PYTHON_BIN ?= python 5 | PROJECT_VENV ?= venv 6 | INTEGRATION_TEST_RERUN ?= 2 7 | INTEGRATION_TEST_RERUN_DELAY ?= 300 8 | 9 | all: local check test 10 | 11 | venv: 12 | $(PYTHON_BIN) -m pip install virtualenv --user 13 | $(PYTHON_BIN) -m virtualenv $(PROJECT_VENV) 14 | 15 | install: venv 16 | @( \ 17 | source $(PROJECT_VENV)/bin/activate; \ 18 | python -m pip install .; \ 19 | ) 20 | 21 | nopyc: 22 | find . -name '*.pyc' | xargs rm -f || true 23 | find . -name __pycache__ | xargs rm -rf || true 24 | 25 | clean: nopyc 26 | rm -rf build dist *.egg-info $(PROJECT_VENV) tests/.*config 27 | 28 | test: install 29 | @( \ 30 | source $(PROJECT_VENV)/bin/activate; \ 31 | python -m pip install ".[dev]"; \ 32 | coverage run -m pytest -v --ignore=tests/integration -o junit_suite_name=unit && coverage report && coverage xml && coverage html; \ 33 | ) 34 | 35 | test-integration: install 36 | @( \ 37 | source $(PROJECT_VENV)/bin/activate; \ 38 | python -m pip install ".[dev,integration]"; \ 39 | pytest -v --ignore=tests/unit -o junit_suite_name=integration --reruns ${INTEGRATION_TEST_RERUN} --reruns-delay ${INTEGRATION_TEST_RERUN_DELAY}; \ 40 | ) 41 | 42 | build-test-resources: install 43 | @( \ 44 | source $(PROJECT_VENV)/bin/activate; \ 45 | make local; \ 46 | python scripts/build-test-resources.py; \ 47 | ) 48 | 49 | docs: install 50 | @( \ 51 | source $(PROJECT_VENV)/bin/activate; \ 52 | python -m pip install ".[docs]"; \ 53 | sphinx-build -M html docs build/docs -n; \ 54 | ) 55 | 56 | check: install 57 | @( \ 58 | source $(PROJECT_VENV)/bin/activate; \ 59 | python -m pip install ".[dev,docs]"; \ 60 | mypy amazonorders; \ 61 | flake8; \ 62 | ) 63 | 64 | local: 65 | @rm -rf *.egg-info dist 66 | @( \ 67 | $(PYTHON_BIN) -m pip install --upgrade pip; \ 68 | $(PYTHON_BIN) -m pip install --upgrade build; \ 69 | $(PYTHON_BIN) -m build; \ 70 | $(PYTHON_BIN) -m pip install dist/*.tar.gz; \ 71 | ) 72 | 73 | validate-release: 74 | @if [[ "${VERSION}" == "" ]]; then echo "VERSION is not set" & exit 1 ; fi 75 | 76 | @if [[ $$(grep "__version__ = \"${VERSION}\"" amazonorders/__init__.py) == "" ]] ; then echo "Version not bumped in amazonorders/__init__.py" & exit 1 ; fi 77 | @if [[ $$(grep "``==${VERSION}``" docs/index.rst) == "" ]] ; then echo "Version not bumped in docs/index.rst" & exit 1 ; fi 78 | @if [[ $$(grep "``==${VERSION}``" README.md) == "" ]] ; then echo "Version not bumped in README.md" & exit 1 ; fi 79 | 80 | upload: local 81 | @( \ 82 | $(PYTHON_BIN) -m pip install --upgrade twine; \ 83 | $(PYTHON_BIN) -m build; \ 84 | $(PYTHON_BIN) -m twine upload dist/*; \ 85 | ) 86 | -------------------------------------------------------------------------------- /tests/unit/entity/test_transaction.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import os 5 | from datetime import date 6 | 7 | from bs4 import BeautifulSoup 8 | 9 | from amazonorders.entity.transaction import Transaction 10 | from tests.unittestcase import UnitTestCase 11 | 12 | 13 | class TestTransaction(UnitTestCase): 14 | def test_parse(self): 15 | # GIVEN 16 | with open(os.path.join(self.RESOURCES_DIR, "transactions", "transaction-snippet.html"), "r", 17 | encoding="utf-8") as f: 18 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 19 | 20 | # WHEN 21 | transaction = Transaction(parsed, self.test_config, date(2024, 1, 1)) 22 | 23 | # THEN 24 | self.assertEqual(transaction.completed_date, date(2024, 1, 1)) 25 | self.assertEqual(transaction.payment_method, "My Payment Method") 26 | self.assertEqual(transaction.order_number, "123-4567890-1234567") 27 | self.assertEqual(transaction.order_details_link, 28 | "https://www.amazon.com/gp/css/summary/edit.html?orderID=123-4567890-1234567") # noqa 29 | self.assertEqual(transaction.seller, "AMZN Mktp COM") 30 | self.assertEqual(transaction.grand_total, -12.34) 31 | self.assertEqual(transaction.is_refund, False) 32 | self.assertEqual(str(transaction), "Transaction 2024-01-01: Order #123-4567890-1234567, Grand Total: -12.34") 33 | self.assertEqual( 34 | repr(transaction), '' 35 | ) 36 | 37 | def test_parse_refund(self): 38 | # GIVEN 39 | with open(os.path.join(self.RESOURCES_DIR, "transactions", "transaction-refund-snippet.html"), "r", 40 | encoding="utf-8") as f: 41 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 42 | 43 | # WHEN 44 | transaction = Transaction(parsed, self.test_config, date(2024, 1, 1)) 45 | 46 | # THEN 47 | self.assertEqual(transaction.completed_date, date(2024, 1, 1)) 48 | self.assertEqual(transaction.payment_method, "My Payment Method") 49 | self.assertEqual(transaction.order_number, "123-4567890-1234567") 50 | self.assertEqual(transaction.order_details_link, 51 | "https://www.amazon.com/gp/css/summary/edit.html?orderID=123-4567890-1234567") # noqa 52 | self.assertEqual(transaction.seller, "AMZN Mktp COM") 53 | self.assertEqual(transaction.grand_total, 12.34) 54 | self.assertEqual(transaction.is_refund, True) 55 | self.assertEqual(str(transaction), "Transaction 2024-01-01: Order #123-4567890-1234567, Grand Total: 12.34") 56 | self.assertEqual( 57 | repr(transaction), '' 58 | ) 59 | -------------------------------------------------------------------------------- /amazonorders/entity/item.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import logging 5 | from datetime import date 6 | from typing import Optional, TypeVar 7 | 8 | from bs4 import Tag 9 | 10 | from amazonorders.conf import AmazonOrdersConfig 11 | from amazonorders.entity.parsable import Parsable 12 | from amazonorders.entity.seller import Seller 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | ItemEntity = TypeVar("ItemEntity", bound="Item") 17 | 18 | 19 | class Item(Parsable): 20 | """ 21 | An Item in an Amazon :class:`~amazonorders.entity.order.Order`. If desired fields are populated as ``None``, 22 | ensure ``full_details`` is ``True`` when retrieving the Order (for instance, with 23 | :func:`~amazonorders.orders.AmazonOrders.get_order_history`), since by default it is ``False`` (it will slow 24 | down querying). 25 | """ 26 | 27 | def __init__(self, 28 | parsed: Tag, 29 | config: AmazonOrdersConfig) -> None: 30 | super().__init__(parsed, config) 31 | 32 | #: The Item title. 33 | self.title: str = self.safe_simple_parse(selector=self.config.selectors.FIELD_ITEM_TITLE_SELECTOR, 34 | required=True) 35 | #: The Item link. 36 | self.link: str = self.safe_simple_parse(selector=self.config.selectors.FIELD_ITEM_LINK_SELECTOR, 37 | attr_name="href", required=True) 38 | #: The Item price. 39 | self.price: Optional[float] = self.to_currency( 40 | self.safe_simple_parse(selector=self.config.selectors.FIELD_ITEM_PRICE_SELECTOR) 41 | ) 42 | #: The Item Seller. 43 | self.seller: Optional[Seller] = self.safe_simple_parse( 44 | selector=self.config.selectors.FIELD_ITEM_SELLER_SELECTOR, 45 | text_contains="Sold by:", 46 | wrap_tag=Seller) 47 | #: The Item condition. 48 | self.condition: Optional[str] = self.safe_simple_parse( 49 | selector=self.config.selectors.FIELD_ITEM_TAG_ITERATOR_SELECTOR, 50 | prefix_split="Condition:") 51 | #: The Item return eligible date. 52 | self.return_eligible_date: Optional[date] = self.safe_simple_parse( 53 | selector=self.config.selectors.FIELD_ITEM_RETURN_SELECTOR, 54 | text_contains="Return", 55 | parse_date=True) 56 | #: The Item image URL. 57 | self.image_link: Optional[str] = self.safe_simple_parse( 58 | selector=self.config.selectors.FIELD_ITEM_IMG_LINK_SELECTOR, 59 | attr_name="src") 60 | #: The Item quantity. 61 | self.quantity: Optional[int] = self.safe_simple_parse( 62 | selector=self.config.selectors.FIELD_ITEM_QUANTITY_SELECTOR) 63 | 64 | def __repr__(self) -> str: 65 | return f"" 66 | 67 | def __str__(self) -> str: # pragma: no cover 68 | return f"Item: {self.title}" 69 | 70 | def __lt__(self, 71 | other: ItemEntity) -> bool: 72 | return self.title < other.title 73 | -------------------------------------------------------------------------------- /tests/resources/500.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Sorry! Something went wrong! 12 | 91 | 92 | 93 | Amazon.com 94 | 101 |
102 |
Sorry! Something went wrong on our end. Please go back and try again or go to Amazon's home page. 104 |
105 | Dogs of Amazon 106 | 107 |
108 | 109 | 110 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "amazon-orders" 3 | dynamic = ["version"] 4 | description = "A Python libray (and CLI) for Amazon order history" 5 | readme = "README.md" 6 | license = "MIT" 7 | authors = [{ name = "Alex Laird", email = "contact@alexlaird.com" }] 8 | maintainers = [{ name = "Alex Laird", email = "contact@alexlaird.com" }] 9 | requires-python = ">=3.9" 10 | dependencies = [ 11 | "click>=7.1", 12 | "requests>=2.23", 13 | "amazoncaptcha>=0.4", 14 | "beautifulsoup4>=4.12", 15 | "PyYAML>=5.1", 16 | "python-dateutil>=2.8", 17 | "pyotp>=2.9", 18 | ] 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Environment :: Console", 22 | "Environment :: Web Environment", 23 | "Intended Audience :: Developers", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Topic :: Software Development :: Libraries :: Python Modules", 31 | ] 32 | 33 | [project.optional-dependencies] 34 | dev = [ 35 | "pytest", 36 | "coverage[toml]", 37 | "flake8", 38 | "flake8-pyproject", 39 | "pep8-naming", 40 | "responses", 41 | "lxml" 42 | ] 43 | integration = [ 44 | "pytest-rerunfailures", 45 | "parameterized" 46 | ] 47 | docs = [ 48 | "Sphinx", 49 | "sphinx-notfound-page", 50 | "sphinx_autodoc_typehints", 51 | "mypy", 52 | "types-requests", 53 | "types-beautifulsoup4", 54 | "types-Pillow", 55 | "types-PyYAML", 56 | "types-python-dateutil", 57 | ] 58 | 59 | [project.scripts] 60 | amazon-orders = "amazonorders.cli:amazon_orders_cli" 61 | 62 | [project.urls] 63 | Changelog = "https://github.com/alexdlaird/amazon-orders/blob/main/CHANGELOG.md" 64 | Documentation = "https://amazon-orders.readthedocs.io" 65 | Sponsor = "https://github.com/sponsors/alexdlaird" 66 | "Source Code" = "https://github.com/alexdlaird/amazon-orders" 67 | 68 | [tool.setuptools] 69 | package-dir = { amazonorders = "amazonorders" } 70 | 71 | [tool.setuptools.dynamic] 72 | version = { attr = "amazonorders.__version__" } 73 | 74 | [tool.pytest.ini_options] 75 | junit_family = "legacy" 76 | addopts = [ 77 | "-s", 78 | "--ignore=venv", 79 | "--durations=0", 80 | "--durations-min=0.5", 81 | "--junitxml=build/test-results/junit.xml" 82 | ] 83 | 84 | [tool.coverage.run] 85 | omit = [ 86 | "venv/**", 87 | "scripts/**", 88 | "*/tests/**" 89 | ] 90 | 91 | [tool.coverage.report] 92 | precision = 2 93 | exclude_lines = [ 94 | "if TYPE_CHECKING:", 95 | "pragma: no cover", 96 | "def __repr__", 97 | "if self.debug", 98 | "if settings.DEBUG", 99 | "raise AssertionError", 100 | "raise NotImplementedError", 101 | "if 0:", 102 | "if __name__ == .__main__.:", 103 | ] 104 | 105 | [tool.coverage.xml] 106 | output = "build/coverage/coverage.xml" 107 | 108 | [tool.coverage.html] 109 | title = "Coverage Report" 110 | directory = "build/coverage" 111 | 112 | [tool.flake8] 113 | max-line-length = 119 114 | statistics = true 115 | exclude = "scripts/*,docs/*,venv/*,build/*,dist/*,.egg-info/*,.mypy_cache/*" 116 | 117 | [[tool.mypy.overrides]] 118 | module = "amazoncaptcha.*" 119 | ignore_missing_imports = true 120 | -------------------------------------------------------------------------------- /amazonorders/entity/transaction.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import logging 5 | import re 6 | from datetime import date 7 | from typing import Union, Optional 8 | 9 | from bs4 import Tag 10 | 11 | from amazonorders.conf import AmazonOrdersConfig 12 | from amazonorders.entity.parsable import Parsable 13 | from amazonorders.exception import AmazonOrdersError 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Transaction(Parsable): 19 | """ 20 | An Amazon Transaction. 21 | """ 22 | 23 | def __init__(self, 24 | parsed: Tag, 25 | config: AmazonOrdersConfig, 26 | completed_date: date) -> None: 27 | super().__init__(parsed, config) 28 | 29 | #: The Transaction completed date. 30 | self.completed_date: date = completed_date 31 | #: The Transaction payment method. 32 | self.payment_method: str = self.safe_simple_parse( 33 | selector=self.config.selectors.FIELD_TRANSACTION_PAYMENT_METHOD_SELECTOR 34 | ) 35 | #: The Transaction grand total. 36 | self.grand_total: float = self.safe_parse(self._parse_grand_total) 37 | #: The Transaction was a refund or not. 38 | self.is_refund: bool = self.grand_total > 0 39 | #: The Transaction Order number. 40 | self.order_number: str = self.safe_parse(self._parse_order_number) 41 | #: The Transaction Order details link. 42 | self.order_details_link: str = self.safe_parse(self._parse_order_details_link) 43 | #: The Transaction seller name. 44 | self.seller: str = self.safe_simple_parse( 45 | selector=self.config.selectors.FIELD_TRANSACTION_SELLER_NAME_SELECTOR 46 | ) 47 | 48 | def __repr__(self) -> str: 49 | return f"" 50 | 51 | def __str__(self) -> str: # pragma: no cover 52 | return f"Transaction {self.completed_date}: Order #{self.order_number}, Grand Total: {self.grand_total}" 53 | 54 | def _parse_grand_total(self) -> Union[float, int, None]: 55 | value = self.simple_parse(self.config.selectors.FIELD_TRANSACTION_GRAND_TOTAL_SELECTOR) 56 | 57 | value = self.to_currency(value) 58 | 59 | if value is None: # pragma: no cover 60 | err_msg = ("Order.grand_total did not populate, but it's required. " 61 | "Check if Amazon changed the HTML.") 62 | if not self.config.warn_on_missing_required_field: 63 | raise AmazonOrdersError(err_msg) 64 | else: 65 | logger.warning(err_msg) 66 | 67 | return value 68 | 69 | def _parse_order_number(self) -> Optional[str]: 70 | value = self.simple_parse(self.config.selectors.FIELD_TRANSACTION_ORDER_NUMBER_SELECTOR) 71 | 72 | if value is None: # pragma: no cover 73 | err_msg = ("Transaction.order_number did not populate, but it's required. " 74 | "Check if Amazon changed the HTML.") 75 | if not self.config.warn_on_missing_required_field: 76 | raise AmazonOrdersError(err_msg) 77 | else: 78 | logger.warning(err_msg) 79 | 80 | return None 81 | 82 | match = re.match(".*#([0-9-]+)$", value) 83 | value = match.group(1) if match else "" 84 | 85 | return value 86 | 87 | def _parse_order_details_link(self) -> Optional[str]: 88 | value = self.simple_parse(self.config.selectors.FIELD_TRANSACTION_ORDER_LINK_SELECTOR, attr_name="href") 89 | 90 | if not value and self.order_number: 91 | value = f"{self.config.constants.ORDER_DETAILS_URL}?orderID={self.order_number}" 92 | 93 | return value 94 | -------------------------------------------------------------------------------- /tests/integration/test_integration_generic.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import datetime 5 | import os 6 | 7 | from amazonorders.exception import AmazonOrdersNotFoundError 8 | from tests.integrationtestcase import IntegrationTestCase 9 | 10 | 11 | class TestIntegrationGeneric(IntegrationTestCase): 12 | """ 13 | These integration tests run generically against any Amazon account. The only requirement is that the 14 | account in question have at least one Order and one Transaction in the year ``AMAZON_INTEGRATION_TEST_YEAR`` 15 | (defaults to the current year). The only assertions done on the fields populated are to test if values are 16 | set generically, not asserting on specific values. 17 | """ 18 | 19 | @classmethod 20 | def setUpClass(cls): 21 | super().setUpClass() 22 | 23 | cls.year = os.environ.get("AMAZON_INTEGRATION_TEST_YEAR") 24 | if not cls.year: 25 | cls.year = datetime.date.today().year 26 | cls.start_index = os.environ.get("AMAZON_START_INDEX") 27 | cls.transactions_days = os.environ.get("AMAZON_TRANSACTIONS_DAYS") 28 | if not cls.transactions_days: 29 | cls.transactions_days = 90 30 | if os.environ.get("AMAZON_FULL_DETAILS_LOOP_COUNT"): 31 | cls.full_details_loop_count = int(os.environ.get("AMAZON_FULL_DETAILS_LOOP_COUNT")) 32 | else: 33 | cls.full_details_loop_count = 1 34 | 35 | def test_get_order_after_get_order_history_single_page(self): 36 | # WHEN 37 | orders = self.amazon_orders.get_order_history(year=self.year, 38 | keep_paging=False) 39 | 40 | # THEN 41 | self.assertGreaterEqual(len(orders), 1) 42 | self.assertLessEqual(len(orders), 10) 43 | 44 | # GIVEN 45 | self.assertIsNotNone(orders[0].order_number) 46 | order_id = orders[0].order_number 47 | 48 | # WHEN 49 | order = self.amazon_orders.get_order(order_id) 50 | 51 | # THEN 52 | self.assert_populated_generic(order, True) 53 | self.assertIsNone(order.index) 54 | 55 | def test_get_order_does_not_exist(self): 56 | # GIVEN 57 | order_id = "1234-fake-id" 58 | 59 | # WHEN 60 | with self.assertRaises(AmazonOrdersNotFoundError): 61 | self.amazon_orders.get_order(order_id) 62 | 63 | def test_get_order_history(self): 64 | # WHEN 65 | orders = self.amazon_orders.get_order_history(year=self.year, 66 | start_index=self.start_index) 67 | 68 | # THEN 69 | self.assertGreaterEqual(len(orders), 1) 70 | self.assert_populated_generic(orders[0], False) 71 | self.assertIsNotNone(orders[0].index) 72 | self.assert_orders_list_index(orders) 73 | 74 | def test_get_order_history_full_details(self): 75 | # The environment variable FULL_DETAILS_LOOP_COUNT can be set to a higher number to put 76 | # more successive request pressure on Amazon, which helps ensure the async concurrency that exists when 77 | # building Order history won't cause issues with rate limiting, etc. 78 | for i in range(self.full_details_loop_count): 79 | # WHEN 80 | orders = self.amazon_orders.get_order_history(year=self.year, 81 | start_index=self.start_index, 82 | full_details=True) 83 | 84 | # THEN 85 | self.assertGreaterEqual(len(orders), 1) 86 | self.assert_populated_generic(orders[0], True) 87 | self.assertIsNotNone(orders[0].index) 88 | self.assert_orders_list_index(orders) 89 | 90 | def test_get_transactions(self): 91 | # WHEN 92 | transactions = self.amazon_transactions.get_transactions(days=self.transactions_days) 93 | 94 | # THEN 95 | self.assertGreaterEqual(len(transactions), 1) 96 | transaction = transactions[0] 97 | self.assertIsNotNone(transaction.completed_date) 98 | self.assertIsNotNone(transaction.payment_method) 99 | self.assertIsNotNone(transaction.grand_total) 100 | self.assertIsNotNone(transaction.is_refund) 101 | self.assertIsNotNone(transaction.order_number) 102 | self.assertIsNotNone(transaction.order_details_link) 103 | self.assertIsNotNone(transaction.seller) 104 | -------------------------------------------------------------------------------- /scripts/build-test-resources.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 4 | __license__ = "MIT" 5 | 6 | import json 7 | import os 8 | import re 9 | import sys 10 | 11 | from bs4 import BeautifulSoup 12 | 13 | from amazonorders.conf import AmazonOrdersConfig 14 | from amazonorders.session import AmazonSession 15 | 16 | ROOT_DIR = os.path.normpath( 17 | os.path.join(os.path.abspath(os.path.dirname(__file__)), "..")) 18 | 19 | 20 | def _obfuscate(response_parsed, hide_data_rules): 21 | cleaned = str(response_parsed) 22 | for rule in hide_data_rules: 23 | pattern = re.compile(rule["find_pattern"], re.IGNORECASE) 24 | cleaned = pattern.sub(rule["replace"], cleaned) 25 | return BeautifulSoup(cleaned, "html.parser") 26 | 27 | 28 | def build_test_resources(args): 29 | """ 30 | The purpose of this script is to pull the same pages used for integration tests, 31 | obfuscate the data as needed, and then store versions of those HTML files in tests/resources 32 | so unit tests can be run against the same data. 33 | 34 | This script should be invoked using `make build-test-resources` and requires AMAZON_USERNAME 35 | and AMAZON_PASSWORD environment variables to be set. 36 | 37 | To use this script to generate orders that correspond correctly to the unit tests, contact the 38 | owner of the GitHub repo. 39 | """ 40 | 41 | if not (os.environ.get("AMAZON_USERNAME") and os.environ.get("AMAZON_PASSWORD")): 42 | print("AMAZON_USERNAME and AMAZON_PASSWORD environment variables must be set to build test resources") 43 | 44 | sys.exit(1) 45 | 46 | amazon_session = AmazonSession() 47 | amazon_session.login() 48 | 49 | pages_to_download = [ 50 | {"type": "order-history", "year": "2010", "start-index": "0"}, 51 | {"type": "order-history", "year": "2010", "start-index": "10"}, 52 | {"type": "order-history", "year": "2018", "start-index": "0"}, 53 | {"type": "order-history", "year": "2020", "start-index": "40"}, 54 | {"type": "order-history", "year": "2020", "start-index": "50"}, 55 | {"type": "order-history", "year": "2023", "start-index": "10"}, 56 | {"type": "order-details", "order-id": "112-0399923-3070642"}, 57 | {"type": "order-details", "order-id": "114-9460922-7737063"}, 58 | {"type": "order-details", "order-id": "112-2961628-4757846"}, 59 | {"type": "order-details", "order-id": "112-9685975-5907428"}, 60 | {"type": "order-details", "order-id": "113-1625648-3437067"}, 61 | ] 62 | 63 | """ 64 | This variable should contain a JSON blob that matches this format: 65 | 66 | [ 67 | {"find_pattern": "Some Sensitive Data (case will be ignored)", "replace": "Obfuscated Replacement"}, 68 | {"find_pattern": "555 My Private Address (and regex pattern can be used)", "replace": "555 My Public Address"}, 69 | {"find_pattern": "Secret City, VT 22222", "replace": "San Francisco, CA 94016"}, 70 | {"find_pattern": "card ending in 1234", "replace": "card ending in 4321"} 71 | ] 72 | """ 73 | hide_data_rules = json.loads(os.environ["HIDE_DATA_RULES"]) 74 | 75 | if not hide_data_rules: 76 | print("HIDE_DATA_RULES environment variables not set, see script for details") 77 | 78 | sys.exit(1) 79 | 80 | config = AmazonOrdersConfig() 81 | 82 | for page in pages_to_download: 83 | if page["type"] == "order-details": 84 | url = f"{config.constants.ORDER_DETAILS_URL}?orderID={page['order-id']}" 85 | session_response = amazon_session.get(url) 86 | response_parsed = BeautifulSoup(session_response.response.text, "html.parser") 87 | 88 | cleaned_response = _obfuscate(response_parsed, hide_data_rules) 89 | 90 | page_name = f"order-details-{page['order-id']}.html" 91 | else: 92 | url = f"{config.constants.ORDER_HISTORY_URL}?timeFilter=year-{page['year']}&startIndex={page['start-index']}" 93 | session_response = amazon_session.get(url) 94 | response_parsed = BeautifulSoup(session_response.response.text, "html.parser") 95 | 96 | cleaned_response = _obfuscate(response_parsed, hide_data_rules) 97 | 98 | page_name = f"order-history-{page['year']}-{page['start-index']}.html" 99 | 100 | with open(os.path.join(ROOT_DIR, "tests", "resources", page_name), "w", 101 | encoding="utf-8") as html_file: 102 | html_file.write(str(cleaned_response)) 103 | 104 | print( 105 | "\nDONE: Test resources update from live data. Be sure to verify data was properly " 106 | "obfuscated before committing any changes.") 107 | 108 | 109 | if __name__ == "__main__": 110 | build_test_resources(sys.argv) 111 | -------------------------------------------------------------------------------- /tests/resources/orders/order-missing-grand-total-snippet.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 | Ordered on December 7, 2023 7 | 8 | 9 | Order# 112-0000000-0000000 10 | 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
    26 |
  • Test User
  • 27 |
  • 123 Test St
  • 28 |
  • 29 | Test City, TS 12345 30 |
  • 31 |
  • United States
  • 32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
Order Summary
41 |
42 |
43 | Item(s) Subtotal: 44 |
45 |
46 | $10.00 47 |
48 |
49 | 50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | Delivered Dec 9, 2023 63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | Test Product 75 |
76 |
77 | $10.00 78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | -------------------------------------------------------------------------------- /amazonorders/constants.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import os 5 | from urllib.parse import urlencode 6 | 7 | 8 | class Constants: 9 | """ 10 | A class containing useful constants. Extend and override with ``constants_class`` in the config: 11 | 12 | .. code-block:: python 13 | 14 | from amazonorders.conf import AmazonOrdersConfig 15 | 16 | config = AmazonOrdersConfig(data={"constants_class": "my_module.MyConstants"}) 17 | """ 18 | 19 | ########################################################################## 20 | # General URL 21 | ########################################################################## 22 | 23 | BASE_URL = os.environ.get("AMAZON_BASE_URL") 24 | if not BASE_URL: 25 | BASE_URL = "https://www.amazon.com" 26 | 27 | ########################################################################## 28 | # URLs for AmazonSession 29 | ########################################################################## 30 | 31 | SIGN_IN_URL = f"{BASE_URL}/ap/signin" 32 | SIGN_IN_QUERY_PARAMS = {"openid.pape.max_auth_age": "0", 33 | "openid.return_to": f"{BASE_URL}/?ref_=nav_custrec_signin", 34 | "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", 35 | "openid.assoc_handle": "usflex", 36 | "openid.mode": "checkid_setup", 37 | "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", 38 | "openid.ns": "http://specs.openid.net/auth/2.0"} 39 | SIGN_IN_CLAIM_URL = f"{BASE_URL}/ax/claim" 40 | SIGN_OUT_URL = f"{BASE_URL}/gp/flex/sign-out.html" 41 | 42 | ########################################################################## 43 | # URLs for Orders 44 | ########################################################################## 45 | 46 | ORDER_HISTORY_URL = f"{BASE_URL}/your-orders/orders" 47 | ORDER_DETAILS_URL = f"{BASE_URL}/gp/your-account/order-details" 48 | HISTORY_FILTER_QUERY_PARAM = "timeFilter" 49 | 50 | ########################################################################## 51 | # URLs for Transactions 52 | ########################################################################## 53 | 54 | TRANSACTION_HISTORY_ROUTE = "/cpe/yourpayments/transactions" 55 | TRANSACTION_HISTORY_URL = f"{BASE_URL}{TRANSACTION_HISTORY_ROUTE}" 56 | 57 | ########################################################################## 58 | # Headers 59 | ########################################################################## 60 | 61 | BASE_HEADERS = { 62 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8," 63 | "application/signed-exchange;v=b3;q=0.7", 64 | "Accept-Encoding": "gzip, deflate, br, zstd", 65 | "Accept-Language": "en-US,en;q=0.9", 66 | "Cache-Control": "max-age=0", 67 | "Device-Memory": "8", 68 | "Downlink": "10", 69 | "Dpr": "2", 70 | "Ect": "4g", 71 | "Origin": BASE_URL, 72 | "Host": BASE_URL.strip("https://"), 73 | "Priority": "u=0, i", 74 | "Referer": f"{SIGN_IN_URL}?{urlencode(SIGN_IN_QUERY_PARAMS)}", 75 | "Rtt": "0", 76 | "Sec-Ch-Device-Memory": "8", 77 | "Sec-Ch-Dpr": "2", 78 | "Sec-Ch-Ua": "Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140", 79 | "Sec-Ch-Ua-Mobile": "?0", 80 | "Sec-Ch-Ua-Platform": "macOS", 81 | "Sec-Ch-Ua-Platform-Version": "15.6.1", 82 | "Sec-Ch-Viewport-Width": "1512", 83 | "Sec-Fetch-Dest": "document", 84 | "Sec-Fetch-Mode": "navigate", 85 | "Sec-Fetch-Site": "none", 86 | "Sec-Fetch-User": "?1", 87 | "Upgrade-Insecure-Requests": "1", 88 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6_1) AppleWebKit/537.36 (KHTML, like Gecko) " 89 | "Chrome/140.0.0.0 Safari/537.36", 90 | "Viewport-Width": "1512" 91 | } 92 | 93 | ########################################################################## 94 | # Authentication 95 | ########################################################################## 96 | 97 | COOKIES_SET_WHEN_AUTHENTICATED = ["x-main"] 98 | JS_ROBOT_TEXT_REGEX = r"[.\s\S]*verify that you're not a robot[.\s\S]*Enable JavaScript[.\s\S]*" 99 | 100 | ########################################################################## 101 | # Currency 102 | ########################################################################## 103 | 104 | CURRENCY_SYMBOL = os.environ.get("AMAZON_CURRENCY_SYMBOL", "$") 105 | 106 | def format_currency(self, 107 | amount: float) -> str: 108 | formatted_amt = "{currency_symbol}{amount:,.2f}".format(currency_symbol=self.CURRENCY_SYMBOL, 109 | amount=abs(amount)) 110 | if round(amount, 2) < 0: 111 | return f"-{formatted_amt}" 112 | return formatted_amt 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

amazon-orders - A Python libray (and CLI) for Amazon order history

2 | 3 | [![Version](https://img.shields.io/pypi/v/amazon-orders)](https://pypi.org/project/amazon-orders) 4 | [![Python Versions](https://img.shields.io/pypi/pyversions/amazon-orders.svg)](https://pypi.org/project/amazon-orders) 5 | [![Coverage](https://img.shields.io/codecov/c/github/alexdlaird/amazon-orders)](https://codecov.io/gh/alexdlaird/amazon-orders) 6 | [![Build](https://img.shields.io/github/actions/workflow/status/alexdlaird/amazon-orders/build.yml)](https://github.com/alexdlaird/amazon-orders/actions/workflows/build.yml) 7 | [![Docs](https://img.shields.io/readthedocs/amazon-orders)](https://amazon-orders.readthedocs.io) 8 | [![GitHub License](https://img.shields.io/github/license/alexdlaird/amazon-orders)](https://github.com/alexdlaird/amazon-orders/blob/main/LICENSE) 9 | 10 | `amazon-orders` is an unofficial library that provides a Python API (and CLI) for Amazon order history. 11 | 12 | This package works by parsing data from Amazon's consumer-facing website. A periodic build validates functionality 13 | to ensure its stability, but as Amazon provides no official API to use, this package may break at any time (so check 14 | often to ensure you're on the latest version). 15 | 16 | Only the English, `.com` version of Amazon is officially supported. 17 | 18 | ## Installation 19 | 20 | `amazon-orders` is available on [PyPI](https://pypi.org/project/amazon-orders/) and can be installed and/or upgraded using `pip`: 21 | 22 | ```sh 23 | pip install amazon-orders --upgrade 24 | ``` 25 | 26 | That's it! `amazon-orders` is now available as a package to your Python projects and from the command line. 27 | 28 | If pinning, be sure to use a wildcard for the [minor version](https://semver.org/) (ex. `==4.0.*`, not `==4.0.18`) to 29 | ensure you always get the latest stable release. 30 | 31 | ## Basic Usage 32 | 33 | You'll use [`AmazonSession`](https://amazon-orders.readthedocs.io/api.html#amazonorders.session.AmazonSession) to 34 | authenticate your Amazon account, then [`AmazonOrders`](https://amazon-orders.readthedocs.io/api.html#amazonorders.orders.AmazonOrders) 35 | and [`AmazonTransactions`](https://amazon-orders.readthedocs.io/api.html#amazonorders.transactions.AmazonTransactions) 36 | to interact with account data. [`get_order_history`](https://amazon-orders.readthedocs.io/api.html#amazonorders.orders.AmazonOrders.get_order_history) 37 | and [`get_order`](https://amazon-orders.readthedocs.io/api.html#amazonorders.orders.AmazonOrders.get_order) are good places to start. 38 | 39 | ```python 40 | from amazonorders.session import AmazonSession 41 | from amazonorders.orders import AmazonOrders 42 | 43 | amazon_session = AmazonSession("", 44 | "") 45 | amazon_session.login() 46 | 47 | amazon_orders = AmazonOrders(amazon_session) 48 | 49 | # Get orders from a specific year 50 | orders = amazon_orders.get_order_history(year=2023) 51 | 52 | # Or use time filters for recent orders 53 | orders = amazon_orders.get_order_history(time_filter="last30") # Last 30 days 54 | orders = amazon_orders.get_order_history(time_filter="months-3") # Past 3 months 55 | 56 | for order in orders: 57 | print(f"{order.order_number} - {order.grand_total}") 58 | ``` 59 | 60 | If the fields you're looking for aren't populated with the above, set `full_details=True` (or pass `--full-details` to 61 | the `history` CLI command), since by default it is `False` (enabling it slows down querying, since an additional 62 | request for each order is necessary). Have a look at the [Order](https://amazon-orders.readthedocs.io/api.html#amazonorders.entity.order.Order) entity's docs to see what fields are only 63 | populated with full details. 64 | 65 | ### Command Line Usage 66 | 67 | You can also run any command available to the main Python interface from the command line: 68 | 69 | ```sh 70 | amazon-orders login 71 | amazon-orders history --year 2023 72 | amazon-orders history --last-30-days 73 | amazon-orders history --last-3-months 74 | ``` 75 | 76 | ### Automating Authentication 77 | 78 | Authentication can be automated by (in order of precedence) storing credentials in environment variables, passing them 79 | to [`AmazonSession`](https://amazon-orders.readthedocs.io/api.html#amazonorders.session.AmazonSession), or storing them 80 | in [`AmazonOrdersConfig`](https://amazon-orders.readthedocs.io/api.html#amazonorders.conf.AmazonOrdersConfig). The 81 | environment variables `amazon-orders` looks for are: 82 | 83 | - `AMAZON_USERNAME` 84 | - `AMAZON_PASSWORD` 85 | - `AMAZON_OTP_SECRET_KEY` (see [docs for usage](https://amazon-orders.readthedocs.io/api.html#amazonorders.session.AmazonSession.otp_secret_key)) 86 | 87 | ## Documentation 88 | 89 | For more advanced usage, `amazon-orders`'s official documentation is available 90 | at [Read the Docs](http://amazon-orders.readthedocs.io). 91 | 92 | ## Contributing 93 | 94 | If you would like to get involved, be sure to review 95 | the [Contribution Guide](https://github.com/alexdlaird/amazon-orders/blob/main/CONTRIBUTING.rst). 96 | 97 | Want to contribute financially? If you've found `amazon-orders` 98 | useful, [sponsorship](https://github.com/sponsors/alexdlaird) would 99 | also be greatly appreciated! 100 | -------------------------------------------------------------------------------- /tests/resources/auth/captcha-field-keywords.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Amazon.com 12 | 13 | 14 | 27 | 28 | 29 | 30 | 34 | 35 | 38 | 39 |
40 | 41 |
42 | 43 |
44 | 45 |
46 |
47 | 48 |

Click the button below to continue shopping

49 |
50 |
51 | 52 |
53 | 54 |
55 |
56 | 57 |
58 | 59 | 60 |
61 | 62 |
63 | 64 | 65 | 66 | 67 | 68 |
69 | 70 |
71 |
72 | 73 |
74 |
75 | 76 |
77 | 78 |
79 | 80 |
81 | 82 |
83 | Conditions of Use 84 | 85 | 86 | 87 | 88 | Privacy Policy 89 |
90 |
91 | © 1996-2025, Amazon.com, Inc. or its affiliates 92 | 97 | 100 |
101 |
102 | 115 | 116 | -------------------------------------------------------------------------------- /amazonorders/util.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import importlib 5 | import logging 6 | import re 7 | from typing import List, Union, Optional, Callable, Any 8 | 9 | from bs4 import Tag, BeautifulSoup 10 | from requests import Response 11 | 12 | from amazonorders.selectors import Selector 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class AmazonSessionResponse: 18 | """ 19 | A wrapper for the :class:`requests.Response` object, which also contains the parsed HTML. 20 | """ 21 | 22 | def __init__(self, response: Response, bs4_parser: str) -> None: 23 | #: The request's response object. 24 | self.response: Response = response 25 | #: The parsed HTML from the response. 26 | self.parsed: Tag = BeautifulSoup(self.response.text, bs4_parser) 27 | 28 | 29 | def select(parsed: Tag, selector: Union[List[Union[str, Selector]], Union[str, Selector]]) -> List[Tag]: 30 | """ 31 | This is a helper function that extends BeautifulSoup's `select() `_ method to allow for multiple selectors. 33 | The ``selector`` can be either a ``str`` or a ``list``. If a ``list`` is given, each selector in the list will be 34 | tried until one is found to return a populated list of ``Tag``'s, and that value will be returned. 35 | 36 | :param parsed: The ``Tag`` from which to attempt selection. 37 | :param selector: The CSS selector(s) for the field. 38 | :return: The selected tag. 39 | """ 40 | if isinstance(selector, str) or isinstance(selector, Selector): 41 | selector = [selector] 42 | 43 | for s in selector: 44 | tag: list = [] 45 | 46 | if isinstance(s, Selector): 47 | for t in parsed.select(s.css_selector): 48 | if t and t.text.strip() == s.text: 49 | tag += t 50 | elif isinstance(s, str): 51 | tag = parsed.select(s) 52 | else: 53 | raise TypeError(f"Invalid selector type: {type(s)}") 54 | 55 | if tag: 56 | return tag 57 | 58 | return [] 59 | 60 | 61 | def select_one(parsed: Tag, 62 | selector: Union[List[Union[str, Selector]], Union[str, Selector]]) -> Optional[Tag]: 63 | """ 64 | This is a helper function that extends BeautifulSoup's `select_one() `_ method to allow for multiple selectors. 66 | The ``selector`` can be either a ``str`` or a ``list``. If a ``list`` is given, each selector in the list will be 67 | tried until one is found to return a populated ``Tag``, and that value will be returned. 68 | 69 | :param parsed: The ``Tag`` from which to attempt selection. 70 | :param selector: The CSS selector(s) for the field. 71 | :return: The selection tag. 72 | """ 73 | if isinstance(selector, str) or isinstance(selector, Selector): 74 | selector = [selector] 75 | 76 | for s in selector: 77 | tag: Optional[Tag] = None 78 | 79 | if isinstance(s, Selector): 80 | t = parsed.select_one(s.css_selector) 81 | if t and t.text.strip() == s.text: 82 | tag = t 83 | elif isinstance(s, str): 84 | tag = parsed.select_one(s) 85 | else: 86 | raise TypeError(f"Invalid selector type: {type(s)}") 87 | 88 | if tag: 89 | return tag 90 | 91 | return None 92 | 93 | 94 | def to_type(value: str) -> Union[int, float, bool, str, None]: 95 | """ 96 | Attempt to convert ``value`` to its primitive type of ``int``, ``float``, or ``bool``. 97 | 98 | If ``value`` is an empty string, ``None`` will be returned. 99 | 100 | :param value: The value to convert. 101 | :return: The converted value. 102 | """ 103 | if not value or value == "": 104 | return None 105 | 106 | rv: Union[int, float, bool, str] = value 107 | 108 | try: 109 | rv = int(rv) 110 | except ValueError: 111 | try: 112 | rv = float(rv) 113 | except ValueError: 114 | pass 115 | 116 | if isinstance(rv, str): 117 | if rv.lower() == "true": 118 | rv = True 119 | elif rv.lower() == "false": 120 | rv = False 121 | 122 | return rv 123 | 124 | 125 | def load_class(package: List[str], clazz: str) -> Union[Callable, Any]: 126 | """ 127 | Import the given class from the given package, and return it. 128 | 129 | :param package: The package. 130 | :param clazz: The class to import. 131 | :return: The return class. 132 | """ 133 | constants_mod = importlib.import_module(".".join(package)) 134 | return getattr(constants_mod, clazz) 135 | 136 | 137 | def cleanup_html_text(text: str) -> str: 138 | """ 139 | Cleanup excessive whitespace within text that comes from an HTML block. 140 | 141 | :param text: The text to clean up. 142 | :return: The cleaned up text. 143 | """ 144 | # First get rid of leading and trailing whitespace 145 | text = text.strip() 146 | # Reduce duplicated line returns, then replace line returns with periods 147 | text = re.sub(r"\n\s*\n+", "\n", text) 148 | text = text.replace("\n", ". ") 149 | # Remove remaining duplicated whitespace of any kind 150 | text = re.sub(r"\s\s+", " ", text) 151 | # Remove duplicate periods at end of text. 152 | text = re.sub("\\.+($|\\s)", r".\1", text) 153 | if not text.endswith("."): 154 | text += "." 155 | return text 156 | -------------------------------------------------------------------------------- /tests/integration/test_integration_auth.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import json 5 | import os 6 | import time 7 | import unittest 8 | 9 | from amazonorders.exception import AmazonOrdersAuthError, AmazonOrdersError, AmazonOrdersNotFoundError, \ 10 | AmazonOrdersAuthRedirectError 11 | from amazonorders.orders import AmazonOrders 12 | from amazonorders.session import AmazonSession 13 | from tests.integrationtestcase import IntegrationTestCase 14 | 15 | 16 | @unittest.skipIf(not os.environ.get("AMAZON_INTEGRATION_TEST_AUTH", "False") == "True", 17 | "Running auth tests too frequently may lock the account. Set " 18 | "AMAZON_INTEGRATION_TEST_AUTH=True explicitly to run.") 19 | class TestIntegrationAuth(IntegrationTestCase): 20 | """ 21 | These integration tests run generically against any Amazon account to validate authentication. 22 | """ 23 | 24 | @classmethod 25 | def setUpClass(cls): 26 | super().set_up_class_conf() 27 | 28 | def setUp(self): 29 | if os.path.exists(self.test_config.cookie_jar_path): 30 | os.remove(self.test_config.cookie_jar_path) 31 | 32 | def tearDown(self): 33 | print(f"... sleeping {self.teardown_sleep_time} seconds to slow down between auth tests ...") 34 | time.sleep(self.teardown_sleep_time) 35 | 36 | def test_login_then_expire_persisted_session(self): 37 | # GIVEN 38 | amazon_session = AmazonSession(debug=self.debug, 39 | config=self.test_config) 40 | amazon_orders = AmazonOrders(amazon_session) 41 | 42 | # WHEN 43 | amazon_session.login() 44 | 45 | # THEN 46 | self.assertTrue(amazon_session.is_authenticated) 47 | self.assertTrue(amazon_session.auth_cookies_stored()) 48 | # Navigating to a non-existent Order when authenticated returns 404 (rather than redirecting to login), which 49 | # indicates we're successfully logged in 50 | with self.assertRaises(AmazonOrdersNotFoundError): 51 | amazon_orders.get_order(order_id="1234-fake-id") 52 | 53 | # WHEN 54 | time.sleep(1) 55 | with open(self.test_config.cookie_jar_path, "r") as f: 56 | cookies = json.loads(f.read()) 57 | for cookie in self.test_config.constants.COOKIES_SET_WHEN_AUTHENTICATED: 58 | cookies[cookie] = "invalid-and-stale" 59 | with open(self.test_config.cookie_jar_path, "w") as f: 60 | f.write(json.dumps(cookies)) 61 | amazon_session.session.cookies.update(cookies) 62 | 63 | # THEN 64 | # Trying to interact with a privileged resource will redirect us to login 65 | self.assertTrue(amazon_session.is_authenticated) 66 | with self.assertRaises(AmazonOrdersAuthRedirectError): 67 | amazon_orders.get_order(order_id="1234-fake-id") 68 | time.sleep(1) 69 | # And then we will find our session invalidated 70 | self.assertFalse(amazon_session.is_authenticated) 71 | self.assertFalse(amazon_session.auth_cookies_stored()) 72 | with self.assertRaises(AmazonOrdersError) as cm: 73 | amazon_orders.get_order(order_id="1234-fake-id") 74 | self.assertIn("AmazonSession.login() to authenticate first", str(cm.exception)) 75 | 76 | def test_logout(self): 77 | # GIVEN 78 | amazon_session = AmazonSession(debug=self.debug, 79 | config=self.test_config) 80 | amazon_session.login() 81 | old_session = amazon_session.session 82 | time.sleep(1) 83 | 84 | # WHEN 85 | amazon_session.logout() 86 | 87 | # THEN 88 | self.assertFalse(amazon_session.is_authenticated) 89 | self.assertFalse(amazon_session.auth_cookies_stored()) 90 | self.assertNotEqual(old_session, amazon_session.session) 91 | 92 | def test_login_no_account(self): 93 | amazon_username = os.environ["AMAZON_USERNAME"] 94 | os.environ["AMAZON_USERNAME"] = "08511698-9ff5-fake@gmail.com" 95 | 96 | # GIVEN 97 | amazon_session = AmazonSession(debug=self.debug, 98 | config=self.test_config) 99 | 100 | # WHEN 101 | with self.assertRaises(AmazonOrdersAuthError) as cm: 102 | amazon_session.login() 103 | 104 | # THEN 105 | self.assertFalse(amazon_session.is_authenticated) 106 | self.assertIn("Looks like you're new to Amazon", str(cm.exception)) 107 | 108 | os.environ["AMAZON_USERNAME"] = amazon_username 109 | 110 | @unittest.skipIf(not os.environ.get("AMAZON_INTEGRATION_TEST_AUTH_WRONG_PASSWORD", "False") == "True", 111 | "Running this test too too frequently will trigger the Captcha flow instead (causing" 112 | "the test to fail), and also may lock the Amazon account. Set " 113 | "AMAZON_INTEGRATION_TEST_AUTH_WRONG_PASSWORD=True explicitly to run.") 114 | def test_login_wrong_password(self): 115 | amazon_password = os.environ["AMAZON_PASSWORD"] 116 | os.environ["AMAZON_PASSWORD"] = "invalid-password" 117 | 118 | # GIVEN 119 | amazon_session = AmazonSession(debug=self.debug, 120 | config=self.test_config) 121 | 122 | # WHEN 123 | with self.assertRaises(AmazonOrdersError) as cm: 124 | amazon_session.login() 125 | 126 | # THEN 127 | self.assertFalse(amazon_session.is_authenticated) 128 | if isinstance(cm.exception, AmazonOrdersAuthError): 129 | self.assertIn("Error from Amazon:", str(cm.exception)) 130 | self.assertIn("password", str(cm.exception)) 131 | else: 132 | self.assertIn("CaptchaForm", str(cm.exception)) 133 | 134 | os.environ["AMAZON_PASSWORD"] = amazon_password 135 | -------------------------------------------------------------------------------- /tests/unit/test_conf.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import os 5 | import shutil 6 | from unittest import TestCase 7 | 8 | import yaml 9 | 10 | from amazonorders import conf 11 | from amazonorders.conf import AmazonOrdersConfig 12 | 13 | 14 | class TestConf(TestCase): 15 | def setUp(self): 16 | conf.DEFAULT_CONFIG_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), ".config") 17 | self.test_output_dir = os.path.join(conf.DEFAULT_CONFIG_DIR, "output") 18 | self.test_cookie_jar_path = os.path.join(conf.DEFAULT_CONFIG_DIR, "cookies.json") 19 | 20 | def tearDown(self): 21 | if os.path.exists(conf.DEFAULT_CONFIG_DIR): 22 | shutil.rmtree(conf.DEFAULT_CONFIG_DIR) 23 | 24 | def test_provision_config(self): 25 | # WHEN 26 | config_path = os.path.join(conf.DEFAULT_CONFIG_DIR, "config.yml") 27 | self.assertFalse(os.path.exists(conf.DEFAULT_CONFIG_DIR)) 28 | self.assertFalse(os.path.exists(config_path)) 29 | self.assertFalse(os.path.exists(self.test_output_dir)) 30 | self.assertFalse(os.path.exists(self.test_cookie_jar_path)) 31 | 32 | # GIVEN 33 | config = AmazonOrdersConfig(data={ 34 | "output_dir": self.test_output_dir, 35 | "cookie_jar_path": self.test_cookie_jar_path 36 | }) 37 | 38 | # THEN 39 | self.assertEqual(5, config.auth_reattempt_wait) 40 | self.assertEqual(config_path, config.config_path) 41 | self.assertTrue(os.path.exists(conf.DEFAULT_CONFIG_DIR)) 42 | self.assertFalse(os.path.exists(config_path)) 43 | self.assertTrue(os.path.exists(self.test_output_dir)) 44 | self.assertEqual(10, config.max_cookie_attempts) 45 | self.assertEqual(0.5, config.cookie_reattempt_wait) 46 | self.assertEqual(10, config.max_auth_attempts) 47 | self.assertEqual(1, config.max_auth_retries) 48 | self.assertEqual(self.test_output_dir, config.output_dir) 49 | self.assertEqual(self.test_cookie_jar_path, config.cookie_jar_path) 50 | self.assertEqual("html.parser", config.bs4_parser) 51 | self.assertFalse(config.warn_on_missing_required_field) 52 | 53 | # GIVEN 54 | config.save() 55 | 56 | # THEN 57 | thread_pool_size = os.cpu_count() * 4 58 | self.assertTrue(os.path.exists(config_path)) 59 | with open(config.config_path, "r") as f: 60 | self.assertEqual("""auth_reattempt_wait: 5 61 | bs4_parser: html.parser 62 | connection_pool_size: {connection_pool_size} 63 | constants_class: amazonorders.constants.Constants 64 | cookie_jar_path: {cookie_jar_path} 65 | cookie_reattempt_wait: 0.5 66 | item_class: amazonorders.entity.item.Item 67 | max_auth_attempts: 10 68 | max_auth_retries: 1 69 | max_cookie_attempts: 10 70 | order_class: amazonorders.entity.order.Order 71 | output_dir: {output_dir} 72 | selectors_class: amazonorders.selectors.Selectors 73 | shipment_class: amazonorders.entity.shipment.Shipment 74 | thread_pool_size: {thread_pool_size} 75 | warn_on_missing_required_field: false 76 | """ 77 | .format(connection_pool_size=thread_pool_size * 2, 78 | cookie_jar_path=self.test_cookie_jar_path, 79 | output_dir=self.test_output_dir, 80 | thread_pool_size=thread_pool_size), f.read()) 81 | 82 | def test_override_default(self): 83 | # GIVEN 84 | # Default is 10 85 | config = AmazonOrdersConfig(data={ 86 | "max_auth_attempts": 11 87 | }) 88 | 89 | self.assertEqual(11, config.max_auth_attempts) 90 | 91 | def test_load_from_file(self): 92 | # GIVEN 93 | config_path = os.path.join(conf.DEFAULT_CONFIG_DIR, "load-from-config.yml") 94 | test_output_dir = os.path.join(conf.DEFAULT_CONFIG_DIR, "load-from-config-output") 95 | test_cookie_jar_path = os.path.join(conf.DEFAULT_CONFIG_DIR, "load-from-config-cookies.json") 96 | os.makedirs(conf.DEFAULT_CONFIG_DIR) 97 | with open(config_path, "w") as f: 98 | f.write("""cookie_jar_path: {cookie_jar_path} 99 | max_auth_attempts: 11 100 | output_dir: {output_dir} 101 | some_custom_config: {custom_config} 102 | """ 103 | .format(cookie_jar_path=test_cookie_jar_path, 104 | output_dir=test_output_dir, 105 | custom_config="my-custom-config")) 106 | 107 | # WHEN 108 | config = AmazonOrdersConfig(config_path=config_path) 109 | 110 | self.assertEqual(config_path, config.config_path) 111 | self.assertEqual(11, config.max_auth_attempts) 112 | self.assertEqual(test_output_dir, config.output_dir) 113 | self.assertEqual(test_cookie_jar_path, config.cookie_jar_path) 114 | self.assertEqual("my-custom-config", config.some_custom_config) 115 | 116 | def test_update_config(self): 117 | # GIVEN 118 | config = AmazonOrdersConfig(data={ 119 | "max_auth_attempts": 11 120 | }) 121 | 122 | self.assertEqual(11, config.max_auth_attempts) 123 | 124 | # WHEN 125 | config.update_config("max_auth_attempts", 7) 126 | config.update_config("username", "test-username") 127 | config.update_config("otp_secret_key", "test-otp-secret-key") 128 | 129 | # THEN 130 | self.assertEqual(7, config.max_auth_attempts) 131 | self.assertEqual("test-username", config.username) 132 | self.assertEqual("test-otp-secret-key", config.otp_secret_key) 133 | with open(config.config_path, "r") as f: 134 | persisted_config = yaml.safe_load(f) 135 | self.assertEqual(7, persisted_config["max_auth_attempts"]) 136 | self.assertEqual("test-username", persisted_config["username"]) 137 | self.assertEqual("test-otp-secret-key", persisted_config["otp_secret_key"]) 138 | -------------------------------------------------------------------------------- /tests/resources/transactions/get-transactions-snippet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Get Transactions 5 | 6 | 7 | 8 |
11 |
12 |
13 |
Completed
14 |
15 |
16 |
17 |
October 11, 2024
19 |
21 |
22 |
24 |
Visa ****1234 25 |
26 |
-CA$45.19
28 |
29 |
31 | 36 |
37 |
39 |
40 |
AMZN Mktp CA
41 |
42 |
43 |
44 |
45 |
October 9, 2024
47 |
49 |
50 |
52 |
Mastercard 53 | ****1234
54 |
-CA$28.79
56 |
57 |
59 | 64 |
65 |
67 |
68 |
Amazon.ca
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
82 |
83 | 84 |
85 | 86 | 87 | -------------------------------------------------------------------------------- /amazonorders/transactions.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import datetime 5 | import logging 6 | from typing import Dict, List, Optional, Tuple, Any 7 | 8 | from bs4 import Tag 9 | from dateutil import parser 10 | 11 | from amazonorders import util 12 | from amazonorders.conf import AmazonOrdersConfig 13 | from amazonorders.entity.transaction import Transaction 14 | from amazonorders.exception import AmazonOrdersError 15 | from amazonorders.session import AmazonSession 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | def _parse_transaction_form_tag(form_tag: Tag, 21 | config: AmazonOrdersConfig) \ 22 | -> Tuple[List[Transaction], Optional[Dict[str, str]]]: 23 | transactions = [] 24 | date_container_tags = util.select(form_tag, config.selectors.TRANSACTION_DATE_CONTAINERS_SELECTOR) 25 | for date_container_tag in date_container_tags: 26 | date_tag = util.select_one(date_container_tag, config.selectors.FIELD_TRANSACTION_COMPLETED_DATE_SELECTOR) 27 | if not date_tag: 28 | logger.warning("Could not find date tag in Transaction form.") 29 | continue 30 | 31 | date_str = date_tag.text 32 | date = parser.parse(date_str).date() 33 | 34 | transactions_container_tag = date_container_tag.find_next_sibling( 35 | config.selectors.TRANSACTIONS_CONTAINER_SELECTOR) 36 | if not isinstance(transactions_container_tag, Tag): 37 | logger.warning("Could not find Transactions container tag in Transaction form.") 38 | continue 39 | 40 | transaction_tags = util.select(transactions_container_tag, config.selectors.TRANSACTIONS_SELECTOR) 41 | for transaction_tag in transaction_tags: 42 | transaction = Transaction(transaction_tag, config, date) 43 | transactions.append(transaction) 44 | 45 | form_state_input = util.select_one(form_tag, config.selectors.TRANSACTIONS_NEXT_PAGE_INPUT_STATE_SELECTOR) 46 | form_ie_input = util.select_one(form_tag, config.selectors.TRANSACTIONS_NEXT_PAGE_INPUT_IE_SELECTOR) 47 | next_page_input = util.select_one(form_tag, config.selectors.TRANSACTIONS_NEXT_PAGE_INPUT_SELECTOR) 48 | if not next_page_input or not form_state_input or not form_ie_input: 49 | return transactions, None 50 | 51 | next_page_data = { 52 | "ppw-widgetState": str(form_state_input["value"]), 53 | "ie": str(form_ie_input["value"]), 54 | str(next_page_input["name"]): "", 55 | } 56 | 57 | return transactions, next_page_data 58 | 59 | 60 | class AmazonTransactions: 61 | """ 62 | Using an authenticated :class:`~amazonorders.session.AmazonSession`, can be used to query Amazon 63 | for Transaction details and history. 64 | """ 65 | 66 | def __init__(self, 67 | amazon_session: AmazonSession, 68 | debug: Optional[bool] = None, 69 | config: Optional[AmazonOrdersConfig] = None) -> None: 70 | if not debug: 71 | debug = amazon_session.debug 72 | if not config: 73 | config = amazon_session.config 74 | 75 | #: The session to use for requests. 76 | self.amazon_session: AmazonSession = amazon_session 77 | #: The config to use. 78 | self.config: AmazonOrdersConfig = config 79 | 80 | #: Setting logger to ``DEBUG`` will send output to ``stderr``. 81 | self.debug: bool = debug 82 | if self.debug: 83 | logger.setLevel(logging.DEBUG) 84 | 85 | def get_transactions(self, 86 | days: int = 365, 87 | next_page_data: Optional[Dict[str, Any]] = None, 88 | keep_paging: bool = True) -> List[Transaction]: 89 | """ 90 | Get Amazon Transaction history for a given number of days. 91 | 92 | :param days: The number of days worth of Transactions to get. 93 | :param next_page_data: If a call to this method previously errored out, passing the exception's 94 | :attr:`~amazonorders.exception.AmazonOrdersError.meta` will continue paging where it left off. 95 | :param keep_paging: ``False`` if only one page should be fetched. 96 | :return: A list of the requested Transactions. 97 | """ 98 | if not self.amazon_session.is_authenticated: 99 | raise AmazonOrdersError("Call AmazonSession.login() to authenticate first.") 100 | 101 | min_date = datetime.date.today() - datetime.timedelta(days=days) 102 | 103 | transactions: List[Transaction] = [] 104 | first_page = True 105 | while first_page or keep_paging: 106 | first_page = False 107 | 108 | page_response = self.amazon_session.post(self.config.constants.TRANSACTION_HISTORY_URL, 109 | data=next_page_data) 110 | self.amazon_session.check_response(page_response, meta=next_page_data) 111 | 112 | form_tag = util.select_one(page_response.parsed, 113 | self.config.selectors.TRANSACTION_HISTORY_FORM_SELECTOR) 114 | 115 | if not form_tag: 116 | transaction_container = util.select_one(page_response.parsed, 117 | self.config.selectors.TRANSACTION_HISTORY_CONTAINER_SELECTOR) 118 | if transaction_container and "don't have any transactions" in transaction_container.text: 119 | break 120 | else: 121 | raise AmazonOrdersError("Could not parse Transaction history. Check if Amazon changed the HTML.") 122 | 123 | loaded_transactions, next_page_data = ( 124 | _parse_transaction_form_tag(form_tag, self.config) 125 | ) 126 | 127 | for transaction in loaded_transactions: 128 | if transaction.completed_date >= min_date: 129 | transactions.append(transaction) 130 | else: 131 | next_page_data = None 132 | break 133 | 134 | if not next_page_data: 135 | keep_paging = False 136 | 137 | return transactions 138 | -------------------------------------------------------------------------------- /tests/resources/transactions/transaction-form-tag.html: -------------------------------------------------------------------------------- 1 |
4 |
5 |
6 |
Completed
7 |
8 |
9 |
10 |
October 11, 2024
12 |
14 |
15 |
17 |
Visa ****1234 18 |
19 |
-CA$45.19
21 |
22 |
24 | 29 |
30 |
32 |
33 |
AMZN Mktp CA
34 |
35 |
36 |
37 |
38 |
October 9, 2024
40 |
42 |
43 |
45 |
Mastercard 46 | ****1234
47 |
-CA$28.79
49 |
50 |
52 | 57 |
58 |
60 |
61 |
Amazon.ca
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
75 |
80 |
81 |
82 |
-------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. rst-class:: hide-header 2 | 3 | ****************************************************************** 4 | Amazon Orders - A Python libray (and CLI) for Amazon order history 5 | ****************************************************************** 6 | 7 | .. image:: _html/logo.png 8 | :alt: amazon-orders - A Python libray (and CLI) for Amazon order history 9 | :align: center 10 | 11 | | 12 | 13 | .. image:: https://img.shields.io/pypi/v/amazon-orders 14 | :target: https://pypi.org/project/amazon-orders 15 | .. image:: https://img.shields.io/pypi/pyversions/amazon-orders.svg 16 | :target: https://pypi.org/project/amazon-orders 17 | .. image:: https://img.shields.io/codecov/c/github/alexdlaird/amazon-orders 18 | :target: https://codecov.io/gh/alexdlaird/amazon-orders 19 | .. image:: https://img.shields.io/github/actions/workflow/status/alexdlaird/amazon-orders/build.yml 20 | :target: https://github.com/alexdlaird/amazon-orders/actions/workflows/build.yml 21 | .. image:: https://img.shields.io/readthedocs/amazon-orders 22 | :target: https://amazon-orders.readthedocs.io 23 | .. image:: https://img.shields.io/github/license/alexdlaird/amazon-orders 24 | :target: https://github.com/alexdlaird/amazon-orders 25 | 26 | ``amazon-orders`` is an unofficial library that provides a Python API (and CLI) for Amazon order history 27 | 28 | This package works by parsing data from Amazon's consumer-facing website. A periodic build validates functionality 29 | to ensure its stability, but as Amazon provides no official API to use, this package may break at any time (so check 30 | often to ensure you're on the latest version). 31 | 32 | Only the English, ``.com`` version of Amazon is officially supported. 33 | 34 | Installation 35 | ============ 36 | 37 | ``amazon-orders`` is available on 38 | `PyPI `__ and can be installed and/or upgraded 39 | using ``pip``: 40 | 41 | .. code:: sh 42 | 43 | pip install amazon-orders --upgrade 44 | 45 | That's it! ``amazon-orders`` is now available as a package to your Python projects and from the command line. 46 | 47 | If pinning, be sure to use a wildcard for the `minor version `_ (ex. ``==4.0.*``, not ``==4.0.18``) 48 | to ensure you always get the latest stable release. 49 | 50 | Basic Usage 51 | =========== 52 | 53 | You'll use :class:`~amazonorders.session.AmazonSession` to authenticate your Amazon account, then 54 | :class:`~amazonorders.orders.AmazonOrders` and :class:`~amazonorders.transactions.AmazonTransactions` to interact with 55 | account data. :func:`~amazonorders.orders.AmazonOrders.get_order_history` and 56 | :func:`~amazonorders.orders.AmazonOrders.get_order` are good places to start. 57 | 58 | .. code:: python 59 | 60 | from amazonorders.session import AmazonSession 61 | from amazonorders.orders import AmazonOrders 62 | 63 | amazon_session = AmazonSession("", 64 | "") 65 | amazon_session.login() 66 | 67 | amazon_orders = AmazonOrders(amazon_session) 68 | 69 | # Get orders from a specific year 70 | orders = amazon_orders.get_order_history(year=2023) 71 | 72 | # Or use time filters for recent orders 73 | orders = amazon_orders.get_order_history(time_filter="last30") # Last 30 days 74 | orders = amazon_orders.get_order_history(time_filter="months-3") # Past 3 months 75 | 76 | for order in orders: 77 | print(f"{order.order_number} - {order.grand_total}") 78 | 79 | If the fields you're looking for aren't populated with the above, set ``full_details=True`` (or pass ``--full-details`` 80 | to the ``history`` CLI command), since by default it is ``False`` (enabling it slows down querying, since an additional 81 | request for each order is necessary). Have a look at the :class:`~amazonorders.entity.order.Order` entity's docs to see 82 | what fields are only populated with full details. 83 | 84 | Command Line Usage 85 | ------------------ 86 | 87 | You can also run any command available to the main Python interface from the command line: 88 | 89 | .. code:: sh 90 | 91 | amazon-orders login 92 | amazon-orders history --year 2023 93 | amazon-orders history --last-30-days 94 | amazon-orders history --last-3-months 95 | 96 | Automating Authentication 97 | ------------------------- 98 | 99 | Authentication can be automated by (in order of precedence) storing credentials in environment variables, passing them 100 | to :class:`~amazonorders.session.AmazonSession`, or storing them :class:`~amazonorders.conf.AmazonOrdersConfig`. The 101 | environment variables ``amazon-orders`` looks for are: 102 | 103 | - ``AMAZON_USERNAME`` 104 | - ``AMAZON_PASSWORD`` 105 | - ``AMAZON_OTP_SECRET_KEY`` (see :attr:`~amazonorders.session.AmazonSession.otp_secret_key`) 106 | 107 | Known Limitations 108 | ----------------- 109 | 110 | - Non-English, non-``.com`` versions of Amazon are unsupported 111 | - Some have reported success with some non-``.com`` sites (ex. ``amazon.ca`` in Canada), so other similar 112 | English-based versions of Amazon may work by chance. However, we do not run nightly regressions against 113 | other versions of the site, and as such do not say they are officially supported. 114 | - If you fork the repo, set ``AMAZON_BASE_URL`` with an English, non-``.com`` version of the site, and use 115 | your own credentials with the ``integration.yml`` workflow to setup a nightly regression run, please 116 | `contact us `_ and we will start mentioning support for that version of the site. 117 | - See `issue #15 `_ for more details. 118 | - Some Captchas are unsupported 119 | - While some Captchas can be auto-solved, and static image-based ones are opened so the user can manually input 120 | the solution, interactive Captchas—like `Amazon's puzzle-based Captchas `_ 121 | —require JavaScript to solve, and will block ``amazon-orders`` from being able to login. 122 | - See the troubleshooting steps for `reducing Captcha challenge frequency `_ for recommended workarounds. 123 | - See `issue #45 `_ for more details. 124 | - Device not remembered for OTP 125 | - Amazon will sometimes re-prompt for OTP even when a device has been remembered. 126 | - The recommended workaround for this is persisting the :attr:`~amazonorders.session.AmazonSession.otp_secret_key` 127 | in the config or the environment so that re-prompts are auto-solved. 128 | - See `issue #55 `_ for more details. 129 | 130 | Dive Deeper 131 | =========== 132 | 133 | For more advanced usage, dive deeper in to the rest of the documentation. 134 | 135 | .. toctree:: 136 | :maxdepth: 2 137 | 138 | api 139 | troubleshooting 140 | 141 | .. include:: ../CONTRIBUTING.rst 142 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import datetime 5 | import os 6 | import sys 7 | 8 | # -- Path setup -------------------------------------------------------------- 9 | 10 | # If extensions (or modules to document with autodoc) are in another directory, 11 | # add these directories to sys.path here. If the directory is relative to the 12 | # documentation root, use os.path.abspath to make it absolute, like shown here. 13 | # 14 | 15 | sys.path.insert(0, os.path.abspath("..")) 16 | 17 | # This import must happen after adding to sys.path so docs build is consistent across environments 18 | from amazonorders import __version__ 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = "amazon-orders" 23 | copyright = f"{datetime.date.today().year}, Alex Laird" 24 | author = "Alex Laird" 25 | 26 | # The short X.Y version 27 | version = __version__ 28 | # The full version, including alpha/beta/rc tags 29 | release = version 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # If your documentation needs a minimal Sphinx version, state it here. 34 | # 35 | # needs_sphinx = "1.0" 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named "sphinx.ext.*") or your custom 39 | # ones. 40 | extensions = [ 41 | "sphinx.ext.autodoc", 42 | "sphinx.ext.doctest", 43 | "sphinx.ext.coverage", 44 | "sphinx.ext.intersphinx", 45 | "sphinx.ext.viewcode", 46 | "notfound.extension", 47 | "sphinx_autodoc_typehints" 48 | ] 49 | autodoc_member_order = "bysource" 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ["_templates"] 53 | 54 | # The suffix(es) of source filenames. 55 | # You can specify multiple suffix as a list of string: 56 | # 57 | source_suffix = [ 58 | ".rst" 59 | ] 60 | 61 | # The master toctree document. 62 | master_doc = "index" 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | # This pattern also affects html_static_path and html_extra_path. 67 | exclude_patterns = ["build", "Thumbs.db", ".DS_Store", "venv"] 68 | 69 | # If true, '()' will be appended to :func: etc. cross-reference text. 70 | add_function_parentheses = False 71 | 72 | # The name of the Pygments (syntax highlighting) style to use. 73 | pygments_style = "sphinx" 74 | 75 | # -- Options for HTML output ------------------------------------------------- 76 | 77 | # The theme to use for HTML and HTML Help pages. See the documentation for 78 | # a list of builtin themes. 79 | # 80 | html_theme = "alabaster" 81 | 82 | # Theme options are theme-specific and customize the look and feel of a theme 83 | # further. For a list of options available for each theme, see the 84 | # documentation. 85 | # 86 | html_theme_options = { 87 | "github_user": "alexdlaird", 88 | "github_repo": "amazon-orders", 89 | "note_bg": "#FFF59C", 90 | } 91 | 92 | # Add any paths that contain custom static files (such as style sheets) here, 93 | # relative to this directory. They are copied after the builtin static files, 94 | # so a file named "default.css" will overwrite the builtin "default.css". 95 | html_static_path = ["_static"] 96 | html_extra_path = ["_html"] 97 | 98 | html_css_files = [ 99 | "custom.css", 100 | ] 101 | 102 | # Custom sidebar templates, must be a dictionary that maps document names 103 | # to template names. 104 | # 105 | # The default sidebars (for documents that don't match any pattern) are 106 | # defined by theme itself. Builtin themes are using these templates by 107 | # default: ``["localtoc.html", "relations.html", "sourcelink.html", 108 | # "searchbox.html"]``. 109 | # 110 | html_sidebars = { 111 | "index": [ 112 | "sidebartoc.html", 113 | "usefullinks.html", 114 | "searchbox.html", 115 | ], 116 | "**": [ 117 | "sidebartoc.html", 118 | "localtoc.html", 119 | "usefullinks.html", 120 | "searchbox.html", 121 | ], 122 | } 123 | 124 | toc_object_entries = False 125 | 126 | # If true, links to the reST sources are added to the pages. 127 | html_show_sourcelink = False 128 | 129 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 130 | html_show_sphinx = False 131 | 132 | # -- Options for HTMLHelp output --------------------------------------------- 133 | 134 | # Output file base name for HTML help builder. 135 | htmlhelp_basename = "amazonordersdoc" 136 | 137 | # -- Options for LaTeX output ------------------------------------------------ 138 | 139 | latex_elements = { 140 | # The paper size ("letterpaper" or "a4paper"). 141 | # 142 | # "papersize": "letterpaper", 143 | 144 | # The font size ("10pt", "11pt" or "12pt"). 145 | # 146 | # "pointsize": "10pt", 147 | 148 | # Additional stuff for the LaTeX preamble. 149 | # 150 | # "preamble": "", 151 | 152 | # Latex figure (float) alignment 153 | # 154 | # "figure_align": "htbp", 155 | } 156 | 157 | # Grouping the document tree into LaTeX files. List of tuples 158 | # (source start file, target name, title, 159 | # author, documentclass [howto, manual, or own class]). 160 | latex_documents = [ 161 | (master_doc, "amazonorders.tex", "amazon-orders Documentation", 162 | author, "manual"), 163 | ] 164 | 165 | # -- Options for manual page output ------------------------------------------ 166 | 167 | # One entry per manual page. List of tuples 168 | # (source start file, name, description, authors, manual section). 169 | man_pages = [ 170 | (master_doc, "amazonorders", "amazon-orders Documentation", 171 | [author], 1) 172 | ] 173 | 174 | # -- Options for Texinfo output ---------------------------------------------- 175 | 176 | # Grouping the document tree into Texinfo files. List of tuples 177 | # (source start file, target name, title, author, 178 | # dir menu entry, description, category) 179 | texinfo_documents = [ 180 | (master_doc, "amazonorders", "amazon-orders Documentation", 181 | author, "amazon-orders", 182 | "A Python libray (and CLI) for Amazon order history", 183 | "Miscellaneous"), 184 | ] 185 | 186 | # -- Options for Epub output ------------------------------------------------- 187 | 188 | # Bibliographic Dublin Core info. 189 | epub_title = project 190 | 191 | # The unique identifier of the text. This can be a ISBN number 192 | # or the project homepage. 193 | # 194 | # epub_identifier = "" 195 | 196 | # A unique identification for the text. 197 | # 198 | # epub_uid = "" 199 | 200 | # A list of files that should not be packed into the epub file. 201 | epub_exclude_files = ["search.html"] 202 | 203 | # -- Extension configuration ------------------------------------------------- 204 | 205 | intersphinx_mapping = { 206 | "click": ("https://click.palletsprojects.com/en/latest", None), 207 | "requests": ("https://requests.readthedocs.io/en/latest/", None), 208 | "beautifulsoup4": ("https://www.crummy.com/software/BeautifulSoup/bs4/doc/", None), 209 | "python": ("https://docs.python.org/3", None) 210 | } 211 | -------------------------------------------------------------------------------- /tests/resources/auth/post-signin-captcha-3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Amazon.com 11 | 12 | 13 | 26 | 27 | 28 | 29 | 33 | 34 | 37 | 38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 |

Enter the characters you see below

48 |

Sorry, we just need to make sure you're not a robot. For best results, please make sure your browser is accepting cookies.

49 |
50 |
51 | 52 |
53 | 54 |
55 |
56 | 57 |
58 | 59 |
60 |
61 |
62 |

Type the characters you see in this image:

63 |
64 | 65 |
66 |
67 |
68 |
69 |
70 | 73 |
74 | 75 |
76 |
77 |
78 |
79 | 80 |
81 | 82 |
83 | 84 | 85 | 86 | 87 | 88 |
89 | 90 |
91 |
92 | 93 |
94 |
95 | 96 |
97 | 98 |
99 | 100 |
101 | 102 |
103 | Conditions of Use 104 | 105 | 106 | 107 | 108 | Privacy Policy 109 |
110 |
111 | © 1996-2014, Amazon.com, Inc. or its affiliates 112 | 117 | 120 |
121 |
122 | 135 | 136 | -------------------------------------------------------------------------------- /amazonorders/conf.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import threading 4 | from typing import Any, Dict, Optional, Union 5 | 6 | import yaml 7 | 8 | from amazonorders import util 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | DEFAULT_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "amazonorders") 13 | 14 | config_file_lock = threading.Lock() 15 | cookies_file_lock = threading.Lock() 16 | debug_output_file_lock = threading.Lock() 17 | 18 | 19 | class AmazonOrdersConfig: 20 | """ 21 | An object containing ``amazon-orders``'s configuration. The state of this object is populated from the config file, 22 | if present, when it is instantiated, and it is also persisted back to the config file when :func:`~save` is called. 23 | 24 | If overrides are passed in ``data`` parameter when this object is instantiated, they will be used to populate the 25 | new object, but not persisted to the config file until :func:`~save` is called. 26 | 27 | Default values provisioned with the config can be found 28 | `here `_. 29 | """ 30 | 31 | def __init__(self, 32 | config_path: Optional[str] = None, 33 | data: Optional[Dict[str, Any]] = None) -> None: 34 | #: The path to use for the config file. 35 | self.config_path: str = os.path.join(DEFAULT_CONFIG_DIR, "config.yml") if config_path is None else config_path 36 | 37 | # Provision default configs 38 | thread_pool_size = (os.cpu_count() or 1) * 4 39 | self._data = { 40 | # The maximum number of times to retry provisioning initial cookies before failing 41 | "max_cookie_attempts": 10, 42 | # The number of seconds to wait before retrying to provision initial cookies 43 | "cookie_reattempt_wait": 0.5, 44 | # The maximum number of authentication forms to try before failing 45 | "max_auth_attempts": 10, 46 | # The number of seconds to wait before retrying the auth flow 47 | "auth_reattempt_wait": 5, 48 | # Where output files (for instance, HTML pages, when ``debug`` mode is enabled) will be written 49 | "output_dir": os.path.join(os.getcwd(), "output"), 50 | "cookie_jar_path": os.path.join(DEFAULT_CONFIG_DIR, "cookies.json"), 51 | "constants_class": "amazonorders.constants.Constants", 52 | "selectors_class": "amazonorders.selectors.Selectors", 53 | "order_class": "amazonorders.entity.order.Order", 54 | "shipment_class": "amazonorders.entity.shipment.Shipment", 55 | "item_class": "amazonorders.entity.item.Item", 56 | "bs4_parser": "html.parser", 57 | "thread_pool_size": (os.cpu_count() or 1) * 4, 58 | "connection_pool_size": thread_pool_size * 2, 59 | # The maximum number of failed attempts to allow before failing CLI authentication 60 | "max_auth_retries": 1, 61 | # Set ``True`` to log a warning message instead of raising an exception when a required field is missing. 62 | "warn_on_missing_required_field": False 63 | } 64 | 65 | with config_file_lock: 66 | # Ensure directories and files exist for config data 67 | config_dir = os.path.dirname(self.config_path) 68 | if not os.path.exists(config_dir): 69 | os.makedirs(config_dir) 70 | 71 | if os.path.exists(self.config_path): 72 | with open(self.config_path, "r") as config_file: 73 | logger.debug(f"Loading config from {self.config_path} ...") 74 | config = yaml.safe_load(config_file) 75 | if config is not None: 76 | config.update(data or {}) 77 | data = config 78 | 79 | # Overload defaults if values passed 80 | self._data.update(data or {}) 81 | 82 | if not os.path.exists(self.output_dir): 83 | os.makedirs(self.output_dir) 84 | 85 | with cookies_file_lock: 86 | cookie_jar_dir = os.path.dirname(self.cookie_jar_path) 87 | if not os.path.exists(cookie_jar_dir): 88 | os.makedirs(cookie_jar_dir) 89 | 90 | constants_class_split = self.constants_class.split(".") 91 | selectors_class_split = self.selectors_class.split(".") 92 | order_class_split = self.order_class.split(".") 93 | shipment_class_split = self.shipment_class.split(".") 94 | item_class_split = self.item_class.split(".") 95 | 96 | self.constants = util.load_class(constants_class_split[:-1], constants_class_split[-1])() 97 | self.selectors = util.load_class(selectors_class_split[:-1], selectors_class_split[-1])() 98 | self.order_cls = util.load_class(order_class_split[:-1], order_class_split[-1]) 99 | self.shipment_cls = util.load_class(shipment_class_split[:-1], shipment_class_split[-1]) 100 | self.item_cls = util.load_class(item_class_split[:-1], item_class_split[-1]) 101 | 102 | def __getattr__(self, 103 | key: str) -> Any: 104 | return self._data.get(key, None) 105 | 106 | def __contains__(self, 107 | key: str) -> bool: 108 | return key in self._data 109 | 110 | def __getstate__(self) -> Dict[str, Any]: 111 | return self._data 112 | 113 | def __setstate__(self, 114 | state: Dict[str, Any]) -> None: 115 | self._data = state 116 | constants_class_split = self.constants_class.split(".") 117 | selectors_class_split = self.selectors_class.split(".") 118 | order_class_split = self.order_class.split(".") 119 | shipment_class_split = self.shipment_class.split(".") 120 | item_class_split = self.item_class.split(".") 121 | 122 | self.constants = util.load_class(constants_class_split[:-1], constants_class_split[-1])() 123 | self.selectors = util.load_class(selectors_class_split[:-1], selectors_class_split[-1])() 124 | self.order_cls = util.load_class(order_class_split[:-1], order_class_split[-1]) 125 | self.shipment_cls = util.load_class(shipment_class_split[:-1], shipment_class_split[-1]) 126 | self.item_cls = util.load_class(item_class_split[:-1], item_class_split[-1]) 127 | 128 | def update_config(self, 129 | key: str, 130 | value: Union[str, int, float], 131 | save: bool = True) -> None: 132 | """ 133 | Update the given key/value pair in the config object. By default, this update will also be persisted to the 134 | config file. If only the object should be updated without persisting, pass ``save=False``. 135 | 136 | :param key: The key to be updated. 137 | :param value: The new value. 138 | :param save: ``True`` if the config should be persisted. 139 | """ 140 | self._data[key] = value 141 | 142 | if save: 143 | self.save() 144 | 145 | def save(self) -> None: 146 | """ 147 | Persist the current state of this config object to the config file. 148 | """ 149 | with config_file_lock: 150 | with open(self.config_path, "w") as config_file: 151 | logger.debug(f"Saving config to {self.config_path} ...") 152 | 153 | yaml.dump(self._data, config_file) 154 | -------------------------------------------------------------------------------- /tests/resources/auth/post-signin-captcha-2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Amazon.com 11 | 12 | 13 | 26 | 27 | 28 | 29 | 33 | 34 | 37 | 38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 |

Enter the characters you see below

48 |

Sorry, we just need to make sure you're not a robot. For best results, please make sure your browser is accepting cookies.

49 |
50 |
51 | 52 |
53 | 54 |
55 |
56 | 57 |
58 | 59 |
60 |
61 |
62 |

Type the characters you see in this image:

63 |
64 | 65 |
66 |
67 |
68 |
69 |
70 | 73 |
74 | 75 |
76 |
77 |
78 |
79 | 80 |
81 | 82 |
83 | 84 | 85 | 86 | 87 | 88 |
89 | 90 |
91 |
92 | 93 |
94 |
95 | 96 |
97 | 98 |
99 | 100 |
101 | 102 |
103 | Conditions of Use 104 | 105 | 106 | 107 | 108 | Privacy Policy 109 |
110 |
111 | © 1996-2014, Amazon.com, Inc. or its affiliates 112 | 117 | 120 |
121 |
122 | 135 | 136 | -------------------------------------------------------------------------------- /tests/unit/entity/test_order.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import os 5 | from unittest.mock import patch 6 | 7 | from bs4 import BeautifulSoup 8 | 9 | from amazonorders.conf import AmazonOrdersConfig 10 | from amazonorders.entity.order import Order 11 | from amazonorders.exception import AmazonOrdersError 12 | from tests.unittestcase import UnitTestCase 13 | 14 | 15 | class TestOrder(UnitTestCase): 16 | def test_order_currency_stripped(self): 17 | # GIVEN 18 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-currency-stripped-snippet.html"), 19 | "r", 20 | encoding="utf-8") as f: 21 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 22 | 23 | # WHEN 24 | order = Order(parsed, self.test_config, full_details=True) 25 | 26 | # THEN 27 | self.assertEqual(order.subtotal, 1111.99) 28 | self.assertEqual(order.shipping_total, 2222.99) 29 | self.assertEqual(order.total_before_tax, 3333.99) 30 | self.assertEqual(order.estimated_tax, 4444.99) 31 | self.assertIsNone(order.refund_total) 32 | self.assertIsNone(order.subscription_discount) 33 | self.assertEqual(order.grand_total, 7777.99) 34 | 35 | def test_order_promotion_applied(self): 36 | # GIVEN 37 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-promotion-applied-snippet.html"), 38 | "r", 39 | encoding="utf-8") as f: 40 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 41 | 42 | # WHEN 43 | order = Order(parsed, self.test_config, full_details=True) 44 | 45 | # THEN 46 | self.assertEqual(order.promotion_applied, -0.05) 47 | 48 | def test_order_subscriptions_and_reward_points(self): 49 | # GIVEN 50 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-subscriptions-and-reward-points-snippet.html"), 51 | "r", 52 | encoding="utf-8") as f: 53 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 54 | 55 | # WHEN 56 | order = Order(parsed, self.test_config, full_details=True) 57 | 58 | # THEN 59 | self.assertEqual(order.subscription_discount, -0.78) 60 | self.assertEqual(order.reward_points, -5.98) 61 | 62 | def test_order_coupon_savings(self): 63 | # GIVEN 64 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-details-coupon-savings.html"), 65 | "r", 66 | encoding="utf-8") as f: 67 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 68 | 69 | # WHEN 70 | order = Order(parsed, self.test_config, full_details=True) 71 | 72 | # THEN 73 | self.assertEqual(order.coupon_savings, -3.89) 74 | 75 | def test_order_free_shipping(self): 76 | # GIVEN 77 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-details-111-6778632-7354601.html"), 78 | "r", 79 | encoding="utf-8") as f: 80 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 81 | 82 | # WHEN 83 | order = Order(parsed, self.test_config, full_details=True) 84 | 85 | # THEN 86 | self.assertEqual(order.free_shipping, -2.99) 87 | 88 | def test_order_coupon_savings_multiple(self): 89 | # GIVEN 90 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-details-coupon-savings-multiple.html"), 91 | "r", 92 | encoding="utf-8") as f: 93 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 94 | 95 | # WHEN 96 | order = Order(parsed, self.test_config, full_details=True) 97 | 98 | # THEN 99 | self.assertEqual(order.coupon_savings, -1.29) 100 | 101 | def test_order_amazon_discount(self): 102 | # GIVEN 103 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-amazon-discount-snippet.html"), 104 | "r", 105 | encoding="utf-8") as f: 106 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 107 | 108 | # WHEN 109 | order = Order(parsed, self.test_config, full_details=True) 110 | 111 | # THEN 112 | self.assertEqual(order.amazon_discount, -1.62) 113 | 114 | def test_order_gift_wrap(self): 115 | # GIVEN 116 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-details-gift-wrap-snippet.html"), 117 | "r", 118 | encoding="utf-8") as f: 119 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 120 | 121 | # WHEN 122 | order = Order(parsed, self.test_config, full_details=True) 123 | 124 | # THEN 125 | self.assertEqual(order.gift_wrap, 3.99) 126 | 127 | def test_order_multibuy_discount(self): 128 | # GIVEN 129 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-multibuy-snippet.html"), 130 | "r", 131 | encoding="utf-8") as f: 132 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 133 | 134 | # WHEN 135 | order = Order(parsed, self.test_config, full_details=True) 136 | 137 | # THEN 138 | self.assertEqual(order.multibuy_discount, -3.74) 139 | 140 | def test_order_gift_card(self): 141 | # GIVEN 142 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-gift-card-snippet.html"), 143 | "r", 144 | encoding="utf-8") as f: 145 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 146 | 147 | # WHEN 148 | order = Order(parsed, self.test_config, full_details=True) 149 | 150 | # THEN 151 | self.assertEqual(order.gift_card, -2.37) 152 | 153 | def test_order_missing_grand_total_raises_exception_by_default(self): 154 | # GIVEN 155 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-missing-grand-total-snippet.html"), 156 | "r", 157 | encoding="utf-8") as f: 158 | parsed = BeautifulSoup(f.read(), self.test_config.bs4_parser) 159 | 160 | # WHEN / THEN 161 | with self.assertRaises(AmazonOrdersError) as context: 162 | Order(parsed, self.test_config, full_details=True) 163 | 164 | self.assertIn("grand_total could not be parsed", str(context.exception)) 165 | self.assertIn("warn_on_missing_required_field=False", str(context.exception)) 166 | 167 | def test_order_missing_grand_total_logs_warning_when_configured(self): 168 | # GIVEN 169 | config = AmazonOrdersConfig(data={ 170 | "output_dir": self.test_output_dir, 171 | "cookie_jar_path": self.test_cookie_jar_path, 172 | "warn_on_missing_required_field": True 173 | }) 174 | with open(os.path.join(self.RESOURCES_DIR, "orders", "order-missing-grand-total-snippet.html"), 175 | "r", 176 | encoding="utf-8") as f: 177 | parsed = BeautifulSoup(f.read(), config.bs4_parser) 178 | 179 | # WHEN 180 | with patch("amazonorders.entity.order.logger") as mock_logger: 181 | order = Order(parsed, config, full_details=True) 182 | 183 | # THEN 184 | self.assertIsNone(order.grand_total) 185 | mock_logger.warning.assert_called_once() 186 | self.assertIn("grand_total could not be parsed", mock_logger.warning.call_args[0][0]) 187 | -------------------------------------------------------------------------------- /tests/integration/test_integration_json.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import json 5 | import os 6 | import sys 7 | import unittest 8 | from datetime import datetime 9 | 10 | from parameterized import parameterized 11 | 12 | from tests.integrationtestcase import IntegrationTestCase 13 | 14 | PRIVATE_RESOURCES_DIR = os.path.normpath( 15 | os.path.join(os.path.abspath(os.path.dirname(__file__)), 16 | "private-resources")) 17 | 18 | private_json_file_data = [] 19 | if os.path.exists(PRIVATE_RESOURCES_DIR): 20 | for filename in os.listdir(PRIVATE_RESOURCES_DIR): 21 | if filename == ".gitignore" or filename.startswith("example-"): 22 | continue 23 | 24 | with open(os.path.join(PRIVATE_RESOURCES_DIR, filename), "r", encoding="utf-8") as f: 25 | data = json.loads(f.read()) 26 | private_json_file_data.append((filename, data)) 27 | 28 | env_json_data = [] 29 | if os.environ.get("AMAZON_INTEGRATION_TEST_JSON"): 30 | data = json.loads(os.environ["AMAZON_INTEGRATION_TEST_JSON"]) 31 | if not isinstance(data, list): 32 | print("AMAZON_INTEGRATION_TEST_JSON must be a list of JSON objects") 33 | 34 | sys.exit(1) 35 | 36 | i = 0 37 | for test in data: 38 | env_json_data.append((f"env_var_test_{i}", test)) 39 | i += 1 40 | 41 | 42 | @unittest.skipIf(not os.environ.get("AMAZON_INTEGRATION_TEST_JSON"), 43 | "AMAZON_INTEGRATION_TEST_JSON is not set") 44 | class TestIntegrationJSON(IntegrationTestCase): 45 | """ 46 | The two JSON files committed to "private-resources" are provided as examples of the syntax. Any other 47 | files created in "private-resources" will be ignored by ``.gitignore``. Alternatively, instead of files, 48 | this same JSON syntax can be provided as a list in the environment variable AMAZON_INTEGRATION_TEST_JSON. 49 | 50 | The starting JSON of a test description is: 51 | 52 | .. code:: json 53 | 54 | { 55 | "func": "" 56 | } 57 | 58 | Field assertion values can be as follows: 59 | 60 | * Primitives and literals (ex. 23.43, "some string") 61 | * Dates formatted YYYY-MM-DD (ex. 2023-12-15) 62 | * isNone 63 | * isNotNone 64 | * Nested values (ex. a list / dict, if a corresponding list / object exists in the entity 65 | 66 | Details 67 | ======= 68 | In a ``get_order`` test, any top-level field (other than ``func``) in the JSON will be asserted on 69 | the ``Order`` (including nested fields). So, for example, if we want to assert the ``Order`` was 70 | placed on 2023-12-15 by "John Doe", the minimal test would be: 71 | 72 | .. code:: json 73 | 74 | { 75 | "func": "get_order", 76 | "order_placed_date": "2023-12-15", 77 | "recipient": { 78 | "name": "John Doe" 79 | } 80 | } 81 | 82 | History 83 | ======= 84 | In a ``get_order_history`` test, additional top-level fields are needed to define the test, and they are: 85 | 86 | .. code:: json 87 | 88 | { 89 | "func": "get_order_history", 90 | "year": , 91 | "start_index": , 92 | "full_details": , 93 | "keep_paging": , 94 | "orders_len": , 95 | "orders": { 96 | "3": { 97 | # ... The Order at index 3 98 | }, 99 | "7": { 100 | # ... The Order at index 7 101 | } 102 | } 103 | } 104 | 105 | With this syntax, multiple ``Orders`` from the response can be asserted against. The indexed dictionaries under 106 | the ``orders`` key then match the assertion functionality when testing against a single order, meaning you 107 | define here the fields and values under the ``Order`` that you want to assert on. 108 | 109 | Transactions 110 | ======= 111 | In a ``get_transactions`` test, additional top-level fields are needed to define the test, and they are: 112 | 113 | .. code:: json 114 | 115 | { 116 | "func": "get_transactions", 117 | "days": , 118 | "transactions_len": , 119 | "transactions": { 120 | "3": { 121 | # ... The Transaction at index 3 122 | }, 123 | "7": { 124 | # ... The Transaction at index 7 125 | } 126 | } 127 | } 128 | """ 129 | 130 | @parameterized.expand(private_json_file_data + env_json_data, skip_on_empty=True) 131 | def test_json(self, testname, data): 132 | print(f"Info: Dynamic test is running from JSON {testname}") 133 | 134 | # GIVEN 135 | func = data.pop("func") 136 | 137 | if func == "get_order_history": 138 | order_len = data.pop("orders_len") 139 | orders_json = data.pop("orders") 140 | full_details = data.get("full_details") 141 | 142 | # WHEN 143 | orders = self.amazon_orders.get_order_history(**data) 144 | 145 | # THEN 146 | self.assertEqual(order_len, len(orders)) 147 | for index, order_json in orders_json.items(): 148 | order = orders[int(index)] 149 | self.assertEqual(order.full_details, full_details) 150 | self.assert_json_items(order, order_json) 151 | elif func == "get_order": 152 | order_json = data 153 | order_id = order_json["order_number"] 154 | 155 | # WHEN 156 | order = self.amazon_orders.get_order(order_id) 157 | 158 | # THEN 159 | self.assertEqual(order.full_details, True) 160 | self.assert_json_items(order, order_json) 161 | elif func == "get_transactions": 162 | transactions_len = data.pop("transactions_len") 163 | transactions_json = data 164 | days = transactions_json["days"] 165 | 166 | # WHEN 167 | transactions = self.amazon_transactions.get_transactions(days) 168 | 169 | # THEN 170 | self.assertEqual(transactions_len, len(transactions)) 171 | for index, transaction_json in transactions_json.items(): 172 | transaction = transactions[int(index)] 173 | self.assert_json_items(transaction, transaction_json) 174 | else: 175 | self.fail( 176 | f"Unknown function AmazonOrders. {func}, check JSON in test file {filename}") 177 | 178 | def assert_json_items(self, entity, json_dict): 179 | for json_key, json_value in json_dict.items(): 180 | entity_attr = getattr(entity, json_key) 181 | if json_value == "isNone": 182 | self.assertIsNone(entity_attr) 183 | elif json_value == "isNotNone": 184 | self.assertIsNotNone(entity_attr) 185 | elif isinstance(json_value, list): 186 | i = 0 187 | for element in json_value: 188 | self.assert_json_items(entity_attr[i], element) 189 | i += 1 190 | elif isinstance(json_value, dict): 191 | self.assert_json_items(entity_attr, json_value) 192 | else: 193 | try: 194 | self.assertEqual( 195 | datetime.strptime(json_value, "%Y-%m-%d").date(), entity_attr) 196 | except (TypeError, ValueError): 197 | self.assertEqual(json_value, entity_attr) 198 | -------------------------------------------------------------------------------- /amazonorders/entity/parsable.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import logging 5 | import re 6 | from datetime import date 7 | from typing import Any, Callable, Dict, Optional, Type, Union 8 | 9 | from bs4 import Tag 10 | from dateutil import parser 11 | 12 | from amazonorders import util 13 | from amazonorders.conf import AmazonOrdersConfig 14 | from amazonorders.exception import AmazonOrdersEntityError, AmazonOrdersError 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class Parsable: 20 | """ 21 | A base class that contains a parsed representation of the entity, which can be extended to build an entity that 22 | utilizes the common the helper methods. 23 | """ 24 | 25 | def __init__(self, 26 | parsed: Tag, 27 | config: AmazonOrdersConfig) -> None: 28 | #: Parsed HTML data that can be used to populate the fields of the entity. 29 | self.parsed: Tag = parsed 30 | #: The config to use. 31 | self.config: AmazonOrdersConfig = config 32 | 33 | def __getstate__(self) -> Dict: 34 | state = self.__dict__.copy() 35 | state.pop("parsed") 36 | return state 37 | 38 | def safe_parse(self, 39 | parse_function: Callable[..., Any], 40 | **kwargs: Any) -> Any: 41 | """ 42 | Execute the given parse function on a field, handling any common parse exceptions and passing 43 | them as warnings to the logger (suppressing them as exceptions). 44 | 45 | :param parse_function: The parse function to attempt safe execution. 46 | :param kwargs: The ``kwargs`` will be passed to ``parse_function``. 47 | :return: The return value from ``parse_function``. 48 | """ 49 | if not parse_function.__name__.startswith("_parse_") and parse_function.__name__ != "simple_parse": 50 | raise AmazonOrdersError("The name of the `parse_function` passed " 51 | "to this method must start with `_parse_`.") 52 | 53 | try: 54 | return parse_function(**kwargs) 55 | except (AttributeError, IndexError, ValueError): 56 | function = "simple_parse" 57 | if parse_function.__name__ != function: 58 | function = parse_function.__name__.split("_parse_")[1] 59 | logger.warning( 60 | "When building {name}, `{function}` " 61 | "could not be parsed.".format(name=self.__class__.__name__, 62 | function=function), 63 | exc_info=True) 64 | return None 65 | 66 | def simple_parse(self, 67 | selector: Union[str, list], 68 | attr_name: Optional[str] = None, 69 | text_contains: Optional[str] = None, 70 | required: bool = False, 71 | prefix_split: Optional[str] = None, 72 | wrap_tag: Optional[Type] = None, 73 | parse_date: bool = False, 74 | prefix_split_fuzzy: bool = False, 75 | suffix_split: Optional[str] = None, 76 | suffix_split_fuzzy: bool = False) -> Any: 77 | """ 78 | Will attempt to extract the text value of the given CSS selector(s) for a field, and 79 | is suitable for most basic functionality on a well-formed page. 80 | 81 | The ``selector`` can be either a ``str`` or a ``list``. If a ``list`` is given, each 82 | selector in the list will be tried. 83 | 84 | In most cases the selected tag's text will be returned, but if ``wrap_tag`` is given, the 85 | tag itself (wrapped in the class) will be returned. 86 | 87 | :param selector: The CSS selector(s) for the field. 88 | :param attr_name: If provided, return the value of this attribute on the selected field. 89 | :param text_contains: Only select the field if this value is found in its text content. 90 | :param required: If required, an exception will be thrown instead of returning ``None``. 91 | :param prefix_split: Only select the field with the given prefix, returning the right side of the split if so. 92 | :param wrap_tag: Wrap the selected tag in this class before returning. 93 | :param parse_date: ``True`` if the resulting value should be fuzzy parsed in to a date (returning ``None`` if 94 | parsing fails). 95 | :param prefix_split_fuzzy: ``True`` if the value should still be used even if ``prefix_split`` is not found. 96 | :param suffix_split: Only select the field with the given suffix, returning the left side of the split if so. 97 | :param suffix_split_fuzzy: ``True`` if the value should still be used even if ``suffix_split`` is not found. 98 | :return: The cleaned up return value from the parsed ``selector``. 99 | """ 100 | if isinstance(selector, str): 101 | selector = [selector] 102 | 103 | value: Union[int, float, bool, date, str, None] = None 104 | 105 | for s in selector: 106 | for tag in self.parsed.select(s): 107 | if tag: 108 | if attr_name: 109 | value = tag.attrs[attr_name] 110 | 111 | if attr_name == "href" or attr_name == "src": 112 | value = self.with_base_url(value) 113 | 114 | return value 115 | else: 116 | if text_contains and text_contains not in tag.text: 117 | continue 118 | 119 | if prefix_split: 120 | if prefix_split not in tag.text: 121 | if prefix_split_fuzzy: 122 | value = tag.text.strip() 123 | else: 124 | continue 125 | else: 126 | value = tag.text.strip().split(prefix_split)[1] 127 | else: 128 | value = tag.text 129 | 130 | if suffix_split: 131 | if suffix_split not in value: 132 | if suffix_split_fuzzy: 133 | value = value.strip() 134 | else: 135 | continue 136 | else: 137 | value = value.strip().split(suffix_split)[0] 138 | 139 | if wrap_tag: 140 | value = wrap_tag(tag, self.config) 141 | else: 142 | value = util.to_type(value.strip()) 143 | 144 | if parse_date and isinstance(value, str): 145 | try: 146 | value = parser.parse(value, fuzzy=True).date() 147 | except ValueError: 148 | value = None 149 | break 150 | if value: 151 | break 152 | 153 | if value is None and required: 154 | raise AmazonOrdersEntityError( 155 | "When building {name}, field for selector `{selector}` was None, but this is not allowed.".format( 156 | name=self.__class__.__name__, 157 | selector=selector)) 158 | 159 | return value 160 | 161 | def safe_simple_parse(self, 162 | selector: Union[str, list], 163 | **kwargs: Any) -> Any: 164 | """ 165 | A helper function that uses :func:`simple_parse` as the ``parse_function()`` passed to :func:`safe_parse`. 166 | 167 | :param selector: The CSS selector to pass to :func:`simple_parse`. 168 | :param kwargs: The ``kwargs`` will be passed to ``parse_function``. 169 | :return: The return value from :func:`simple_parse`. 170 | """ 171 | return self.safe_parse(self.simple_parse, selector=selector, **kwargs) 172 | 173 | def with_base_url(self, 174 | url: str) -> str: 175 | """ 176 | If the given URL is relative, the ``BASE_URL`` will be prepended. 177 | 178 | :param url: The URL to check. 179 | :return: The fully qualified URL. 180 | """ 181 | if not url.startswith("http"): 182 | url = f"{self.config.constants.BASE_URL}{url}" 183 | return url 184 | 185 | def to_currency(self, 186 | value: Union[str, int, float]) -> Union[int, float, None]: 187 | """ 188 | Clean up a currency, stripping non-numeric values and returning it as a primitive. 189 | 190 | :param value: The currency to parse. 191 | :return: The currency as a primitive. 192 | """ 193 | if isinstance(value, (int, float)): 194 | return value 195 | 196 | if not value: 197 | return None 198 | 199 | value = value.strip() 200 | value = re.sub("[a-zA-Z$£€,]+", "", value) 201 | currency = util.to_type(value) 202 | 203 | if isinstance(currency, str): 204 | return None 205 | 206 | return currency 207 | -------------------------------------------------------------------------------- /docs/troubleshooting.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Troubleshooting 3 | =============== 4 | 5 | Enable Debug Mode 6 | ----------------- 7 | 8 | Enabling ``debug`` mode does a few main things: 9 | 10 | 1. Sets ``logger`` levels to ``DEBUG`` 11 | 2. Sends ``logger`` output to ``stderr``, so you'll see it on the console when using the CLI 12 | 3. HTML pages will be be saved locally 13 | 14 | To enable ``debug`` mode in Python, you need to pass ``debug=True`` to 15 | :class:`~amazonorders.session.AmazonSession`: 16 | 17 | .. code-block:: python 18 | 19 | from amazonorders.session import AmazonSession 20 | from amazonorders.orders import AmazonOrders 21 | 22 | amazon_session = AmazonSession("", 23 | "", 24 | debug=True) 25 | amazon_session.login() 26 | 27 | amazon_orders = AmazonOrders(amazon_session) 28 | orders = amazon_orders.get_order_history() 29 | 30 | To enable ``debug`` mode when using the CLI, pass the ``--debug`` flag, which works with any 31 | command: 32 | 33 | .. code-block:: shell 34 | 35 | amazon-orders --debug history 36 | 37 | Integrating with Amazon.com via scraping is complicated and requires ``form`` data from the 38 | website's HTML. Before submitting a bug report or requesting a new feature, try running 39 | ``amazon-orders`` one of the ways described above, and if any console output or generated HTML 40 | files are relevant to the issue, attach them to your request. 41 | 42 | Captcha Blocking Login 43 | ---------------------- 44 | 45 | While ``amazon-orders`` can auto-solve some Captchas, others are opened so the user can input the solution manually, 46 | and still other—like `Amazon's puzzle-based WAF Captchas `_ 47 | —require JavaScript and can't be solved at all by ``amazon-orders`` and block it from being able to log in (see `issue #45 `_ 48 | for more details). 49 | 50 | To attempt to fully automate login, you need to minimize how often (if at all) you are presented with Captcha 51 | challenges. There is no perfect workaround to this, as when and how challenges are presented is at the discretion of 52 | Amazon, but there are at least a few ways you can try to reduce the likelihood you will be presented with 53 | Captcha challenges: 54 | 55 | - Ensure credentials are correct. Too many failed login attempts in a short period of time increases the chances of 56 | being given a Captcha challenge. Persisting authentication in the config or the environment (see `docs `_) 57 | can help ensure passwords are never entered incorrectly. 58 | - Wait several hours (sometimes over a day is necessary) before trying again. 59 | - Enable 2FA. One-time password (OTP) challenges during login can be auto-solved with :attr:`~amazonorders.session.AmazonSession.otp_secret_key`, 60 | and having this security measure enabled seems to reduce the frequency of Captcha challenges. 61 | - Use a browser to manually solve the Captcha, as doing so may reduce the likelihood Amazon will continue to give you 62 | Captcha challenges from ``amazon-orders``. 63 | 64 | - In your browser, visit https://www.amazon.com/errors/validateCaptcha to solve a Captcha challenge while logged 65 | in to your account. 66 | 67 | - Logout of your account, then log back in. Amazon may present you with a different Captcha flow in this case, 68 | which may be the one that needs to be solved for them to stop prompting you. 69 | 70 | - Captcha challenges are more often presented to unknown devices. If possible, first manually login from a browser on 71 | the device on which you're using ``amazon-orders``. 72 | 73 | Slow Parsing / Malformed Data 74 | ----------------------------- 75 | 76 | By default, ``amazon-orders`` uses ``html.parser``, Python's `built-in HTML parser `_. 77 | There are some situations where this parsers is not preferred, either because it is slower than other options, or in 78 | some cases it leads to parsing issues, where fields like ``title``, ``currency``, etc. are populated with mangled data. 79 | ``amazon-orders`` should work with any `BeautifulSoup-compatible HTML parser `_, 80 | and many prefer to use `lxml `_ instead. If another parser is installed, you can change 81 | the parser ``amazon-orders`` will use with ``AmazonOrdersConfig.bs4_parser``. 82 | 83 | Concurrency Workers Exhausted 84 | ----------------------------- 85 | 86 | If you see this or similar errors, you may need to tweak ``AmazonOrdersConfig.thread_pool_size``. Increasing it is the 87 | likely solution, but doing so may also have an effect on how many active URL connections to Amazon can be executed at 88 | any given time, so adjusting both may be necessary. See also `URL Connection Pool Full`_. 89 | 90 | URL Connection Pool Full 91 | ------------------------ 92 | 93 | .. code-block:: sh 94 | 95 | WARNING:requests.packages.urllib3.connectionpool:HttpConnectionPool 96 | is full, discarding connection: 97 | 98 | If you see this or similar errors, you may need to tweak ``AmazonOrdersConfig.connection_pool_size``. Increasing it is 99 | the likely solution, but the issue may also be linked to the number of async tasks being executed at a given time. 100 | Adjusting one or both of these values up or down may be necessary to find the correct threshold. See also 101 | `Concurrency Workers Exhausted`_. 102 | 103 | Broken After Previously Working 104 | ------------------------------- 105 | 106 | If you have successfully integrated with ``amazon-orders``, and an existing script or CLI 107 | command stops working, a likely cause is that something changed on an associated Amazon.com page. 108 | This could be that Amazon changed the layout of a page, renamed or refactored a field, or 109 | something else. 110 | 111 | To see what the effected page looks like, `enable debug mode`_, then rerun your code. Running in 112 | ``debug`` mode will save parsed HTML files locally for you so that you can inspect the DOM and compare it to 113 | the parsing code within ``amazon-orders``. This may give you some insight in to what changed. 114 | In ``amazon-orders``, look for code that uses `BeautifulSoup's CSS select() methods `_. 115 | 116 | More advanced troubleshooting can be done by extending :class:`~amazonorders.selectors.Selectors` and 117 | :class:`~amazonorders.constants.Constants`, for instance to try different CSS selectors for parsing a field. When these 118 | classes are extended, use ``AmazonOrdersConfig.selectors_class`` and ``AmazonOrdersConfig.constants_class`` to provide 119 | override classes. 120 | 121 | If you identify the issue, please `submit a bug report `_. 122 | If you're able to resolve the issue, please `also submit a PR `_ 123 | so others can benefit. 124 | 125 | Found an Unknown Page in Auth Flow 126 | ---------------------------------- 127 | 128 | If you get an error during :func:`~amazonorders.session.AmazonSession.login()` saying you've encountered an unknown 129 | page, you've found a page in the login flow that we haven't. Amazon has many different routes through 130 | authentication, and is always adding more, so this is bound to happen. Hopefully you'll consider implementing the 131 | solution to this auth flow and contributing it back to the repo. 132 | 133 | Auth forms are actually relatively simple to implement. To get started investigating, `enable debug mode`_, then try 134 | to login again. With ``debug`` mode enabled, the new page will be saved locally as an HTML file that you can open 135 | and inspect. 136 | 137 | Have a look at the HTML source of the new page, specifically the ``
`` tag, and look in ``forms.py`` to see how 138 | other auth forms are implemented. You'll need to create a new class that inherits from 139 | :class:`~amazonorders.forms.AuthForm`, override :func:`~amazonorders.forms.AuthForm.fill_form`, and manually pass 140 | a list to :func:`~amazonorders.session.AmazonSession.auth_forms` that includes the new form. 141 | 142 | Once you've implemented and tested the new form, `submit a PR `_ 143 | for maximum karma! 144 | 145 | Found a Missing / Broken Field on an Entity 146 | ------------------------------------------- 147 | 148 | If you find that a useful field on an entity (for instance, an :class:`~amazonorders.entity.order.Order` or an 149 | :class:`~amazonorders.entity.item.Item`) is missing (or one that exists isn't being populated for you), consider 150 | contributing it! Fields are populated by simple ``_parse()`` methods on the entity, and many fields are able to 151 | utilize :class:`~amazonorders.entity.parsable.Parsable`'s :func:`~amazonorders.entity.parsable.Parsable.simple_parse` 152 | function, which just needs a selector. 153 | 154 | If you can't fetch the field's value with just a selector, implementing a new ``_parse()`` function on the 155 | entity will give you a lot more flexibility. 156 | 157 | Once you've implemented and tested the new field, `submit a PR `_! 158 | -------------------------------------------------------------------------------- /amazonorders/orders.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2024-2025 Alex Laird" 2 | __license__ = "MIT" 3 | 4 | import asyncio 5 | import concurrent.futures 6 | import datetime 7 | import logging 8 | from typing import Any, Callable, List, Optional 9 | 10 | from bs4 import Tag 11 | 12 | from amazonorders import util 13 | from amazonorders.conf import AmazonOrdersConfig 14 | from amazonorders.entity.order import Order 15 | from amazonorders.exception import AmazonOrdersError, AmazonOrdersNotFoundError 16 | from amazonorders.session import AmazonSession 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class AmazonOrders: 22 | """ 23 | Using an authenticated :class:`~amazonorders.session.AmazonSession`, can be used to query Amazon 24 | for Order details and history. 25 | """ 26 | 27 | def __init__(self, 28 | amazon_session: AmazonSession, 29 | debug: Optional[bool] = None, 30 | config: Optional[AmazonOrdersConfig] = None) -> None: 31 | if not debug: 32 | debug = amazon_session.debug 33 | if not config: 34 | config = amazon_session.config 35 | 36 | #: The session to use for requests. 37 | self.amazon_session: AmazonSession = amazon_session 38 | #: The config to use. 39 | self.config: AmazonOrdersConfig = config 40 | 41 | #: Setting logger to ``DEBUG`` will send output to ``stderr``. 42 | self.debug: bool = debug 43 | if self.debug: 44 | logger.setLevel(logging.DEBUG) 45 | 46 | def get_order(self, 47 | order_id: str, 48 | clone: Optional[Order] = None) -> Order: 49 | """ 50 | Get the full details for a given Amazon Order ID. 51 | 52 | :param order_id: The Amazon Order ID to lookup. 53 | :param clone: If a partially populated version of the Order has already been fetched from history. 54 | :return: The requested Order. 55 | """ 56 | if not self.amazon_session.is_authenticated: 57 | raise AmazonOrdersError("Call AmazonSession.login() to authenticate first.") 58 | 59 | meta = {"index": clone.index} if clone else None 60 | 61 | order_details_response = self.amazon_session.get( 62 | f"{self.config.constants.ORDER_DETAILS_URL}?orderID={order_id}") 63 | self.amazon_session.check_response(order_details_response, meta=meta) 64 | 65 | if not order_details_response.response.url.startswith(self.config.constants.ORDER_DETAILS_URL): 66 | raise AmazonOrdersNotFoundError(f"Amazon redirected, which likely means Order {order_id} was not found.", 67 | meta=meta) 68 | 69 | order_details_tag = util.select_one(order_details_response.parsed, 70 | self.config.selectors.ORDER_DETAILS_ENTITY_SELECTOR) 71 | 72 | if not order_details_tag: 73 | raise AmazonOrdersError(f"Could not parse details for Order {order_id}. Check if Amazon changed the HTML.") 74 | 75 | order: Order = self.config.order_cls(order_details_tag, self.config, full_details=True, clone=clone) 76 | 77 | return order 78 | 79 | def get_order_history(self, 80 | year: Optional[int] = None, 81 | start_index: Optional[int] = None, 82 | full_details: bool = False, 83 | keep_paging: bool = True, 84 | time_filter: Optional[str] = None) -> List[Order]: 85 | """ 86 | Get the Amazon Order history for a given time period. 87 | 88 | :param year: The year for which to get history. Ignored if ``time_filter`` is provided. 89 | Defaults to the current year if neither ``year`` nor ``time_filter`` is specified. 90 | :param start_index: The index of the Order from which to start fetching in the history. See 91 | :attr:`~amazonorders.entity.order.Order.index` to correlate, or if a call to this method previously errored 92 | out, see ``index`` in the exception's :attr:`~amazonorders.exception.AmazonOrdersError.meta` to continue 93 | paging where it left off. 94 | :param full_details: Get the full details for each Order in the history. This will execute an additional 95 | request per Order. 96 | :param keep_paging: ``False`` if only one page should be fetched. 97 | :param time_filter: The time filter to use. Supported values are ``"last30"`` (last 30 days), 98 | ``"months-3"`` (past 3 months), or ``"year-YYYY"`` (specific year). If provided, this takes 99 | precedence over the ``year`` parameter. 100 | :return: A list of the requested Orders. 101 | """ 102 | if not self.amazon_session.is_authenticated: 103 | raise AmazonOrdersError("Call AmazonSession.login() to authenticate first.") 104 | 105 | if time_filter and year: 106 | raise AmazonOrdersError("Only one of 'year' or 'time_filter' may be used at a time.") 107 | 108 | # Determine the filter value to use 109 | if time_filter: 110 | # Validate time_filter value 111 | valid_filters = ["last30", "months-3"] 112 | is_year_filter = time_filter.startswith("year-") and time_filter[5:].isdigit() 113 | if time_filter not in valid_filters and not is_year_filter: 114 | raise AmazonOrdersError( 115 | f"Invalid time_filter '{time_filter}'. " 116 | f"Valid values are 'last30', 'months-3', or 'year-YYYY'." 117 | ) 118 | filter_value = time_filter 119 | else: 120 | if year is None: 121 | year = datetime.date.today().year 122 | filter_value = f"year-{year}" 123 | 124 | optional_start_index = f"&startIndex={start_index}" if start_index else "" 125 | next_page: Optional[str] = ( 126 | "{url}?{query_param}={filter_value}{optional_start_index}" 127 | ).format( 128 | url=self.config.constants.ORDER_HISTORY_URL, 129 | query_param=self.config.constants.HISTORY_FILTER_QUERY_PARAM, 130 | filter_value=filter_value, 131 | optional_start_index=optional_start_index 132 | ) 133 | 134 | current_index = int(start_index) if start_index else 0 135 | 136 | return asyncio.run(self._build_orders_async(next_page, keep_paging, full_details, current_index)) 137 | 138 | async def _build_orders_async(self, 139 | next_page: Optional[str], 140 | keep_paging: bool, 141 | full_details: bool, 142 | current_index: int) -> List[Order]: 143 | order_tasks = [] 144 | 145 | while next_page: 146 | page_response = self.amazon_session.get(next_page) 147 | self.amazon_session.check_response(page_response, meta={"index": current_index}) 148 | 149 | order_tags = util.select(page_response.parsed, 150 | self.config.selectors.ORDER_HISTORY_ENTITY_SELECTOR) 151 | 152 | if not order_tags: 153 | order_count_tag = util.select_one(page_response.parsed, 154 | self.config.selectors.ORDER_HISTORY_COUNT_SELECTOR) 155 | (order_count, _) = order_count_tag.text.split(" ", 2) if order_count_tag else ("0", None) 156 | 157 | if order_count_tag and int(order_count) <= current_index: 158 | break 159 | else: 160 | raise AmazonOrdersError("Could not parse Order history. Check if Amazon changed the HTML.") 161 | 162 | for order_tag in order_tags: 163 | order_tasks.append(self._async_wrapper(self._build_order, order_tag, full_details, current_index)) 164 | 165 | current_index += 1 166 | 167 | next_page = None 168 | if keep_paging: 169 | next_page_tag = util.select_one(page_response.parsed, 170 | self.config.selectors.NEXT_PAGE_LINK_SELECTOR) 171 | if next_page_tag: 172 | next_page = str(next_page_tag["href"]) 173 | if not next_page.startswith("http"): 174 | next_page = f"{self.config.constants.BASE_URL}{next_page}" 175 | else: 176 | logger.debug("No next page") 177 | else: 178 | logger.debug("keep_paging is False, not paging") 179 | 180 | return await asyncio.gather(*order_tasks) 181 | 182 | def _build_order(self, 183 | order_tag: List[Tag], 184 | full_details: bool, 185 | current_index: int) -> Order: 186 | order: Order = self.config.order_cls(order_tag, self.config, index=current_index) 187 | 188 | if full_details: 189 | if len(util.select(order.parsed, self.config.selectors.ORDER_SKIP_ITEMS)) > 0: 190 | logger.warning(f"Order {order.order_number} was partially populated, " 191 | f"since it is an unsupported Order type.") 192 | else: 193 | order = self.get_order(order.order_number, clone=order) 194 | 195 | return order 196 | 197 | async def _async_wrapper(self, 198 | func: Callable, 199 | *args: Any) -> Order: 200 | loop = asyncio.get_running_loop() 201 | with concurrent.futures.ThreadPoolExecutor(max_workers=self.config.thread_pool_size) as pool: 202 | result = await loop.run_in_executor(pool, func, *args) 203 | return result 204 | --------------------------------------------------------------------------------