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