Repository: mixmoe/HibiAPI Branch: main Commit: ada5d2205b4f Files: 100 Total size: 201.0 KB Directory structure: gitextract_8q_i_0ls/ ├── .all-contributorsrc ├── .flake8 ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── docker.yml │ ├── lint.yml │ ├── mirror.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .replit ├── .vscode/ │ ├── docstring.mustache │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── hibiapi/ │ ├── __init__.py │ ├── __main__.py │ ├── api/ │ │ ├── __init__.py │ │ ├── bika/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── constants.py │ │ │ └── net.py │ │ ├── bilibili/ │ │ │ ├── __init__.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── v2.py │ │ │ │ └── v3.py │ │ │ ├── constants.py │ │ │ └── net.py │ │ ├── netease/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── constants.py │ │ │ └── net.py │ │ ├── pixiv/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── constants.py │ │ │ └── net.py │ │ ├── qrcode.py │ │ ├── sauce/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── constants.py │ │ │ └── net.py │ │ ├── tieba/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── net.py │ │ └── wallpaper/ │ │ ├── __init__.py │ │ ├── api.py │ │ ├── constants.py │ │ └── net.py │ ├── app/ │ │ ├── __init__.py │ │ ├── application.py │ │ ├── handlers.py │ │ ├── middlewares.py │ │ └── routes/ │ │ ├── __init__.py │ │ ├── bika.py │ │ ├── bilibili/ │ │ │ ├── __init__.py │ │ │ ├── v2.py │ │ │ └── v3.py │ │ ├── netease.py │ │ ├── pixiv.py │ │ ├── qrcode.py │ │ ├── sauce.py │ │ ├── tieba.py │ │ └── wallpaper.py │ ├── configs/ │ │ ├── bika.yml │ │ ├── bilibili.yml │ │ ├── general.yml │ │ ├── netease.yml │ │ ├── pixiv.yml │ │ ├── qrcode.yml │ │ ├── sauce.yml │ │ ├── tieba.yml │ │ └── wallpaper.yml │ └── utils/ │ ├── __init__.py │ ├── cache.py │ ├── config.py │ ├── decorators/ │ │ ├── __init__.py │ │ ├── enum.py │ │ └── timer.py │ ├── exceptions.py │ ├── log.py │ ├── net.py │ ├── routing.py │ └── temp.py ├── pyproject.toml ├── scripts/ │ └── pixiv_login.py └── test/ ├── __init__.py ├── test_base.py ├── test_bika.py ├── test_bilibili_v2.py ├── test_bilibili_v3.py ├── test_netease.py ├── test_pixiv.py ├── test_qrcode.py ├── test_sauce.py ├── test_tieba.py └── test_wallpaper.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "files": [ "README.md" ], "imageSize": 100, "commit": false, "contributors": [ { "login": "Kyomotoi", "name": "Kyomotoi", "avatar_url": "https://avatars.githubusercontent.com/u/37587870?v=4", "profile": "http://kyomotoi.moe", "contributions": [ "doc", "test" ] }, { "login": "shirokurakana", "name": "城倉奏", "avatar_url": "https://avatars.githubusercontent.com/u/46120251?v=4", "profile": "http://thdog.moe", "contributions": [ "example" ] }, { "login": "SkipM4", "name": "SkipM4", "avatar_url": "https://avatars.githubusercontent.com/u/40311581?v=4", "profile": "http://skipm4.com", "contributions": [ "doc" ] }, { "login": "leaf7th", "name": "Nook", "avatar_url": "https://avatars.githubusercontent.com/u/38352552?v=4", "profile": "https://github.com/leaf7th", "contributions": [ "code" ] }, { "login": "jiangzhuochi", "name": "Jocky Chiang", "avatar_url": "https://avatars.githubusercontent.com/u/50538375?v=4", "profile": "https://github.com/jiangzhuochi", "contributions": [ "code" ] }, { "login": "cleoold", "name": "midori", "avatar_url": "https://avatars.githubusercontent.com/u/13920903?v=4", "profile": "https://github.com/cleoold", "contributions": [ "doc" ] }, { "login": "Pretty9", "name": "Pretty9", "avatar_url": "https://avatars.githubusercontent.com/u/41198038?v=4", "profile": "https://www.2yo.cc", "contributions": [ "code" ] }, { "login": "journey-ad", "name": "Jad", "avatar_url": "https://avatars.githubusercontent.com/u/16256221?v=4", "profile": "https://nocilol.me/", "contributions": [ "bug", "ideas" ] }, { "login": "asadahimeka", "name": "Yumine Sakura", "avatar_url": "https://avatars.githubusercontent.com/u/31837214?v=4", "profile": "http://nanoka.top", "contributions": [ "code" ] }, { "login": "yeyang52", "name": "yeyang", "avatar_url": "https://avatars.githubusercontent.com/u/107110851?v=4", "profile": "https://github.com/yeyang52", "contributions": [ "code" ] } ], "contributorsPerLine": 7, "projectName": "HibiAPI", "projectOwner": "mixmoe", "repoType": "github", "repoHost": "https://github.com", "skipCi": true, "commitConvention": "none" } ================================================ FILE: .flake8 ================================================ [flake8] max-line-length = 90 ignore = W391, W292, W503, E203 ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: pip # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: weekly versioning-strategy: lockfile-only - package-ecosystem: github-actions directory: "/" schedule: interval: weekly ================================================ FILE: .github/workflows/docker.yml ================================================ # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. name: Docker on: push: branches: [main] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build-and-push-image: name: Build and Push Image runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v3 - name: Log in to the Container registry uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@c4ee3adeed93b1fa6a762f209fb01608c1a22f1e with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: push: branches: [main, dev] pull_request_target: jobs: lint: runs-on: ubuntu-latest name: Lint Code steps: - uses: actions/checkout@v4 - uses: pdm-project/setup-pdm@v4 with: python-version: "3.9" cache: true - name: Install dependencies run: | pdm install -G :all echo `dirname $(pdm info --python)` >> $GITHUB_PATH - name: Lint with Ruff continue-on-error: true run: pdm lint --output-format github - name: Lint with Pyright uses: jakebailey/pyright-action@v2 continue-on-error: true with: pylance-version: latest-release analyze: runs-on: ubuntu-latest name: CodeQL Analyze if: startsWith(github.ref, 'refs/heads/') steps: - uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: languages: python - name: Auto build uses: github/codeql-action/autobuild@v2 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 ================================================ FILE: .github/workflows/mirror.yml ================================================ name: Gitee Mirror on: push: branches: [main, dev] schedule: - cron: "0 0 * * *" workflow_dispatch: concurrency: group: ${{ github.workflow }} cancel-in-progress: true jobs: git-mirror: name: Mirror runs-on: ubuntu-latest steps: - uses: wearerequired/git-mirror-action@v1 env: SSH_PRIVATE_KEY: ${{ secrets.SSH_KEY }} with: source-repo: "git@github.com:mixmoe/HibiAPI.git" destination-repo: "git@gitee.com:mixmoe/HibiAPI.git" ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: create: tags: [v*] workflow_dispatch: jobs: release: name: Create release runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: pdm-project/setup-pdm@v3 with: python-version: 3.9 - name: Install Dependencies run: | pdm install --prod - name: Release to PyPI env: PDM_PUBLISH_USERNAME: __token__ PDM_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | pdm publish ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: workflow_dispatch: push: branches: [main, dev] pull_request_target: concurrency: group: ${{ github.workflow }} cancel-in-progress: false jobs: cloc: runs-on: ubuntu-latest name: Count Lines of Code steps: - uses: actions/checkout@v3 - name: Install CLoC run: | sudo apt-get update sudo apt-get install cloc - name: Count Lines of Code run: | cloc . --md >> $GITHUB_STEP_SUMMARY test: runs-on: ${{ matrix.os }} name: Testing strategy: matrix: python: ["3.9", "3.10", "3.11", "3.12"] os: [ubuntu-latest, windows-latest, macos-latest] max-parallel: 3 defaults: run: shell: bash env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python }} steps: - uses: actions/checkout@v3 with: ref: ${{ github.event.pull_request.head.sha }} - uses: pdm-project/setup-pdm@v3 with: python-version: ${{ matrix.python }} cache: true - name: Install dependencies timeout-minutes: 5 run: pdm install - name: Testing with pytest timeout-minutes: 15 run: | curl -L ${{ secrets.DOTENV_LINK }} > .env pdm test - name: Create step summary if: always() run: | echo "## Summary" >> $GITHUB_STEP_SUMMARY echo "OS: ${{ matrix.os }}" >> $GITHUB_STEP_SUMMARY echo "Python: ${{ matrix.python }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY pdm run coverage report -m >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - uses: codecov/codecov-action@v3 if: always() with: env_vars: OS,PYTHON file: coverage.xml ================================================ FILE: .gitignore ================================================ # Project ignore data/** configs/**.yml # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml .pdm-python # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ================================================ FILE: .replit ================================================ language = "python3" run = "python -m hibiapi run" ================================================ FILE: .vscode/docstring.mustache ================================================ {{! FastAPI Automatic docstring}} ## Name: `{{name}}` > {{summaryPlaceholder}} {{extendedSummaryPlaceholder}} {{#argsExist}} --- ### Required: {{#args}} - ***{{typePlaceholder}}*** **`{{var}}`** - Description: {{descriptionPlaceholder}} {{/args}} {{/argsExist}} {{#kwargsExist}} --- ### Optional: {{#kwargs}} - ***{{typePlaceholder}}*** `{{var}}` = `{{default}}` - Description: {{descriptionPlaceholder}} {{/kwargs}} {{/kwargsExist}} {{#exceptionsExist}} --- ### Exceptions: {{#exceptions}} - **`{{var}}`** - Description: {{descriptionPlaceholder}} {{/exceptions}} {{/exceptionsExist}} {{#yieldsExist}} --- ### Yields: {{#yields}} - `{{typePlaceholder}}` - Description: {{descriptionPlaceholder}} {{/yields}} {{/yieldsExist}} {{#returnsExist}} --- ### Returns: {{#returns}} - `{{typePlaceholder}}` - Description: {{descriptionPlaceholder}} {{/returns}} {{/returnsExist}} ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "visualstudioexptteam.vscodeintellicode", "ms-python.python", "ms-python.vscode-pylance", "njpwerner.autodocstring", "streetsidesoftware.code-spell-checker", "redhat.vscode-yaml", "seatonjiang.gitmoji-vscode" ] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "Python: Module", "type": "python", "request": "launch", "module": "hibiapi", "args": [ "run", "--reload" ], "justMyCode": true }, ] } ================================================ FILE: .vscode/settings.json ================================================ { "python.analysis.completeFunctionParens": true, "python.analysis.typeCheckingMode": "basic", "python.languageServer": "Pylance", "python.testing.pytestEnabled": true, "[python]": { "editor.codeActionsOnSave": { "source.organizeImports": "explicit", "source.fixAll": "explicit" } }, "autoDocstring.customTemplatePath": ".vscode/docstring.mustache", "editor.formatOnSave": true, "files.watcherExclude": { "**/.git/objects/**": true, "**/.git/subtree-cache/**": true, "**/node_modules/**": true, "**/.hg/store/**": true, "**/.venv/**": true, "**/.mypy_cache/**": true }, "files.encoding": "utf8", "python.analysis.diagnosticMode": "workspace", "cSpell.words": [ "Bilibili", "DOUGA", "GUOCHUANG", "Hibi", "Imjad", "KICHIKU", "Pixiv", "RGBA", "Tieba", "aclose", "aenter", "aexit", "aiocache", "asyncio", "bangumi", "bgcolor", "dotenv", "favlist", "fgcolor", "fnmatch", "getrgb", "hibiapi", "httpx", "illusts", "iscoroutinefunction", "itertools", "levelno", "mixmoe", "mypy", "noqa", "proto", "pydantic", "pytest", "qrcode", "redoc", "referer", "rfind", "rsplit", "starlette", "ugoira", "uvicorn", "vmid", "weapi" ], "gitmoji.outputType": "code", "python.analysis.autoImportCompletions": true } ================================================ FILE: Dockerfile ================================================ FROM python:bullseye EXPOSE 8080 ENV PORT=8080 \ PROCS=1 \ GENERAL_SERVER_HOST=0.0.0.0 COPY . /hibi WORKDIR /hibi RUN pip install . CMD hibiapi run --port $PORT --workers $PROCS HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ CMD httpx --verbose --follow-redirects http://127.0.0.1:${PORT} ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2020-2021 Mix Technology Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================
# HibiAPI **_一个实现了多种常用站点的易用化 API 的程序._** **_A program that implements easy-to-use APIs for a variety of commonly used sites._** [![Demo Version](https://img.shields.io/badge/dynamic/json?label=demo%20status&query=%24.info.version&url=https%3A%2F%2Fapi.obfs.dev%2Fopenapi.json&style=for-the-badge&color=lightblue)](https://api.obfs.dev) ![Lint](https://github.com/mixmoe/HibiAPI/workflows/Lint/badge.svg) ![Test](https://github.com/mixmoe/HibiAPI/workflows/Test/badge.svg) [![Coverage](https://codecov.io/gh/mixmoe/HibiAPI/branch/main/graph/badge.svg)](https://codecov.io/gh/mixmoe/HibiAPI) [![PyPI](https://img.shields.io/pypi/v/hibiapi)](https://pypi.org/project/hibiapi/) ![PyPI - Downloads](https://img.shields.io/pypi/dm/hibiapi) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/hibiapi) ![PyPI - License](https://img.shields.io/pypi/l/hibiapi) ![GitHub last commit](https://img.shields.io/github/last-commit/mixmoe/HibiAPI) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/mixmoe/hibiapi) ![Lines of code](https://img.shields.io/tokei/lines/github/mixmoe/hibiapi) [![GitHub stars](https://img.shields.io/github/stars/mixmoe/HibiAPI)](https://github.com/mixmoe/HibiAPI/stargazers) [![GitHub forks](https://img.shields.io/github/forks/mixmoe/HibiAPI)](https://github.com/mixmoe/HibiAPI/network) [![GitHub issues](https://img.shields.io/github/issues/mixmoe/HibiAPI)](https://github.com/mixmoe/HibiAPI/issues)
--- ## 前言 - `HibiAPI`提供多种网站公开内容的 API 集合, 它们包括: - Pixiv 的图片和小说相关信息获取和搜索 - Bilibili 的视频/番剧等信息获取和搜索 - 网易云音乐的音乐/MV 等信息获取和搜索 - 百度贴吧的帖子内容的获取 - [爱壁纸](https://adesk.com/)的横版和竖版壁纸获取 - 哔咔漫画的漫画信息获取和搜索 - … - 该项目的前身是 Imjad API[^1] - 由于它的使用人数过多, 致使调用超出限制, 所以本人希望提供一个开源替代来供社区进行自由地部署和使用, 从而减轻一部分该 API 的使用压力 [^1]: [什么是 Imjad API](https://github.com/mixmoe/HibiAPI/wiki/FAQ#%E4%BB%80%E4%B9%88%E6%98%AFimjad-api) ## 优势 ### 开源 - 本项目以[Apache-2.0](./LICENSE)许可开源, 请看[开源许可](#开源许可)一节 ### 高效 - 使用 Python 的[异步机制](https://docs.python.org/zh-cn/3/library/asyncio.html), 由[FastAPI](https://fastapi.tiangolo.com/)驱动, 带来高效的使用体验 ~~虽然性能瓶颈压根不在这~~ ### 稳定 - 在代码中广泛使用了 Python 的[类型提示支持](https://docs.python.org/zh-cn/3/library/typing.html), 使代码可读性更高且更加易于维护和调试 - 在开发初期起就一直使用多种现代 Python 开发工具辅助开发, 包括: - 使用 [PyLance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) 进行静态类型推断 - 使用 [Flake8](https://flake8.pycqa.org/en/latest/) 对代码格式进行检查 - 使用 [Black](https://black.readthedocs.io/en/stable/) 格式化代码以提升代码可读性 - 不直接使用第三方开发的 API 调用库, 而是全部用更加适合 Web 应用的逻辑重写第三方 API 请求, 更加可控 ~~疯狂造轮子~~ ## 已实现 API[^2] [^2]: 请查看 [#1](https://github.com/mixmoe/HibiAPI/issues/1) - [x] Pixiv - [x] 网易云音乐 - [ ] ~~一言~~ (其代替方案提供的方案已足够好, 暂不考虑支持) - [x] Bilibili - [x] 二维码 - [ ] ~~企鹅 FM~~ (似乎用的人不是很多) - [x] 百度贴吧 - [x] 爱壁纸 - [x] 哔咔漫画 ## 部署指南 - 手动部署指南: **[点击此处查看](https://github.com/mixmoe/HibiAPI/wiki/Deployment)** ## 应用实例 **我有更多的应用实例?** [立即 PR!](https://github.com/mixmoe/HibiAPI/pulls) - [`journey-ad/pixiv-viewer`](https://github.com/journey-ad/pixiv-viewer) - **又一个 Pixiv 阅览工具** - 公开搭建实例 | **站点名称** | **网址** | **状态** | | :--------------------------: | :-----------------------------: | :---------------------: | | **官方 Demo[^3]** | | ![official][official] | | [MyCard](https://mycard.moe) | | ![mycard][mycard] | [^3]: 为了减轻服务器负担, Demo 服务器已开启了 Cloudflare 全站缓存, 如果有实时获取更新的需求, 请自行搭建或使用其他部署实例 [official]: https://img.shields.io/website?url=https%3A%2F%2Fapi.obfs.dev%2Fopenapi.json [mycard]: https://img.shields.io/website?url=https%3A%2F%2Fhibi.moecube.com%2Fopenapi.json ## 特别鸣谢 [**@journey-ad**](https://github.com/journey-ad) 大佬的 **Imjad API**, 它是本项目的起源 ### 参考项目 > **正是因为有了你们, 这个项目才得以存在** - Pixiv: [`Mikubill/pixivpy-async`](https://github.com/Mikubill/pixivpy-async) [`upbit/pixivpy`](https://github.com/upbit/pixivpy) - Bilibili: [`SocialSisterYi/bilibili-API-collect`](https://github.com/SocialSisterYi/bilibili-API-collect) [`soimort/you-get`](https://github.com/soimort/you-get) - 网易云音乐: [`metowolf/NeteaseCloudMusicApi`](https://github.com/metowolf/NeteaseCloudMusicApi) [`greats3an/pyncm`](https://github.com/greats3an/pyncm) [`Binaryify/NeteaseCloudMusicApi`](https://github.com/Binaryify/NeteaseCloudMusicApi) - 百度贴吧: [`libsgh/tieba-api`](https://github.com/libsgh/tieba-api) - 哔咔漫画:[`niuhuan/pica-rust`](https://github.com/niuhuan/pica-rust) [`abbeyokgo/PicaComic-Api`](https://github.com/abbeyokgo/PicaComic-Api) ### 贡献者们 感谢这些为这个项目作出贡献的各位大佬:
Kyomotoi
Kyomotoi

📖 ⚠️
城倉奏
城倉奏

💡
SkipM4
SkipM4

📖
Nook
Nook

💻
Jocky Chiang
Jocky Chiang

💻
midori
midori

📖
Pretty9
Pretty9

💻
Jad
Jad

🐛 🤔
Yumine Sakura
Yumine Sakura

💻
yeyang
yeyang

💻
_本段符合 [all-contributors](https://github.com/all-contributors/all-contributors) 规范_ ## 开源许可 Copyright 2020-2021 Mix Technology Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: docker-compose.yml ================================================ version: "3.9" volumes: hibi_redis: {} networks: hibi_net: {} services: redis: image: redis:alpine container_name: hibi_redis healthcheck: test: ["CMD-SHELL", "redis-cli ping"] interval: 10s timeout: 5s retries: 5 networks: - hibi_net volumes: - hibi_redis:/data expose: [6379] api: container_name: hibiapi build: dockerfile: Dockerfile context: . restart: on-failure networks: - hibi_net depends_on: redis: condition: service_healthy ports: - "8080:8080" environment: PORT: "8080" FORWARDED_ALLOW_IPS: "*" GENERAL_CACHE_URI: "redis://redis:6379" GENERAL_SERVER_HOST: "0.0.0.0" ================================================ FILE: hibiapi/__init__.py ================================================ r""" _ _ _ _ _ _____ _____ | | | (_) | (_) /\ | __ \_ _| | |__| |_| |__ _ / \ | |__) || | | __ | | '_ \| | / /\ \ | ___/ | | | | | | | |_) | |/ ____ \| | _| |_ |_| |_|_|_.__/|_/_/ \_\_| |_____| A program that implements easy-to-use APIs for a variety of commonly used sites Repository: https://github.com/mixmoe/HibiAPI """ # noqa:W291,W293 from importlib.metadata import version __version__ = version("hibiapi") ================================================ FILE: hibiapi/__main__.py ================================================ import os from pathlib import Path import typer import uvicorn from hibiapi import __file__ as root_file from hibiapi import __version__ from hibiapi.utils.config import CONFIG_DIR, DEFAULT_DIR, Config from hibiapi.utils.log import LOG_LEVEL, logger COPYRIGHT = r""" _ _ _ _ _ _____ _____ | | | (_) | (_) /\ | __ \_ _| | |__| |_| |__ _ / \ | |__) || | | __ | | '_ \| | / /\ \ | ___/ | | | | | | | |_) | |/ ____ \| | _| |_ |_| |_|_|_.__/|_/_/ \_\_| |_____| A program that implements easy-to-use APIs for a variety of commonly used sites Repository: https://github.com/mixmoe/HibiAPI """.strip() # noqa:W291 LOG_CONFIG = { "version": 1, "disable_existing_loggers": False, "handlers": { "default": { "class": "hibiapi.utils.log.LoguruHandler", }, }, "loggers": { "uvicorn.error": { "handlers": ["default"], "level": LOG_LEVEL, }, "uvicorn.access": { "handlers": ["default"], "level": LOG_LEVEL, }, }, } RELOAD_CONFIG = { "reload": True, "reload_dirs": [ *map(str, [Path(root_file).parent.absolute(), CONFIG_DIR.absolute()]) ], "reload_includes": ["*.py", "*.yml"], } cli = typer.Typer() @cli.callback(invoke_without_command=True) @cli.command() def run( ctx: typer.Context, host: str = Config["server"]["host"].as_str(), port: int = Config["server"]["port"].as_number(), workers: int = 1, reload: bool = False, ): if ctx.invoked_subcommand is not None: return if ctx.info_name != (func_name := run.__name__): logger.warning( f"Directly usage of command {ctx.info_name} is deprecated, " f"please use {ctx.info_name} {func_name} instead." ) try: terminal_width, _ = os.get_terminal_size() except OSError: terminal_width = 0 logger.warning( "\n".join(i.center(terminal_width) for i in COPYRIGHT.splitlines()), ) logger.info(f"HibiAPI version: {__version__}") uvicorn.run( "hibiapi.app:app", host=host, port=port, access_log=False, log_config=LOG_CONFIG, workers=workers, forwarded_allow_ips=Config["server"]["allowed-forward"].get_optional(str), **(RELOAD_CONFIG if reload else {}), ) @cli.command() def config(force: bool = False): total_written = 0 CONFIG_DIR.mkdir(parents=True, exist_ok=True) for file in os.listdir(DEFAULT_DIR): default_path = DEFAULT_DIR / file config_path = CONFIG_DIR / file if not (existed := config_path.is_file()) or force: total_written += config_path.write_text( default_path.read_text(encoding="utf-8"), encoding="utf-8", ) typer.echo( typer.style(("Overwritten" if existed else "Created") + ": ", fg="blue") + typer.style(str(config_path), fg="yellow") ) if total_written > 0: typer.echo(f"Config folder generated, {total_written=}") if __name__ == "__main__": cli() ================================================ FILE: hibiapi/api/__init__.py ================================================ ================================================ FILE: hibiapi/api/bika/__init__.py ================================================ from .api import BikaEndpoints, ImageQuality, ResultSort # noqa: F401 from .constants import BikaConstants # noqa: F401 from .net import BikaLogin, NetRequest # noqa: F401 ================================================ FILE: hibiapi/api/bika/api.py ================================================ import hashlib import hmac from datetime import timedelta from enum import Enum from time import time from typing import Any, Optional, cast from httpx import URL from hibiapi.api.bika.constants import BikaConstants from hibiapi.api.bika.net import NetRequest from hibiapi.utils.cache import cache_config from hibiapi.utils.decorators import enum_auto_doc from hibiapi.utils.net import catch_network_error from hibiapi.utils.routing import BaseEndpoint, dont_route, request_headers @enum_auto_doc class ImageQuality(str, Enum): """哔咔API返回的图片质量""" low = "low" """低质量""" medium = "medium" """中等质量""" high = "high" """高质量""" original = "original" """原图""" @enum_auto_doc class ResultSort(str, Enum): """哔咔API返回的搜索结果排序方式""" date_descending = "dd" """最新发布""" date_ascending = "da" """最早发布""" like_descending = "ld" """最多喜欢""" views_descending = "vd" """最多浏览""" class BikaEndpoints(BaseEndpoint): @staticmethod def _sign(url: URL, timestamp_bytes: bytes, nonce: bytes, method: bytes): return hmac.new( BikaConstants.DIGEST_KEY, ( url.raw_path.lstrip(b"/") + timestamp_bytes + nonce + method + BikaConstants.API_KEY ).lower(), hashlib.sha256, ).hexdigest() @dont_route @catch_network_error async def request( self, endpoint: str, *, params: Optional[dict[str, Any]] = None, body: Optional[dict[str, Any]] = None, no_token: bool = False, ): net_client = cast(NetRequest, self.client.net_client) if not no_token: async with net_client.auth_lock: if net_client.token is None: await net_client.login(self) headers = { "Authorization": net_client.token or "", "Time": (current_time := f"{time():.0f}".encode()), "Image-Quality": request_headers.get().get( "X-Image-Quality", ImageQuality.medium ), "Nonce": (nonce := hashlib.md5(current_time).hexdigest().encode()), "Signature": self._sign( request_url := self._join( base=BikaConstants.API_HOST, endpoint=endpoint, params=params or {}, ), current_time, nonce, b"GET" if body is None else b"POST", ), } response = await ( self.client.get(request_url, headers=headers) if body is None else self.client.post(request_url, headers=headers, json=body) ) return response.json() @cache_config(ttl=timedelta(days=1)) async def collections(self): return await self.request("collections") @cache_config(ttl=timedelta(days=3)) async def categories(self): return await self.request("categories") @cache_config(ttl=timedelta(days=3)) async def keywords(self): return await self.request("keywords") async def advanced_search( self, *, keyword: str, page: int = 1, sort: ResultSort = ResultSort.date_descending, ): return await self.request( "comics/advanced-search", body={ "keyword": keyword, "sort": sort, }, params={ "page": page, "s": sort, }, ) async def category_list( self, *, category: str, page: int = 1, sort: ResultSort = ResultSort.date_descending, ): return await self.request( "comics", params={ "page": page, "c": category, "s": sort, }, ) async def author_list( self, *, author: str, page: int = 1, sort: ResultSort = ResultSort.date_descending, ): return await self.request( "comics", params={ "page": page, "a": author, "s": sort, }, ) @cache_config(ttl=timedelta(days=3)) async def comic_detail(self, *, id: str): return await self.request("comics/{id}", params={"id": id}) async def comic_recommendation(self, *, id: str): return await self.request("comics/{id}/recommendation", params={"id": id}) async def comic_episodes(self, *, id: str, page: int = 1): return await self.request( "comics/{id}/eps", params={ "id": id, "page": page, }, ) async def comic_page(self, *, id: str, order: int = 1, page: int = 1): return await self.request( "comics/{id}/order/{order}/pages", params={ "id": id, "order": order, "page": page, }, ) async def comic_comments(self, *, id: str, page: int = 1): return await self.request( "comics/{id}/comments", params={ "id": id, "page": page, }, ) async def games(self, *, page: int = 1): return await self.request("games", params={"page": page}) @cache_config(ttl=timedelta(days=3)) async def game_detail(self, *, id: str): return await self.request("games/{id}", params={"id": id}) ================================================ FILE: hibiapi/api/bika/constants.py ================================================ from hibiapi.utils.config import APIConfig class BikaConstants: DIGEST_KEY = b"~d}$Q7$eIni=V)9\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn" API_KEY = b"C69BAF41DA5ABD1FFEDC6D2FEA56B" DEFAULT_HEADERS = { "API-Key": API_KEY, "App-Channel": "2", "App-Version": "2.2.1.2.3.3", "App-Build-Version": "44", "App-UUID": "defaultUuid", "Accept": "application/vnd.picacomic.com.v1+json", "App-Platform": "android", "User-Agent": "okhttp/3.8.1", "Content-Type": "application/json; charset=UTF-8", } API_HOST = "https://picaapi.picacomic.com/" CONFIG = APIConfig("bika") ================================================ FILE: hibiapi/api/bika/net.py ================================================ import asyncio from base64 import urlsafe_b64decode from datetime import datetime, timezone from functools import lru_cache from typing import TYPE_CHECKING, Any, Literal, Optional from pydantic import BaseModel, Field from hibiapi.api.bika.constants import BikaConstants from hibiapi.utils.net import BaseNetClient if TYPE_CHECKING: from .api import BikaEndpoints class BikaLogin(BaseModel): email: str password: str class JWTHeader(BaseModel): alg: str typ: Literal["JWT"] class JWTBody(BaseModel): id: str = Field(alias="_id") iat: datetime exp: datetime @lru_cache(maxsize=4) def load_jwt(token: str): def b64pad(data: str): return data + "=" * (-len(data) % 4) head, body, _ = token.split(".") head_data = JWTHeader.parse_raw(urlsafe_b64decode(b64pad(head))) body_data = JWTBody.parse_raw(urlsafe_b64decode(b64pad(body))) return head_data, body_data class NetRequest(BaseNetClient): _token: Optional[str] = None def __init__(self): super().__init__( headers=BikaConstants.DEFAULT_HEADERS.copy(), proxies=BikaConstants.CONFIG["proxy"].as_dict(), ) self.auth_lock = asyncio.Lock() @property def token(self) -> Optional[str]: if self._token is None: return None _, body = load_jwt(self._token) return None if body.exp < datetime.now(timezone.utc) else self._token async def login(self, endpoint: "BikaEndpoints"): login_data = BikaConstants.CONFIG["account"].get(BikaLogin) login_result: dict[str, Any] = await endpoint.request( "auth/sign-in", body=login_data.dict(), no_token=True, ) assert login_result["code"] == 200, login_result["message"] if not ( isinstance(login_data := login_result.get("data"), dict) and "token" in login_data ): raise ValueError("failed to read Bika account token.") self._token = login_data["token"] ================================================ FILE: hibiapi/api/bilibili/__init__.py ================================================ # flake8:noqa:F401 from .api import * # noqa: F401, F403 from .constants import BilibiliConstants from .net import NetRequest ================================================ FILE: hibiapi/api/bilibili/api/__init__.py ================================================ # flake8:noqa:F401 from .base import BaseBilibiliEndpoint, TimelineType, VideoFormatType, VideoQualityType from .v2 import BilibiliEndpointV2, SearchType from .v3 import BilibiliEndpointV3 ================================================ FILE: hibiapi/api/bilibili/api/base.py ================================================ import hashlib import json from enum import Enum, IntEnum from time import time from typing import Any, Optional, overload from httpx import URL from hibiapi.api.bilibili.constants import BilibiliConstants from hibiapi.utils.decorators import enum_auto_doc from hibiapi.utils.net import catch_network_error from hibiapi.utils.routing import BaseEndpoint, dont_route @enum_auto_doc class TimelineType(str, Enum): """番剧时间线类型""" CN = "cn" """国产动画""" GLOBAL = "global" """番剧""" @enum_auto_doc class VideoQualityType(IntEnum): """视频质量类型""" VIDEO_240P = 6 VIDEO_360P = 16 VIDEO_480P = 32 VIDEO_720P = 64 VIDEO_720P_60FPS = 74 VIDEO_1080P = 80 VIDEO_1080P_PLUS = 112 VIDEO_1080P_60FPS = 116 VIDEO_4K = 120 @enum_auto_doc class VideoFormatType(IntEnum): """视频格式类型""" FLV = 0 MP4 = 2 DASH = 16 class BaseBilibiliEndpoint(BaseEndpoint): def _sign(self, base: str, endpoint: str, params: dict[str, Any]) -> URL: params.update( { **BilibiliConstants.DEFAULT_PARAMS, "access_key": BilibiliConstants.ACCESS_KEY, "appkey": BilibiliConstants.APP_KEY, "ts": int(time()), } ) params = {k: params[k] for k in sorted(params.keys())} url = self._join(base=base, endpoint=endpoint, params=params) params["sign"] = hashlib.md5(url.query + BilibiliConstants.SECRET).hexdigest() return URL(url, params=params) @staticmethod def _parse_json(content: str) -> dict[str, Any]: try: return json.loads(content) except json.JSONDecodeError: # NOTE: this is used to parse jsonp response right, left = content.find("("), content.rfind(")") return json.loads(content[right + 1 : left].strip()) @overload async def request( self, endpoint: str, *, sign: bool = True, params: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: ... @overload async def request( self, endpoint: str, source: str, *, sign: bool = True, params: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: ... @dont_route @catch_network_error async def request( self, endpoint: str, source: Optional[str] = None, *, sign: bool = True, params: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: host = BilibiliConstants.SERVER_HOST[source or "app"] url = (self._sign if sign else self._join)( base=host, endpoint=endpoint, params=params or {} ) response = await self.client.get(url) response.raise_for_status() return self._parse_json(response.text) async def playurl( self, *, aid: int, cid: int, quality: VideoQualityType = VideoQualityType.VIDEO_480P, type: VideoFormatType = VideoFormatType.FLV, ): return await self.request( "x/player/playurl", "api", sign=False, params={ "avid": aid, "cid": cid, "qn": quality, "fnval": type, "fnver": 0, "fourk": 0 if quality >= VideoQualityType.VIDEO_4K else 1, }, ) async def view(self, *, aid: int): return await self.request( "x/v2/view", params={ "aid": aid, }, ) async def search(self, *, keyword: str, page: int = 1, pagesize: int = 20): return await self.request( "x/v2/search", params={ "duration": 0, "keyword": keyword, "pn": page, "ps": pagesize, }, ) async def search_hot(self, *, limit: int = 50): return await self.request( "x/v2/search/hot", params={ "limit": limit, }, ) async def search_suggest(self, *, keyword: str, type: str = "accurate"): return await self.request( "x/v2/search/suggest", params={ "keyword": keyword, "type": type, }, ) async def space(self, *, vmid: int, page: int = 1, pagesize: int = 10): return await self.request( "x/v2/space", params={ "vmid": vmid, "ps": pagesize, "pn": page, }, ) async def space_archive(self, *, vmid: int, page: int = 1, pagesize: int = 10): return await self.request( "x/v2/space/archive", params={ "vmid": vmid, "ps": pagesize, "pn": page, }, ) async def favorite_video( self, *, fid: int, vmid: int, page: int = 1, pagesize: int = 20, ): return await self.request( "x/v2/fav/video", "api", params={ "fid": fid, "pn": page, "ps": pagesize, "vmid": vmid, "order": "ftime", }, ) async def event_list( self, *, fid: int, vmid: int, page: int = 1, pagesize: int = 20, ): # NOTE: this endpoint is not used return await self.request( "event/getlist", "api", params={ "fid": fid, "pn": page, "ps": pagesize, "vmid": vmid, "order": "ftime", }, ) async def season_info(self, *, season_id: int): return await self.request( "pgc/view/web/season", "api", params={ "season_id": season_id, }, ) async def bangumi_source(self, *, episode_id: int): return await self.request( "api/get_source", "bgm", params={ "episode_id": episode_id, }, ) async def season_recommend(self, *, season_id: int): return await self.request( "pgc/season/web/related/recommend", "api", sign=False, params={ "season_id": season_id, }, ) async def timeline(self, *, type: TimelineType = TimelineType.GLOBAL): return await self.request( "web_api/timeline_{type}", "bgm", sign=False, params={ "type": type, }, ) async def suggest(self, *, keyword: str): # NOTE: this endpoint is not used return await self.request( "main/suggest", "search", sign=False, params={ "func": "suggest", "suggest_type": "accurate", "sug_type": "tag", "main_ver": "v1", "keyword": keyword, }, ) ================================================ FILE: hibiapi/api/bilibili/api/v2.py ================================================ from collections.abc import Coroutine from enum import Enum from functools import wraps from typing import Callable, Optional, TypeVar from hibiapi.api.bilibili.api.base import ( BaseBilibiliEndpoint, TimelineType, VideoFormatType, VideoQualityType, ) from hibiapi.utils.decorators import enum_auto_doc from hibiapi.utils.exceptions import ClientSideException from hibiapi.utils.net import AsyncHTTPClient from hibiapi.utils.routing import BaseEndpoint _AnyCallable = TypeVar("_AnyCallable", bound=Callable[..., Coroutine]) def process_keyerror(function: _AnyCallable) -> _AnyCallable: @wraps(function) async def wrapper(*args, **kwargs): try: return await function(*args, **kwargs) except (KeyError, IndexError) as e: raise ClientSideException(detail=str(e)) from None return wrapper # type:ignore @enum_auto_doc class SearchType(str, Enum): """搜索类型""" search = "search" """综合搜索""" suggest = "suggest" """搜索建议""" hot = "hot" """热门""" class BilibiliEndpointV2(BaseEndpoint, cache_endpoints=False): def __init__(self, client: AsyncHTTPClient): super().__init__(client) self.base = BaseBilibiliEndpoint(client) @process_keyerror async def playurl( self, *, aid: int, page: Optional[int] = None, quality: VideoQualityType = VideoQualityType.VIDEO_480P, type: VideoFormatType = VideoFormatType.MP4, ): # NOTE: not completely same with origin video_view = await self.base.view(aid=aid) if page is None: return video_view cid: int = video_view["data"]["pages"][page - 1]["cid"] return await self.base.playurl( aid=aid, cid=cid, quality=quality, type=type, ) async def seasoninfo(self, *, season_id: int): # NOTE: not same with origin return await self.base.season_info(season_id=season_id) async def source(self, *, episode_id: int): return await self.base.bangumi_source(episode_id=episode_id) async def seasonrecommend(self, *, season_id: int): # NOTE: not same with origin return await self.base.season_recommend(season_id=season_id) async def search( self, *, keyword: str = "", type: SearchType = SearchType.search, page: int = 1, pagesize: int = 20, limit: int = 50, ): if type == SearchType.suggest: return await self.base.search_suggest(keyword=keyword) elif type == SearchType.hot: return await self.base.search_hot(limit=limit) else: return await self.base.search( keyword=keyword, page=page, pagesize=pagesize, ) async def timeline( self, *, type: TimelineType = TimelineType.GLOBAL ): # NOTE: not same with origin return await self.base.timeline(type=type) async def space(self, *, vmid: int, page: int = 1, pagesize: int = 10): return await self.base.space( vmid=vmid, page=page, pagesize=pagesize, ) async def archive(self, *, vmid: int, page: int = 1, pagesize: int = 10): return await self.base.space_archive( vmid=vmid, page=page, pagesize=pagesize, ) async def favlist(self, *, fid: int, vmid: int, page: int = 1, pagesize: int = 20): return await self.base.favorite_video( fid=fid, vmid=vmid, page=page, pagesize=pagesize, ) ================================================ FILE: hibiapi/api/bilibili/api/v3.py ================================================ from hibiapi.api.bilibili.api.base import ( BaseBilibiliEndpoint, TimelineType, VideoFormatType, VideoQualityType, ) from hibiapi.utils.net import AsyncHTTPClient from hibiapi.utils.routing import BaseEndpoint class BilibiliEndpointV3(BaseEndpoint, cache_endpoints=False): def __init__(self, client: AsyncHTTPClient): super().__init__(client) self.base = BaseBilibiliEndpoint(client) async def video_info(self, *, aid: int): return await self.base.view(aid=aid) async def video_address( self, *, aid: int, cid: int, quality: VideoQualityType = VideoQualityType.VIDEO_480P, type: VideoFormatType = VideoFormatType.FLV, ): return await self.base.playurl( aid=aid, cid=cid, quality=quality, type=type, ) async def user_info(self, *, uid: int, page: int = 1, size: int = 10): return await self.base.space( vmid=uid, page=page, pagesize=size, ) async def user_uploaded(self, *, uid: int, page: int = 1, size: int = 10): return await self.base.space_archive( vmid=uid, page=page, pagesize=size, ) async def user_favorite(self, *, uid: int, fid: int, page: int = 1, size: int = 10): return await self.base.favorite_video( fid=fid, vmid=uid, page=page, pagesize=size, ) async def season_info(self, *, season_id: int): return await self.base.season_info(season_id=season_id) async def season_recommend(self, *, season_id: int): return await self.base.season_recommend(season_id=season_id) async def season_episode(self, *, episode_id: int): return await self.base.bangumi_source(episode_id=episode_id) async def season_timeline(self, *, type: TimelineType = TimelineType.GLOBAL): return await self.base.timeline(type=type) async def search(self, *, keyword: str, page: int = 1, size: int = 20): return await self.base.search( keyword=keyword, page=page, pagesize=size, ) async def search_recommend(self, *, limit: int = 50): return await self.base.search_hot(limit=limit) async def search_suggestion(self, *, keyword: str): return await self.base.search_suggest(keyword=keyword) ================================================ FILE: hibiapi/api/bilibili/constants.py ================================================ from http.cookies import SimpleCookie from typing import Any from hibiapi.utils.config import APIConfig _CONFIG = APIConfig("bilibili") class BilibiliConstants: SERVER_HOST: dict[str, str] = { "app": "https://app.bilibili.com", "api": "https://api.bilibili.com", "interface": "https://interface.bilibili.com", "main": "https://www.bilibili.com", "bgm": "https://bangumi.bilibili.com", "comment": "https://comment.bilibili.com", "search": "https://s.search.bilibili.com", "mobile": "https://m.bilibili.com", } APP_HOST: str = "http://app.bilibili.com" DEFAULT_PARAMS: dict[str, Any] = { "build": 507000, "device": "android", "platform": "android", "mobi_app": "android", } APP_KEY: str = "1d8b6e7d45233436" SECRET: bytes = b"560c52ccd288fed045859ed18bffd973" ACCESS_KEY: str = "5271b2f0eb92f5f89af4dc39197d8e41" COOKIES: SimpleCookie = SimpleCookie(_CONFIG["net"]["cookie"].as_str()) USER_AGENT: str = _CONFIG["net"]["user-agent"].as_str() CONFIG: APIConfig = _CONFIG ================================================ FILE: hibiapi/api/bilibili/net.py ================================================ from httpx import Cookies from hibiapi.utils.net import BaseNetClient from .constants import BilibiliConstants class NetRequest(BaseNetClient): def __init__(self): super().__init__( headers={"user-agent": BilibiliConstants.USER_AGENT}, cookies=Cookies({k: v.value for k, v in BilibiliConstants.COOKIES.items()}), ) ================================================ FILE: hibiapi/api/netease/__init__.py ================================================ # flake8:noqa:F401 from .api import BitRateType, NeteaseEndpoint, RecordPeriodType, SearchType from .constants import NeteaseConstants from .net import NetRequest ================================================ FILE: hibiapi/api/netease/api.py ================================================ import base64 import json import secrets import string from datetime import timedelta from enum import IntEnum from ipaddress import IPv4Address from random import randint from typing import Annotated, Any, Optional from Cryptodome.Cipher import AES from Cryptodome.Util.Padding import pad from fastapi import Query from hibiapi.api.netease.constants import NeteaseConstants from hibiapi.utils.cache import cache_config from hibiapi.utils.decorators import enum_auto_doc from hibiapi.utils.exceptions import UpstreamAPIException from hibiapi.utils.net import catch_network_error from hibiapi.utils.routing import BaseEndpoint, dont_route @enum_auto_doc class SearchType(IntEnum): """搜索内容类型""" SONG = 1 """单曲""" ALBUM = 10 """专辑""" ARTIST = 100 """歌手""" PLAYLIST = 1000 """歌单""" USER = 1002 """用户""" MV = 1004 """MV""" LYRICS = 1006 """歌词""" DJ = 1009 """主播电台""" VIDEO = 1014 """视频""" @enum_auto_doc class BitRateType(IntEnum): """歌曲码率""" LOW = 64000 MEDIUM = 128000 STANDARD = 198000 HIGH = 320000 @enum_auto_doc class MVResolutionType(IntEnum): """MV分辨率""" QVGA = 240 VGA = 480 HD = 720 FHD = 1080 @enum_auto_doc class RecordPeriodType(IntEnum): """听歌记录时段类型""" WEEKLY = 1 """本周""" ALL = 0 """所有时段""" class _EncryptUtil: alphabets = bytearray(ord(char) for char in string.ascii_letters + string.digits) @staticmethod def _aes(data: bytes, key: bytes) -> bytes: data = pad(data, 16) if len(data) % 16 else data return base64.encodebytes( AES.new( key=key, mode=AES.MODE_CBC, iv=NeteaseConstants.AES_IV, ).encrypt(data) ) @staticmethod def _rsa(data: bytes): result = pow( base=int(data.hex(), 16), exp=NeteaseConstants.RSA_PUBKEY, mod=NeteaseConstants.RSA_MODULUS, ) return f"{result:0>256x}" @classmethod def encrypt(cls, data: dict[str, Any]) -> dict[str, str]: secret = bytes(secrets.choice(cls.alphabets) for _ in range(16)) secure_key = cls._rsa(bytes(reversed(secret))) return { "params": cls._aes( data=cls._aes( data=json.dumps(data).encode(), key=NeteaseConstants.AES_KEY, ), key=secret, ).decode("ascii"), "encSecKey": secure_key, } class NeteaseEndpoint(BaseEndpoint): def _construct_headers(self): headers = self.client.headers.copy() headers["X-Real-IP"] = str( IPv4Address( randint( int(NeteaseConstants.SOURCE_IP_SEGMENT.network_address), int(NeteaseConstants.SOURCE_IP_SEGMENT.broadcast_address), ) ) ) return headers @dont_route @catch_network_error async def request( self, endpoint: str, *, params: Optional[dict[str, Any]] = None ) -> dict[str, Any]: params = { **(params or {}), "csrf_token": self.client.cookies.get("__csrf", ""), } response = await self.client.post( self._join( NeteaseConstants.HOST, endpoint=endpoint, params=params, ), headers=self._construct_headers(), data=_EncryptUtil.encrypt(params), ) response.raise_for_status() if not response.text.strip(): raise UpstreamAPIException( f"Upstream API {endpoint=} returns blank content" ) return response.json() async def search( self, *, s: str, search_type: SearchType = SearchType.SONG, limit: int = 20, offset: int = 0, ): return await self.request( "api/cloudsearch/pc", params={ "s": s, "type": search_type, "limit": limit, "offset": offset, "total": True, }, ) async def artist(self, *, id: int): return await self.request( "weapi/v1/artist/{artist_id}", params={ "artist_id": id, }, ) async def album(self, *, id: int): return await self.request( "weapi/v1/album/{album_id}", params={ "album_id": id, }, ) async def detail( self, *, id: Annotated[list[int], Query()], ): return await self.request( "api/v3/song/detail", params={ "c": json.dumps( [{"id": str(i)} for i in id], ), }, ) @cache_config(ttl=timedelta(minutes=20)) async def song( self, *, id: Annotated[list[int], Query()], br: BitRateType = BitRateType.STANDARD, ): return await self.request( "weapi/song/enhance/player/url", params={ "ids": [str(i) for i in id], "br": br, }, ) async def playlist(self, *, id: int): return await self.request( "weapi/v6/playlist/detail", params={ "id": id, "total": True, "offset": 0, "limit": 1000, "n": 1000, }, ) async def lyric(self, *, id: int): return await self.request( "weapi/song/lyric", params={ "id": id, "os": "pc", "lv": -1, "kv": -1, "tv": -1, }, ) async def mv(self, *, id: int): return await self.request( "api/v1/mv/detail", params={ "id": id, }, ) async def mv_url( self, *, id: int, res: MVResolutionType = MVResolutionType.FHD, ): return await self.request( "weapi/song/enhance/play/mv/url", params={ "id": id, "r": res, }, ) async def comments(self, *, id: int, offset: int = 0, limit: int = 1): return await self.request( "weapi/v1/resource/comments/R_SO_4_{song_id}", params={ "song_id": id, "offset": offset, "total": True, "limit": limit, }, ) async def record(self, *, id: int, period: RecordPeriodType = RecordPeriodType.ALL): return await self.request( "weapi/v1/play/record", params={ "uid": id, "type": period, }, ) async def djradio(self, *, id: int): return await self.request( "api/djradio/v2/get", params={ "id": id, }, ) async def dj(self, *, id: int, offset: int = 0, limit: int = 20, asc: bool = False): # NOTE: Possible not same with origin return await self.request( "weapi/dj/program/byradio", params={ "radioId": id, "offset": offset, "limit": limit, "asc": asc, }, ) async def detail_dj(self, *, id: int): return await self.request( "api/dj/program/detail", params={ "id": id, }, ) async def user(self, *, id: int): return await self.request( "weapi/v1/user/detail/{id}", params={"id": id}, ) async def user_playlist(self, *, id: int, limit: int = 50, offset: int = 0): return await self.request( "weapi/user/playlist", params={ "uid": id, "limit": limit, "offset": offset, }, ) ================================================ FILE: hibiapi/api/netease/constants.py ================================================ from http.cookies import SimpleCookie from ipaddress import IPv4Network from hibiapi.utils.config import APIConfig _Config = APIConfig("netease") class NeteaseConstants: AES_KEY: bytes = b"0CoJUm6Qyw8W8jud" AES_IV: bytes = b"0102030405060708" RSA_PUBKEY: int = int("010001", 16) RSA_MODULUS: int = int( "00e0b509f6259df8642dbc3566290147" "7df22677ec152b5ff68ace615bb7b725" "152b3ab17a876aea8a5aa76d2e417629" "ec4ee341f56135fccf695280104e0312" "ecbda92557c93870114af6c9d05c4f7f" "0c3685b7a46bee255932575cce10b424" "d813cfe4875d3e82047b97ddef52741d" "546b8e289dc6935b3ece0462db0a22b8e7", 16, ) HOST: str = "http://music.163.com" COOKIES: SimpleCookie = SimpleCookie(_Config["net"]["cookie"].as_str()) SOURCE_IP_SEGMENT: IPv4Network = _Config["net"]["source"].get(IPv4Network) DEFAULT_HEADERS: dict[str, str] = { "user-agent": _Config["net"]["user-agent"].as_str(), "referer": "http://music.163.com", } CONFIG: APIConfig = _Config ================================================ FILE: hibiapi/api/netease/net.py ================================================ from httpx import Cookies from hibiapi.utils.net import BaseNetClient from .constants import NeteaseConstants class NetRequest(BaseNetClient): def __init__(self): super().__init__( headers=NeteaseConstants.DEFAULT_HEADERS, cookies=Cookies({k: v.value for k, v in NeteaseConstants.COOKIES.items()}), ) ================================================ FILE: hibiapi/api/pixiv/__init__.py ================================================ # flake8:noqa:F401 from .api import ( IllustType, PixivEndpoints, RankingDate, RankingType, SearchDurationType, SearchModeType, SearchNovelModeType, SearchSortType, ) from .constants import PixivConstants from .net import NetRequest, PixivAuthData ================================================ FILE: hibiapi/api/pixiv/api.py ================================================ import json import re from datetime import date, timedelta from enum import Enum from typing import Any, Literal, Optional, Union, cast, overload from hibiapi.api.pixiv.constants import PixivConstants from hibiapi.api.pixiv.net import NetRequest as PixivNetClient from hibiapi.utils.cache import cache_config from hibiapi.utils.decorators import enum_auto_doc from hibiapi.utils.net import catch_network_error from hibiapi.utils.routing import BaseEndpoint, dont_route, request_headers @enum_auto_doc class IllustType(str, Enum): """画作类型""" illust = "illust" """插画""" manga = "manga" """漫画""" @enum_auto_doc class RankingType(str, Enum): """排行榜内容类型""" day = "day" """日榜""" week = "week" """周榜""" month = "month" """月榜""" day_male = "day_male" """男性向""" day_female = "day_female" """女性向""" week_original = "week_original" """原创周榜""" week_rookie = "week_rookie" """新人周榜""" day_ai = "day_ai" """AI日榜""" day_manga = "day_manga" """漫画日榜""" week_manga = "week_manga" """漫画周榜""" month_manga = "month_manga" """漫画月榜""" week_rookie_manga = "week_rookie_manga" """漫画新人周榜""" day_r18 = "day_r18" day_male_r18 = "day_male_r18" day_female_r18 = "day_female_r18" week_r18 = "week_r18" week_r18g = "week_r18g" day_r18_ai = "day_r18_ai" day_r18_manga = "day_r18_manga" week_r18_manga = "week_r18_manga" @enum_auto_doc class SearchModeType(str, Enum): """搜索匹配类型""" partial_match_for_tags = "partial_match_for_tags" """标签部分一致""" exact_match_for_tags = "exact_match_for_tags" """标签完全一致""" title_and_caption = "title_and_caption" """标题说明文""" @enum_auto_doc class SearchNovelModeType(str, Enum): """搜索匹配类型""" partial_match_for_tags = "partial_match_for_tags" """标签部分一致""" exact_match_for_tags = "exact_match_for_tags" """标签完全一致""" text = "text" """正文""" keyword = "keyword" """关键词""" @enum_auto_doc class SearchSortType(str, Enum): """搜索排序类型""" date_desc = "date_desc" """按日期倒序""" date_asc = "date_asc" """按日期正序""" popular_desc = "popular_desc" """受欢迎降序(Premium功能)""" @enum_auto_doc class SearchDurationType(str, Enum): """搜索时段类型""" within_last_day = "within_last_day" """一天内""" within_last_week = "within_last_week" """一周内""" within_last_month = "within_last_month" """一个月内""" class RankingDate(date): @classmethod def yesterday(cls) -> "RankingDate": yesterday = cls.today() - timedelta(days=1) return cls(yesterday.year, yesterday.month, yesterday.day) def toString(self) -> str: return self.strftime(r"%Y-%m-%d") @classmethod def new(cls, date: date) -> "RankingDate": return cls(date.year, date.month, date.day) class PixivEndpoints(BaseEndpoint): @staticmethod def _parse_accept_language(accept_language: str) -> str: first_language, *_ = accept_language.partition(",") language_code, *_ = first_language.partition(";") return language_code.lower().strip() @overload async def request( self, endpoint: str, *, params: Optional[dict[str, Any]] = None, return_text: Literal[False] = False, ) -> dict[str, Any]: ... @overload async def request( self, endpoint: str, *, params: Optional[dict[str, Any]] = None, return_text: Literal[True], ) -> str: ... @dont_route @catch_network_error async def request( self, endpoint: str, *, params: Optional[dict[str, Any]] = None, return_text: bool = False, ) -> Union[dict[str, Any], str]: headers = self.client.headers.copy() net_client = cast(PixivNetClient, self.client.net_client) async with net_client.auth_lock: auth, token = net_client.get_available_user() if auth is None: auth = await net_client.auth(token) headers["Authorization"] = f"Bearer {auth.access_token}" if language := request_headers.get().get("Accept-Language"): language = self._parse_accept_language(language) headers["Accept-Language"] = language response = await self.client.get( self._join( base=PixivConstants.APP_HOST, endpoint=endpoint, params=params or {}, ), headers=headers, ) if return_text: return response.text return response.json() @cache_config(ttl=timedelta(days=3)) async def illust(self, *, id: int): return await self.request("v1/illust/detail", params={"illust_id": id}) @cache_config(ttl=timedelta(days=1)) async def member(self, *, id: int): return await self.request("v1/user/detail", params={"user_id": id}) async def member_illust( self, *, id: int, illust_type: IllustType = IllustType.illust, page: int = 1, size: int = 30, ): return await self.request( "v1/user/illusts", params={ "user_id": id, "type": illust_type, "offset": (page - 1) * size, }, ) async def favorite( self, *, id: int, tag: Optional[str] = None, max_bookmark_id: Optional[int] = None, ): return await self.request( "v1/user/bookmarks/illust", params={ "user_id": id, "tag": tag, "restrict": "public", "max_bookmark_id": max_bookmark_id or None, }, ) # 用户收藏的小说 async def favorite_novel( self, *, id: int, tag: Optional[str] = None, ): return await self.request( "v1/user/bookmarks/novel", params={ "user_id": id, "tag": tag, "restrict": "public", }, ) async def following(self, *, id: int, page: int = 1, size: int = 30): return await self.request( "v1/user/following", params={ "user_id": id, "offset": (page - 1) * size, }, ) async def follower(self, *, id: int, page: int = 1, size: int = 30): return await self.request( "v1/user/follower", params={ "user_id": id, "offset": (page - 1) * size, }, ) @cache_config(ttl=timedelta(hours=12)) async def rank( self, *, mode: RankingType = RankingType.week, date: Optional[RankingDate] = None, page: int = 1, size: int = 30, ): return await self.request( "v1/illust/ranking", params={ "mode": mode, "date": RankingDate.new(date or RankingDate.yesterday()).toString(), "offset": (page - 1) * size, }, ) async def search( self, *, word: str, mode: SearchModeType = SearchModeType.partial_match_for_tags, order: SearchSortType = SearchSortType.date_desc, duration: Optional[SearchDurationType] = None, page: int = 1, size: int = 30, include_translated_tag_results: bool = True, search_ai_type: bool = True, # 搜索结果是否包含AI作品 ): return await self.request( "v1/search/illust", params={ "word": word, "search_target": mode, "sort": order, "duration": duration, "offset": (page - 1) * size, "include_translated_tag_results": include_translated_tag_results, "search_ai_type": 1 if search_ai_type else 0, }, ) # 热门插画作品预览 async def popular_preview( self, *, word: str, mode: SearchModeType = SearchModeType.partial_match_for_tags, merge_plain_keyword_results: bool = True, include_translated_tag_results: bool = True, filter: str = "for_ios", ): return await self.request( "v1/search/popular-preview/illust", params={ "word": word, "search_target": mode, "merge_plain_keyword_results": merge_plain_keyword_results, "include_translated_tag_results": include_translated_tag_results, "filter": filter, }, ) async def search_user( self, *, word: str, page: int = 1, size: int = 30, ): return await self.request( "v1/search/user", params={"word": word, "offset": (page - 1) * size}, ) async def tags_autocomplete( self, *, word: str, merge_plain_keyword_results: bool = True, ): return await self.request( "/v2/search/autocomplete", params={ "word": word, "merge_plain_keyword_results": merge_plain_keyword_results, }, ) @cache_config(ttl=timedelta(hours=12)) async def tags(self): return await self.request("v1/trending-tags/illust") @cache_config(ttl=timedelta(minutes=15)) async def related(self, *, id: int, page: int = 1, size: int = 30): return await self.request( "v2/illust/related", params={ "illust_id": id, "offset": (page - 1) * size, }, ) @cache_config(ttl=timedelta(days=3)) async def ugoira_metadata(self, *, id: int): return await self.request( "v1/ugoira/metadata", params={ "illust_id": id, }, ) # 大家的新作品(插画) async def illust_new( self, *, content_type: str = "illust", ): return await self.request( "v1/illust/new", params={ "content_type": content_type, "filter": "for_ios", }, ) # pixivision(亮点/特辑) 列表 async def spotlights( self, *, category: str = "all", page: int = 1, size: int = 10, ): return await self.request( "v1/spotlight/articles", params={ "filter": "for_ios", "category": category, "offset": (page - 1) * size, }, ) # 插画评论 async def illust_comments( self, *, id: int, page: int = 1, size: int = 30, ): return await self.request( "v3/illust/comments", params={ "illust_id": id, "offset": (page - 1) * size, }, ) # 插画评论回复 async def illust_comment_replies( self, *, id: int, ): return await self.request( "v2/illust/comment/replies", params={ "comment_id": id, }, ) # 小说评论 async def novel_comments( self, *, id: int, page: int = 1, size: int = 30, ): return await self.request( "v3/novel/comments", params={ "novel_id": id, "offset": (page - 1) * size, }, ) # 小说评论回复 async def novel_comment_replies( self, *, id: int, ): return await self.request( "v2/novel/comment/replies", params={ "comment_id": id, }, ) # 小说排行榜 async def rank_novel( self, *, mode: str = "day", date: Optional[RankingDate] = None, page: int = 1, size: int = 30, ): return await self.request( "v1/novel/ranking", params={ "mode": mode, "date": RankingDate.new(date or RankingDate.yesterday()).toString(), "offset": (page - 1) * size, }, ) async def member_novel(self, *, id: int, page: int = 1, size: int = 30): return await self.request( "/v1/user/novels", params={ "user_id": id, "offset": (page - 1) * size, }, ) async def novel_series(self, *, id: int): return await self.request("/v2/novel/series", params={"series_id": id}) async def novel_detail(self, *, id: int): return await self.request("/v2/novel/detail", params={"novel_id": id}) # 已被官方移除,调用 webview/v2/novel 作兼容处理 async def novel_text(self, *, id: int): # return await self.request("/v1/novel/text", params={"novel_id": id}) response = await self.webview_novel(id=id) return {"novel_text": response["text"] or ""} # 获取小说 HTML 后解析 JSON async def webview_novel(self, *, id: int): response = await self.request( "webview/v2/novel", params={ "id": id, "viewer_version": "20221031_ai", }, return_text=True, ) novel_match = re.search(r"novel:\s+(?P{.+?}),\s+isOwnWork", response) return json.loads(novel_match["data"] if novel_match else response) @cache_config(ttl=timedelta(hours=12)) async def tags_novel(self): return await self.request("v1/trending-tags/novel") async def search_novel( self, *, word: str, mode: SearchNovelModeType = SearchNovelModeType.partial_match_for_tags, sort: SearchSortType = SearchSortType.date_desc, merge_plain_keyword_results: bool = True, include_translated_tag_results: bool = True, duration: Optional[SearchDurationType] = None, page: int = 1, size: int = 30, search_ai_type: bool = True, # 搜索结果是否包含AI作品 ): return await self.request( "/v1/search/novel", params={ "word": word, "search_target": mode, "sort": sort, "merge_plain_keyword_results": merge_plain_keyword_results, "include_translated_tag_results": include_translated_tag_results, "duration": duration, "offset": (page - 1) * size, "search_ai_type": 1 if search_ai_type else 0, }, ) # 热门小说作品预览 async def popular_preview_novel( self, *, word: str, mode: SearchNovelModeType = SearchNovelModeType.partial_match_for_tags, merge_plain_keyword_results: bool = True, include_translated_tag_results: bool = True, filter: str = "for_ios", ): return await self.request( "v1/search/popular-preview/novel", params={ "word": word, "search_target": mode, "merge_plain_keyword_results": merge_plain_keyword_results, "include_translated_tag_results": include_translated_tag_results, "filter": filter, }, ) async def novel_new(self, *, max_novel_id: Optional[int] = None): return await self.request( "/v1/novel/new", params={"max_novel_id": max_novel_id} ) # 人气直播列表 async def live_list(self, *, page: int = 1, size: int = 30): params = {"list_type": "popular", "offset": (page - 1) * size} if not params["offset"]: del params["offset"] return await self.request("v1/live/list", params=params) # 相关小说作品 async def related_novel(self, *, id: int, page: int = 1, size: int = 30): return await self.request( "v1/novel/related", params={ "novel_id": id, "offset": (page - 1) * size, }, ) # 相关用户 async def related_member(self, *, id: int): return await self.request("v1/user/related", params={"seed_user_id": id}) # 漫画系列 async def illust_series(self, *, id: int, page: int = 1, size: int = 30): return await self.request( "v1/illust/series", params={"illust_series_id": id, "offset": (page - 1) * size}, ) # 用户的漫画系列 async def member_illust_series(self, *, id: int, page: int = 1, size: int = 30): return await self.request( "v1/user/illust-series", params={"user_id": id, "offset": (page - 1) * size}, ) # 用户的小说系列 async def member_novel_series(self, *, id: int, page: int = 1, size: int = 30): return await self.request( "v1/user/novel-series", params={"user_id": id, "offset": (page - 1) * size} ) ================================================ FILE: hibiapi/api/pixiv/constants.py ================================================ from typing import Any from hibiapi.utils.config import APIConfig class PixivConstants: DEFAULT_HEADERS: dict[str, Any] = { "App-OS": "ios", "App-OS-Version": "14.6", "User-Agent": "PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)", } CLIENT_ID: str = "MOBrBDS8blbauoSck0ZfDbtuzpyT" CLIENT_SECRET: str = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj" HASH_SECRET: bytes = ( b"28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c" ) CONFIG: APIConfig = APIConfig("pixiv") APP_HOST: str = "https://app-api.pixiv.net" AUTH_HOST: str = "https://oauth.secure.pixiv.net" ================================================ FILE: hibiapi/api/pixiv/net.py ================================================ import asyncio import hashlib from datetime import datetime, timedelta, timezone from itertools import cycle from httpx import URL from pydantic import BaseModel, Extra, Field from hibiapi.utils.log import logger from hibiapi.utils.net import BaseNetClient from .constants import PixivConstants class AccountDataModel(BaseModel): class Config: extra = Extra.allow class PixivUserData(AccountDataModel): account: str id: int is_premium: bool mail_address: str name: str class PixivAuthData(AccountDataModel): time: datetime = Field(default_factory=datetime.now) expires_in: int access_token: str refresh_token: str user: PixivUserData class NetRequest(BaseNetClient): def __init__(self, tokens: list[str]): super().__init__( headers=PixivConstants.DEFAULT_HEADERS.copy(), proxies=PixivConstants.CONFIG["proxy"].as_dict(), ) self.user_tokens = cycle(tokens) self.auth_lock = asyncio.Lock() self.user_tokens_dict: dict[str, PixivAuthData] = {} self.headers["accept-language"] = PixivConstants.CONFIG["language"].as_str() def get_available_user(self): token = next(self.user_tokens) if (auth_data := self.user_tokens_dict.get(token)) and ( auth_data.time + timedelta(minutes=1, seconds=auth_data.expires_in) > datetime.now() ): return auth_data, token return None, token async def auth(self, refresh_token: str): url = URL(PixivConstants.AUTH_HOST).join("/auth/token") time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") headers = { **self.headers, "X-Client-Time": time, "X-Client-Hash": hashlib.md5( time.encode() + PixivConstants.HASH_SECRET ).hexdigest(), } payload = { "get_secure_url": 1, "client_id": PixivConstants.CLIENT_ID, "client_secret": PixivConstants.CLIENT_SECRET, "grant_type": "refresh_token", "refresh_token": refresh_token, } async with self as client: response = await client.post(url, data=payload, headers=headers) response.raise_for_status() self.user_tokens_dict[refresh_token] = PixivAuthData.parse_obj(response.json()) user_data = self.user_tokens_dict[refresh_token].user logger.opt(colors=True).info( f"Pixiv account {user_data.id} info Updated: " f"{user_data.name}({user_data.account})." ) return self.user_tokens_dict[refresh_token] ================================================ FILE: hibiapi/api/qrcode.py ================================================ from datetime import datetime from enum import Enum from io import BytesIO from os import fdopen from pathlib import Path from typing import Literal, Optional, cast from PIL import Image from pydantic import AnyHttpUrl, BaseModel, Field, validate_arguments from pydantic.color import Color from qrcode import constants from qrcode.image.pil import PilImage from qrcode.main import QRCode from hibiapi.utils.config import APIConfig from hibiapi.utils.decorators import ToAsync, enum_auto_doc from hibiapi.utils.exceptions import ClientSideException from hibiapi.utils.net import BaseNetClient from hibiapi.utils.routing import BaseHostUrl from hibiapi.utils.temp import TempFile Config = APIConfig("qrcode") class HostUrl(BaseHostUrl): allowed_hosts = Config["qrcode"]["icon-site"].get(list[str]) @enum_auto_doc class QRCodeLevel(str, Enum): """二维码容错率""" LOW = "L" """最低容错率""" MEDIUM = "M" """中等容错率""" QUARTILE = "Q" """高容错率""" HIGH = "H" """最高容错率""" @enum_auto_doc class ReturnEncode(str, Enum): """二维码返回的编码方式""" raw = "raw" """直接重定向到二维码图片""" json = "json" """返回JSON格式的二维码信息""" js = "js" jsc = "jsc" COLOR_WHITE = Color("FFFFFF") COLOR_BLACK = Color("000000") class QRInfo(BaseModel): url: Optional[AnyHttpUrl] = None path: Path time: datetime = Field(default_factory=datetime.now) data: str logo: Optional[HostUrl] = None level: QRCodeLevel = QRCodeLevel.MEDIUM size: int = 200 code: Literal[0] = 0 status: Literal["success"] = "success" @classmethod @validate_arguments async def new( cls, text: str, *, size: int = Field( 200, gt=Config["qrcode"]["min-size"].as_number(), lt=Config["qrcode"]["max-size"].as_number(), ), logo: Optional[HostUrl] = None, level: QRCodeLevel = QRCodeLevel.MEDIUM, bgcolor: Color = COLOR_WHITE, fgcolor: Color = COLOR_BLACK, ): icon_stream = None if logo is not None: async with BaseNetClient() as client: response = await client.get( logo, headers={"user-agent": "HibiAPI@GitHub"}, timeout=6 ) response.raise_for_status() icon_stream = BytesIO(response.content) return cls( data=text, logo=logo, level=level, size=size, path=await cls._generate( text, size=size, level=level, icon_stream=icon_stream, bgcolor=bgcolor.as_hex(), fgcolor=fgcolor.as_hex(), ), ) @classmethod @ToAsync def _generate( cls, text: str, *, size: int = 200, level: QRCodeLevel = QRCodeLevel.MEDIUM, icon_stream: Optional[BytesIO] = None, bgcolor: str = "#FFFFFF", fgcolor: str = "#000000", ) -> Path: qr = QRCode( error_correction={ QRCodeLevel.LOW: constants.ERROR_CORRECT_L, QRCodeLevel.MEDIUM: constants.ERROR_CORRECT_M, QRCodeLevel.QUARTILE: constants.ERROR_CORRECT_Q, QRCodeLevel.HIGH: constants.ERROR_CORRECT_H, }[level], border=2, box_size=8, ) qr.add_data(text) image = cast( Image.Image, qr.make_image( PilImage, back_color=bgcolor, fill_color=fgcolor, ).get_image(), ) image = image.resize((size, size)) if icon_stream is not None: try: icon = Image.open(icon_stream) except ValueError as e: raise ClientSideException("Invalid image format.") from e icon_width, icon_height = icon.size image.paste( icon, box=( int(size / 2 - icon_width / 2), int(size / 2 - icon_height / 2), int(size / 2 + icon_width / 2), int(size / 2 + icon_height / 2), ), mask=icon if icon.mode == "RGBA" else None, ) descriptor, path = TempFile.create(".png") with fdopen(descriptor, "wb") as f: image.save(f, format="PNG") return path ================================================ FILE: hibiapi/api/sauce/__init__.py ================================================ # flake8:noqa:F401 from .api import DeduplicateType, HostUrl, SauceEndpoint, UploadFileIO from .constants import SauceConstants from .net import NetRequest ================================================ FILE: hibiapi/api/sauce/api.py ================================================ import random from enum import IntEnum from io import BytesIO from typing import Any, Optional, overload from httpx import HTTPError from hibiapi.api.sauce.constants import SauceConstants from hibiapi.utils.decorators import enum_auto_doc from hibiapi.utils.exceptions import ClientSideException from hibiapi.utils.net import catch_network_error from hibiapi.utils.routing import BaseEndpoint, BaseHostUrl class UnavailableSourceException(ClientSideException): code = 422 detail = "given image is not avaliable to fetch" class ImageSourceOversizedException(UnavailableSourceException): code = 413 detail = ( "given image size is rather than maximum limit " f"{SauceConstants.IMAGE_MAXIMUM_SIZE} bytes" ) class HostUrl(BaseHostUrl): allowed_hosts = SauceConstants.IMAGE_ALLOWED_HOST class UploadFileIO(BytesIO): @classmethod def __get_validators__(cls): yield cls.validate @classmethod def validate(cls, v: Any) -> BytesIO: if not isinstance(v, BytesIO): raise ValueError(f"Expected UploadFile, received: {type(v)}") return v @enum_auto_doc class DeduplicateType(IntEnum): DISABLED = 0 """no result deduplicating""" IDENTIFIER = 1 """consolidate search results and deduplicate by item identifier""" ALL = 2 """all implemented deduplicate methods such as by series name""" class SauceEndpoint(BaseEndpoint, cache_endpoints=False): base = "https://saucenao.com" async def fetch(self, host: HostUrl) -> UploadFileIO: try: response = await self.client.get( url=host, headers=SauceConstants.IMAGE_HEADERS, timeout=SauceConstants.IMAGE_TIMEOUT, ) response.raise_for_status() if len(response.content) > SauceConstants.IMAGE_MAXIMUM_SIZE: raise ImageSourceOversizedException return UploadFileIO(response.content) except HTTPError as e: raise UnavailableSourceException(detail=str(e)) from e @catch_network_error async def request( self, *, file: UploadFileIO, params: dict[str, Any] ) -> dict[str, Any]: response = await self.client.post( url=self._join( self.base, "search.php", params={ **params, "api_key": random.choice(SauceConstants.API_KEY), "output_type": 2, }, ), files={"file": file}, ) if response.status_code >= 500: response.raise_for_status() return response.json() @overload async def search( self, *, url: HostUrl, size: int = 30, deduplicate: DeduplicateType = DeduplicateType.ALL, database: Optional[int] = None, enabled_mask: Optional[int] = None, disabled_mask: Optional[int] = None, ) -> dict[str, Any]: ... @overload async def search( self, *, file: UploadFileIO, size: int = 30, deduplicate: DeduplicateType = DeduplicateType.ALL, database: Optional[int] = None, enabled_mask: Optional[int] = None, disabled_mask: Optional[int] = None, ) -> dict[str, Any]: ... async def search( self, *, url: Optional[HostUrl] = None, file: Optional[UploadFileIO] = None, size: int = 30, deduplicate: DeduplicateType = DeduplicateType.ALL, database: Optional[int] = None, enabled_mask: Optional[int] = None, disabled_mask: Optional[int] = None, ): if url is not None: file = await self.fetch(url) assert file is not None return await self.request( file=file, params={ "dbmask": enabled_mask, "dbmaski": disabled_mask, "db": database, "numres": size, "dedupe": deduplicate, }, ) ================================================ FILE: hibiapi/api/sauce/constants.py ================================================ from typing import Any from hibiapi.utils.config import APIConfig _Config = APIConfig("sauce") class SauceConstants: CONFIG: APIConfig = _Config API_KEY: list[str] = _Config["net"]["api-key"].as_str_seq() USER_AGENT: str = _Config["net"]["user-agent"].as_str() PROXIES: dict[str, str] = _Config["proxy"].as_dict() IMAGE_HEADERS: dict[str, Any] = _Config["image"]["headers"].as_dict() IMAGE_ALLOWED_HOST: list[str] = _Config["image"]["allowed"].get(list[str]) IMAGE_MAXIMUM_SIZE: int = _Config["image"]["max-size"].as_number() * 1024 IMAGE_TIMEOUT: int = _Config["image"]["timeout"].as_number() ================================================ FILE: hibiapi/api/sauce/net.py ================================================ from hibiapi.utils.net import BaseNetClient from .constants import SauceConstants class NetRequest(BaseNetClient): def __init__(self): super().__init__( headers={"user-agent": SauceConstants.USER_AGENT}, proxies=SauceConstants.PROXIES, ) ================================================ FILE: hibiapi/api/tieba/__init__.py ================================================ # flake8:noqa:F401 from .api import Config, TiebaEndpoint from .net import NetRequest ================================================ FILE: hibiapi/api/tieba/api.py ================================================ import hashlib from enum import Enum from random import randint from typing import Any, Optional from hibiapi.utils.config import APIConfig from hibiapi.utils.net import catch_network_error from hibiapi.utils.routing import BaseEndpoint, dont_route Config = APIConfig("tieba") class TiebaSignUtils: salt = b"tiebaclient!!!" @staticmethod def random_digit(length: int) -> str: return "".join(map(str, [randint(0, 9) for _ in range(length)])) @staticmethod def construct_content(params: dict[str, Any]) -> bytes: # NOTE: this function used to construct form content WITHOUT urlencode # Don't ask me why this is necessary, ask Tieba's programmers instead return b"&".join( map( lambda k, v: ( k.encode() + b"=" + str(v.value if isinstance(v, Enum) else v).encode() ), params.keys(), params.values(), ) ) @classmethod def sign(cls, params: dict[str, Any]) -> bytes: params.update( { "_client_id": ( "wappc_" + cls.random_digit(13) + "_" + cls.random_digit(3) ), "_client_type": 2, "_client_version": "9.9.8.32", **{ k.upper(): str(v).strip() for k, v in Config["net"]["params"].as_dict().items() if v }, } ) params = {k: params[k] for k in sorted(params.keys())} params["sign"] = ( hashlib.md5(cls.construct_content(params).replace(b"&", b"") + cls.salt) .hexdigest() .upper() ) return cls.construct_content(params) class TiebaEndpoint(BaseEndpoint): base = "http://c.tieba.baidu.com" @dont_route @catch_network_error async def request( self, endpoint: str, *, params: Optional[dict[str, Any]] = None ) -> dict[str, Any]: response = await self.client.post( url=self._join(self.base, endpoint, {}), content=TiebaSignUtils.sign(params or {}), ) response.raise_for_status() return response.json() async def post_list(self, *, name: str, page: int = 1, size: int = 50): return await self.request( "c/f/frs/page", params={ "kw": name, "pn": page, "rn": size, }, ) async def post_detail( self, *, tid: int, page: int = 1, size: int = 50, reversed: bool = False, ): return await self.request( "c/f/pb/page", params={ **({"last": 1, "r": 1} if reversed else {}), "kz": tid, "pn": page, "rn": size, }, ) async def subpost_detail( self, *, tid: int, pid: int, page: int = 1, size: int = 50, ): return await self.request( "c/f/pb/floor", params={ "kz": tid, "pid": pid, "pn": page, "rn": size, }, ) async def user_profile(self, *, uid: int): return await self.request( "c/u/user/profile", params={ "uid": uid, "need_post_count": 1, "has_plist": 1, }, ) async def user_subscribed( self, *, uid: int, page: int = 1 ): # XXX This API required user login! return await self.request( "c/f/forum/like", params={ "is_guest": 0, "uid": uid, "page_no": page, }, ) ================================================ FILE: hibiapi/api/tieba/net.py ================================================ from hibiapi.utils.net import BaseNetClient class NetRequest(BaseNetClient): pass ================================================ FILE: hibiapi/api/wallpaper/__init__.py ================================================ # flake8:noqa:F401 from .api import Config, WallpaperCategoryType, WallpaperEndpoint, WallpaperOrderType from .net import NetRequest ================================================ FILE: hibiapi/api/wallpaper/api.py ================================================ from datetime import timedelta from enum import Enum from typing import Any, Optional from hibiapi.utils.cache import cache_config from hibiapi.utils.config import APIConfig from hibiapi.utils.decorators import enum_auto_doc from hibiapi.utils.net import catch_network_error from hibiapi.utils.routing import BaseEndpoint, dont_route Config = APIConfig("wallpaper") @enum_auto_doc class WallpaperCategoryType(str, Enum): """壁纸分类""" girl = "girl" """女生""" animal = "animal" """动物""" landscape = "landscape" """自然""" anime = "anime" """二次元""" drawn = "drawn" """手绘""" mechanics = "mechanics" """机械""" boy = "boy" """男生""" game = "game" """游戏""" text = "text" """文字""" CATEGORY: dict[WallpaperCategoryType, str] = { WallpaperCategoryType.girl: "4e4d610cdf714d2966000000", WallpaperCategoryType.animal: "4e4d610cdf714d2966000001", WallpaperCategoryType.landscape: "4e4d610cdf714d2966000002", WallpaperCategoryType.anime: "4e4d610cdf714d2966000003", WallpaperCategoryType.drawn: "4e4d610cdf714d2966000004", WallpaperCategoryType.mechanics: "4e4d610cdf714d2966000005", WallpaperCategoryType.boy: "4e4d610cdf714d2966000006", WallpaperCategoryType.game: "4e4d610cdf714d2966000007", WallpaperCategoryType.text: "5109e04e48d5b9364ae9ac45", } @enum_auto_doc class WallpaperOrderType(str, Enum): """壁纸排序方式""" hot = "hot" """热门""" new = "new" """最新""" class WallpaperEndpoint(BaseEndpoint): base = "http://service.aibizhi.adesk.com" @dont_route @catch_network_error async def request( self, endpoint: str, *, params: Optional[dict[str, Any]] = None ) -> dict[str, Any]: response = await self.client.get( self._join( base=WallpaperEndpoint.base, endpoint=endpoint, params=params or {}, ) ) return response.json() # 壁纸有防盗链token, 不建议长时间缓存 @cache_config(ttl=timedelta(hours=2)) async def wallpaper( self, *, category: WallpaperCategoryType, limit: int = 20, skip: int = 0, adult: bool = True, order: WallpaperOrderType = WallpaperOrderType.hot, ): return await self.request( "v1/wallpaper/category/{category}/wallpaper", params={ "limit": limit, "skip": skip, "adult": adult, "order": order, "first": 0, "category": CATEGORY[category], }, ) # 壁纸有防盗链token, 不建议长时间缓存 @cache_config(ttl=timedelta(hours=2)) async def vertical( self, *, category: WallpaperCategoryType, limit: int = 20, skip: int = 0, adult: bool = True, order: WallpaperOrderType = WallpaperOrderType.hot, ): return await self.request( "v1/vertical/category/{category}/vertical", params={ "limit": limit, "skip": skip, "adult": adult, "order": order, "first": 0, "category": CATEGORY[category], }, ) ================================================ FILE: hibiapi/api/wallpaper/constants.py ================================================ from hibiapi.utils.config import APIConfig _CONFIG = APIConfig("wallpaper") class WallpaperConstants: CONFIG: APIConfig = _CONFIG USER_AGENT: str = _CONFIG["net"]["user-agent"].as_str() ================================================ FILE: hibiapi/api/wallpaper/net.py ================================================ from hibiapi.utils.net import BaseNetClient from .constants import WallpaperConstants class NetRequest(BaseNetClient): def __init__(self): super().__init__(headers={"user-agent": WallpaperConstants.USER_AGENT}) ================================================ FILE: hibiapi/app/__init__.py ================================================ # flake8:noqa:F401 from . import application, handlers, middlewares app = application.app ================================================ FILE: hibiapi/app/application.py ================================================ import asyncio import re from contextlib import asynccontextmanager from ipaddress import ip_address from secrets import compare_digest from typing import Annotated import sentry_sdk from fastapi import Depends, FastAPI, Request, Response from fastapi.responses import RedirectResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from sentry_sdk.integrations.logging import LoggingIntegration from hibiapi import __version__ from hibiapi.app.routes import router as ImplRouter from hibiapi.utils.cache import cache from hibiapi.utils.config import Config from hibiapi.utils.exceptions import ClientSideException, RateLimitReachedException from hibiapi.utils.log import logger from hibiapi.utils.net import BaseNetClient from hibiapi.utils.temp import TempFile DESCRIPTION = ( """ **A program that implements easy-to-use APIs for a variety of commonly used sites** - *Documents*: - [Redoc](/docs) (Easier to read and more beautiful) - [Swagger UI](/docs/test) (Integrated interactive testing function) Project: [mixmoe/HibiAPI](https://github.com/mixmoe/HibiAPI) """ + Config["content"]["slogan"].as_str().strip() ).strip() if Config["log"]["sentry"]["enabled"].as_bool(): sentry_sdk.init( dsn=Config["log"]["sentry"]["dsn"].as_str(), send_default_pii=Config["log"]["sentry"]["pii"].as_bool(), integrations=[LoggingIntegration(level=None, event_level=None)], traces_sample_rate=Config["log"]["sentry"]["sample"].get(float), ) else: sentry_sdk.init() class AuthorizationModel(BaseModel): username: str password: str AUTHORIZATION_ENABLED = Config["authorization"]["enabled"].as_bool() AUTHORIZATION_ALLOWED = Config["authorization"]["allowed"].get(list[AuthorizationModel]) security = HTTPBasic() async def basic_authorization_depend( credentials: Annotated[HTTPBasicCredentials, Depends(security)], ): # NOTE: We use `compare_digest` to avoid timing attacks. # Ref: https://fastapi.tiangolo.com/advanced/security/http-basic-auth/ for allowed in AUTHORIZATION_ALLOWED: if compare_digest(credentials.username, allowed.username) and compare_digest( credentials.password, allowed.password ): return credentials.username, credentials.password raise ClientSideException( f"Invalid credentials for user {credentials.username!r}", status_code=401, headers={"WWW-Authenticate": "Basic"}, ) RATE_LIMIT_ENABLED = Config["limit"]["enabled"].as_bool() RATE_LIMIT_MAX = Config["limit"]["max"].as_number() RATE_LIMIT_INTERVAL = Config["limit"]["interval"].as_number() async def rate_limit_depend(request: Request): if not request.client: return try: client_ip = ip_address(request.client.host) client_ip_hex = client_ip.packed.hex() limit_key = f"rate_limit:IPv{client_ip.version}-{client_ip_hex:x}" except ValueError: limit_key = f"rate_limit:fallback-{request.client.host}" request_count = await cache.incr(limit_key) if request_count <= 1: await cache.expire(limit_key, timeout=RATE_LIMIT_INTERVAL) elif request_count > RATE_LIMIT_MAX: limit_remain: int = await cache.get_expire(limit_key) raise RateLimitReachedException(headers={"Retry-After": limit_remain}) return async def flush_sentry(): client = sentry_sdk.Hub.current.client if client is not None: client.close() sentry_sdk.flush() logger.debug("Sentry client has been closed") async def cleanup_clients(): opened_clients = [ client for client in BaseNetClient.clients if not client.is_closed ] if opened_clients: await asyncio.gather( *map(lambda client: client.aclose(), opened_clients), return_exceptions=True, ) logger.debug(f"Cleaned {len(opened_clients)} unclosed HTTP clients") @asynccontextmanager async def fastapi_lifespan(app: FastAPI): yield await asyncio.gather(cleanup_clients(), flush_sentry()) app = FastAPI( title="HibiAPI", version=__version__, description=DESCRIPTION, docs_url="/docs/test", redoc_url="/docs", lifespan=fastapi_lifespan, ) app.include_router( ImplRouter, prefix="/api", dependencies=( ([Depends(basic_authorization_depend)] if AUTHORIZATION_ENABLED else []) + ([Depends(rate_limit_depend)] if RATE_LIMIT_ENABLED else []) ), ) app.mount("/temp", StaticFiles(directory=TempFile.path, check_dir=False)) @app.get("/", include_in_schema=False) async def redirect(): return Response(status_code=302, headers={"Location": "/docs"}) @app.get("/robots.txt", include_in_schema=False) async def robots(): content = Config["content"]["robots"].as_str().strip() return Response(content, status_code=200) @app.middleware("http") async def redirect_workaround_middleware(request: Request, call_next): """Temporary redirection workaround for #12""" if matched := re.match( r"^/(qrcode|pixiv|netease|bilibili)/(\w*)$", request.url.path ): service, path = matched.groups() redirect_url = request.url.replace(path=f"/api/{service}/{path}") return RedirectResponse(redirect_url, status_code=301) return await call_next(request) ================================================ FILE: hibiapi/app/handlers.py ================================================ from fastapi import Request, Response from fastapi.exceptions import HTTPException as FastAPIHTTPException from fastapi.exceptions import RequestValidationError as FastAPIValidationError from pydantic.error_wrappers import ValidationError as PydanticValidationError from starlette.exceptions import HTTPException as StarletteHTTPException from hibiapi.utils import exceptions from hibiapi.utils.log import logger from .application import app @app.exception_handler(exceptions.BaseServerException) async def exception_handler( request: Request, exc: exceptions.BaseServerException, ) -> Response: if isinstance(exc, exceptions.UncaughtException): logger.opt(exception=exc).exception(f"Uncaught exception raised {exc.data=}:") exc.data.url = str(request.url) # type:ignore return Response( content=exc.data.json(), status_code=exc.data.code, headers=exc.data.headers, media_type="application/json", ) @app.exception_handler(StarletteHTTPException) async def override_handler( request: Request, exc: StarletteHTTPException, ): return await exception_handler( request, exceptions.BaseHTTPException( exc.detail, code=exc.status_code, headers={} if not isinstance(exc, FastAPIHTTPException) else exc.headers, ), ) @app.exception_handler(AssertionError) async def assertion_handler(request: Request, exc: AssertionError): return await exception_handler( request, exceptions.ClientSideException(detail=f"Assertion: {exc}"), ) @app.exception_handler(FastAPIValidationError) @app.exception_handler(PydanticValidationError) async def validation_handler(request: Request, exc: PydanticValidationError): return await exception_handler( request, exceptions.ValidationException(detail=str(exc), validation=exc.errors()), ) ================================================ FILE: hibiapi/app/middlewares.py ================================================ from collections.abc import Awaitable from datetime import datetime from typing import Callable from fastapi import Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.trustedhost import TrustedHostMiddleware from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.integrations.httpx import HttpxIntegration from starlette.datastructures import MutableHeaders from hibiapi.utils.config import Config from hibiapi.utils.exceptions import BaseServerException, UncaughtException from hibiapi.utils.log import LoguruHandler, logger from hibiapi.utils.routing import request_headers, response_headers from .application import app from .handlers import exception_handler RequestHandler = Callable[[Request], Awaitable[Response]] if Config["server"]["gzip"].as_bool(): app.add_middleware(GZipMiddleware) app.add_middleware( CORSMiddleware, allow_origins=Config["server"]["cors"]["origins"].get(list[str]), allow_credentials=Config["server"]["cors"]["credentials"].as_bool(), allow_methods=Config["server"]["cors"]["methods"].get(list[str]), allow_headers=Config["server"]["cors"]["headers"].get(list[str]), ) app.add_middleware( TrustedHostMiddleware, allowed_hosts=Config["server"]["allowed"].get(list[str]), ) app.add_middleware(SentryAsgiMiddleware) HttpxIntegration.setup_once() @app.middleware("http") async def request_logger(request: Request, call_next: RequestHandler) -> Response: start_time = datetime.now() host, port = request.client or (None, None) response = await call_next(request) process_time = (datetime.now() - start_time).total_seconds() * 1000 response_headers.get().setdefault("X-Process-Time", f"{process_time:.3f}") bg, fg = ( ("green", "red") if response.status_code < 400 else ("yellow", "blue") if response.status_code < 500 else ("red", "green") ) status_code, method = response.status_code, request.method.upper() user_agent = ( LoguruHandler.escape_tag(request.headers["user-agent"]) if "user-agent" in request.headers else "Unknown" ) logger.info( f"{host}:{port}" f" | <{bg.upper()}><{fg}>{method}" f" | {str(request.url)!r}" f" | {process_time:.3f}ms" f" | {user_agent}" f" | <{bg}>{status_code}" ) return response @app.middleware("http") async def contextvar_setter(request: Request, call_next: RequestHandler): request_headers.set(request.headers) response_headers.set(MutableHeaders()) response = await call_next(request) response.headers.update({**response_headers.get()}) return response @app.middleware("http") async def uncaught_exception_handler( request: Request, call_next: RequestHandler ) -> Response: try: response = await call_next(request) except Exception as error: response = await exception_handler( request, exc=( error if isinstance(error, BaseServerException) else UncaughtException.with_exception(error) ), ) return response ================================================ FILE: hibiapi/app/routes/__init__.py ================================================ from typing import Protocol, cast from hibiapi.app.routes import ( bika, bilibili, netease, pixiv, qrcode, sauce, tieba, wallpaper, ) from hibiapi.utils.config import APIConfig from hibiapi.utils.exceptions import ExceptionReturn from hibiapi.utils.log import logger from hibiapi.utils.routing import SlashRouter router = SlashRouter( responses={ code: { "model": ExceptionReturn, } for code in (400, 422, 500, 502) } ) class RouteInterface(Protocol): router: SlashRouter __mount__: str __config__: APIConfig modules = cast( list[RouteInterface], [bilibili, netease, pixiv, qrcode, sauce, tieba, wallpaper, bika], ) for module in modules: mount = ( mount_point if (mount_point := module.__mount__).startswith("/") else f"/{mount_point}" ) if not module.__config__["enabled"].as_bool(): logger.warning( f"API Route {mount} has been " "disabled in config." ) continue router.include_router(module.router, prefix=mount) ================================================ FILE: hibiapi/app/routes/bika.py ================================================ from typing import Annotated from fastapi import Depends, Header from hibiapi.api.bika import ( BikaConstants, BikaEndpoints, BikaLogin, ImageQuality, NetRequest, ) from hibiapi.utils.log import logger from hibiapi.utils.routing import EndpointRouter try: BikaConstants.CONFIG["account"].get(BikaLogin) except Exception as e: logger.warning(f"Bika account misconfigured: {e}") BikaConstants.CONFIG["enabled"].set(False) async def x_image_quality( x_image_quality: Annotated[ImageQuality, Header()] = ImageQuality.medium, ): if x_image_quality is None: return BikaConstants.CONFIG["image_quality"].get(ImageQuality) return x_image_quality __mount__, __config__ = "bika", BikaConstants.CONFIG router = EndpointRouter(tags=["Bika"], dependencies=[Depends(x_image_quality)]) BikaAPIRoot = NetRequest() router.include_endpoint(BikaEndpoints, BikaAPIRoot) ================================================ FILE: hibiapi/app/routes/bilibili/__init__.py ================================================ from hibiapi.api.bilibili import BilibiliConstants from hibiapi.app.routes.bilibili.v2 import router as RouterV2 from hibiapi.app.routes.bilibili.v3 import router as RouterV3 from hibiapi.utils.routing import SlashRouter __mount__, __config__ = "bilibili", BilibiliConstants.CONFIG router = SlashRouter() router.include_router(RouterV2, prefix="/v2") router.include_router(RouterV3, prefix="/v3") ================================================ FILE: hibiapi/app/routes/bilibili/v2.py ================================================ from hibiapi.api.bilibili.api import BilibiliEndpointV2 from hibiapi.api.bilibili.net import NetRequest from hibiapi.utils.routing import EndpointRouter router = EndpointRouter(tags=["Bilibili V2"]) router.include_endpoint(BilibiliEndpointV2, NetRequest()) ================================================ FILE: hibiapi/app/routes/bilibili/v3.py ================================================ from hibiapi.api.bilibili import BilibiliEndpointV3, NetRequest from hibiapi.utils.routing import EndpointRouter router = EndpointRouter(tags=["Bilibili V3"]) router.include_endpoint(BilibiliEndpointV3, NetRequest()) ================================================ FILE: hibiapi/app/routes/netease.py ================================================ from hibiapi.api.netease import NeteaseConstants, NeteaseEndpoint, NetRequest from hibiapi.utils.routing import EndpointRouter __mount__, __config__ = "netease", NeteaseConstants.CONFIG router = EndpointRouter(tags=["Netease"]) router.include_endpoint(NeteaseEndpoint, NetRequest()) ================================================ FILE: hibiapi/app/routes/pixiv.py ================================================ from typing import Optional from fastapi import Depends, Header from hibiapi.api.pixiv import NetRequest, PixivConstants, PixivEndpoints from hibiapi.utils.log import logger from hibiapi.utils.routing import EndpointRouter if not (refresh_tokens := PixivConstants.CONFIG["account"]["token"].as_str_seq()): logger.warning("Pixiv API token is not set, pixiv endpoint will be unavailable.") PixivConstants.CONFIG["enabled"].set(False) async def accept_language( accept_language: Optional[str] = Header( None, description="Accepted tag translation language", ) ): return accept_language __mount__, __config__ = "pixiv", PixivConstants.CONFIG router = EndpointRouter(tags=["Pixiv"], dependencies=[Depends(accept_language)]) router.include_endpoint(PixivEndpoints, api_root := NetRequest(refresh_tokens)) ================================================ FILE: hibiapi/app/routes/qrcode.py ================================================ from typing import Optional from fastapi import Request, Response from pydantic.color import Color from hibiapi.api.qrcode import ( COLOR_BLACK, COLOR_WHITE, Config, HostUrl, QRCodeLevel, QRInfo, ReturnEncode, ) from hibiapi.utils.routing import SlashRouter from hibiapi.utils.temp import TempFile QR_CALLBACK_TEMPLATE = ( r"""function {fun}(){document.write('');}""" ) __mount__, __config__ = "qrcode", Config router = SlashRouter(tags=["QRCode"]) @router.get( "/", responses={ 200: { "content": {"image/png": {}, "text/javascript": {}, "application/json": {}}, "description": "Avaliable to return an javascript, image or json.", } }, response_model=QRInfo, ) async def qrcode_api( request: Request, *, text: str, size: int = 200, logo: Optional[HostUrl] = None, encode: ReturnEncode = ReturnEncode.raw, level: QRCodeLevel = QRCodeLevel.MEDIUM, bgcolor: Color = COLOR_BLACK, fgcolor: Color = COLOR_WHITE, fun: str = "qrcode", ): qr = await QRInfo.new( text, size=size, logo=logo, level=level, bgcolor=bgcolor, fgcolor=fgcolor ) qr.url = TempFile.to_url(request, qr.path) # type:ignore """function {fun}(){document.write('');}""" return ( qr if encode == ReturnEncode.json else Response( content=qr.json(), media_type="application/json", headers={"Location": qr.url}, status_code=302, ) if encode == ReturnEncode.raw else Response( content=f"{fun}({qr.json()})", media_type="text/javascript", ) if encode == ReturnEncode.jsc else Response( content="function " + fun + '''(){document.write('');}""", media_type="text/javascript", ) ) ================================================ FILE: hibiapi/app/routes/sauce.py ================================================ from typing import Annotated, Optional from fastapi import Depends, File, Form from loguru import logger from hibiapi.api.sauce import ( DeduplicateType, HostUrl, NetRequest, SauceConstants, SauceEndpoint, UploadFileIO, ) from hibiapi.utils.routing import SlashRouter if (not SauceConstants.API_KEY) or (not all(map(str.strip, SauceConstants.API_KEY))): logger.warning("Sauce API key not set, SauceNAO endpoint will be unavailable") SauceConstants.CONFIG["enabled"].set(False) __mount__, __config__ = "sauce", SauceConstants.CONFIG router = SlashRouter(tags=["SauceNAO"]) SauceAPIRoot = NetRequest() async def request_client(): async with SauceAPIRoot as client: yield SauceEndpoint(client) @router.get("/") async def sauce_url( endpoint: Annotated[SauceEndpoint, Depends(request_client)], url: HostUrl, size: int = 30, deduplicate: DeduplicateType = DeduplicateType.ALL, database: Optional[int] = None, enabled_mask: Optional[int] = None, disabled_mask: Optional[int] = None, ): """ ## Name: `sauce_url` > 使用SauceNAO检索网络图片 --- ### Required: - ***HostUrl*** **`url`** - Description: 图片URL --- ### Optional: - ***int*** `size` = `30` - Description: 搜索结果数目 - ***DeduplicateType*** `deduplicate` = `DeduplicateType.ALL` - Description: 结果去重模式 - ***Optional[int]*** `database` = `None` - Description: 检索的数据库ID, 999为全部检索 - ***Optional[int]*** `enabled_mask` = `None` - Description: 启用的检索数据库 - ***Optional[int]*** `disabled_mask` = `None` - Description: 禁用的检索数据库 """ return await endpoint.search( url=url, size=size, deduplicate=deduplicate, database=database, enabled_mask=enabled_mask, disabled_mask=disabled_mask, ) @router.post("/") async def sauce_form( endpoint: Annotated[SauceEndpoint, Depends(request_client)], file: bytes = File(..., max_length=SauceConstants.IMAGE_MAXIMUM_SIZE), size: int = Form(30), deduplicate: Annotated[DeduplicateType, Form()] = DeduplicateType.ALL, database: Optional[int] = Form(None), enabled_mask: Optional[int] = Form(None), disabled_mask: Optional[int] = Form(None), ): """ ## Name: `sauce_form` > 使用SauceNAO检索表单上传图片 --- ### Required: - ***bytes*** `file` - Description: 上传的图片 --- ### Optional: - ***int*** `size` = `30` - Description: 搜索结果数目 - ***DeduplicateType*** `deduplicate` = `DeduplicateType.ALL` - Description: 结果去重模式 - ***Optional[int]*** `database` = `None` - Description: 检索的数据库ID, 999为全部检索 - ***Optional[int]*** `enabled_mask` = `None` - Description: 启用的检索数据库 - ***Optional[int]*** `disabled_mask` = `None` - Description: 禁用的检索数据库 """ return await endpoint.search( file=UploadFileIO(file), size=size, deduplicate=deduplicate, database=database, disabled_mask=disabled_mask, enabled_mask=enabled_mask, ) ================================================ FILE: hibiapi/app/routes/tieba.py ================================================ from hibiapi.api.tieba import Config, NetRequest, TiebaEndpoint from hibiapi.utils.routing import EndpointRouter __mount__, __config__ = "tieba", Config router = EndpointRouter(tags=["Tieba"]) router.include_endpoint(TiebaEndpoint, NetRequest()) ================================================ FILE: hibiapi/app/routes/wallpaper.py ================================================ from hibiapi.api.wallpaper import Config, NetRequest, WallpaperEndpoint from hibiapi.utils.routing import EndpointRouter __mount__, __config__ = "wallpaper", Config router = EndpointRouter(tags=["Wallpaper"]) router.include_endpoint(WallpaperEndpoint, NetRequest()) ================================================ FILE: hibiapi/configs/bika.yml ================================================ enabled: true proxy: {} account: # 请在此处填写你的哔咔账号密码 email: password: ================================================ FILE: hibiapi/configs/bilibili.yml ================================================ enabled: true net: cookie: > # Bilibili的Cookie, 在一些需要用户登录的场景下需要 DedeUserID=; DedeUserID__ckMd5=; SESSDATA=; user-agent: "Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810" # UA头, 一般没必要改 ================================================ FILE: hibiapi/configs/general.yml ================================================ # _ _ _ _ _ _____ _____ # | | | (_) | (_) /\ | __ \_ _| # | |__| |_| |__ _ / \ | |__) || | # | __ | | '_ \| | / /\ \ | ___/ | | # | | | | | |_) | |/ ____ \| | _| |_ # |_| |_|_|_.__/|_/_/ \_\_| |_____| # # An alternative implement of Imjad API data: temp-expiry: 7 # 临时文件目录文件过期时间, 单位为天 path: ./data # data目录所在位置 server: host: 127.0.0.1 # 监听主机 port: 8080 # 端口 gzip: true # 限定来源域名, 支持通配符, 参考: # https://fastapi.tiangolo.com/advanced/middleware/#trustedhostmiddleware allowed: ["*"] cors: origins: - "http://localhost.tiangolo.com" - "https://localhost.tiangolo.com" - "http://localhost" - "http://localhost:8080" credentials: true methods: ["*"] headers: ["*"] allowed-forward: null # Reference: https://stackoverflow.com/questions/63511413 limit: # 单IP速率限制策略 enabled: true max: 60 # 每个单位时间内最大请求数 interval: 60 # 单位时间长度, 单位为秒 cache: enabled: true # 设置是否启用缓存 ttl: 3600 # 缓存默认生存时间, 单位为秒 uri: "mem://" # 缓存URI controllable: true # 配置是否可以通过Cache-Control请求头刷新缓存 log: level: INFO # 日志等级, 可选 [TRACE,DEBUG,INFO,WARNING,ERROR] format: > # 输出日志格式, 如果没有必要请不要修改 {level:<8} [{time:YYYY/MM/DD} {time:HH:mm:ss.SSS} {module}:{name}:{line}] {message} # file: logs/{time.log} file: null # 日志输出文件位置, 相对于data目录, 为空则不保存 sentry: enabled: false sample: 1 dsn: "" pii: false content: slogan: | # 在文档附加的标语, 可以用于自定义内容 ![](https://img.shields.io/github/stars/mixmoe/HibiAPI?color=brightgreen&logo=github&style=for-the-badge) robots: | # 提供的robots.txt内容, 用于提供搜索引擎抓取 User-agent: * Disallow: /api/ authorization: enabled: false # 是否开启验证 allowed: - username: admin # 用户名 password: admin # 密码 ================================================ FILE: hibiapi/configs/netease.yml ================================================ enabled: true net: cookie: > # 网易云的Cookie, 可能有些API需要 os=pc; osver=Microsoft-Windows-10-Professional-build-10586-64bit; appver=2.0.3.131777; channel=netease; __remember_me=true user-agent: "Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810" # UA头, 一般没必要改 source: 118.88.64.0/18 # 伪造来源IP以绕过地区限制 #68 ================================================ FILE: hibiapi/configs/pixiv.yml ================================================ enabled: true # HTTP代理地址 # 示例格式 # proxy: { "all://": "http://127.0.0.1:1081" } proxy: {} account: # Pixiv 登录凭证刷新令牌 (Refresh Token) # 获取方法请参考: https://github.com/mixmoe/HibiAPI/issues/53 # 支持使用多个账户进行负载均衡, 每行一个token token: "" language: zh-cn # 返回语言, 会影响标签的翻译 ================================================ FILE: hibiapi/configs/qrcode.yml ================================================ enabled: true qrcode: max-size: 1000 # 允许的二维码最大尺寸, 单位像素 min-size: 50 # 允许的二维码最小尺寸, 单位像素 icon-site: # 图标支持的站点, 可以阻止服务器ip泄漏, 支持通配符 - localhost - i.loli.net # - "*" ================================================ FILE: hibiapi/configs/sauce.yml ================================================ enabled: true # HTTP代理地址 # 示例格式 # proxy: # http_proxy: http://127.0.0.1:1081 # https_proxy: https://127.0.0.1:1081 proxy: {} net: # SauceNAO 的API KEY, 支持多个以进行负载均衡, 每个KEY以换行分隔 # api-key: | # aaaaaaa # bbbbbbb api-key: "" keys: # SauceNAO 的API KEY, 支持多个以进行负载均衡 - "" user-agent: &ua "Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810" # UA头, 一般没必要改 image: max-size: 4096 # 获取图片最大大小, 单位为 KBytes timeout: 6 # 获取图片超时时间, 单位为秒 headers: { "user-agent": *ua } # 获取图片时携带的请求头 allowed: # 获取图片的站点白名单, 可以阻止服务器ip泄漏, 支持通配符 - localhost - i.loli.net # - "*" ================================================ FILE: hibiapi/configs/tieba.yml ================================================ enabled: true net: user-agent: "Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810" # UA头, 一般没必要改 params: BDUSS: "" # 百度的BDUSS登录凭证, 在使用部分API时需要 ================================================ FILE: hibiapi/configs/wallpaper.yml ================================================ enabled: true net: user-agent: "Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810" # UA头, 一般没必要改 ================================================ FILE: hibiapi/utils/__init__.py ================================================ ================================================ FILE: hibiapi/utils/cache.py ================================================ import hashlib from collections.abc import Awaitable from datetime import timedelta from functools import wraps from typing import Any, Callable, Optional, TypeVar, cast from cashews import Cache from pydantic import BaseModel from pydantic.decorator import ValidatedFunction from .config import Config from .log import logger CACHE_CONFIG_KEY = "_cache_config" AsyncFunc = Callable[..., Awaitable[Any]] T_AsyncFunc = TypeVar("T_AsyncFunc", bound=AsyncFunc) CACHE_ENABLED = Config["cache"]["enabled"].as_bool() CACHE_DELTA = timedelta(seconds=Config["cache"]["ttl"].as_number()) CACHE_URI = Config["cache"]["uri"].as_str() CACHE_CONTROLLABLE = Config["cache"]["controllable"].as_bool() cache = Cache(name="hibiapi") try: cache.setup(CACHE_URI) except Exception as e: logger.warning( f"Cache URI {CACHE_URI!r} setup failed: " f"{e!r}, use memory backend instead." ) class CacheConfig(BaseModel): endpoint: AsyncFunc namespace: str enabled: bool = True ttl: timedelta = CACHE_DELTA @staticmethod def new( function: AsyncFunc, *, enabled: bool = True, ttl: timedelta = CACHE_DELTA, namespace: Optional[str] = None, ): return CacheConfig( endpoint=function, enabled=enabled, ttl=ttl, namespace=namespace or function.__qualname__, ) def cache_config( enabled: bool = True, ttl: timedelta = CACHE_DELTA, namespace: Optional[str] = None, ): def decorator(function: T_AsyncFunc) -> T_AsyncFunc: setattr( function, CACHE_CONFIG_KEY, CacheConfig.new(function, enabled=enabled, ttl=ttl, namespace=namespace), ) return function return decorator disable_cache = cache_config(enabled=False) class CachedValidatedFunction(ValidatedFunction): def serialize(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> BaseModel: values = self.build_values(args=args, kwargs=kwargs) return self.model(**values) def endpoint_cache(function: T_AsyncFunc) -> T_AsyncFunc: from .routing import request_headers, response_headers vf = CachedValidatedFunction(function, config={}) config = cast( CacheConfig, getattr(function, CACHE_CONFIG_KEY, None) or CacheConfig.new(function), ) config.enabled = CACHE_ENABLED and config.enabled @wraps(function) async def wrapper(*args, **kwargs): cache_policy = "public" if CACHE_CONTROLLABLE: cache_policy = request_headers.get().get("cache-control", cache_policy) if not config.enabled or cache_policy.casefold() == "no-store": return await vf.call(*args, **kwargs) key = ( f"{config.namespace}:" + hashlib.md5( (model := vf.serialize(args=args, kwargs=kwargs)) .json(exclude={"self"}, sort_keys=True, ensure_ascii=False) .encode() ).hexdigest() ) response_header = response_headers.get() result: Optional[Any] = None if cache_policy.casefold() == "no-cache": await cache.delete(key) elif result := await cache.get(key): logger.debug(f"Request hit cache {key}") response_header.setdefault("X-Cache-Hit", key) if result is None: result = await vf.execute(model) await cache.set(key, result, expire=config.ttl) if (cache_remain := await cache.get_expire(key)) > 0: response_header.setdefault("Cache-Control", f"max-age={cache_remain}") return result return wrapper # type:ignore ================================================ FILE: hibiapi/utils/config.py ================================================ import json import os from pathlib import Path from typing import Any, Optional, TypeVar, overload import confuse import dotenv from pydantic import parse_obj_as from hibiapi import __file__ as root_file CONFIG_DIR = Path(".") / "configs" DEFAULT_DIR = Path(root_file).parent / "configs" _T = TypeVar("_T") class ConfigSubView(confuse.Subview): @overload def get(self) -> Any: ... @overload def get(self, template: type[_T]) -> _T: ... def get(self, template: Optional[type[_T]] = None): # type: ignore object_ = super().get() if template is not None: return parse_obj_as(template, object_) return object_ def get_optional(self, template: type[_T]) -> Optional[_T]: try: return self.get(template) except Exception: return None def as_str(self) -> str: return self.get(str) def as_str_seq(self, split: str = "\n") -> list[str]: # type: ignore return [ stripped for line in self.as_str().strip().split(split) if (stripped := line.strip()) ] def as_number(self) -> int: return self.get(int) def as_bool(self) -> bool: return self.get(bool) def as_path(self) -> Path: return self.get(Path) def as_dict(self) -> dict[str, Any]: return self.get(dict[str, Any]) def __getitem__(self, key: str) -> "ConfigSubView": return self.__class__(self, key) class AppConfig(confuse.Configuration): def __init__(self, name: str): self._config_name = name self._config = CONFIG_DIR / (filename := f"{name}.yml") self._default = DEFAULT_DIR / filename super().__init__(name) self._add_env_source() def config_dir(self) -> str: return str(CONFIG_DIR) def user_config_path(self) -> str: return str(self._config) def _add_env_source(self): if dotenv.find_dotenv(): dotenv.load_dotenv() config_name = f"{self._config_name.lower()}_" env_configs = { k[len(config_name) :].lower(): str(v) for k, v in os.environ.items() if k.lower().startswith(config_name) } # Convert `AAA_BBB_CCC=DDD` to `{'aaa':{'bbb':{'ccc':'ddd'}}}` source_tree: dict[str, Any] = {} for key, value in env_configs.items(): _tmp = source_tree *nodes, name = key.split("_") for node in nodes: _tmp = _tmp.setdefault(node, {}) if value == "": continue try: _tmp[name] = json.loads(value) except json.JSONDecodeError: _tmp[name] = value self.sources.insert(0, confuse.ConfigSource.of(source_tree)) def _add_default_source(self): self.add(confuse.YamlSource(self._default, default=True)) def _add_user_source(self): self.add(confuse.YamlSource(self._config, optional=True)) def __getitem__(self, key: str) -> ConfigSubView: return ConfigSubView(self, key) class GeneralConfig(AppConfig): def __init__(self, name: str): super().__init__(name) class APIConfig(GeneralConfig): pass Config = GeneralConfig("general") ================================================ FILE: hibiapi/utils/decorators/__init__.py ================================================ from __future__ import annotations import asyncio from asyncio import sleep as async_sleep from collections.abc import Awaitable, Iterable from functools import partial, wraps from inspect import iscoroutinefunction from time import sleep as sync_sleep from typing import Callable, Protocol, TypeVar, overload from typing_extensions import ParamSpec from hibiapi.utils.decorators.enum import enum_auto_doc as enum_auto_doc from hibiapi.utils.decorators.timer import Callable_T, TimeIt from hibiapi.utils.log import logger Argument_T = ParamSpec("Argument_T") Return_T = TypeVar("Return_T") class RetryT(Protocol): @overload def __call__(self, function: Callable_T) -> Callable_T: ... @overload def __call__( self, *, retries: int = ..., delay: float = ..., exceptions: Iterable[type[Exception]] | None = ..., ) -> RetryT: ... def __call__( self, function: Callable | None = ..., *, retries: int = ..., delay: float = ..., exceptions: Iterable[type[Exception]] | None = ..., ) -> Callable | RetryT: ... @overload def Retry(function: Callable_T) -> Callable_T: ... @overload def Retry( *, retries: int = ..., delay: float = ..., exceptions: Iterable[type[Exception]] | None = ..., ) -> RetryT: ... def Retry( function: Callable | None = None, *, retries: int = 3, delay: float = 0.1, exceptions: Iterable[type[Exception]] | None = None, ) -> Callable | RetryT: if function is None: return partial( Retry, retries=retries, delay=delay, exceptions=exceptions, ) timed_func = TimeIt(function) allowed_exceptions: tuple[type[Exception], ...] = tuple(exceptions or [Exception]) assert (retries >= 1) and (delay >= 0) @wraps(timed_func) def sync_wrapper(*args, **kwargs): error: Exception | None = None for retried in range(retries): try: return timed_func(*args, **kwargs) except Exception as exception: error = exception if not isinstance(exception, allowed_exceptions): raise logger.opt().debug( f"Retry of {timed_func=} trigged " f"due to {exception=} raised ({retried=}/{retries=})" ) sync_sleep(delay) assert isinstance(error, Exception) raise error @wraps(timed_func) async def async_wrapper(*args, **kwargs): error: Exception | None = None for retried in range(retries): try: return await timed_func(*args, **kwargs) except Exception as exception: error = exception if not isinstance(exception, allowed_exceptions): raise logger.opt().debug( f"Retry of {timed_func=} trigged " f"due to {exception=} raised ({retried=}/{retries})" ) await async_sleep(delay) assert isinstance(error, Exception) raise error return async_wrapper if iscoroutinefunction(function) else sync_wrapper def ToAsync( function: Callable[Argument_T, Return_T], ) -> Callable[Argument_T, Awaitable[Return_T]]: @TimeIt @wraps(function) async def wrapper(*args: Argument_T.args, **kwargs: Argument_T.kwargs) -> Return_T: return await asyncio.get_running_loop().run_in_executor( None, lambda: function(*args, **kwargs) ) return wrapper ================================================ FILE: hibiapi/utils/decorators/enum.py ================================================ import ast import inspect from enum import Enum from typing import TypeVar _ET = TypeVar("_ET", bound=type[Enum]) def enum_auto_doc(enum: _ET) -> _ET: enum_class_ast, *_ = ast.parse(inspect.getsource(enum)).body assert isinstance(enum_class_ast, ast.ClassDef) enum_value_comments: dict[str, str] = {} for index, body in enumerate(body_list := enum_class_ast.body): if ( isinstance(body, ast.Assign) and (next_index := index + 1) < len(body_list) and isinstance(next_body := body_list[next_index], ast.Expr) ): target, *_ = body.targets assert isinstance(target, ast.Name) assert isinstance(next_body.value, ast.Constant) assert isinstance(member_doc := next_body.value.value, str) enum[target.id].__doc__ = member_doc enum_value_comments[target.id] = inspect.cleandoc(member_doc) if not enum_value_comments and all(member.name == member.value for member in enum): return enum members_doc = "" for member in enum: value_document = "-" if member.name != member.value: value_document += f" `{member.name}` =" value_document += f" *`{member.value}`*" if doc := enum_value_comments.get(member.name): value_document += f" : {doc}" members_doc += value_document + "\n" enum.__doc__ = f"{enum.__doc__}\n{members_doc}" return enum ================================================ FILE: hibiapi/utils/decorators/timer.py ================================================ from __future__ import annotations import time from dataclasses import dataclass, field from functools import wraps from inspect import iscoroutinefunction from typing import Any, Callable, ClassVar, TypeVar from hibiapi.utils.log import logger Callable_T = TypeVar("Callable_T", bound=Callable) class TimerError(Exception): """A custom exception used to report errors in use of Timer class""" @dataclass class Timer: """Time your code using a class, context manager, or decorator""" timers: ClassVar[dict[str, float]] = dict() name: str | None = None text: str = "Elapsed time: {:0.3f} seconds" logger_func: Callable[[str], None] | None = print _start_time: float | None = field(default=None, init=False, repr=False) def __post_init__(self) -> None: """Initialization: add timer to dict of timers""" if self.name: self.timers.setdefault(self.name, 0) def start(self) -> None: """Start a new timer""" if self._start_time is not None: raise TimerError("Timer is running. Use .stop() to stop it") self._start_time = time.perf_counter() def stop(self) -> float: """Stop the timer, and report the elapsed time""" if self._start_time is None: raise TimerError("Timer is not running. Use .start() to start it") # Calculate elapsed time elapsed_time = time.perf_counter() - self._start_time self._start_time = None # Report elapsed time if self.logger_func: self.logger_func(self.text.format(elapsed_time * 1000)) if self.name: self.timers[self.name] += elapsed_time return elapsed_time def __enter__(self) -> Timer: """Start a new timer as a context manager""" self.start() return self def __exit__(self, *exc_info: Any) -> None: """Stop the context manager timer""" self.stop() def _recreate_cm(self) -> Timer: return self.__class__(self.name, self.text, self.logger_func) def __call__(self, function: Callable_T) -> Callable_T: @wraps(function) async def async_wrapper(*args: Any, **kwargs: Any): self.text = ( f"Async function {function.__qualname__} " "cost {:.3f}ms" ) with self._recreate_cm(): return await function(*args, **kwargs) @wraps(function) def sync_wrapper(*args: Any, **kwargs: Any): self.text = ( f"sync function {function.__qualname__} " "cost {:.3f}ms" ) with self._recreate_cm(): return function(*args, **kwargs) return ( async_wrapper if iscoroutinefunction(function) else sync_wrapper ) # type:ignore TimeIt = Timer(logger_func=logger.trace) ================================================ FILE: hibiapi/utils/exceptions.py ================================================ from datetime import datetime from typing import Any, Optional from pydantic import AnyHttpUrl, BaseModel, Extra, Field class ExceptionReturn(BaseModel): url: Optional[AnyHttpUrl] = None time: datetime = Field(default_factory=datetime.now) code: int = Field(ge=400, le=599) detail: str headers: dict[str, str] = {} class Config: extra = Extra.allow class BaseServerException(Exception): code: int = 500 detail: str = "Server Fault" headers: dict[str, Any] = {} def __init__( self, detail: Optional[str] = None, *, code: Optional[int] = None, headers: Optional[dict[str, Any]] = None, **params ) -> None: self.data = ExceptionReturn( detail=detail or self.__class__.detail, code=code or self.__class__.code, headers=headers or self.__class__.headers, **params ) super().__init__(detail) class BaseHTTPException(BaseServerException): pass class ServerSideException(BaseServerException): code = 500 detail = "Internal Server Error" class UpstreamAPIException(ServerSideException): code = 502 detail = "Upstram API request failed" class UncaughtException(ServerSideException): code = 500 detail = "Uncaught exception raised during processing" exc: Exception @classmethod def with_exception(cls, e: Exception): c = cls(e.__class__.__qualname__) c.exc = e return c class ClientSideException(BaseServerException): code = 400 detail = "Bad Request" class ValidationException(ClientSideException): code = 422 class RateLimitReachedException(ClientSideException): code = 429 detail = "Rate limit reached" ================================================ FILE: hibiapi/utils/log.py ================================================ import logging import re import sys from datetime import timedelta from pathlib import Path import sentry_sdk.integrations.logging as sentry from loguru import logger as _logger from hibiapi.utils.config import Config LOG_FILE = Config["log"]["file"].get_optional(Path) LOG_LEVEL = Config["log"]["level"].as_str().strip().upper() LOG_FORMAT = Config["log"]["format"].as_str().strip() class LoguruHandler(logging.Handler): _tag_escape_re = re.compile(r"\s]*)>") @classmethod def escape_tag(cls, string: str) -> str: return cls._tag_escape_re.sub(r"\\\g<0>", string) def emit(self, record: logging.LogRecord): try: level = logger.level(record.levelname).name except ValueError: level = record.levelno frame, depth, message = logging.currentframe(), 2, record.getMessage() while frame.f_code.co_filename == logging.__file__: # type: ignore frame = frame.f_back # type: ignore depth += 1 logger.opt(depth=depth, exception=record.exc_info, colors=True).log( level, f"{self.escape_tag(message)}" ) logger = _logger.opt(colors=True) logger.remove() logger.add( sys.stdout, level=LOG_LEVEL, format=LOG_FORMAT, filter=lambda record: record["level"].no < logging.WARNING, ) logger.add( sys.stderr, level=LOG_LEVEL, filter=lambda record: record["level"].no >= logging.WARNING, format=LOG_FORMAT, ) logger.add(sentry.BreadcrumbHandler(), level=LOG_LEVEL) logger.add(sentry.EventHandler(), level="ERROR") if LOG_FILE is not None: LOG_FILE.parent.mkdir(parents=True, exist_ok=True) logger.add( str(LOG_FILE), level=LOG_LEVEL, encoding="utf-8", rotation=timedelta(days=1), ) logger.level(LOG_LEVEL) ================================================ FILE: hibiapi/utils/net.py ================================================ import functools from collections.abc import Coroutine from types import TracebackType from typing import ( Any, Callable, ClassVar, Optional, TypeVar, Union, ) from httpx import ( URL, AsyncClient, Cookies, HTTPError, HTTPStatusError, Request, Response, ResponseNotRead, TransportError, ) from .decorators import Retry, TimeIt from .exceptions import UpstreamAPIException from .log import logger AsyncCallable_T = TypeVar("AsyncCallable_T", bound=Callable[..., Coroutine]) class AsyncHTTPClient(AsyncClient): net_client: "BaseNetClient" @staticmethod async def _log_request(request: Request): method, url = request.method, request.url logger.debug( f"Network request sent: {method} {url}" ) @staticmethod async def _log_response(response: Response): method, url = response.request.method, response.url try: length, code = len(response.content), response.status_code except ResponseNotRead: length, code = -1, response.status_code logger.debug( f"Network request finished: {method} " f"{url} {code} {length}" ) @Retry(exceptions=[TransportError]) async def request(self, method: str, url: Union[URL, str], **kwargs): self.event_hooks = { "request": [self._log_request], "response": [self._log_response], } return await super().request(method, url, **kwargs) class BaseNetClient: connections: ClassVar[int] = 0 clients: ClassVar[list[AsyncHTTPClient]] = [] client: Optional[AsyncHTTPClient] = None def __init__( self, headers: Optional[dict[str, Any]] = None, cookies: Optional[Cookies] = None, proxies: Optional[dict[str, str]] = None, client_class: type[AsyncHTTPClient] = AsyncHTTPClient, ): self.cookies, self.client_class = cookies or Cookies(), client_class self.headers: dict[str, Any] = headers or {} self.proxies: Any = proxies or {} # Bypass type checker self.create_client() def create_client(self): self.client = self.client_class( headers=self.headers, proxies=self.proxies, cookies=self.cookies, http2=True, follow_redirects=True, ) self.client.net_client = self BaseNetClient.clients.append(self.client) return self.client async def __aenter__(self): if not self.client or self.client.is_closed: self.client = await self.create_client().__aenter__() self.__class__.connections += 1 return self.client async def __aexit__( self, exc_type: Optional[type[BaseException]] = None, exc_value: Optional[BaseException] = None, traceback: Optional[TracebackType] = None, ): self.__class__.connections -= 1 if not (exc_type and exc_value and traceback): return if self.client and not self.client.is_closed: client = self.client self.client = None await client.__aexit__(exc_type, exc_value, traceback) return def catch_network_error(function: AsyncCallable_T) -> AsyncCallable_T: timed_func = TimeIt(function) @functools.wraps(timed_func) async def wrapper(*args, **kwargs): try: return await timed_func(*args, **kwargs) except HTTPStatusError as e: raise UpstreamAPIException(detail=e.response.text) from e except HTTPError as e: raise UpstreamAPIException from e return wrapper # type:ignore ================================================ FILE: hibiapi/utils/routing.py ================================================ import inspect from collections.abc import Mapping from contextvars import ContextVar from enum import Enum from fnmatch import fnmatch from functools import wraps from typing import Annotated, Any, Callable, Literal, Optional from urllib.parse import ParseResult, urlparse from fastapi import Depends, Request from fastapi.routing import APIRouter from httpx import URL from pydantic import AnyHttpUrl from pydantic.errors import UrlHostError from starlette.datastructures import Headers, MutableHeaders from hibiapi.utils.cache import endpoint_cache from hibiapi.utils.net import AsyncCallable_T, AsyncHTTPClient, BaseNetClient DONT_ROUTE_KEY = "_dont_route" def dont_route(func: AsyncCallable_T) -> AsyncCallable_T: setattr(func, DONT_ROUTE_KEY, True) return func class EndpointMeta(type): @staticmethod def _list_router_function(members: dict[str, Any]): return { name: object for name, object in members.items() if ( inspect.iscoroutinefunction(object) and not name.startswith("_") and not getattr(object, DONT_ROUTE_KEY, False) ) } def __new__( cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any], *, cache_endpoints: bool = True, **kwargs, ): for object_name, object in cls._list_router_function(namespace).items(): namespace[object_name] = ( endpoint_cache(object) if cache_endpoints else object ) return super().__new__(cls, name, bases, namespace, **kwargs) @property def router_functions(self): return self._list_router_function(dict(inspect.getmembers(self))) class BaseEndpoint(metaclass=EndpointMeta, cache_endpoints=False): def __init__(self, client: AsyncHTTPClient): self.client = client @staticmethod def _join(base: str, endpoint: str, params: dict[str, Any]) -> URL: host: ParseResult = urlparse(base) params = { k: (v.value if isinstance(v, Enum) else v) for k, v in params.items() if v is not None } return URL( url=ParseResult( scheme=host.scheme, netloc=host.netloc, path=endpoint.format(**params), params="", query="", fragment="", ).geturl(), params=params, ) class SlashRouter(APIRouter): def api_route(self, path: str, **kwargs): path = path if path.startswith("/") else f"/{path}" return super().api_route(path, **kwargs) class EndpointRouter(SlashRouter): @staticmethod def _exclude_params(func: Callable, params: Mapping[str, Any]) -> dict[str, Any]: func_params = inspect.signature(func).parameters return {k: v for k, v in params.items() if k in func_params} @staticmethod def _router_signature_convert( func, endpoint_class: type["BaseEndpoint"], request_client: Callable, method_name: Optional[str] = None, ): @wraps(func) async def route_func(endpoint: endpoint_class, **kwargs): endpoint_method = getattr(endpoint, method_name or func.__name__) return await endpoint_method(**kwargs) route_func.__signature__ = inspect.signature(route_func).replace( # type:ignore parameters=[ inspect.Parameter( name="endpoint", kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=endpoint_class, default=Depends(request_client), ), *( param for param in inspect.signature(func).parameters.values() if param.kind == inspect.Parameter.KEYWORD_ONLY ), ] ) return route_func def include_endpoint( self, endpoint_class: type[BaseEndpoint], net_client: BaseNetClient, add_match_all: bool = True, ): router_functions = endpoint_class.router_functions async def request_client(): async with net_client as client: yield endpoint_class(client) for func_name, func in router_functions.items(): self.add_api_route( path=f"/{func_name}", endpoint=self._router_signature_convert( func, endpoint_class=endpoint_class, request_client=request_client, method_name=func_name, ), methods=["GET"], ) if not add_match_all: return @self.get("/", description="JournalAD style API routing", deprecated=True) async def match_all( endpoint: Annotated[endpoint_class, Depends(request_client)], request: Request, type: Literal[tuple(router_functions.keys())], # type: ignore ): func = router_functions[type] return await func( endpoint, **self._exclude_params(func, request.query_params) ) class BaseHostUrl(AnyHttpUrl): allowed_hosts: list[str] = [] @classmethod def validate_host(cls, parts) -> tuple[str, Optional[str], str, bool]: host, tld, host_type, rebuild = super().validate_host(parts) if not cls._check_domain(host): raise UrlHostError(allowed=cls.allowed_hosts) return host, tld, host_type, rebuild @classmethod def _check_domain(cls, host: str) -> bool: return any( filter( lambda x: fnmatch(host, x), # type:ignore cls.allowed_hosts, ) ) request_headers = ContextVar[Headers]("request_headers") response_headers = ContextVar[MutableHeaders]("response_headers") ================================================ FILE: hibiapi/utils/temp.py ================================================ from pathlib import Path from tempfile import mkdtemp, mkstemp from threading import Lock from urllib.parse import ParseResult from fastapi import Request class TempFile: path = Path(mkdtemp()) path_depth = 3 name_length = 16 _lock = Lock() @classmethod def create(cls, ext: str = ".tmp"): descriptor, str_path = mkstemp(suffix=ext, dir=str(cls.path)) return descriptor, Path(str_path) @classmethod def to_url(cls, request: Request, path: Path) -> str: assert cls.path return ParseResult( scheme=request.url.scheme, netloc=request.url.netloc, path=f"/temp/{path.relative_to(cls.path)}", params="", query="", fragment="", ).geturl() ================================================ FILE: pyproject.toml ================================================ [project] name = "HibiAPI" version = "0.8.0" description = "A program that implements easy-to-use APIs for a variety of commonly used sites" readme = "README.md" license = { text = "Apache-2.0" } authors = [{ name = "mixmoe", email = "admin@obfs.dev" }] requires-python = ">=3.9,<4.0" dependencies = [ "fastapi>=0.110.2", "httpx[http2]>=0.27.0", "uvicorn[standard]>=0.29.0", "confuse>=2.0.1", "loguru>=0.7.2", "python-dotenv>=1.0.1", "qrcode[pil]>=7.4.2", "pycryptodomex>=3.20.0", "sentry-sdk>=1.45.0", "pydantic<2.0.0,>=1.9.0", "python-multipart>=0.0.9", "cashews[diskcache,redis]>=7.0.2", "typing-extensions>=4.11.0", "typer[all]>=0.12.3", ] [project.urls] homepage = "https://api.obfs.dev" repository = "https://github.com/mixmoe/HibiAPI" documentation = "https://github.com/mixmoe/HibiAPI/wiki" [project.optional-dependencies] scripts = ["pyqt6>=6.6.1", "pyqt6-webengine>=6.6.0", "requests>=2.31.0"] [project.scripts] hibiapi = "hibiapi.__main__:cli" [build-system] requires = ["pdm-backend"] build-backend = "pdm.backend" [tool.pdm.dev-dependencies] dev = [ "pytest>=8.1.1", "pytest-httpserver>=1.0.10", "pytest-cov>=5.0.0", "pytest-benchmark>=4.0.0", "pytest-pretty>=1.2.0", "ruff>=0.4.1", ] [tool.pdm.build] includes = [] [tool.pdm.scripts] test = """pytest \ --cov ./hibiapi/ \ --cov-report xml \ --cov-report term-missing \ ./test""" start = "hibiapi run" lint = "ruff check" [tool.pyright] typeCheckingMode = "standard" [tool.ruff] lint.select = [ # pycodestyle "E", # Pyflakes "F", # pyupgrade "UP", # flake8-bugbear "B", # flake8-simplify "SIM", # isort "I", ] target-version = "py39" ================================================ FILE: scripts/pixiv_login.py ================================================ import hashlib import sys from base64 import urlsafe_b64encode from secrets import token_urlsafe from typing import Any, Callable, Optional, TypeVar from urllib.parse import parse_qs, urlencode import requests from loguru import logger as _logger from PyQt6.QtCore import QUrl from PyQt6.QtNetwork import QNetworkCookie from PyQt6.QtWebEngineCore import ( QWebEngineUrlRequestInfo, QWebEngineUrlRequestInterceptor, ) from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import ( QApplication, QHBoxLayout, QMainWindow, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, ) USER_AGENT = "PixivAndroidApp/5.0.234 (Android 11; Pixel 5)" REDIRECT_URI = "https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback" LOGIN_URL = "https://app-api.pixiv.net/web/v1/login" AUTH_TOKEN_URL = "https://oauth.secure.pixiv.net/auth/token" CLIENT_ID = "MOBrBDS8blbauoSck0ZfDbtuzpyT" CLIENT_SECRET = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj" app = QApplication(sys.argv) logger = _logger.opt(colors=True) class RequestInterceptor(QWebEngineUrlRequestInterceptor): code_listener: Optional[Callable[[str], None]] = None def __init__(self): super().__init__() def interceptRequest(self, info: QWebEngineUrlRequestInfo) -> None: method = info.requestMethod().data().decode() url = info.requestUrl().url() if ( self.code_listener and "app-api.pixiv.net" in info.requestUrl().host() and info.requestUrl().path().endswith("callback") ): query = parse_qs(info.requestUrl().query()) code, *_ = query["code"] self.code_listener(code) logger.debug(f"{method} {url}") class WebView(QWebEngineView): def __init__(self): super().__init__() self.cookies: dict[str, str] = {} page = self.page() assert page is not None profile = page.profile() assert profile is not None profile.setHttpUserAgent(USER_AGENT) page.contentsSize().setHeight(768) page.contentsSize().setWidth(432) self.interceptor = RequestInterceptor() profile.setUrlRequestInterceptor(self.interceptor) cookie_store = profile.cookieStore() assert cookie_store is not None cookie_store.cookieAdded.connect(self._on_cookie_added) self.setFixedHeight(896) self.setFixedWidth(414) self.start("about:blank") def start(self, goto: str): self.page().profile().cookieStore().deleteAllCookies() # type: ignore self.cookies.clear() self.load(QUrl(goto)) def _on_cookie_added(self, cookie: QNetworkCookie): domain = cookie.domain() name = cookie.name().data().decode() value = cookie.value().data().decode() self.cookies[name] = value logger.debug(f"Set-Cookie {domain} {name} -> {value!r}") class ResponseDataWidget(QWidget): def __init__(self, webview: WebView): super().__init__() self.webview = webview layout = QVBoxLayout() self.cookie_paste = QPlainTextEdit() self.cookie_paste.setDisabled(True) self.cookie_paste.setPlaceholderText("得到的登录数据将会展示在这里") layout.addWidget(self.cookie_paste) copy_button = QPushButton() copy_button.clicked.connect(self._on_clipboard_copy) copy_button.setText("复制上述登录数据到剪贴板") layout.addWidget(copy_button) self.setLayout(layout) def _on_clipboard_copy(self, checked: bool): if paste_string := self.cookie_paste.toPlainText().strip(): app.clipboard().setText(paste_string) # type: ignore _T = TypeVar("_T", bound="LoginPhrase") class LoginPhrase: @staticmethod def s256(data: bytes): return urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() @classmethod def oauth_pkce(cls) -> tuple[str, str]: code_verifier = token_urlsafe(32) code_challenge = cls.s256(code_verifier.encode()) return code_verifier, code_challenge def __init__(self: _T, url_open_callback: Callable[[str, _T], None]): self.code_verifier, self.code_challenge = self.oauth_pkce() login_params = { "code_challenge": self.code_challenge, "code_challenge_method": "S256", "client": "pixiv-android", } login_url = f"{LOGIN_URL}?{urlencode(login_params)}" url_open_callback(login_url, self) def code_received(self, code: str): response = requests.post( AUTH_TOKEN_URL, data={ "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "code": code, "code_verifier": self.code_verifier, "grant_type": "authorization_code", "include_policy": "true", "redirect_uri": REDIRECT_URI, }, headers={"User-Agent": USER_AGENT}, ) response.raise_for_status() data: dict[str, Any] = response.json() access_token = data["access_token"] refresh_token = data["refresh_token"] expires_in = data.get("expires_in", 0) return_text = "" return_text += f"access_token: {access_token}\n" return_text += f"refresh_token: {refresh_token}\n" return_text += f"expires_in: {expires_in}\n" return return_text class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Pixiv login helper") layout = QHBoxLayout() self.webview = WebView() layout.addWidget(self.webview) self.form = ResponseDataWidget(self.webview) layout.addWidget(self.form) widget = QWidget() widget.setLayout(layout) self.setCentralWidget(widget) if __name__ == "__main__": window = MainWindow() window.show() def url_open_callback(url: str, login_phrase: LoginPhrase): def code_listener(code: str): response = login_phrase.code_received(code) window.form.cookie_paste.setPlainText(response) window.webview.interceptor.code_listener = code_listener window.webview.start(url) LoginPhrase(url_open_callback) exit(app.exec()) ================================================ FILE: test/__init__.py ================================================ ================================================ FILE: test/test_base.py ================================================ from typing import Annotated, Any import pytest from fastapi import Depends from fastapi.testclient import TestClient from pytest_benchmark.fixture import BenchmarkFixture @pytest.fixture(scope="package") def client(): from hibiapi.app import app with TestClient(app, base_url="http://testserver/") as client: yield client def test_openapi(client: TestClient, in_stress: bool = False): response = client.get("/openapi.json") assert response.status_code == 200 assert response.json() if in_stress: return True def test_doc_page(client: TestClient, in_stress: bool = False): response = client.get("/docs") assert response.status_code == 200 assert response.text response = client.get("/docs/test") assert response.status_code == 200 assert response.text if in_stress: return True def test_openapi_stress(client: TestClient, benchmark: BenchmarkFixture): assert benchmark.pedantic( test_openapi, args=(client, True), rounds=200, warmup_rounds=10, iterations=3, ) def test_doc_page_stress(client: TestClient, benchmark: BenchmarkFixture): assert benchmark.pedantic( test_doc_page, args=(client, True), rounds=200, iterations=3 ) def test_notfound(client: TestClient): from hibiapi.utils.exceptions import ExceptionReturn response = client.get("/notexistpath") assert response.status_code == 404 assert ExceptionReturn.parse_obj(response.json()) @pytest.mark.xfail(reason="not implemented yet") def test_net_request(): from hibiapi.utils.net import BaseNetClient from hibiapi.utils.routing import BaseEndpoint, SlashRouter test_headers = {"x-test-header": "random-string"} test_data = {"test": "test"} class TestEndpoint(BaseEndpoint): base = "https://httpbin.org" async def request(self, path: str, params: dict[str, Any]): url = self._join(self.base, path, params) response = await self.client.post(url, data=params) response.raise_for_status() return response.json() async def form(self, *, data: dict[str, Any]): return await self.request("/post", data) async def teapot(self): return await self.request("/status/{codes}", {"codes": 418}) class TestNetClient(BaseNetClient): pass async def net_client(): async with TestNetClient(headers=test_headers) as client: yield TestEndpoint(client) router = SlashRouter() @router.post("form") async def form( *, endpoint: Annotated[TestEndpoint, Depends(net_client)], data: dict[str, Any], ): return await endpoint.form(data=data) @router.post("teapot") async def teapot(endpoint: Annotated[TestEndpoint, Depends(net_client)]): return await endpoint.teapot() from hibiapi.app.routes import router as api_router api_router.include_router(router, prefix="/test") from hibiapi.app import app from hibiapi.utils.exceptions import ExceptionReturn with TestClient(app, base_url="http://testserver/api/test/") as client: response = client.post("form", json=test_data) assert response.status_code == 200 response_data = response.json() assert response_data["form"] == test_data request_headers = {k.lower(): v for k, v in response_data["headers"].items()} assert test_headers.items() <= request_headers.items() response = client.post("teapot", json=test_data) exception_return = ExceptionReturn.parse_obj(response.json()) assert exception_return.code == response.status_code ================================================ FILE: test/test_bika.py ================================================ from math import inf import pytest from fastapi.testclient import TestClient @pytest.fixture(scope="package") def client(): from hibiapi.app import app, application application.RATE_LIMIT_MAX = inf with TestClient(app, base_url="http://testserver/api/bika/") as client: client.headers["Cache-Control"] = "no-cache" yield client def test_collections(client: TestClient): response = client.get("collections") assert response.status_code == 200 assert response.json()["code"] == 200 def test_categories(client: TestClient): response = client.get("categories") assert response.status_code == 200 assert response.json()["code"] == 200 def test_keywords(client: TestClient): response = client.get("keywords") assert response.status_code == 200 assert response.json()["code"] == 200 def test_advanced_search(client: TestClient): response = client.get( "advanced_search", params={"keyword": "blend", "page": 1, "sort": "vd"} ) assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"] def test_category_list(client: TestClient): response = client.get( "category_list", params={"category": "全彩", "page": 1, "sort": "vd"} ) assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"] def test_author_list(client: TestClient): response = client.get( "author_list", params={"author": "ゆうき", "page": 1, "sort": "vd"} ) assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"] def test_comic_detail(client: TestClient): response = client.get("comic_detail", params={"id": "5873aa128fe1fa02b156863a"}) assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"] def test_comic_recommendation(client: TestClient): response = client.get( "comic_recommendation", params={"id": "5873aa128fe1fa02b156863a"} ) assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"] def test_comic_episodes(client: TestClient): response = client.get("comic_episodes", params={"id": "5873aa128fe1fa02b156863a"}) assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"] def test_comic_page(client: TestClient): response = client.get("comic_page", params={"id": "5873aa128fe1fa02b156863a"}) assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"] def test_comic_comments(client: TestClient): response = client.get("comic_comments", params={"id": "5873aa128fe1fa02b156863a"}) assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"] def test_games(client: TestClient): response = client.get("games") assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"]["games"] def test_game_detail(client: TestClient): response = client.get("game_detail", params={"id": "6298dc83fee4a055417cdd98"}) assert response.status_code == 200 assert response.json()["code"] == 200 and response.json()["data"] ================================================ FILE: test/test_bilibili_v2.py ================================================ from math import inf import pytest from fastapi.testclient import TestClient @pytest.fixture(scope="package") def client(): from hibiapi.app import app, application application.RATE_LIMIT_MAX = inf with TestClient(app, base_url="http://testserver/api/bilibili/v2/") as client: yield client def test_playurl(client: TestClient): response = client.get("playurl", params={"aid": 2}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_paged_playurl(client: TestClient): response = client.get("playurl", params={"aid": 2, "page": 1}) assert response.status_code == 200 if response.json()["code"] != 0: pytest.xfail(reason=response.text) def test_seasoninfo(client: TestClient): response = client.get("seasoninfo", params={"season_id": 425}) assert response.status_code == 200 assert response.json()["code"] in (0, -404) def test_seasonrecommend(client: TestClient): response = client.get("seasonrecommend", params={"season_id": 425}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_search(client: TestClient): response = client.get("search", params={"keyword": "railgun"}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_search_suggest(client: TestClient): from hibiapi.api.bilibili import SearchType response = client.get( "search", params={"keyword": "paperclip", "type": SearchType.suggest.value} ) assert response.status_code == 200 assert response.json()["code"] == 0 def test_search_hot(client: TestClient): from hibiapi.api.bilibili import SearchType response = client.get( "search", params={"limit": "10", "type": SearchType.hot.value} ) assert response.status_code == 200 assert response.json()["code"] == 0 def test_timeline(client: TestClient): from hibiapi.api.bilibili import TimelineType response = client.get("timeline", params={"type": TimelineType.CN.value}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_space(client: TestClient): response = client.get("space", params={"vmid": 2}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_archive(client: TestClient): response = client.get("archive", params={"vmid": 2}) assert response.status_code == 200 assert response.json()["code"] == 0 @pytest.mark.skip(reason="not implemented yet") def test_favlist(client: TestClient): # TODO:add test case pass ================================================ FILE: test/test_bilibili_v3.py ================================================ from math import inf import pytest from fastapi.testclient import TestClient @pytest.fixture(scope="package") def client(): from hibiapi.app import app, application application.RATE_LIMIT_MAX = inf with TestClient(app, base_url="http://testserver/api/bilibili/v3/") as client: yield client def test_video_info(client: TestClient): response = client.get("video_info", params={"aid": 2}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_video_address(client: TestClient): response = client.get( "video_address", params={"aid": 2, "cid": 62131}, ) assert response.status_code == 200 if response.json()["code"] != 0: pytest.xfail(reason=response.text) def test_user_info(client: TestClient): response = client.get("user_info", params={"uid": 2}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_user_uploaded(client: TestClient): response = client.get("user_uploaded", params={"uid": 2}) assert response.status_code == 200 assert response.json()["code"] == 0 @pytest.mark.skip(reason="not implemented yet") def test_user_favorite(client: TestClient): # TODO:add test case pass def test_season_info(client: TestClient): response = client.get("season_info", params={"season_id": 425}) assert response.status_code == 200 assert response.json()["code"] in (0, -404) def test_season_recommend(client: TestClient): response = client.get("season_recommend", params={"season_id": 425}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_season_episode(client: TestClient): response = client.get("season_episode", params={"episode_id": 84340}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_season_timeline(client: TestClient): response = client.get("season_timeline") assert response.status_code == 200 assert response.json()["code"] == 0 def test_search(client: TestClient): response = client.get("search", params={"keyword": "railgun"}) assert response.status_code == 200 assert response.json()["code"] == 0 def test_search_recommend(client: TestClient): response = client.get("search_recommend") assert response.status_code == 200 assert response.json()["code"] == 0 def test_search_suggestion(client: TestClient): response = client.get("search_suggestion", params={"keyword": "paperclip"}) assert response.status_code == 200 assert response.json()["code"] == 0 ================================================ FILE: test/test_netease.py ================================================ from math import inf import pytest from fastapi.testclient import TestClient @pytest.fixture(scope="package") def client(): from hibiapi.app import app, application application.RATE_LIMIT_MAX = inf with TestClient(app, base_url="http://testserver/api/netease/") as client: yield client def test_search(client: TestClient): response = client.get("search", params={"s": "test"}) assert response.status_code == 200 data = response.json() assert data["code"] == 200 assert data["result"]["songs"] def test_artist(client: TestClient): response = client.get("artist", params={"id": 1024317}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_album(client: TestClient): response = client.get("album", params={"id": 63263}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_detail(client: TestClient): response = client.get("detail", params={"id": 657666}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_detail_multiple(client: TestClient): response = client.get("detail", params={"id": [657666, 657667, 77185]}) assert response.status_code == 200 data = response.json() assert data["code"] == 200 assert len(data["songs"]) == 3 def test_song(client: TestClient): response = client.get("song", params={"id": 657666}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_song_multiple(client: TestClient): response = client.get( "song", params={"id": (input_ids := [657666, 657667, 77185, 86369])} ) assert response.status_code == 200 data = response.json() assert data["code"] == 200 assert len(data["data"]) == len(input_ids) def test_playlist(client: TestClient): response = client.get("playlist", params={"id": 39983375}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_lyric(client: TestClient): response = client.get("lyric", params={"id": 657666}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_mv(client: TestClient): response = client.get("mv", params={"id": 425588}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_mv_url(client: TestClient): response = client.get("mv_url", params={"id": 425588}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_comments(client: TestClient): response = client.get("comments", params={"id": 657666}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_record(client: TestClient): response = client.get("record", params={"id": 286609438}) assert response.status_code == 200 # TODO: test case is no longer valid # assert response.json()["code"] == 200 def test_djradio(client: TestClient): response = client.get("djradio", params={"id": 350596191}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_dj(client: TestClient): response = client.get("dj", params={"id": 10785929}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_detail_dj(client: TestClient): response = client.get("detail_dj", params={"id": 1370045285}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_user(client: TestClient): response = client.get("user", params={"id": 1887530069}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_user_playlist(client: TestClient): response = client.get("user_playlist", params={"id": 1887530069}) assert response.status_code == 200 assert response.json()["code"] == 200 def test_search_redirect(client: TestClient): response = client.get("http://testserver/netease/search", params={"s": "test"}) assert response.status_code == 200 assert response.history assert response.history[0].status_code == 301 ================================================ FILE: test/test_pixiv.py ================================================ from datetime import date, timedelta from math import inf import pytest from fastapi.testclient import TestClient from pytest_benchmark.fixture import BenchmarkFixture @pytest.fixture(scope="package") def client(): from hibiapi.app import app, application application.RATE_LIMIT_MAX = inf with TestClient(app, base_url="http://testserver/api/pixiv/") as client: client.headers["Cache-Control"] = "no-cache" client.headers["Accept-Language"] = "en-US,en;q=0.9" yield client def test_illust(client: TestClient): # https://www.pixiv.net/artworks/109862531 response = client.get("illust", params={"id": 109862531}) assert response.status_code == 200 assert response.json().get("illust") def test_member(client: TestClient): response = client.get("member", params={"id": 3036679}) assert response.status_code == 200 assert response.json().get("user") def test_member_illust(client: TestClient): response = client.get("member_illust", params={"id": 3036679}) assert response.status_code == 200 assert response.json().get("illusts") is not None def test_favorite(client: TestClient): response = client.get("favorite", params={"id": 3036679}) assert response.status_code == 200 def test_favorite_novel(client: TestClient): response = client.get("favorite_novel", params={"id": 55170615}) assert response.status_code == 200 def test_following(client: TestClient): response = client.get("following", params={"id": 3036679}) assert response.status_code == 200 assert response.json().get("user_previews") is not None def test_follower(client: TestClient): response = client.get("follower", params={"id": 3036679}) assert response.status_code == 200 assert response.json().get("user_previews") is not None def test_rank(client: TestClient): for i in range(2, 5): response = client.get( "rank", params={"date": str(date.today() - timedelta(days=i))} ) assert response.status_code == 200 assert response.json().get("illusts") def test_search(client: TestClient): response = client.get("search", params={"word": "東方Project"}) assert response.status_code == 200 assert response.json().get("illusts") def test_popular_preview(client: TestClient): response = client.get("popular_preview", params={"word": "東方Project"}) assert response.status_code == 200 assert response.json().get("illusts") def test_search_user(client: TestClient): response = client.get("search_user", params={"word": "鬼针草"}) assert response.status_code == 200 assert response.json().get("user_previews") def test_tags(client: TestClient): response = client.get("tags") assert response.status_code == 200 assert response.json().get("trend_tags") def test_tags_autocomplete(client: TestClient): response = client.get("tags_autocomplete", params={"word": "甘雨"}) assert response.status_code == 200 assert response.json().get("tags") def test_related(client: TestClient): response = client.get("related", params={"id": 85162550}) assert response.status_code == 200 assert response.json().get("illusts") def test_ugoira_metadata(client: TestClient): response = client.get("ugoira_metadata", params={"id": 85162550}) assert response.status_code == 200 assert response.json().get("ugoira_metadata") def test_spotlights(client: TestClient): response = client.get("spotlights") assert response.status_code == 200 assert response.json().get("spotlight_articles") def test_illust_new(client: TestClient): response = client.get("illust_new") assert response.status_code == 200 assert response.json().get("illusts") def test_illust_comments(client: TestClient): response = client.get("illust_comments", params={"id": 99973718}) assert response.status_code == 200 assert response.json().get("comments") def test_illust_comment_replies(client: TestClient): response = client.get("illust_comment_replies", params={"id": 151400579}) assert response.status_code == 200 assert response.json().get("comments") def test_novel_comments(client: TestClient): response = client.get("novel_comments", params={"id": 12656898}) assert response.status_code == 200 assert response.json().get("comments") def test_novel_comment_replies(client: TestClient): response = client.get("novel_comment_replies", params={"id": 42372000}) assert response.status_code == 200 assert response.json().get("comments") def test_rank_novel(client: TestClient): for i in range(2, 5): response = client.get( "rank_novel", params={"date": str(date.today() - timedelta(days=i))} ) assert response.status_code == 200 assert response.json().get("novels") def test_member_novel(client: TestClient): response = client.get("member_novel", params={"id": 14883165}) assert response.status_code == 200 assert response.json().get("novels") def test_novel_series(client: TestClient): response = client.get("novel_series", params={"id": 1496457}) assert response.status_code == 200 assert response.json().get("novels") def test_novel_detail(client: TestClient): response = client.get("novel_detail", params={"id": 14617902}) assert response.status_code == 200 assert response.json().get("novel") def test_novel_text(client: TestClient): response = client.get("novel_text", params={"id": 14617902}) assert response.status_code == 200 assert response.json().get("novel_text") def test_webview_novel(client: TestClient): response = client.get("webview_novel", params={"id": 19791013}) assert response.status_code == 200 assert response.json().get("text") def test_live_list(client: TestClient): response = client.get("live_list") assert response.status_code == 200 assert response.json().get("lives") def test_related_novel(client: TestClient): response = client.get("related_novel", params={"id": 19791013}) assert response.status_code == 200 assert response.json().get("novels") def test_related_member(client: TestClient): response = client.get("related_member", params={"id": 10109777}) assert response.status_code == 200 assert response.json().get("user_previews") def test_illust_series(client: TestClient): response = client.get("illust_series", params={"id": 218893}) assert response.status_code == 200 assert response.json().get("illust_series_detail") def test_member_illust_series(client: TestClient): response = client.get("member_illust_series", params={"id": 4087934}) assert response.status_code == 200 assert response.json().get("illust_series_details") def test_member_novel_series(client: TestClient): response = client.get("member_novel_series", params={"id": 86832559}) assert response.status_code == 200 assert response.json().get("novel_series_details") def test_tags_novel(client: TestClient): response = client.get("tags_novel") assert response.status_code == 200 assert response.json().get("trend_tags") def test_search_novel(client: TestClient): response = client.get("search_novel", params={"word": "碧蓝航线"}) assert response.status_code == 200 assert response.json().get("novels") def test_popular_preview_novel(client: TestClient): response = client.get("popular_preview_novel", params={"word": "東方Project"}) assert response.status_code == 200 assert response.json().get("novels") def test_novel_new(client: TestClient): response = client.get("novel_new", params={"max_novel_id": 16002726}) assert response.status_code == 200 assert response.json().get("next_url") def test_request_cache(client: TestClient, benchmark: BenchmarkFixture): client.headers["Cache-Control"] = "public" first_response = client.get("rank") assert first_response.status_code == 200 second_response = client.get("rank") assert second_response.status_code == 200 assert "x-cache-hit" in second_response.headers assert "cache-control" in second_response.headers assert second_response.json() == first_response.json() def cache_benchmark(): response = client.get("rank") assert response.status_code == 200 assert "x-cache-hit" in response.headers assert "cache-control" in response.headers benchmark.pedantic(cache_benchmark, rounds=200, iterations=3) def test_rank_redirect(client: TestClient): response = client.get("http://testserver/pixiv/rank") assert response.status_code == 200 assert response.history assert response.history[0].status_code == 301 def test_rate_limit(client: TestClient): from hibiapi.app import application application.RATE_LIMIT_MAX = 1 first_response = client.get("rank") assert first_response.status_code in (200, 429) second_response = client.get("rank") assert second_response.status_code == 429 assert "retry-after" in second_response.headers ================================================ FILE: test/test_qrcode.py ================================================ from math import inf from secrets import token_urlsafe import pytest from fastapi.testclient import TestClient from httpx import Response from pytest_benchmark.fixture import BenchmarkFixture @pytest.fixture(scope="package") def client(): from hibiapi.app import app, application application.RATE_LIMIT_MAX = inf with TestClient(app, base_url="http://testserver/api/") as client: yield client def test_qrcode_generate(client: TestClient, in_stress: bool = False): response = client.get( "qrcode/", params={ "text": token_urlsafe(32), "encode": "raw", }, ) assert response.status_code == 200 assert "image/png" in response.headers["content-type"] if in_stress: return True def test_qrcode_all(client: TestClient): from hibiapi.api.qrcode import QRCodeLevel, ReturnEncode encodes = [i.value for i in ReturnEncode.__members__.values()] levels = [i.value for i in QRCodeLevel.__members__.values()] responses: list[Response] = [] for encode in encodes: for level in levels: response = client.get( "qrcode/", params={"text": "Hello, World!", "encode": encode, "level": level}, ) responses.append(response) assert not any(map(lambda r: r.status_code != 200, responses)) def test_qrcode_stress(client: TestClient, benchmark: BenchmarkFixture): assert benchmark.pedantic( test_qrcode_generate, args=(client, True), rounds=50, iterations=3, ) def test_qrcode_redirect(client: TestClient): response = client.get("http://testserver/qrcode/", params={"text": "Hello, World!"}) assert response.status_code == 200 redirect1, redirect2 = response.history assert redirect1.status_code == 301 assert redirect2.status_code == 302 ================================================ FILE: test/test_sauce.py ================================================ from math import inf from pathlib import Path import pytest from fastapi.testclient import TestClient from pytest_httpserver import HTTPServer LOCAL_SAUCE_PATH = Path(__file__).parent / "test_sauce.jpg" @pytest.fixture(scope="package") def client(): from hibiapi.app import app, application application.RATE_LIMIT_MAX = inf with TestClient(app, base_url="http://testserver/api/") as client: yield client @pytest.mark.xfail(reason="rate limit possible reached") def test_sauce_url(client: TestClient, httpserver: HTTPServer): httpserver.expect_request("/sauce").respond_with_data(LOCAL_SAUCE_PATH.read_bytes()) response = client.get("sauce/", params={"url": httpserver.url_for("/sauce")}) assert response.status_code == 200 data = response.json() assert data["header"]["status"] == 0, data["header"]["message"] @pytest.mark.xfail(reason="rate limit possible reached") def test_sauce_file(client: TestClient): with open(LOCAL_SAUCE_PATH, "rb") as file: response = client.post("sauce/", files={"file": file}) assert response.status_code == 200 data = response.json() assert data["header"]["status"] == 0, data["header"]["message"] ================================================ FILE: test/test_tieba.py ================================================ from math import inf import pytest from fastapi.testclient import TestClient @pytest.fixture(scope="package") def client(): from hibiapi.app import app, application application.RATE_LIMIT_MAX = inf with TestClient(app, base_url="http://testserver/api/tieba/") as client: yield client def test_post_list(client: TestClient): response = client.get("post_list", params={"name": "minecraft"}) assert response.status_code == 200 if response.json()["error_code"] != "0": pytest.xfail(reason=response.text) def test_post_list_chinese(client: TestClient): # NOTE: reference https://github.com/mixmoe/HibiAPI/issues/117 response = client.get("post_list", params={"name": "图拉丁"}) assert response.status_code == 200 if response.json()["error_code"] != "0": pytest.xfail(reason=response.text) def test_post_detail(client: TestClient): response = client.get("post_detail", params={"tid": 1766018024}) assert response.status_code == 200 if response.json()["error_code"] != "0": pytest.xfail(reason=response.text) def test_subpost_detail(client: TestClient): response = client.get( "subpost_detail", params={"tid": 1766018024, "pid": 22616319749} ) assert response.status_code == 200 assert int(response.json()["error_code"]) == 0 def test_user_profile(client: TestClient): response = client.get("user_profile", params={"uid": 105525655}) assert response.status_code == 200 assert int(response.json()["error_code"]) == 0 ================================================ FILE: test/test_wallpaper.py ================================================ from math import inf import pytest from fastapi.testclient import TestClient @pytest.fixture(scope="package") def client(): from hibiapi.app import app, application application.RATE_LIMIT_MAX = inf with TestClient(app, base_url="http://testserver/api/wallpaper/") as client: client.headers["Cache-Control"] = "no-cache" yield client def test_wallpaper(client: TestClient): response = client.get("wallpaper", params={"category": "girl"}) assert response.status_code == 200 assert response.json().get("msg") == "success" def test_wallpaper_limit(client: TestClient): response = client.get("wallpaper", params={"category": "girl", "limit": "21"}) assert response.status_code == 200 assert response.json()["msg"] == "success" assert len(response.json()["res"]["wallpaper"]) == 21 def test_wallpaper_skip(client: TestClient): response_1 = client.get( "wallpaper", params={"category": "girl", "limit": "20", "skip": "20"} ) response_2 = client.get( "wallpaper", params={"category": "girl", "limit": "40", "skip": "0"} ) assert response_1.status_code == 200 and response_2.status_code == 200 assert ( response_1.json()["res"]["wallpaper"][0]["id"] == response_2.json()["res"]["wallpaper"][20]["id"] ) def test_vertical(client: TestClient): response = client.get("vertical", params={"category": "girl"}) assert response.status_code == 200 assert response.json().get("msg") == "success" def test_vertical_limit(client: TestClient): response = client.get("vertical", params={"category": "girl", "limit": "21"}) assert response.status_code == 200 assert response.json().get("msg") == "success" assert len(response.json()["res"]["vertical"]) == 21 def test_vertical_skip(client: TestClient): response_1 = client.get( "vertical", params={"category": "girl", "limit": "20", "skip": "20"} ) response_2 = client.get( "vertical", params={"category": "girl", "limit": "40", "skip": "0"} ) assert response_1.status_code == 200 and response_2.status_code == 200 assert ( response_1.json()["res"]["vertical"][0]["id"] == response_2.json()["res"]["vertical"][20]["id"] )