[
  {
    "path": ".all-contributorsrc",
    "content": "{\n  \"files\": [\n    \"README.md\"\n  ],\n  \"imageSize\": 100,\n  \"commit\": false,\n  \"contributors\": [\n    {\n      \"login\": \"Kyomotoi\",\n      \"name\": \"Kyomotoi\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/37587870?v=4\",\n      \"profile\": \"http://kyomotoi.moe\",\n      \"contributions\": [\n        \"doc\",\n        \"test\"\n      ]\n    },\n    {\n      \"login\": \"shirokurakana\",\n      \"name\": \"城倉奏\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/46120251?v=4\",\n      \"profile\": \"http://thdog.moe\",\n      \"contributions\": [\n        \"example\"\n      ]\n    },\n    {\n      \"login\": \"SkipM4\",\n      \"name\": \"SkipM4\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/40311581?v=4\",\n      \"profile\": \"http://skipm4.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"leaf7th\",\n      \"name\": \"Nook\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/38352552?v=4\",\n      \"profile\": \"https://github.com/leaf7th\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jiangzhuochi\",\n      \"name\": \"Jocky Chiang\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/50538375?v=4\",\n      \"profile\": \"https://github.com/jiangzhuochi\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"cleoold\",\n      \"name\": \"midori\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/13920903?v=4\",\n      \"profile\": \"https://github.com/cleoold\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Pretty9\",\n      \"name\": \"Pretty9\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/41198038?v=4\",\n      \"profile\": \"https://www.2yo.cc\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"journey-ad\",\n      \"name\": \"Jad\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/16256221?v=4\",\n      \"profile\": \"https://nocilol.me/\",\n      \"contributions\": [\n        \"bug\",\n        \"ideas\"\n      ]\n    },\n    {\n      \"login\": \"asadahimeka\",\n      \"name\": \"Yumine Sakura\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/31837214?v=4\",\n      \"profile\": \"http://nanoka.top\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"yeyang52\",\n      \"name\": \"yeyang\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/107110851?v=4\",\n      \"profile\": \"https://github.com/yeyang52\",\n      \"contributions\": [\n        \"code\"\n      ]\n    }\n  ],\n  \"contributorsPerLine\": 7,\n  \"projectName\": \"HibiAPI\",\n  \"projectOwner\": \"mixmoe\",\n  \"repoType\": \"github\",\n  \"repoHost\": \"https://github.com\",\n  \"skipCi\": true,\n  \"commitConvention\": \"none\"\n}\n"
  },
  {
    "path": ".flake8",
    "content": "[flake8]\nmax-line-length = 90\nignore = W391, W292, W503, E203"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: pip # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: weekly\n    versioning-strategy: lockfile-only\n\n  - package-ecosystem: github-actions\n    directory: \"/\"\n    schedule:\n      interval: weekly\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "# This workflow uses actions that are not certified by GitHub.\n# They are provided by a third-party and are governed by\n# separate terms of service, privacy policy, and support\n# documentation.\n\nname: Docker\n\non:\n  push:\n    branches: [main]\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  build-and-push-image:\n    name: Build and Push Image\n\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v3\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@c4ee3adeed93b1fa6a762f209fb01608c1a22f1e\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671\n        with:\n          context: .\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Lint\n\non:\n  push:\n    branches: [main, dev]\n\n  pull_request_target:\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    name: Lint Code\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pdm-project/setup-pdm@v4\n        with:\n          python-version: \"3.9\"\n          cache: true\n\n      - name: Install dependencies\n        run: |\n          pdm install -G :all\n          echo `dirname $(pdm info --python)` >> $GITHUB_PATH\n\n      - name: Lint with Ruff\n        continue-on-error: true\n        run: pdm lint --output-format github\n\n      - name: Lint with Pyright\n        uses: jakebailey/pyright-action@v2\n        continue-on-error: true\n        with:\n          pylance-version: latest-release\n\n  analyze:\n    runs-on: ubuntu-latest\n    name: CodeQL Analyze\n\n    if: startsWith(github.ref, 'refs/heads/')\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@v2\n        with:\n          languages: python\n\n      - name: Auto build\n        uses: github/codeql-action/autobuild@v2\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v2\n"
  },
  {
    "path": ".github/workflows/mirror.yml",
    "content": "name: Gitee Mirror\n\non:\n  push:\n    branches: [main, dev]\n\n  schedule:\n    - cron: \"0 0 * * *\"\n\n  workflow_dispatch:\n\nconcurrency:\n  group: ${{ github.workflow }}\n  cancel-in-progress: true\n\njobs:\n  git-mirror:\n    name: Mirror\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: wearerequired/git-mirror-action@v1\n        env:\n          SSH_PRIVATE_KEY: ${{ secrets.SSH_KEY }}\n        with:\n          source-repo: \"git@github.com:mixmoe/HibiAPI.git\"\n          destination-repo: \"git@gitee.com:mixmoe/HibiAPI.git\"\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  create:\n    tags: [v*]\n  workflow_dispatch:\n\njobs:\n  release:\n    name: Create release\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v3\n\n      - uses: pdm-project/setup-pdm@v3\n        with:\n          python-version: 3.9\n\n      - name: Install Dependencies\n        run: |\n          pdm install --prod\n\n      - name: Release to PyPI\n        env:\n          PDM_PUBLISH_USERNAME: __token__\n          PDM_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN }}\n        run: |\n          pdm publish\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  workflow_dispatch:\n\n  push:\n    branches: [main, dev]\n\n  pull_request_target:\n\nconcurrency:\n  group: ${{ github.workflow }}\n  cancel-in-progress: false\n\njobs:\n  cloc:\n    runs-on: ubuntu-latest\n    name: Count Lines of Code\n\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Install CLoC\n        run: |\n          sudo apt-get update\n          sudo apt-get install cloc\n\n      - name: Count Lines of Code\n        run: |\n          cloc . --md >> $GITHUB_STEP_SUMMARY\n\n  test:\n    runs-on: ${{ matrix.os }}\n    name: Testing\n\n    strategy:\n      matrix:\n        python: [\"3.9\", \"3.10\", \"3.11\", \"3.12\"]\n        os: [ubuntu-latest, windows-latest, macos-latest]\n      max-parallel: 3\n\n    defaults:\n      run:\n        shell: bash\n\n    env:\n      OS: ${{ matrix.os }}\n      PYTHON: ${{ matrix.python }}\n\n    steps:\n      - uses: actions/checkout@v3\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n\n      - uses: pdm-project/setup-pdm@v3\n        with:\n          python-version: ${{ matrix.python }}\n          cache: true\n\n      - name: Install dependencies\n        timeout-minutes: 5\n        run: pdm install\n\n      - name: Testing with pytest\n        timeout-minutes: 15\n        run: |\n          curl -L ${{ secrets.DOTENV_LINK }} > .env\n          pdm test\n\n      - name: Create step summary\n        if: always()\n        run: |\n          echo \"## Summary\" >> $GITHUB_STEP_SUMMARY\n          echo \"OS: ${{ matrix.os }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"Python: ${{ matrix.python }}\" >> $GITHUB_STEP_SUMMARY\n          echo '```' >> $GITHUB_STEP_SUMMARY\n          pdm run coverage report -m >> $GITHUB_STEP_SUMMARY\n          echo '```' >> $GITHUB_STEP_SUMMARY\n\n      - uses: codecov/codecov-action@v3\n        if: always()\n        with:\n          env_vars: OS,PYTHON\n          file: coverage.xml\n"
  },
  {
    "path": ".gitignore",
    "content": "# Project ignore\ndata/**\nconfigs/**.yml\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n.pdm-python\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n#.idea/\n"
  },
  {
    "path": ".replit",
    "content": "language = \"python3\"\nrun = \"python -m hibiapi run\""
  },
  {
    "path": ".vscode/docstring.mustache",
    "content": "{{! FastAPI Automatic docstring}}\n\n## Name: `{{name}}`\n\n> {{summaryPlaceholder}} {{extendedSummaryPlaceholder}}\n{{#argsExist}}\n\n---\n\n### Required:\n\n{{#args}}\n- ***{{typePlaceholder}}*** **`{{var}}`** \n    - Description: {{descriptionPlaceholder}}\n{{/args}}\n{{/argsExist}}\n{{#kwargsExist}}\n\n---\n\n### Optional:\n{{#kwargs}}\n- ***{{typePlaceholder}}*** `{{var}}` = `{{default}}`\n    - Description: {{descriptionPlaceholder}}\n{{/kwargs}}\n{{/kwargsExist}}\n{{#exceptionsExist}}\n\n---\n\n### Exceptions:\n\n{{#exceptions}}\n- **`{{var}}`** \n    - Description: {{descriptionPlaceholder}}\n{{/exceptions}}\n{{/exceptionsExist}}\n{{#yieldsExist}}\n\n---\n\n### Yields:\n{{#yields}}\n- `{{typePlaceholder}}`\n    - Description: {{descriptionPlaceholder}}\n{{/yields}}\n{{/yieldsExist}}\n{{#returnsExist}}\n\n---\n\n### Returns:\n\n{{#returns}}\n- `{{typePlaceholder}}`\n    - Description: {{descriptionPlaceholder}}\n{{/returns}}\n{{/returnsExist}}"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n    \"recommendations\": [\n        \"visualstudioexptteam.vscodeintellicode\",\n        \"ms-python.python\",\n        \"ms-python.vscode-pylance\",\n        \"njpwerner.autodocstring\",\n        \"streetsidesoftware.code-spell-checker\",\n        \"redhat.vscode-yaml\",\n        \"seatonjiang.gitmoji-vscode\"\n    ]\n}"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Python: Module\",\n            \"type\": \"python\",\n            \"request\": \"launch\",\n            \"module\": \"hibiapi\",\n            \"args\": [\n                \"run\",\n                \"--reload\"\n            ],\n            \"justMyCode\": true\n        },\n    ]\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"python.analysis.completeFunctionParens\": true,\n    \"python.analysis.typeCheckingMode\": \"basic\",\n    \"python.languageServer\": \"Pylance\",\n    \"python.testing.pytestEnabled\": true,\n    \"[python]\": {\n        \"editor.codeActionsOnSave\": {\n            \"source.organizeImports\": \"explicit\",\n            \"source.fixAll\": \"explicit\"\n        }\n    },\n    \"autoDocstring.customTemplatePath\": \".vscode/docstring.mustache\",\n    \"editor.formatOnSave\": true,\n    \"files.watcherExclude\": {\n        \"**/.git/objects/**\": true,\n        \"**/.git/subtree-cache/**\": true,\n        \"**/node_modules/**\": true,\n        \"**/.hg/store/**\": true,\n        \"**/.venv/**\": true,\n        \"**/.mypy_cache/**\": true\n    },\n    \"files.encoding\": \"utf8\",\n    \"python.analysis.diagnosticMode\": \"workspace\",\n    \"cSpell.words\": [\n        \"Bilibili\",\n        \"DOUGA\",\n        \"GUOCHUANG\",\n        \"Hibi\",\n        \"Imjad\",\n        \"KICHIKU\",\n        \"Pixiv\",\n        \"RGBA\",\n        \"Tieba\",\n        \"aclose\",\n        \"aenter\",\n        \"aexit\",\n        \"aiocache\",\n        \"asyncio\",\n        \"bangumi\",\n        \"bgcolor\",\n        \"dotenv\",\n        \"favlist\",\n        \"fgcolor\",\n        \"fnmatch\",\n        \"getrgb\",\n        \"hibiapi\",\n        \"httpx\",\n        \"illusts\",\n        \"iscoroutinefunction\",\n        \"itertools\",\n        \"levelno\",\n        \"mixmoe\",\n        \"mypy\",\n        \"noqa\",\n        \"proto\",\n        \"pydantic\",\n        \"pytest\",\n        \"qrcode\",\n        \"redoc\",\n        \"referer\",\n        \"rfind\",\n        \"rsplit\",\n        \"starlette\",\n        \"ugoira\",\n        \"uvicorn\",\n        \"vmid\",\n        \"weapi\"\n    ],\n    \"gitmoji.outputType\": \"code\",\n    \"python.analysis.autoImportCompletions\": true\n}"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:bullseye\n\nEXPOSE 8080\n\nENV PORT=8080 \\\n    PROCS=1 \\\n    GENERAL_SERVER_HOST=0.0.0.0\n\nCOPY . /hibi\n\nWORKDIR /hibi\n\nRUN pip install .\n\nCMD hibiapi run --port $PORT --workers $PROCS\n\nHEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \\\n    CMD httpx --verbose --follow-redirects http://127.0.0.1:${PORT}"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2020-2021 Mix Technology\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "<!-- spell-checker: disable -->\n<!-- markdownlint-disable MD033 MD041 -->\n\n<img src=\".github/logo.svg\" align=\"right\">\n\n<div align=\"left\">\n\n# HibiAPI\n\n**_一个实现了多种常用站点的易用化 API 的程序._**\n\n**_A program that implements easy-to-use APIs for a variety of commonly used sites._**\n\n[![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)\n\n![Lint](https://github.com/mixmoe/HibiAPI/workflows/Lint/badge.svg)\n![Test](https://github.com/mixmoe/HibiAPI/workflows/Test/badge.svg)\n[![Coverage](https://codecov.io/gh/mixmoe/HibiAPI/branch/main/graph/badge.svg)](https://codecov.io/gh/mixmoe/HibiAPI)\n\n[![PyPI](https://img.shields.io/pypi/v/hibiapi)](https://pypi.org/project/hibiapi/)\n![PyPI - Downloads](https://img.shields.io/pypi/dm/hibiapi)\n![PyPI - Python Version](https://img.shields.io/pypi/pyversions/hibiapi)\n![PyPI - License](https://img.shields.io/pypi/l/hibiapi)\n\n![GitHub last commit](https://img.shields.io/github/last-commit/mixmoe/HibiAPI)\n![GitHub commit activity](https://img.shields.io/github/commit-activity/m/mixmoe/hibiapi)\n![Lines of code](https://img.shields.io/tokei/lines/github/mixmoe/hibiapi)\n[![GitHub stars](https://img.shields.io/github/stars/mixmoe/HibiAPI)](https://github.com/mixmoe/HibiAPI/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/mixmoe/HibiAPI)](https://github.com/mixmoe/HibiAPI/network)\n[![GitHub issues](https://img.shields.io/github/issues/mixmoe/HibiAPI)](https://github.com/mixmoe/HibiAPI/issues)\n\n</div>\n\n---\n\n## 前言\n\n- `HibiAPI`提供多种网站公开内容的 API 集合, 它们包括:\n\n  - Pixiv 的图片和小说相关信息获取和搜索\n  - Bilibili 的视频/番剧等信息获取和搜索\n  - 网易云音乐的音乐/MV 等信息获取和搜索\n  - 百度贴吧的帖子内容的获取\n  - [爱壁纸](https://adesk.com/)的横版和竖版壁纸获取\n  - 哔咔漫画的漫画信息获取和搜索\n  - …\n\n- 该项目的前身是 Imjad API[^1]\n  - 由于它的使用人数过多, 致使调用超出限制, 所以本人希望提供一个开源替代来供社区进行自由地部署和使用, 从而减轻一部分该 API 的使用压力\n\n[^1]: [什么是 Imjad API](https://github.com/mixmoe/HibiAPI/wiki/FAQ#%E4%BB%80%E4%B9%88%E6%98%AFimjad-api)\n\n## 优势\n\n### 开源\n\n- 本项目以[Apache-2.0](./LICENSE)许可开源, 请看[开源许可](#开源许可)一节\n\n### 高效\n\n- 使用 Python 的[异步机制](https://docs.python.org/zh-cn/3/library/asyncio.html), 由[FastAPI](https://fastapi.tiangolo.com/)驱动, 带来高效的使用体验 ~~虽然性能瓶颈压根不在这~~\n\n### 稳定\n\n- 在代码中广泛使用了 Python 的[类型提示支持](https://docs.python.org/zh-cn/3/library/typing.html), 使代码可读性更高且更加易于维护和调试\n\n- 在开发初期起就一直使用多种现代 Python 开发工具辅助开发, 包括:\n\n  - 使用 [PyLance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) 进行静态类型推断\n  - 使用 [Flake8](https://flake8.pycqa.org/en/latest/) 对代码格式进行检查\n  - 使用 [Black](https://black.readthedocs.io/en/stable/) 格式化代码以提升代码可读性\n\n- 不直接使用第三方开发的 API 调用库, 而是全部用更加适合 Web 应用的逻辑重写第三方 API 请求, 更加可控 ~~疯狂造轮子~~\n\n## 已实现 API[^2]\n\n[^2]: 请查看 [#1](https://github.com/mixmoe/HibiAPI/issues/1)\n\n- [x] Pixiv\n- [x] 网易云音乐\n- [ ] ~~一言~~ (其代替方案<https://hitokoto.cn>提供的方案已足够好, 暂不考虑支持)\n- [x] Bilibili\n- [x] 二维码\n- [ ] ~~企鹅 FM~~ (似乎用的人不是很多)\n- [x] 百度贴吧\n- [x] 爱壁纸\n- [x] 哔咔漫画\n\n## 部署指南\n\n- 手动部署指南: **[点击此处查看](https://github.com/mixmoe/HibiAPI/wiki/Deployment)**\n\n## 应用实例\n\n**我有更多的应用实例?** [立即 PR!](https://github.com/mixmoe/HibiAPI/pulls)\n\n- [`journey-ad/pixiv-viewer`](https://github.com/journey-ad/pixiv-viewer)\n\n  - **又一个 Pixiv 阅览工具**\n\n- 公开搭建实例\n  | **站点名称** | **网址** | **状态** |\n  | :--------------------------: | :-----------------------------: | :---------------------: |\n  | **官方 Demo[^3]** | <https://api.obfs.dev> | ![official][official] |\n  | [MyCard](https://mycard.moe) | <https://hibi.moecube.com> | ![mycard][mycard] |\n\n[^3]: 为了减轻服务器负担, Demo 服务器已开启了 Cloudflare 全站缓存, 如果有实时获取更新的需求, 请自行搭建或使用其他部署实例\n\n[official]: https://img.shields.io/website?url=https%3A%2F%2Fapi.obfs.dev%2Fopenapi.json\n[mycard]: https://img.shields.io/website?url=https%3A%2F%2Fhibi.moecube.com%2Fopenapi.json\n\n## 特别鸣谢\n\n[**@journey-ad**](https://github.com/journey-ad) 大佬的 **Imjad API**, 它是本项目的起源\n\n### 参考项目\n\n> **正是因为有了你们, 这个项目才得以存在**\n\n- Pixiv: [`Mikubill/pixivpy-async`](https://github.com/Mikubill/pixivpy-async) [`upbit/pixivpy`](https://github.com/upbit/pixivpy)\n\n- Bilibili: [`SocialSisterYi/bilibili-API-collect`](https://github.com/SocialSisterYi/bilibili-API-collect) [`soimort/you-get`](https://github.com/soimort/you-get)\n\n- 网易云音乐: [`metowolf/NeteaseCloudMusicApi`](https://github.com/metowolf/NeteaseCloudMusicApi) [`greats3an/pyncm`](https://github.com/greats3an/pyncm) [`Binaryify/NeteaseCloudMusicApi`](https://github.com/Binaryify/NeteaseCloudMusicApi)\n\n- 百度贴吧: [`libsgh/tieba-api`](https://github.com/libsgh/tieba-api)\n\n- 哔咔漫画：[`niuhuan/pica-rust`](https://github.com/niuhuan/pica-rust) [`abbeyokgo/PicaComic-Api`](https://github.com/abbeyokgo/PicaComic-Api)\n\n### 贡献者们\n\n感谢这些为这个项目作出贡献的各位大佬:\n\n<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->\n<!-- prettier-ignore-start -->\n<!-- markdownlint-disable -->\n<table>\n  <tbody>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://kyomotoi.moe\"><img src=\"https://avatars.githubusercontent.com/u/37587870?v=4?s=100\" width=\"100px;\" alt=\"Kyomotoi\"/><br /><sub><b>Kyomotoi</b></sub></a><br /><a href=\"https://github.com/mixmoe/HibiAPI/commits?author=Kyomotoi\" title=\"Documentation\">📖</a> <a href=\"https://github.com/mixmoe/HibiAPI/commits?author=Kyomotoi\" title=\"Tests\">⚠️</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://thdog.moe\"><img src=\"https://avatars.githubusercontent.com/u/46120251?v=4?s=100\" width=\"100px;\" alt=\"城倉奏\"/><br /><sub><b>城倉奏</b></sub></a><br /><a href=\"#example-shirokurakana\" title=\"Examples\">💡</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://skipm4.com\"><img src=\"https://avatars.githubusercontent.com/u/40311581?v=4?s=100\" width=\"100px;\" alt=\"SkipM4\"/><br /><sub><b>SkipM4</b></sub></a><br /><a href=\"https://github.com/mixmoe/HibiAPI/commits?author=SkipM4\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/leaf7th\"><img src=\"https://avatars.githubusercontent.com/u/38352552?v=4?s=100\" width=\"100px;\" alt=\"Nook\"/><br /><sub><b>Nook</b></sub></a><br /><a href=\"https://github.com/mixmoe/HibiAPI/commits?author=leaf7th\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/jiangzhuochi\"><img src=\"https://avatars.githubusercontent.com/u/50538375?v=4?s=100\" width=\"100px;\" alt=\"Jocky Chiang\"/><br /><sub><b>Jocky Chiang</b></sub></a><br /><a href=\"https://github.com/mixmoe/HibiAPI/commits?author=jiangzhuochi\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/cleoold\"><img src=\"https://avatars.githubusercontent.com/u/13920903?v=4?s=100\" width=\"100px;\" alt=\"midori\"/><br /><sub><b>midori</b></sub></a><br /><a href=\"https://github.com/mixmoe/HibiAPI/commits?author=cleoold\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.2yo.cc\"><img src=\"https://avatars.githubusercontent.com/u/41198038?v=4?s=100\" width=\"100px;\" alt=\"Pretty9\"/><br /><sub><b>Pretty9</b></sub></a><br /><a href=\"https://github.com/mixmoe/HibiAPI/commits?author=Pretty9\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://nocilol.me/\"><img src=\"https://avatars.githubusercontent.com/u/16256221?v=4?s=100\" width=\"100px;\" alt=\"Jad\"/><br /><sub><b>Jad</b></sub></a><br /><a href=\"https://github.com/mixmoe/HibiAPI/issues?q=author%3Ajourney-ad\" title=\"Bug reports\">🐛</a> <a href=\"#ideas-journey-ad\" title=\"Ideas, Planning, & Feedback\">🤔</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://nanoka.top\"><img src=\"https://avatars.githubusercontent.com/u/31837214?v=4?s=100\" width=\"100px;\" alt=\"Yumine Sakura\"/><br /><sub><b>Yumine Sakura</b></sub></a><br /><a href=\"https://github.com/mixmoe/HibiAPI/commits?author=asadahimeka\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/yeyang52\"><img src=\"https://avatars.githubusercontent.com/u/107110851?v=4?s=100\" width=\"100px;\" alt=\"yeyang\"/><br /><sub><b>yeyang</b></sub></a><br /><a href=\"https://github.com/mixmoe/HibiAPI/commits?author=yeyang52\" title=\"Code\">💻</a></td>\n    </tr>\n  </tbody>\n</table>\n\n<!-- markdownlint-restore -->\n<!-- prettier-ignore-end -->\n\n<!-- ALL-CONTRIBUTORS-LIST:END -->\n\n_本段符合 [all-contributors](https://github.com/all-contributors/all-contributors) 规范_\n\n## 开源许可\n\n    Copyright 2020-2021 Mix Technology\n\n    Licensed under the Apache License, Version 2.0 (the \"License\");\n    you may not use this file except in compliance with the License.\n    You may obtain a copy of the License at\n\n        http://www.apache.org/licenses/LICENSE-2.0\n\n    Unless required by applicable law or agreed to in writing, software\n    distributed under the License is distributed on an \"AS IS\" BASIS,\n    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n    See the License for the specific language governing permissions and\n    limitations under the License.\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"3.9\"\n\nvolumes:\n  hibi_redis: {}\n\nnetworks:\n  hibi_net: {}\n\nservices:\n  redis:\n    image: redis:alpine\n    container_name: hibi_redis\n    healthcheck:\n      test: [\"CMD-SHELL\", \"redis-cli ping\"]\n      interval: 10s\n      timeout: 5s\n      retries: 5\n    networks:\n      - hibi_net\n    volumes:\n      - hibi_redis:/data\n    expose: [6379]\n\n  api:\n    container_name: hibiapi\n    build:\n      dockerfile: Dockerfile\n      context: .\n    restart: on-failure\n    networks:\n      - hibi_net\n    depends_on:\n      redis:\n        condition: service_healthy\n    ports:\n      - \"8080:8080\"\n    environment:\n      PORT: \"8080\"\n      FORWARDED_ALLOW_IPS: \"*\"\n      GENERAL_CACHE_URI: \"redis://redis:6379\"\n      GENERAL_SERVER_HOST: \"0.0.0.0\"\n"
  },
  {
    "path": "hibiapi/__init__.py",
    "content": "r\"\"\"\n  _    _ _ _     _          _____ _____  \n | |  | (_) |   (_)   /\\   |  __ \\_   _| \n | |__| |_| |__  _   /  \\  | |__) || |   \n |  __  | | '_ \\| | / /\\ \\ |  ___/ | |   \n | |  | | | |_) | |/ ____ \\| |    _| |_  \n |_|  |_|_|_.__/|_/_/    \\_\\_|   |_____| \n\nA program that implements easy-to-use APIs for a variety of commonly used sites\nRepository: https://github.com/mixmoe/HibiAPI\n\"\"\"  # noqa:W291,W293\n\nfrom importlib.metadata import version\n\n__version__ = version(\"hibiapi\")\n"
  },
  {
    "path": "hibiapi/__main__.py",
    "content": "import os\nfrom pathlib import Path\n\nimport typer\nimport uvicorn\n\nfrom hibiapi import __file__ as root_file\nfrom hibiapi import __version__\nfrom hibiapi.utils.config import CONFIG_DIR, DEFAULT_DIR, Config\nfrom hibiapi.utils.log import LOG_LEVEL, logger\n\nCOPYRIGHT = r\"\"\"\n<b><g>\n  _    _ _ _     _          _____ _____  \n | |  | (_) |   (_)   /\\   |  __ \\_   _| \n | |__| |_| |__  _   /  \\  | |__) || |   \n |  __  | | '_ \\| | / /\\ \\ |  ___/ | |   \n | |  | | | |_) | |/ ____ \\| |    _| |_  \n |_|  |_|_|_.__/|_/_/    \\_\\_|   |_____| \n</g><e>\nA program that implements easy-to-use APIs for a variety of commonly used sites\nRepository: https://github.com/mixmoe/HibiAPI\n</e></b>\"\"\".strip()  # noqa:W291\n\n\nLOG_CONFIG = {\n    \"version\": 1,\n    \"disable_existing_loggers\": False,\n    \"handlers\": {\n        \"default\": {\n            \"class\": \"hibiapi.utils.log.LoguruHandler\",\n        },\n    },\n    \"loggers\": {\n        \"uvicorn.error\": {\n            \"handlers\": [\"default\"],\n            \"level\": LOG_LEVEL,\n        },\n        \"uvicorn.access\": {\n            \"handlers\": [\"default\"],\n            \"level\": LOG_LEVEL,\n        },\n    },\n}\n\nRELOAD_CONFIG = {\n    \"reload\": True,\n    \"reload_dirs\": [\n        *map(str, [Path(root_file).parent.absolute(), CONFIG_DIR.absolute()])\n    ],\n    \"reload_includes\": [\"*.py\", \"*.yml\"],\n}\n\n\ncli = typer.Typer()\n\n\n@cli.callback(invoke_without_command=True)\n@cli.command()\ndef run(\n    ctx: typer.Context,\n    host: str = Config[\"server\"][\"host\"].as_str(),\n    port: int = Config[\"server\"][\"port\"].as_number(),\n    workers: int = 1,\n    reload: bool = False,\n):\n    if ctx.invoked_subcommand is not None:\n        return\n\n    if ctx.info_name != (func_name := run.__name__):\n        logger.warning(\n            f\"Directly usage of command <r>{ctx.info_name}</r> is <b>deprecated</b>, \"\n            f\"please use <g>{ctx.info_name} {func_name}</g> instead.\"\n        )\n\n    try:\n        terminal_width, _ = os.get_terminal_size()\n    except OSError:\n        terminal_width = 0\n    logger.warning(\n        \"\\n\".join(i.center(terminal_width) for i in COPYRIGHT.splitlines()),\n    )\n    logger.info(f\"HibiAPI version: <g><b>{__version__}</b></g>\")\n\n    uvicorn.run(\n        \"hibiapi.app:app\",\n        host=host,\n        port=port,\n        access_log=False,\n        log_config=LOG_CONFIG,\n        workers=workers,\n        forwarded_allow_ips=Config[\"server\"][\"allowed-forward\"].get_optional(str),\n        **(RELOAD_CONFIG if reload else {}),\n    )\n\n\n@cli.command()\ndef config(force: bool = False):\n    total_written = 0\n    CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n    for file in os.listdir(DEFAULT_DIR):\n        default_path = DEFAULT_DIR / file\n        config_path = CONFIG_DIR / file\n        if not (existed := config_path.is_file()) or force:\n            total_written += config_path.write_text(\n                default_path.read_text(encoding=\"utf-8\"),\n                encoding=\"utf-8\",\n            )\n            typer.echo(\n                typer.style((\"Overwritten\" if existed else \"Created\") + \": \", fg=\"blue\")\n                + typer.style(str(config_path), fg=\"yellow\")\n            )\n    if total_written > 0:\n        typer.echo(f\"Config folder generated, {total_written=}\")\n\n\nif __name__ == \"__main__\":\n    cli()\n"
  },
  {
    "path": "hibiapi/api/__init__.py",
    "content": ""
  },
  {
    "path": "hibiapi/api/bika/__init__.py",
    "content": "from .api import BikaEndpoints, ImageQuality, ResultSort  # noqa: F401\nfrom .constants import BikaConstants  # noqa: F401\nfrom .net import BikaLogin, NetRequest  # noqa: F401\n"
  },
  {
    "path": "hibiapi/api/bika/api.py",
    "content": "import hashlib\nimport hmac\nfrom datetime import timedelta\nfrom enum import Enum\nfrom time import time\nfrom typing import Any, Optional, cast\n\nfrom httpx import URL\n\nfrom hibiapi.api.bika.constants import BikaConstants\nfrom hibiapi.api.bika.net import NetRequest\nfrom hibiapi.utils.cache import cache_config\nfrom hibiapi.utils.decorators import enum_auto_doc\nfrom hibiapi.utils.net import catch_network_error\nfrom hibiapi.utils.routing import BaseEndpoint, dont_route, request_headers\n\n\n@enum_auto_doc\nclass ImageQuality(str, Enum):\n    \"\"\"哔咔API返回的图片质量\"\"\"\n\n    low = \"low\"\n    \"\"\"低质量\"\"\"\n    medium = \"medium\"\n    \"\"\"中等质量\"\"\"\n    high = \"high\"\n    \"\"\"高质量\"\"\"\n    original = \"original\"\n    \"\"\"原图\"\"\"\n\n\n@enum_auto_doc\nclass ResultSort(str, Enum):\n    \"\"\"哔咔API返回的搜索结果排序方式\"\"\"\n\n    date_descending = \"dd\"\n    \"\"\"最新发布\"\"\"\n    date_ascending = \"da\"\n    \"\"\"最早发布\"\"\"\n    like_descending = \"ld\"\n    \"\"\"最多喜欢\"\"\"\n    views_descending = \"vd\"\n    \"\"\"最多浏览\"\"\"\n\n\nclass BikaEndpoints(BaseEndpoint):\n    @staticmethod\n    def _sign(url: URL, timestamp_bytes: bytes, nonce: bytes, method: bytes):\n        return hmac.new(\n            BikaConstants.DIGEST_KEY,\n            (\n                url.raw_path.lstrip(b\"/\")\n                + timestamp_bytes\n                + nonce\n                + method\n                + BikaConstants.API_KEY\n            ).lower(),\n            hashlib.sha256,\n        ).hexdigest()\n\n    @dont_route\n    @catch_network_error\n    async def request(\n        self,\n        endpoint: str,\n        *,\n        params: Optional[dict[str, Any]] = None,\n        body: Optional[dict[str, Any]] = None,\n        no_token: bool = False,\n    ):\n        net_client = cast(NetRequest, self.client.net_client)\n        if not no_token:\n            async with net_client.auth_lock:\n                if net_client.token is None:\n                    await net_client.login(self)\n\n        headers = {\n            \"Authorization\": net_client.token or \"\",\n            \"Time\": (current_time := f\"{time():.0f}\".encode()),\n            \"Image-Quality\": request_headers.get().get(\n                \"X-Image-Quality\", ImageQuality.medium\n            ),\n            \"Nonce\": (nonce := hashlib.md5(current_time).hexdigest().encode()),\n            \"Signature\": self._sign(\n                request_url := self._join(\n                    base=BikaConstants.API_HOST,\n                    endpoint=endpoint,\n                    params=params or {},\n                ),\n                current_time,\n                nonce,\n                b\"GET\" if body is None else b\"POST\",\n            ),\n        }\n\n        response = await (\n            self.client.get(request_url, headers=headers)\n            if body is None\n            else self.client.post(request_url, headers=headers, json=body)\n        )\n        return response.json()\n\n    @cache_config(ttl=timedelta(days=1))\n    async def collections(self):\n        return await self.request(\"collections\")\n\n    @cache_config(ttl=timedelta(days=3))\n    async def categories(self):\n        return await self.request(\"categories\")\n\n    @cache_config(ttl=timedelta(days=3))\n    async def keywords(self):\n        return await self.request(\"keywords\")\n\n    async def advanced_search(\n        self,\n        *,\n        keyword: str,\n        page: int = 1,\n        sort: ResultSort = ResultSort.date_descending,\n    ):\n        return await self.request(\n            \"comics/advanced-search\",\n            body={\n                \"keyword\": keyword,\n                \"sort\": sort,\n            },\n            params={\n                \"page\": page,\n                \"s\": sort,\n            },\n        )\n\n    async def category_list(\n        self,\n        *,\n        category: str,\n        page: int = 1,\n        sort: ResultSort = ResultSort.date_descending,\n    ):\n        return await self.request(\n            \"comics\",\n            params={\n                \"page\": page,\n                \"c\": category,\n                \"s\": sort,\n            },\n        )\n\n    async def author_list(\n        self,\n        *,\n        author: str,\n        page: int = 1,\n        sort: ResultSort = ResultSort.date_descending,\n    ):\n        return await self.request(\n            \"comics\",\n            params={\n                \"page\": page,\n                \"a\": author,\n                \"s\": sort,\n            },\n        )\n\n    @cache_config(ttl=timedelta(days=3))\n    async def comic_detail(self, *, id: str):\n        return await self.request(\"comics/{id}\", params={\"id\": id})\n\n    async def comic_recommendation(self, *, id: str):\n        return await self.request(\"comics/{id}/recommendation\", params={\"id\": id})\n\n    async def comic_episodes(self, *, id: str, page: int = 1):\n        return await self.request(\n            \"comics/{id}/eps\",\n            params={\n                \"id\": id,\n                \"page\": page,\n            },\n        )\n\n    async def comic_page(self, *, id: str, order: int = 1, page: int = 1):\n        return await self.request(\n            \"comics/{id}/order/{order}/pages\",\n            params={\n                \"id\": id,\n                \"order\": order,\n                \"page\": page,\n            },\n        )\n\n    async def comic_comments(self, *, id: str, page: int = 1):\n        return await self.request(\n            \"comics/{id}/comments\",\n            params={\n                \"id\": id,\n                \"page\": page,\n            },\n        )\n\n    async def games(self, *, page: int = 1):\n        return await self.request(\"games\", params={\"page\": page})\n\n    @cache_config(ttl=timedelta(days=3))\n    async def game_detail(self, *, id: str):\n        return await self.request(\"games/{id}\", params={\"id\": id})\n"
  },
  {
    "path": "hibiapi/api/bika/constants.py",
    "content": "from hibiapi.utils.config import APIConfig\n\n\nclass BikaConstants:\n    DIGEST_KEY = b\"~d}$Q7$eIni=V)9\\\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn\"\n    API_KEY = b\"C69BAF41DA5ABD1FFEDC6D2FEA56B\"\n    DEFAULT_HEADERS = {\n        \"API-Key\": API_KEY,\n        \"App-Channel\": \"2\",\n        \"App-Version\": \"2.2.1.2.3.3\",\n        \"App-Build-Version\": \"44\",\n        \"App-UUID\": \"defaultUuid\",\n        \"Accept\": \"application/vnd.picacomic.com.v1+json\",\n        \"App-Platform\": \"android\",\n        \"User-Agent\": \"okhttp/3.8.1\",\n        \"Content-Type\": \"application/json; charset=UTF-8\",\n    }\n    API_HOST = \"https://picaapi.picacomic.com/\"\n    CONFIG = APIConfig(\"bika\")\n"
  },
  {
    "path": "hibiapi/api/bika/net.py",
    "content": "import asyncio\nfrom base64 import urlsafe_b64decode\nfrom datetime import datetime, timezone\nfrom functools import lru_cache\nfrom typing import TYPE_CHECKING, Any, Literal, Optional\n\nfrom pydantic import BaseModel, Field\n\nfrom hibiapi.api.bika.constants import BikaConstants\nfrom hibiapi.utils.net import BaseNetClient\n\nif TYPE_CHECKING:\n    from .api import BikaEndpoints\n\n\nclass BikaLogin(BaseModel):\n    email: str\n    password: str\n\n\nclass JWTHeader(BaseModel):\n    alg: str\n    typ: Literal[\"JWT\"]\n\n\nclass JWTBody(BaseModel):\n    id: str = Field(alias=\"_id\")\n    iat: datetime\n    exp: datetime\n\n\n@lru_cache(maxsize=4)\ndef load_jwt(token: str):\n    def b64pad(data: str):\n        return data + \"=\" * (-len(data) % 4)\n\n    head, body, _ = token.split(\".\")\n    head_data = JWTHeader.parse_raw(urlsafe_b64decode(b64pad(head)))\n    body_data = JWTBody.parse_raw(urlsafe_b64decode(b64pad(body)))\n    return head_data, body_data\n\n\nclass NetRequest(BaseNetClient):\n    _token: Optional[str] = None\n\n    def __init__(self):\n        super().__init__(\n            headers=BikaConstants.DEFAULT_HEADERS.copy(),\n            proxies=BikaConstants.CONFIG[\"proxy\"].as_dict(),\n        )\n        self.auth_lock = asyncio.Lock()\n\n    @property\n    def token(self) -> Optional[str]:\n        if self._token is None:\n            return None\n        _, body = load_jwt(self._token)\n        return None if body.exp < datetime.now(timezone.utc) else self._token\n\n    async def login(self, endpoint: \"BikaEndpoints\"):\n        login_data = BikaConstants.CONFIG[\"account\"].get(BikaLogin)\n        login_result: dict[str, Any] = await endpoint.request(\n            \"auth/sign-in\",\n            body=login_data.dict(),\n            no_token=True,\n        )\n        assert login_result[\"code\"] == 200, login_result[\"message\"]\n        if not (\n            isinstance(login_data := login_result.get(\"data\"), dict)\n            and \"token\" in login_data\n        ):\n            raise ValueError(\"failed to read Bika account token.\")\n        self._token = login_data[\"token\"]\n"
  },
  {
    "path": "hibiapi/api/bilibili/__init__.py",
    "content": "# flake8:noqa:F401\nfrom .api import *  # noqa: F401, F403\nfrom .constants import BilibiliConstants\nfrom .net import NetRequest\n"
  },
  {
    "path": "hibiapi/api/bilibili/api/__init__.py",
    "content": "# flake8:noqa:F401\nfrom .base import BaseBilibiliEndpoint, TimelineType, VideoFormatType, VideoQualityType\nfrom .v2 import BilibiliEndpointV2, SearchType\nfrom .v3 import BilibiliEndpointV3\n"
  },
  {
    "path": "hibiapi/api/bilibili/api/base.py",
    "content": "import hashlib\nimport json\nfrom enum import Enum, IntEnum\nfrom time import time\nfrom typing import Any, Optional, overload\n\nfrom httpx import URL\n\nfrom hibiapi.api.bilibili.constants import BilibiliConstants\nfrom hibiapi.utils.decorators import enum_auto_doc\nfrom hibiapi.utils.net import catch_network_error\nfrom hibiapi.utils.routing import BaseEndpoint, dont_route\n\n\n@enum_auto_doc\nclass TimelineType(str, Enum):\n    \"\"\"番剧时间线类型\"\"\"\n\n    CN = \"cn\"\n    \"\"\"国产动画\"\"\"\n    GLOBAL = \"global\"\n    \"\"\"番剧\"\"\"\n\n\n@enum_auto_doc\nclass VideoQualityType(IntEnum):\n    \"\"\"视频质量类型\"\"\"\n\n    VIDEO_240P = 6\n    VIDEO_360P = 16\n    VIDEO_480P = 32\n    VIDEO_720P = 64\n    VIDEO_720P_60FPS = 74\n    VIDEO_1080P = 80\n    VIDEO_1080P_PLUS = 112\n    VIDEO_1080P_60FPS = 116\n    VIDEO_4K = 120\n\n\n@enum_auto_doc\nclass VideoFormatType(IntEnum):\n    \"\"\"视频格式类型\"\"\"\n\n    FLV = 0\n    MP4 = 2\n    DASH = 16\n\n\nclass BaseBilibiliEndpoint(BaseEndpoint):\n    def _sign(self, base: str, endpoint: str, params: dict[str, Any]) -> URL:\n        params.update(\n            {\n                **BilibiliConstants.DEFAULT_PARAMS,\n                \"access_key\": BilibiliConstants.ACCESS_KEY,\n                \"appkey\": BilibiliConstants.APP_KEY,\n                \"ts\": int(time()),\n            }\n        )\n        params = {k: params[k] for k in sorted(params.keys())}\n        url = self._join(base=base, endpoint=endpoint, params=params)\n        params[\"sign\"] = hashlib.md5(url.query + BilibiliConstants.SECRET).hexdigest()\n        return URL(url, params=params)\n\n    @staticmethod\n    def _parse_json(content: str) -> dict[str, Any]:\n        try:\n            return json.loads(content)\n        except json.JSONDecodeError:\n            # NOTE: this is used to parse jsonp response\n            right, left = content.find(\"(\"), content.rfind(\")\")\n            return json.loads(content[right + 1 : left].strip())\n\n    @overload\n    async def request(\n        self,\n        endpoint: str,\n        *,\n        sign: bool = True,\n        params: Optional[dict[str, Any]] = None,\n    ) -> dict[str, Any]: ...\n\n    @overload\n    async def request(\n        self,\n        endpoint: str,\n        source: str,\n        *,\n        sign: bool = True,\n        params: Optional[dict[str, Any]] = None,\n    ) -> dict[str, Any]: ...\n\n    @dont_route\n    @catch_network_error\n    async def request(\n        self,\n        endpoint: str,\n        source: Optional[str] = None,\n        *,\n        sign: bool = True,\n        params: Optional[dict[str, Any]] = None,\n    ) -> dict[str, Any]:\n        host = BilibiliConstants.SERVER_HOST[source or \"app\"]\n        url = (self._sign if sign else self._join)(\n            base=host, endpoint=endpoint, params=params or {}\n        )\n        response = await self.client.get(url)\n        response.raise_for_status()\n        return self._parse_json(response.text)\n\n    async def playurl(\n        self,\n        *,\n        aid: int,\n        cid: int,\n        quality: VideoQualityType = VideoQualityType.VIDEO_480P,\n        type: VideoFormatType = VideoFormatType.FLV,\n    ):\n        return await self.request(\n            \"x/player/playurl\",\n            \"api\",\n            sign=False,\n            params={\n                \"avid\": aid,\n                \"cid\": cid,\n                \"qn\": quality,\n                \"fnval\": type,\n                \"fnver\": 0,\n                \"fourk\": 0 if quality >= VideoQualityType.VIDEO_4K else 1,\n            },\n        )\n\n    async def view(self, *, aid: int):\n        return await self.request(\n            \"x/v2/view\",\n            params={\n                \"aid\": aid,\n            },\n        )\n\n    async def search(self, *, keyword: str, page: int = 1, pagesize: int = 20):\n        return await self.request(\n            \"x/v2/search\",\n            params={\n                \"duration\": 0,\n                \"keyword\": keyword,\n                \"pn\": page,\n                \"ps\": pagesize,\n            },\n        )\n\n    async def search_hot(self, *, limit: int = 50):\n        return await self.request(\n            \"x/v2/search/hot\",\n            params={\n                \"limit\": limit,\n            },\n        )\n\n    async def search_suggest(self, *, keyword: str, type: str = \"accurate\"):\n        return await self.request(\n            \"x/v2/search/suggest\",\n            params={\n                \"keyword\": keyword,\n                \"type\": type,\n            },\n        )\n\n    async def space(self, *, vmid: int, page: int = 1, pagesize: int = 10):\n        return await self.request(\n            \"x/v2/space\",\n            params={\n                \"vmid\": vmid,\n                \"ps\": pagesize,\n                \"pn\": page,\n            },\n        )\n\n    async def space_archive(self, *, vmid: int, page: int = 1, pagesize: int = 10):\n        return await self.request(\n            \"x/v2/space/archive\",\n            params={\n                \"vmid\": vmid,\n                \"ps\": pagesize,\n                \"pn\": page,\n            },\n        )\n\n    async def favorite_video(\n        self,\n        *,\n        fid: int,\n        vmid: int,\n        page: int = 1,\n        pagesize: int = 20,\n    ):\n        return await self.request(\n            \"x/v2/fav/video\",\n            \"api\",\n            params={\n                \"fid\": fid,\n                \"pn\": page,\n                \"ps\": pagesize,\n                \"vmid\": vmid,\n                \"order\": \"ftime\",\n            },\n        )\n\n    async def event_list(\n        self,\n        *,\n        fid: int,\n        vmid: int,\n        page: int = 1,\n        pagesize: int = 20,\n    ):  # NOTE: this endpoint is not used\n        return await self.request(\n            \"event/getlist\",\n            \"api\",\n            params={\n                \"fid\": fid,\n                \"pn\": page,\n                \"ps\": pagesize,\n                \"vmid\": vmid,\n                \"order\": \"ftime\",\n            },\n        )\n\n    async def season_info(self, *, season_id: int):\n        return await self.request(\n            \"pgc/view/web/season\",\n            \"api\",\n            params={\n                \"season_id\": season_id,\n            },\n        )\n\n    async def bangumi_source(self, *, episode_id: int):\n        return await self.request(\n            \"api/get_source\",\n            \"bgm\",\n            params={\n                \"episode_id\": episode_id,\n            },\n        )\n\n    async def season_recommend(self, *, season_id: int):\n        return await self.request(\n            \"pgc/season/web/related/recommend\",\n            \"api\",\n            sign=False,\n            params={\n                \"season_id\": season_id,\n            },\n        )\n\n    async def timeline(self, *, type: TimelineType = TimelineType.GLOBAL):\n        return await self.request(\n            \"web_api/timeline_{type}\",\n            \"bgm\",\n            sign=False,\n            params={\n                \"type\": type,\n            },\n        )\n\n    async def suggest(self, *, keyword: str):  # NOTE: this endpoint is not used\n        return await self.request(\n            \"main/suggest\",\n            \"search\",\n            sign=False,\n            params={\n                \"func\": \"suggest\",\n                \"suggest_type\": \"accurate\",\n                \"sug_type\": \"tag\",\n                \"main_ver\": \"v1\",\n                \"keyword\": keyword,\n            },\n        )\n"
  },
  {
    "path": "hibiapi/api/bilibili/api/v2.py",
    "content": "from collections.abc import Coroutine\nfrom enum import Enum\nfrom functools import wraps\nfrom typing import Callable, Optional, TypeVar\n\nfrom hibiapi.api.bilibili.api.base import (\n    BaseBilibiliEndpoint,\n    TimelineType,\n    VideoFormatType,\n    VideoQualityType,\n)\nfrom hibiapi.utils.decorators import enum_auto_doc\nfrom hibiapi.utils.exceptions import ClientSideException\nfrom hibiapi.utils.net import AsyncHTTPClient\nfrom hibiapi.utils.routing import BaseEndpoint\n\n_AnyCallable = TypeVar(\"_AnyCallable\", bound=Callable[..., Coroutine])\n\n\ndef process_keyerror(function: _AnyCallable) -> _AnyCallable:\n    @wraps(function)\n    async def wrapper(*args, **kwargs):\n        try:\n            return await function(*args, **kwargs)\n        except (KeyError, IndexError) as e:\n            raise ClientSideException(detail=str(e)) from None\n\n    return wrapper  # type:ignore\n\n\n@enum_auto_doc\nclass SearchType(str, Enum):\n    \"\"\"搜索类型\"\"\"\n\n    search = \"search\"\n    \"\"\"综合搜索\"\"\"\n\n    suggest = \"suggest\"\n    \"\"\"搜索建议\"\"\"\n\n    hot = \"hot\"\n    \"\"\"热门\"\"\"\n\n\nclass BilibiliEndpointV2(BaseEndpoint, cache_endpoints=False):\n    def __init__(self, client: AsyncHTTPClient):\n        super().__init__(client)\n        self.base = BaseBilibiliEndpoint(client)\n\n    @process_keyerror\n    async def playurl(\n        self,\n        *,\n        aid: int,\n        page: Optional[int] = None,\n        quality: VideoQualityType = VideoQualityType.VIDEO_480P,\n        type: VideoFormatType = VideoFormatType.MP4,\n    ):  # NOTE: not completely same with origin\n        video_view = await self.base.view(aid=aid)\n        if page is None:\n            return video_view\n        cid: int = video_view[\"data\"][\"pages\"][page - 1][\"cid\"]\n        return await self.base.playurl(\n            aid=aid,\n            cid=cid,\n            quality=quality,\n            type=type,\n        )\n\n    async def seasoninfo(self, *, season_id: int):  # NOTE: not same with origin\n        return await self.base.season_info(season_id=season_id)\n\n    async def source(self, *, episode_id: int):\n        return await self.base.bangumi_source(episode_id=episode_id)\n\n    async def seasonrecommend(self, *, season_id: int):  # NOTE: not same with origin\n        return await self.base.season_recommend(season_id=season_id)\n\n    async def search(\n        self,\n        *,\n        keyword: str = \"\",\n        type: SearchType = SearchType.search,\n        page: int = 1,\n        pagesize: int = 20,\n        limit: int = 50,\n    ):\n        if type == SearchType.suggest:\n            return await self.base.search_suggest(keyword=keyword)\n        elif type == SearchType.hot:\n            return await self.base.search_hot(limit=limit)\n        else:\n            return await self.base.search(\n                keyword=keyword,\n                page=page,\n                pagesize=pagesize,\n            )\n\n    async def timeline(\n        self, *, type: TimelineType = TimelineType.GLOBAL\n    ):  # NOTE: not same with origin\n        return await self.base.timeline(type=type)\n\n    async def space(self, *, vmid: int, page: int = 1, pagesize: int = 10):\n        return await self.base.space(\n            vmid=vmid,\n            page=page,\n            pagesize=pagesize,\n        )\n\n    async def archive(self, *, vmid: int, page: int = 1, pagesize: int = 10):\n        return await self.base.space_archive(\n            vmid=vmid,\n            page=page,\n            pagesize=pagesize,\n        )\n\n    async def favlist(self, *, fid: int, vmid: int, page: int = 1, pagesize: int = 20):\n        return await self.base.favorite_video(\n            fid=fid,\n            vmid=vmid,\n            page=page,\n            pagesize=pagesize,\n        )\n"
  },
  {
    "path": "hibiapi/api/bilibili/api/v3.py",
    "content": "from hibiapi.api.bilibili.api.base import (\n    BaseBilibiliEndpoint,\n    TimelineType,\n    VideoFormatType,\n    VideoQualityType,\n)\nfrom hibiapi.utils.net import AsyncHTTPClient\nfrom hibiapi.utils.routing import BaseEndpoint\n\n\nclass BilibiliEndpointV3(BaseEndpoint, cache_endpoints=False):\n    def __init__(self, client: AsyncHTTPClient):\n        super().__init__(client)\n        self.base = BaseBilibiliEndpoint(client)\n\n    async def video_info(self, *, aid: int):\n        return await self.base.view(aid=aid)\n\n    async def video_address(\n        self,\n        *,\n        aid: int,\n        cid: int,\n        quality: VideoQualityType = VideoQualityType.VIDEO_480P,\n        type: VideoFormatType = VideoFormatType.FLV,\n    ):\n        return await self.base.playurl(\n            aid=aid,\n            cid=cid,\n            quality=quality,\n            type=type,\n        )\n\n    async def user_info(self, *, uid: int, page: int = 1, size: int = 10):\n        return await self.base.space(\n            vmid=uid,\n            page=page,\n            pagesize=size,\n        )\n\n    async def user_uploaded(self, *, uid: int, page: int = 1, size: int = 10):\n        return await self.base.space_archive(\n            vmid=uid,\n            page=page,\n            pagesize=size,\n        )\n\n    async def user_favorite(self, *, uid: int, fid: int, page: int = 1, size: int = 10):\n        return await self.base.favorite_video(\n            fid=fid,\n            vmid=uid,\n            page=page,\n            pagesize=size,\n        )\n\n    async def season_info(self, *, season_id: int):\n        return await self.base.season_info(season_id=season_id)\n\n    async def season_recommend(self, *, season_id: int):\n        return await self.base.season_recommend(season_id=season_id)\n\n    async def season_episode(self, *, episode_id: int):\n        return await self.base.bangumi_source(episode_id=episode_id)\n\n    async def season_timeline(self, *, type: TimelineType = TimelineType.GLOBAL):\n        return await self.base.timeline(type=type)\n\n    async def search(self, *, keyword: str, page: int = 1, size: int = 20):\n        return await self.base.search(\n            keyword=keyword,\n            page=page,\n            pagesize=size,\n        )\n\n    async def search_recommend(self, *, limit: int = 50):\n        return await self.base.search_hot(limit=limit)\n\n    async def search_suggestion(self, *, keyword: str):\n        return await self.base.search_suggest(keyword=keyword)\n"
  },
  {
    "path": "hibiapi/api/bilibili/constants.py",
    "content": "from http.cookies import SimpleCookie\nfrom typing import Any\n\nfrom hibiapi.utils.config import APIConfig\n\n_CONFIG = APIConfig(\"bilibili\")\n\n\nclass BilibiliConstants:\n    SERVER_HOST: dict[str, str] = {\n        \"app\": \"https://app.bilibili.com\",\n        \"api\": \"https://api.bilibili.com\",\n        \"interface\": \"https://interface.bilibili.com\",\n        \"main\": \"https://www.bilibili.com\",\n        \"bgm\": \"https://bangumi.bilibili.com\",\n        \"comment\": \"https://comment.bilibili.com\",\n        \"search\": \"https://s.search.bilibili.com\",\n        \"mobile\": \"https://m.bilibili.com\",\n    }\n    APP_HOST: str = \"http://app.bilibili.com\"\n    DEFAULT_PARAMS: dict[str, Any] = {\n        \"build\": 507000,\n        \"device\": \"android\",\n        \"platform\": \"android\",\n        \"mobi_app\": \"android\",\n    }\n    APP_KEY: str = \"1d8b6e7d45233436\"\n    SECRET: bytes = b\"560c52ccd288fed045859ed18bffd973\"\n    ACCESS_KEY: str = \"5271b2f0eb92f5f89af4dc39197d8e41\"\n    COOKIES: SimpleCookie = SimpleCookie(_CONFIG[\"net\"][\"cookie\"].as_str())\n    USER_AGENT: str = _CONFIG[\"net\"][\"user-agent\"].as_str()\n    CONFIG: APIConfig = _CONFIG\n"
  },
  {
    "path": "hibiapi/api/bilibili/net.py",
    "content": "from httpx import Cookies\n\nfrom hibiapi.utils.net import BaseNetClient\n\nfrom .constants import BilibiliConstants\n\n\nclass NetRequest(BaseNetClient):\n    def __init__(self):\n        super().__init__(\n            headers={\"user-agent\": BilibiliConstants.USER_AGENT},\n            cookies=Cookies({k: v.value for k, v in BilibiliConstants.COOKIES.items()}),\n        )\n"
  },
  {
    "path": "hibiapi/api/netease/__init__.py",
    "content": "# flake8:noqa:F401\nfrom .api import BitRateType, NeteaseEndpoint, RecordPeriodType, SearchType\nfrom .constants import NeteaseConstants\nfrom .net import NetRequest\n"
  },
  {
    "path": "hibiapi/api/netease/api.py",
    "content": "import base64\nimport json\nimport secrets\nimport string\nfrom datetime import timedelta\nfrom enum import IntEnum\nfrom ipaddress import IPv4Address\nfrom random import randint\nfrom typing import Annotated, Any, Optional\n\nfrom Cryptodome.Cipher import AES\nfrom Cryptodome.Util.Padding import pad\nfrom fastapi import Query\n\nfrom hibiapi.api.netease.constants import NeteaseConstants\nfrom hibiapi.utils.cache import cache_config\nfrom hibiapi.utils.decorators import enum_auto_doc\nfrom hibiapi.utils.exceptions import UpstreamAPIException\nfrom hibiapi.utils.net import catch_network_error\nfrom hibiapi.utils.routing import BaseEndpoint, dont_route\n\n\n@enum_auto_doc\nclass SearchType(IntEnum):\n    \"\"\"搜索内容类型\"\"\"\n\n    SONG = 1\n    \"\"\"单曲\"\"\"\n    ALBUM = 10\n    \"\"\"专辑\"\"\"\n    ARTIST = 100\n    \"\"\"歌手\"\"\"\n    PLAYLIST = 1000\n    \"\"\"歌单\"\"\"\n    USER = 1002\n    \"\"\"用户\"\"\"\n    MV = 1004\n    \"\"\"MV\"\"\"\n    LYRICS = 1006\n    \"\"\"歌词\"\"\"\n    DJ = 1009\n    \"\"\"主播电台\"\"\"\n    VIDEO = 1014\n    \"\"\"视频\"\"\"\n\n\n@enum_auto_doc\nclass BitRateType(IntEnum):\n    \"\"\"歌曲码率\"\"\"\n\n    LOW = 64000\n    MEDIUM = 128000\n    STANDARD = 198000\n    HIGH = 320000\n\n\n@enum_auto_doc\nclass MVResolutionType(IntEnum):\n    \"\"\"MV分辨率\"\"\"\n\n    QVGA = 240\n    VGA = 480\n    HD = 720\n    FHD = 1080\n\n\n@enum_auto_doc\nclass RecordPeriodType(IntEnum):\n    \"\"\"听歌记录时段类型\"\"\"\n\n    WEEKLY = 1\n    \"\"\"本周\"\"\"\n    ALL = 0\n    \"\"\"所有时段\"\"\"\n\n\nclass _EncryptUtil:\n    alphabets = bytearray(ord(char) for char in string.ascii_letters + string.digits)\n\n    @staticmethod\n    def _aes(data: bytes, key: bytes) -> bytes:\n        data = pad(data, 16) if len(data) % 16 else data\n        return base64.encodebytes(\n            AES.new(\n                key=key,\n                mode=AES.MODE_CBC,\n                iv=NeteaseConstants.AES_IV,\n            ).encrypt(data)\n        )\n\n    @staticmethod\n    def _rsa(data: bytes):\n        result = pow(\n            base=int(data.hex(), 16),\n            exp=NeteaseConstants.RSA_PUBKEY,\n            mod=NeteaseConstants.RSA_MODULUS,\n        )\n        return f\"{result:0>256x}\"\n\n    @classmethod\n    def encrypt(cls, data: dict[str, Any]) -> dict[str, str]:\n        secret = bytes(secrets.choice(cls.alphabets) for _ in range(16))\n        secure_key = cls._rsa(bytes(reversed(secret)))\n        return {\n            \"params\": cls._aes(\n                data=cls._aes(\n                    data=json.dumps(data).encode(),\n                    key=NeteaseConstants.AES_KEY,\n                ),\n                key=secret,\n            ).decode(\"ascii\"),\n            \"encSecKey\": secure_key,\n        }\n\n\nclass NeteaseEndpoint(BaseEndpoint):\n    def _construct_headers(self):\n        headers = self.client.headers.copy()\n        headers[\"X-Real-IP\"] = str(\n            IPv4Address(\n                randint(\n                    int(NeteaseConstants.SOURCE_IP_SEGMENT.network_address),\n                    int(NeteaseConstants.SOURCE_IP_SEGMENT.broadcast_address),\n                )\n            )\n        )\n        return headers\n\n    @dont_route\n    @catch_network_error\n    async def request(\n        self, endpoint: str, *, params: Optional[dict[str, Any]] = None\n    ) -> dict[str, Any]:\n        params = {\n            **(params or {}),\n            \"csrf_token\": self.client.cookies.get(\"__csrf\", \"\"),\n        }\n        response = await self.client.post(\n            self._join(\n                NeteaseConstants.HOST,\n                endpoint=endpoint,\n                params=params,\n            ),\n            headers=self._construct_headers(),\n            data=_EncryptUtil.encrypt(params),\n        )\n        response.raise_for_status()\n        if not response.text.strip():\n            raise UpstreamAPIException(\n                f\"Upstream API {endpoint=} returns blank content\"\n            )\n        return response.json()\n\n    async def search(\n        self,\n        *,\n        s: str,\n        search_type: SearchType = SearchType.SONG,\n        limit: int = 20,\n        offset: int = 0,\n    ):\n        return await self.request(\n            \"api/cloudsearch/pc\",\n            params={\n                \"s\": s,\n                \"type\": search_type,\n                \"limit\": limit,\n                \"offset\": offset,\n                \"total\": True,\n            },\n        )\n\n    async def artist(self, *, id: int):\n        return await self.request(\n            \"weapi/v1/artist/{artist_id}\",\n            params={\n                \"artist_id\": id,\n            },\n        )\n\n    async def album(self, *, id: int):\n        return await self.request(\n            \"weapi/v1/album/{album_id}\",\n            params={\n                \"album_id\": id,\n            },\n        )\n\n    async def detail(\n        self,\n        *,\n        id: Annotated[list[int], Query()],\n    ):\n        return await self.request(\n            \"api/v3/song/detail\",\n            params={\n                \"c\": json.dumps(\n                    [{\"id\": str(i)} for i in id],\n                ),\n            },\n        )\n\n    @cache_config(ttl=timedelta(minutes=20))\n    async def song(\n        self,\n        *,\n        id: Annotated[list[int], Query()],\n        br: BitRateType = BitRateType.STANDARD,\n    ):\n        return await self.request(\n            \"weapi/song/enhance/player/url\",\n            params={\n                \"ids\": [str(i) for i in id],\n                \"br\": br,\n            },\n        )\n\n    async def playlist(self, *, id: int):\n        return await self.request(\n            \"weapi/v6/playlist/detail\",\n            params={\n                \"id\": id,\n                \"total\": True,\n                \"offset\": 0,\n                \"limit\": 1000,\n                \"n\": 1000,\n            },\n        )\n\n    async def lyric(self, *, id: int):\n        return await self.request(\n            \"weapi/song/lyric\",\n            params={\n                \"id\": id,\n                \"os\": \"pc\",\n                \"lv\": -1,\n                \"kv\": -1,\n                \"tv\": -1,\n            },\n        )\n\n    async def mv(self, *, id: int):\n        return await self.request(\n            \"api/v1/mv/detail\",\n            params={\n                \"id\": id,\n            },\n        )\n\n    async def mv_url(\n        self,\n        *,\n        id: int,\n        res: MVResolutionType = MVResolutionType.FHD,\n    ):\n        return await self.request(\n            \"weapi/song/enhance/play/mv/url\",\n            params={\n                \"id\": id,\n                \"r\": res,\n            },\n        )\n\n    async def comments(self, *, id: int, offset: int = 0, limit: int = 1):\n        return await self.request(\n            \"weapi/v1/resource/comments/R_SO_4_{song_id}\",\n            params={\n                \"song_id\": id,\n                \"offset\": offset,\n                \"total\": True,\n                \"limit\": limit,\n            },\n        )\n\n    async def record(self, *, id: int, period: RecordPeriodType = RecordPeriodType.ALL):\n        return await self.request(\n            \"weapi/v1/play/record\",\n            params={\n                \"uid\": id,\n                \"type\": period,\n            },\n        )\n\n    async def djradio(self, *, id: int):\n        return await self.request(\n            \"api/djradio/v2/get\",\n            params={\n                \"id\": id,\n            },\n        )\n\n    async def dj(self, *, id: int, offset: int = 0, limit: int = 20, asc: bool = False):\n        # NOTE: Possible not same with origin\n        return await self.request(\n            \"weapi/dj/program/byradio\",\n            params={\n                \"radioId\": id,\n                \"offset\": offset,\n                \"limit\": limit,\n                \"asc\": asc,\n            },\n        )\n\n    async def detail_dj(self, *, id: int):\n        return await self.request(\n            \"api/dj/program/detail\",\n            params={\n                \"id\": id,\n            },\n        )\n\n    async def user(self, *, id: int):\n        return await self.request(\n            \"weapi/v1/user/detail/{id}\",\n            params={\"id\": id},\n        )\n\n    async def user_playlist(self, *, id: int, limit: int = 50, offset: int = 0):\n        return await self.request(\n            \"weapi/user/playlist\",\n            params={\n                \"uid\": id,\n                \"limit\": limit,\n                \"offset\": offset,\n            },\n        )\n"
  },
  {
    "path": "hibiapi/api/netease/constants.py",
    "content": "from http.cookies import SimpleCookie\nfrom ipaddress import IPv4Network\n\nfrom hibiapi.utils.config import APIConfig\n\n_Config = APIConfig(\"netease\")\n\n\nclass NeteaseConstants:\n    AES_KEY: bytes = b\"0CoJUm6Qyw8W8jud\"\n    AES_IV: bytes = b\"0102030405060708\"\n    RSA_PUBKEY: int = int(\"010001\", 16)\n    RSA_MODULUS: int = int(\n        \"00e0b509f6259df8642dbc3566290147\"\n        \"7df22677ec152b5ff68ace615bb7b725\"\n        \"152b3ab17a876aea8a5aa76d2e417629\"\n        \"ec4ee341f56135fccf695280104e0312\"\n        \"ecbda92557c93870114af6c9d05c4f7f\"\n        \"0c3685b7a46bee255932575cce10b424\"\n        \"d813cfe4875d3e82047b97ddef52741d\"\n        \"546b8e289dc6935b3ece0462db0a22b8e7\",\n        16,\n    )\n\n    HOST: str = \"http://music.163.com\"\n    COOKIES: SimpleCookie = SimpleCookie(_Config[\"net\"][\"cookie\"].as_str())\n    SOURCE_IP_SEGMENT: IPv4Network = _Config[\"net\"][\"source\"].get(IPv4Network)\n    DEFAULT_HEADERS: dict[str, str] = {\n        \"user-agent\": _Config[\"net\"][\"user-agent\"].as_str(),\n        \"referer\": \"http://music.163.com\",\n    }\n\n    CONFIG: APIConfig = _Config\n"
  },
  {
    "path": "hibiapi/api/netease/net.py",
    "content": "from httpx import Cookies\n\nfrom hibiapi.utils.net import BaseNetClient\n\nfrom .constants import NeteaseConstants\n\n\nclass NetRequest(BaseNetClient):\n    def __init__(self):\n        super().__init__(\n            headers=NeteaseConstants.DEFAULT_HEADERS,\n            cookies=Cookies({k: v.value for k, v in NeteaseConstants.COOKIES.items()}),\n        )\n"
  },
  {
    "path": "hibiapi/api/pixiv/__init__.py",
    "content": "# flake8:noqa:F401\nfrom .api import (\n    IllustType,\n    PixivEndpoints,\n    RankingDate,\n    RankingType,\n    SearchDurationType,\n    SearchModeType,\n    SearchNovelModeType,\n    SearchSortType,\n)\nfrom .constants import PixivConstants\nfrom .net import NetRequest, PixivAuthData\n"
  },
  {
    "path": "hibiapi/api/pixiv/api.py",
    "content": "import json\nimport re\nfrom datetime import date, timedelta\nfrom enum import Enum\nfrom typing import Any, Literal, Optional, Union, cast, overload\n\nfrom hibiapi.api.pixiv.constants import PixivConstants\nfrom hibiapi.api.pixiv.net import NetRequest as PixivNetClient\nfrom hibiapi.utils.cache import cache_config\nfrom hibiapi.utils.decorators import enum_auto_doc\nfrom hibiapi.utils.net import catch_network_error\nfrom hibiapi.utils.routing import BaseEndpoint, dont_route, request_headers\n\n\n@enum_auto_doc\nclass IllustType(str, Enum):\n    \"\"\"画作类型\"\"\"\n\n    illust = \"illust\"\n    \"\"\"插画\"\"\"\n    manga = \"manga\"\n    \"\"\"漫画\"\"\"\n\n\n@enum_auto_doc\nclass RankingType(str, Enum):\n    \"\"\"排行榜内容类型\"\"\"\n\n    day = \"day\"\n    \"\"\"日榜\"\"\"\n    week = \"week\"\n    \"\"\"周榜\"\"\"\n    month = \"month\"\n    \"\"\"月榜\"\"\"\n    day_male = \"day_male\"\n    \"\"\"男性向\"\"\"\n    day_female = \"day_female\"\n    \"\"\"女性向\"\"\"\n    week_original = \"week_original\"\n    \"\"\"原创周榜\"\"\"\n    week_rookie = \"week_rookie\"\n    \"\"\"新人周榜\"\"\"\n    day_ai = \"day_ai\"\n    \"\"\"AI日榜\"\"\"\n    day_manga = \"day_manga\"\n    \"\"\"漫画日榜\"\"\"\n    week_manga = \"week_manga\"\n    \"\"\"漫画周榜\"\"\"\n    month_manga = \"month_manga\"\n    \"\"\"漫画月榜\"\"\"\n    week_rookie_manga = \"week_rookie_manga\"\n    \"\"\"漫画新人周榜\"\"\"\n    day_r18 = \"day_r18\"\n    day_male_r18 = \"day_male_r18\"\n    day_female_r18 = \"day_female_r18\"\n    week_r18 = \"week_r18\"\n    week_r18g = \"week_r18g\"\n    day_r18_ai = \"day_r18_ai\"\n    day_r18_manga = \"day_r18_manga\"\n    week_r18_manga = \"week_r18_manga\"\n\n\n@enum_auto_doc\nclass SearchModeType(str, Enum):\n    \"\"\"搜索匹配类型\"\"\"\n\n    partial_match_for_tags = \"partial_match_for_tags\"\n    \"\"\"标签部分一致\"\"\"\n    exact_match_for_tags = \"exact_match_for_tags\"\n    \"\"\"标签完全一致\"\"\"\n    title_and_caption = \"title_and_caption\"\n    \"\"\"标题说明文\"\"\"\n\n\n@enum_auto_doc\nclass SearchNovelModeType(str, Enum):\n    \"\"\"搜索匹配类型\"\"\"\n\n    partial_match_for_tags = \"partial_match_for_tags\"\n    \"\"\"标签部分一致\"\"\"\n    exact_match_for_tags = \"exact_match_for_tags\"\n    \"\"\"标签完全一致\"\"\"\n    text = \"text\"\n    \"\"\"正文\"\"\"\n    keyword = \"keyword\"\n    \"\"\"关键词\"\"\"\n\n\n@enum_auto_doc\nclass SearchSortType(str, Enum):\n    \"\"\"搜索排序类型\"\"\"\n\n    date_desc = \"date_desc\"\n    \"\"\"按日期倒序\"\"\"\n    date_asc = \"date_asc\"\n    \"\"\"按日期正序\"\"\"\n    popular_desc = \"popular_desc\"\n    \"\"\"受欢迎降序(Premium功能)\"\"\"\n\n\n@enum_auto_doc\nclass SearchDurationType(str, Enum):\n    \"\"\"搜索时段类型\"\"\"\n\n    within_last_day = \"within_last_day\"\n    \"\"\"一天内\"\"\"\n    within_last_week = \"within_last_week\"\n    \"\"\"一周内\"\"\"\n    within_last_month = \"within_last_month\"\n    \"\"\"一个月内\"\"\"\n\n\nclass RankingDate(date):\n    @classmethod\n    def yesterday(cls) -> \"RankingDate\":\n        yesterday = cls.today() - timedelta(days=1)\n        return cls(yesterday.year, yesterday.month, yesterday.day)\n\n    def toString(self) -> str:\n        return self.strftime(r\"%Y-%m-%d\")\n\n    @classmethod\n    def new(cls, date: date) -> \"RankingDate\":\n        return cls(date.year, date.month, date.day)\n\n\nclass PixivEndpoints(BaseEndpoint):\n    @staticmethod\n    def _parse_accept_language(accept_language: str) -> str:\n        first_language, *_ = accept_language.partition(\",\")\n        language_code, *_ = first_language.partition(\";\")\n        return language_code.lower().strip()\n\n    @overload\n    async def request(\n        self,\n        endpoint: str,\n        *,\n        params: Optional[dict[str, Any]] = None,\n        return_text: Literal[False] = False,\n    ) -> dict[str, Any]: ...\n\n    @overload\n    async def request(\n        self,\n        endpoint: str,\n        *,\n        params: Optional[dict[str, Any]] = None,\n        return_text: Literal[True],\n    ) -> str: ...\n\n    @dont_route\n    @catch_network_error\n    async def request(\n        self,\n        endpoint: str,\n        *,\n        params: Optional[dict[str, Any]] = None,\n        return_text: bool = False,\n    ) -> Union[dict[str, Any], str]:\n        headers = self.client.headers.copy()\n\n        net_client = cast(PixivNetClient, self.client.net_client)\n        async with net_client.auth_lock:\n            auth, token = net_client.get_available_user()\n            if auth is None:\n                auth = await net_client.auth(token)\n        headers[\"Authorization\"] = f\"Bearer {auth.access_token}\"\n\n        if language := request_headers.get().get(\"Accept-Language\"):\n            language = self._parse_accept_language(language)\n            headers[\"Accept-Language\"] = language\n\n        response = await self.client.get(\n            self._join(\n                base=PixivConstants.APP_HOST,\n                endpoint=endpoint,\n                params=params or {},\n            ),\n            headers=headers,\n        )\n        if return_text:\n            return response.text\n        return response.json()\n\n    @cache_config(ttl=timedelta(days=3))\n    async def illust(self, *, id: int):\n        return await self.request(\"v1/illust/detail\", params={\"illust_id\": id})\n\n    @cache_config(ttl=timedelta(days=1))\n    async def member(self, *, id: int):\n        return await self.request(\"v1/user/detail\", params={\"user_id\": id})\n\n    async def member_illust(\n        self,\n        *,\n        id: int,\n        illust_type: IllustType = IllustType.illust,\n        page: int = 1,\n        size: int = 30,\n    ):\n        return await self.request(\n            \"v1/user/illusts\",\n            params={\n                \"user_id\": id,\n                \"type\": illust_type,\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    async def favorite(\n        self,\n        *,\n        id: int,\n        tag: Optional[str] = None,\n        max_bookmark_id: Optional[int] = None,\n    ):\n        return await self.request(\n            \"v1/user/bookmarks/illust\",\n            params={\n                \"user_id\": id,\n                \"tag\": tag,\n                \"restrict\": \"public\",\n                \"max_bookmark_id\": max_bookmark_id or None,\n            },\n        )\n\n    # 用户收藏的小说\n    async def favorite_novel(\n        self,\n        *,\n        id: int,\n        tag: Optional[str] = None,\n    ):\n        return await self.request(\n            \"v1/user/bookmarks/novel\",\n            params={\n                \"user_id\": id,\n                \"tag\": tag,\n                \"restrict\": \"public\",\n            },\n        )\n\n    async def following(self, *, id: int, page: int = 1, size: int = 30):\n        return await self.request(\n            \"v1/user/following\",\n            params={\n                \"user_id\": id,\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    async def follower(self, *, id: int, page: int = 1, size: int = 30):\n        return await self.request(\n            \"v1/user/follower\",\n            params={\n                \"user_id\": id,\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    @cache_config(ttl=timedelta(hours=12))\n    async def rank(\n        self,\n        *,\n        mode: RankingType = RankingType.week,\n        date: Optional[RankingDate] = None,\n        page: int = 1,\n        size: int = 30,\n    ):\n        return await self.request(\n            \"v1/illust/ranking\",\n            params={\n                \"mode\": mode,\n                \"date\": RankingDate.new(date or RankingDate.yesterday()).toString(),\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    async def search(\n        self,\n        *,\n        word: str,\n        mode: SearchModeType = SearchModeType.partial_match_for_tags,\n        order: SearchSortType = SearchSortType.date_desc,\n        duration: Optional[SearchDurationType] = None,\n        page: int = 1,\n        size: int = 30,\n        include_translated_tag_results: bool = True,\n        search_ai_type: bool = True,  # 搜索结果是否包含AI作品\n    ):\n        return await self.request(\n            \"v1/search/illust\",\n            params={\n                \"word\": word,\n                \"search_target\": mode,\n                \"sort\": order,\n                \"duration\": duration,\n                \"offset\": (page - 1) * size,\n                \"include_translated_tag_results\": include_translated_tag_results,\n                \"search_ai_type\": 1 if search_ai_type else 0,\n            },\n        )\n\n    # 热门插画作品预览\n    async def popular_preview(\n        self,\n        *,\n        word: str,\n        mode: SearchModeType = SearchModeType.partial_match_for_tags,\n        merge_plain_keyword_results: bool = True,\n        include_translated_tag_results: bool = True,\n        filter: str = \"for_ios\",\n    ):\n        return await self.request(\n            \"v1/search/popular-preview/illust\",\n            params={\n                \"word\": word,\n                \"search_target\": mode,\n                \"merge_plain_keyword_results\": merge_plain_keyword_results,\n                \"include_translated_tag_results\": include_translated_tag_results,\n                \"filter\": filter,\n            },\n        )\n\n    async def search_user(\n        self,\n        *,\n        word: str,\n        page: int = 1,\n        size: int = 30,\n    ):\n        return await self.request(\n            \"v1/search/user\",\n            params={\"word\": word, \"offset\": (page - 1) * size},\n        )\n\n    async def tags_autocomplete(\n        self,\n        *,\n        word: str,\n        merge_plain_keyword_results: bool = True,\n    ):\n        return await self.request(\n            \"/v2/search/autocomplete\",\n            params={\n                \"word\": word,\n                \"merge_plain_keyword_results\": merge_plain_keyword_results,\n            },\n        )\n\n    @cache_config(ttl=timedelta(hours=12))\n    async def tags(self):\n        return await self.request(\"v1/trending-tags/illust\")\n\n    @cache_config(ttl=timedelta(minutes=15))\n    async def related(self, *, id: int, page: int = 1, size: int = 30):\n        return await self.request(\n            \"v2/illust/related\",\n            params={\n                \"illust_id\": id,\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    @cache_config(ttl=timedelta(days=3))\n    async def ugoira_metadata(self, *, id: int):\n        return await self.request(\n            \"v1/ugoira/metadata\",\n            params={\n                \"illust_id\": id,\n            },\n        )\n\n    # 大家的新作品（插画）\n    async def illust_new(\n        self,\n        *,\n        content_type: str = \"illust\",\n    ):\n        return await self.request(\n            \"v1/illust/new\",\n            params={\n                \"content_type\": content_type,\n                \"filter\": \"for_ios\",\n            },\n        )\n\n    # pixivision(亮点/特辑) 列表\n    async def spotlights(\n        self,\n        *,\n        category: str = \"all\",\n        page: int = 1,\n        size: int = 10,\n    ):\n        return await self.request(\n            \"v1/spotlight/articles\",\n            params={\n                \"filter\": \"for_ios\",\n                \"category\": category,\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    # 插画评论\n    async def illust_comments(\n        self,\n        *,\n        id: int,\n        page: int = 1,\n        size: int = 30,\n    ):\n        return await self.request(\n            \"v3/illust/comments\",\n            params={\n                \"illust_id\": id,\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    # 插画评论回复\n    async def illust_comment_replies(\n        self,\n        *,\n        id: int,\n    ):\n        return await self.request(\n            \"v2/illust/comment/replies\",\n            params={\n                \"comment_id\": id,\n            },\n        )\n\n    # 小说评论\n    async def novel_comments(\n        self,\n        *,\n        id: int,\n        page: int = 1,\n        size: int = 30,\n    ):\n        return await self.request(\n            \"v3/novel/comments\",\n            params={\n                \"novel_id\": id,\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    # 小说评论回复\n    async def novel_comment_replies(\n        self,\n        *,\n        id: int,\n    ):\n        return await self.request(\n            \"v2/novel/comment/replies\",\n            params={\n                \"comment_id\": id,\n            },\n        )\n\n    # 小说排行榜\n    async def rank_novel(\n        self,\n        *,\n        mode: str = \"day\",\n        date: Optional[RankingDate] = None,\n        page: int = 1,\n        size: int = 30,\n    ):\n        return await self.request(\n            \"v1/novel/ranking\",\n            params={\n                \"mode\": mode,\n                \"date\": RankingDate.new(date or RankingDate.yesterday()).toString(),\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    async def member_novel(self, *, id: int, page: int = 1, size: int = 30):\n        return await self.request(\n            \"/v1/user/novels\",\n            params={\n                \"user_id\": id,\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    async def novel_series(self, *, id: int):\n        return await self.request(\"/v2/novel/series\", params={\"series_id\": id})\n\n    async def novel_detail(self, *, id: int):\n        return await self.request(\"/v2/novel/detail\", params={\"novel_id\": id})\n\n    # 已被官方移除，调用 webview/v2/novel 作兼容处理\n    async def novel_text(self, *, id: int):\n        # return await self.request(\"/v1/novel/text\", params={\"novel_id\": id})\n        response = await self.webview_novel(id=id)\n        return {\"novel_text\": response[\"text\"] or \"\"}\n\n    # 获取小说 HTML 后解析 JSON\n    async def webview_novel(self, *, id: int):\n        response = await self.request(\n            \"webview/v2/novel\",\n            params={\n                \"id\": id,\n                \"viewer_version\": \"20221031_ai\",\n            },\n            return_text=True,\n        )\n\n        novel_match = re.search(r\"novel:\\s+(?P<data>{.+?}),\\s+isOwnWork\", response)\n        return json.loads(novel_match[\"data\"] if novel_match else response)\n\n    @cache_config(ttl=timedelta(hours=12))\n    async def tags_novel(self):\n        return await self.request(\"v1/trending-tags/novel\")\n\n    async def search_novel(\n        self,\n        *,\n        word: str,\n        mode: SearchNovelModeType = SearchNovelModeType.partial_match_for_tags,\n        sort: SearchSortType = SearchSortType.date_desc,\n        merge_plain_keyword_results: bool = True,\n        include_translated_tag_results: bool = True,\n        duration: Optional[SearchDurationType] = None,\n        page: int = 1,\n        size: int = 30,\n        search_ai_type: bool = True,  # 搜索结果是否包含AI作品\n    ):\n        return await self.request(\n            \"/v1/search/novel\",\n            params={\n                \"word\": word,\n                \"search_target\": mode,\n                \"sort\": sort,\n                \"merge_plain_keyword_results\": merge_plain_keyword_results,\n                \"include_translated_tag_results\": include_translated_tag_results,\n                \"duration\": duration,\n                \"offset\": (page - 1) * size,\n                \"search_ai_type\": 1 if search_ai_type else 0,\n            },\n        )\n\n    # 热门小说作品预览\n    async def popular_preview_novel(\n        self,\n        *,\n        word: str,\n        mode: SearchNovelModeType = SearchNovelModeType.partial_match_for_tags,\n        merge_plain_keyword_results: bool = True,\n        include_translated_tag_results: bool = True,\n        filter: str = \"for_ios\",\n    ):\n        return await self.request(\n            \"v1/search/popular-preview/novel\",\n            params={\n                \"word\": word,\n                \"search_target\": mode,\n                \"merge_plain_keyword_results\": merge_plain_keyword_results,\n                \"include_translated_tag_results\": include_translated_tag_results,\n                \"filter\": filter,\n            },\n        )\n\n    async def novel_new(self, *, max_novel_id: Optional[int] = None):\n        return await self.request(\n            \"/v1/novel/new\", params={\"max_novel_id\": max_novel_id}\n        )\n\n    # 人气直播列表\n    async def live_list(self, *, page: int = 1, size: int = 30):\n        params = {\"list_type\": \"popular\", \"offset\": (page - 1) * size}\n        if not params[\"offset\"]:\n            del params[\"offset\"]\n        return await self.request(\"v1/live/list\", params=params)\n\n    # 相关小说作品\n    async def related_novel(self, *, id: int, page: int = 1, size: int = 30):\n        return await self.request(\n            \"v1/novel/related\",\n            params={\n                \"novel_id\": id,\n                \"offset\": (page - 1) * size,\n            },\n        )\n\n    # 相关用户\n    async def related_member(self, *, id: int):\n        return await self.request(\"v1/user/related\", params={\"seed_user_id\": id})\n\n    # 漫画系列\n    async def illust_series(self, *, id: int, page: int = 1, size: int = 30):\n        return await self.request(\n            \"v1/illust/series\",\n            params={\"illust_series_id\": id, \"offset\": (page - 1) * size},\n        )\n\n    # 用户的漫画系列\n    async def member_illust_series(self, *, id: int, page: int = 1, size: int = 30):\n        return await self.request(\n            \"v1/user/illust-series\",\n            params={\"user_id\": id, \"offset\": (page - 1) * size},\n        )\n\n    # 用户的小说系列\n    async def member_novel_series(self, *, id: int, page: int = 1, size: int = 30):\n        return await self.request(\n            \"v1/user/novel-series\", params={\"user_id\": id, \"offset\": (page - 1) * size}\n        )\n"
  },
  {
    "path": "hibiapi/api/pixiv/constants.py",
    "content": "from typing import Any\n\nfrom hibiapi.utils.config import APIConfig\n\n\nclass PixivConstants:\n    DEFAULT_HEADERS: dict[str, Any] = {\n        \"App-OS\": \"ios\",\n        \"App-OS-Version\": \"14.6\",\n        \"User-Agent\": \"PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)\",\n    }\n    CLIENT_ID: str = \"MOBrBDS8blbauoSck0ZfDbtuzpyT\"\n    CLIENT_SECRET: str = \"lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj\"\n    HASH_SECRET: bytes = (\n        b\"28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c\"\n    )\n    CONFIG: APIConfig = APIConfig(\"pixiv\")\n    APP_HOST: str = \"https://app-api.pixiv.net\"\n    AUTH_HOST: str = \"https://oauth.secure.pixiv.net\"\n"
  },
  {
    "path": "hibiapi/api/pixiv/net.py",
    "content": "import asyncio\nimport hashlib\nfrom datetime import datetime, timedelta, timezone\nfrom itertools import cycle\n\nfrom httpx import URL\nfrom pydantic import BaseModel, Extra, Field\n\nfrom hibiapi.utils.log import logger\nfrom hibiapi.utils.net import BaseNetClient\n\nfrom .constants import PixivConstants\n\n\nclass AccountDataModel(BaseModel):\n    class Config:\n        extra = Extra.allow\n\n\nclass PixivUserData(AccountDataModel):\n    account: str\n    id: int\n    is_premium: bool\n    mail_address: str\n    name: str\n\n\nclass PixivAuthData(AccountDataModel):\n    time: datetime = Field(default_factory=datetime.now)\n    expires_in: int\n    access_token: str\n    refresh_token: str\n    user: PixivUserData\n\n\nclass NetRequest(BaseNetClient):\n    def __init__(self, tokens: list[str]):\n        super().__init__(\n            headers=PixivConstants.DEFAULT_HEADERS.copy(),\n            proxies=PixivConstants.CONFIG[\"proxy\"].as_dict(),\n        )\n        self.user_tokens = cycle(tokens)\n        self.auth_lock = asyncio.Lock()\n        self.user_tokens_dict: dict[str, PixivAuthData] = {}\n        self.headers[\"accept-language\"] = PixivConstants.CONFIG[\"language\"].as_str()\n\n    def get_available_user(self):\n        token = next(self.user_tokens)\n        if (auth_data := self.user_tokens_dict.get(token)) and (\n            auth_data.time + timedelta(minutes=1, seconds=auth_data.expires_in)\n            > datetime.now()\n        ):\n            return auth_data, token\n        return None, token\n\n    async def auth(self, refresh_token: str):\n        url = URL(PixivConstants.AUTH_HOST).join(\"/auth/token\")\n        time = datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%S+00:00\")\n        headers = {\n            **self.headers,\n            \"X-Client-Time\": time,\n            \"X-Client-Hash\": hashlib.md5(\n                time.encode() + PixivConstants.HASH_SECRET\n            ).hexdigest(),\n        }\n        payload = {\n            \"get_secure_url\": 1,\n            \"client_id\": PixivConstants.CLIENT_ID,\n            \"client_secret\": PixivConstants.CLIENT_SECRET,\n            \"grant_type\": \"refresh_token\",\n            \"refresh_token\": refresh_token,\n        }\n\n        async with self as client:\n            response = await client.post(url, data=payload, headers=headers)\n            response.raise_for_status()\n\n        self.user_tokens_dict[refresh_token] = PixivAuthData.parse_obj(response.json())\n        user_data = self.user_tokens_dict[refresh_token].user\n        logger.opt(colors=True).info(\n            f\"Pixiv account <m>{user_data.id}</m> info <b>Updated</b>: \"\n            f\"<b><e>{user_data.name}</e>({user_data.account})</b>.\"\n        )\n\n        return self.user_tokens_dict[refresh_token]\n"
  },
  {
    "path": "hibiapi/api/qrcode.py",
    "content": "from datetime import datetime\nfrom enum import Enum\nfrom io import BytesIO\nfrom os import fdopen\nfrom pathlib import Path\nfrom typing import Literal, Optional, cast\n\nfrom PIL import Image\nfrom pydantic import AnyHttpUrl, BaseModel, Field, validate_arguments\nfrom pydantic.color import Color\nfrom qrcode import constants\nfrom qrcode.image.pil import PilImage\nfrom qrcode.main import QRCode\n\nfrom hibiapi.utils.config import APIConfig\nfrom hibiapi.utils.decorators import ToAsync, enum_auto_doc\nfrom hibiapi.utils.exceptions import ClientSideException\nfrom hibiapi.utils.net import BaseNetClient\nfrom hibiapi.utils.routing import BaseHostUrl\nfrom hibiapi.utils.temp import TempFile\n\nConfig = APIConfig(\"qrcode\")\n\n\nclass HostUrl(BaseHostUrl):\n    allowed_hosts = Config[\"qrcode\"][\"icon-site\"].get(list[str])\n\n\n@enum_auto_doc\nclass QRCodeLevel(str, Enum):\n    \"\"\"二维码容错率\"\"\"\n\n    LOW = \"L\"\n    \"\"\"最低容错率\"\"\"\n    MEDIUM = \"M\"\n    \"\"\"中等容错率\"\"\"\n    QUARTILE = \"Q\"\n    \"\"\"高容错率\"\"\"\n    HIGH = \"H\"\n    \"\"\"最高容错率\"\"\"\n\n\n@enum_auto_doc\nclass ReturnEncode(str, Enum):\n    \"\"\"二维码返回的编码方式\"\"\"\n\n    raw = \"raw\"\n    \"\"\"直接重定向到二维码图片\"\"\"\n    json = \"json\"\n    \"\"\"返回JSON格式的二维码信息\"\"\"\n    js = \"js\"\n    jsc = \"jsc\"\n\n\nCOLOR_WHITE = Color(\"FFFFFF\")\nCOLOR_BLACK = Color(\"000000\")\n\n\nclass QRInfo(BaseModel):\n    url: Optional[AnyHttpUrl] = None\n    path: Path\n    time: datetime = Field(default_factory=datetime.now)\n    data: str\n    logo: Optional[HostUrl] = None\n    level: QRCodeLevel = QRCodeLevel.MEDIUM\n    size: int = 200\n    code: Literal[0] = 0\n    status: Literal[\"success\"] = \"success\"\n\n    @classmethod\n    @validate_arguments\n    async def new(\n        cls,\n        text: str,\n        *,\n        size: int = Field(\n            200,\n            gt=Config[\"qrcode\"][\"min-size\"].as_number(),\n            lt=Config[\"qrcode\"][\"max-size\"].as_number(),\n        ),\n        logo: Optional[HostUrl] = None,\n        level: QRCodeLevel = QRCodeLevel.MEDIUM,\n        bgcolor: Color = COLOR_WHITE,\n        fgcolor: Color = COLOR_BLACK,\n    ):\n        icon_stream = None\n        if logo is not None:\n            async with BaseNetClient() as client:\n                response = await client.get(\n                    logo, headers={\"user-agent\": \"HibiAPI@GitHub\"}, timeout=6\n                )\n                response.raise_for_status()\n            icon_stream = BytesIO(response.content)\n        return cls(\n            data=text,\n            logo=logo,\n            level=level,\n            size=size,\n            path=await cls._generate(\n                text,\n                size=size,\n                level=level,\n                icon_stream=icon_stream,\n                bgcolor=bgcolor.as_hex(),\n                fgcolor=fgcolor.as_hex(),\n            ),\n        )\n\n    @classmethod\n    @ToAsync\n    def _generate(\n        cls,\n        text: str,\n        *,\n        size: int = 200,\n        level: QRCodeLevel = QRCodeLevel.MEDIUM,\n        icon_stream: Optional[BytesIO] = None,\n        bgcolor: str = \"#FFFFFF\",\n        fgcolor: str = \"#000000\",\n    ) -> Path:\n        qr = QRCode(\n            error_correction={\n                QRCodeLevel.LOW: constants.ERROR_CORRECT_L,\n                QRCodeLevel.MEDIUM: constants.ERROR_CORRECT_M,\n                QRCodeLevel.QUARTILE: constants.ERROR_CORRECT_Q,\n                QRCodeLevel.HIGH: constants.ERROR_CORRECT_H,\n            }[level],\n            border=2,\n            box_size=8,\n        )\n        qr.add_data(text)\n        image = cast(\n            Image.Image,\n            qr.make_image(\n                PilImage,\n                back_color=bgcolor,\n                fill_color=fgcolor,\n            ).get_image(),\n        )\n        image = image.resize((size, size))\n        if icon_stream is not None:\n            try:\n                icon = Image.open(icon_stream)\n            except ValueError as e:\n                raise ClientSideException(\"Invalid image format.\") from e\n            icon_width, icon_height = icon.size\n            image.paste(\n                icon,\n                box=(\n                    int(size / 2 - icon_width / 2),\n                    int(size / 2 - icon_height / 2),\n                    int(size / 2 + icon_width / 2),\n                    int(size / 2 + icon_height / 2),\n                ),\n                mask=icon if icon.mode == \"RGBA\" else None,\n            )\n        descriptor, path = TempFile.create(\".png\")\n        with fdopen(descriptor, \"wb\") as f:\n            image.save(f, format=\"PNG\")\n        return path\n"
  },
  {
    "path": "hibiapi/api/sauce/__init__.py",
    "content": "# flake8:noqa:F401\nfrom .api import DeduplicateType, HostUrl, SauceEndpoint, UploadFileIO\nfrom .constants import SauceConstants\nfrom .net import NetRequest\n"
  },
  {
    "path": "hibiapi/api/sauce/api.py",
    "content": "import random\nfrom enum import IntEnum\nfrom io import BytesIO\nfrom typing import Any, Optional, overload\n\nfrom httpx import HTTPError\n\nfrom hibiapi.api.sauce.constants import SauceConstants\nfrom hibiapi.utils.decorators import enum_auto_doc\nfrom hibiapi.utils.exceptions import ClientSideException\nfrom hibiapi.utils.net import catch_network_error\nfrom hibiapi.utils.routing import BaseEndpoint, BaseHostUrl\n\n\nclass UnavailableSourceException(ClientSideException):\n    code = 422\n    detail = \"given image is not avaliable to fetch\"\n\n\nclass ImageSourceOversizedException(UnavailableSourceException):\n    code = 413\n    detail = (\n        \"given image size is rather than maximum limit \"\n        f\"{SauceConstants.IMAGE_MAXIMUM_SIZE} bytes\"\n    )\n\n\nclass HostUrl(BaseHostUrl):\n    allowed_hosts = SauceConstants.IMAGE_ALLOWED_HOST\n\n\nclass UploadFileIO(BytesIO):\n    @classmethod\n    def __get_validators__(cls):\n        yield cls.validate\n\n    @classmethod\n    def validate(cls, v: Any) -> BytesIO:\n        if not isinstance(v, BytesIO):\n            raise ValueError(f\"Expected UploadFile, received: {type(v)}\")\n        return v\n\n\n@enum_auto_doc\nclass DeduplicateType(IntEnum):\n    DISABLED = 0\n    \"\"\"no result deduplicating\"\"\"\n    IDENTIFIER = 1\n    \"\"\"consolidate search results and deduplicate by item identifier\"\"\"\n    ALL = 2\n    \"\"\"all implemented deduplicate methods such as by series name\"\"\"\n\n\nclass SauceEndpoint(BaseEndpoint, cache_endpoints=False):\n    base = \"https://saucenao.com\"\n\n    async def fetch(self, host: HostUrl) -> UploadFileIO:\n        try:\n            response = await self.client.get(\n                url=host,\n                headers=SauceConstants.IMAGE_HEADERS,\n                timeout=SauceConstants.IMAGE_TIMEOUT,\n            )\n            response.raise_for_status()\n            if len(response.content) > SauceConstants.IMAGE_MAXIMUM_SIZE:\n                raise ImageSourceOversizedException\n            return UploadFileIO(response.content)\n        except HTTPError as e:\n            raise UnavailableSourceException(detail=str(e)) from e\n\n    @catch_network_error\n    async def request(\n        self, *, file: UploadFileIO, params: dict[str, Any]\n    ) -> dict[str, Any]:\n        response = await self.client.post(\n            url=self._join(\n                self.base,\n                \"search.php\",\n                params={\n                    **params,\n                    \"api_key\": random.choice(SauceConstants.API_KEY),\n                    \"output_type\": 2,\n                },\n            ),\n            files={\"file\": file},\n        )\n        if response.status_code >= 500:\n            response.raise_for_status()\n        return response.json()\n\n    @overload\n    async def search(\n        self,\n        *,\n        url: HostUrl,\n        size: int = 30,\n        deduplicate: DeduplicateType = DeduplicateType.ALL,\n        database: Optional[int] = None,\n        enabled_mask: Optional[int] = None,\n        disabled_mask: Optional[int] = None,\n    ) -> dict[str, Any]:\n        ...\n\n    @overload\n    async def search(\n        self,\n        *,\n        file: UploadFileIO,\n        size: int = 30,\n        deduplicate: DeduplicateType = DeduplicateType.ALL,\n        database: Optional[int] = None,\n        enabled_mask: Optional[int] = None,\n        disabled_mask: Optional[int] = None,\n    ) -> dict[str, Any]:\n        ...\n\n    async def search(\n        self,\n        *,\n        url: Optional[HostUrl] = None,\n        file: Optional[UploadFileIO] = None,\n        size: int = 30,\n        deduplicate: DeduplicateType = DeduplicateType.ALL,\n        database: Optional[int] = None,\n        enabled_mask: Optional[int] = None,\n        disabled_mask: Optional[int] = None,\n    ):\n        if url is not None:\n            file = await self.fetch(url)\n        assert file is not None\n        return await self.request(\n            file=file,\n            params={\n                \"dbmask\": enabled_mask,\n                \"dbmaski\": disabled_mask,\n                \"db\": database,\n                \"numres\": size,\n                \"dedupe\": deduplicate,\n            },\n        )\n"
  },
  {
    "path": "hibiapi/api/sauce/constants.py",
    "content": "from typing import Any\n\nfrom hibiapi.utils.config import APIConfig\n\n_Config = APIConfig(\"sauce\")\n\n\nclass SauceConstants:\n    CONFIG: APIConfig = _Config\n    API_KEY: list[str] = _Config[\"net\"][\"api-key\"].as_str_seq()\n    USER_AGENT: str = _Config[\"net\"][\"user-agent\"].as_str()\n    PROXIES: dict[str, str] = _Config[\"proxy\"].as_dict()\n    IMAGE_HEADERS: dict[str, Any] = _Config[\"image\"][\"headers\"].as_dict()\n    IMAGE_ALLOWED_HOST: list[str] = _Config[\"image\"][\"allowed\"].get(list[str])\n    IMAGE_MAXIMUM_SIZE: int = _Config[\"image\"][\"max-size\"].as_number() * 1024\n    IMAGE_TIMEOUT: int = _Config[\"image\"][\"timeout\"].as_number()\n"
  },
  {
    "path": "hibiapi/api/sauce/net.py",
    "content": "from hibiapi.utils.net import BaseNetClient\n\nfrom .constants import SauceConstants\n\n\nclass NetRequest(BaseNetClient):\n    def __init__(self):\n        super().__init__(\n            headers={\"user-agent\": SauceConstants.USER_AGENT},\n            proxies=SauceConstants.PROXIES,\n        )\n"
  },
  {
    "path": "hibiapi/api/tieba/__init__.py",
    "content": "# flake8:noqa:F401\nfrom .api import Config, TiebaEndpoint\nfrom .net import NetRequest\n"
  },
  {
    "path": "hibiapi/api/tieba/api.py",
    "content": "import hashlib\nfrom enum import Enum\nfrom random import randint\nfrom typing import Any, Optional\n\nfrom hibiapi.utils.config import APIConfig\nfrom hibiapi.utils.net import catch_network_error\nfrom hibiapi.utils.routing import BaseEndpoint, dont_route\n\nConfig = APIConfig(\"tieba\")\n\n\nclass TiebaSignUtils:\n    salt = b\"tiebaclient!!!\"\n\n    @staticmethod\n    def random_digit(length: int) -> str:\n        return \"\".join(map(str, [randint(0, 9) for _ in range(length)]))\n\n    @staticmethod\n    def construct_content(params: dict[str, Any]) -> bytes:\n        # NOTE: this function used to construct form content WITHOUT urlencode\n        # Don't ask me why this is necessary, ask Tieba's programmers instead\n        return b\"&\".join(\n            map(\n                lambda k, v: (\n                    k.encode()\n                    + b\"=\"\n                    + str(v.value if isinstance(v, Enum) else v).encode()\n                ),\n                params.keys(),\n                params.values(),\n            )\n        )\n\n    @classmethod\n    def sign(cls, params: dict[str, Any]) -> bytes:\n        params.update(\n            {\n                \"_client_id\": (\n                    \"wappc_\" + cls.random_digit(13) + \"_\" + cls.random_digit(3)\n                ),\n                \"_client_type\": 2,\n                \"_client_version\": \"9.9.8.32\",\n                **{\n                    k.upper(): str(v).strip()\n                    for k, v in Config[\"net\"][\"params\"].as_dict().items()\n                    if v\n                },\n            }\n        )\n        params = {k: params[k] for k in sorted(params.keys())}\n        params[\"sign\"] = (\n            hashlib.md5(cls.construct_content(params).replace(b\"&\", b\"\") + cls.salt)\n            .hexdigest()\n            .upper()\n        )\n        return cls.construct_content(params)\n\n\nclass TiebaEndpoint(BaseEndpoint):\n    base = \"http://c.tieba.baidu.com\"\n\n    @dont_route\n    @catch_network_error\n    async def request(\n        self, endpoint: str, *, params: Optional[dict[str, Any]] = None\n    ) -> dict[str, Any]:\n        response = await self.client.post(\n            url=self._join(self.base, endpoint, {}),\n            content=TiebaSignUtils.sign(params or {}),\n        )\n        response.raise_for_status()\n        return response.json()\n\n    async def post_list(self, *, name: str, page: int = 1, size: int = 50):\n        return await self.request(\n            \"c/f/frs/page\",\n            params={\n                \"kw\": name,\n                \"pn\": page,\n                \"rn\": size,\n            },\n        )\n\n    async def post_detail(\n        self,\n        *,\n        tid: int,\n        page: int = 1,\n        size: int = 50,\n        reversed: bool = False,\n    ):\n        return await self.request(\n            \"c/f/pb/page\",\n            params={\n                **({\"last\": 1, \"r\": 1} if reversed else {}),\n                \"kz\": tid,\n                \"pn\": page,\n                \"rn\": size,\n            },\n        )\n\n    async def subpost_detail(\n        self,\n        *,\n        tid: int,\n        pid: int,\n        page: int = 1,\n        size: int = 50,\n    ):\n        return await self.request(\n            \"c/f/pb/floor\",\n            params={\n                \"kz\": tid,\n                \"pid\": pid,\n                \"pn\": page,\n                \"rn\": size,\n            },\n        )\n\n    async def user_profile(self, *, uid: int):\n        return await self.request(\n            \"c/u/user/profile\",\n            params={\n                \"uid\": uid,\n                \"need_post_count\": 1,\n                \"has_plist\": 1,\n            },\n        )\n\n    async def user_subscribed(\n        self, *, uid: int, page: int = 1\n    ):  # XXX This API required user login!\n        return await self.request(\n            \"c/f/forum/like\",\n            params={\n                \"is_guest\": 0,\n                \"uid\": uid,\n                \"page_no\": page,\n            },\n        )\n"
  },
  {
    "path": "hibiapi/api/tieba/net.py",
    "content": "from hibiapi.utils.net import BaseNetClient\n\n\nclass NetRequest(BaseNetClient):\n    pass\n"
  },
  {
    "path": "hibiapi/api/wallpaper/__init__.py",
    "content": "# flake8:noqa:F401\nfrom .api import Config, WallpaperCategoryType, WallpaperEndpoint, WallpaperOrderType\nfrom .net import NetRequest\n"
  },
  {
    "path": "hibiapi/api/wallpaper/api.py",
    "content": "from datetime import timedelta\nfrom enum import Enum\nfrom typing import Any, Optional\n\nfrom hibiapi.utils.cache import cache_config\nfrom hibiapi.utils.config import APIConfig\nfrom hibiapi.utils.decorators import enum_auto_doc\nfrom hibiapi.utils.net import catch_network_error\nfrom hibiapi.utils.routing import BaseEndpoint, dont_route\n\nConfig = APIConfig(\"wallpaper\")\n\n\n@enum_auto_doc\nclass WallpaperCategoryType(str, Enum):\n    \"\"\"壁纸分类\"\"\"\n\n    girl = \"girl\"\n    \"\"\"女生\"\"\"\n    animal = \"animal\"\n    \"\"\"动物\"\"\"\n    landscape = \"landscape\"\n    \"\"\"自然\"\"\"\n    anime = \"anime\"\n    \"\"\"二次元\"\"\"\n    drawn = \"drawn\"\n    \"\"\"手绘\"\"\"\n    mechanics = \"mechanics\"\n    \"\"\"机械\"\"\"\n    boy = \"boy\"\n    \"\"\"男生\"\"\"\n    game = \"game\"\n    \"\"\"游戏\"\"\"\n    text = \"text\"\n    \"\"\"文字\"\"\"\n\n\nCATEGORY: dict[WallpaperCategoryType, str] = {\n    WallpaperCategoryType.girl: \"4e4d610cdf714d2966000000\",\n    WallpaperCategoryType.animal: \"4e4d610cdf714d2966000001\",\n    WallpaperCategoryType.landscape: \"4e4d610cdf714d2966000002\",\n    WallpaperCategoryType.anime: \"4e4d610cdf714d2966000003\",\n    WallpaperCategoryType.drawn: \"4e4d610cdf714d2966000004\",\n    WallpaperCategoryType.mechanics: \"4e4d610cdf714d2966000005\",\n    WallpaperCategoryType.boy: \"4e4d610cdf714d2966000006\",\n    WallpaperCategoryType.game: \"4e4d610cdf714d2966000007\",\n    WallpaperCategoryType.text: \"5109e04e48d5b9364ae9ac45\",\n}\n\n\n@enum_auto_doc\nclass WallpaperOrderType(str, Enum):\n    \"\"\"壁纸排序方式\"\"\"\n\n    hot = \"hot\"\n    \"\"\"热门\"\"\"\n    new = \"new\"\n    \"\"\"最新\"\"\"\n\n\nclass WallpaperEndpoint(BaseEndpoint):\n    base = \"http://service.aibizhi.adesk.com\"\n\n    @dont_route\n    @catch_network_error\n    async def request(\n        self, endpoint: str, *, params: Optional[dict[str, Any]] = None\n    ) -> dict[str, Any]:\n\n        response = await self.client.get(\n            self._join(\n                base=WallpaperEndpoint.base,\n                endpoint=endpoint,\n                params=params or {},\n            )\n        )\n        return response.json()\n\n    # 壁纸有防盗链token, 不建议长时间缓存\n    @cache_config(ttl=timedelta(hours=2))\n    async def wallpaper(\n        self,\n        *,\n        category: WallpaperCategoryType,\n        limit: int = 20,\n        skip: int = 0,\n        adult: bool = True,\n        order: WallpaperOrderType = WallpaperOrderType.hot,\n    ):\n\n        return await self.request(\n            \"v1/wallpaper/category/{category}/wallpaper\",\n            params={\n                \"limit\": limit,\n                \"skip\": skip,\n                \"adult\": adult,\n                \"order\": order,\n                \"first\": 0,\n                \"category\": CATEGORY[category],\n            },\n        )\n\n    # 壁纸有防盗链token, 不建议长时间缓存\n    @cache_config(ttl=timedelta(hours=2))\n    async def vertical(\n        self,\n        *,\n        category: WallpaperCategoryType,\n        limit: int = 20,\n        skip: int = 0,\n        adult: bool = True,\n        order: WallpaperOrderType = WallpaperOrderType.hot,\n    ):\n\n        return await self.request(\n            \"v1/vertical/category/{category}/vertical\",\n            params={\n                \"limit\": limit,\n                \"skip\": skip,\n                \"adult\": adult,\n                \"order\": order,\n                \"first\": 0,\n                \"category\": CATEGORY[category],\n            },\n        )\n"
  },
  {
    "path": "hibiapi/api/wallpaper/constants.py",
    "content": "from hibiapi.utils.config import APIConfig\n\n_CONFIG = APIConfig(\"wallpaper\")\n\n\nclass WallpaperConstants:\n    CONFIG: APIConfig = _CONFIG\n    USER_AGENT: str = _CONFIG[\"net\"][\"user-agent\"].as_str()\n"
  },
  {
    "path": "hibiapi/api/wallpaper/net.py",
    "content": "from hibiapi.utils.net import BaseNetClient\n\nfrom .constants import WallpaperConstants\n\n\nclass NetRequest(BaseNetClient):\n    def __init__(self):\n        super().__init__(headers={\"user-agent\": WallpaperConstants.USER_AGENT})\n"
  },
  {
    "path": "hibiapi/app/__init__.py",
    "content": "# flake8:noqa:F401\nfrom . import application, handlers, middlewares\n\napp = application.app\n"
  },
  {
    "path": "hibiapi/app/application.py",
    "content": "import asyncio\nimport re\nfrom contextlib import asynccontextmanager\nfrom ipaddress import ip_address\nfrom secrets import compare_digest\nfrom typing import Annotated\n\nimport sentry_sdk\nfrom fastapi import Depends, FastAPI, Request, Response\nfrom fastapi.responses import RedirectResponse\nfrom fastapi.security import HTTPBasic, HTTPBasicCredentials\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel\nfrom sentry_sdk.integrations.logging import LoggingIntegration\n\nfrom hibiapi import __version__\nfrom hibiapi.app.routes import router as ImplRouter\nfrom hibiapi.utils.cache import cache\nfrom hibiapi.utils.config import Config\nfrom hibiapi.utils.exceptions import ClientSideException, RateLimitReachedException\nfrom hibiapi.utils.log import logger\nfrom hibiapi.utils.net import BaseNetClient\nfrom hibiapi.utils.temp import TempFile\n\nDESCRIPTION = (\n    \"\"\"\n**A program that implements easy-to-use APIs for a variety of commonly used sites**\n\n- *Documents*:\n    - [Redoc](/docs) (Easier to read and more beautiful)\n    - [Swagger UI](/docs/test) (Integrated interactive testing function)\n\nProject: [mixmoe/HibiAPI](https://github.com/mixmoe/HibiAPI)\n\n\"\"\"\n    + Config[\"content\"][\"slogan\"].as_str().strip()\n).strip()\n\n\nif Config[\"log\"][\"sentry\"][\"enabled\"].as_bool():\n    sentry_sdk.init(\n        dsn=Config[\"log\"][\"sentry\"][\"dsn\"].as_str(),\n        send_default_pii=Config[\"log\"][\"sentry\"][\"pii\"].as_bool(),\n        integrations=[LoggingIntegration(level=None, event_level=None)],\n        traces_sample_rate=Config[\"log\"][\"sentry\"][\"sample\"].get(float),\n    )\nelse:\n    sentry_sdk.init()\n\n\nclass AuthorizationModel(BaseModel):\n    username: str\n    password: str\n\n\nAUTHORIZATION_ENABLED = Config[\"authorization\"][\"enabled\"].as_bool()\nAUTHORIZATION_ALLOWED = Config[\"authorization\"][\"allowed\"].get(list[AuthorizationModel])\n\nsecurity = HTTPBasic()\n\n\nasync def basic_authorization_depend(\n    credentials: Annotated[HTTPBasicCredentials, Depends(security)],\n):\n    # NOTE: We use `compare_digest` to avoid timing attacks.\n    # Ref: https://fastapi.tiangolo.com/advanced/security/http-basic-auth/\n    for allowed in AUTHORIZATION_ALLOWED:\n        if compare_digest(credentials.username, allowed.username) and compare_digest(\n            credentials.password, allowed.password\n        ):\n            return credentials.username, credentials.password\n    raise ClientSideException(\n        f\"Invalid credentials for user {credentials.username!r}\",\n        status_code=401,\n        headers={\"WWW-Authenticate\": \"Basic\"},\n    )\n\n\nRATE_LIMIT_ENABLED = Config[\"limit\"][\"enabled\"].as_bool()\nRATE_LIMIT_MAX = Config[\"limit\"][\"max\"].as_number()\nRATE_LIMIT_INTERVAL = Config[\"limit\"][\"interval\"].as_number()\n\n\nasync def rate_limit_depend(request: Request):\n    if not request.client:\n        return\n\n    try:\n        client_ip = ip_address(request.client.host)\n        client_ip_hex = client_ip.packed.hex()\n        limit_key = f\"rate_limit:IPv{client_ip.version}-{client_ip_hex:x}\"\n    except ValueError:\n        limit_key = f\"rate_limit:fallback-{request.client.host}\"\n\n    request_count = await cache.incr(limit_key)\n    if request_count <= 1:\n        await cache.expire(limit_key, timeout=RATE_LIMIT_INTERVAL)\n    elif request_count > RATE_LIMIT_MAX:\n        limit_remain: int = await cache.get_expire(limit_key)\n        raise RateLimitReachedException(headers={\"Retry-After\": limit_remain})\n\n    return\n\n\nasync def flush_sentry():\n    client = sentry_sdk.Hub.current.client\n    if client is not None:\n        client.close()\n    sentry_sdk.flush()\n    logger.debug(\"Sentry client has been closed\")\n\n\nasync def cleanup_clients():\n    opened_clients = [\n        client for client in BaseNetClient.clients if not client.is_closed\n    ]\n    if opened_clients:\n        await asyncio.gather(\n            *map(lambda client: client.aclose(), opened_clients),\n            return_exceptions=True,\n        )\n    logger.debug(f\"Cleaned <r>{len(opened_clients)}</r> unclosed HTTP clients\")\n\n\n@asynccontextmanager\nasync def fastapi_lifespan(app: FastAPI):\n    yield\n    await asyncio.gather(cleanup_clients(), flush_sentry())\n\n\napp = FastAPI(\n    title=\"HibiAPI\",\n    version=__version__,\n    description=DESCRIPTION,\n    docs_url=\"/docs/test\",\n    redoc_url=\"/docs\",\n    lifespan=fastapi_lifespan,\n)\napp.include_router(\n    ImplRouter,\n    prefix=\"/api\",\n    dependencies=(\n        ([Depends(basic_authorization_depend)] if AUTHORIZATION_ENABLED else [])\n        + ([Depends(rate_limit_depend)] if RATE_LIMIT_ENABLED else [])\n    ),\n)\napp.mount(\"/temp\", StaticFiles(directory=TempFile.path, check_dir=False))\n\n\n@app.get(\"/\", include_in_schema=False)\nasync def redirect():\n    return Response(status_code=302, headers={\"Location\": \"/docs\"})\n\n\n@app.get(\"/robots.txt\", include_in_schema=False)\nasync def robots():\n    content = Config[\"content\"][\"robots\"].as_str().strip()\n    return Response(content, status_code=200)\n\n\n@app.middleware(\"http\")\nasync def redirect_workaround_middleware(request: Request, call_next):\n    \"\"\"Temporary redirection workaround for #12\"\"\"\n    if matched := re.match(\n        r\"^/(qrcode|pixiv|netease|bilibili)/(\\w*)$\", request.url.path\n    ):\n        service, path = matched.groups()\n        redirect_url = request.url.replace(path=f\"/api/{service}/{path}\")\n        return RedirectResponse(redirect_url, status_code=301)\n    return await call_next(request)\n"
  },
  {
    "path": "hibiapi/app/handlers.py",
    "content": "from fastapi import Request, Response\nfrom fastapi.exceptions import HTTPException as FastAPIHTTPException\nfrom fastapi.exceptions import RequestValidationError as FastAPIValidationError\nfrom pydantic.error_wrappers import ValidationError as PydanticValidationError\nfrom starlette.exceptions import HTTPException as StarletteHTTPException\n\nfrom hibiapi.utils import exceptions\nfrom hibiapi.utils.log import logger\n\nfrom .application import app\n\n\n@app.exception_handler(exceptions.BaseServerException)\nasync def exception_handler(\n    request: Request,\n    exc: exceptions.BaseServerException,\n) -> Response:\n    if isinstance(exc, exceptions.UncaughtException):\n        logger.opt(exception=exc).exception(f\"Uncaught exception raised {exc.data=}:\")\n\n    exc.data.url = str(request.url)  # type:ignore\n    return Response(\n        content=exc.data.json(),\n        status_code=exc.data.code,\n        headers=exc.data.headers,\n        media_type=\"application/json\",\n    )\n\n\n@app.exception_handler(StarletteHTTPException)\nasync def override_handler(\n    request: Request,\n    exc: StarletteHTTPException,\n):\n    return await exception_handler(\n        request,\n        exceptions.BaseHTTPException(\n            exc.detail,\n            code=exc.status_code,\n            headers={} if not isinstance(exc, FastAPIHTTPException) else exc.headers,\n        ),\n    )\n\n\n@app.exception_handler(AssertionError)\nasync def assertion_handler(request: Request, exc: AssertionError):\n    return await exception_handler(\n        request,\n        exceptions.ClientSideException(detail=f\"Assertion: {exc}\"),\n    )\n\n\n@app.exception_handler(FastAPIValidationError)\n@app.exception_handler(PydanticValidationError)\nasync def validation_handler(request: Request, exc: PydanticValidationError):\n    return await exception_handler(\n        request,\n        exceptions.ValidationException(detail=str(exc), validation=exc.errors()),\n    )\n"
  },
  {
    "path": "hibiapi/app/middlewares.py",
    "content": "from collections.abc import Awaitable\nfrom datetime import datetime\nfrom typing import Callable\n\nfrom fastapi import Request, Response\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.middleware.trustedhost import TrustedHostMiddleware\nfrom sentry_sdk.integrations.asgi import SentryAsgiMiddleware\nfrom sentry_sdk.integrations.httpx import HttpxIntegration\nfrom starlette.datastructures import MutableHeaders\n\nfrom hibiapi.utils.config import Config\nfrom hibiapi.utils.exceptions import BaseServerException, UncaughtException\nfrom hibiapi.utils.log import LoguruHandler, logger\nfrom hibiapi.utils.routing import request_headers, response_headers\n\nfrom .application import app\nfrom .handlers import exception_handler\n\nRequestHandler = Callable[[Request], Awaitable[Response]]\n\n\nif Config[\"server\"][\"gzip\"].as_bool():\n    app.add_middleware(GZipMiddleware)\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=Config[\"server\"][\"cors\"][\"origins\"].get(list[str]),\n    allow_credentials=Config[\"server\"][\"cors\"][\"credentials\"].as_bool(),\n    allow_methods=Config[\"server\"][\"cors\"][\"methods\"].get(list[str]),\n    allow_headers=Config[\"server\"][\"cors\"][\"headers\"].get(list[str]),\n)\napp.add_middleware(\n    TrustedHostMiddleware,\n    allowed_hosts=Config[\"server\"][\"allowed\"].get(list[str]),\n)\napp.add_middleware(SentryAsgiMiddleware)\n\nHttpxIntegration.setup_once()\n\n\n@app.middleware(\"http\")\nasync def request_logger(request: Request, call_next: RequestHandler) -> Response:\n    start_time = datetime.now()\n    host, port = request.client or (None, None)\n    response = await call_next(request)\n    process_time = (datetime.now() - start_time).total_seconds() * 1000\n    response_headers.get().setdefault(\"X-Process-Time\", f\"{process_time:.3f}\")\n    bg, fg = (\n        (\"green\", \"red\")\n        if response.status_code < 400\n        else (\"yellow\", \"blue\")\n        if response.status_code < 500\n        else (\"red\", \"green\")\n    )\n    status_code, method = response.status_code, request.method.upper()\n    user_agent = (\n        LoguruHandler.escape_tag(request.headers[\"user-agent\"])\n        if \"user-agent\" in request.headers\n        else \"<d>Unknown</d>\"\n    )\n    logger.info(\n        f\"<m><b>{host}</b>:{port}</m>\"\n        f\" | <{bg.upper()}><b><{fg}>{method}</{fg}></b></{bg.upper()}>\"\n        f\" | <n><b>{str(request.url)!r}</b></n>\"\n        f\" | <c>{process_time:.3f}ms</c>\"\n        f\" | <e>{user_agent}</e>\"\n        f\" | <b><{bg}>{status_code}</{bg}></b>\"\n    )\n    return response\n\n\n@app.middleware(\"http\")\nasync def contextvar_setter(request: Request, call_next: RequestHandler):\n    request_headers.set(request.headers)\n    response_headers.set(MutableHeaders())\n    response = await call_next(request)\n    response.headers.update({**response_headers.get()})\n    return response\n\n\n@app.middleware(\"http\")\nasync def uncaught_exception_handler(\n    request: Request, call_next: RequestHandler\n) -> Response:\n    try:\n        response = await call_next(request)\n    except Exception as error:\n        response = await exception_handler(\n            request,\n            exc=(\n                error\n                if isinstance(error, BaseServerException)\n                else UncaughtException.with_exception(error)\n            ),\n        )\n    return response\n"
  },
  {
    "path": "hibiapi/app/routes/__init__.py",
    "content": "from typing import Protocol, cast\n\nfrom hibiapi.app.routes import (\n    bika,\n    bilibili,\n    netease,\n    pixiv,\n    qrcode,\n    sauce,\n    tieba,\n    wallpaper,\n)\nfrom hibiapi.utils.config import APIConfig\nfrom hibiapi.utils.exceptions import ExceptionReturn\nfrom hibiapi.utils.log import logger\nfrom hibiapi.utils.routing import SlashRouter\n\nrouter = SlashRouter(\n    responses={\n        code: {\n            \"model\": ExceptionReturn,\n        }\n        for code in (400, 422, 500, 502)\n    }\n)\n\n\nclass RouteInterface(Protocol):\n    router: SlashRouter\n    __mount__: str\n    __config__: APIConfig\n\n\nmodules = cast(\n    list[RouteInterface],\n    [bilibili, netease, pixiv, qrcode, sauce, tieba, wallpaper, bika],\n)\n\nfor module in modules:\n    mount = (\n        mount_point\n        if (mount_point := module.__mount__).startswith(\"/\")\n        else f\"/{mount_point}\"\n    )\n\n    if not module.__config__[\"enabled\"].as_bool():\n        logger.warning(\n            f\"API Route <y><b>{mount}</b></y> has been \"\n            \"<r><b>disabled</b></r> in config.\"\n        )\n        continue\n    router.include_router(module.router, prefix=mount)\n"
  },
  {
    "path": "hibiapi/app/routes/bika.py",
    "content": "from typing import Annotated\n\nfrom fastapi import Depends, Header\n\nfrom hibiapi.api.bika import (\n    BikaConstants,\n    BikaEndpoints,\n    BikaLogin,\n    ImageQuality,\n    NetRequest,\n)\nfrom hibiapi.utils.log import logger\nfrom hibiapi.utils.routing import EndpointRouter\n\ntry:\n    BikaConstants.CONFIG[\"account\"].get(BikaLogin)\nexcept Exception as e:\n    logger.warning(f\"Bika account misconfigured: {e}\")\n    BikaConstants.CONFIG[\"enabled\"].set(False)\n\n\nasync def x_image_quality(\n    x_image_quality: Annotated[ImageQuality, Header()] = ImageQuality.medium,\n):\n    if x_image_quality is None:\n        return BikaConstants.CONFIG[\"image_quality\"].get(ImageQuality)\n    return x_image_quality\n\n\n__mount__, __config__ = \"bika\", BikaConstants.CONFIG\nrouter = EndpointRouter(tags=[\"Bika\"], dependencies=[Depends(x_image_quality)])\n\nBikaAPIRoot = NetRequest()\n\n\nrouter.include_endpoint(BikaEndpoints, BikaAPIRoot)\n"
  },
  {
    "path": "hibiapi/app/routes/bilibili/__init__.py",
    "content": "from hibiapi.api.bilibili import BilibiliConstants\nfrom hibiapi.app.routes.bilibili.v2 import router as RouterV2\nfrom hibiapi.app.routes.bilibili.v3 import router as RouterV3\nfrom hibiapi.utils.routing import SlashRouter\n\n__mount__, __config__ = \"bilibili\", BilibiliConstants.CONFIG\n\nrouter = SlashRouter()\nrouter.include_router(RouterV2, prefix=\"/v2\")\nrouter.include_router(RouterV3, prefix=\"/v3\")\n"
  },
  {
    "path": "hibiapi/app/routes/bilibili/v2.py",
    "content": "from hibiapi.api.bilibili.api import BilibiliEndpointV2\nfrom hibiapi.api.bilibili.net import NetRequest\nfrom hibiapi.utils.routing import EndpointRouter\n\nrouter = EndpointRouter(tags=[\"Bilibili V2\"])\nrouter.include_endpoint(BilibiliEndpointV2, NetRequest())\n"
  },
  {
    "path": "hibiapi/app/routes/bilibili/v3.py",
    "content": "from hibiapi.api.bilibili import BilibiliEndpointV3, NetRequest\nfrom hibiapi.utils.routing import EndpointRouter\n\nrouter = EndpointRouter(tags=[\"Bilibili V3\"])\nrouter.include_endpoint(BilibiliEndpointV3, NetRequest())\n"
  },
  {
    "path": "hibiapi/app/routes/netease.py",
    "content": "from hibiapi.api.netease import NeteaseConstants, NeteaseEndpoint, NetRequest\nfrom hibiapi.utils.routing import EndpointRouter\n\n__mount__, __config__ = \"netease\", NeteaseConstants.CONFIG\n\nrouter = EndpointRouter(tags=[\"Netease\"])\nrouter.include_endpoint(NeteaseEndpoint, NetRequest())\n"
  },
  {
    "path": "hibiapi/app/routes/pixiv.py",
    "content": "from typing import Optional\n\nfrom fastapi import Depends, Header\n\nfrom hibiapi.api.pixiv import NetRequest, PixivConstants, PixivEndpoints\nfrom hibiapi.utils.log import logger\nfrom hibiapi.utils.routing import EndpointRouter\n\nif not (refresh_tokens := PixivConstants.CONFIG[\"account\"][\"token\"].as_str_seq()):\n    logger.warning(\"Pixiv API token is not set, pixiv endpoint will be unavailable.\")\n    PixivConstants.CONFIG[\"enabled\"].set(False)\n\n\nasync def accept_language(\n    accept_language: Optional[str] = Header(\n        None,\n        description=\"Accepted tag translation language\",\n    )\n):\n    return accept_language\n\n\n__mount__, __config__ = \"pixiv\", PixivConstants.CONFIG\n\nrouter = EndpointRouter(tags=[\"Pixiv\"], dependencies=[Depends(accept_language)])\nrouter.include_endpoint(PixivEndpoints, api_root := NetRequest(refresh_tokens))\n"
  },
  {
    "path": "hibiapi/app/routes/qrcode.py",
    "content": "from typing import Optional\n\nfrom fastapi import Request, Response\nfrom pydantic.color import Color\n\nfrom hibiapi.api.qrcode import (\n    COLOR_BLACK,\n    COLOR_WHITE,\n    Config,\n    HostUrl,\n    QRCodeLevel,\n    QRInfo,\n    ReturnEncode,\n)\nfrom hibiapi.utils.routing import SlashRouter\nfrom hibiapi.utils.temp import TempFile\n\nQR_CALLBACK_TEMPLATE = (\n    r\"\"\"function {fun}(){document.write('<img class=\"qrcode\" src=\"{url}\"/>');}\"\"\"\n)\n\n__mount__, __config__ = \"qrcode\", Config\nrouter = SlashRouter(tags=[\"QRCode\"])\n\n\n@router.get(\n    \"/\",\n    responses={\n        200: {\n            \"content\": {\"image/png\": {}, \"text/javascript\": {}, \"application/json\": {}},\n            \"description\": \"Avaliable to return an javascript, image or json.\",\n        }\n    },\n    response_model=QRInfo,\n)\nasync def qrcode_api(\n    request: Request,\n    *,\n    text: str,\n    size: int = 200,\n    logo: Optional[HostUrl] = None,\n    encode: ReturnEncode = ReturnEncode.raw,\n    level: QRCodeLevel = QRCodeLevel.MEDIUM,\n    bgcolor: Color = COLOR_BLACK,\n    fgcolor: Color = COLOR_WHITE,\n    fun: str = \"qrcode\",\n):\n    qr = await QRInfo.new(\n        text, size=size, logo=logo, level=level, bgcolor=bgcolor, fgcolor=fgcolor\n    )\n    qr.url = TempFile.to_url(request, qr.path)  # type:ignore\n    \"\"\"function {fun}(){document.write('<img class=\"qrcode\" src=\"{url}\"/>');}\"\"\"\n    return (\n        qr\n        if encode == ReturnEncode.json\n        else Response(\n            content=qr.json(),\n            media_type=\"application/json\",\n            headers={\"Location\": qr.url},\n            status_code=302,\n        )\n        if encode == ReturnEncode.raw\n        else Response(\n            content=f\"{fun}({qr.json()})\",\n            media_type=\"text/javascript\",\n        )\n        if encode == ReturnEncode.jsc\n        else Response(\n            content=\"function \"\n            + fun\n            + '''(){document.write('<img class=\"qrcode\" src=\"'''\n            + qr.url\n            + \"\"\"\"/>');}\"\"\",\n            media_type=\"text/javascript\",\n        )\n    )\n"
  },
  {
    "path": "hibiapi/app/routes/sauce.py",
    "content": "from typing import Annotated, Optional\n\nfrom fastapi import Depends, File, Form\nfrom loguru import logger\n\nfrom hibiapi.api.sauce import (\n    DeduplicateType,\n    HostUrl,\n    NetRequest,\n    SauceConstants,\n    SauceEndpoint,\n    UploadFileIO,\n)\nfrom hibiapi.utils.routing import SlashRouter\n\nif (not SauceConstants.API_KEY) or (not all(map(str.strip, SauceConstants.API_KEY))):\n    logger.warning(\"Sauce API key not set, SauceNAO endpoint will be unavailable\")\n    SauceConstants.CONFIG[\"enabled\"].set(False)\n\n__mount__, __config__ = \"sauce\", SauceConstants.CONFIG\nrouter = SlashRouter(tags=[\"SauceNAO\"])\n\nSauceAPIRoot = NetRequest()\n\n\nasync def request_client():\n    async with SauceAPIRoot as client:\n        yield SauceEndpoint(client)\n\n\n@router.get(\"/\")\nasync def sauce_url(\n    endpoint: Annotated[SauceEndpoint, Depends(request_client)],\n    url: HostUrl,\n    size: int = 30,\n    deduplicate: DeduplicateType = DeduplicateType.ALL,\n    database: Optional[int] = None,\n    enabled_mask: Optional[int] = None,\n    disabled_mask: Optional[int] = None,\n):\n    \"\"\"\n    ## Name: `sauce_url`\n\n    > 使用SauceNAO检索网络图片\n\n    ---\n\n    ### Required:\n\n    - ***HostUrl*** **`url`**\n        - Description: 图片URL\n\n    ---\n\n    ### Optional:\n    - ***int*** `size` = `30`\n        - Description: 搜索结果数目\n    - ***DeduplicateType*** `deduplicate` = `DeduplicateType.ALL`\n        - Description: 结果去重模式\n    - ***Optional[int]*** `database` = `None`\n        - Description: 检索的数据库ID, 999为全部检索\n    - ***Optional[int]*** `enabled_mask` = `None`\n        - Description: 启用的检索数据库\n    - ***Optional[int]*** `disabled_mask` = `None`\n        - Description: 禁用的检索数据库\n    \"\"\"\n    return await endpoint.search(\n        url=url,\n        size=size,\n        deduplicate=deduplicate,\n        database=database,\n        enabled_mask=enabled_mask,\n        disabled_mask=disabled_mask,\n    )\n\n\n@router.post(\"/\")\nasync def sauce_form(\n    endpoint: Annotated[SauceEndpoint, Depends(request_client)],\n    file: bytes = File(..., max_length=SauceConstants.IMAGE_MAXIMUM_SIZE),\n    size: int = Form(30),\n    deduplicate: Annotated[DeduplicateType, Form()] = DeduplicateType.ALL,\n    database: Optional[int] = Form(None),\n    enabled_mask: Optional[int] = Form(None),\n    disabled_mask: Optional[int] = Form(None),\n):\n    \"\"\"\n    ## Name: `sauce_form`\n\n    > 使用SauceNAO检索表单上传图片\n\n    ---\n\n    ### Required:\n    - ***bytes*** `file`\n        - Description: 上传的图片\n\n    ---\n\n    ### Optional:\n    - ***int*** `size` = `30`\n        - Description: 搜索结果数目\n    - ***DeduplicateType*** `deduplicate` = `DeduplicateType.ALL`\n        - Description: 结果去重模式\n    - ***Optional[int]*** `database` = `None`\n        - Description: 检索的数据库ID, 999为全部检索\n    - ***Optional[int]*** `enabled_mask` = `None`\n        - Description: 启用的检索数据库\n    - ***Optional[int]*** `disabled_mask` = `None`\n        - Description: 禁用的检索数据库\n\n    \"\"\"\n    return await endpoint.search(\n        file=UploadFileIO(file),\n        size=size,\n        deduplicate=deduplicate,\n        database=database,\n        disabled_mask=disabled_mask,\n        enabled_mask=enabled_mask,\n    )\n"
  },
  {
    "path": "hibiapi/app/routes/tieba.py",
    "content": "from hibiapi.api.tieba import Config, NetRequest, TiebaEndpoint\nfrom hibiapi.utils.routing import EndpointRouter\n\n__mount__, __config__ = \"tieba\", Config\n\nrouter = EndpointRouter(tags=[\"Tieba\"])\nrouter.include_endpoint(TiebaEndpoint, NetRequest())\n"
  },
  {
    "path": "hibiapi/app/routes/wallpaper.py",
    "content": "from hibiapi.api.wallpaper import Config, NetRequest, WallpaperEndpoint\nfrom hibiapi.utils.routing import EndpointRouter\n\n__mount__, __config__ = \"wallpaper\", Config\n\nrouter = EndpointRouter(tags=[\"Wallpaper\"])\nrouter.include_endpoint(WallpaperEndpoint, NetRequest())\n"
  },
  {
    "path": "hibiapi/configs/bika.yml",
    "content": "enabled: true\n\nproxy: {}\n\naccount:\n  # 请在此处填写你的哔咔账号密码\n  email:\n  password:\n"
  },
  {
    "path": "hibiapi/configs/bilibili.yml",
    "content": "enabled: true\n\nnet:\n  cookie: > # Bilibili的Cookie, 在一些需要用户登录的场景下需要\n    DedeUserID=; \n    DedeUserID__ckMd5=; \n    SESSDATA=;\n  user-agent: \"Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810\" # UA头, 一般没必要改\n"
  },
  {
    "path": "hibiapi/configs/general.yml",
    "content": "#   _    _ _ _     _          _____ _____\n#  | |  | (_) |   (_)   /\\   |  __ \\_   _|\n#  | |__| |_| |__  _   /  \\  | |__) || |\n#  |  __  | | '_ \\| | / /\\ \\ |  ___/ | |\n#  | |  | | | |_) | |/ ____ \\| |    _| |_\n#  |_|  |_|_|_.__/|_/_/    \\_\\_|   |_____|\n#\n# An alternative implement of Imjad API\n\ndata:\n  temp-expiry: 7 # 临时文件目录文件过期时间, 单位为天\n  path: ./data # data目录所在位置\n\nserver:\n  host: 127.0.0.1 # 监听主机\n  port: 8080 # 端口\n  gzip: true\n\n  # 限定来源域名, 支持通配符, 参考:\n  # https://fastapi.tiangolo.com/advanced/middleware/#trustedhostmiddleware\n  allowed: [\"*\"]\n\n  cors:\n    origins:\n      - \"http://localhost.tiangolo.com\"\n      - \"https://localhost.tiangolo.com\"\n      - \"http://localhost\"\n      - \"http://localhost:8080\"\n    credentials: true\n    methods: [\"*\"]\n    headers: [\"*\"]\n\n  allowed-forward: null # Reference: https://stackoverflow.com/questions/63511413\n\nlimit: # 单IP速率限制策略\n  enabled: true\n  max: 60 # 每个单位时间内最大请求数\n  interval: 60 # 单位时间长度, 单位为秒\n\ncache:\n  enabled: true # 设置是否启用缓存\n  ttl: 3600 # 缓存默认生存时间, 单位为秒\n  uri: \"mem://\" # 缓存URI\n  controllable: true # 配置是否可以通过Cache-Control请求头刷新缓存\n\nlog:\n  level: INFO # 日志等级, 可选 [TRACE,DEBUG,INFO,WARNING,ERROR]\n  format: > # 输出日志格式, 如果没有必要请不要修改\n    <level>\n    <v>{level:<8}</v>\n    [{time:YYYY/MM/DD} {time:HH:mm:ss.SSS} <d>{module}:{name}:{line}</d>]</level>\n    {message}\n\n  # file: logs/{time.log}\n  file: null # 日志输出文件位置, 相对于data目录, 为空则不保存\n\n  sentry:\n    enabled: false\n    sample: 1\n    dsn: \"\"\n    pii: false\n\ncontent:\n  slogan: | # 在文档附加的标语, 可以用于自定义内容\n    ![](https://img.shields.io/github/stars/mixmoe/HibiAPI?color=brightgreen&logo=github&style=for-the-badge)\n  robots: | # 提供的robots.txt内容, 用于提供搜索引擎抓取\n    User-agent: *\n    Disallow: /api/\n\nauthorization:\n  enabled: false # 是否开启验证\n  allowed:\n    - username: admin # 用户名\n      password: admin # 密码\n"
  },
  {
    "path": "hibiapi/configs/netease.yml",
    "content": "enabled: true\n\nnet:\n  cookie: > # 网易云的Cookie, 可能有些API需要\n    os=pc;\n    osver=Microsoft-Windows-10-Professional-build-10586-64bit; \n    appver=2.0.3.131777; \n    channel=netease; \n    __remember_me=true\n  user-agent: \"Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810\" # UA头, 一般没必要改\n  source: 118.88.64.0/18 # 伪造来源IP以绕过地区限制 #68\n"
  },
  {
    "path": "hibiapi/configs/pixiv.yml",
    "content": "enabled: true\n\n# HTTP代理地址\n# 示例格式\n# proxy: { \"all://\": \"http://127.0.0.1:1081\" }\nproxy: {}\n\naccount:\n  # Pixiv 登录凭证刷新令牌 (Refresh Token)\n  # 获取方法请参考: https://github.com/mixmoe/HibiAPI/issues/53\n  # 支持使用多个账户进行负载均衡, 每行一个token\n  token: \"\"\n\nlanguage: zh-cn # 返回语言, 会影响标签的翻译\n"
  },
  {
    "path": "hibiapi/configs/qrcode.yml",
    "content": "enabled: true\n\nqrcode:\n  max-size: 1000 # 允许的二维码最大尺寸, 单位像素\n  min-size: 50 # 允许的二维码最小尺寸, 单位像素\n  icon-site: # 图标支持的站点, 可以阻止服务器ip泄漏, 支持通配符\n    - localhost\n    - i.loli.net\n    # - \"*\"\n"
  },
  {
    "path": "hibiapi/configs/sauce.yml",
    "content": "enabled: true\n\n# HTTP代理地址\n# 示例格式\n# proxy:\n#   http_proxy: http://127.0.0.1:1081\n#   https_proxy: https://127.0.0.1:1081\nproxy: {}\n\nnet:\n  # SauceNAO 的API KEY, 支持多个以进行负载均衡, 每个KEY以换行分隔\n  # api-key: |\n  #   aaaaaaa\n  #   bbbbbbb\n  api-key: \"\"\n\n  keys: # SauceNAO 的API KEY, 支持多个以进行负载均衡\n    - \"\"\n  user-agent: &ua \"Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810\" # UA头, 一般没必要改\n\nimage:\n  max-size: 4096 # 获取图片最大大小, 单位为 KBytes\n  timeout: 6 # 获取图片超时时间, 单位为秒\n  headers: { \"user-agent\": *ua } # 获取图片时携带的请求头\n  allowed: # 获取图片的站点白名单, 可以阻止服务器ip泄漏, 支持通配符\n    - localhost\n    - i.loli.net\n    # - \"*\"\n"
  },
  {
    "path": "hibiapi/configs/tieba.yml",
    "content": "enabled: true\n\nnet:\n  user-agent: \"Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810\" # UA头, 一般没必要改\n  params:\n    BDUSS: \"\" # 百度的BDUSS登录凭证, 在使用部分API时需要\n"
  },
  {
    "path": "hibiapi/configs/wallpaper.yml",
    "content": "enabled: true\n\nnet:\n  user-agent: \"Mozilla/5.0 (mixmoe@GitHub.com/HibiAPI) Chrome/114.514.1919810\" # UA头, 一般没必要改\n"
  },
  {
    "path": "hibiapi/utils/__init__.py",
    "content": ""
  },
  {
    "path": "hibiapi/utils/cache.py",
    "content": "import hashlib\nfrom collections.abc import Awaitable\nfrom datetime import timedelta\nfrom functools import wraps\nfrom typing import Any, Callable, Optional, TypeVar, cast\n\nfrom cashews import Cache\nfrom pydantic import BaseModel\nfrom pydantic.decorator import ValidatedFunction\n\nfrom .config import Config\nfrom .log import logger\n\nCACHE_CONFIG_KEY = \"_cache_config\"\n\nAsyncFunc = Callable[..., Awaitable[Any]]\nT_AsyncFunc = TypeVar(\"T_AsyncFunc\", bound=AsyncFunc)\n\n\nCACHE_ENABLED = Config[\"cache\"][\"enabled\"].as_bool()\nCACHE_DELTA = timedelta(seconds=Config[\"cache\"][\"ttl\"].as_number())\nCACHE_URI = Config[\"cache\"][\"uri\"].as_str()\nCACHE_CONTROLLABLE = Config[\"cache\"][\"controllable\"].as_bool()\n\ncache = Cache(name=\"hibiapi\")\ntry:\n    cache.setup(CACHE_URI)\nexcept Exception as e:\n    logger.warning(\n        f\"Cache URI <y>{CACHE_URI!r}</y> setup <r><b>failed</b></r>: \"\n        f\"<r>{e!r}</r>, use memory backend instead.\"\n    )\n\n\nclass CacheConfig(BaseModel):\n    endpoint: AsyncFunc\n    namespace: str\n    enabled: bool = True\n    ttl: timedelta = CACHE_DELTA\n\n    @staticmethod\n    def new(\n        function: AsyncFunc,\n        *,\n        enabled: bool = True,\n        ttl: timedelta = CACHE_DELTA,\n        namespace: Optional[str] = None,\n    ):\n        return CacheConfig(\n            endpoint=function,\n            enabled=enabled,\n            ttl=ttl,\n            namespace=namespace or function.__qualname__,\n        )\n\n\ndef cache_config(\n    enabled: bool = True,\n    ttl: timedelta = CACHE_DELTA,\n    namespace: Optional[str] = None,\n):\n    def decorator(function: T_AsyncFunc) -> T_AsyncFunc:\n        setattr(\n            function,\n            CACHE_CONFIG_KEY,\n            CacheConfig.new(function, enabled=enabled, ttl=ttl, namespace=namespace),\n        )\n        return function\n\n    return decorator\n\n\ndisable_cache = cache_config(enabled=False)\n\n\nclass CachedValidatedFunction(ValidatedFunction):\n    def serialize(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> BaseModel:\n        values = self.build_values(args=args, kwargs=kwargs)\n        return self.model(**values)\n\n\ndef endpoint_cache(function: T_AsyncFunc) -> T_AsyncFunc:\n    from .routing import request_headers, response_headers\n\n    vf = CachedValidatedFunction(function, config={})\n    config = cast(\n        CacheConfig,\n        getattr(function, CACHE_CONFIG_KEY, None) or CacheConfig.new(function),\n    )\n\n    config.enabled = CACHE_ENABLED and config.enabled\n\n    @wraps(function)\n    async def wrapper(*args, **kwargs):\n        cache_policy = \"public\"\n\n        if CACHE_CONTROLLABLE:\n            cache_policy = request_headers.get().get(\"cache-control\", cache_policy)\n\n        if not config.enabled or cache_policy.casefold() == \"no-store\":\n            return await vf.call(*args, **kwargs)\n\n        key = (\n            f\"{config.namespace}:\"\n            + hashlib.md5(\n                (model := vf.serialize(args=args, kwargs=kwargs))\n                .json(exclude={\"self\"}, sort_keys=True, ensure_ascii=False)\n                .encode()\n            ).hexdigest()\n        )\n\n        response_header = response_headers.get()\n        result: Optional[Any] = None\n\n        if cache_policy.casefold() == \"no-cache\":\n            await cache.delete(key)\n        elif result := await cache.get(key):\n            logger.debug(f\"Request hit cache <b><e>{key}</e></b>\")\n            response_header.setdefault(\"X-Cache-Hit\", key)\n\n        if result is None:\n            result = await vf.execute(model)\n            await cache.set(key, result, expire=config.ttl)\n\n        if (cache_remain := await cache.get_expire(key)) > 0:\n            response_header.setdefault(\"Cache-Control\", f\"max-age={cache_remain}\")\n\n        return result\n\n    return wrapper  # type:ignore\n"
  },
  {
    "path": "hibiapi/utils/config.py",
    "content": "import json\nimport os\nfrom pathlib import Path\nfrom typing import Any, Optional, TypeVar, overload\n\nimport confuse\nimport dotenv\nfrom pydantic import parse_obj_as\n\nfrom hibiapi import __file__ as root_file\n\nCONFIG_DIR = Path(\".\") / \"configs\"\nDEFAULT_DIR = Path(root_file).parent / \"configs\"\n\n_T = TypeVar(\"_T\")\n\n\nclass ConfigSubView(confuse.Subview):\n    @overload\n    def get(self) -> Any: ...\n\n    @overload\n    def get(self, template: type[_T]) -> _T: ...\n\n    def get(self, template: Optional[type[_T]] = None):  # type: ignore\n        object_ = super().get()\n        if template is not None:\n            return parse_obj_as(template, object_)\n        return object_\n\n    def get_optional(self, template: type[_T]) -> Optional[_T]:\n        try:\n            return self.get(template)\n        except Exception:\n            return None\n\n    def as_str(self) -> str:\n        return self.get(str)\n\n    def as_str_seq(self, split: str = \"\\n\") -> list[str]:  # type: ignore\n        return [\n            stripped\n            for line in self.as_str().strip().split(split)\n            if (stripped := line.strip())\n        ]\n\n    def as_number(self) -> int:\n        return self.get(int)\n\n    def as_bool(self) -> bool:\n        return self.get(bool)\n\n    def as_path(self) -> Path:\n        return self.get(Path)\n\n    def as_dict(self) -> dict[str, Any]:\n        return self.get(dict[str, Any])\n\n    def __getitem__(self, key: str) -> \"ConfigSubView\":\n        return self.__class__(self, key)\n\n\nclass AppConfig(confuse.Configuration):\n    def __init__(self, name: str):\n        self._config_name = name\n        self._config = CONFIG_DIR / (filename := f\"{name}.yml\")\n        self._default = DEFAULT_DIR / filename\n        super().__init__(name)\n        self._add_env_source()\n\n    def config_dir(self) -> str:\n        return str(CONFIG_DIR)\n\n    def user_config_path(self) -> str:\n        return str(self._config)\n\n    def _add_env_source(self):\n        if dotenv.find_dotenv():\n            dotenv.load_dotenv()\n        config_name = f\"{self._config_name.lower()}_\"\n        env_configs = {\n            k[len(config_name) :].lower(): str(v)\n            for k, v in os.environ.items()\n            if k.lower().startswith(config_name)\n        }\n        # Convert `AAA_BBB_CCC=DDD` to `{'aaa':{'bbb':{'ccc':'ddd'}}}`\n        source_tree: dict[str, Any] = {}\n        for key, value in env_configs.items():\n            _tmp = source_tree\n            *nodes, name = key.split(\"_\")\n            for node in nodes:\n                _tmp = _tmp.setdefault(node, {})\n            if value == \"\":\n                continue\n            try:\n                _tmp[name] = json.loads(value)\n            except json.JSONDecodeError:\n                _tmp[name] = value\n\n        self.sources.insert(0, confuse.ConfigSource.of(source_tree))\n\n    def _add_default_source(self):\n        self.add(confuse.YamlSource(self._default, default=True))\n\n    def _add_user_source(self):\n        self.add(confuse.YamlSource(self._config, optional=True))\n\n    def __getitem__(self, key: str) -> ConfigSubView:\n        return ConfigSubView(self, key)\n\n\nclass GeneralConfig(AppConfig):\n    def __init__(self, name: str):\n        super().__init__(name)\n\n\nclass APIConfig(GeneralConfig):\n    pass\n\n\nConfig = GeneralConfig(\"general\")\n"
  },
  {
    "path": "hibiapi/utils/decorators/__init__.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom asyncio import sleep as async_sleep\nfrom collections.abc import Awaitable, Iterable\nfrom functools import partial, wraps\nfrom inspect import iscoroutinefunction\nfrom time import sleep as sync_sleep\nfrom typing import Callable, Protocol, TypeVar, overload\n\nfrom typing_extensions import ParamSpec\n\nfrom hibiapi.utils.decorators.enum import enum_auto_doc as enum_auto_doc\nfrom hibiapi.utils.decorators.timer import Callable_T, TimeIt\nfrom hibiapi.utils.log import logger\n\nArgument_T = ParamSpec(\"Argument_T\")\nReturn_T = TypeVar(\"Return_T\")\n\n\nclass RetryT(Protocol):\n    @overload\n    def __call__(self, function: Callable_T) -> Callable_T: ...\n\n    @overload\n    def __call__(\n        self,\n        *,\n        retries: int = ...,\n        delay: float = ...,\n        exceptions: Iterable[type[Exception]] | None = ...,\n    ) -> RetryT: ...\n\n    def __call__(\n        self,\n        function: Callable | None = ...,\n        *,\n        retries: int = ...,\n        delay: float = ...,\n        exceptions: Iterable[type[Exception]] | None = ...,\n    ) -> Callable | RetryT: ...\n\n\n@overload\ndef Retry(function: Callable_T) -> Callable_T: ...\n\n\n@overload\ndef Retry(\n    *,\n    retries: int = ...,\n    delay: float = ...,\n    exceptions: Iterable[type[Exception]] | None = ...,\n) -> RetryT: ...\n\n\ndef Retry(\n    function: Callable | None = None,\n    *,\n    retries: int = 3,\n    delay: float = 0.1,\n    exceptions: Iterable[type[Exception]] | None = None,\n) -> Callable | RetryT:\n    if function is None:\n        return partial(\n            Retry,\n            retries=retries,\n            delay=delay,\n            exceptions=exceptions,\n        )\n\n    timed_func = TimeIt(function)\n    allowed_exceptions: tuple[type[Exception], ...] = tuple(exceptions or [Exception])\n    assert (retries >= 1) and (delay >= 0)\n\n    @wraps(timed_func)\n    def sync_wrapper(*args, **kwargs):\n        error: Exception | None = None\n        for retried in range(retries):\n            try:\n                return timed_func(*args, **kwargs)\n            except Exception as exception:\n                error = exception\n                if not isinstance(exception, allowed_exceptions):\n                    raise\n                logger.opt().debug(\n                    f\"Retry of {timed_func=} trigged \"\n                    f\"due to {exception=} raised ({retried=}/{retries=})\"\n                )\n                sync_sleep(delay)\n        assert isinstance(error, Exception)\n        raise error\n\n    @wraps(timed_func)\n    async def async_wrapper(*args, **kwargs):\n        error: Exception | None = None\n        for retried in range(retries):\n            try:\n                return await timed_func(*args, **kwargs)\n            except Exception as exception:\n                error = exception\n                if not isinstance(exception, allowed_exceptions):\n                    raise\n                logger.opt().debug(\n                    f\"Retry of {timed_func=} trigged \"\n                    f\"due to {exception=} raised ({retried=}/{retries})\"\n                )\n                await async_sleep(delay)\n        assert isinstance(error, Exception)\n        raise error\n\n    return async_wrapper if iscoroutinefunction(function) else sync_wrapper\n\n\ndef ToAsync(\n    function: Callable[Argument_T, Return_T],\n) -> Callable[Argument_T, Awaitable[Return_T]]:\n    @TimeIt\n    @wraps(function)\n    async def wrapper(*args: Argument_T.args, **kwargs: Argument_T.kwargs) -> Return_T:\n        return await asyncio.get_running_loop().run_in_executor(\n            None, lambda: function(*args, **kwargs)\n        )\n\n    return wrapper\n"
  },
  {
    "path": "hibiapi/utils/decorators/enum.py",
    "content": "import ast\nimport inspect\nfrom enum import Enum\nfrom typing import TypeVar\n\n_ET = TypeVar(\"_ET\", bound=type[Enum])\n\n\ndef enum_auto_doc(enum: _ET) -> _ET:\n    enum_class_ast, *_ = ast.parse(inspect.getsource(enum)).body\n    assert isinstance(enum_class_ast, ast.ClassDef)\n\n    enum_value_comments: dict[str, str] = {}\n    for index, body in enumerate(body_list := enum_class_ast.body):\n        if (\n            isinstance(body, ast.Assign)\n            and (next_index := index + 1) < len(body_list)\n            and isinstance(next_body := body_list[next_index], ast.Expr)\n        ):\n            target, *_ = body.targets\n            assert isinstance(target, ast.Name)\n            assert isinstance(next_body.value, ast.Constant)\n            assert isinstance(member_doc := next_body.value.value, str)\n            enum[target.id].__doc__ = member_doc\n            enum_value_comments[target.id] = inspect.cleandoc(member_doc)\n\n    if not enum_value_comments and all(member.name == member.value for member in enum):\n        return enum\n\n    members_doc = \"\"\n    for member in enum:\n        value_document = \"-\"\n        if member.name != member.value:\n            value_document += f\" `{member.name}` =\"\n        value_document += f\" *`{member.value}`*\"\n        if doc := enum_value_comments.get(member.name):\n            value_document += f\" : {doc}\"\n        members_doc += value_document + \"\\n\"\n\n    enum.__doc__ = f\"{enum.__doc__}\\n{members_doc}\"\n    return enum\n"
  },
  {
    "path": "hibiapi/utils/decorators/timer.py",
    "content": "from __future__ import annotations\n\nimport time\nfrom dataclasses import dataclass, field\nfrom functools import wraps\nfrom inspect import iscoroutinefunction\nfrom typing import Any, Callable, ClassVar, TypeVar\n\nfrom hibiapi.utils.log import logger\n\nCallable_T = TypeVar(\"Callable_T\", bound=Callable)\n\n\nclass TimerError(Exception):\n    \"\"\"A custom exception used to report errors in use of Timer class\"\"\"\n\n\n@dataclass\nclass Timer:\n    \"\"\"Time your code using a class, context manager, or decorator\"\"\"\n\n    timers: ClassVar[dict[str, float]] = dict()\n    name: str | None = None\n    text: str = \"Elapsed time: {:0.3f} seconds\"\n    logger_func: Callable[[str], None] | None = print\n    _start_time: float | None = field(default=None, init=False, repr=False)\n\n    def __post_init__(self) -> None:\n        \"\"\"Initialization: add timer to dict of timers\"\"\"\n        if self.name:\n            self.timers.setdefault(self.name, 0)\n\n    def start(self) -> None:\n        \"\"\"Start a new timer\"\"\"\n        if self._start_time is not None:\n            raise TimerError(\"Timer is running. Use .stop() to stop it\")\n\n        self._start_time = time.perf_counter()\n\n    def stop(self) -> float:\n        \"\"\"Stop the timer, and report the elapsed time\"\"\"\n        if self._start_time is None:\n            raise TimerError(\"Timer is not running. Use .start() to start it\")\n\n        # Calculate elapsed time\n        elapsed_time = time.perf_counter() - self._start_time\n        self._start_time = None\n\n        # Report elapsed time\n        if self.logger_func:\n            self.logger_func(self.text.format(elapsed_time * 1000))\n        if self.name:\n            self.timers[self.name] += elapsed_time\n\n        return elapsed_time\n\n    def __enter__(self) -> Timer:\n        \"\"\"Start a new timer as a context manager\"\"\"\n        self.start()\n        return self\n\n    def __exit__(self, *exc_info: Any) -> None:\n        \"\"\"Stop the context manager timer\"\"\"\n        self.stop()\n\n    def _recreate_cm(self) -> Timer:\n        return self.__class__(self.name, self.text, self.logger_func)\n\n    def __call__(self, function: Callable_T) -> Callable_T:\n        @wraps(function)\n        async def async_wrapper(*args: Any, **kwargs: Any):\n            self.text = (\n                f\"<g>Async</g> function <y>{function.__qualname__}</y> \"\n                \"cost <e>{:.3f}ms</e>\"\n            )\n\n            with self._recreate_cm():\n                return await function(*args, **kwargs)\n\n        @wraps(function)\n        def sync_wrapper(*args: Any, **kwargs: Any):\n            self.text = (\n                f\"<g>sync</g> function <y>{function.__qualname__}</y> \"\n                \"cost <e>{:.3f}ms</e>\"\n            )\n\n            with self._recreate_cm():\n                return function(*args, **kwargs)\n\n        return (\n            async_wrapper if iscoroutinefunction(function) else sync_wrapper\n        )  # type:ignore\n\n\nTimeIt = Timer(logger_func=logger.trace)\n"
  },
  {
    "path": "hibiapi/utils/exceptions.py",
    "content": "from datetime import datetime\nfrom typing import Any, Optional\n\nfrom pydantic import AnyHttpUrl, BaseModel, Extra, Field\n\n\nclass ExceptionReturn(BaseModel):\n    url: Optional[AnyHttpUrl] = None\n    time: datetime = Field(default_factory=datetime.now)\n    code: int = Field(ge=400, le=599)\n    detail: str\n    headers: dict[str, str] = {}\n\n    class Config:\n        extra = Extra.allow\n\n\nclass BaseServerException(Exception):\n    code: int = 500\n    detail: str = \"Server Fault\"\n    headers: dict[str, Any] = {}\n\n    def __init__(\n        self,\n        detail: Optional[str] = None,\n        *,\n        code: Optional[int] = None,\n        headers: Optional[dict[str, Any]] = None,\n        **params\n    ) -> None:\n        self.data = ExceptionReturn(\n            detail=detail or self.__class__.detail,\n            code=code or self.__class__.code,\n            headers=headers or self.__class__.headers,\n            **params\n        )\n        super().__init__(detail)\n\n\nclass BaseHTTPException(BaseServerException):\n    pass\n\n\nclass ServerSideException(BaseServerException):\n    code = 500\n    detail = \"Internal Server Error\"\n\n\nclass UpstreamAPIException(ServerSideException):\n    code = 502\n    detail = \"Upstram API request failed\"\n\n\nclass UncaughtException(ServerSideException):\n    code = 500\n    detail = \"Uncaught exception raised during processing\"\n    exc: Exception\n\n    @classmethod\n    def with_exception(cls, e: Exception):\n        c = cls(e.__class__.__qualname__)\n        c.exc = e\n        return c\n\n\nclass ClientSideException(BaseServerException):\n    code = 400\n    detail = \"Bad Request\"\n\n\nclass ValidationException(ClientSideException):\n    code = 422\n\n\nclass RateLimitReachedException(ClientSideException):\n    code = 429\n    detail = \"Rate limit reached\"\n"
  },
  {
    "path": "hibiapi/utils/log.py",
    "content": "import logging\nimport re\nimport sys\nfrom datetime import timedelta\nfrom pathlib import Path\n\nimport sentry_sdk.integrations.logging as sentry\nfrom loguru import logger as _logger\n\nfrom hibiapi.utils.config import Config\n\nLOG_FILE = Config[\"log\"][\"file\"].get_optional(Path)\nLOG_LEVEL = Config[\"log\"][\"level\"].as_str().strip().upper()\nLOG_FORMAT = Config[\"log\"][\"format\"].as_str().strip()\n\n\nclass LoguruHandler(logging.Handler):\n    _tag_escape_re = re.compile(r\"</?((?:[fb]g\\s)?[^<>\\s]*)>\")\n\n    @classmethod\n    def escape_tag(cls, string: str) -> str:\n        return cls._tag_escape_re.sub(r\"\\\\\\g<0>\", string)\n\n    def emit(self, record: logging.LogRecord):\n        try:\n            level = logger.level(record.levelname).name\n        except ValueError:\n            level = record.levelno\n\n        frame, depth, message = logging.currentframe(), 2, record.getMessage()\n        while frame.f_code.co_filename == logging.__file__:  # type: ignore\n            frame = frame.f_back  # type: ignore\n            depth += 1\n\n        logger.opt(depth=depth, exception=record.exc_info, colors=True).log(\n            level, f\"<e>{self.escape_tag(message)}</e>\"\n        )\n\n\nlogger = _logger.opt(colors=True)\nlogger.remove()\nlogger.add(\n    sys.stdout,\n    level=LOG_LEVEL,\n    format=LOG_FORMAT,\n    filter=lambda record: record[\"level\"].no < logging.WARNING,\n)\nlogger.add(\n    sys.stderr,\n    level=LOG_LEVEL,\n    filter=lambda record: record[\"level\"].no >= logging.WARNING,\n    format=LOG_FORMAT,\n)\nlogger.add(sentry.BreadcrumbHandler(), level=LOG_LEVEL)\nlogger.add(sentry.EventHandler(), level=\"ERROR\")\n\nif LOG_FILE is not None:\n    LOG_FILE.parent.mkdir(parents=True, exist_ok=True)\n\n    logger.add(\n        str(LOG_FILE),\n        level=LOG_LEVEL,\n        encoding=\"utf-8\",\n        rotation=timedelta(days=1),\n    )\n\nlogger.level(LOG_LEVEL)\n"
  },
  {
    "path": "hibiapi/utils/net.py",
    "content": "import functools\nfrom collections.abc import Coroutine\nfrom types import TracebackType\nfrom typing import (\n    Any,\n    Callable,\n    ClassVar,\n    Optional,\n    TypeVar,\n    Union,\n)\n\nfrom httpx import (\n    URL,\n    AsyncClient,\n    Cookies,\n    HTTPError,\n    HTTPStatusError,\n    Request,\n    Response,\n    ResponseNotRead,\n    TransportError,\n)\n\nfrom .decorators import Retry, TimeIt\nfrom .exceptions import UpstreamAPIException\nfrom .log import logger\n\nAsyncCallable_T = TypeVar(\"AsyncCallable_T\", bound=Callable[..., Coroutine])\n\n\nclass AsyncHTTPClient(AsyncClient):\n    net_client: \"BaseNetClient\"\n\n    @staticmethod\n    async def _log_request(request: Request):\n        method, url = request.method, request.url\n        logger.debug(\n            f\"Network request <y>sent</y>: <b><e>{method}</e> <u>{url}</u></b>\"\n        )\n\n    @staticmethod\n    async def _log_response(response: Response):\n        method, url = response.request.method, response.url\n        try:\n            length, code = len(response.content), response.status_code\n        except ResponseNotRead:\n            length, code = -1, response.status_code\n        logger.debug(\n            f\"Network request <g>finished</g>: <b><e>{method}</e> \"\n            f\"<u>{url}</u> <m>{code}</m></b> <m>{length}</m>\"\n        )\n\n    @Retry(exceptions=[TransportError])\n    async def request(self, method: str, url: Union[URL, str], **kwargs):\n        self.event_hooks = {\n            \"request\": [self._log_request],\n            \"response\": [self._log_response],\n        }\n        return await super().request(method, url, **kwargs)\n\n\nclass BaseNetClient:\n    connections: ClassVar[int] = 0\n    clients: ClassVar[list[AsyncHTTPClient]] = []\n\n    client: Optional[AsyncHTTPClient] = None\n\n    def __init__(\n        self,\n        headers: Optional[dict[str, Any]] = None,\n        cookies: Optional[Cookies] = None,\n        proxies: Optional[dict[str, str]] = None,\n        client_class: type[AsyncHTTPClient] = AsyncHTTPClient,\n    ):\n        self.cookies, self.client_class = cookies or Cookies(), client_class\n        self.headers: dict[str, Any] = headers or {}\n        self.proxies: Any = proxies or {}  # Bypass type checker\n\n        self.create_client()\n\n    def create_client(self):\n        self.client = self.client_class(\n            headers=self.headers,\n            proxies=self.proxies,\n            cookies=self.cookies,\n            http2=True,\n            follow_redirects=True,\n        )\n        self.client.net_client = self\n        BaseNetClient.clients.append(self.client)\n        return self.client\n\n    async def __aenter__(self):\n        if not self.client or self.client.is_closed:\n            self.client = await self.create_client().__aenter__()\n\n        self.__class__.connections += 1\n        return self.client\n\n    async def __aexit__(\n        self,\n        exc_type: Optional[type[BaseException]] = None,\n        exc_value: Optional[BaseException] = None,\n        traceback: Optional[TracebackType] = None,\n    ):\n        self.__class__.connections -= 1\n\n        if not (exc_type and exc_value and traceback):\n            return\n        if self.client and not self.client.is_closed:\n            client = self.client\n            self.client = None\n            await client.__aexit__(exc_type, exc_value, traceback)\n        return\n\n\ndef catch_network_error(function: AsyncCallable_T) -> AsyncCallable_T:\n    timed_func = TimeIt(function)\n\n    @functools.wraps(timed_func)\n    async def wrapper(*args, **kwargs):\n        try:\n            return await timed_func(*args, **kwargs)\n        except HTTPStatusError as e:\n            raise UpstreamAPIException(detail=e.response.text) from e\n        except HTTPError as e:\n            raise UpstreamAPIException from e\n\n    return wrapper  # type:ignore\n"
  },
  {
    "path": "hibiapi/utils/routing.py",
    "content": "import inspect\nfrom collections.abc import Mapping\nfrom contextvars import ContextVar\nfrom enum import Enum\nfrom fnmatch import fnmatch\nfrom functools import wraps\nfrom typing import Annotated, Any, Callable, Literal, Optional\nfrom urllib.parse import ParseResult, urlparse\n\nfrom fastapi import Depends, Request\nfrom fastapi.routing import APIRouter\nfrom httpx import URL\nfrom pydantic import AnyHttpUrl\nfrom pydantic.errors import UrlHostError\nfrom starlette.datastructures import Headers, MutableHeaders\n\nfrom hibiapi.utils.cache import endpoint_cache\nfrom hibiapi.utils.net import AsyncCallable_T, AsyncHTTPClient, BaseNetClient\n\nDONT_ROUTE_KEY = \"_dont_route\"\n\n\ndef dont_route(func: AsyncCallable_T) -> AsyncCallable_T:\n    setattr(func, DONT_ROUTE_KEY, True)\n    return func\n\n\nclass EndpointMeta(type):\n    @staticmethod\n    def _list_router_function(members: dict[str, Any]):\n        return {\n            name: object\n            for name, object in members.items()\n            if (\n                inspect.iscoroutinefunction(object)\n                and not name.startswith(\"_\")\n                and not getattr(object, DONT_ROUTE_KEY, False)\n            )\n        }\n\n    def __new__(\n        cls,\n        name: str,\n        bases: tuple[type, ...],\n        namespace: dict[str, Any],\n        *,\n        cache_endpoints: bool = True,\n        **kwargs,\n    ):\n        for object_name, object in cls._list_router_function(namespace).items():\n            namespace[object_name] = (\n                endpoint_cache(object) if cache_endpoints else object\n            )\n        return super().__new__(cls, name, bases, namespace, **kwargs)\n\n    @property\n    def router_functions(self):\n        return self._list_router_function(dict(inspect.getmembers(self)))\n\n\nclass BaseEndpoint(metaclass=EndpointMeta, cache_endpoints=False):\n    def __init__(self, client: AsyncHTTPClient):\n        self.client = client\n\n    @staticmethod\n    def _join(base: str, endpoint: str, params: dict[str, Any]) -> URL:\n        host: ParseResult = urlparse(base)\n        params = {\n            k: (v.value if isinstance(v, Enum) else v)\n            for k, v in params.items()\n            if v is not None\n        }\n        return URL(\n            url=ParseResult(\n                scheme=host.scheme,\n                netloc=host.netloc,\n                path=endpoint.format(**params),\n                params=\"\",\n                query=\"\",\n                fragment=\"\",\n            ).geturl(),\n            params=params,\n        )\n\n\nclass SlashRouter(APIRouter):\n    def api_route(self, path: str, **kwargs):\n        path = path if path.startswith(\"/\") else f\"/{path}\"\n        return super().api_route(path, **kwargs)\n\n\nclass EndpointRouter(SlashRouter):\n    @staticmethod\n    def _exclude_params(func: Callable, params: Mapping[str, Any]) -> dict[str, Any]:\n        func_params = inspect.signature(func).parameters\n        return {k: v for k, v in params.items() if k in func_params}\n\n    @staticmethod\n    def _router_signature_convert(\n        func,\n        endpoint_class: type[\"BaseEndpoint\"],\n        request_client: Callable,\n        method_name: Optional[str] = None,\n    ):\n        @wraps(func)\n        async def route_func(endpoint: endpoint_class, **kwargs):\n            endpoint_method = getattr(endpoint, method_name or func.__name__)\n            return await endpoint_method(**kwargs)\n\n        route_func.__signature__ = inspect.signature(route_func).replace(  # type:ignore\n            parameters=[\n                inspect.Parameter(\n                    name=\"endpoint\",\n                    kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,\n                    annotation=endpoint_class,\n                    default=Depends(request_client),\n                ),\n                *(\n                    param\n                    for param in inspect.signature(func).parameters.values()\n                    if param.kind == inspect.Parameter.KEYWORD_ONLY\n                ),\n            ]\n        )\n        return route_func\n\n    def include_endpoint(\n        self,\n        endpoint_class: type[BaseEndpoint],\n        net_client: BaseNetClient,\n        add_match_all: bool = True,\n    ):\n        router_functions = endpoint_class.router_functions\n\n        async def request_client():\n            async with net_client as client:\n                yield endpoint_class(client)\n\n        for func_name, func in router_functions.items():\n            self.add_api_route(\n                path=f\"/{func_name}\",\n                endpoint=self._router_signature_convert(\n                    func,\n                    endpoint_class=endpoint_class,\n                    request_client=request_client,\n                    method_name=func_name,\n                ),\n                methods=[\"GET\"],\n            )\n\n        if not add_match_all:\n            return\n\n        @self.get(\"/\", description=\"JournalAD style API routing\", deprecated=True)\n        async def match_all(\n            endpoint: Annotated[endpoint_class, Depends(request_client)],\n            request: Request,\n            type: Literal[tuple(router_functions.keys())],  # type: ignore\n        ):\n            func = router_functions[type]\n            return await func(\n                endpoint, **self._exclude_params(func, request.query_params)\n            )\n\n\nclass BaseHostUrl(AnyHttpUrl):\n    allowed_hosts: list[str] = []\n\n    @classmethod\n    def validate_host(cls, parts) -> tuple[str, Optional[str], str, bool]:\n        host, tld, host_type, rebuild = super().validate_host(parts)\n        if not cls._check_domain(host):\n            raise UrlHostError(allowed=cls.allowed_hosts)\n        return host, tld, host_type, rebuild\n\n    @classmethod\n    def _check_domain(cls, host: str) -> bool:\n        return any(\n            filter(\n                lambda x: fnmatch(host, x),  # type:ignore\n                cls.allowed_hosts,\n            )\n        )\n\n\nrequest_headers = ContextVar[Headers](\"request_headers\")\nresponse_headers = ContextVar[MutableHeaders](\"response_headers\")\n"
  },
  {
    "path": "hibiapi/utils/temp.py",
    "content": "from pathlib import Path\nfrom tempfile import mkdtemp, mkstemp\nfrom threading import Lock\nfrom urllib.parse import ParseResult\n\nfrom fastapi import Request\n\n\nclass TempFile:\n    path = Path(mkdtemp())\n    path_depth = 3\n    name_length = 16\n\n    _lock = Lock()\n\n    @classmethod\n    def create(cls, ext: str = \".tmp\"):\n        descriptor, str_path = mkstemp(suffix=ext, dir=str(cls.path))\n        return descriptor, Path(str_path)\n\n    @classmethod\n    def to_url(cls, request: Request, path: Path) -> str:\n        assert cls.path\n        return ParseResult(\n            scheme=request.url.scheme,\n            netloc=request.url.netloc,\n            path=f\"/temp/{path.relative_to(cls.path)}\",\n            params=\"\",\n            query=\"\",\n            fragment=\"\",\n        ).geturl()\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"HibiAPI\"\nversion = \"0.8.0\"\ndescription = \"A program that implements easy-to-use APIs for a variety of commonly used sites\"\nreadme = \"README.md\"\nlicense = { text = \"Apache-2.0\" }\nauthors = [{ name = \"mixmoe\", email = \"admin@obfs.dev\" }]\nrequires-python = \">=3.9,<4.0\"\ndependencies = [\n    \"fastapi>=0.110.2\",\n    \"httpx[http2]>=0.27.0\",\n    \"uvicorn[standard]>=0.29.0\",\n    \"confuse>=2.0.1\",\n    \"loguru>=0.7.2\",\n    \"python-dotenv>=1.0.1\",\n    \"qrcode[pil]>=7.4.2\",\n    \"pycryptodomex>=3.20.0\",\n    \"sentry-sdk>=1.45.0\",\n    \"pydantic<2.0.0,>=1.9.0\",\n    \"python-multipart>=0.0.9\",\n    \"cashews[diskcache,redis]>=7.0.2\",\n    \"typing-extensions>=4.11.0\",\n    \"typer[all]>=0.12.3\",\n]\n\n[project.urls]\nhomepage = \"https://api.obfs.dev\"\nrepository = \"https://github.com/mixmoe/HibiAPI\"\ndocumentation = \"https://github.com/mixmoe/HibiAPI/wiki\"\n\n[project.optional-dependencies]\nscripts = [\"pyqt6>=6.6.1\", \"pyqt6-webengine>=6.6.0\", \"requests>=2.31.0\"]\n\n[project.scripts]\nhibiapi = \"hibiapi.__main__:cli\"\n\n[build-system]\nrequires = [\"pdm-backend\"]\nbuild-backend = \"pdm.backend\"\n\n[tool.pdm.dev-dependencies]\ndev = [\n    \"pytest>=8.1.1\",\n    \"pytest-httpserver>=1.0.10\",\n    \"pytest-cov>=5.0.0\",\n    \"pytest-benchmark>=4.0.0\",\n    \"pytest-pretty>=1.2.0\",\n    \"ruff>=0.4.1\",\n]\n\n[tool.pdm.build]\nincludes = []\n\n[tool.pdm.scripts]\ntest = \"\"\"pytest \\\n    --cov ./hibiapi/ \\\n    --cov-report xml \\\n    --cov-report term-missing \\\n    ./test\"\"\"\nstart = \"hibiapi run\"\nlint = \"ruff check\"\n\n[tool.pyright]\ntypeCheckingMode = \"standard\"\n\n[tool.ruff]\nlint.select = [\n    # pycodestyle\n    \"E\",\n    # Pyflakes\n    \"F\",\n    # pyupgrade\n    \"UP\",\n    # flake8-bugbear\n    \"B\",\n    # flake8-simplify\n    \"SIM\",\n    # isort\n    \"I\",\n]\ntarget-version = \"py39\"\n"
  },
  {
    "path": "scripts/pixiv_login.py",
    "content": "import hashlib\nimport sys\nfrom base64 import urlsafe_b64encode\nfrom secrets import token_urlsafe\nfrom typing import Any, Callable, Optional, TypeVar\nfrom urllib.parse import parse_qs, urlencode\n\nimport requests\nfrom loguru import logger as _logger\nfrom PyQt6.QtCore import QUrl\nfrom PyQt6.QtNetwork import QNetworkCookie\nfrom PyQt6.QtWebEngineCore import (\n    QWebEngineUrlRequestInfo,\n    QWebEngineUrlRequestInterceptor,\n)\nfrom PyQt6.QtWebEngineWidgets import QWebEngineView\nfrom PyQt6.QtWidgets import (\n    QApplication,\n    QHBoxLayout,\n    QMainWindow,\n    QPlainTextEdit,\n    QPushButton,\n    QVBoxLayout,\n    QWidget,\n)\n\nUSER_AGENT = \"PixivAndroidApp/5.0.234 (Android 11; Pixel 5)\"\nREDIRECT_URI = \"https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback\"\nLOGIN_URL = \"https://app-api.pixiv.net/web/v1/login\"\nAUTH_TOKEN_URL = \"https://oauth.secure.pixiv.net/auth/token\"\nCLIENT_ID = \"MOBrBDS8blbauoSck0ZfDbtuzpyT\"\nCLIENT_SECRET = \"lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj\"\n\n\napp = QApplication(sys.argv)\nlogger = _logger.opt(colors=True)\n\n\nclass RequestInterceptor(QWebEngineUrlRequestInterceptor):\n    code_listener: Optional[Callable[[str], None]] = None\n\n    def __init__(self):\n        super().__init__()\n\n    def interceptRequest(self, info: QWebEngineUrlRequestInfo) -> None:\n        method = info.requestMethod().data().decode()\n        url = info.requestUrl().url()\n\n        if (\n            self.code_listener\n            and \"app-api.pixiv.net\" in info.requestUrl().host()\n            and info.requestUrl().path().endswith(\"callback\")\n        ):\n            query = parse_qs(info.requestUrl().query())\n            code, *_ = query[\"code\"]\n            self.code_listener(code)\n\n        logger.debug(f\"<y>{method}</y> <u>{url}</u>\")\n\n\nclass WebView(QWebEngineView):\n    def __init__(self):\n        super().__init__()\n\n        self.cookies: dict[str, str] = {}\n\n        page = self.page()\n        assert page is not None\n        profile = page.profile()\n        assert profile is not None\n        profile.setHttpUserAgent(USER_AGENT)\n        page.contentsSize().setHeight(768)\n        page.contentsSize().setWidth(432)\n\n        self.interceptor = RequestInterceptor()\n        profile.setUrlRequestInterceptor(self.interceptor)\n        cookie_store = profile.cookieStore()\n        assert cookie_store is not None\n        cookie_store.cookieAdded.connect(self._on_cookie_added)\n\n        self.setFixedHeight(896)\n        self.setFixedWidth(414)\n\n        self.start(\"about:blank\")\n\n    def start(self, goto: str):\n        self.page().profile().cookieStore().deleteAllCookies()  # type: ignore\n        self.cookies.clear()\n        self.load(QUrl(goto))\n\n    def _on_cookie_added(self, cookie: QNetworkCookie):\n        domain = cookie.domain()\n        name = cookie.name().data().decode()\n        value = cookie.value().data().decode()\n        self.cookies[name] = value\n        logger.debug(f\"<m>Set-Cookie</m> <r>{domain}</r> <g>{name}</g> -> {value!r}\")\n\n\nclass ResponseDataWidget(QWidget):\n    def __init__(self, webview: WebView):\n        super().__init__()\n        self.webview = webview\n\n        layout = QVBoxLayout()\n\n        self.cookie_paste = QPlainTextEdit()\n        self.cookie_paste.setDisabled(True)\n        self.cookie_paste.setPlaceholderText(\"得到的登录数据将会展示在这里\")\n\n        layout.addWidget(self.cookie_paste)\n\n        copy_button = QPushButton()\n        copy_button.clicked.connect(self._on_clipboard_copy)\n        copy_button.setText(\"复制上述登录数据到剪贴板\")\n\n        layout.addWidget(copy_button)\n\n        self.setLayout(layout)\n\n    def _on_clipboard_copy(self, checked: bool):\n        if paste_string := self.cookie_paste.toPlainText().strip():\n            app.clipboard().setText(paste_string)  # type: ignore\n\n\n_T = TypeVar(\"_T\", bound=\"LoginPhrase\")\n\n\nclass LoginPhrase:\n    @staticmethod\n    def s256(data: bytes):\n        return urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b\"=\").decode()\n\n    @classmethod\n    def oauth_pkce(cls) -> tuple[str, str]:\n        code_verifier = token_urlsafe(32)\n        code_challenge = cls.s256(code_verifier.encode())\n        return code_verifier, code_challenge\n\n    def __init__(self: _T, url_open_callback: Callable[[str, _T], None]):\n        self.code_verifier, self.code_challenge = self.oauth_pkce()\n\n        login_params = {\n            \"code_challenge\": self.code_challenge,\n            \"code_challenge_method\": \"S256\",\n            \"client\": \"pixiv-android\",\n        }\n        login_url = f\"{LOGIN_URL}?{urlencode(login_params)}\"\n        url_open_callback(login_url, self)\n\n    def code_received(self, code: str):\n        response = requests.post(\n            AUTH_TOKEN_URL,\n            data={\n                \"client_id\": CLIENT_ID,\n                \"client_secret\": CLIENT_SECRET,\n                \"code\": code,\n                \"code_verifier\": self.code_verifier,\n                \"grant_type\": \"authorization_code\",\n                \"include_policy\": \"true\",\n                \"redirect_uri\": REDIRECT_URI,\n            },\n            headers={\"User-Agent\": USER_AGENT},\n        )\n        response.raise_for_status()\n        data: dict[str, Any] = response.json()\n\n        access_token = data[\"access_token\"]\n        refresh_token = data[\"refresh_token\"]\n        expires_in = data.get(\"expires_in\", 0)\n\n        return_text = \"\"\n        return_text += f\"access_token: {access_token}\\n\"\n        return_text += f\"refresh_token: {refresh_token}\\n\"\n        return_text += f\"expires_in: {expires_in}\\n\"\n\n        return return_text\n\n\nclass MainWindow(QMainWindow):\n    def __init__(self):\n        super().__init__()\n        self.setWindowTitle(\"Pixiv login helper\")\n\n        layout = QHBoxLayout()\n\n        self.webview = WebView()\n        layout.addWidget(self.webview)\n\n        self.form = ResponseDataWidget(self.webview)\n        layout.addWidget(self.form)\n\n        widget = QWidget()\n        widget.setLayout(layout)\n\n        self.setCentralWidget(widget)\n\n\nif __name__ == \"__main__\":\n    window = MainWindow()\n    window.show()\n\n    def url_open_callback(url: str, login_phrase: LoginPhrase):\n        def code_listener(code: str):\n            response = login_phrase.code_received(code)\n            window.form.cookie_paste.setPlainText(response)\n\n        window.webview.interceptor.code_listener = code_listener\n        window.webview.start(url)\n\n    LoginPhrase(url_open_callback)\n\n    exit(app.exec())\n"
  },
  {
    "path": "test/__init__.py",
    "content": "\n"
  },
  {
    "path": "test/test_base.py",
    "content": "from typing import Annotated, Any\n\nimport pytest\nfrom fastapi import Depends\nfrom fastapi.testclient import TestClient\nfrom pytest_benchmark.fixture import BenchmarkFixture\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app\n\n    with TestClient(app, base_url=\"http://testserver/\") as client:\n        yield client\n\n\ndef test_openapi(client: TestClient, in_stress: bool = False):\n    response = client.get(\"/openapi.json\")\n    assert response.status_code == 200\n    assert response.json()\n\n    if in_stress:\n        return True\n\n\ndef test_doc_page(client: TestClient, in_stress: bool = False):\n    response = client.get(\"/docs\")\n    assert response.status_code == 200\n    assert response.text\n\n    response = client.get(\"/docs/test\")\n    assert response.status_code == 200\n    assert response.text\n\n    if in_stress:\n        return True\n\n\ndef test_openapi_stress(client: TestClient, benchmark: BenchmarkFixture):\n    assert benchmark.pedantic(\n        test_openapi,\n        args=(client, True),\n        rounds=200,\n        warmup_rounds=10,\n        iterations=3,\n    )\n\n\ndef test_doc_page_stress(client: TestClient, benchmark: BenchmarkFixture):\n    assert benchmark.pedantic(\n        test_doc_page, args=(client, True), rounds=200, iterations=3\n    )\n\n\ndef test_notfound(client: TestClient):\n    from hibiapi.utils.exceptions import ExceptionReturn\n\n    response = client.get(\"/notexistpath\")\n    assert response.status_code == 404\n    assert ExceptionReturn.parse_obj(response.json())\n\n\n@pytest.mark.xfail(reason=\"not implemented yet\")\ndef test_net_request():\n    from hibiapi.utils.net import BaseNetClient\n    from hibiapi.utils.routing import BaseEndpoint, SlashRouter\n\n    test_headers = {\"x-test-header\": \"random-string\"}\n    test_data = {\"test\": \"test\"}\n\n    class TestEndpoint(BaseEndpoint):\n        base = \"https://httpbin.org\"\n\n        async def request(self, path: str, params: dict[str, Any]):\n            url = self._join(self.base, path, params)\n            response = await self.client.post(url, data=params)\n            response.raise_for_status()\n            return response.json()\n\n        async def form(self, *, data: dict[str, Any]):\n            return await self.request(\"/post\", data)\n\n        async def teapot(self):\n            return await self.request(\"/status/{codes}\", {\"codes\": 418})\n\n    class TestNetClient(BaseNetClient):\n        pass\n\n    async def net_client():\n        async with TestNetClient(headers=test_headers) as client:\n            yield TestEndpoint(client)\n\n    router = SlashRouter()\n\n    @router.post(\"form\")\n    async def form(\n        *,\n        endpoint: Annotated[TestEndpoint, Depends(net_client)],\n        data: dict[str, Any],\n    ):\n        return await endpoint.form(data=data)\n\n    @router.post(\"teapot\")\n    async def teapot(endpoint: Annotated[TestEndpoint, Depends(net_client)]):\n        return await endpoint.teapot()\n\n    from hibiapi.app.routes import router as api_router\n\n    api_router.include_router(router, prefix=\"/test\")\n\n    from hibiapi.app import app\n    from hibiapi.utils.exceptions import ExceptionReturn\n\n    with TestClient(app, base_url=\"http://testserver/api/test/\") as client:\n        response = client.post(\"form\", json=test_data)\n        assert response.status_code == 200\n        response_data = response.json()\n        assert response_data[\"form\"] == test_data\n        request_headers = {k.lower(): v for k, v in response_data[\"headers\"].items()}\n        assert test_headers.items() <= request_headers.items()\n\n        response = client.post(\"teapot\", json=test_data)\n        exception_return = ExceptionReturn.parse_obj(response.json())\n        assert exception_return.code == response.status_code\n"
  },
  {
    "path": "test/test_bika.py",
    "content": "from math import inf\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app, application\n\n    application.RATE_LIMIT_MAX = inf\n\n    with TestClient(app, base_url=\"http://testserver/api/bika/\") as client:\n        client.headers[\"Cache-Control\"] = \"no-cache\"\n        yield client\n\n\ndef test_collections(client: TestClient):\n    response = client.get(\"collections\")\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_categories(client: TestClient):\n    response = client.get(\"categories\")\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_keywords(client: TestClient):\n    response = client.get(\"keywords\")\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_advanced_search(client: TestClient):\n    response = client.get(\n        \"advanced_search\", params={\"keyword\": \"blend\", \"page\": 1, \"sort\": \"vd\"}\n    )\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"]\n\n\ndef test_category_list(client: TestClient):\n    response = client.get(\n        \"category_list\", params={\"category\": \"全彩\", \"page\": 1, \"sort\": \"vd\"}\n    )\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"]\n\n\ndef test_author_list(client: TestClient):\n    response = client.get(\n        \"author_list\", params={\"author\": \"ゆうき\", \"page\": 1, \"sort\": \"vd\"}\n    )\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"]\n\n\ndef test_comic_detail(client: TestClient):\n    response = client.get(\"comic_detail\", params={\"id\": \"5873aa128fe1fa02b156863a\"})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"]\n\n\ndef test_comic_recommendation(client: TestClient):\n    response = client.get(\n        \"comic_recommendation\", params={\"id\": \"5873aa128fe1fa02b156863a\"}\n    )\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"]\n\n\ndef test_comic_episodes(client: TestClient):\n    response = client.get(\"comic_episodes\", params={\"id\": \"5873aa128fe1fa02b156863a\"})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"]\n\n\ndef test_comic_page(client: TestClient):\n    response = client.get(\"comic_page\", params={\"id\": \"5873aa128fe1fa02b156863a\"})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"]\n\n\ndef test_comic_comments(client: TestClient):\n    response = client.get(\"comic_comments\", params={\"id\": \"5873aa128fe1fa02b156863a\"})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"]\n\n\ndef test_games(client: TestClient):\n    response = client.get(\"games\")\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"][\"games\"]\n\n\ndef test_game_detail(client: TestClient):\n    response = client.get(\"game_detail\", params={\"id\": \"6298dc83fee4a055417cdd98\"})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200 and response.json()[\"data\"]\n"
  },
  {
    "path": "test/test_bilibili_v2.py",
    "content": "from math import inf\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app, application\n\n    application.RATE_LIMIT_MAX = inf\n\n    with TestClient(app, base_url=\"http://testserver/api/bilibili/v2/\") as client:\n        yield client\n\n\ndef test_playurl(client: TestClient):\n    response = client.get(\"playurl\", params={\"aid\": 2})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_paged_playurl(client: TestClient):\n    response = client.get(\"playurl\", params={\"aid\": 2, \"page\": 1})\n    assert response.status_code == 200\n\n    if response.json()[\"code\"] != 0:\n        pytest.xfail(reason=response.text)\n\n\ndef test_seasoninfo(client: TestClient):\n    response = client.get(\"seasoninfo\", params={\"season_id\": 425})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] in (0, -404)\n\n\ndef test_seasonrecommend(client: TestClient):\n    response = client.get(\"seasonrecommend\", params={\"season_id\": 425})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_search(client: TestClient):\n    response = client.get(\"search\", params={\"keyword\": \"railgun\"})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_search_suggest(client: TestClient):\n    from hibiapi.api.bilibili import SearchType\n\n    response = client.get(\n        \"search\", params={\"keyword\": \"paperclip\", \"type\": SearchType.suggest.value}\n    )\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_search_hot(client: TestClient):\n    from hibiapi.api.bilibili import SearchType\n\n    response = client.get(\n        \"search\", params={\"limit\": \"10\", \"type\": SearchType.hot.value}\n    )\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_timeline(client: TestClient):\n    from hibiapi.api.bilibili import TimelineType\n\n    response = client.get(\"timeline\", params={\"type\": TimelineType.CN.value})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_space(client: TestClient):\n    response = client.get(\"space\", params={\"vmid\": 2})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_archive(client: TestClient):\n    response = client.get(\"archive\", params={\"vmid\": 2})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\n@pytest.mark.skip(reason=\"not implemented yet\")\ndef test_favlist(client: TestClient):\n    # TODO:add test case\n    pass\n"
  },
  {
    "path": "test/test_bilibili_v3.py",
    "content": "from math import inf\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app, application\n\n    application.RATE_LIMIT_MAX = inf\n\n    with TestClient(app, base_url=\"http://testserver/api/bilibili/v3/\") as client:\n        yield client\n\n\ndef test_video_info(client: TestClient):\n    response = client.get(\"video_info\", params={\"aid\": 2})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_video_address(client: TestClient):\n    response = client.get(\n        \"video_address\",\n        params={\"aid\": 2, \"cid\": 62131},\n    )\n    assert response.status_code == 200\n\n    if response.json()[\"code\"] != 0:\n        pytest.xfail(reason=response.text)\n\n\ndef test_user_info(client: TestClient):\n    response = client.get(\"user_info\", params={\"uid\": 2})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_user_uploaded(client: TestClient):\n    response = client.get(\"user_uploaded\", params={\"uid\": 2})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\n@pytest.mark.skip(reason=\"not implemented yet\")\ndef test_user_favorite(client: TestClient):\n    # TODO:add test case\n    pass\n\n\ndef test_season_info(client: TestClient):\n    response = client.get(\"season_info\", params={\"season_id\": 425})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] in (0, -404)\n\n\ndef test_season_recommend(client: TestClient):\n    response = client.get(\"season_recommend\", params={\"season_id\": 425})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_season_episode(client: TestClient):\n    response = client.get(\"season_episode\", params={\"episode_id\": 84340})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_season_timeline(client: TestClient):\n    response = client.get(\"season_timeline\")\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_search(client: TestClient):\n    response = client.get(\"search\", params={\"keyword\": \"railgun\"})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_search_recommend(client: TestClient):\n    response = client.get(\"search_recommend\")\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n\n\ndef test_search_suggestion(client: TestClient):\n    response = client.get(\"search_suggestion\", params={\"keyword\": \"paperclip\"})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 0\n"
  },
  {
    "path": "test/test_netease.py",
    "content": "from math import inf\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app, application\n\n    application.RATE_LIMIT_MAX = inf\n\n    with TestClient(app, base_url=\"http://testserver/api/netease/\") as client:\n        yield client\n\n\ndef test_search(client: TestClient):\n    response = client.get(\"search\", params={\"s\": \"test\"})\n    assert response.status_code == 200\n\n    data = response.json()\n    assert data[\"code\"] == 200\n    assert data[\"result\"][\"songs\"]\n\n\ndef test_artist(client: TestClient):\n    response = client.get(\"artist\", params={\"id\": 1024317})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_album(client: TestClient):\n    response = client.get(\"album\", params={\"id\": 63263})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_detail(client: TestClient):\n    response = client.get(\"detail\", params={\"id\": 657666})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_detail_multiple(client: TestClient):\n    response = client.get(\"detail\", params={\"id\": [657666, 657667, 77185]})\n    assert response.status_code == 200\n    data = response.json()\n\n    assert data[\"code\"] == 200\n    assert len(data[\"songs\"]) == 3\n\n\ndef test_song(client: TestClient):\n    response = client.get(\"song\", params={\"id\": 657666})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_song_multiple(client: TestClient):\n    response = client.get(\n        \"song\", params={\"id\": (input_ids := [657666, 657667, 77185, 86369])}\n    )\n    assert response.status_code == 200\n    data = response.json()\n\n    assert data[\"code\"] == 200\n    assert len(data[\"data\"]) == len(input_ids)\n\n\ndef test_playlist(client: TestClient):\n    response = client.get(\"playlist\", params={\"id\": 39983375})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_lyric(client: TestClient):\n    response = client.get(\"lyric\", params={\"id\": 657666})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_mv(client: TestClient):\n    response = client.get(\"mv\", params={\"id\": 425588})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_mv_url(client: TestClient):\n    response = client.get(\"mv_url\", params={\"id\": 425588})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_comments(client: TestClient):\n    response = client.get(\"comments\", params={\"id\": 657666})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_record(client: TestClient):\n    response = client.get(\"record\", params={\"id\": 286609438})\n    assert response.status_code == 200\n    # TODO: test case is no longer valid\n    # assert response.json()[\"code\"] == 200\n\n\ndef test_djradio(client: TestClient):\n    response = client.get(\"djradio\", params={\"id\": 350596191})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_dj(client: TestClient):\n    response = client.get(\"dj\", params={\"id\": 10785929})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_detail_dj(client: TestClient):\n    response = client.get(\"detail_dj\", params={\"id\": 1370045285})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_user(client: TestClient):\n    response = client.get(\"user\", params={\"id\": 1887530069})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_user_playlist(client: TestClient):\n    response = client.get(\"user_playlist\", params={\"id\": 1887530069})\n    assert response.status_code == 200\n    assert response.json()[\"code\"] == 200\n\n\ndef test_search_redirect(client: TestClient):\n    response = client.get(\"http://testserver/netease/search\", params={\"s\": \"test\"})\n\n    assert response.status_code == 200\n    assert response.history\n    assert response.history[0].status_code == 301\n"
  },
  {
    "path": "test/test_pixiv.py",
    "content": "from datetime import date, timedelta\nfrom math import inf\n\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom pytest_benchmark.fixture import BenchmarkFixture\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app, application\n\n    application.RATE_LIMIT_MAX = inf\n\n    with TestClient(app, base_url=\"http://testserver/api/pixiv/\") as client:\n        client.headers[\"Cache-Control\"] = \"no-cache\"\n        client.headers[\"Accept-Language\"] = \"en-US,en;q=0.9\"\n        yield client\n\n\ndef test_illust(client: TestClient):\n    # https://www.pixiv.net/artworks/109862531\n    response = client.get(\"illust\", params={\"id\": 109862531})\n    assert response.status_code == 200\n    assert response.json().get(\"illust\")\n\n\ndef test_member(client: TestClient):\n    response = client.get(\"member\", params={\"id\": 3036679})\n    assert response.status_code == 200\n    assert response.json().get(\"user\")\n\n\ndef test_member_illust(client: TestClient):\n    response = client.get(\"member_illust\", params={\"id\": 3036679})\n    assert response.status_code == 200\n    assert response.json().get(\"illusts\") is not None\n\n\ndef test_favorite(client: TestClient):\n    response = client.get(\"favorite\", params={\"id\": 3036679})\n    assert response.status_code == 200\n\n\ndef test_favorite_novel(client: TestClient):\n    response = client.get(\"favorite_novel\", params={\"id\": 55170615})\n    assert response.status_code == 200\n\n\ndef test_following(client: TestClient):\n    response = client.get(\"following\", params={\"id\": 3036679})\n    assert response.status_code == 200\n    assert response.json().get(\"user_previews\") is not None\n\n\ndef test_follower(client: TestClient):\n    response = client.get(\"follower\", params={\"id\": 3036679})\n    assert response.status_code == 200\n    assert response.json().get(\"user_previews\") is not None\n\n\ndef test_rank(client: TestClient):\n    for i in range(2, 5):\n        response = client.get(\n            \"rank\", params={\"date\": str(date.today() - timedelta(days=i))}\n        )\n        assert response.status_code == 200\n        assert response.json().get(\"illusts\")\n\n\ndef test_search(client: TestClient):\n    response = client.get(\"search\", params={\"word\": \"東方Project\"})\n    assert response.status_code == 200\n    assert response.json().get(\"illusts\")\n\n\ndef test_popular_preview(client: TestClient):\n    response = client.get(\"popular_preview\", params={\"word\": \"東方Project\"})\n    assert response.status_code == 200\n    assert response.json().get(\"illusts\")\n\n\ndef test_search_user(client: TestClient):\n    response = client.get(\"search_user\", params={\"word\": \"鬼针草\"})\n    assert response.status_code == 200\n    assert response.json().get(\"user_previews\")\n\n\ndef test_tags(client: TestClient):\n    response = client.get(\"tags\")\n    assert response.status_code == 200\n    assert response.json().get(\"trend_tags\")\n\n\ndef test_tags_autocomplete(client: TestClient):\n    response = client.get(\"tags_autocomplete\", params={\"word\": \"甘雨\"})\n    assert response.status_code == 200\n    assert response.json().get(\"tags\")\n\n\ndef test_related(client: TestClient):\n    response = client.get(\"related\", params={\"id\": 85162550})\n    assert response.status_code == 200\n    assert response.json().get(\"illusts\")\n\n\ndef test_ugoira_metadata(client: TestClient):\n    response = client.get(\"ugoira_metadata\", params={\"id\": 85162550})\n    assert response.status_code == 200\n    assert response.json().get(\"ugoira_metadata\")\n\n\ndef test_spotlights(client: TestClient):\n    response = client.get(\"spotlights\")\n    assert response.status_code == 200\n    assert response.json().get(\"spotlight_articles\")\n\n\ndef test_illust_new(client: TestClient):\n    response = client.get(\"illust_new\")\n    assert response.status_code == 200\n    assert response.json().get(\"illusts\")\n\n\ndef test_illust_comments(client: TestClient):\n    response = client.get(\"illust_comments\", params={\"id\": 99973718})\n    assert response.status_code == 200\n    assert response.json().get(\"comments\")\n\n\ndef test_illust_comment_replies(client: TestClient):\n    response = client.get(\"illust_comment_replies\", params={\"id\": 151400579})\n    assert response.status_code == 200\n    assert response.json().get(\"comments\")\n\n\ndef test_novel_comments(client: TestClient):\n    response = client.get(\"novel_comments\", params={\"id\": 12656898})\n    assert response.status_code == 200\n    assert response.json().get(\"comments\")\n\n\ndef test_novel_comment_replies(client: TestClient):\n    response = client.get(\"novel_comment_replies\", params={\"id\": 42372000})\n    assert response.status_code == 200\n    assert response.json().get(\"comments\")\n\n\ndef test_rank_novel(client: TestClient):\n    for i in range(2, 5):\n        response = client.get(\n            \"rank_novel\", params={\"date\": str(date.today() - timedelta(days=i))}\n        )\n        assert response.status_code == 200\n        assert response.json().get(\"novels\")\n\n\ndef test_member_novel(client: TestClient):\n    response = client.get(\"member_novel\", params={\"id\": 14883165})\n    assert response.status_code == 200\n    assert response.json().get(\"novels\")\n\n\ndef test_novel_series(client: TestClient):\n    response = client.get(\"novel_series\", params={\"id\": 1496457})\n    assert response.status_code == 200\n    assert response.json().get(\"novels\")\n\n\ndef test_novel_detail(client: TestClient):\n    response = client.get(\"novel_detail\", params={\"id\": 14617902})\n    assert response.status_code == 200\n    assert response.json().get(\"novel\")\n\n\ndef test_novel_text(client: TestClient):\n    response = client.get(\"novel_text\", params={\"id\": 14617902})\n    assert response.status_code == 200\n    assert response.json().get(\"novel_text\")\n\n\ndef test_webview_novel(client: TestClient):\n    response = client.get(\"webview_novel\", params={\"id\": 19791013})\n    assert response.status_code == 200\n    assert response.json().get(\"text\")\n\n\ndef test_live_list(client: TestClient):\n    response = client.get(\"live_list\")\n    assert response.status_code == 200\n    assert response.json().get(\"lives\")\n\n\ndef test_related_novel(client: TestClient):\n    response = client.get(\"related_novel\", params={\"id\": 19791013})\n    assert response.status_code == 200\n    assert response.json().get(\"novels\")\n\n\ndef test_related_member(client: TestClient):\n    response = client.get(\"related_member\", params={\"id\": 10109777})\n    assert response.status_code == 200\n    assert response.json().get(\"user_previews\")\n\n\ndef test_illust_series(client: TestClient):\n    response = client.get(\"illust_series\", params={\"id\": 218893})\n    assert response.status_code == 200\n    assert response.json().get(\"illust_series_detail\")\n\n\ndef test_member_illust_series(client: TestClient):\n    response = client.get(\"member_illust_series\", params={\"id\": 4087934})\n    assert response.status_code == 200\n    assert response.json().get(\"illust_series_details\")\n\n\ndef test_member_novel_series(client: TestClient):\n    response = client.get(\"member_novel_series\", params={\"id\": 86832559})\n    assert response.status_code == 200\n    assert response.json().get(\"novel_series_details\")\n\n\ndef test_tags_novel(client: TestClient):\n    response = client.get(\"tags_novel\")\n    assert response.status_code == 200\n    assert response.json().get(\"trend_tags\")\n\n\ndef test_search_novel(client: TestClient):\n    response = client.get(\"search_novel\", params={\"word\": \"碧蓝航线\"})\n    assert response.status_code == 200\n    assert response.json().get(\"novels\")\n\n\ndef test_popular_preview_novel(client: TestClient):\n    response = client.get(\"popular_preview_novel\", params={\"word\": \"東方Project\"})\n    assert response.status_code == 200\n    assert response.json().get(\"novels\")\n\n\ndef test_novel_new(client: TestClient):\n    response = client.get(\"novel_new\", params={\"max_novel_id\": 16002726})\n    assert response.status_code == 200\n    assert response.json().get(\"next_url\")\n\n\ndef test_request_cache(client: TestClient, benchmark: BenchmarkFixture):\n    client.headers[\"Cache-Control\"] = \"public\"\n\n    first_response = client.get(\"rank\")\n    assert first_response.status_code == 200\n\n    second_response = client.get(\"rank\")\n    assert second_response.status_code == 200\n\n    assert \"x-cache-hit\" in second_response.headers\n    assert \"cache-control\" in second_response.headers\n    assert second_response.json() == first_response.json()\n\n    def cache_benchmark():\n        response = client.get(\"rank\")\n        assert response.status_code == 200\n\n        assert \"x-cache-hit\" in response.headers\n        assert \"cache-control\" in response.headers\n\n    benchmark.pedantic(cache_benchmark, rounds=200, iterations=3)\n\n\ndef test_rank_redirect(client: TestClient):\n    response = client.get(\"http://testserver/pixiv/rank\")\n\n    assert response.status_code == 200\n    assert response.history\n    assert response.history[0].status_code == 301\n\n\ndef test_rate_limit(client: TestClient):\n    from hibiapi.app import application\n\n    application.RATE_LIMIT_MAX = 1\n\n    first_response = client.get(\"rank\")\n    assert first_response.status_code in (200, 429)\n\n    second_response = client.get(\"rank\")\n    assert second_response.status_code == 429\n    assert \"retry-after\" in second_response.headers\n"
  },
  {
    "path": "test/test_qrcode.py",
    "content": "from math import inf\nfrom secrets import token_urlsafe\n\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom httpx import Response\nfrom pytest_benchmark.fixture import BenchmarkFixture\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app, application\n\n    application.RATE_LIMIT_MAX = inf\n\n    with TestClient(app, base_url=\"http://testserver/api/\") as client:\n        yield client\n\n\ndef test_qrcode_generate(client: TestClient, in_stress: bool = False):\n    response = client.get(\n        \"qrcode/\",\n        params={\n            \"text\": token_urlsafe(32),\n            \"encode\": \"raw\",\n        },\n    )\n    assert response.status_code == 200\n    assert \"image/png\" in response.headers[\"content-type\"]\n\n    if in_stress:\n        return True\n\n\ndef test_qrcode_all(client: TestClient):\n    from hibiapi.api.qrcode import QRCodeLevel, ReturnEncode\n\n    encodes = [i.value for i in ReturnEncode.__members__.values()]\n    levels = [i.value for i in QRCodeLevel.__members__.values()]\n    responses: list[Response] = []\n    for encode in encodes:\n        for level in levels:\n            response = client.get(\n                \"qrcode/\",\n                params={\"text\": \"Hello, World!\", \"encode\": encode, \"level\": level},\n            )\n            responses.append(response)\n    assert not any(map(lambda r: r.status_code != 200, responses))\n\n\ndef test_qrcode_stress(client: TestClient, benchmark: BenchmarkFixture):\n    assert benchmark.pedantic(\n        test_qrcode_generate,\n        args=(client, True),\n        rounds=50,\n        iterations=3,\n    )\n\n\ndef test_qrcode_redirect(client: TestClient):\n    response = client.get(\"http://testserver/qrcode/\", params={\"text\": \"Hello, World!\"})\n\n    assert response.status_code == 200\n\n    redirect1, redirect2 = response.history\n\n    assert redirect1.status_code == 301\n    assert redirect2.status_code == 302\n"
  },
  {
    "path": "test/test_sauce.py",
    "content": "from math import inf\nfrom pathlib import Path\n\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom pytest_httpserver import HTTPServer\n\nLOCAL_SAUCE_PATH = Path(__file__).parent / \"test_sauce.jpg\"\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app, application\n\n    application.RATE_LIMIT_MAX = inf\n\n    with TestClient(app, base_url=\"http://testserver/api/\") as client:\n        yield client\n\n\n@pytest.mark.xfail(reason=\"rate limit possible reached\")\ndef test_sauce_url(client: TestClient, httpserver: HTTPServer):\n    httpserver.expect_request(\"/sauce\").respond_with_data(LOCAL_SAUCE_PATH.read_bytes())\n    response = client.get(\"sauce/\", params={\"url\": httpserver.url_for(\"/sauce\")})\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"header\"][\"status\"] == 0, data[\"header\"][\"message\"]\n\n\n@pytest.mark.xfail(reason=\"rate limit possible reached\")\ndef test_sauce_file(client: TestClient):\n    with open(LOCAL_SAUCE_PATH, \"rb\") as file:\n        response = client.post(\"sauce/\", files={\"file\": file})\n    assert response.status_code == 200\n    data = response.json()\n    assert data[\"header\"][\"status\"] == 0, data[\"header\"][\"message\"]\n"
  },
  {
    "path": "test/test_tieba.py",
    "content": "from math import inf\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app, application\n\n    application.RATE_LIMIT_MAX = inf\n\n    with TestClient(app, base_url=\"http://testserver/api/tieba/\") as client:\n        yield client\n\n\ndef test_post_list(client: TestClient):\n    response = client.get(\"post_list\", params={\"name\": \"minecraft\"})\n    assert response.status_code == 200\n    if response.json()[\"error_code\"] != \"0\":\n        pytest.xfail(reason=response.text)\n\n\ndef test_post_list_chinese(client: TestClient):\n    # NOTE: reference https://github.com/mixmoe/HibiAPI/issues/117\n    response = client.get(\"post_list\", params={\"name\": \"图拉丁\"})\n    assert response.status_code == 200\n    if response.json()[\"error_code\"] != \"0\":\n        pytest.xfail(reason=response.text)\n\n\ndef test_post_detail(client: TestClient):\n    response = client.get(\"post_detail\", params={\"tid\": 1766018024})\n    assert response.status_code == 200\n    if response.json()[\"error_code\"] != \"0\":\n        pytest.xfail(reason=response.text)\n\n\ndef test_subpost_detail(client: TestClient):\n    response = client.get(\n        \"subpost_detail\", params={\"tid\": 1766018024, \"pid\": 22616319749}\n    )\n    assert response.status_code == 200\n    assert int(response.json()[\"error_code\"]) == 0\n\n\ndef test_user_profile(client: TestClient):\n    response = client.get(\"user_profile\", params={\"uid\": 105525655})\n    assert response.status_code == 200\n    assert int(response.json()[\"error_code\"]) == 0\n"
  },
  {
    "path": "test/test_wallpaper.py",
    "content": "from math import inf\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\n\n@pytest.fixture(scope=\"package\")\ndef client():\n    from hibiapi.app import app, application\n\n    application.RATE_LIMIT_MAX = inf\n\n    with TestClient(app, base_url=\"http://testserver/api/wallpaper/\") as client:\n        client.headers[\"Cache-Control\"] = \"no-cache\"\n        yield client\n\n\ndef test_wallpaper(client: TestClient):\n    response = client.get(\"wallpaper\", params={\"category\": \"girl\"})\n    assert response.status_code == 200\n    assert response.json().get(\"msg\") == \"success\"\n\n\ndef test_wallpaper_limit(client: TestClient):\n    response = client.get(\"wallpaper\", params={\"category\": \"girl\", \"limit\": \"21\"})\n\n    assert response.status_code == 200\n    assert response.json()[\"msg\"] == \"success\"\n    assert len(response.json()[\"res\"][\"wallpaper\"]) == 21\n\n\ndef test_wallpaper_skip(client: TestClient):\n    response_1 = client.get(\n        \"wallpaper\", params={\"category\": \"girl\", \"limit\": \"20\", \"skip\": \"20\"}\n    )\n    response_2 = client.get(\n        \"wallpaper\", params={\"category\": \"girl\", \"limit\": \"40\", \"skip\": \"0\"}\n    )\n\n    assert response_1.status_code == 200 and response_2.status_code == 200\n    assert (\n        response_1.json()[\"res\"][\"wallpaper\"][0][\"id\"]\n        == response_2.json()[\"res\"][\"wallpaper\"][20][\"id\"]\n    )\n\n\ndef test_vertical(client: TestClient):\n    response = client.get(\"vertical\", params={\"category\": \"girl\"})\n    assert response.status_code == 200\n    assert response.json().get(\"msg\") == \"success\"\n\n\ndef test_vertical_limit(client: TestClient):\n    response = client.get(\"vertical\", params={\"category\": \"girl\", \"limit\": \"21\"})\n    assert response.status_code == 200\n    assert response.json().get(\"msg\") == \"success\"\n    assert len(response.json()[\"res\"][\"vertical\"]) == 21\n\n\ndef test_vertical_skip(client: TestClient):\n    response_1 = client.get(\n        \"vertical\", params={\"category\": \"girl\", \"limit\": \"20\", \"skip\": \"20\"}\n    )\n    response_2 = client.get(\n        \"vertical\", params={\"category\": \"girl\", \"limit\": \"40\", \"skip\": \"0\"}\n    )\n\n    assert response_1.status_code == 200 and response_2.status_code == 200\n    assert (\n        response_1.json()[\"res\"][\"vertical\"][0][\"id\"]\n        == response_2.json()[\"res\"][\"vertical\"][20][\"id\"]\n    )\n"
  }
]