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

📖 ⚠️
城倉奏
城倉奏

💡
SkipM4
SkipM4

📖
Nook
Nook

💻
Jocky Chiang
Jocky Chiang

💻
midori
midori

📖
Pretty9
Pretty9

💻
Jad
Jad

🐛 🤔
Yumine Sakura
Yumine Sakura

💻
yeyang
yeyang

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