├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml └── src ├── actions ├── __init__.py ├── inventory_monitoring.py └── order.py ├── common ├── __init__.py └── schemas.py ├── libs ├── __init__.py ├── address.py ├── notifications.py ├── payments.py ├── products.py └── requests.py ├── main.py └── statics └── payments └── cn.json /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | push_to_registry: 11 | name: Push Docker image to Docker Hub 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repo 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3 22 | 23 | - name: Log in to Docker Hub 24 | uses: docker/login-action@v3 25 | with: 26 | username: ${{ secrets.DOCKER_USERNAME }} 27 | password: ${{ secrets.DOCKER_PASSWORD }} 28 | 29 | - name: Extract metadata (tags, labels) for Docker 30 | id: meta 31 | uses: docker/metadata-action@v5 32 | with: 33 | images: toolgallery/ape-store-assistant 34 | 35 | - name: Build and push Docker image 36 | uses: docker/build-push-action@v5 37 | with: 38 | context: . 39 | file: ./Dockerfile 40 | platforms: linux/amd64,linux/arm64 41 | push: true 42 | tags: ${{ steps.meta.outputs.tags }} 43 | labels: ${{ steps.meta.outputs.labels }} 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim as builder 2 | 3 | ADD poetry.lock pyproject.toml ./ 4 | 5 | RUN pip install --no-build-isolation poetry 6 | RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi 7 | 8 | 9 | 10 | FROM python:3.10-slim 11 | 12 | COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages 13 | 14 | WORKDIR /app 15 | 16 | ADD src . 17 | 18 | ENTRYPOINT ["python", "main.py"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # APEStoreAssistant 2 | 3 | Reduce the waiting time 4 | 5 | ## Features 6 | 7 | - [x] Query product list 8 | - [x] Query address list 9 | - [x] Monitor inventory for multiple products 10 | - [x] Support multiple countries 11 | - [x] Notification support (Dingtalk | Bark | Feishu) 12 | - [x] Automatic order placement 13 | 14 | ## Usage 15 | 16 | ### Use with Docker 17 | 18 | #### Supported options 19 | 20 | ```shell 21 | docker run --rm toolgallery/ape-store-assistant:main -h 22 | ``` 23 | 24 | ``` 25 | -p, --products PRODUCTS 26 | -l, --location LOCATION 27 | -pc, --postal-code POSTAL_CODE 28 | --state STATE 29 | -lp, --list-products 30 | -c COUNTRY, --country COUNTRY cn|hk-zh|sg|jp 31 | --code CODE 15|15-pro 32 | -i, --interval default:5 Query interval 33 | --ac-type iphone14|iphone14promax|iphone14plus 34 | iphone14 for iPhone15/iPhone15 Pro, iphone14promax for iPhone15 Pro Max, iphone14plus for iPhone15 Plus 35 | --ac-product AC+ Product 36 | ``` 37 | 38 | #### Query products 39 | 40 | ```shell 41 | docker run --rm toolgallery/ape-store-assistant:main -lp -c sg --code 15-pro 42 | ``` 43 | 44 | #### Start monitoring 45 | 46 | ```shell 47 | docker run --rm toolgallery/ape-store-assistant:main -c sg -p MTV13ZP/A MTV73ZP/A -l 329816 48 | 49 | # message notification through dingtalk 50 | docker run -e DINGTALK_TOKEN=yourtoken --rm toolgallery/ape-store-assistant:main -c sg -p MTV13ZP/A MTV73ZP/A -l 329816 51 | 52 | # through bark, support both 53 | docker run -e BARK_TOKEN=yourtoken --rm toolgallery/ape-store-assistant:main -c sg -p MTV13ZP/A MTV73ZP/A -l 329816 54 | ``` 55 | 56 | #### Query address 57 | Only supports certain countries. 58 | 59 | ```shell 60 | docker run --rm toolgallery/ape-store-assistant:main -la -c jp 61 | 62 | # continue filter 63 | docker run --rm toolgallery/ape-store-assistant:main -la -c jp -ft 青森県 64 | docker run --rm toolgallery/ape-store-assistant:main -la -c jp -ft "青森県 山形県" 65 | ``` 66 | 67 | #### Query payment methods 68 | Only supports certain countries. 69 | 70 | ```shell 71 | docker run --rm toolgallery/ape-store-assistant:main -lpa -c cn 72 | ``` 73 | 74 | #### Automatic ordering 75 | Only supports certain countries. 76 | 77 | - Only supports a single model. 78 | - Automatically select the nearest pickup time slot. 79 | - After successfully placing an order, please check your email for the order information. 80 | 81 | ```shell 82 | docker run --rm toolgallery/ape-store-assistant:main -c cn -p MPVG3CH/A -l "your location" -o -onc -1 --code 14 83 | 84 | # -o Enable order support 85 | # -onc The number of order notification reminders, effective after the order is successful, -1 means no limit. 86 | # --code Product model code // remove in the future. 87 | 88 | # The following environment variables must be provided. 89 | DELIVERY_FIRST_NAME 90 | DELIVERY_LAST_NAME 91 | DELIVERY_EMAIL 92 | DELIVERY_PHONE 93 | DELIVERY_IDCARD # Last 4 digits of ID card 94 | DELIVERY_PAYMENT # Payment method, check through -lpa, such as installments0001321713 95 | DELIVERY_PAYMENT_NUMBER # The number of installments, regular payment is 0. 96 | ``` 97 | 98 | 99 | ### Supported environment variables 100 | 101 | ```shell 102 | # dingtalk notification 103 | DINGTALK_TOKEN 104 | # bark notification 105 | BARK_HOST 106 | BARK_TOKEN 107 | # feishu notification 108 | FEISHU_TOKEN 109 | ``` 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "black" 3 | version = "23.9.1" 4 | description = "The uncompromising code formatter." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=3.8" 8 | 9 | [package.dependencies] 10 | click = ">=8.0.0" 11 | mypy-extensions = ">=0.4.3" 12 | packaging = ">=22.0" 13 | pathspec = ">=0.9.0" 14 | platformdirs = ">=2" 15 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 16 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 17 | 18 | [package.extras] 19 | colorama = ["colorama (>=0.4.3)"] 20 | d = ["aiohttp (>=3.7.4)"] 21 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 22 | uvloop = ["uvloop (>=0.15.2)"] 23 | 24 | [[package]] 25 | name = "certifi" 26 | version = "2023.7.22" 27 | description = "Python package for providing Mozilla's CA Bundle." 28 | category = "main" 29 | optional = false 30 | python-versions = ">=3.6" 31 | 32 | [[package]] 33 | name = "charset-normalizer" 34 | version = "3.2.0" 35 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 36 | category = "main" 37 | optional = false 38 | python-versions = ">=3.7.0" 39 | 40 | [[package]] 41 | name = "click" 42 | version = "8.1.7" 43 | description = "Composable command line interface toolkit" 44 | category = "dev" 45 | optional = false 46 | python-versions = ">=3.7" 47 | 48 | [package.dependencies] 49 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 50 | 51 | [[package]] 52 | name = "colorama" 53 | version = "0.4.6" 54 | description = "Cross-platform colored terminal text." 55 | category = "dev" 56 | optional = false 57 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 58 | 59 | [[package]] 60 | name = "idna" 61 | version = "3.4" 62 | description = "Internationalized Domain Names in Applications (IDNA)" 63 | category = "main" 64 | optional = false 65 | python-versions = ">=3.5" 66 | 67 | [[package]] 68 | name = "mypy-extensions" 69 | version = "1.0.0" 70 | description = "Type system extensions for programs checked with the mypy type checker." 71 | category = "dev" 72 | optional = false 73 | python-versions = ">=3.5" 74 | 75 | [[package]] 76 | name = "packaging" 77 | version = "23.1" 78 | description = "Core utilities for Python packages" 79 | category = "dev" 80 | optional = false 81 | python-versions = ">=3.7" 82 | 83 | [[package]] 84 | name = "pathspec" 85 | version = "0.11.2" 86 | description = "Utility library for gitignore style pattern matching of file paths." 87 | category = "dev" 88 | optional = false 89 | python-versions = ">=3.7" 90 | 91 | [[package]] 92 | name = "platformdirs" 93 | version = "3.10.0" 94 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 95 | category = "dev" 96 | optional = false 97 | python-versions = ">=3.7" 98 | 99 | [package.extras] 100 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 101 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 102 | 103 | [[package]] 104 | name = "requests" 105 | version = "2.31.0" 106 | description = "Python HTTP for Humans." 107 | category = "main" 108 | optional = false 109 | python-versions = ">=3.7" 110 | 111 | [package.dependencies] 112 | certifi = ">=2017.4.17" 113 | charset-normalizer = ">=2,<4" 114 | idna = ">=2.5,<4" 115 | urllib3 = ">=1.21.1,<3" 116 | 117 | [package.extras] 118 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 119 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 120 | 121 | [[package]] 122 | name = "tomli" 123 | version = "2.0.1" 124 | description = "A lil' TOML parser" 125 | category = "dev" 126 | optional = false 127 | python-versions = ">=3.7" 128 | 129 | [[package]] 130 | name = "typing-extensions" 131 | version = "4.8.0" 132 | description = "Backported and Experimental Type Hints for Python 3.8+" 133 | category = "dev" 134 | optional = false 135 | python-versions = ">=3.8" 136 | 137 | [[package]] 138 | name = "urllib3" 139 | version = "2.0.4" 140 | description = "HTTP library with thread-safe connection pooling, file post, and more." 141 | category = "main" 142 | optional = false 143 | python-versions = ">=3.7" 144 | 145 | [package.extras] 146 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 147 | secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] 148 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 149 | zstd = ["zstandard (>=0.18.0)"] 150 | 151 | [metadata] 152 | lock-version = "1.1" 153 | python-versions = "^3.10" 154 | content-hash = "d8fbf75b6526cbc461957404196555d394c677d14eac4f952d76ddd3e1b3e16b" 155 | 156 | [metadata.files] 157 | black = [ 158 | {file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"}, 159 | {file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"}, 160 | {file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"}, 161 | {file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"}, 162 | {file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"}, 163 | {file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"}, 164 | {file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"}, 165 | {file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"}, 166 | {file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"}, 167 | {file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"}, 168 | {file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"}, 169 | {file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"}, 170 | {file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"}, 171 | {file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"}, 172 | {file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"}, 173 | {file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"}, 174 | {file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"}, 175 | {file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"}, 176 | {file = "black-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9"}, 177 | {file = "black-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f"}, 178 | {file = "black-23.9.1-py3-none-any.whl", hash = "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9"}, 179 | {file = "black-23.9.1.tar.gz", hash = "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d"}, 180 | ] 181 | certifi = [ 182 | {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, 183 | {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, 184 | ] 185 | charset-normalizer = [ 186 | {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, 187 | {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, 188 | {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, 189 | {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, 190 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, 191 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, 192 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, 193 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, 194 | {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, 195 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, 196 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, 197 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, 198 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, 199 | {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, 200 | {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, 201 | {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, 202 | {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, 203 | {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, 204 | {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, 205 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, 206 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, 207 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, 208 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, 209 | {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, 210 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, 211 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, 212 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, 213 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, 214 | {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, 215 | {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, 216 | {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, 217 | {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, 218 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, 219 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, 220 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, 221 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, 222 | {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, 223 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, 224 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, 225 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, 226 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, 227 | {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, 228 | {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, 229 | {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, 230 | {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, 231 | {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, 232 | {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, 233 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, 234 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, 235 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, 236 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, 237 | {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, 238 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, 239 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, 240 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, 241 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, 242 | {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, 243 | {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, 244 | {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, 245 | {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, 246 | {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, 247 | {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, 248 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, 249 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, 250 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, 251 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, 252 | {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, 253 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, 254 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, 255 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, 256 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, 257 | {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, 258 | {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, 259 | {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, 260 | {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, 261 | ] 262 | click = [ 263 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 264 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 265 | ] 266 | colorama = [ 267 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 268 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 269 | ] 270 | idna = [ 271 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 272 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 273 | ] 274 | mypy-extensions = [ 275 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 276 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 277 | ] 278 | packaging = [ 279 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 280 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 281 | ] 282 | pathspec = [ 283 | {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, 284 | {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, 285 | ] 286 | platformdirs = [ 287 | {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, 288 | {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, 289 | ] 290 | requests = [ 291 | {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, 292 | {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, 293 | ] 294 | tomli = [ 295 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 296 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 297 | ] 298 | typing-extensions = [ 299 | {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, 300 | {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, 301 | ] 302 | urllib3 = [ 303 | {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, 304 | {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, 305 | ] 306 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "apestoreassistant" 3 | version = "0.1.0" 4 | license = "Apache-2.0" 5 | description = "" 6 | authors = ["Idom"] 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | requests = "^2.31.0" 12 | 13 | 14 | [tool.poetry.group.dev.dependencies] 15 | black = "^23.9.1" 16 | 17 | [build-system] 18 | requires = ["poetry-core"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /src/actions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ToolGallery/APEStoreAssistant/79d86a13acb6ff30942774f9b5a8e9fe100316b8/src/actions/__init__.py -------------------------------------------------------------------------------- /src/actions/inventory_monitoring.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import time 4 | from enum import Enum 5 | from typing import Optional 6 | 7 | from actions.order import OrderSessionPool 8 | from common.schemas import DeliverySchema, ShopSchema, OrderSchema, OrderDeliverySchema 9 | from libs.notifications import NotificationBase 10 | from libs.requests import Request 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | apple_api_host = "https://www.apple.com" 15 | 16 | 17 | class DeliveryStatusEnum(str, Enum): 18 | AVAILABLE = "available" 19 | INELIGIBLE = "ineligible" 20 | 21 | 22 | class InventoryMonitor(object): 23 | def __init__(self) -> None: 24 | super().__init__() 25 | self.session = Request(apple_api_host) 26 | self.is_stop = False 27 | self.order_pool: Optional[OrderSessionPool] = None 28 | 29 | def start( 30 | self, 31 | shop_data: ShopSchema, 32 | order: bool = False, 33 | delivery_data: Optional[OrderDeliverySchema] = None, 34 | notification_providers: Optional[list[NotificationBase]] = None, 35 | interval: int = 5, 36 | order_notice_count: int = 1, 37 | ac_type : str = "", 38 | ac_model : str = "" 39 | ): 40 | logger.info(f"Start monitoring, query interval: {interval}s") 41 | order_data: Optional[OrderSchema] = None 42 | if order: 43 | # only support one product 44 | # fixme Is there a better way to obtain the model code? 45 | order_data = OrderSchema( 46 | model=shop_data.models[0], 47 | model_code=shop_data.code, 48 | country=shop_data.country, 49 | ac_type=ac_type, 50 | ac_model=ac_model 51 | ) 52 | self.enable_order(order_data) 53 | 54 | ignore_wait = False 55 | while not self.is_stop: 56 | try: 57 | ignore_wait = False 58 | inventory_data = self.get_data( 59 | shop_data.country, 60 | shop_data.models, 61 | shop_data.location, 62 | ) 63 | pickup_lists = self.parse_data(inventory_data) 64 | pickup_lists = [i for i in pickup_lists if not shop_data.store_filters or any( 65 | [ 66 | True 67 | for ii in shop_data.store_filters 68 | if ii in i.store_name 69 | ] 70 | )] 71 | 72 | if not pickup_lists: 73 | logger.warning("No available stores found") 74 | time.sleep(interval) 75 | continue 76 | 77 | for pickup in pickup_lists: 78 | logger.info(pickup.intro()) 79 | 80 | available_lists = [i for i in pickup_lists if i.status == DeliveryStatusEnum.AVAILABLE] 81 | if available_lists and notification_providers: 82 | self.push_notifications(available_lists, notification_providers) 83 | 84 | if available_lists and order: 85 | for pickup in available_lists: 86 | order_data.store_number = pickup.store_number 87 | order_data.state = pickup.state 88 | order_data.city = pickup.city 89 | order_data.district = pickup.district 90 | order_data.delivery = delivery_data 91 | 92 | order_result = self.start_order( 93 | order_data, 94 | notification_providers, 95 | notice_count=order_notice_count, 96 | ) 97 | if order_result is False: 98 | ignore_wait = True 99 | 100 | except Exception as e: 101 | logging.exception( 102 | "Failed to retrieve inventory data with error: ", exc_info=e 103 | ) 104 | ignore_wait = True 105 | if not ignore_wait: 106 | time.sleep(interval) 107 | 108 | def enable_order(self, data: OrderSchema): 109 | self.order_pool = OrderSessionPool() 110 | self.order_pool.start(data) 111 | 112 | def start_order( 113 | self, 114 | data: OrderSchema, 115 | notification_providers: list[NotificationBase], 116 | notice_count: int, 117 | ): 118 | order_obj = self.order_pool.get() 119 | order_result = order_obj.start_order(data) 120 | if order_result: 121 | for provider in notification_providers: 122 | title, content = ( 123 | "Order success notification", 124 | "Check your email for detailed information.", 125 | ) 126 | provider.repeat_push(title, content, max_count=notice_count) 127 | logger.info( 128 | "The order has been successfully placed, and the program will automatically exit." 129 | ) 130 | self.stop() 131 | return order_result 132 | 133 | def push_notifications( 134 | self, pickup_lists: list[DeliverySchema], providers: list[NotificationBase] 135 | ): 136 | title = "Apple inventory notification" 137 | buffers = [] 138 | for pickup in pickup_lists: 139 | buffers.append(pickup.intro()) 140 | 141 | if not buffers: 142 | return 143 | 144 | for provider in providers: 145 | try: 146 | provider.push( 147 | title, 148 | "\r\n".join(buffers), 149 | key=f"inventory_monitor_{provider.name}", 150 | min_interval=60, 151 | ) 152 | except Exception as e: 153 | logging.exception( 154 | "Inventory information push failed with error: ", exc_info=e 155 | ) 156 | 157 | def get_data( 158 | self, 159 | country: str, 160 | models: list[str], 161 | location: str = "", 162 | postal_code: str = "", 163 | state: str = "", 164 | ): 165 | parts = {f"parts.{idx}": i for idx, i in enumerate(models)} 166 | search_params = { 167 | "searchNearby": "true", 168 | "pl": "true", 169 | "mts.0": "regular", 170 | "mts.1": "compact", 171 | } | parts 172 | if location: 173 | search_params["location"] = location 174 | if postal_code: 175 | search_params["postalCode"] = postal_code 176 | if state: 177 | search_params["state"] = state 178 | 179 | resp = self.session.get( 180 | f"/{country}/shop/fulfillment-messages", params=search_params 181 | ) 182 | 183 | return resp.json() 184 | 185 | def parse_data(self, data: dict): 186 | pickup_message = data["body"]["content"]["pickupMessage"] 187 | if not pickup_message.get("stores"): 188 | logger.error("No stores found") 189 | return [] 190 | deliveries = [] 191 | for store in pickup_message["stores"]: 192 | parts = store["partsAvailability"].values() 193 | for part in parts: 194 | address = store["retailStore"]["address"] 195 | model_name = part["messageTypes"]["regular"][ 196 | "storePickupProductTitle" 197 | ].replace("\xa0", " ") 198 | deliveries.append( 199 | DeliverySchema( 200 | state=address["state"], 201 | city=address["city"], 202 | district=address["district"], 203 | store_name=store["storeName"], 204 | store_number=store["storeNumber"], 205 | model_name=model_name, 206 | pickup_quote=part["pickupSearchQuote"], 207 | model=part["partNumber"], 208 | status=part["pickupDisplay"], 209 | pickup_type=part["pickupType"], 210 | ) 211 | ) 212 | 213 | return deliveries 214 | 215 | def stop(self): 216 | self.is_stop = True 217 | self.order_pool and self.order_pool.stop() 218 | sys.exit(0) 219 | -------------------------------------------------------------------------------- /src/actions/order.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import json 3 | import logging 4 | import random 5 | import re 6 | import threading 7 | import time 8 | from datetime import datetime 9 | from typing import Optional 10 | from urllib.parse import urlparse, parse_qsl, quote_plus 11 | 12 | from common.schemas import OrderSchema 13 | from libs.requests import Request 14 | 15 | apple_api_host = "https://www.apple.com" 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class Order(object): 21 | def __init__(self, country: str) -> None: 22 | super().__init__() 23 | # only support cn yet 24 | assert country == "cn", "Only support cn yet" 25 | api_host = apple_api_host + ".cn" 26 | self.session = Request( 27 | api_host, 28 | headers={ 29 | "Referer": f"https://www.apple.com/{country}/shop/bag", 30 | "Content-Type": "application/x-www-form-urlencoded", 31 | }, 32 | ) 33 | self.secure_host = "" 34 | 35 | def init_order(self, order_data: OrderSchema): 36 | self.add_to_cart(order_data.model, order_data.model_code, order_data.ac_type, order_data.ac_model) 37 | cart_item_id = self.get_cart_item_id() 38 | 39 | signin_url, signin_params, secure_api_host = self.start_checkout( 40 | cart_item_id, 41 | order_data.country, 42 | order_data.state, 43 | order_data.city, 44 | order_data.district, 45 | ) 46 | self.secure_host = secure_api_host 47 | 48 | self.get_page_with_meta(signin_url, None) 49 | 50 | signin_data = self.signin(signin_params) 51 | 52 | logger.debug("Access '/bg' page") 53 | self.get_page_with_meta(signin_data["head"]["data"]["url"], None) 54 | 55 | self.update_delivery_method() 56 | 57 | def start_order(self, order_data: OrderSchema): 58 | logger.info( 59 | f"Order starting with {order_data.model_code} {order_data.model} {order_data.state} {order_data.city}..." 60 | ) 61 | 62 | address_data = self.fill_address( 63 | order_data.store_number, 64 | order_data.country, 65 | order_data.state, 66 | order_data.city, 67 | order_data.district, 68 | ) 69 | 70 | selected_window = self.get_select_window(address_data) 71 | if not selected_window: 72 | return False 73 | 74 | self.fill_contact( 75 | selected_window, 76 | order_data.store_number, 77 | order_data.country, 78 | order_data.state, 79 | order_data.city, 80 | order_data.district, 81 | ) 82 | 83 | self.fill_recipient( 84 | order_data.delivery.first_name, 85 | order_data.delivery.last_name, 86 | order_data.delivery.email, 87 | order_data.delivery.phone, 88 | order_data.delivery.idcard, 89 | ) 90 | self.fill_pay_method( 91 | order_data.delivery.payment, order_data.delivery.payment_number 92 | ) 93 | self.finish_checkout() 94 | 95 | return True 96 | 97 | def get_cart_item_id(self): 98 | logger.info("Getting cart id...") 99 | page_data = self.get_page_with_meta("/shop/bag", None) 100 | item_id = page_data["shoppingCart"]["items"]["c"].pop() 101 | logger.debug(f"Cart item id: {item_id}") 102 | return item_id 103 | 104 | def add_to_cart(self, model_number: str, phone_model: str, ac_type: str, ac_model: str): 105 | logger.info("Adding to cart...") 106 | resp_atb = self.session.get("/shop/beacon/atb") 107 | atb_str: str = resp_atb.cookies.get("as_atb") 108 | atb_token = atb_str.split("|")[-1] 109 | 110 | params = { 111 | "product": model_number, 112 | "purchaseOption": "fullPrice", 113 | "step": "select", 114 | "ams": "0", 115 | "atbtoken": atb_token, 116 | "igt": "true", 117 | "add-to-cart": "add-to-cart", 118 | } 119 | 120 | if ac_type: 121 | key = "ao.add_" + ac_type +"_ac_iup" 122 | params[key] = ac_model 123 | 124 | resp = self.session.get( 125 | f"/shop/buy-iphone/iphone-{phone_model}/{model_number}#", 126 | params=params, 127 | ) 128 | assert resp.status_code == 200 129 | 130 | def start_checkout( 131 | self, item_id: str, country: str, state: str, city: str, district: str 132 | ) -> (dict, str): 133 | logger.info("Starting checkout...") 134 | data = { 135 | "shoppingCart.recommendations.recommendedItem.part": "", 136 | f"shoppingCart.items.{item_id}.isIntentToGift": "false", 137 | f"shoppingCart.items.{item_id}.itemQuantity.quantity": "1", 138 | f"shoppingCart.items.{item_id}.delivery.lineDeliveryOptions.address.provinceCityDistrictTabs.city": city, 139 | f"shoppingCart.items.{item_id}.delivery.lineDeliveryOptions.address.provinceCityDistrictTabs.state": state, 140 | f"shoppingCart.items.{item_id}.delivery.lineDeliveryOptions.address.provinceCityDistrictTabs.provinceCityDistrict": f"{state} {city} {district}", 141 | f"shoppingCart.items.{item_id}.delivery.lineDeliveryOptions.address.provinceCityDistrictTabs.countryCode": country, 142 | f"shoppingCart.items.{item_id}.delivery.lineDeliveryOptions.address.provinceCityDistrictTabs.district": district, 143 | "shoppingCart.locationConsent.locationConsent": "false", 144 | "shoppingCart.summary.promoCode.promoCode": "", 145 | "shoppingCart.actions.fcscounter": "", 146 | "shoppingCart.actions.fcsdata": "", 147 | } 148 | resp = self.session.post( 149 | "/shop/bagx/checkout_now", 150 | params={ 151 | "_a": "checkout", 152 | "_m": "shoppingCart.actions", 153 | }, 154 | data=data, 155 | ) 156 | resp_json = resp.json() 157 | signin_url = resp_json["head"]["data"]["url"] 158 | url_parsed = urlparse(signin_url) 159 | 160 | signin_params = dict(parse_qsl(url_parsed.query)) 161 | secure_api_host = f"{url_parsed.scheme}://{url_parsed.hostname}" 162 | 163 | logger.debug(f"Secure api host: {secure_api_host}") 164 | return signin_url, signin_params, secure_api_host 165 | 166 | def signin(self, signin_params: dict): 167 | timestamp = str(int(time.time() * 1000)) 168 | now = datetime.now() 169 | data = { 170 | "signIn.consentOverlay.policiesAccepted": "true", 171 | "signIn.consentOverlay.dataHandleByApple": "true", 172 | "signIn.consentOverlay.dataOutSideMyCountry": "true", 173 | "signIn.guestLogin.deviceID": f'TF1;015;;;;;;;;;;;;;;;;;;;;;;Mozilla;Netscape;5.0 (Macintosh);20100101;undefined;true;Intel Mac OS X 10.15;true;MacIntel;undefined;Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:{random.randrange(100, 109)}.0) Gecko/20100101 Firefox/{random.randrange(100, 117)}.0;en-US;undefined;secure7.www.apple.com.cn;undefined;undefined;undefined;undefined;false;false;{timestamp};8;6/7/2005, 9:33:44 PM;2560;1440;;;;;;;;-480;-480;{now.strftime("%-m/%-d/%Y")}, {now.strftime("%-I:%M:%S %p")};30;2560;1415;0;25;;;;;;;;;;;;;;;;;;;25;', 174 | } 175 | data["signIn.guestLogin.deviceID"] = ";".join( 176 | [quote_plus(i) for i in data["signIn.guestLogin.deviceID"].split(";")] 177 | ) 178 | 179 | sign_resp = self.session.post( 180 | self.secure_host + "/shop/signInx", 181 | params=signin_params | {"_a": "guestLogin", "_m": "signIn.guestLogin"}, 182 | data=data, 183 | ) 184 | sign_resp_json = sign_resp.json() 185 | start_response = self.session.post( 186 | sign_resp_json["head"]["data"]["url"], 187 | data=sign_resp_json["head"]["data"]["args"], 188 | ) 189 | start_response_json = start_response.json() 190 | assert start_response_json["head"]["status"] == 302 191 | return start_response_json 192 | 193 | def checkout_request( 194 | self, 195 | url: str, 196 | params: Optional[dict], 197 | data: Optional[dict] = None, 198 | assert_code: int = 200, 199 | ): 200 | resp = self.session.post(url, params=params, data=data) 201 | 202 | resp_json = resp.json() 203 | if assert_code: 204 | logger.debug(f"Url {url} response {resp_json}") 205 | resp_status = resp_json["head"]["status"] 206 | if resp_status != assert_code: 207 | return False 208 | assert ( 209 | resp_status == assert_code 210 | ), f"Expected {assert_code}, actually {resp_status}" 211 | 212 | return resp_json 213 | 214 | def update_delivery_method(self): 215 | # Pick up at physical store 216 | pick_store_data = self.checkout_request( 217 | self.secure_host + "/shop/checkoutx", 218 | params={ 219 | "_a": "selectFulfillmentLocationAction", 220 | "_m": "checkout.fulfillment.fulfillmentOptions", 221 | }, 222 | data={ 223 | "checkout.fulfillment.fulfillmentOptions.selectFulfillmentLocation": "RETAIL", 224 | }, 225 | ) 226 | 227 | def fill_address( 228 | self, 229 | store_number: str, 230 | country: str, 231 | state: str, 232 | city: str, 233 | district: str, 234 | ): 235 | logger.info("Starting fill address...") 236 | 237 | data = { 238 | "checkout.fulfillment.pickupTab.pickup.storeLocator.showAllStores": "false", 239 | "checkout.fulfillment.pickupTab.pickup.storeLocator.selectStore": store_number, 240 | "checkout.fulfillment.pickupTab.pickup.storeLocator.searchInput": f"{state} {city} {district}", 241 | "checkout.fulfillment.pickupTab.pickup.storeLocator.address.stateCitySelectorForCheckout.city": city, 242 | "checkout.fulfillment.pickupTab.pickup.storeLocator.address.stateCitySelectorForCheckout.state": state, 243 | "checkout.fulfillment.pickupTab.pickup.storeLocator.address.stateCitySelectorForCheckout.provinceCityDistrict": f"{state} {city} {district}", 244 | "checkout.fulfillment.pickupTab.pickup.storeLocator.address.stateCitySelectorForCheckout.countryCode": country, 245 | "checkout.fulfillment.pickupTab.pickup.storeLocator.address.stateCitySelectorForCheckout.district": district, 246 | } 247 | store_resp = self.checkout_request( 248 | self.secure_host + "/shop/checkoutx", 249 | params={ 250 | "_a": "search", 251 | "_m": "checkout.fulfillment.pickupTab.pickup.storeLocator", 252 | }, 253 | data=data, 254 | ) 255 | 256 | return store_resp 257 | 258 | def get_select_window(self, address_data: dict): 259 | pickups = address_data["body"]["checkout"]["fulfillment"]["pickupTab"]["pickup"] 260 | if "timeSlot" not in pickups: 261 | logger.info("No available pickup time") 262 | return None 263 | pick_data = pickups["timeSlot"]["dateTimeSlots"]["d"] 264 | selected_window = {} 265 | for idx, window in enumerate(pick_data["timeSlotWindows"]): 266 | assert isinstance(window, dict) 267 | deep_windows = list(window.values())[0] if window else None 268 | if not deep_windows: 269 | continue 270 | for deep_window in deep_windows: 271 | if not deep_window["isRestricted"]: 272 | selected_window = pick_data 273 | selected_window["window"] = deep_window 274 | pick_date = pick_data["pickUpDates"][idx] 275 | selected_window["date"] = pick_date 276 | logger.debug(f"Delivery raw: {selected_window}") 277 | logger.info( 278 | f"Delivery information: {pick_date.get('dayOfWeek')} {deep_window.get('Label')}" 279 | ) 280 | break 281 | if selected_window: 282 | return selected_window 283 | logger.debug(f"Pick_data: {pick_data}") 284 | assert selected_window, "No pickup options found." 285 | 286 | def fill_contact( 287 | self, 288 | selected_window: dict, 289 | store_number: str, 290 | country: str, 291 | state: str, 292 | city: str, 293 | district: str, 294 | ): 295 | logger.info("Starting fill contact...") 296 | pickup_prefix = "checkout.fulfillment.pickupTab.pickup" 297 | dt_prefix = "checkout.fulfillment.pickupTab.pickup.timeSlot.dateTimeSlots" 298 | data = { 299 | "checkout.fulfillment.fulfillmentOptions.selectFulfillmentLocation": "RETAIL", 300 | f"{pickup_prefix}.storeLocator.showAllStores": "false", 301 | f"{pickup_prefix}.storeLocator.selectStore": store_number, 302 | f"{pickup_prefix}.storeLocator.searchInput": f"{state} {city} {district}", 303 | f"{pickup_prefix}.storeLocator.address.stateCitySelectorForCheckout.city": city, 304 | f"{pickup_prefix}.storeLocator.address.stateCitySelectorForCheckout.state": state, 305 | f"{pickup_prefix}.storeLocator.address.stateCitySelectorForCheckout.provinceCityDistrict": f"{state} {city} {district}", 306 | f"{pickup_prefix}.storeLocator.address.stateCitySelectorForCheckout.countryCode": country, 307 | f"{pickup_prefix}.storeLocator.address.stateCitySelectorForCheckout.district": district, 308 | f"{dt_prefix}.startTime": selected_window["window"]["checkInStart"], 309 | f"{dt_prefix}.displayEndTime": selected_window["displayEndTime"], 310 | f"{dt_prefix}.isRecommended": str(selected_window["isRecommended"]).lower(), 311 | f"{dt_prefix}.endTime": selected_window["window"]["checkInEnd"], 312 | f"{dt_prefix}.date": selected_window["date"]["date"], 313 | f"{dt_prefix}.timeSlotId": selected_window["window"]["SlotId"], 314 | f"{dt_prefix}.signKey": selected_window["window"]["signKey"], 315 | f"{dt_prefix}.timeZone": selected_window["window"]["timeZone"], 316 | f"{dt_prefix}.timeSlotValue": selected_window["window"]["timeSlotValue"], 317 | f"{dt_prefix}.dayRadio": selected_window["dayRadio"], 318 | f"{dt_prefix}.isRestricted": selected_window["isRestricted"] or "", 319 | f"{dt_prefix}.displayStartTime": selected_window["displayStartTime"], 320 | } 321 | 322 | contact_data = self.checkout_request( 323 | self.secure_host + "/shop/checkoutx", 324 | params={ 325 | "_a": "continueFromFulfillmentToPickupContact", 326 | "_m": "checkout.fulfillment", 327 | }, 328 | data=data, 329 | ) 330 | 331 | def fill_recipient( 332 | self, 333 | first_name: str, 334 | last_name: str, 335 | email: str, 336 | phone: str, 337 | idcard: str, 338 | ): 339 | logger.info("Starting fill recipient...") 340 | 341 | data = { 342 | "checkout.pickupContact.selfPickupContact.selfContact.address.lastName": last_name, 343 | "checkout.pickupContact.selfPickupContact.selfContact.address.firstName": first_name, 344 | "checkout.pickupContact.selfPickupContact.selfContact.address.emailAddress": email, 345 | "checkout.pickupContact.selfPickupContact.selfContact.address.fullDaytimePhone": phone, 346 | "checkout.pickupContact.selfPickupContact.nationalIdSelf.nationalIdSelf": idcard, 347 | "checkout.pickupContact.eFapiaoSelector.selectFapiao": "none", # e_personal 348 | } 349 | review_bill_data = self.checkout_request( 350 | self.secure_host + "/shop/checkoutx", 351 | params={ 352 | "_a": "continueFromPickupContactToBilling", 353 | "_m": "checkout.pickupContact", 354 | }, 355 | data=data, 356 | ) 357 | 358 | def fill_pay_method(self, payment: str, number: int): 359 | logger.info("Starting fill pay methods...") 360 | data = { 361 | "checkout.billing.billingOptions.selectBillingOption": payment, 362 | "checkout.locationConsent.locationConsent": "false", 363 | } 364 | bill_option_data = self.checkout_request( 365 | self.secure_host + "/shop/checkoutx/billing", 366 | params={ 367 | "_a": "selectBillingOptionAction", 368 | "_m": "checkout.billing.billingOptions", 369 | }, 370 | data=data, 371 | ) 372 | data = { 373 | "checkout.billing.billingOptions.selectBillingOption": payment, 374 | "checkout.billing.billingOptions.selectedBillingOptions.installments.installmentOptions.selectInstallmentOption": str( 375 | number 376 | ), 377 | } 378 | bill_confirm_data = self.checkout_request( 379 | self.secure_host + "/shop/checkoutx/billing", 380 | params={ 381 | "_a": "continueFromBillingToReview", 382 | "_m": "checkout.billing", 383 | }, 384 | data=data, 385 | ) 386 | 387 | def finish_checkout(self, show_cookie: bool = False): 388 | logger.info("Starting final checkout...") 389 | 390 | place_order_data = self.get_place_order_data() 391 | 392 | self.get_page_with_meta( 393 | self.secure_host + place_order_data["head"]["data"]["url"], None 394 | ) 395 | 396 | status_data = self.get_checkout_status_x() 397 | 398 | if show_cookie: 399 | cookie_str = "; ".join( 400 | [f"{k}={v}" for k, v in self.session.session.cookies.get_dict().items()] 401 | ) 402 | 403 | logger.info("Order page cookies: %s", cookie_str) 404 | 405 | while True: 406 | thank_data = self.get_page_with_meta( 407 | self.secure_host + place_order_data["head"]["data"]["url"], None 408 | ) 409 | thank_you_interstitial = thank_data.get("thankYouInterstitial") or {} 410 | order_data = thank_you_interstitial.get("d") or {} 411 | order_number = order_data.get("orderNumber") 412 | if order_number: 413 | logger.info(f"Order done, order number: {order_number}.") 414 | break 415 | time.sleep(1) 416 | 417 | def get_place_order_data(self): 418 | logger.info("Get order data...") 419 | place_order_data = self.checkout_request( 420 | self.secure_host + "/shop/checkoutx", 421 | params={ 422 | "_a": "continueFromReviewToProcess", 423 | "_m": "checkout.review.placeOrder", 424 | }, 425 | assert_code=302, 426 | ) 427 | if place_order_data is False: 428 | time.sleep(1) 429 | return self.get_place_order_data() 430 | return place_order_data 431 | 432 | def get_checkout_status_x(self): 433 | logger.info("Get order status...") 434 | status_data = self.checkout_request( 435 | self.secure_host + "/shop/checkoutx/statusX", 436 | params={ 437 | "_a": "checkStatus", 438 | "_m": "spinner", 439 | }, 440 | assert_code=302, 441 | ) 442 | if status_data is False: 443 | time.sleep(1) 444 | return self.get_checkout_status_x() 445 | return status_data 446 | 447 | def get_page_with_meta(self, url, params, data: Optional[dict] = None): 448 | page_resp = self.session.get(url, params=params, data=data) 449 | assert page_resp.status_code == 200 450 | page_content = page_resp.text 451 | assert "x-aos-stk" in page_content 452 | cart_meta_match = re.search( 453 | r"", 454 | page_content, 455 | flags=re.DOTALL, 456 | ) 457 | assert cart_meta_match 458 | meta_json = cart_meta_match.group(1) 459 | meta_json_data = json.loads(meta_json.strip()) 460 | headers = meta_json_data["meta"]["h"] 461 | self.session.session.headers.update(headers) 462 | 463 | return meta_json_data 464 | 465 | 466 | @dataclasses.dataclass() 467 | class PoolData(object): 468 | order: Order 469 | timestamp: float 470 | available: bool = True 471 | 472 | 473 | class OrderSessionPool(object): 474 | def __init__(self, timeout: int = 60 * 30) -> None: 475 | super().__init__() 476 | self.timeout = timeout 477 | self.pools: list[PoolData] = [] 478 | self.redundant_time = 60 * 5 479 | self.lock = threading.Lock() 480 | self.is_stop = False 481 | 482 | def start(self, order_data: OrderSchema): 483 | thread = threading.Thread( 484 | target=self.handle_pool, args=(order_data,), name="OrderPool" 485 | ) 486 | thread.start() 487 | 488 | def handle_pool(self, order_data: OrderSchema, max_count: int = 3): 489 | timeout = self.timeout - self.redundant_time 490 | logger.info("Start maintaining the order session pool...") 491 | while not self.is_stop: 492 | for pool in self.pools: 493 | if time.time() - pool.timestamp >= timeout: 494 | pool.available = False 495 | with self.lock: 496 | self.pools = [i for i in self.pools if i.available] 497 | 498 | logger.info(f"Number of available order session pools: {len(self.pools)}") 499 | 500 | while max_count - len(self.pools) > 0: 501 | pool_data = self.new(order_data) 502 | with self.lock: 503 | self.pools.append(pool_data) 504 | time.sleep(30) 505 | 506 | def new(self, order_data: OrderSchema) -> PoolData: 507 | try: 508 | create_timestamp = time.time() 509 | order = Order(order_data.country) 510 | order.init_order(order_data) 511 | return PoolData(order=order, timestamp=create_timestamp) 512 | except Exception as e: 513 | logging.exception("Init order fail with error", exc_info=e) 514 | time.sleep(1) 515 | return self.new(order_data) 516 | 517 | def stop(self): 518 | self.is_stop = True 519 | 520 | def get(self) -> Order: 521 | while True: 522 | if not self.pools: 523 | time.sleep(0.1) 524 | continue 525 | with self.lock: 526 | pool_data = self.pools.pop(0) 527 | return pool_data.order 528 | -------------------------------------------------------------------------------- /src/common/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | 5 | def init_logging(): 6 | logging.basicConfig( 7 | stream=sys.stdout, 8 | level=logging.INFO, 9 | format="%(asctime)s.%(msecs)03d %(levelname)s %(threadName)s.%(module)s/%(funcName)s: %(message)s", 10 | ) 11 | 12 | 13 | init_logging() 14 | -------------------------------------------------------------------------------- /src/common/schemas.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import Optional 3 | 4 | 5 | @dataclasses.dataclass() 6 | class ShopSchema(object): 7 | country: str 8 | models: list[str] 9 | location: str = "" 10 | postal_code: str = "" 11 | state: str = "" 12 | code: str = "" 13 | store_filters: list[str] = dataclasses.field(default_factory=lambda: []) 14 | 15 | 16 | @dataclasses.dataclass() 17 | class DeliverySchema(object): 18 | state: str 19 | city: str 20 | district: str 21 | store_name: str 22 | store_number: str 23 | model_name: str 24 | pickup_quote: str 25 | model: str 26 | status: str 27 | pickup_type: str 28 | 29 | def intro(self) -> str: 30 | return " ".join( 31 | [ 32 | self.store_name, 33 | self.model_name, 34 | self.pickup_type, 35 | self.pickup_quote, 36 | ] 37 | ) 38 | 39 | 40 | @dataclasses.dataclass() 41 | class ProductSchema(object): 42 | model: str 43 | type: str 44 | color: str 45 | capacity: str 46 | color_display: str 47 | price: float 48 | price_display: str 49 | price_currency: str 50 | carrier_model: str = "" 51 | 52 | def key(self): 53 | return "-".join([self.type, self.capacity, self.color]) 54 | 55 | def intro(self): 56 | buffers = [ 57 | i 58 | for i in [ 59 | self.model, 60 | self.type, 61 | self.capacity, 62 | self.carrier_model, 63 | self.color_display, 64 | self.price_display, 65 | ] 66 | if i 67 | ] 68 | return " ".join(buffers) 69 | 70 | 71 | @dataclasses.dataclass() 72 | class PaymentSchema(object): 73 | label: str 74 | key: str 75 | value: str 76 | numbers: list[int] 77 | 78 | def intro(self): 79 | return " ".join( 80 | [self.value, self.label] 81 | + ( 82 | ["support numbers: ", ",".join(map(str, self.numbers))] 83 | if self.numbers 84 | else [] 85 | ) 86 | ) 87 | 88 | 89 | @dataclasses.dataclass() 90 | class OrderDeliverySchema(object): 91 | first_name: str 92 | last_name: str 93 | email: str 94 | phone: str 95 | idcard: str 96 | payment: str 97 | payment_number: int = 0 98 | 99 | 100 | @dataclasses.dataclass() 101 | class OrderSchema(object): 102 | model: str 103 | model_code: str 104 | country: str 105 | state: str = "" 106 | city: str = "" 107 | district: str = "" 108 | store_number: str = "" 109 | delivery: Optional[OrderDeliverySchema] = None 110 | ac_type: str = "" 111 | ac_model: str = "" 112 | -------------------------------------------------------------------------------- /src/libs/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/libs/address.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_address(country: str, filter_str: str = ""): 5 | default_headers = { 6 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0", 7 | } 8 | filters = filter_str.split(" ") 9 | params = { 10 | "state": filters[0] if len(filters) > 0 else None, 11 | "city": filters[1] if len(filters) > 1 else None, 12 | "district": filters[2] if len(filters) > 2 else None, 13 | } 14 | resp = requests.get( 15 | f"https://www.apple.com/{country}/shop/address-lookup", 16 | params=params, 17 | headers=default_headers, 18 | ) 19 | resp_json = resp.json() 20 | assert resp_json["head"]["status"] == "200" 21 | address_data = resp_json["body"].popitem()[1] 22 | if isinstance(address_data, dict): 23 | addresses = [i["value"] for i in address_data["data"]] 24 | else: 25 | addresses = [address_data] 26 | return addresses 27 | -------------------------------------------------------------------------------- /src/libs/notifications.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import logging 3 | import time 4 | from typing import Optional 5 | from urllib.parse import quote_plus 6 | 7 | import requests 8 | 9 | 10 | class NotificationBase(object): 11 | name: str 12 | 13 | def __init__(self, token: Optional[str] = None) -> None: 14 | super().__init__() 15 | self.token = token 16 | self.last_push_maps: dict[str, float] = {} 17 | 18 | def push( 19 | self, title: str, content: str, key: str = "default", min_interval: int = 0 20 | ): 21 | if ( 22 | min_interval 23 | and self.last_push_maps.get(key) 24 | and (time.time() - self.last_push_maps[key]) < min_interval 25 | ): 26 | logging.info("Pushing too frequently, wait for the next push") 27 | return 28 | 29 | self.push_data(title, content) 30 | 31 | self.last_push_maps[key] = time.time() 32 | 33 | def repeat_push( 34 | self, title: str, content: str, max_count: int = 0, interval: int = 5 35 | ): 36 | max_count = 1024 * 1024 if max_count <= 0 else max_count 37 | for i in range(0, max_count): 38 | self.push_data(title, content) 39 | time.sleep(interval) 40 | 41 | @abc.abstractmethod 42 | def push_data(self, title: str, content: str): 43 | pass 44 | 45 | 46 | class DingTalkNotification(NotificationBase): 47 | name = "dingtalk" 48 | 49 | def push_data(self, title: str, content: str): 50 | assert self.token, "Access_token credentials must be provided" 51 | url = f"https://oapi.dingtalk.com/robot/send?access_token={self.token}" 52 | resp = requests.post( 53 | url, 54 | json={ 55 | "msgtype": "text", 56 | "text": {"content": title + "\r\n\r\n" + content}, 57 | "at": {"isAtAll": 0}, 58 | }, 59 | ) 60 | resp_json = resp.json() 61 | 62 | assert resp_json.get("errcode") == 0, resp_json.get("errmsg") 63 | 64 | 65 | class BarkNotification(NotificationBase): 66 | name = "bark" 67 | 68 | def __init__( 69 | self, 70 | token: Optional[str] = None, 71 | host: Optional[str] = None, 72 | ) -> None: 73 | super().__init__(token=token) 74 | self.host = (host or "https://api.day.app").rstrip("/") 75 | 76 | def push_data(self, title: str, content: str): 77 | assert self.token, "Token credentials must be provided" 78 | title, content = quote_plus(title), quote_plus(content) 79 | url = f"{self.host}/{self.token}/{title}/{content}" 80 | resp = requests.get(url) 81 | resp_json = resp.json() 82 | 83 | assert resp_json.get("code") == 200, resp_json.get("message") 84 | 85 | 86 | class FeishuNotification(NotificationBase): 87 | name = "feishu" 88 | 89 | def push_data(self, title: str, content: str): 90 | assert self.token, "token credentials must be provided" 91 | url = f"https://open.feishu.cn/open-apis/bot/v2/hook/{self.token}" 92 | resp = requests.post( 93 | url, 94 | json={ 95 | "msg_type": "text", 96 | "content": {"text": title + "\r\n\r\n" + content}, 97 | }, 98 | ) 99 | resp_json = resp.json() 100 | 101 | assert resp_json.get("code") == 0, resp_json.get("msg") 102 | -------------------------------------------------------------------------------- /src/libs/payments.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os.path 4 | 5 | from common.schemas import PaymentSchema 6 | 7 | 8 | def get_payments(country: str): 9 | # current only support cn yet 10 | payments = [] 11 | payments_path = os.path.abspath("statics/payments") 12 | file_path = payments_path + f"/{country}.json" 13 | if not os.path.isfile(file_path): 14 | logging.error(f"Payment methods does not support {country} yet.") 15 | return [] 16 | with open(file_path, "r") as f: 17 | f_content = f.read() 18 | payments_json = json.loads(f_content) 19 | for payment in payments_json: 20 | payments.append( 21 | PaymentSchema( 22 | label=payment.get("label", payment.get("labelImageAlt", "")), 23 | key=payment["moduleKey"], 24 | value=payment["value"], 25 | numbers=payment.get("numbers", []) 26 | ) 27 | ) 28 | 29 | return payments 30 | -------------------------------------------------------------------------------- /src/libs/products.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | import requests 5 | 6 | from common.schemas import ProductSchema 7 | 8 | 9 | def get_products(code: str, country: str): 10 | default_headers = { 11 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0", 12 | } 13 | resp = requests.get( 14 | f"https://www.apple.com/{country}/shop/buy-iphone/iphone-{code}", 15 | headers=default_headers, 16 | ) 17 | content = resp.text 18 | assert "productSelectionData" in resp.text 19 | return parse_products(content) 20 | 21 | 22 | def parse_products(content): 23 | select_match = re.search( 24 | r"window.PRODUCT_SELECTION_BOOTSTRAP = (.+?)", content, flags=re.DOTALL 25 | ) 26 | assert select_match 27 | select_text = ( 28 | select_match.group(1) 29 | .strip() 30 | .replace("productSelectionData", '"productSelectionData"') 31 | ) 32 | select_data = json.loads(select_text)["productSelectionData"] 33 | products = [] 34 | prices_data = select_data["displayValues"]["prices"] 35 | colors_data = select_data["displayValues"]["dimensionColor"] 36 | for product in select_data["products"]: 37 | price_tag = product["fullPrice"] 38 | price_data = prices_data[price_tag] 39 | products.append( 40 | ProductSchema( 41 | type=product["familyType"], 42 | model=product["partNumber"], 43 | color=product["dimensionColor"], 44 | capacity=product["dimensionCapacity"], 45 | color_display=colors_data[product["dimensionColor"]]["value"], 46 | price=float(price_data["currentPrice"]["raw_amount"]), 47 | price_display=price_data["currentPrice"]["amount"], 48 | price_currency=price_data["priceCurrency"], 49 | carrier_model=product.get("carrierModel", ""), 50 | ) 51 | ) 52 | return sorted(products, key=lambda x: (x.price, x.color)) 53 | -------------------------------------------------------------------------------- /src/libs/requests.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import requests 4 | 5 | 6 | class Request(object): 7 | default_headers = { 8 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:108.0) Gecko/20100101 Firefox/116.0", 9 | } 10 | 11 | def __init__( 12 | self, host: str, headers: Optional[dict] = None, timeout: int = 5 13 | ) -> None: 14 | super().__init__() 15 | self.session = requests.Session() 16 | self.request_host = host 17 | 18 | self.session.headers.update(self.default_headers) 19 | headers and self.session.headers.update(headers) 20 | 21 | self.default_timeout = timeout 22 | 23 | def request(self, method: str, *args, **kwargs): 24 | kwargs = {k: v for k, v in kwargs.items() if v is not None} 25 | if "timeout" in kwargs: 26 | kwargs["timeout"] = self.default_timeout 27 | return self.session.request(method, *args, **kwargs) 28 | 29 | def get( 30 | self, 31 | path: str, 32 | params: Optional[dict] = None, 33 | data: Optional[dict] = None, 34 | headers: Optional[dict] = None, 35 | ): 36 | return self.request( 37 | "GET", self.get_url(path), params=params, data=data, headers=headers 38 | ) 39 | 40 | def get_url(self, path: str): 41 | if path.startswith("http"): 42 | return path 43 | return self.request_host + path 44 | 45 | def post( 46 | self, 47 | path: str, 48 | params: Optional[dict] = None, 49 | data: Optional[dict] = None, 50 | headers: Optional[dict] = None, 51 | fetch_header: bool = True, 52 | ): 53 | headers = headers if headers else self.session.headers 54 | if fetch_header: 55 | headers = dict(headers) | {"X-Requested-With": "Fetch"} 56 | return self.request( 57 | "POST", self.get_url(path), params=params, data=data, headers=headers 58 | ) 59 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import argparse 3 | import os 4 | import sys 5 | 6 | from common.schemas import ShopSchema, DeliverySchema, OrderDeliverySchema 7 | from actions.inventory_monitoring import InventoryMonitor 8 | from libs.address import get_address 9 | from libs.notifications import ( 10 | DingTalkNotification, 11 | NotificationBase, 12 | BarkNotification, 13 | FeishuNotification, 14 | ) 15 | from libs.payments import get_payments 16 | from libs.products import get_products 17 | 18 | 19 | def get_notification_providers() -> list[NotificationBase]: 20 | providers = [] 21 | dingtalk_token = os.environ.get("DINGTALK_TOKEN") 22 | bark_host = os.environ.get("BARK_HOST") 23 | bark_token = os.environ.get("BARK_TOKEN") 24 | feishu_token = os.environ.get("FEISHU_TOKEN") 25 | 26 | if dingtalk_token: 27 | providers.append(DingTalkNotification(dingtalk_token)) 28 | if bark_token: 29 | providers.append(BarkNotification(bark_token, host=bark_host)) 30 | if feishu_token: 31 | providers.append(FeishuNotification(feishu_token)) 32 | return providers 33 | 34 | 35 | def get_delivery_data() -> DeliverySchema: 36 | data = OrderDeliverySchema( 37 | first_name=os.environ.get("DELIVERY_FIRST_NAME"), 38 | last_name=os.environ.get("DELIVERY_LAST_NAME"), 39 | email=os.environ.get("DELIVERY_EMAIL"), 40 | phone=os.environ.get("DELIVERY_PHONE"), 41 | idcard=os.environ.get("DELIVERY_IDCARD"), 42 | payment=os.environ.get("DELIVERY_PAYMENT"), 43 | payment_number=int(os.environ.get("DELIVERY_PAYMENT_NUMBER") or 0), 44 | ) 45 | assert ( 46 | data.first_name 47 | and data.last_name 48 | and data.email 49 | and data.phone 50 | and data.idcard 51 | and data.payment 52 | ), "Please check the delivery information" 53 | return data 54 | 55 | 56 | def get_args(): 57 | parser = argparse.ArgumentParser() 58 | parser.add_argument("-p", "--products", nargs="+", default=[], type=str, help="") 59 | parser.add_argument("-l", "--location", type=str, default="", help="") 60 | parser.add_argument("-pc", "--postal-code", type=str, default="", help="") 61 | parser.add_argument("--state", type=str, default="", help="") 62 | parser.add_argument("-lp", "--list-products", action="store_true", help="") 63 | parser.add_argument("-la", "--list-address", action="store_true", help="") 64 | parser.add_argument("-lpa", "--list-payments", action="store_true", help="") 65 | parser.add_argument("-o", "--order", action="store_true", help="") 66 | parser.add_argument("-onc", "--order-notice-count", type=int, default=1, help="") 67 | parser.add_argument( 68 | "-c", "--country", type=str, required=True, help="cn|hk-zh|sg|jp" 69 | ) 70 | parser.add_argument("--code", type=str, default="", help="15|15-pro") 71 | parser.add_argument("-i", "--interval", type=int, default=5, help="Query interval") 72 | parser.add_argument("-ft", "--filter", type=str, default="", help="") 73 | parser.add_argument( 74 | "-sft", "--store-filter", nargs="+", type=str, default=[], help="" 75 | ) 76 | parser.add_argument("--ac-type", type=str, default="", help="iphone14|iphone14promax|iphone14plus") 77 | parser.add_argument("--ac-product", type=str, default="", help="SJTU2CH/A|SJTP2CH/A|SJTW2CH/A|SJTR2CH/A") 78 | return parser.parse_args() 79 | 80 | 81 | def main(): 82 | args = get_args() 83 | 84 | if args.list_products: 85 | assert args.country and args.code, "Lack of key information" 86 | products = get_products(args.code, args.country) 87 | for product in products: 88 | logging.info(product.intro()) 89 | sys.exit(0) 90 | if args.list_address: 91 | assert args.country, "Lack of key information" 92 | addresses = get_address(args.country, args.filter) 93 | for address in addresses: 94 | logging.info(address) 95 | sys.exit(0) 96 | if args.list_payments: 97 | assert args.country, "Lack of key information" 98 | payments = get_payments(args.country) 99 | for payment in payments: 100 | logging.info(payment.intro()) 101 | sys.exit(0) 102 | delivery_data = None 103 | if args.order: 104 | delivery_data = get_delivery_data() 105 | assert args.code, "Lack of key information" 106 | 107 | shop_data = ShopSchema( 108 | args.country, 109 | models=args.products, 110 | location=args.location, 111 | postal_code=args.postal_code, 112 | state=args.state, 113 | code=args.code, 114 | store_filters=args.store_filter 115 | ) 116 | InventoryMonitor().start( 117 | shop_data, 118 | order=args.order, 119 | delivery_data=delivery_data, 120 | notification_providers=get_notification_providers(), 121 | interval=args.interval, 122 | order_notice_count=args.order_notice_count, 123 | ac_model=args.ac_product, 124 | ac_type=args.ac_type 125 | ) 126 | 127 | 128 | if __name__ == "__main__": 129 | main() 130 | -------------------------------------------------------------------------------- /src/statics/payments/cn.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "sublabel": "Visa, Mastercard", 4 | "label": "信用卡", 5 | "moduleKey": "creditCard", 6 | "controlType": "RADIO", 7 | "value": "CREDIT" 8 | }, 9 | { 10 | "labelImageUrl": "https://store.storeimages.cdn-apple.com/8756/as-images.apple.com/is/huabei-installment-logo?wid=280&hei=52&fmt=jpeg&qlt=90&.v=1647587266354", 11 | "labelImageHeight": "26", 12 | "labelImageWidth": "140", 13 | "labelImageAlt": "花呗分期", 14 | "controlType": "RADIO", 15 | "value": "installments0001243254", 16 | "message": "如选择分期并下单,您同意您的部分信息(如网络订单号、购买价格和从您的 IP地址获取的城市信息)可能会被发送给您选择的金融机构及其服务提供商,以便其根据其隐私政策处理您的请求并用于反诈目的。", 17 | "moduleKey": "installments", 18 | "numbers": [ 19 | 3, 20 | 6, 21 | 12 22 | ] 23 | }, 24 | { 25 | "labelImageUrl": "https://store.storeimages.cdn-apple.com/8756/as-images.apple.com/is/checkout-bank-WeChat?wid=280&hei=52&fmt=jpeg&qlt=90&.v=0", 26 | "labelImageHeight": "26", 27 | "labelImageWidth": "140", 28 | "labelImageAlt": "微信支付", 29 | "moduleKey": "weChat", 30 | "controlType": "RADIO", 31 | "value": "WECHAT" 32 | }, 33 | { 34 | "labelImageUrl": "https://store.storeimages.cdn-apple.com/8756/as-images.apple.com/is/checkout-bank-CMB?wid=280&hei=52&fmt=jpeg&qlt=90&.v=1541793470488", 35 | "labelImageHeight": "26", 36 | "labelImageWidth": "140", 37 | "labelImageAlt": "招商银行", 38 | "controlType": "RADIO", 39 | "value": "installments0001321713", 40 | "message": "", 41 | "moduleKey": "installments", 42 | "numbers": [ 43 | 1, 44 | 3, 45 | 6, 46 | 12, 47 | 24 48 | ] 49 | }, 50 | { 51 | "labelImageUrl": "https://store.storeimages.cdn-apple.com/8756/as-images.apple.com/is/checkout-bank-ICBC?wid=280&hei=52&fmt=jpeg&qlt=90&.v=1541793472543", 52 | "labelImageHeight": "26", 53 | "labelImageWidth": "140", 54 | "labelImageAlt": "工商银行", 55 | "controlType": "RADIO", 56 | "value": "installments0000833448", 57 | "message": "", 58 | "moduleKey": "installments", 59 | "numbers": [ 60 | 3, 61 | 6, 62 | 12, 63 | 24 64 | ] 65 | }, 66 | { 67 | "labelImageUrl": "https://store.storeimages.cdn-apple.com/8756/as-images.apple.com/is/checkout-bank-Alipay?wid=280&hei=52&fmt=jpeg&qlt=95&.v=0", 68 | "labelImageHeight": "26", 69 | "labelImageWidth": "140", 70 | "labelImageAlt": "支付宝", 71 | "moduleKey": "aliPay", 72 | "controlType": "RADIO", 73 | "value": "ALIPAY" 74 | }, 75 | { 76 | "labelImageUrl": "https://store.storeimages.cdn-apple.com/8756/as-images.apple.com/is/checkout-bank-CCB?wid=280&hei=52&fmt=jpeg&qlt=90&.v=1573863320558", 77 | "labelImageHeight": "26", 78 | "labelImageWidth": "140", 79 | "labelImageAlt": "中国建设银行", 80 | "controlType": "RADIO", 81 | "value": "installments0000882476", 82 | "message": "", 83 | "moduleKey": "installments", 84 | "numbers": [ 85 | 1, 86 | 3, 87 | 6, 88 | 12, 89 | 24 90 | ] 91 | } 92 | ] --------------------------------------------------------------------------------