├── .gitignore
├── LICENSE
├── README.md
├── papihub-frontend
├── .eslintrc.json
├── .gitignore
├── LICENSE
├── README.md
├── jsconfig.json
├── next.config.js
├── package.json
├── postcss.config.js
├── public
│ ├── icons
│ │ └── pt
│ │ │ ├── chdbits.ico
│ │ │ ├── hdsky.ico
│ │ │ ├── mteam.ico
│ │ │ └── ourbits.ico
│ ├── next.svg
│ └── vercel.svg
├── src
│ ├── app
│ │ ├── apidoc
│ │ │ ├── layout.js
│ │ │ └── page.js
│ │ ├── auth
│ │ │ ├── layout.js
│ │ │ └── login
│ │ │ │ └── page.js
│ │ ├── components
│ │ │ ├── badge
│ │ │ │ └── badge.js
│ │ │ ├── breadcrumbs
│ │ │ │ └── breadcrumbs.js
│ │ │ ├── button
│ │ │ │ └── button.js
│ │ │ ├── footer
│ │ │ │ └── footer.js
│ │ │ ├── hook-form
│ │ │ │ ├── form-provider.js
│ │ │ │ ├── input.js
│ │ │ │ └── select.js
│ │ │ ├── layout
│ │ │ │ ├── auth-layout.js
│ │ │ │ ├── header
│ │ │ │ │ └── main-page-header.js
│ │ │ │ ├── main-layout.js
│ │ │ │ └── simple-layout.js
│ │ │ ├── nav
│ │ │ │ ├── desktop
│ │ │ │ │ ├── nav-desktop.js
│ │ │ │ │ └── nav-user-desktop.js
│ │ │ │ └── mobile
│ │ │ │ │ └── nav-mobile.js
│ │ │ └── page-sections
│ │ │ │ └── site
│ │ │ │ ├── edit-form.js
│ │ │ │ ├── list.js
│ │ │ │ └── options.js
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.js
│ │ ├── page.js
│ │ └── site
│ │ │ ├── add
│ │ │ └── page.js
│ │ │ ├── layout.js
│ │ │ └── page.js
│ ├── auth
│ │ ├── auth-provider.js
│ │ ├── guard
│ │ │ ├── auth-guard.js
│ │ │ └── guest-guard.js
│ │ ├── store
│ │ │ └── use-user-store.js
│ │ └── utils.js
│ ├── context
│ │ └── query-provider.js
│ ├── hooks
│ │ └── use-http.js
│ ├── service
│ │ └── site-service.js
│ └── utils
│ │ ├── axios.js
│ │ └── format-time.js
└── tailwind.config.js
├── papihub
├── .gitignore
├── __init__.py
├── api
│ ├── __init__.py
│ ├── auth.py
│ ├── concurrenttorrentsite.py
│ ├── parser
│ │ ├── __init__.py
│ │ └── htmlparser.py
│ ├── sites
│ │ ├── __init__.py
│ │ └── nexusphp.py
│ ├── torrentsite.py
│ └── types.py
├── auth.py
├── common
│ ├── __init__.py
│ ├── customjsonencoder.py
│ ├── logging.py
│ └── response.py
├── config
│ ├── __init__.py
│ ├── siteparserconfigloader.py
│ └── types.py
├── constants.py
├── databases.py
├── eventbus.py
├── exceptions.py
├── main.py
├── manager
│ ├── __init__.py
│ └── sitemanager.py
├── models
│ ├── __init__.py
│ ├── cookiestoremodel.py
│ ├── sitemodel.py
│ └── usermodel.py
├── routers
│ ├── __init__.py
│ ├── siterouter.py
│ ├── torrentsrouter.py
│ └── userrouter.py
├── tasks
│ ├── __init__.py
│ └── sitetask.py
└── utils.py
├── requirements.txt
└── tests
├── api
├── __init__.py
└── test_nexusphp.py
├── conf
└── parser
│ ├── mteam.yml
│ └── ourbits.yml
├── config
├── __init__.py
└── test_siteparserconfigload.py
└── test_utils.py
/.gitignore:
--------------------------------------------------------------------------------
1 | .vs/
2 | .vscode/
3 | .idea/
4 | # Byte-compiled / optimized / DLL files
5 | __pycache__/
6 | *.py[cod]
7 | *$py.class
8 |
9 | # C extensions
10 | *.so
11 |
12 | # Distribution / packaging
13 | .Python
14 | build/
15 | develop-eggs/
16 | dist/
17 | downloads/
18 | eggs/
19 | .eggs/
20 | lib/
21 | lib64/
22 | parts/
23 | sdist/
24 | var/
25 | wheels/
26 | pip-wheel-metadata/
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 |
57 | # Translations
58 | *.mo
59 | *.pot
60 |
61 | # Django stuff:
62 | *.log
63 | local_settings.py
64 | db.sqlite3
65 | db.sqlite3-journal
66 |
67 | # Flask stuff:
68 | instance/
69 | .webassets-cache
70 |
71 | # Scrapy stuff:
72 | .scrapy
73 |
74 | # Sphinx documentation
75 | docs/_build/
76 |
77 | # PyBuilder
78 | target/
79 |
80 | # Jupyter Notebook
81 | .ipynb_checkpoints
82 | notebooks/
83 |
84 | # IPython
85 | profile_default/
86 | ipython_config.py
87 |
88 | # pyenv
89 | .python-version
90 |
91 | # pipenv
92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
95 | # install all needed dependencies.
96 | #Pipfile.lock
97 |
98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
99 | __pypackages__/
100 |
101 | # Celery stuff
102 | celerybeat-schedule
103 | celerybeat.pid
104 |
105 | # SageMath parsed files
106 | *.sage.py
107 |
108 | # Environments
109 | .env
110 | .envrc
111 | .venv
112 | .venvs
113 | env/
114 | venv/
115 | ENV/
116 | env.bak/
117 | venv.bak/
118 |
119 | # Spyder project settings
120 | .spyderproject
121 | .spyproject
122 |
123 | # Rope project settings
124 | .ropeproject
125 |
126 | # mkdocs documentation
127 | /site
128 |
129 | # mypy
130 | .mypy_cache/
131 | .dmypy.json
132 | dmypy.json
133 |
134 | # Pyre type checker
135 | .pyre/
136 |
137 | # macOS display setting files
138 | .DS_Store
139 |
140 | # Wandb directory
141 | wandb/
142 |
143 | # asdf tool versions
144 | .tool-versions
145 | /.ruff_cache/
146 |
147 | *.pkl
148 | *.bin
149 |
150 | # integration test artifacts
151 | data_map*
152 | \[('_type', 'fake'), ('stop', None)]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2023, papihubcom
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PapiHub
2 |
3 | PapiHub = Private API Hub = 私有API中心
4 |
5 |
6 |
7 |
8 |
9 |
10 | # 背景
11 |
12 | 当前全球BT/PT站点非常多,这些站点的技术架构大多类似,但技术非常古老。如果我们想基于这些资源站点,做一些使用场景的扩展,是找不到合适的开放接口的。本项目的初始目标,既为了解决这个痛点,自动为这些资源站点提供标准化的接口,供我们二次开发,或者DIY一些场景。
13 |
14 | # 安装
15 |
16 | # 使用
17 |
18 | # 开发者调试
19 |
20 | ## 安装依赖
21 |
22 | 项目根目录安装Python依赖
23 |
24 | ```shell
25 | pip install -r requirements.txt
26 | ```
27 |
28 | 进入前端项目目录,安装前端依赖
29 |
30 | ``` shell
31 | cd papihub-frontend
32 | ```
33 |
34 | ```yarn install``` or ```npm install```
35 |
36 | ## 设置环境变量
37 |
38 | 变量名:WORKDIR
39 | 值为你的本地路径,项目运行时路径,此路径会存放项目的数据文件。命令行或者IDE启动,都需要设置此环境变量。
40 |
41 | ## 运行入口
42 |
43 | Python项目的运行入口在 ```papihub/main.py```
44 |
45 | 前端项目的运行入口在 ```papihub-frontend``` 目录,使用```yarn dev``` 或 ```npm dev``` 运行项目
--------------------------------------------------------------------------------
/papihub-frontend/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/papihub-frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 | _static
3 | _static/
4 | .idea
5 | .vscode
6 | # dependencies
7 | /node_modules
8 | /.pnp
9 | .pnp.js
10 |
11 | # testing
12 | /coverage
13 |
14 | # production
15 | /build
16 |
17 | # misc
18 | .DS_Store
19 | .env
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 | .eslintcache
24 |
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 | package-lock.json
29 | yarn.lock
30 | stats.html
31 |
32 | .next
33 | .next/*
34 |
--------------------------------------------------------------------------------
/papihub-frontend/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2023, papihubcom
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/papihub-frontend/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | ```
14 |
15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
16 |
17 | You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
18 |
19 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
20 |
21 | ## Learn More
22 |
23 | To learn more about Next.js, take a look at the following resources:
24 |
25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
27 |
28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
29 |
30 | ## Deploy on Vercel
31 |
32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
33 |
34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
35 |
--------------------------------------------------------------------------------
/papihub-frontend/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./src/*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/papihub-frontend/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const API_BASE = process.env.API_BASE;
3 | const nextConfig = {
4 | async rewrites() {
5 | return [
6 | {
7 | source: '/api/:path*',
8 | destination: `${API_BASE}/api/:path*`,
9 | },
10 | ]
11 | },
12 | }
13 |
14 | module.exports = nextConfig
15 |
--------------------------------------------------------------------------------
/papihub-frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "papihub-frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@headlessui/react": "^1.7.16",
13 | "@heroicons/react": "^2.0.18",
14 | "@hookform/resolvers": "^3.3.1",
15 | "@tailwindcss/forms": "^0.5.6",
16 | "autoprefixer": "10.4.14",
17 | "axios": "^1.5.0",
18 | "classnames": "^2.3.2",
19 | "date-fns": "^2.30.0",
20 | "eslint": "8.46.0",
21 | "eslint-config-next": "13.4.12",
22 | "next": "13.4.12",
23 | "postcss": "8.4.27",
24 | "prop-types": "^15.8.1",
25 | "react": "^18.2.0",
26 | "react-dom": "^18.2.0",
27 | "react-hook-form": "^7.46.1",
28 | "react-query": "^3.39.3",
29 | "tailwindcss": "3.3.3",
30 | "yup": "^1.2.0",
31 | "zustand": "^4.4.1"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/papihub-frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/papihub-frontend/public/icons/pt/chdbits.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/papihub-frontend/public/icons/pt/chdbits.ico
--------------------------------------------------------------------------------
/papihub-frontend/public/icons/pt/hdsky.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/papihub-frontend/public/icons/pt/hdsky.ico
--------------------------------------------------------------------------------
/papihub-frontend/public/icons/pt/mteam.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/papihub-frontend/public/icons/pt/mteam.ico
--------------------------------------------------------------------------------
/papihub-frontend/public/icons/pt/ourbits.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/papihub-frontend/public/icons/pt/ourbits.ico
--------------------------------------------------------------------------------
/papihub-frontend/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/papihub-frontend/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/papihub-frontend/src/app/apidoc/layout.js:
--------------------------------------------------------------------------------
1 | import MainLayout from "@/app/components/layout/main-layout";
2 |
3 | export default function Layout({children}) {
4 | return (
5 | {children}
6 | )
7 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/apidoc/page.js:
--------------------------------------------------------------------------------
1 | import MainPageHeader from "@/app/components/layout/header/main-page-header";
2 |
3 | export default function Page() {
4 | return (
5 |
6 |
)
7 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/auth/layout.js:
--------------------------------------------------------------------------------
1 | import AuthLayout from "@/app/components/layout/auth-layout";
2 |
3 | export default function Layout({children}) {
4 | return (
5 | {children}
6 | )
7 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/auth/login/page.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Button from "@/app/components/button/button";
3 | import Input from "@/app/components/hook-form/input";
4 | import FormProvider from "@/app/components/hook-form/form-provider";
5 | import {useForm} from "react-hook-form";
6 | import * as Yup from "yup";
7 | import {yupResolver} from "@hookform/resolvers/yup";
8 | import useUserStore from "@/auth/store/use-user-store";
9 | import {useEffect} from "react";
10 | import {useRouter, useSearchParams} from "next/navigation";
11 | import {XCircleIcon} from "@heroicons/react/24/solid";
12 |
13 | export default function Page() {
14 | const router = useRouter();
15 | const searchParams = useSearchParams()
16 |
17 | const {
18 | login,
19 | authenticated,
20 | isInitializing,
21 | hasError,
22 | errorMessage
23 | } = useUserStore();
24 | useEffect(() => {
25 | if (isInitializing) {
26 | return;
27 | }
28 | if (authenticated) {
29 | if (searchParams.get("returnTo")) {
30 | router.replace(searchParams.get("returnTo"));
31 | } else {
32 | router.replace('/');
33 | }
34 | }
35 | }, [isInitializing, authenticated, router])
36 | const LoginSchema = Yup.object().shape({
37 | username: Yup.string().required('必须填写用户名'),
38 | password: Yup.string()
39 | .required('必须填写密码')
40 | .min(6, '密码是一个大于6位的字符串'),
41 | });
42 | const methods = useForm({
43 | resolver: yupResolver(LoginSchema),
44 | });
45 | const {
46 | handleSubmit,
47 | formState: {isSubmitting},
48 | } = methods;
49 |
50 | const onSubmit = handleSubmit(async (data) => {
51 | login(data.username, data.password);
52 | });
53 | return (
54 |
56 |
57 |

62 |
63 | 登入PapiHub
64 |
65 |
66 |
67 | {hasError &&
68 |
69 |
70 |
71 |
72 |
73 |
{errorMessage}
74 |
75 |
76 |
}
77 |
79 |
84 |
90 | 忘记密码?
91 | }
92 | />
93 |
94 |
95 |
96 |
97 |
98 |
99 |
);
100 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/badge/badge.js:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 |
3 | const fontColors = {
4 | primary: "text-indigo-400",
5 | info: "text-blue-400",
6 | success: "text-green-400",
7 | error: "text-red-700"
8 | };
9 | const borderColors = {
10 | primary: "ring-1 ring-inset ring-indigo-400/30",
11 | info: "ring-1 ring-inset ring-blue-400/30",
12 | success: "ring-1 ring-inset ring-green-500/20",
13 | error: "ring-1 ring-inset ring-red-600/10"
14 | }
15 | const transparentBgColors = {
16 | primary: "bg-indigo-400/10",
17 | info: "bg-blue-400/10",
18 | success: "bg-green-500/10",
19 | error: "bg-red-400/10"
20 | }
21 | const bgColor = {
22 | primary: "bg-indigo-50",
23 | info: "bg-blue-50",
24 | success: "bg-green-50",
25 | error: "bg-red-50"
26 | }
27 | const dotColors = {
28 | primary: "fill-indigo-400",
29 | info: "fill-blue-400",
30 | success: "fill-green-400",
31 | error: "fill-red-400"
32 |
33 | };
34 | export default function Badge({
35 | color,
36 | dot = false,
37 | fillBgColor = true,
38 | transparentBgColor = true,
39 | withBorder = true,
40 | children
41 | }) {
42 | return (
53 | {dot && }
58 | {children}
59 | );
60 | };
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/breadcrumbs/breadcrumbs.js:
--------------------------------------------------------------------------------
1 | import {ChevronLeftIcon, ChevronRightIcon} from "@heroicons/react/20/solid";
2 | import Link from "next/link";
3 | import classNames from "classnames";
4 |
5 | export default function Breadcrumbs({
6 | navigation
7 | }) {
8 | return (
9 |
10 |
19 |
41 |
)
42 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/button/button.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import classNames from "classnames";
3 | import {useRouter} from "next/navigation";
4 |
5 | const sizes = {
6 | small: "px-2.5 py-1.5 text-sm",
7 | medium: "px-3 py-2 text-sm",
8 | large: "px-3.5 py-2.5 text-sm",
9 | }
10 | const colors = {
11 | primary: "bg-indigo-500 hover:bg-indigo-400 focus-visible:outline-indigo-500 text-white",
12 | secondary: "bg-white/10 hover:bg-white/20 text-white",
13 | }
14 | export default function Button(
15 | {
16 | type = 'button',
17 | size = 'medium',
18 | color = 'primary',
19 | className = null,
20 | href = null,
21 | onClick,
22 | disabled,
23 | children,
24 | }
25 | ) {
26 | const router = useRouter();
27 | const handleClick = (e) => {
28 | if (href) {
29 | e.preventDefault();
30 | router.push(href);
31 | } else if (onClick) {
32 | onClick(e);
33 | }
34 | }
35 | return ();
50 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/footer/footer.js:
--------------------------------------------------------------------------------
1 | const navigation = {
2 | main: [],
3 | social: [
4 | {
5 | name: 'Twitter',
6 | href: '#',
7 | icon: (props) => (
8 |
12 | ),
13 | },
14 | {
15 | name: 'GitHub',
16 | href: 'https://github.com/papihubcom/papihub',
17 | icon: (props) => (
18 |
25 | ),
26 | },
27 | {
28 | name: 'Discord',
29 | href: '#',
30 | icon: (props) => (
31 |
37 | ),
38 | },
39 | ],
40 | }
41 | export default function Footer() {
42 | return (
43 |
72 | )
73 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/hook-form/form-provider.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import PropTypes from 'prop-types';
3 | import {FormProvider as Form} from 'react-hook-form';
4 |
5 | export default function FormProvider({
6 | className,
7 | children,
8 | onSubmit,
9 | methods
10 | }) {
11 | return (
12 |
14 |
15 | );
16 | }
17 |
18 | FormProvider.propTypes = {
19 | className: PropTypes.string,
20 | children: PropTypes.node,
21 | methods: PropTypes.object,
22 | onSubmit: PropTypes.func,
23 | };
24 |
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/hook-form/input.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import PropTypes from 'prop-types';
3 | import {Controller, useFormContext} from 'react-hook-form';
4 | import classNames from "classnames";
5 | import {ExclamationCircleIcon} from "@heroicons/react/20/solid";
6 |
7 | // ----------------------------------------------------------------------
8 |
9 | export default function Input({
10 | label,
11 | name,
12 | helperText,
13 | cornerHint,
14 | type,
15 | className,
16 | ...other
17 | }) {
18 | const {control} = useFormContext();
19 |
20 | return (
21 | (
25 |
26 |
27 |
31 |
32 | {cornerHint}
33 |
34 |
35 |
41 |
{
56 | if (type === 'number') {
57 | field.onChange(Number(event.target.value));
58 | } else {
59 | field.onChange(event.target.value);
60 | }
61 | }}
62 | {...other}
63 | />
64 | {error &&
66 |
68 |
}
69 |
70 | {(helperText || error) &&
75 | {error ? error?.message : helperText}
76 |
}
77 |
78 | )}
79 | />
80 | );
81 | }
82 |
83 | Input.propTypes = {
84 | label: PropTypes.string,
85 | cornerHint: PropTypes.object,
86 | helperText: PropTypes.object,
87 | name: PropTypes.string,
88 | type: PropTypes.string,
89 | };
90 |
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/hook-form/select.js:
--------------------------------------------------------------------------------
1 | "use client"
2 | import {Fragment} from 'react'
3 |
4 | import classNames from "classnames";
5 | import {CheckIcon, ChevronUpDownIcon} from "@heroicons/react/24/solid";
6 | import {Listbox, Transition} from "@headlessui/react";
7 | import {Controller, useFormContext} from "react-hook-form";
8 | import {ExclamationCircleIcon} from "@heroicons/react/20/solid";
9 |
10 | export default function Select({
11 | name,
12 | label,
13 | cornerHint,
14 | options,
15 | helperText
16 | }) {
17 | const {control} = useFormContext();
18 |
19 | return (
20 | (
24 |
25 |
26 |
30 |
31 | {cornerHint}
32 |
33 |
34 |
40 |
41 | {({open}) => (
42 | <>
43 |
44 |
46 |
47 | {field.value?.icon}
48 | {field.value?.label}
50 |
51 |
53 |
55 |
56 |
57 |
64 |
66 | {options.map((item, index) => (
67 |
70 | classNames(
71 | active
72 | ? 'bg-indigo-600 text-white'
73 | : 'text-gray-900',
74 | 'relative cursor-default select-none py-2 pl-3 pr-9'
75 | )
76 | }
77 | value={item}
78 | >
79 | {({selected, active}) => (
80 | <>
81 |
82 | {item.icon}
83 |
89 | {item.label}
90 |
91 |
92 |
93 | {selected ? (
94 |
101 |
102 |
103 | ) : null}
104 | >
105 | )}
106 |
107 | ))}
108 |
109 |
110 |
111 | >
112 | )}
113 | {error &&
115 |
117 |
}
118 |
119 | {(helperText || error) &&
124 | {error ? error?.message : helperText}
125 |
}
126 |
127 | )}
128 | />
129 | )
130 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/layout/auth-layout.js:
--------------------------------------------------------------------------------
1 | import Footer from "@/app/components/footer/footer";
2 |
3 | export default function AuthLayout({children}) {
4 | return (
5 |
7 |
19 |
20 |
21 | {children}
22 |
23 |
24 |
25 | {/* Footer */}
26 |
27 |
)
28 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/layout/header/main-page-header.js:
--------------------------------------------------------------------------------
1 | import Breadcrumbs from "@/app/components/breadcrumbs/breadcrumbs";
2 |
3 | export default function MainPageHeader({
4 | title,
5 | breadcrumbNavs,
6 | actions
7 | }) {
8 | return (
9 |
10 |
11 |
12 |
13 | {title}
14 |
15 |
16 | {actions &&
17 | {actions}
18 |
}
19 |
20 | );
21 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/layout/main-layout.js:
--------------------------------------------------------------------------------
1 | import SimpleLayout from "@/app/components/layout/simple-layout";
2 |
3 | const navigation = [
4 | {name: '首页', href: '/'},
5 | {name: '网站配置', href: '/site'},
6 | {name: '使用接口', href: '/apidoc'},
7 | ]
8 | export default function MainLayout({children}) {
9 | return (
10 |
12 |
24 |
25 |
26 | {children}
27 |
28 |
29 |
30 | )
31 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/layout/simple-layout.js:
--------------------------------------------------------------------------------
1 | import NavMobile from "@/app/components/nav/mobile/nav-mobile";
2 | import NavDesktop from "@/app/components/nav/desktop/nav-desktop";
3 | import Footer from "@/app/components/footer/footer";
4 | import NavUserDesktop from "@/app/components/nav/desktop/nav-user-desktop";
5 |
6 | const navigation = [
7 | {name: '首页', href: '/'},
8 | {name: '网站配置', href: '/site'},
9 | {name: '使用接口', href: '/apidoc'},
10 | ]
11 | export default function SimpleLayout({children}) {
12 | return (
13 | {/* Header */}
14 |
15 |
35 |
36 |
37 |
38 | {children}
39 |
40 | {/* Footer */}
41 |
42 |
)
43 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/nav/desktop/nav-desktop.js:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export default function NavDesktop({data}) {
4 | return (<>
5 | {data.map((item) => (
6 |
8 | {item.name}
9 |
10 | ))}
11 | >);
12 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/nav/desktop/nav-user-desktop.js:
--------------------------------------------------------------------------------
1 | "use client"
2 | import {Fragment} from 'react'
3 |
4 | import Link from "next/link";
5 | import useUserStore from "@/auth/store/use-user-store";
6 | import {Menu, Transition} from "@headlessui/react";
7 | import classNames from "classnames";
8 | import {ChevronDownIcon} from "@heroicons/react/24/solid";
9 | import {useRouter} from "next/navigation";
10 |
11 | export default function NavUserDesktop() {
12 | const router = useRouter();
13 | const {authenticated, user, logout} = useUserStore();
14 | return ();
58 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/nav/mobile/nav-mobile.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import {Bars3Icon, XMarkIcon} from "@heroicons/react/24/outline";
3 | import {useState} from "react";
4 | import {Dialog} from "@headlessui/react";
5 | export default function NavMobile({data}) {
6 | const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
7 |
8 | return (<>
9 |
17 |
63 | >);
64 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/page-sections/site/edit-form.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import FormProvider from "@/app/components/hook-form/form-provider";
3 | import Select from "@/app/components/hook-form/select";
4 | import {Controller, useForm} from "react-hook-form";
5 | import Input from "@/app/components/hook-form/input";
6 | import {RadioGroup} from "@headlessui/react";
7 | import {CheckCircleIcon} from "@heroicons/react/24/solid";
8 | import classNames from "classnames";
9 | import {useAddSite, useListParsers} from "@/service/site-service";
10 | import {useEffect, useState} from "react";
11 | import * as Yup from "yup";
12 | import {yupResolver} from "@hookform/resolvers/yup";
13 | import Button from "@/app/components/button/button";
14 | import {useRouter} from "next/navigation";
15 |
16 | const authOptions = [{
17 | id: 1,
18 | label: "Cookies认证",
19 | desc: "登录站点后,通过浏览器抓包获取。",
20 | value: "cookies"
21 | }, {
22 | id: 2,
23 | label: "登录认证",
24 | desc: "直接使用用户名密码认证,少量站点支持。",
25 | value: "user_auth"
26 | }];
27 | export default function SiteEditForm() {
28 | const router = useRouter();
29 | const {data} = useListParsers();
30 | const {mutate: addSite, isLoading} = useAddSite();
31 | const [siteOptions, setSiteOptions] = useState([]);
32 | useEffect(() => {
33 | setSiteOptions(data?.data?.map((item) => {
34 | return {
35 | icon:
,
37 | label: item.site_name,
38 | value: item.site_id,
39 | }
40 | }) || []);
41 | }, [data]);
42 | const formSchema = Yup.object().shape({
43 | authType: Yup.string(),
44 | cookies: Yup.string()
45 | .when('authType', ([authType], schema) =>
46 | authType === 'cookies' ? schema.required('请填写一个有效的Cookies')
47 | : schema,
48 | ),
49 | username: Yup.string()
50 | .when('authType', ([authType], schema) =>
51 | authType === 'user_auth' ? schema.required('用户名必填') : schema,
52 | ),
53 | password: Yup.string()
54 | .when('authType', ([authType], schema) =>
55 | authType === 'user_auth' ? schema.required('密码必填') : schema,
56 | ),
57 | });
58 | const methods = useForm({
59 | resolver: yupResolver(formSchema),
60 | defaultValues: {
61 | authType: authOptions[0].value
62 | }
63 | });
64 | const {
65 | setValue,
66 | watch,
67 | control,
68 | register,
69 | handleSubmit,
70 | formState: {isSubmitting},
71 | } = methods;
72 | const authType = watch("authType");
73 | const onSubmit = handleSubmit(async (data) => {
74 | const params = {
75 | site_id: data.siteId.value,
76 | auth_type: data.authType,
77 | auth_config: data.authType === "cookies" ? {
78 | cookies: data.cookies
79 | } : {
80 | username: data.username,
81 | password: data.password
82 | }
83 | };
84 | console.log(params)
85 | addSite(params, {
86 | onSuccess: res => {
87 | const {success, message, data} = res;
88 | if (success) {
89 | //todo 提示成功
90 | router.push("/site");
91 | } else {
92 | //todo 提示失败
93 | }
94 | }
95 | });
96 | });
97 | useEffect(() => {
98 | if (siteOptions && siteOptions.length > 0) {
99 | setValue("siteId", siteOptions[0])
100 | }
101 | }, [siteOptions])
102 | return (
103 |
104 |
194 |
195 |
196 |
199 |
200 | )
201 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/page-sections/site/list.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import SiteOptions from "@/app/components/page-sections/site/options";
3 | import Badge from "@/app/components/badge/badge";
4 | import {useListSite} from "@/service/site-service";
5 | import {fToNow} from "@/utils/format-time";
6 |
7 | const statusMessage = {
8 | pending: "待检测",
9 | active: "可用",
10 | error: "不可用"
11 | }
12 | const statusColor = {
13 | pending: "info",
14 | active: "success",
15 | error: "error"
16 | }
17 |
18 | export default function SiteList() {
19 | const {data} = useListSite();
20 | const siteList = data?.data || [];
21 | return (
23 | {siteList && siteList.map((item) => (
24 | -
26 |
28 |

33 |
{item.site_name}
35 |
36 |
37 |
38 |
39 |
- 上次访问
40 | -
41 |
44 |
45 |
46 |
47 |
- 状态
48 |
49 | {statusMessage[item.site_status]}
50 |
51 |
52 |
53 |
54 | ))}
55 |
);
56 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/components/page-sections/site/options.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import {Menu, Transition} from "@headlessui/react";
3 | import {EllipsisHorizontalIcon} from "@heroicons/react/20/solid";
4 | import {Fragment} from "react";
5 | import classNames from "classnames";
6 |
7 | export default function SiteOptions() {
8 | return ();
68 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/papihub-frontend/src/app/favicon.ico
--------------------------------------------------------------------------------
/papihub-frontend/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
--------------------------------------------------------------------------------
/papihub-frontend/src/app/layout.js:
--------------------------------------------------------------------------------
1 | import './globals.css'
2 | import {Inter} from 'next/font/google'
3 | import AuthProvider from "@/auth/auth-provider";
4 | import {QueryProvider} from "@/context/query-provider";
5 |
6 | const inter = Inter({subsets: ['latin']})
7 |
8 | export const metadata = {
9 | title: 'PapiHub',
10 | description: 'Generated by create next app',
11 | }
12 |
13 | export default function RootLayout({children}) {
14 | return (
15 |
16 |
17 |
18 |
19 | {children}
20 |
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/papihub-frontend/src/app/page.js:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import MainLayout from "@/app/components/layout/main-layout";
4 | import SimpleLayout from "@/app/components/layout/simple-layout";
5 |
6 | export default function Home() {
7 |
8 | return (
9 |
10 |
12 |
24 |
25 |
26 |
27 |
28 | PapiHub 私有接口中心
29 |
30 |
31 | 部署在本地的私有接口中心,把任何网站转换成标准API接口。
32 |
33 |
42 |
43 |
44 |
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/papihub-frontend/src/app/site/add/page.js:
--------------------------------------------------------------------------------
1 | import SiteEditForm from "@/app/components/page-sections/site/edit-form";
2 | import MainPageHeader from "@/app/components/layout/header/main-page-header";
3 |
4 | export default function Page() {
5 | return (
6 |
7 |
20 |
21 |
22 | )
23 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/site/layout.js:
--------------------------------------------------------------------------------
1 | import MainLayout from "@/app/components/layout/main-layout";
2 | import {AuthGuard} from "@/auth/guard/auth-guard";
3 |
4 | export default function Layout({children}) {
5 | return (
6 |
7 |
8 | {children}
9 |
10 |
11 | )
12 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/app/site/page.js:
--------------------------------------------------------------------------------
1 | import MainPageHeader from "@/app/components/layout/header/main-page-header";
2 | import Button from "@/app/components/button/button";
3 | import SiteList from "@/app/components/page-sections/site/list";
4 |
5 | export const metadata = {
6 | title: '网站配置 | PapiHub',
7 | description: '配置系统内可以抓取访问的站点。',
8 | }
9 | export default function Page() {
10 |
11 | return (
12 | 添加站点}
25 | />
26 |
27 |
)
28 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/auth/auth-provider.js:
--------------------------------------------------------------------------------
1 | "use client"
2 | import useUserStore from "@/auth/store/use-user-store";
3 | import {useEffect, useState} from "react";
4 |
5 | export default function AuthProvider({children}) {
6 | const {authenticated, isInitializing, initializeMethod} = useUserStore();
7 | const [checked, setChecked] = useState(false);
8 | useEffect(() => {
9 | if (!isInitializing) {
10 | return;
11 | }
12 | initializeMethod();
13 | }, [initializeMethod, isInitializing]);
14 | return (
15 | <>
16 | {children}
17 | >
18 | );
19 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/auth/guard/auth-guard.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import useUserStore from "@/auth/store/use-user-store";
3 | import {useCallback, useEffect, useState} from "react";
4 | import {usePathname, useRouter} from "next/navigation";
5 |
6 | export const AuthGuard = ({children}) => {
7 | const router = useRouter();
8 | const pathname = usePathname();
9 | const {authenticated, isInitializing} = useUserStore();
10 | const [checked, setChecked] = useState(false);
11 | const check = useCallback(async () => {
12 | if (isInitializing) {
13 | return;
14 | }
15 | if (!authenticated) {
16 | const params = new URLSearchParams({returnTo: pathname}).toString();
17 | router.replace(`/auth/login?${params}`);
18 | } else {
19 | setChecked(true);
20 | }
21 | }, [pathname, authenticated, router, isInitializing]);
22 | useEffect(() => {
23 | check();
24 | }, [check]);
25 | if (!checked) {
26 | return null;
27 | }
28 | return <>{children}>;
29 | }
--------------------------------------------------------------------------------
/papihub-frontend/src/auth/guard/guest-guard.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/papihub-frontend/src/auth/guard/guest-guard.js
--------------------------------------------------------------------------------
/papihub-frontend/src/auth/store/use-user-store.js:
--------------------------------------------------------------------------------
1 | import {create} from "zustand";
2 | import {setSession} from "@/auth/utils";
3 | import axios from "@/utils/axios";
4 |
5 | const useUserStore = create((set, get) => ({
6 | isInitializing: true,
7 | user: null,
8 | authenticated: false,
9 | hasError: false,
10 | errorMessage: null,
11 | initializeMethod: async () => {
12 | const accessToken = window.localStorage.getItem("accessToken");
13 | setSession(accessToken);
14 | try {
15 | const response = await axios.get("/api/user/profile");
16 | const {success, data} = response;
17 | if (success) {
18 | set({user: data, authenticated: true, isInitializing: false});
19 | } else {
20 | setSession(null)
21 | set({isInitializing: false});
22 | }
23 | } catch (e) {
24 | setSession(null);
25 | set({isInitializing: false});
26 | }
27 | },
28 | login: async (username, password) => {
29 | let formData = new FormData();
30 | formData.append("username", username);
31 | formData.append("password", password);
32 | const response = await axios.post(
33 | "/api/user/get_token",
34 | formData
35 | );
36 | const {success, data, message} = response;
37 | if (success) {
38 | setSession(data.access_token);
39 | set({user: data.user, authenticated: true, isInitializing: true});
40 | } else {
41 | set({hasError: true, errorMessage: message});
42 | }
43 | },
44 | logout: () => {
45 | setSession(null);
46 | set({user: null, authenticated: false, isInitializing: true});
47 | }
48 | }));
49 |
50 | export default useUserStore;
--------------------------------------------------------------------------------
/papihub-frontend/src/auth/utils.js:
--------------------------------------------------------------------------------
1 | import axios from "@/utils/axios";
2 |
3 | const setSession = (accessToken) => {
4 | if (accessToken) {
5 | localStorage.setItem("accessToken", accessToken);
6 | axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
7 | // This function below will handle when token is expired
8 | // const { exp } = jwtDecode(accessToken);
9 | // handleTokenExpired(exp);
10 | } else {
11 | localStorage.removeItem("accessToken");
12 | delete axios.defaults.headers.common.Authorization;
13 | }
14 | };
15 | export {setSession};
--------------------------------------------------------------------------------
/papihub-frontend/src/context/query-provider.js:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from 'react';
3 | import {QueryClient, QueryClientProvider} from "react-query";
4 |
5 | export const QueryProvider = ({children}) => {
6 | const queryClient = new QueryClient({
7 | defaultOptions: {
8 | queries: {
9 | refetchOnWindowFocus: false,
10 | },
11 | },
12 | });
13 |
14 | return (
15 |
16 | {children}
17 | {/**/}
18 |
19 | );
20 |
21 | };
22 |
--------------------------------------------------------------------------------
/papihub-frontend/src/hooks/use-http.js:
--------------------------------------------------------------------------------
1 | import axios from "@/utils/axios";
2 | import { useCallback } from 'react';
3 |
4 | const useHttp = () => {
5 | return useCallback(
6 | (...[endpoint, config]) =>
7 | (config?.method?.toUpperCase() === "POST"
8 | ? axios.post(endpoint, config.params)
9 | : axios.get(endpoint, config)),
10 | []
11 | );
12 | };
13 |
14 | export default useHttp;
15 |
16 |
--------------------------------------------------------------------------------
/papihub-frontend/src/service/site-service.js:
--------------------------------------------------------------------------------
1 | import useHttp from "@/hooks/use-http";
2 | import {useMutation, useQuery} from "react-query";
3 |
4 | export const useListParsers = (param) => {
5 | const client = useHttp();
6 | return useQuery(['list_parsers', param], () =>
7 | client("/api/site/list_parsers", {params: param})
8 | );
9 | };
10 | export const useListSite = (param) => {
11 | const client = useHttp();
12 | return useQuery(['list_site', param], () =>
13 | client("/api/site/list", {params: param})
14 | );
15 | };
16 | export const useAddSite = (param) => {
17 | const client = useHttp();
18 | return useMutation(
19 | (params) =>
20 | client("/api/site/add", {params: params, method: "POST"})
21 | );
22 | };
--------------------------------------------------------------------------------
/papihub-frontend/src/utils/axios.js:
--------------------------------------------------------------------------------
1 | // config
2 |
3 | // ----------------------------------------------------------------------
4 |
5 | import axios from "axios";
6 |
7 | const axiosInstance = axios.create({
8 | validateStatus: function (status) {
9 | return true; // 任何状态码都视为成功
10 | }
11 | });
12 |
13 | axiosInstance.interceptors.response.use(
14 | (response) => {
15 | const res = response.data;
16 | return {...res, response};
17 | },
18 | (error) => Promise.reject(
19 | (error.response && error.response.data) || 'Something went wrong')
20 | );
21 |
22 | export default axiosInstance;
23 |
--------------------------------------------------------------------------------
/papihub-frontend/src/utils/format-time.js:
--------------------------------------------------------------------------------
1 | import {format, formatDistanceToNow, getTime} from 'date-fns';
2 | import {zhCN} from 'date-fns/locale';
3 | // ----------------------------------------------------------------------
4 |
5 | export function fDate(date, newFormat) {
6 | const fm = newFormat || 'dd MMM yyyy';
7 |
8 | return date ? format(new Date(date), fm) : '';
9 | }
10 |
11 | export function fDateTime(date, newFormat) {
12 | const fm = newFormat || 'dd MMM yyyy p';
13 |
14 | return date ? format(new Date(date), fm) : '';
15 | }
16 |
17 | export function fTimestamp(date) {
18 | return date ? getTime(new Date(date)) : '';
19 | }
20 |
21 | export function fToNow(date) {
22 | return date
23 | ? formatDistanceToNow(new Date(date), {
24 | addSuffix: true,
25 | locale: zhCN
26 | })
27 | : '';
28 | }
29 |
--------------------------------------------------------------------------------
/papihub-frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
5 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
6 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
7 | ],
8 | theme: {
9 | extend: {
10 | backgroundImage: {
11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
12 | 'gradient-conic':
13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
14 | },
15 | },
16 | },
17 | plugins: [
18 | require('@tailwindcss/forms'),
19 | ],
20 | }
21 |
--------------------------------------------------------------------------------
/papihub/.gitignore:
--------------------------------------------------------------------------------
1 | .vs/
2 | .vscode/
3 | .idea/
4 | # Byte-compiled / optimized / DLL files
5 | __pycache__/
6 | *.py[cod]
7 | *$py.class
8 |
9 | # C extensions
10 | *.so
11 |
12 | # Distribution / packaging
13 | .Python
14 | build/
15 | develop-eggs/
16 | dist/
17 | downloads/
18 | eggs/
19 | .eggs/
20 | lib/
21 | lib64/
22 | parts/
23 | sdist/
24 | var/
25 | wheels/
26 | pip-wheel-metadata/
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 |
57 | # Translations
58 | *.mo
59 | *.pot
60 |
61 | # Django stuff:
62 | *.log
63 | local_settings.py
64 | db.sqlite3
65 | db.sqlite3-journal
66 |
67 | # Flask stuff:
68 | instance/
69 | .webassets-cache
70 |
71 | # Scrapy stuff:
72 | .scrapy
73 |
74 | # Sphinx documentation
75 | docs/_build/
76 |
77 | # PyBuilder
78 | target/
79 |
80 | # Jupyter Notebook
81 | .ipynb_checkpoints
82 | notebooks/
83 |
84 | # IPython
85 | profile_default/
86 | ipython_config.py
87 |
88 | # pyenv
89 | .python-version
90 |
91 | # pipenv
92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
95 | # install all needed dependencies.
96 | #Pipfile.lock
97 |
98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
99 | __pypackages__/
100 |
101 | # Celery stuff
102 | celerybeat-schedule
103 | celerybeat.pid
104 |
105 | # SageMath parsed files
106 | *.sage.py
107 |
108 | # Environments
109 | .env
110 | .envrc
111 | .venv
112 | .venvs
113 | env/
114 | venv/
115 | ENV/
116 | env.bak/
117 | venv.bak/
118 |
119 | # Spyder project settings
120 | .spyderproject
121 | .spyproject
122 |
123 | # Rope project settings
124 | .ropeproject
125 |
126 | # mkdocs documentation
127 | /site
128 |
129 | # mypy
130 | .mypy_cache/
131 | .dmypy.json
132 | dmypy.json
133 |
134 | # Pyre type checker
135 | .pyre/
136 |
137 | # macOS display setting files
138 | .DS_Store
139 |
140 | # Wandb directory
141 | wandb/
142 |
143 | # asdf tool versions
144 | .tool-versions
145 | /.ruff_cache/
146 |
147 | *.pkl
148 | *.bin
149 |
150 | # integration test artifacts
151 | data_map*
152 | \[('_type', 'fake'), ('stop', None)]
--------------------------------------------------------------------------------
/papihub/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/papihub/__init__.py
--------------------------------------------------------------------------------
/papihub/api/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 私有接口的核心实现包
3 | """
4 |
--------------------------------------------------------------------------------
/papihub/api/auth.py:
--------------------------------------------------------------------------------
1 | import abc
2 |
3 |
4 | class Auth(metaclass=abc.ABCMeta):
5 | """
6 | 站点认证信息类
7 | 不需要认证的站点,无需实现此接口
8 | """
9 |
10 | @abc.abstractmethod
11 | def auth_with_cookies(self, cookies_str: str):
12 | """
13 | 通过Cookies完成认证
14 | :param cookies_str: Cookies字符串
15 | :return:
16 | """
17 | pass
18 |
19 | @abc.abstractmethod
20 | def auth(self, username: str, password: str) -> str:
21 | """
22 | 通过用户名密码完成认证
23 | :param username: 用户名
24 | :param password: 密码
25 | :return: 返回授权后的cookies字符串
26 | """
27 | pass
28 |
29 | @abc.abstractmethod
30 | def test_login(self) -> bool:
31 | """
32 | 测试登录
33 | :return:
34 | """
35 | pass
36 |
--------------------------------------------------------------------------------
/papihub/api/concurrenttorrentsite.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from concurrent.futures import ThreadPoolExecutor
3 | from typing import List, Optional
4 |
5 | from papihub.api.torrentsite import TorrentSite
6 | from papihub.api.types import Torrent
7 | from papihub.exceptions import SiteApiErrorException
8 |
9 | _LOGGER = logging.getLogger(__name__)
10 |
11 | executor = ThreadPoolExecutor(max_workers=50)
12 |
13 |
14 | class ConcurrentTorrentSite:
15 | """
16 | 并行种子站点操作类
17 | 传入很多站点实例后,开启多线程并行处理
18 | """
19 |
20 | def __init__(self, torrent_sites: List[TorrentSite]):
21 | if not torrent_sites:
22 | raise SiteApiErrorException('没有可用的站点')
23 | self.torrent_sites = torrent_sites
24 |
25 | def search(
26 | self,
27 | keyword: str,
28 | imdb_id: Optional[str],
29 | cate_level1: Optional[List[str]]
30 | ):
31 | results: List[Torrent] = []
32 | futures = [executor.submit(lambda: s.search(
33 | keyword=keyword,
34 | imdb_id=imdb_id,
35 | cate_level1_list=cate_level1
36 | )) for s in self.torrent_sites]
37 | for future in futures:
38 | try:
39 | r = future.result()
40 | if not r:
41 | continue
42 | results.extend(r)
43 | except Exception as e:
44 | _LOGGER.error(e)
45 | return results
46 |
--------------------------------------------------------------------------------
/papihub/api/parser/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 各种解析器的包
3 | """
4 |
--------------------------------------------------------------------------------
/papihub/api/parser/htmlparser.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import logging
3 | import re
4 | import urllib
5 |
6 | from pyquery import PyQuery
7 |
8 | from papihub.exceptions import ParserFieldException
9 |
10 | _LOGGER = logging.getLogger(__name__)
11 |
12 |
13 | def filter_querystring(value, args):
14 | if value.startswith('?'):
15 | value = value[1:]
16 | elif value.find('?') != -1:
17 | value = value.split('?')[1]
18 | qs = urllib.parse.parse_qs(value)
19 | value = qs.get(args)
20 | if value:
21 | value = value[0]
22 | return value
23 |
24 |
25 | def filter_split(value, args):
26 | arr = value.split(args[0])
27 | if args[1] < len(arr):
28 | return arr[args[1]]
29 | return None
30 |
31 |
32 | def filter_re_search(value, args):
33 | result = re.search(args[0], value)
34 | if result:
35 | if args[1] <= len(result.groups()):
36 | return result.group(args[1])
37 | else:
38 | return
39 | return
40 |
41 |
42 | def filter_parse_date_elapsed(value, args) -> datetime:
43 | t = re.match(r'(?:(\d+)日)?(?:(\d+)[時时])?(?:(\d+)分)?', value)
44 | if not t:
45 | return None
46 | now = datetime.datetime.now()
47 | if t.group(1):
48 | now = now + datetime.timedelta(days=int(t.group(1)))
49 | if t.group(2):
50 | now = now + datetime.timedelta(hours=int(t.group(2)))
51 | if t.group(3):
52 | now = now + datetime.timedelta(minutes=int(t.group(3)))
53 | return now
54 |
55 |
56 | def filter_parse_date_elapsed_en(value, args):
57 | if not value:
58 | return
59 | value = str(value).strip()
60 | t = re.match(r'([\d\.]+)\s(seconds|minutes|hours|days|weeks|years)\sago', value)
61 | if not t:
62 | return
63 | now = datetime.datetime.now()
64 | num = t.group(1)
65 | unit = t.group(2)
66 | if unit == 'seconds':
67 | now = now + datetime.timedelta(seconds=float(num))
68 | elif unit == 'minutes':
69 | now = now + datetime.timedelta(minutes=float(num))
70 | elif unit == 'hours':
71 | now = now + datetime.timedelta(hours=float(num))
72 | elif unit == 'days':
73 | now = now + datetime.timedelta(days=float(num))
74 | elif unit == 'weeks':
75 | now = now + datetime.timedelta(weeks=float(num))
76 | elif unit == 'years':
77 | now = now + datetime.timedelta(days=float(num) * 365)
78 | return now
79 |
80 |
81 | def filter_regexp(value, args):
82 | return re.sub(args, '', value)
83 |
84 |
85 | def filter_dateparse(value, args):
86 | if not value:
87 | return datetime.datetime.now()
88 | value = str(value)
89 | if isinstance(args, list):
90 | args = args
91 | else:
92 | args = [str(args)]
93 | for f in args:
94 | try:
95 | try:
96 | return datetime.datetime.strptime(value, f)
97 | except ValueError as e:
98 | if value.startswith('今天'):
99 | value = value.replace('今天', datetime.datetime.now().strftime('%Y-%m-%d'))
100 | return filter_dateparse(value, args)
101 | elif value.startswith('昨天'):
102 | value = value.replace('昨天',
103 | (datetime.datetime.now() - datetime.timedelta(days=-1)).strftime('%Y-%m-%d'))
104 | return filter_dateparse(value, args)
105 | raise e
106 | except ValueError as e:
107 | continue
108 | return
109 |
110 |
111 | filter_handler = {
112 | 'lstrip': lambda val, args: str(val).lstrip(str(args[0])),
113 | 'rstrip': lambda val, args: str(val).rstrip(str(args[0])),
114 | 'replace': lambda val, args: val.replace(args[0], args[1]) if val else None,
115 | 'append': lambda val, args: val + args,
116 | 'prepend': lambda val, args: args + val,
117 | 'tolower': lambda val, args: val.lower(),
118 | 'toupper': lambda val, args: val.upper(),
119 | 'split': filter_split,
120 | 'dateparse': filter_dateparse,
121 | 'querystring': filter_querystring,
122 | 're_search': filter_re_search,
123 | 'date_elapsed_parse': filter_parse_date_elapsed,
124 | 'date_en_elapsed_parse': filter_parse_date_elapsed_en,
125 | 'regexp': filter_regexp
126 | }
127 |
128 |
129 | class HtmlParser:
130 | @staticmethod
131 | def _select_value(tag: PyQuery, rule):
132 | val = None
133 | if tag:
134 | if 'attribute' in rule:
135 | attr = tag.attr(rule['attribute'])
136 | if attr:
137 | if isinstance(attr, list):
138 | val = attr[0]
139 | else:
140 | val = attr
141 | elif 'method' in rule:
142 | if rule['method'] == 'next_sibling' and tag:
143 | val = tag[0].tail
144 | elif 'remove' in rule:
145 | remove_tag_name = rule['remove'].split(',')
146 | for rt in remove_tag_name:
147 | tag.remove(rt)
148 | val = tag.text()
149 | elif 'contents' in rule:
150 | idx = rule['contents']
151 | e = tag.eq(0).contents()[idx]
152 | if hasattr(e, 'text'):
153 | val = e.text
154 | else:
155 | val = str(e)
156 | else:
157 | val = tag.text()
158 | if val:
159 | val = val.strip()
160 | return val
161 |
162 | @staticmethod
163 | def _case_value(r: PyQuery, case):
164 | val = None
165 | for ck in case:
166 | if ck == '*':
167 | val = case[ck]
168 | break
169 | if r(ck):
170 | val = case[ck]
171 | break
172 | return val
173 |
174 | @staticmethod
175 | def _filter_value(value, filters):
176 | if not value:
177 | return value
178 | for f in filters:
179 | if f['name'] in filter_handler:
180 | value = filter_handler[f['name']](value, f.get('args'))
181 | return value
182 |
183 | @staticmethod
184 | def parse_item_fields(item_tag: PyQuery, item_rule, context=None):
185 | if not item_tag:
186 | return {}
187 | self = HtmlParser
188 | values = {}
189 | for key in item_rule:
190 | rule = item_rule[key]
191 | val = None
192 | try:
193 | if 'text' in rule:
194 | if '_template' in rule:
195 | tmpl = rule['_template']
196 | ctx = {'fields': values, 'now': datetime.datetime.now()}
197 | if context:
198 | ctx.update(context)
199 | val = tmpl.render(ctx)
200 | else:
201 | val = rule.get('text')
202 | elif 'selector' in rule:
203 | val = self._select_value(item_tag(rule['selector']), rule)
204 | elif 'selectors' in rule:
205 | tag_list = item_tag(rule['selectors'])
206 | if rule.get('index'):
207 | if tag_list and rule['index'] < tag_list.length:
208 | tag = tag_list.eq(rule['index'])
209 | val = self._select_value(tag, rule)
210 | else:
211 | val = []
212 | for i in range(tag_list.length):
213 | val.append(self._select_value(tag_list.eq(i), rule))
214 | elif 'case' in rule:
215 | val = self._case_value(item_tag, rule['case'])
216 | if 'filters' in rule:
217 | val = self._filter_value(val, rule['filters'])
218 | if not val and 'default_value' in rule:
219 | if '_default_value_template' in rule:
220 | tmpl = rule['_default_value_template']
221 | ctx = {'fields': values, 'now': datetime.datetime.now(), 'max_time': datetime.datetime.max}
222 | if context:
223 | ctx.update(context)
224 | val = tmpl.render(ctx)
225 | else:
226 | val = rule['default_value']
227 | if val and 'default_value_format' in rule:
228 | val = datetime.datetime.strptime(val, rule['default_value_format'])
229 | except Exception as e:
230 | raise ParserFieldException(key, e)
231 | values[key] = val
232 | return values
233 |
--------------------------------------------------------------------------------
/papihub/api/sites/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/papihub/api/sites/__init__.py
--------------------------------------------------------------------------------
/papihub/api/sites/nexusphp.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from http.cookies import SimpleCookie
3 | from typing import Optional, List, Dict
4 |
5 | import httpx
6 | from cssselect import SelectorSyntaxError
7 | from httpx import Timeout
8 | from jinja2 import Template
9 | from pyquery import PyQuery
10 |
11 | from papihub import utils
12 | from papihub.api.auth import Auth
13 | from papihub.api.parser.htmlparser import HtmlParser
14 | from papihub.api.torrentsite import TorrentSite
15 | from papihub.api.types import TorrentDetail, Torrent, TorrentSiteUser, ApiOptions, CateLevel1
16 | from papihub.config.types import TorrentSiteParserConfig
17 | from papihub.constants import BASE_HEADERS, ALL_CATE_LEVEL1
18 | from papihub.exceptions import NotAuthenticatedException, ParserException
19 |
20 | DEFAULT_LOGIN_PATH = '/takelogin.php'
21 |
22 |
23 | class NexusPhp(TorrentSite, Auth):
24 | """
25 | NexusPHP站点架构解析器,此实现类有以下特点
26 | 1、此类带有站点登录功能
27 | 2、此类遵循NexusPHP站点分类设计,搜索时会自动找到正确的搜索分类进行搜索
28 | """
29 |
30 | auth_cookies: Optional[Dict[str, str]] = None
31 | auth_headers: Optional[Dict[str, str]] = None
32 | _user: Optional[TorrentSiteUser] = None
33 | _search_paths: List
34 | _search_query: Dict
35 |
36 | def __init__(self, parser_config: TorrentSiteParserConfig, options: Optional[ApiOptions] = None):
37 | self.parser_config = parser_config
38 | if not options:
39 | options = ApiOptions()
40 | options.request_timeout = 20
41 | self.options = options
42 | self._init()
43 |
44 | def _init(self):
45 | """
46 | 初始化站点配置信息
47 | :return:
48 | """
49 | for c in self.parser_config.category_mappings:
50 | # id to string
51 | c['id'] = str(c['id'])
52 | self.parser_config.domain = self.parser_config.domain.rstrip('/')
53 | self._init_jinja_template(self.parser_config.torrents.get('fields'))
54 | if self.parser_config.get_list:
55 | self._init_jinja_template(self.parser_config.get_list.get('fields'))
56 | self._search_paths = self._init_search_paths(self.parser_config.search.get('paths'),
57 | self.parser_config.category_mappings)
58 | self._search_query = self._init_search_query(self.parser_config.search.get('query'))
59 | self.auth_headers = BASE_HEADERS.copy()
60 | self.auth_headers['Referer'] = self.parser_config.domain
61 | if self.options.user_agent:
62 | self.auth_headers['User-Agent'] = self.options.user_agent
63 |
64 | @staticmethod
65 | def _init_search_paths(paths_config, category_mappings):
66 | """
67 | 根据类目的配置,加载搜索配置,把搜索路径及可用的类目组合,方便搜索时查找
68 | :param paths_config:
69 | :param category_mappings:
70 | :return:
71 | """
72 | paths = []
73 | for p in paths_config:
74 | obj: dict = dict()
75 | obj['path'] = p.get('path')
76 | cate_ids_config = p.get('categories')
77 | search_cate_ids = []
78 | if cate_ids_config:
79 | # 如果可用id第一个字符为!,则说明是排除设置模式
80 | if cate_ids_config[0] == '!':
81 | for c in category_mappings:
82 | if (int(c['id']) if c['id'] else 0) not in cate_ids_config:
83 | search_cate_ids.append(str(c['id']))
84 | else:
85 | search_cate_ids = [str(c) for c in cate_ids_config]
86 | else:
87 | search_cate_ids = [str(c['id']) for c in category_mappings]
88 | obj['categories'] = search_cate_ids
89 | if p.get('method'):
90 | obj['method'] = p.get('method')
91 | else:
92 | obj['method'] = 'get'
93 | paths.append(obj)
94 | return paths
95 |
96 | @staticmethod
97 | def _init_search_query(query_config):
98 | query_tmpl = {}
99 | for key in query_config:
100 | val = query_config[key]
101 | if isinstance(val, str) and val.find('{') != -1:
102 | query_tmpl[key] = Template(val)
103 | else:
104 | query_tmpl[key] = val
105 | return query_tmpl
106 |
107 | @staticmethod
108 | def _init_jinja_template(fields):
109 | """
110 | 把fields中使用到的jinja2模版提前编译好
111 | :param fields:
112 | :return:
113 | """
114 | if not fields:
115 | return
116 | if fields:
117 | for key in fields:
118 | rule = fields[key]
119 | if 'text' in rule:
120 | if isinstance(rule['text'], str) and rule['text'].find('{') != -1:
121 | rule['_template'] = Template(rule['text'])
122 | if 'default_value' in rule:
123 | if isinstance(rule['default_value'], str) and rule['default_value'].find('{') != -1:
124 | rule['_default_value_template'] = Template(rule['default_value'])
125 |
126 | def _set_auth_cookies(self, cookie_str: str):
127 | if not cookie_str:
128 | return
129 | cookie = SimpleCookie(cookie_str)
130 | cookies = {}
131 | for key, morsel in cookie.items():
132 | cookies[key] = morsel.value
133 | self.auth_cookies = cookies
134 |
135 | def _get_cate_level2_ids(self, cate_level1_list: Optional[List[CateLevel1]] = None):
136 | """
137 | 通过一级大分类,去找到配置好的站点二级小分类,真正搜索时,搜站点二级小分类的编号
138 | :param cate_level1_list:
139 | :return:
140 | """
141 | if not cate_level1_list:
142 | ids = []
143 | # 默认不查成人
144 | for c in self.parser_config.category_mappings:
145 | if c['cate_level1'] == CateLevel1.AV.name:
146 | continue
147 | ids.append(c['id'])
148 | return ids
149 | cate_level1_str_arr = [i.name for i in cate_level1_list]
150 | cate2_ids = []
151 | # 找到一级分类下所有的二级分类编号
152 | for c in self.parser_config.category_mappings:
153 | if c.get('cate_level1') in cate_level1_str_arr or c.get('cate_level1') == '*':
154 | cate2_ids.append(c.get('id'))
155 | return cate2_ids
156 |
157 | def _build_search_path(self, cate_level1_list: Optional[List[CateLevel1]]) -> List[Dict]:
158 | if not cate_level1_list:
159 | cate_level1_list = [x for x in CateLevel1]
160 | input_cate2_ids = set(self._get_cate_level2_ids(cate_level1_list))
161 | paths = []
162 | # 根据传入一级分类数据,查找真正要执行的搜索path,一级对应分类
163 | for p in self._search_paths:
164 | cpath = p.copy()
165 | cate_in = list(set(cpath['categories']).intersection(input_cate2_ids))
166 | if not cate_in:
167 | continue
168 | del cpath['categories']
169 | if len(cate_in) == len(self.parser_config.category_mappings):
170 | # 如果等于全部,不需要传分类
171 | cpath['query_cates'] = []
172 | else:
173 | cpath['query_cates'] = cate_in
174 | paths.append(cpath)
175 | return paths
176 |
177 | def _trans_search_cate_id(self, ids):
178 | if not ids:
179 | return ids
180 | id_mapping = self.parser_config.category_id_mapping
181 | if not id_mapping:
182 | return ids
183 | new_ids = []
184 | for _id in ids:
185 | for mid in id_mapping:
186 | if mid.get('id') == _id:
187 | if isinstance(mid.get('mapping'), list):
188 | new_ids += mid.get('mapping')
189 | else:
190 | new_ids.append(mid.get('mapping'))
191 | new_ids = list(filter(None, new_ids))
192 | if not new_ids:
193 | return ids
194 | return new_ids
195 |
196 | def _render_querystring(self, query_context: Dict):
197 | qs = ''
198 | for key in self._search_query:
199 | val = self._search_query[key]
200 | if isinstance(val, Template):
201 | val = val.render({'query': query_context})
202 | if key == '$raw' and val is not None and val != '':
203 | qs += val
204 | elif val is not None and val != '':
205 | qs += f'{key}={val}&'
206 | if qs:
207 | qs = qs.rstrip('&')
208 | return qs
209 |
210 | def _get_response_text(self, response):
211 | if not response:
212 | return
213 | c = response.content
214 | if not c:
215 | return
216 | s = str(response.content, self.parser_config.encoding)
217 | return utils.trim_emoji(s)
218 |
219 | @staticmethod
220 | def _is_login(response) -> bool:
221 | if not response:
222 | return False
223 | if response.url.path.startswith('/login.php'):
224 | return False
225 | return True
226 |
227 | def auth_with_cookies(self, cookies_str: str):
228 | self._set_auth_cookies(cookies_str)
229 |
230 | def auth(self, username: str, password: str) -> str:
231 | with httpx.Client(
232 | headers=self.auth_headers,
233 | cookies=self.auth_cookies,
234 | timeout=Timeout(self.options.request_timeout) if self.options else None,
235 | proxies=self.options.proxies,
236 | follow_redirects=True,
237 | verify=False
238 | ) as client:
239 | if self.parser_config.login and self.parser_config.login.get('path'):
240 | login_path = f'{self.parser_config.domain}{self.parser_config.login.get("path")}'
241 | else:
242 | login_path = f'{self.parser_config.domain}{DEFAULT_LOGIN_PATH}'
243 | login_method = self.parser_config.login.get('method') if self.parser_config.login else 'post'
244 | if login_method == 'get':
245 | res = client.get(f'{login_path}?username={username}&password={password}')
246 | else:
247 | res = client.post(login_path, data={
248 | 'username': username,
249 | 'password': password
250 | })
251 | if res.url.path.startswith('/index') and res.history:
252 | cookies_str = res.history[-1].headers.get('Set-Cookie')
253 | self._set_auth_cookies(cookies_str)
254 | return cookies_str
255 | else:
256 | raise NotAuthenticatedException(self.parser_config.site_id, self.parser_config.site_name,
257 | f'{self.parser_config.site_name}登录失败,用户名或密码错误')
258 |
259 | def _parse_user(self, pq: PyQuery) -> Optional[TorrentSiteUser]:
260 | try:
261 | item_tag = pq(self.parser_config.user.get('item')['selector'])
262 | result = HtmlParser.parse_item_fields(item_tag, self.parser_config.user.get('fields'))
263 | return TorrentSiteUser.from_data(result)
264 | except SelectorSyntaxError as e:
265 | raise ParserException(self.parser_config.site_id, self.parser_config.site_name,
266 | f"{self.parser_config.site_name}解析用户信息使用了错误的CSS选择器:{str(e)}")
267 | except Exception as e:
268 | raise ParserException(self.parser_config.site_id, self.parser_config.site_name,
269 | f"{self.parser_config.site_name}解析用户信息出现错误:{str(e)}")
270 |
271 | def _copy_to_torrent(self, item: dict) -> Optional[Torrent]:
272 | """
273 | 把按css选择器解析出来的种子数据,标准化成Torrent对象
274 | """
275 | if not item:
276 | return None
277 | t = Torrent()
278 | t.site_id = self.parser_config.site_id
279 | t.id = utils.parse_value(str, item.get('id'))
280 | t.name = utils.parse_value(str, item.get('title'))
281 | t.subject = utils.parse_value(str, item.get('description'))
282 | if t.subject:
283 | t.subject = t.subject.strip()
284 | t.free_deadline = item.get('free_deadline')
285 | t.imdb_id = item.get('imdbid')
286 | t.upload_count = utils.parse_value(int, item.get('seeders'), 0)
287 | t.downloading_count = utils.parse_value(int, item.get('leechers'), 0)
288 | t.download_count = utils.parse_value(int, item.get('grabs'), 0)
289 | t.download_url = item.get('download')
290 | if t.download_url and not t.download_url.startswith('http') and not t.download_url.startswith('magnet'):
291 | t.download_url = self.parser_config.domain + t.download_url
292 | t.publish_date = utils.parse_value(datetime.datetime, item.get('date'), datetime.datetime.now())
293 | t.cate_id = utils.parse_value(str, item.get('category'))
294 | for c in self.parser_config.category_mappings:
295 | cid = t.cate_id
296 | id_mapping = self.parser_config.category_id_mapping
297 | if id_mapping:
298 | for mid in id_mapping:
299 | if str(mid.get('id')) == str(cid):
300 | if isinstance(mid.get('mapping'), list):
301 | cid = mid.get('mapping')[0]
302 | else:
303 | cid = mid.get('mapping')
304 | if str(c.get('id')) == str(cid):
305 | t.cate_level1 = CateLevel1.get_type(c.get('cate_level1'))
306 | break
307 | t.details_url = item.get('details')
308 | if t.details_url:
309 | t.details_url = self.parser_config.domain + t.details_url
310 | t.download_volume_factor = utils.parse_value(float, item.get('downloadvolumefactor'), 1)
311 | t.upload_volume_factor = utils.parse_value(float, item.get('uploadvolumefactor'), 1)
312 | t.size_mb = utils.trans_size_str_to_mb(utils.parse_value(str, item.get('size'), '0'))
313 | t.poster_url = item.get('poster')
314 | t.minimum_ratio = utils.parse_value(float, item.get('minimumratio'), 0.0)
315 | t.minimum_seed_time = utils.parse_value(int, item.get('minimumseedtime'), 0)
316 | if t.poster_url:
317 | if t.poster_url.startswith("./"):
318 | t.poster_url = self.parser_config.domain + t.poster_url[2:]
319 | elif not t.poster_url.startswith("http"):
320 | t.poster_url = self.parser_config.domain + t.poster_url
321 | return t
322 |
323 | def _parse_torrents(self, pq: PyQuery, context: Dict) -> List[Torrent]:
324 | list_rule = self.parser_config.torrents.get('list')
325 | fields_rule = self.parser_config.torrents.get('fields')
326 | if not fields_rule:
327 | return []
328 | try:
329 | rows = pq(list_rule['selector'])
330 | if not rows:
331 | return []
332 | result: List[Torrent] = []
333 | for i in range(rows.length):
334 | tag = rows.eq(i)
335 | result.append(self._copy_to_torrent(HtmlParser.parse_item_fields(tag, fields_rule, context=context)))
336 | return result
337 | except SelectorSyntaxError as e:
338 | raise ParserException(self.parser_config.site_id, self.parser_config.site_name,
339 | f"{self.parser_config.site_name}种子信息解析使用了错误的CSS选择器:{str(e)}")
340 | except Exception as e:
341 | raise ParserException(self.parser_config.site_id, self.parser_config.site_name,
342 | f"{self.parser_config.site_name}种子信息解析失败")
343 |
344 | def _copy_torrent_detail(self, item):
345 | if not item:
346 | return
347 | t = TorrentDetail()
348 | t.site_id = self.parser_config.site_id
349 | t.id = item.get_int('id', item.get_value('id'))
350 | t.name = utils.parse_value(str, item.get('title'))
351 | t.subject = utils.parse_value(str, item.get('description'))
352 | if t.subject:
353 | t.subject = t.subject.strip()
354 | t.download_url = utils.parse_value(str, item.get('download'))
355 | if t.download_url and not t.download_url.startswith('http'):
356 | t.download_url = self.parser_config.domain + t.download_url
357 | t.filename = item.get('filename')
358 | t.intro = item.get('intro')
359 | t.publish_date = item.get('date')
360 | return t
361 |
362 | def _parse_detail(self, pq: PyQuery) -> TorrentDetail:
363 | detail_config = self.parser_config.get_detail
364 | if not detail_config:
365 | return
366 | field_rule = detail_config.get('fields')
367 | if not field_rule:
368 | return
369 | try:
370 | item_tag = pq(detail_config.get('item')['selector'])
371 | result = HtmlParser.parse_item_fields(item_tag, field_rule)
372 | return self._copy_torrent_detail(result)
373 | except SelectorSyntaxError as e:
374 | raise ParserException(self.parser_config.site_id, self.parser_config.site_name,
375 | f"{self.parser_config.site_name}种子详情页解析使用了错误的CSS选择器:{str(e)}")
376 | except Exception as e:
377 | raise ParserException(self.parser_config.site_id, self.parser_config.site_name,
378 | f"{self.parser_config.site_name}种子详情页解析失败")
379 |
380 | def list(self, timeout: Optional[int] = None, cate_level1_list: Optional[List] = None, ) -> List[Torrent]:
381 | if not timeout:
382 | timeout = self.options.request_timeout
383 | list_parser = self.parser_config.get_list
384 | if list_parser:
385 | with httpx.Client(
386 | headers=self.auth_headers,
387 | cookies=self.auth_cookies,
388 | timeout=Timeout(timeout),
389 | proxies=self.options.proxies,
390 | follow_redirects=True,
391 | verify=False
392 | ) as client:
393 | url = f'{self.parser_config.domain}{list_parser.get("path")}'
394 | r = client.get(url)
395 | text = self._get_response_text(r)
396 | if not text:
397 | return []
398 | pq = PyQuery(text)
399 | if not self._user:
400 | self._user = self._parse_user(pq)
401 | return self._parse_torrents(pq, context={'userinfo': self._user})
402 | else:
403 | return self.search(cate_level1_list=cate_level1_list if cate_level1_list else ALL_CATE_LEVEL1,
404 | timeout=timeout)
405 |
406 | def get_user(self, refresh=False) -> Optional[TorrentSiteUser]:
407 | url = self.parser_config.user.get('path')
408 | if not url:
409 | return
410 | with httpx.Client(
411 | headers=self.auth_headers,
412 | cookies=self.auth_cookies,
413 | http2=False,
414 | timeout=Timeout(timeout=self.options.request_timeout),
415 | proxies=self.options.proxies,
416 | follow_redirects=True,
417 | verify=False
418 | ) as client:
419 | r = client.get(url)
420 | text = self._get_response_text(r)
421 | pq = PyQuery(text)
422 | return self._parse_user(pq)
423 |
424 | def search(self, keyword: Optional[str] = None,
425 | imdb_id: Optional[str] = None,
426 | cate_level1_list: Optional[List] = None,
427 | free: Optional[bool] = False,
428 | page: Optional[int] = None,
429 | timeout: Optional[int] = None) -> List[Torrent]:
430 | if not self._search_paths:
431 | return []
432 | paths = self._build_search_path(cate_level1_list)
433 | if not paths:
434 | # 配置文件的分类设置有问题或者真的不存在所需查询分类
435 | return []
436 | # 构造查询参数的上下文,供配置渲染真实querystring
437 | query_context = {}
438 | if keyword:
439 | query_context['keyword'] = keyword
440 | if imdb_id:
441 | query_context['imdb_id'] = imdb_id
442 | if free:
443 | query_context['free'] = free
444 | else:
445 | query_context['cates'] = []
446 | if page:
447 | query_context['page'] = page
448 | total_torrents: List[Torrent] = []
449 | if not timeout:
450 | timeout = self.options.request_timeout
451 | for i, p in enumerate(paths):
452 | if p.get('query_cates'):
453 | query_context['cates'] = self._trans_search_cate_id(p.get('query_cates'))
454 | uri = p.get('path')
455 | qs = self._render_querystring(query_context)
456 | with httpx.Client(
457 | headers=self.auth_headers,
458 | cookies=self.auth_cookies,
459 | timeout=Timeout(timeout),
460 | proxies=self.options.proxies,
461 | follow_redirects=True,
462 | verify=False
463 | ) as client:
464 | if p.get('method') == 'get':
465 | url = f'{self.parser_config.domain}/{uri}?{qs}'
466 | res = client.get(url)
467 | else:
468 | url = f'{self.parser_config.domain}/{uri}'
469 | res = client.post(url, data=qs)
470 | if not self._is_login(res):
471 | raise NotAuthenticatedException(self.parser_config.site_id, self.parser_config.site_name,
472 | f'{self.parser_config.site_name}未授权,无法访问')
473 | text = self._get_response_text(res)
474 | if not text:
475 | continue
476 | pq = PyQuery(text)
477 | if not self._user:
478 | self._user = self._parse_user(pq)
479 | torrents = self._parse_torrents(pq, context={'userinfo': self._user})
480 | if torrents:
481 | total_torrents += torrents
482 | return total_torrents
483 |
484 | def download_torrent(self, url, filepath):
485 | pass
486 |
487 | def get_detail(self, url) -> Optional[TorrentDetail]:
488 | detail_config = self.parser_config.get_detail
489 | if not detail_config:
490 | return
491 | with httpx.Client(
492 | headers=self.auth_headers,
493 | cookies=self.auth_cookies,
494 | timeout=Timeout(self.options.request_timeout),
495 | proxies=self.options.proxies,
496 | follow_redirects=True,
497 | verify=False
498 | ) as client:
499 | r = client.get(url)
500 | text = self._get_response_text(r)
501 | if not text:
502 | return
503 | pq = PyQuery(text)
504 | return self._parse_detail(pq)
505 |
506 | def test_login(self):
507 | with httpx.Client(
508 | headers=self.auth_headers,
509 | cookies=self.auth_cookies,
510 | timeout=Timeout(self.options.request_timeout) if self.options else None,
511 | proxies=self.options.proxies,
512 | follow_redirects=False,
513 | verify=False
514 | ) as client:
515 | # nexusphp 都有一个用户首页
516 | r = client.get(f'{self.parser_config.domain}/usercp.php')
517 | return r.status_code == 200
518 |
--------------------------------------------------------------------------------
/papihub/api/torrentsite.py:
--------------------------------------------------------------------------------
1 | from abc import ABCMeta, abstractmethod
2 | from typing import List, Optional
3 |
4 | from papihub.api.types import Torrent, TorrentDetail, TorrentSiteUser
5 | from papihub.config.types import TorrentSiteParserConfig
6 |
7 |
8 | class TorrentSite(metaclass=ABCMeta):
9 | """
10 | 种子站点抓取接口
11 | """
12 | parser_config: TorrentSiteParserConfig
13 |
14 | @abstractmethod
15 | def list(self, timeout=None, cate_level1_list=None) -> List[Torrent]:
16 | """
17 | 获取种子列表
18 | :param timeout: 超时信息
19 | :param cate_level1_list: 一级种子分类信息
20 | :return:
21 | """
22 | pass
23 |
24 | @abstractmethod
25 | def get_user(self, refresh=False) -> Optional[TorrentSiteUser]:
26 | """
27 | 获取用户信息
28 | :param refresh:
29 | :return:
30 | """
31 | pass
32 |
33 | @abstractmethod
34 | def search(
35 | self,
36 | keyword: Optional[str] = None,
37 | imdb_id: Optional[str] = None,
38 | cate_level1_list: Optional[List] = None,
39 | free: Optional[bool] = False,
40 | page: Optional[int] = None,
41 | timeout: Optional[int] = None
42 | ) -> List[Torrent]:
43 | """
44 | 搜索种子
45 | :param keyword:
46 | :param imdb_id:
47 | :param cate_level1_list:
48 | :param free:
49 | :param page:
50 | :param timeout:
51 | :return:
52 | """
53 | pass
54 |
55 | @abstractmethod
56 | def download_torrent(self, url, filepath):
57 | """
58 | 下载种子
59 | :param url:
60 | :param filepath:
61 | :return:
62 | """
63 | pass
64 |
65 | @abstractmethod
66 | def get_detail(self, url) -> Optional[TorrentDetail]:
67 | """
68 | 获取种子详情信息
69 | :param url:
70 | :return:
71 | """
72 | pass
73 |
--------------------------------------------------------------------------------
/papihub/api/types.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from dataclasses import dataclass
3 | from enum import Enum
4 | from typing import Optional
5 |
6 | from dataclasses_json import dataclass_json
7 |
8 | from papihub import utils
9 | from papihub.utils import trans_size_str_to_mb
10 |
11 |
12 | @dataclass
13 | class ApiOptions:
14 | """
15 | 接口设置
16 | """
17 | request_timeout: Optional[int] = None
18 | proxies: Optional[str] = None
19 | user_agent: Optional[str] = None
20 |
21 |
22 | @dataclass_json
23 | @dataclass
24 | class TorrentSiteUser:
25 | """
26 | 种子站点用户信息
27 | """
28 | uid: Optional[int] = 0
29 | username: Optional[str] = 'unknown'
30 | user_group: Optional[str] = 'unknown'
31 | share_ratio: Optional[float] = 0
32 | uploaded: Optional[float] = 0
33 | downloaded: Optional[float] = 0
34 | seeding: Optional[int] = 0
35 | leeching: Optional[int] = 0
36 | vip_group: Optional[bool] = False
37 |
38 | @staticmethod
39 | def from_data(result: dict) -> Optional["TorrentSiteUser"]:
40 | if not result:
41 | return None
42 | user = TorrentSiteUser()
43 | user.uid = int(result['uid'])
44 | user.username = result['username']
45 | user.user_group = result['user_group']
46 | user.uploaded = trans_size_str_to_mb(str(result['uploaded']))
47 | user.downloaded = trans_size_str_to_mb(str(result['downloaded']))
48 | try:
49 | user.seeding = int(result['seeding'])
50 | except Exception as e:
51 | user.seeding = 0
52 | try:
53 | user.leeching = int(result['leeching'])
54 | except Exception as e:
55 | user.leeching = 0
56 | try:
57 | if 'share_ratio' in result:
58 | ss = result['share_ratio'].replace(',', '')
59 | user.share_ratio = float(ss)
60 | else:
61 | if not user.downloaded:
62 | user.share_ratio = float('inf')
63 | else:
64 | user.share_ratio = round(user.uploaded / user.downloaded, 2)
65 | except Exception as e:
66 | user.share_ratio = 0.0
67 | user.vip_group = result['vip_group']
68 | return user
69 |
70 |
71 | class CateLevel1(str, Enum):
72 | """
73 | 种子一级分类信息
74 | """
75 | Movie = 'Movie'
76 | TV = 'TV'
77 | Documentary = 'Documentary'
78 | Anime = 'Anime'
79 | Music = 'Music'
80 | Game = 'Game'
81 | AV = 'AV'
82 | Other = 'Other'
83 |
84 | @staticmethod
85 | def get_type(enum_name: str) -> Optional["CateLevel1"]:
86 | for item in CateLevel1:
87 | if item.name == enum_name:
88 | return item
89 | return None
90 |
91 |
92 | @dataclass_json
93 | @dataclass
94 | class Torrent:
95 | """
96 | 种子信息(来自列表页)
97 | """
98 | # 站点编号
99 | site_id: Optional[str] = None
100 | # 种子编号
101 | id: Optional[str] = None
102 | # 种子名称
103 | name: Optional[str] = None
104 | # 种子标题
105 | subject: Optional[str] = None
106 | # 以及类目
107 | cate_level1: Optional[CateLevel1] = None
108 | # 站点类目id
109 | cate_id: Optional[str] = None
110 | # 种子详情页地址
111 | details_url: Optional[str] = None
112 | # 种子下载链接
113 | download_url: Optional[str] = None
114 | # 种子关联的imdbid
115 | imdb_id: Optional[str] = None
116 | # 种子发布时间
117 | publish_date: Optional[datetime.datetime] = None
118 | # 种子大小,转化为mb尺寸
119 | size_mb: Optional[float] = None
120 | # 做种人数
121 | upload_count: Optional[int] = None
122 | # 下载中人数
123 | downloading_count: Optional[int] = None
124 | # 下载完成人数
125 | download_count: Optional[int] = None
126 | # 免费截止时间
127 | free_deadline: Optional[datetime.datetime] = None
128 | # 下载折扣,1为不免费
129 | download_volume_factor: Optional[float] = None
130 | # 做种上传系数,1为正常
131 | upload_volume_factor: Optional[int] = None
132 | minimum_ratio: float = 0
133 | minimum_seed_time: int = 0
134 | # 封面链接
135 | poster_url: Optional[str] = None
136 |
137 |
138 | @dataclass_json
139 | @dataclass
140 | class TorrentDetail:
141 | """
142 | 种子详情页信息
143 | """
144 | site_id: Optional[str] = None
145 | name: Optional[str] = None
146 | subject: Optional[str] = None
147 | download_url: Optional[str] = None
148 | filename: Optional[str] = None
149 | intro: Optional[str] = None
150 | publish_date: Optional[datetime.datetime] = None
151 |
--------------------------------------------------------------------------------
/papihub/auth.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import time
3 | from datetime import timedelta
4 | from typing import Annotated, Optional
5 |
6 | from fastapi import Depends, HTTPException
7 | from fastapi.security import OAuth2PasswordBearer
8 | from jose import jwt, JWTError
9 | from passlib.context import CryptContext
10 | from starlette import status
11 |
12 | from papihub.models.usermodel import UserModel
13 |
14 | # 一周过期,单位分钟
15 | ACCESS_TOKEN_EXPIRE_MINUTES = 10080
16 |
17 | # openssl rand -hex 32 生成 secret_key
18 | SECRET_KEY = "4f30cdb27ed3002c32b289371dd5897e73178719432f6b27bbd1747169a6e0b8"
19 | ALGORITHM = "HS256"
20 |
21 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/user/get_token")
22 |
23 | # 密码加盐
24 | PWD_SALT = "d3A^FQh8**!q"
25 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
26 |
27 |
28 | def get_password_hash(password):
29 | return pwd_context.hash(password + PWD_SALT)
30 |
31 |
32 | def verify_password(plain_password, hashed_password):
33 | return pwd_context.verify(plain_password + PWD_SALT, hashed_password)
34 |
35 |
36 | def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
37 | if expires_delta:
38 | expire = datetime.datetime.now() + expires_delta
39 | else:
40 | expire = datetime.datetime.now() + timedelta(minutes=60)
41 | data = {**data, "exp": expire}
42 | token = jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM)
43 | return token
44 |
45 |
46 | def get_current_user(
47 | token: Annotated[str, Depends(oauth2_scheme)]
48 | ):
49 | authenticate_value = "Bearer"
50 | credentials_exception = HTTPException(
51 | status_code=status.HTTP_401_UNAUTHORIZED,
52 | detail="验证信息无效",
53 | headers={"WWW-Authenticate": authenticate_value},
54 | )
55 | try:
56 | payload = jwt.decode(token, SECRET_KEY, algorithms=ALGORITHM)
57 | username: str = payload.get("sub")
58 | if username is None:
59 | raise credentials_exception
60 | expires = payload.get("exp")
61 | if expires < time.time():
62 | raise credentials_exception
63 | except JWTError:
64 | raise credentials_exception
65 | user = UserModel.get_by_username(username)
66 | if user is None:
67 | raise credentials_exception
68 | return user
69 |
--------------------------------------------------------------------------------
/papihub/common/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 一些公共的,偏工具类的包
3 | """
--------------------------------------------------------------------------------
/papihub/common/customjsonencoder.py:
--------------------------------------------------------------------------------
1 | import json
2 | import datetime
3 |
4 |
5 | class CustomJSONEncoder(json.JSONEncoder):
6 | def default(self, obj):
7 | if isinstance(obj, datetime.datetime):
8 | return obj.strftime("%Y-%m-%d %H:%M:%S")
9 | return super().default(obj)
10 |
--------------------------------------------------------------------------------
/papihub/common/logging.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | LOGGING_CONFIG = {
4 | 'version': 1,
5 | 'disable_existing_loggers': False,
6 | 'formatters': {
7 | 'default': {
8 | 'format': '%(asctime)s - %(name)s - %(levelname)s - [%(threadName)s] - %(message)s',
9 | },
10 | },
11 | 'handlers': {
12 | 'console': {
13 | 'class': 'logging.StreamHandler',
14 | 'level': 'INFO',
15 | 'formatter': 'default',
16 | },
17 | 'file': {
18 | 'class': 'logging.handlers.TimedRotatingFileHandler',
19 | 'level': 'INFO',
20 | 'formatter': 'default',
21 | 'filename': f"{os.environ.get('WORKDIR', '/app/papihub')}/logs/app.log",
22 | 'when': 'D',
23 | 'interval': 1,
24 | 'backupCount': 7,
25 | },
26 | },
27 | 'loggers': {
28 | '': { # root logger
29 | 'handlers': ['file'],
30 | 'level': 'INFO',
31 | 'propagate': True,
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/papihub/common/response.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from fastapi import status
4 | from fastapi.encoders import jsonable_encoder
5 | from fastapi.responses import JSONResponse, Response
6 | from typing import Union
7 |
8 | from starlette.responses import PlainTextResponse
9 |
10 | from papihub.common.customjsonencoder import CustomJSONEncoder
11 |
12 |
13 | def json_200(data: Union[bool, list, dict, str, None] = None, message: Union[str, None] = None) -> Response:
14 | """
15 | 返回http_status=200的结果
16 | :param data: 返回结果
17 | :param message: 消息
18 | :return:
19 | """
20 | if not message:
21 | message = "success"
22 | if data:
23 | if isinstance(data, list):
24 | if len(data) > 0 and 'to_dict' in dir(data[0]):
25 | data = [i.to_dict() for i in data]
26 | elif 'to_dict' in dir(data):
27 | data = data.to_dict()
28 | return PlainTextResponse(
29 | media_type="application/json",
30 | status_code=status.HTTP_200_OK,
31 | content=json.dumps({
32 | 'success': True,
33 | 'errorCode': 0,
34 | 'message': message,
35 | 'data': data,
36 | }, cls=CustomJSONEncoder),
37 | )
38 |
39 |
40 | def json_500(data: Union[bool, list, dict, str, None] = None, message: Union[str, None] = None) -> Response:
41 | """
42 | 返回http_status=500的结果
43 | :param data: 返回结果
44 | :param message: 消息
45 | :return:
46 | """
47 | if not message:
48 | message = "success"
49 | return JSONResponse(
50 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
51 | content={
52 | 'success': False,
53 | 'errorCode': 1,
54 | 'message': message,
55 | 'data': data,
56 | }
57 | )
58 |
59 |
60 | def json_with_status(status_code: int, data: Union[bool, list, dict, str, None] = None,
61 | message: Union[str, None] = None) -> Response:
62 | """
63 | 返回自定义statuscode的结果
64 | :param data: 返回结果
65 | :param message: 消息
66 | :return:
67 | """
68 | if not message:
69 | message = "success"
70 | return JSONResponse(
71 | status_code=status_code,
72 | content={
73 | 'success': False,
74 | 'errorCode': 1,
75 | 'message': message,
76 | 'data': data,
77 | }
78 | )
79 |
--------------------------------------------------------------------------------
/papihub/config/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 配置信息处理包
3 | """
--------------------------------------------------------------------------------
/papihub/config/siteparserconfigloader.py:
--------------------------------------------------------------------------------
1 | import os
2 | from typing import List, Dict
3 |
4 | import yaml
5 |
6 | from papihub.config.types import TorrentSiteParserConfig, ParserConfig
7 | from papihub.exceptions import ParserConfigErrorException
8 |
9 |
10 | class SiteParserConfigLoader:
11 | """
12 | 站点适配文件加载器
13 | """
14 |
15 | def __init__(self, conf_path: str):
16 | if not conf_path or not os.path.exists(conf_path):
17 | raise FileNotFoundError(conf_path, f'站点适配文件路径不存在:{conf_path}')
18 | self.conf_path = conf_path
19 |
20 | def load(self) -> Dict[str, ParserConfig]:
21 | """
22 | 加载配置目录的所有站点适配文件
23 | :return:
24 | """
25 | if not self.conf_path:
26 | return {}
27 | parse_configs = {}
28 | for path, dir_list, file_list in os.walk(self.conf_path):
29 | for file_name in file_list:
30 | if os.path.splitext(file_name)[1] != '.yml':
31 | continue
32 | filepath = os.path.join(self.conf_path, file_name)
33 | try:
34 | with open(filepath, 'r', encoding='utf-8') as file:
35 | lines = file.readlines()
36 | parser_config_dict = yaml.safe_load(''.join(lines))
37 | config_type: str = parser_config_dict.get('config_type')
38 | if config_type == 'torrent_site':
39 | parse_configs[parser_config_dict.get('site_id')] = TorrentSiteParserConfig(
40 | **parser_config_dict)
41 | except Exception as e:
42 | raise ParserConfigErrorException(filepath,
43 | f'站点适配文件错误,请检查文件是否标准yml文件,没有掺杂无效信息:{filepath}',
44 | e)
45 | return parse_configs
46 |
--------------------------------------------------------------------------------
/papihub/config/types.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, List, Dict
2 | from dataclasses import dataclass
3 |
4 | from dataclasses_json import dataclass_json
5 |
6 |
7 | @dataclass_json
8 | @dataclass
9 | class ParserConfig:
10 | """
11 | 站点解析配置文件,也可以成为站点适配文件
12 | """
13 | config_type: Optional[str] = None
14 | # 站点编号
15 | site_id: Optional[str] = None
16 | # 站点名称
17 | site_name: Optional[str] = None
18 | # 站点类型
19 | site_type: Optional[str] = None
20 | # 站点域名
21 | domain: Optional[str] = None
22 | # 站点编码
23 | encoding: Optional[str] = None
24 |
25 |
26 | @dataclass
27 | class TorrentSiteParserConfig(ParserConfig):
28 | """
29 | 种子站点特有的配置信息
30 | """
31 | login: Optional[Dict] = None
32 | # 站点类目映射
33 | category_mappings: Optional[List[Dict]] = None
34 | category_id_mapping: Optional[List[Dict]] = None
35 | # 用户信息解析配置
36 | user: Optional[Dict] = None
37 | # 搜索解析配置
38 | search: Optional[Dict] = None
39 | # 独立的种子列表页解析配置
40 | get_list: Optional[Dict] = None
41 | # 标准种子解析配置
42 | torrents: Optional[Dict] = None
43 | get_detail: Optional[Dict] = None
44 |
--------------------------------------------------------------------------------
/papihub/constants.py:
--------------------------------------------------------------------------------
1 | """
2 | 常量信息类
3 | """
4 | from papihub.api.types import CateLevel1
5 |
6 | # 默认所有种子搜索的一级分类
7 | ALL_CATE_LEVEL1 = [CateLevel1.Movie,
8 | CateLevel1.TV,
9 | CateLevel1.Documentary,
10 | CateLevel1.Anime,
11 | CateLevel1.Music,
12 | CateLevel1.AV,
13 | CateLevel1.Game,
14 | CateLevel1.Other]
15 |
16 | # 全局http请求的默认头
17 | BASE_HEADERS = {
18 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'
19 | }
20 |
--------------------------------------------------------------------------------
/papihub/databases.py:
--------------------------------------------------------------------------------
1 | """
2 | 与数据库有关的操作类
3 | """
4 | import datetime
5 | import os
6 |
7 | from dataclasses_json import dataclass_json
8 | from sqlalchemy import create_engine, Column, DateTime, String, Integer, Text, select
9 | from sqlalchemy.ext.declarative import declarative_base
10 | from sqlalchemy.orm import Session
11 |
12 | from papihub import utils
13 |
14 | # WORKDIR环境变量文件夹内的db目录,为数据库文件存放目录
15 | db_path = os.path.join(os.environ.get('WORKDIR', os.path.dirname(os.path.abspath(__file__))), 'db')
16 | if not os.path.exists(db_path):
17 | os.makedirs(db_path)
18 | engine = create_engine(
19 | f'sqlite:////{db_path}/main.db?check_same_thread=False&timeout=60'
20 | )
21 | Base = declarative_base()
22 |
23 |
24 | def create_all():
25 | """
26 | 自动初始化数据库引擎和ORM框架
27 | 会自动生成模型定义的结构为数据表
28 | :return:
29 | """
30 | Base.metadata.create_all(engine)
31 |
32 |
33 | class BaseDBModel(Base):
34 | """
35 | 数据表基类,每张表的模型类继承此类
36 | """
37 | __abstract__ = True
38 | __table_args__ = {'extend_existing': True}
39 | gmt_create = Column(DateTime, nullable=False, default=datetime.datetime.now)
40 | gmt_modified = Column(DateTime, nullable=False, default=datetime.datetime.now, onupdate=datetime.datetime.now)
41 |
42 | def get_columns(self):
43 | """
44 | 返回所有字段对象
45 | :return:
46 | """
47 | return self.__table__.columns
48 |
49 | @classmethod
50 | def query(cls):
51 | session = Session(bind=engine)
52 | return session.query(cls)
53 |
54 | def get_fields(self):
55 | """
56 | 返回所有字段
57 | :return:
58 | """
59 | return self.__dict__
60 |
61 | def save(self):
62 | """
63 | 新增
64 | :return:
65 | """
66 | session = Session(bind=engine)
67 | try:
68 | session.add(self)
69 | session.commit()
70 | except BaseException as e:
71 | session.rollback()
72 | raise
73 |
74 | def update(self):
75 | """
76 | 新增
77 | :return:
78 | """
79 | session = Session(bind=engine)
80 | try:
81 | self.gmt_modified = datetime.datetime.now()
82 | session.merge(self)
83 | session.commit()
84 | except:
85 | session.rollback()
86 | raise
87 |
88 | @staticmethod
89 | def save_all(model_list):
90 | """
91 | 批量新增
92 | :param model_list:
93 | :return:
94 | """
95 | session = Session(bind=engine)
96 | try:
97 | session.add_all(model_list)
98 | session.commit()
99 | except:
100 | session.rollback()
101 | raise
102 |
103 | def delete(self):
104 | session = Session(bind=engine)
105 | try:
106 | session.commit()
107 | except:
108 | session.rollback()
109 | raise
110 |
111 | def to_dict(self, hidden_fields=None):
112 | """
113 | Json序列化
114 | :param hidden_fields: 覆盖类属性 hidden_fields
115 | :return:
116 | """
117 | model_json = {}
118 | if not hidden_fields:
119 | hidden_fields = self.__hidden_fields__
120 | if not hidden_fields:
121 | hidden_fields = []
122 | for column in self.__dict__:
123 | if column in hidden_fields:
124 | continue
125 | if hasattr(self, column):
126 | model_json[column] = utils.parse_field_value(getattr(self, column))
127 | if '_sa_instance_state' in model_json:
128 | del model_json['_sa_instance_state']
129 | return model_json
130 |
--------------------------------------------------------------------------------
/papihub/eventbus.py:
--------------------------------------------------------------------------------
1 | """
2 | 程序事件管理类
3 | 程序内产生的所有事件,都统一在此处描述
4 | """
5 | from event_bus import EventBus
6 |
7 | # 全局默认的事件处理器
8 | bus = EventBus()
9 | # 站点初始化事件
10 | EVENT_SITE_INIT = 'site:init'
11 |
--------------------------------------------------------------------------------
/papihub/exceptions.py:
--------------------------------------------------------------------------------
1 | """
2 | 程序内产生的自定义异常
3 | """
4 |
5 |
6 | class PapiHubException(Exception):
7 | pass
8 |
9 |
10 | class SiteApiErrorException(PapiHubException):
11 | pass
12 |
13 |
14 | class SiteAuthenticationFailureException(PapiHubException):
15 | def __init__(self, site_id: str, site_name: str, *args):
16 | super().__init__(*args)
17 | self.site_id = site_id
18 | self.site_name = site_name
19 |
20 |
21 | class ParserException(PapiHubException):
22 | pass
23 |
24 |
25 | class NotFoundParserException(ParserException):
26 | pass
27 |
28 |
29 | class ParserFieldException(ParserException):
30 | def __init__(self, field_name: str, *args):
31 | super().__init__(*args)
32 | self.field_name = field_name
33 |
34 |
35 | class ParserConfigErrorException(ParserException):
36 | filepath: str
37 |
38 | def __init__(self, filepath: str, *args):
39 | super().__init__(*args)
40 | self.filepath = filepath
41 |
42 |
43 | class NotAuthenticatedException(PapiHubException):
44 | def __init__(self, site_id: str, site_name: str, *args):
45 | super().__init__(*args)
46 | self.site_id = site_id
47 | self.site_name = site_name
48 |
--------------------------------------------------------------------------------
/papihub/main.py:
--------------------------------------------------------------------------------
1 | """
2 | 程序启动入口类
3 | """
4 | import json
5 | import logging.config
6 | import os
7 |
8 | from fastapi.exceptions import RequestValidationError
9 | from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
10 |
11 | from papihub.common.logging import LOGGING_CONFIG
12 |
13 | logging.config.dictConfig(LOGGING_CONFIG)
14 |
15 | import inject
16 |
17 | from papihub.config.siteparserconfigloader import SiteParserConfigLoader
18 | from papihub.manager.sitemanager import SiteManager
19 |
20 | import httpx
21 | import uvicorn
22 | from starlette.exceptions import HTTPException
23 | from fastapi import FastAPI
24 | from papihub.databases import create_all
25 |
26 | from papihub.common.response import json_200, json_500, json_with_status
27 | from papihub.routers import torrentsrouter
28 | from papihub.routers import siterouter
29 | from papihub.routers import userrouter
30 | from papihub.models import *
31 | from papihub.tasks import *
32 |
33 | log = logging.getLogger(__name__)
34 |
35 | # 初始化ORM框架
36 | create_all()
37 |
38 | app = FastAPI()
39 |
40 | # 加载所有fastapi的接口路由
41 | app.include_router(torrentsrouter.router)
42 | app.include_router(siterouter.router)
43 | app.include_router(userrouter.router)
44 |
45 |
46 | @app.get("/")
47 | async def root():
48 | """
49 | 默认首页
50 | :return:
51 | """
52 | return json_200(message='papihub server')
53 |
54 |
55 | @app.exception_handler(RequestValidationError)
56 | async def unprocessable_entity_handler(request, exc: RequestValidationError):
57 | return json_with_status(
58 | status_code=422,
59 | message='参数错误',
60 | data=dict(exc.errors())
61 | )
62 |
63 |
64 | @app.exception_handler(HTTPException)
65 | async def http_exception_handler(request, exc):
66 | return json_with_status(status_code=exc.status_code, message=exc.detail)
67 |
68 |
69 | @app.exception_handler(httpx.HTTPStatusError)
70 | async def http_status_exception_handler(request, e: httpx.HTTPStatusError):
71 | msg = e.response.json().get('error', {}).get('message')
72 | log.error('http status exception: ' + msg, exc_info=True)
73 | return json_500(message=msg)
74 |
75 |
76 | @app.exception_handler(Exception)
77 | async def universal_exception_handler(request, exc):
78 | log.error('universal_exception_handler', exc_info=True)
79 | return json_500(message=str(exc))
80 |
81 |
82 | def config(binder):
83 | """
84 | 依赖注入机制的初始化
85 | 所有通过inject使用的对象,需要提前再此绑定
86 | :param binder:
87 | :return:
88 | """
89 | loader = SiteParserConfigLoader(conf_path=os.path.join(os.environ.get('WORKDIR'), 'conf', 'parser'))
90 | binder.bind(SiteParserConfigLoader, loader)
91 | binder.bind(SiteManager, SiteManager(loader))
92 |
93 |
94 | def init_biz_data():
95 | """
96 | 初始化业务数据
97 | :return:
98 | """
99 | from papihub.models.usermodel import UserModel
100 | list_users = UserModel.query().all()
101 | if not list_users:
102 | log.info("初始化用户:admin 密码:papiadmin 请尽快登录后修改密码")
103 | from papihub.auth import get_password_hash
104 | user = UserModel(
105 | nickname="默认管理员",
106 | username='admin',
107 | password=get_password_hash("admin123"),
108 | )
109 | user.save()
110 |
111 |
112 | if __name__ == "__main__":
113 | # 加载公共全局依赖
114 | inject.configure(config)
115 | init_biz_data()
116 | uvicorn.run(app, host="0.0.0.0", port=os.environ.get("WEB_PORT", 8000))
117 |
--------------------------------------------------------------------------------
/papihub/manager/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 带有业务逻辑的公共管理器包
3 | """
4 |
--------------------------------------------------------------------------------
/papihub/manager/sitemanager.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Optional, Dict, List
3 | from papihub import utils
4 | from papihub.api.auth import Auth
5 | from papihub.api.sites.nexusphp import NexusPhp
6 | from papihub.api.torrentsite import TorrentSite
7 | from papihub.config.siteparserconfigloader import SiteParserConfigLoader
8 | from papihub.eventbus import bus, EVENT_SITE_INIT
9 | from papihub.exceptions import NotFoundParserException, ParserException, SiteAuthenticationFailureException
10 | from papihub.models import CookieStoreModel
11 | from papihub.models.sitemodel import AuthType, AuthConfig, SiteModel, SiteStatus, CookieAuthConfig, UserAuthConfig
12 |
13 | _LOGGER = logging.getLogger(__name__)
14 |
15 |
16 | class SiteManager:
17 | """
18 | 站点管理器
19 | """
20 |
21 | def __init__(self, site_parser_config_loader: SiteParserConfigLoader):
22 | self.parser_instance: Dict[str, TorrentSite] = {}
23 | self.site_parser_config_loader = site_parser_config_loader
24 | self.parser_config = site_parser_config_loader.load()
25 | self.reload_site_instance()
26 |
27 | def reload_site_instance(self):
28 | """
29 | 重新加载站点实例
30 | 此处加载站点实例暂时不做强制验证,避免应用初始化过慢
31 | :return:
32 | """
33 | _LOGGER.info("开始初始化站点配置实例")
34 | site_list = SiteModel.list()
35 | if not site_list:
36 | return
37 | for site in site_list:
38 | self.init_site(site.site_id, test_login=False)
39 | _LOGGER.info(f"站点配置实例初始化完成,共{len(site_list)}个站点")
40 |
41 | def get_instance(self, site_id: List[str]):
42 | """
43 | 获取站点实例
44 | :param site_id: 站点唯一编号
45 | :return:
46 | """
47 | if not site_id:
48 | return []
49 | return [self.parser_instance.get(s) for s in site_id if self.parser_instance.get(s) is not None]
50 |
51 | def add(self, site_id: str, auth_type: AuthType, auth_config: AuthConfig):
52 | """
53 | 添加站点配置信息
54 | 配置会存储在数据库内,方便后期加载到程序
55 | :param site_id: 站点唯一编号
56 | :param auth_type: 授权类型
57 | :param auth_config: 授权详细配置
58 | :return:
59 | """
60 | if site_id not in self.parser_config:
61 | raise NotFoundParserException(f'站点编号不存在:{site_id}')
62 | parser_config = self.parser_config[site_id]
63 | site = SiteModel.get_by_site_id(site_id)
64 | if site:
65 | raise Exception(f'站点已存在:{site_id}')
66 | site = SiteModel(
67 | site_id=site_id,
68 | display_name=parser_config.site_name,
69 | auth_type=auth_type.value,
70 | auth_config=auth_config.to_json(),
71 | site_status=SiteStatus.Pending.value
72 | )
73 | site.save()
74 | # 异步做后续的处理
75 | bus.emit(EVENT_SITE_INIT, site_id=site_id, threads=True)
76 |
77 | def init_site(self, site_id: str, test_login: Optional[bool] = True):
78 | """
79 | 初始化站信息
80 | 初始化后会将状态同步存储到数据库内
81 | :param site_id: 站点唯一编号
82 | :param test_login: 是否测试登录
83 | :return:
84 | """
85 | site_model = SiteModel.get_by_site_id(site_id)
86 | if not site_model:
87 | site_model.site_status = SiteStatus.Error.value
88 | site_model.status_message = f'站点不存在:{site_id}'
89 | site_model.update()
90 | raise Exception(site_model.status_message)
91 | parser_config = self.parser_config[site_id]
92 | _LOGGER.info(f'开始初始化站点:{parser_config.site_name}')
93 | torrent_site: Optional[TorrentSite] = None
94 | if 'nexusphp' == parser_config.site_type:
95 | torrent_site = NexusPhp(parser_config)
96 | if not torrent_site:
97 | site_model.site_status = SiteStatus.Error.value
98 | site_model.status_message = f'站点类型不支持:{parser_config.site_type}'
99 | site_model.update()
100 | raise ParserException(site_model.status_message)
101 |
102 | try:
103 | if isinstance(torrent_site, Auth):
104 | if site_model.auth_type == AuthType.Cookies.value:
105 | _LOGGER.info(f'使用现有cookie授权:{parser_config.site_name}')
106 | auth_config: CookieAuthConfig = CookieAuthConfig.from_json(site_model.auth_config)
107 | torrent_site.auth_with_cookies(auth_config.cookies)
108 | if test_login:
109 | if not torrent_site.test_login():
110 | site_model.site_status = SiteStatus.Error.value
111 | site_model.status_message = f'站点认证配置错误:Cookies无效'
112 | site_model.update()
113 | raise SiteAuthenticationFailureException(site_id, parser_config.site_name,
114 | site_model.status_message)
115 | elif site_model.auth_type == AuthType.UserAuth.value:
116 | _LOGGER.info(f'使用用户名密码授权:{parser_config.site_name}')
117 | cookie_store = CookieStoreModel.get_cookies(site_id)
118 | auth = False
119 | if cookie_store:
120 | _LOGGER.info(f'检测到存在cookie历史,使用现有cookie授权:{parser_config.site_name}')
121 | # 优先用现存cookie授权
122 | torrent_site.auth_with_cookies(cookie_store.cookies)
123 | if test_login:
124 | auth = torrent_site.test_login()
125 | else:
126 | auth = True
127 | if not auth:
128 | _LOGGER.info(f'使用现有cookie授权失败,采用用户名密码授权:{parser_config.site_name}')
129 | # 如果已有cookie授权失败,使用用户名密码授权
130 | auth_config: UserAuthConfig = UserAuthConfig.from_json(site_model.auth_config)
131 | cookie_str = torrent_site.auth(auth_config.username, auth_config.password)
132 | expire_time = utils.parse_cookies_expire_time(cookie_str)
133 | ck_item = CookieStoreModel(
134 | site_id=site_id,
135 | cookies=cookie_str,
136 | expire_time=expire_time
137 | )
138 | ck_item.save()
139 | else:
140 | site_model.site_status = SiteStatus.Error.value
141 | site_model.status_message = f'站点认证配置错误:{site_model.auth_config}'
142 | site_model.update()
143 | raise SiteAuthenticationFailureException(site_id, parser_config.site_name)
144 | except SiteAuthenticationFailureException as e:
145 | raise e
146 | except Exception as e:
147 | site_model.site_status = SiteStatus.Error.value
148 | site_model.status_message = f'站点认证配置错误:{str(e)}'
149 | site_model.update()
150 | raise SiteAuthenticationFailureException(site_id, parser_config.site_name, e)
151 | self.parser_instance.update({site_id: torrent_site})
152 | SiteModel.update_status(site_id, SiteStatus.Active)
153 | _LOGGER.info(f'站点初始化完成:{parser_config.site_name}')
154 |
--------------------------------------------------------------------------------
/papihub/models/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | ORM框架数据持久层的模型
3 | """
4 | from .cookiestoremodel import CookieStoreModel
--------------------------------------------------------------------------------
/papihub/models/cookiestoremodel.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, List
2 |
3 | from papihub.databases import *
4 |
5 |
6 | class CookieStoreModel(BaseDBModel):
7 | """
8 | 存储站点用的一些Cookie
9 | """
10 | __tablename__ = 'cookie_store'
11 |
12 | id = Column(Integer, primary_key=True, autoincrement=True, comment='id')
13 | site_id = Column(String, comment='站点编号', nullable=False)
14 | cookies = Column(String, comment='Cookie字符串', nullable=False)
15 | expire_time = Column(DateTime, nullable=False, default=datetime.datetime.now, comment='过期时间')
16 |
17 | @staticmethod
18 | def get_cookies(site_id: str) -> Optional["CookieStoreModel"]:
19 | """
20 | 根据站点唯一编号获取存储的cookie信息,只返回首条结果
21 | :param site_id:
22 | :return:
23 | """
24 | return CookieStoreModel.query().filter(
25 | (CookieStoreModel.site_id == site_id) & (CookieStoreModel.expire_time > datetime.datetime.now())).first()
26 |
--------------------------------------------------------------------------------
/papihub/models/sitemodel.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from dataclasses_json import dataclass_json
3 | from enum import Enum
4 | from typing import Optional, List
5 |
6 | from papihub.databases import *
7 |
8 |
9 | @dataclass
10 | class AuthType(Enum):
11 | Cookies = 'cookies'
12 | UserAuth = 'user_auth'
13 |
14 | @staticmethod
15 | def from_str(value: str):
16 | if value == 'cookies':
17 | return AuthType.Cookies
18 | elif value == 'user_auth':
19 | return AuthType.UserAuth
20 | else:
21 | raise NotImplementedError
22 |
23 |
24 | @dataclass_json
25 | @dataclass
26 | class AuthConfig:
27 | user_agent: Optional[str] = None
28 |
29 |
30 | @dataclass_json
31 | @dataclass
32 | class CookieAuthConfig(AuthConfig):
33 | cookies: Optional[str] = None
34 |
35 |
36 | @dataclass_json
37 | @dataclass
38 | class UserAuthConfig(AuthConfig):
39 | username: Optional[str] = None
40 | password: Optional[str] = None
41 |
42 |
43 | @dataclass
44 | class SiteStatus(Enum):
45 | Pending = 'pending'
46 | Active = 'active'
47 | Error = 'error'
48 |
49 |
50 | class SiteModel(BaseDBModel):
51 | """
52 | 站点信息管理
53 | """
54 | __tablename__ = 'site'
55 | id = Column(Integer, primary_key=True, autoincrement=True, comment='id')
56 | site_id = Column(String, comment='站点编号', nullable=False)
57 | display_name = Column(String, comment='站点显示名称', nullable=False)
58 | auth_type = Column(String, comment='站点认证类型', nullable=False)
59 | auth_config = Column(String, comment='站点认证配置', nullable=False)
60 | site_status = Column(String, comment='站点状态', nullable=False, default='pending')
61 | status_message = Column(String, comment='状态信息', nullable=True)
62 | last_active_time = Column(DateTime, nullable=False, default=datetime.datetime.now)
63 |
64 | @staticmethod
65 | def list() -> List["SiteModel"]:
66 | """
67 | 获取所有站点信息
68 | :return:
69 | """
70 | return SiteModel.query().all()
71 |
72 | @staticmethod
73 | def get_by_site_id(site_id: str) -> "SiteModel":
74 | """
75 | 根据站点唯一编号获取站点信息
76 | :param site_id:
77 | :return:
78 | """
79 | return SiteModel.query().filter(SiteModel.site_id == site_id).first()
80 |
81 | @staticmethod
82 | def update_status(site_id: str, status: SiteStatus, message: Optional[str] = None):
83 | """
84 | 更新站点状态
85 | :param site_id: 站点唯一编号
86 | :param status: 状态码
87 | :param message: 状态相关消息
88 | :return:
89 | """
90 | site = SiteModel.get_by_site_id(site_id)
91 | if site:
92 | site.site_status = status.value
93 | site.status_message = message
94 | site.update()
95 |
--------------------------------------------------------------------------------
/papihub/models/usermodel.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 |
3 | from papihub.databases import *
4 |
5 | from papihub.databases import BaseDBModel
6 |
7 |
8 | class UserModel(BaseDBModel):
9 | """
10 | 存储站点用的一些Cookie
11 | """
12 | __tablename__ = 'user'
13 | __hidden_fields__ = ['password']
14 | id = Column(Integer, primary_key=True, autoincrement=True, comment='id')
15 | nickname = Column(String(64), nullable=False)
16 | username = Column(String(64), nullable=False)
17 | password = Column(String(256), nullable=False)
18 |
19 | @staticmethod
20 | def get_by_username(username: str) -> "UserModel":
21 | """
22 | 根据用户名获取用户信息,只返回首条结果
23 | :param username:
24 | :return:
25 | """
26 | return UserModel.query().filter(UserModel.username == username).first()
27 |
--------------------------------------------------------------------------------
/papihub/routers/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | http接口的控制器
3 | """
4 |
--------------------------------------------------------------------------------
/papihub/routers/siterouter.py:
--------------------------------------------------------------------------------
1 | import inject
2 | from fastapi import APIRouter, Depends
3 |
4 | from papihub.auth import get_current_user
5 | from papihub.common.response import json_200
6 | from pydantic import BaseModel
7 |
8 | from papihub.manager.sitemanager import SiteManager
9 | from papihub.models.sitemodel import AuthType, CookieAuthConfig, UserAuthConfig, SiteModel
10 | from papihub.models.usermodel import UserModel
11 |
12 | router = APIRouter()
13 |
14 |
15 | class AddParam(BaseModel):
16 | """
17 | 添加站点接口的参数
18 | """
19 | site_id: str
20 | auth_type: str
21 | auth_config: dict
22 |
23 |
24 | @router.get("/api/site/list_parsers")
25 | def list_parsers():
26 | site_manager = inject.instance(SiteManager)
27 | return json_200(
28 | message='获取站点解析器配置成功',
29 | data=[{
30 | 'site_id': x.site_id,
31 | 'site_name': x.site_name,
32 | 'domain': x.domain,
33 | 'site_type': x.site_type,
34 | 'config_type': x.config_type,
35 | 'encoding': x.encoding,
36 | } for x in site_manager.parser_config.values()]
37 | )
38 |
39 |
40 | @router.get("/api/site/list")
41 | def list():
42 | site_list = SiteModel.list()
43 | site_manager = inject.instance(SiteManager)
44 | results = []
45 | for x in site_list:
46 | config = site_manager.parser_config.get(x.site_id)
47 | results.append({
48 | 'site_id': x.site_id,
49 | 'site_name': x.display_name,
50 | 'auth_type': x.auth_type,
51 | 'site_status': x.site_status,
52 | 'status_message': x.status_message,
53 | 'domain': config.domain,
54 | 'last_active_time': x.last_active_time
55 | })
56 | return json_200(
57 | message='获取站点配置成功',
58 | data=results
59 | )
60 |
61 |
62 | @router.post("/api/site/add")
63 | def add(param: AddParam, user: UserModel = Depends(get_current_user)):
64 | """
65 | 添加站点信息到数据库
66 | :param param:
67 | :return:
68 | """
69 | site_manager = inject.instance(SiteManager)
70 | auth_type = AuthType.from_str(param.auth_type)
71 | auth_config = None
72 | if auth_type is AuthType.Cookies:
73 | auth_config = CookieAuthConfig.from_dict(param.auth_config)
74 | elif auth_type is AuthType.UserAuth:
75 | auth_config = UserAuthConfig.from_dict(param.auth_config)
76 | site_manager.add(
77 | site_id=param.site_id,
78 | auth_type=auth_type,
79 | auth_config=auth_config
80 | )
81 | return json_200(
82 | message='添加站点配置成功',
83 | )
84 |
--------------------------------------------------------------------------------
/papihub/routers/torrentsrouter.py:
--------------------------------------------------------------------------------
1 | from typing import List, Optional
2 |
3 | import inject
4 | from fastapi import APIRouter
5 | from pydantic import BaseModel
6 |
7 | from papihub.api.concurrenttorrentsite import ConcurrentTorrentSite
8 | from papihub.api.types import CateLevel1
9 | from papihub.common.response import json_200
10 | from papihub.manager.sitemanager import SiteManager
11 |
12 | router = APIRouter()
13 |
14 |
15 | class SearchParam(BaseModel):
16 | site_id: Optional[List[str]]
17 | keyword: str
18 | imdb_id: Optional[str] = None
19 | cate_level1: Optional[List[CateLevel1]] = None
20 |
21 |
22 | @router.post("/api/torrents/search")
23 | def search(param: SearchParam):
24 | """
25 | 搜索站点种子信息
26 | :param param:
27 | :return:
28 | """
29 | site_manager = inject.instance(SiteManager)
30 | cts = ConcurrentTorrentSite(
31 | torrent_sites=site_manager.get_instance(param.site_id)
32 | )
33 | results = cts.search(
34 | keyword=param.keyword,
35 | imdb_id=param.imdb_id,
36 | cate_level1=param.cate_level1
37 | )
38 | return json_200(data=results)
39 |
--------------------------------------------------------------------------------
/papihub/routers/userrouter.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 | from typing import Optional
3 |
4 | from fastapi import Depends, status, APIRouter
5 | from fastapi.security import OAuth2PasswordRequestForm
6 |
7 | from papihub.auth import create_access_token, verify_password, ACCESS_TOKEN_EXPIRE_MINUTES, get_current_user
8 | from papihub.common.response import json_with_status, json_200
9 | from papihub.models.usermodel import UserModel
10 |
11 | router = APIRouter()
12 |
13 |
14 | def auth(username: str, password: str) -> Optional[UserModel]:
15 | user = UserModel.get_by_username(username)
16 | if not user:
17 | return
18 | # 密码要加盐,防范彩虹表攻击
19 | if not verify_password(password, user.password):
20 | return
21 | return user
22 |
23 |
24 | @router.get("/api/user/profile")
25 | def profile(user: UserModel = Depends(get_current_user)):
26 | return json_200(data=user)
27 |
28 |
29 | @router.post("/api/user/get_token")
30 | def get_token(form: OAuth2PasswordRequestForm = Depends()):
31 | user = auth(form.username, form.password)
32 | if not user:
33 | return json_with_status(status.HTTP_401_UNAUTHORIZED, message='用户名或密码错误')
34 | token = create_access_token({"sub": user.username}, timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
35 | return json_200(data={"access_token": token, "token_type": "bearer"}, message='登录成功')
36 |
--------------------------------------------------------------------------------
/papihub/tasks/__init__.py:
--------------------------------------------------------------------------------
1 | from .sitetask import *
2 |
--------------------------------------------------------------------------------
/papihub/tasks/sitetask.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import inject
4 |
5 | from papihub.eventbus import bus, EVENT_SITE_INIT
6 | from papihub.manager.sitemanager import SiteManager
7 |
8 | _LOGGER = logging.getLogger(__name__)
9 |
10 |
11 | @bus.on(EVENT_SITE_INIT)
12 | def on_site_init(site_id: str):
13 | """
14 | 接收站点初始化事件对站点授权信息进行验证,实例初始化。
15 | :param site_id: 站点唯一编号
16 | :return:
17 | """
18 | _LOGGER.info("站带初始化事件:%s", site_id)
19 | site_manager = inject.instance(SiteManager)
20 | site_manager.init_site(site_id)
21 |
--------------------------------------------------------------------------------
/papihub/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | 所有的工具函数
3 | """
4 | import datetime
5 | import decimal
6 | import json
7 | from email.utils import parsedate_to_datetime
8 | from enum import Enum
9 | from http.cookies import SimpleCookie
10 | from typing import _GenericAlias, List, Union, Dict
11 |
12 | import emoji
13 |
14 |
15 | def _list_value(value):
16 | if isinstance(value, str):
17 | if value[0] in ['{', '[']:
18 | return json.loads(value)
19 | else:
20 | return value.split(',')
21 | else:
22 | return list(value)
23 |
24 |
25 | def _dict_value(value):
26 | if isinstance(value, str):
27 | return json.loads(value)
28 | else:
29 | return value
30 |
31 |
32 | def parse_field_value(field_value):
33 | if isinstance(field_value, decimal.Decimal): # Decimal -> float
34 | field_value = round(float(field_value), 2)
35 | elif isinstance(field_value, datetime.datetime): # datetime -> str
36 | field_value = str(field_value)
37 | elif isinstance(field_value, list):
38 | field_value = [parse_field_value(i) for i in field_value]
39 | if hasattr(field_value, 'to_json'):
40 | field_value = field_value.to_json()
41 | elif isinstance(field_value, Enum):
42 | field_value = field_value.name
43 | elif isinstance(field_value, Dict):
44 | val = {}
45 | for key_ in field_value:
46 | val[key_] = parse_field_value(field_value[key_])
47 | field_value = val
48 | return field_value
49 |
50 |
51 | def parse_value(func, value, default_value=None):
52 | if value is not None:
53 | if func == bool:
54 | if value in (1, True, "1", "true"):
55 | return True
56 | elif value in (0, False, "0", "false"):
57 | return False
58 | else:
59 | raise ValueError(value)
60 |
61 | elif func in (int, float):
62 | try:
63 | if isinstance(value, str):
64 | value = value.replace(',', '')
65 | return func(value)
66 | except ValueError:
67 | return float('nan')
68 | elif func == datetime.datetime:
69 | if isinstance(value, datetime.datetime):
70 | return value
71 | elif isinstance(value, str):
72 | if value:
73 | return datetime.datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
74 | else:
75 | return None
76 | else:
77 | return None
78 | elif func in [Dict, dict]:
79 | return _dict_value(value)
80 | elif func in [List, list]:
81 | return _list_value(value)
82 | elif isinstance(func, _GenericAlias):
83 | if func.__origin__ in [List, list]:
84 | list_ = _list_value(value)
85 | res = []
86 | for x in list_:
87 | res.append(parse_value(func.__args__[0], x))
88 | return res
89 | elif func.__origin__ == Union:
90 | return parse_value(func.__args__[0], value)
91 | return func(value)
92 | else:
93 | return default_value
94 |
95 |
96 | def trim_emoji(text):
97 | """
98 | 去掉字符串中的emoji表情
99 | :param text:
100 | :return:
101 | """
102 | return emoji.demojize(text)
103 |
104 |
105 | def trans_size_str_to_mb(size: str):
106 | """
107 | 把一个字符串格式的文件尺寸单位,转换成MB单位的标准数字
108 | :param size:
109 | :return:
110 | """
111 | if not size:
112 | return 0.0
113 | s = None
114 | u = None
115 | if size.find(' ') != -1:
116 | arr = size.split(' ')
117 | s = arr[0]
118 | u = arr[1]
119 | else:
120 | if size.endswith('GB'):
121 | s = size[0:-2]
122 | u = 'GB'
123 | elif size.endswith('GiB'):
124 | s = size[0:-3]
125 | u = 'GB'
126 | elif size.endswith('MB'):
127 | s = size[0:-2]
128 | u = 'MB'
129 | elif size.endswith('MiB'):
130 | s = size[0:-3]
131 | u = 'MB'
132 | elif size.endswith('KB'):
133 | s = size[0:-2]
134 | u = 'KB'
135 | elif size.endswith('KiB'):
136 | s = size[0:-3]
137 | u = 'KB'
138 | elif size.endswith('TB'):
139 | s = size[0:-2]
140 | u = 'TB'
141 | elif size.endswith('TiB'):
142 | s = size[0:-3]
143 | u = 'TB'
144 | elif size.endswith('PB'):
145 | s = size[0:-2]
146 | u = 'PB'
147 | elif size.endswith('PiB'):
148 | s = size[0:-3]
149 | u = 'PB'
150 | if not s:
151 | return 0.0
152 | if s.find(',') != -1:
153 | s = s.replace(',', '')
154 | return trans_unit_to_mb(float(s), u)
155 |
156 |
157 | def trans_unit_to_mb(size: float, unit: str) -> float:
158 | """
159 | 按文件大小尺寸规格,转换成MB单位的数字
160 | :param size:
161 | :param unit:
162 | :return:
163 | """
164 | if unit == 'GB' or unit == 'GiB':
165 | return round(size * 1024, 2)
166 | elif unit == 'MB' or unit == 'MiB':
167 | return round(size, 2)
168 | elif unit == 'KB' or unit == 'KiB':
169 | return round(size / 1024, 2)
170 | elif unit == 'TB' or unit == 'TiB':
171 | return round(size * 1024 * 1024, 2)
172 | elif unit == 'PB' or unit == 'PiB':
173 | return round(size * 1024 * 1024 * 1024, 2)
174 | else:
175 | return size
176 |
177 |
178 | def parse_cookies_expire_time(cookie_str: str):
179 | cookie = SimpleCookie()
180 | cookie.load(cookie_str)
181 | for key, morsel in cookie.items():
182 | if morsel.get('expires'):
183 | return parsedate_to_datetime(morsel['expires'])
184 | return
185 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | uvicorn[standard]
2 | fastapi
3 | SQLAlchemy
4 | PyYAML
5 | Jinja2
6 | httpx
7 | pyquery==2.0.0
8 | dataclasses-json
9 | inject
10 | event-bus
11 | emoji==2.2.0
12 | pydantic
13 | python-jose[cryptography]
14 | passlib[bcrypt]
15 | python-multipart
--------------------------------------------------------------------------------
/tests/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/tests/api/__init__.py
--------------------------------------------------------------------------------
/tests/api/test_nexusphp.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from papihub.api.sites.nexusphp import NexusPhp
4 | from papihub.api.types import CateLevel1
5 | from tests.config.test_siteparserconfigload import test_load_parser
6 |
7 | parsers = test_load_parser()
8 |
9 |
10 | def test_search():
11 | api = NexusPhp(parsers.get('mteam'))
12 | asyncio.run(api.auth('xxx', 'xxx'))
13 | torrents = asyncio.run(api.search(keyword='潜行者', cate_level1_list=[CateLevel1.TV]))
14 |
--------------------------------------------------------------------------------
/tests/conf/parser/mteam.yml:
--------------------------------------------------------------------------------
1 | config_type: torrent_site
2 | site_id: mteam
3 | site_name: 馒头
4 | site_type: nexusphp
5 | domain: https://kp.m-team.cc
6 | encoding: UTF-8
7 |
8 |
9 | category_mappings:
10 | - { id: 401, cate_level1: Movie, cate_level2: Movies/SD, cate_level2_desc: "Movie(電影)/SD" }
11 | - { id: 419, cate_level1: Movie, cate_level2: Movies/HD, cate_level2_desc: "Movie(電影)/HD" }
12 | - { id: 420, cate_level1: Movie, cate_level2: Movies/DVD, cate_level2_desc: "Movie(電影)/DVDiSo" }
13 | - { id: 421, cate_level1: Movie, cate_level2: Movies/BluRay, cate_level2_desc: "Movie(電影)/Blu-Ray" }
14 | - { id: 439, cate_level1: Movie, cate_level2: Movies/Other, cate_level2_desc: "Movie(電影)/Remux" }
15 | - { id: 403, cate_level1: TV, cate_level2: TV/SD, cate_level2_desc: "TV Series(影劇/綜藝)/SD" }
16 | - { id: 402, cate_level1: TV, cate_level2: TV/HD, cate_level2_desc: "TV Series(影劇/綜藝)/HD" }
17 | - { id: 435, cate_level1: TV, cate_level2: TV/SD, cate_level2_desc: "TV Series(影劇/綜藝)/DVDiSo" }
18 | - { id: 438, cate_level1: TV, cate_level2: TV/HD, cate_level2_desc: "TV Series(影劇/綜藝)/BD" }
19 | - { id: 404, cate_level1: Documentary, cate_level2: TV/Documentary, cate_level2_desc: "紀錄教育" }
20 | - { id: 405, cate_level1: Anime, cate_level2: TV/Anime, cate_level2_desc: "Anime(動畫)" }
21 | - { id: 407, cate_level1: TV, cate_level2: TV/Sport, cate_level2_desc: "Sports(運動)" }
22 | - { id: 422, cate_level1: Other, cate_level2: PC/0day, cate_level2_desc: "Software(軟體)" }
23 | - { id: 423, cate_level1: Game, cate_level2: PC/Games, cate_level2_desc: "PCGame(PC遊戲)" }
24 | - { id: 427, cate_level1: Other, cate_level2: Books, cate_level2_desc: "eBook(電子書)" }
25 | - { id: 409, cate_level1: Other, cate_level2: Other, cate_level2_desc: "Misc(其他)" }
26 | - { id: 406, cate_level1: Music, cate_level2: Audio/Video, cate_level2_desc: "MV(演唱)" }
27 | - { id: 408, cate_level1: Music, cate_level2: Audio/Other, cate_level2_desc: "Music(AAC/ALAC)" }
28 | - { id: 434, cate_level1: Music, cate_level2: Audio, cate_level2_desc: "Music(無損)" }
29 | - { id: 410, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "AV(有碼)/HD Censored" }
30 | - { id: 429, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "AV(無碼)/HD Uncensored" }
31 | - { id: 424, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "AV(有碼)/SD Censored" }
32 | - { id: 430, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "AV(無碼)/SD Uncensored" }
33 | - { id: 426, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "AV(無碼)/DVDiSo Uncensored" }
34 | - { id: 437, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "AV(有碼)/DVDiSo Censored" }
35 | - { id: 431, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "AV(有碼)/Blu-Ray Censored" }
36 | - { id: 432, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "AV(無碼)/Blu-Ray Uncensored" }
37 | - { id: 436, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "AV(網站)/0Day" }
38 | - { id: 425, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "IV(寫真影集)/Video Collection" }
39 | - { id: 433, cate_level1: AV, cate_level2: XXX, cate_level2_desc: "IV(寫真圖集)/Picture Collection" }
40 | - { id: 411, cate_level1: Game, cate_level2: XXX, cate_level2_desc: "H-Game(遊戲)" }
41 | - { id: 412, cate_level1: Anime, cate_level2: XXX, cate_level2_desc: "H-Anime(動畫)" }
42 | - { id: 413, cate_level1: Anime, cate_level2: XXX, cate_level2_desc: "H-Comic(漫畫)" }
43 |
44 | user:
45 | path: https://kp.m-team.cc/index.php
46 | item:
47 | selector: table[id="info_block"]
48 | fields:
49 | uid:
50 | default_value: 0
51 | selector: a[href^="userdetails.php?id="]
52 | attribute: href
53 | filters:
54 | - name: re_search
55 | args: [ '\d+', 0 ]
56 | user_group:
57 | default_value: ''
58 | selector: a[href^="userdetails.php?id="]
59 | attribute: class
60 | username:
61 | default_value: '未知用户名'
62 | selector: a[href^="userdetails.php?id="]
63 | share_ratio:
64 | default_value: ''
65 | selector: font[class="color_ratio"]
66 | method: next_sibling
67 | filters:
68 | - name: replace
69 | args: [ '無限','inf' ]
70 | uploaded:
71 | default_value: 0
72 | selector: font[class="color_uploaded"]
73 | method: next_sibling
74 | filters:
75 | - name: replace
76 | args: [ '無限','inf' ]
77 | downloaded:
78 | default_value: 0
79 | selector: font[class="color_downloaded"]
80 | method: next_sibling
81 | filters:
82 | - name: replace
83 | args: [ '無限','inf' ]
84 | seeding:
85 | default_value: 0
86 | selector: img[alt="Torrents seeding"]
87 | method: next_sibling
88 | leeching:
89 | default_value: 0
90 | selector: img[alt="Torrents leeching"]
91 | method: next_sibling
92 | vip_group:
93 | case:
94 | a[class^="VIP"]: true
95 | "*": false
96 | search:
97 | paths:
98 | - path: torrents.php
99 | categories: [ "!", 410, 429, 424, 430, 426, 437, 431, 432, 436, 425, 433, 411, 412, 413, 406, 408, 434 ]
100 | - path: adult.php
101 | categories: [ 410, 429, 424, 430, 426, 437, 431, 432, 436, 425, 433, 411 ]
102 | - path: music.php
103 | categories: [ 406, 408, 434 ]
104 | query:
105 | $raw: "{% for c in query.cates %}cat{{c}}=1&{% endfor %}"
106 | search: "{% if query.imdb_id %}{{query.imdb_id}}{%else%}{{query.keyword}}{% endif %}"
107 | # 0 incldead, 1 active, 2 dead
108 | incldead: 1
109 | # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30%
110 | spstate: "{% if query.free %}2{% else %}0{% endif %}"
111 | # 0 title, 1 descr, 3 uploader, 4 imdburl (searching imdburl does not work with tt1234567, but descr is good)
112 | search_area: "{% if query.imdb_id %}4{% else %}0{%endif%}"
113 | # 0 AND, 1 OR, 2 exact
114 | search_mode: 0
115 | sort: "{{ query.sort }}"
116 | type: "{{ query.type }}"
117 | page: "{{ query.page }}"
118 |
119 | torrents:
120 | list:
121 | selector: table.torrents > tr:has(table.torrentname)
122 | fields:
123 | id:
124 | selector: a[href^="details.php?id="]
125 | attribute: href
126 | filters:
127 | - name: re_search
128 | args: [ '\d+', 0 ]
129 | title_default:
130 | # shortened for long release names
131 | selector: a[href^="details.php?id="] > b
132 | title_optional:
133 | # not available if IMDB tooltips are turned on
134 | optional: true
135 | selector: a[title][href^="details.php?id="]
136 | attribute: title
137 | title:
138 | text: "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
139 | category:
140 | selector: a[href^="?cat="]
141 | attribute: href
142 | filters:
143 | - name: querystring
144 | args: cat
145 | details:
146 | selector: a[href^="details.php?id="]
147 | attribute: href
148 | download:
149 | selector: a[href^="download.php?id="]
150 | attribute: href
151 | poster:
152 | selector: img[alt="torrent thumbnail"]
153 | attribute: src
154 | filters:
155 | - name: replace
156 | args: [ "pic/nopic.jpg", "" ]
157 | imdbid:
158 | selector: a[href*="imdb.com/title/tt"]
159 | attribute: href
160 | filters:
161 | - name: re_search
162 | args: [ 'tt\d+',0 ]
163 | size:
164 | selector: td.rowfollow:nth-last-child(6)
165 | grabs:
166 | selector: td.rowfollow:nth-last-child(3)
167 | seeders:
168 | selector: td.rowfollow:nth-last-child(5)
169 | leechers:
170 | selector: td.rowfollow:nth-last-child(4)
171 | date_added:
172 | selector: td.rowfollow:nth-last-child(7) > span[title]
173 | optional: true
174 | attribute: title
175 | date_elapsed:
176 | selector: tr > td > span[title]
177 | attribute: title
178 | optional: true
179 | date:
180 | text: "{% if fields['date_elapsed'] or fields['date_added'] %}{{ fields['date_elapsed'] if fields['date_elapsed'] else fields['date_added'] }}{% else %}now{% endif %}"
181 | filters:
182 | - name: dateparse
183 | args: "%Y-%m-%d %H:%M:%S"
184 | downloadvolumefactor:
185 | case:
186 | img.pro_free: 0
187 | img.pro_free2up: 0
188 | img.pro_50pctdown: 0.5
189 | img.pro_50pctdown2up: 0.5
190 | img.pro_30pctdown: 0.3
191 | "*": 1
192 | uploadvolumefactor:
193 | case:
194 | img.pro_50pctdown2up: 2
195 | img.pro_free2up: 2
196 | img.pro_2up: 2
197 | "*": 1
198 | free_deadline:
199 | default_value: "{% if fields['downloadvolumefactor']==0 %}{{max_time}}{% endif%}"
200 | default_value_format: '%Y-%m-%d %H:%M:%S.%f'
201 | selector: span[style="font-weight:normal"]
202 | filters:
203 | - name: re_search
204 | args: [ '(?:限時:\s*)((?:\d+日)?(?:\d+時)?(?:\d+分)?)',1 ]
205 | - name: date_elapsed_parse
206 | description:
207 | selector: table.torrentname > tr > td.embedded
208 | contents: -1
--------------------------------------------------------------------------------
/tests/conf/parser/ourbits.yml:
--------------------------------------------------------------------------------
1 | config_type: torrent_site
2 | site_id: ourbits
3 | site_name: 我堡
4 | site_type: nexusphp
5 | domain: https://ourbits.club/
6 | encoding: UTF-8
7 |
8 | category_mappings:
9 | - { id: 401, cate_level1: Movie, cate_level2: "Movies", cate_level2_desc: "Movies" }
10 | - { id: 402, cate_level1: Movie, cate_level2: "Movies-3D", cate_level2_desc: "Movies-3D" }
11 | - { id: 419, cate_level1: Music, cate_level2: "Concert", cate_level2_desc: "Concert" }
12 | - { id: 412, cate_level1: TV, cate_level2: "TV-Episode", cate_level2_desc: "TV-Episode" }
13 | - { id: 405, cate_level1: TV, cate_level2: "TV-Pack", cate_level2_desc: "TV-Pack" }
14 | - { id: 413, cate_level1: TV, cate_level2: "TV-Show", cate_level2_desc: "TV-Show" }
15 | - { id: 410, cate_level1: Documentary, cate_level2: "Documentary", cate_level2_desc: "Documentary" }
16 | - { id: 411, cate_level1: Anime, cate_level2: "Animation", cate_level2_desc: "Animation" }
17 | - { id: 415, cate_level1: TV, cate_level2: "Sports", cate_level2_desc: "Sports" }
18 | - { id: 414, cate_level1: Music, cate_level2: "Music-Video", cate_level2_desc: "Music-Video" }
19 | - { id: 414, cate_level1: Music, cate_level2: "Music-Video", cate_level2_desc: "Music-Video" }
20 | - { id: 416, cate_level1: Music, cate_level2: "Music", cate_level2_desc: "Music" }
21 | user:
22 | path: https://ourbits.club/rules.php
23 | item:
24 | selector: table[id="info_block"]
25 | fields:
26 | uid:
27 | default_value: 0
28 | selector: a[href^="userdetails.php?id="]
29 | attribute: href
30 | filters:
31 | - name: re_search
32 | args: [ '\d+', 0 ]
33 | user_group:
34 | default_value: ''
35 | selector: a[href^="userdetails.php?id="]
36 | attribute: class
37 | username:
38 | default_value: '未知用户名'
39 | selector: a[href^="userdetails.php?id="]
40 | share_ratio:
41 | default_value: ''
42 | selector: span[class="color_ratio"]
43 | method: next_sibling
44 | filters:
45 | - name: replace
46 | args: [ '无限','inf' ]
47 | uploaded:
48 | default_value: 0
49 | selector: font[class="color_uploaded"]
50 | method: next_sibling
51 | filters:
52 | - name: replace
53 | args: [ '无限','inf' ]
54 | downloaded:
55 | default_value: 0
56 | selector: font[class="color_downloaded"]
57 | method: next_sibling
58 | filters:
59 | - name: replace
60 | args: [ '无限','inf' ]
61 | seeding:
62 | default_value: 0
63 | selector: img[alt="Torrents seeding"]
64 | method: next_sibling
65 | leeching:
66 | default_value: 0
67 | selector: img[alt="Torrents leeching"]
68 | method: next_sibling
69 | vip_group:
70 | case:
71 | a[class^="VIP"]: true
72 | "*": false
73 | search:
74 | paths:
75 | - path: torrents.php
76 | method: get
77 | query:
78 | $raw: "{% for c in query.cates %}cat{{c}}=1&{% endfor %}"
79 | search: "{% if query.imdb_id %}{{query.imdb_id}}{%else%}{{query.keyword}}{% endif %}"
80 | # 0 incldead, 1 active, 2 dead
81 | incldead: 1
82 | # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30%
83 | spstate: "{% if query.free %}2{% else %}0{% endif %}"
84 | # 0 title, 1 descr, 3 uploader, 4 imdburl (searching imdburl does not work with tt1234567, but descr is good)
85 | search_area: "{% if query.imdb_id %}4{% else %}0{%endif%}"
86 | # 0 AND, 1 OR, 2 exact
87 | search_mode: 0
88 | sort: "{{ query.sort }}"
89 | type: "{{ query.type }}"
90 | page: "{{ query.page }}"
91 |
92 | torrents:
93 | list:
94 | selector: table.torrents > tr:has(table.torrentname)
95 | fields:
96 | id:
97 | selector: a[href^="details.php?id="]
98 | attribute: href
99 | filters:
100 | - name: re_search
101 | args: [ '\d+', 0 ]
102 | category:
103 | selector: a[href^="?cat="]
104 | attribute: href
105 | filters:
106 | - name: querystring
107 | args: cat
108 | title_default:
109 | selector: a[href^="details.php?id="]
110 | title_optional:
111 | optional: true
112 | selector: a[title][href^="details.php?id="]
113 | attribute: title
114 | title:
115 | text: "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
116 | details:
117 | selector: a[href^="details.php?id="]
118 | attribute: href
119 | download:
120 | selector: a[href^="download.php?id="]
121 | attribute: href
122 | size:
123 | selectors: td:nth-child(5)
124 | index: 1
125 | grabs:
126 | selector: td:nth-child(8)
127 | seeders:
128 | selector: td:nth-child(6)
129 | leechers:
130 | selector: td:nth-child(7)
131 | date_elapsed:
132 | # time type: time elapsed (default)
133 | selector: td:nth-child(4) > span[title]
134 | attribute: title
135 | optional: true
136 | date_added:
137 | # time added
138 | selector: td:nth-child(4):not(:has(span))
139 | optional: true
140 | date:
141 | text: "{% if fields['date_elapsed'] or fields['date_added'] %}{{ fields['date_elapsed'] if fields['date_elapsed'] else fields['date_added'] }}{% else %}now{% endif %}"
142 | filters:
143 | - name: dateparse
144 | args: "%Y-%m-%d %H:%M:%S"
145 | downloadvolumefactor:
146 | case:
147 | img.pro_free: 0
148 | img.pro_free2up: 0
149 | img.pro_50pctdown: 0.5
150 | img.pro_50pctdown2up: 0.5
151 | img.pro_30pctdown: 0.3
152 | "*": 1
153 | uploadvolumefactor:
154 | case:
155 | img.pro_50pctdown2up: 2
156 | img.pro_free2up: 2
157 | img.pro_2up: 2
158 | "*": 1
159 | free_deadline:
160 | default_value: "{% if fields['downloadvolumefactor']==0 %}{{max_time}}{% endif%}"
161 | default_value_format: '%Y-%m-%d %H:%M:%S.%f'
162 | selector: 'td[class="embedded"] > b > span[title]'
163 | attribute: title
164 | filters:
165 | - name: dateparse
166 | args: "%Y-%m-%d %H:%M:%S"
167 | tags:
168 | selector: table.torrentname > tr > td.embedded > div:has(a)
169 | tmp_subject:
170 | selector: table.torrentname > tr > td.embedded
171 | remove: div, a
172 | subject:
173 | selector: table.torrentname > tr > td.embedded
174 | contents: -1
175 | description:
176 | text: "{% if fields['tags']%}{{ fields['subject']+' '+fields['tags'] }}{% else %}{{ fields['subject'] }}{% endif %}"
177 | minimumratio:
178 | case:
179 | img.hitandrun: 1
180 | "*": 0
181 | minimumseedtime:
182 | case:
183 | img.hitandrun: 172800
184 | "*": 0
--------------------------------------------------------------------------------
/tests/config/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/papihubcom/papihub/8a6f72f7e3aba86e93004eb9ea5215bf0d8c58e3/tests/config/__init__.py
--------------------------------------------------------------------------------
/tests/config/test_siteparserconfigload.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from papihub.config.siteparserconfigloader import SiteParserConfigLoader
4 |
5 |
6 | def test_load_parser():
7 | configs = SiteParserConfigLoader(os.path.join(os.path.dirname(os.path.realpath('.')), 'conf', 'parser')).load()
8 | assert len(configs) > 0
9 | return configs
10 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | from papihub import utils
2 |
3 |
4 | def test_parse_cookies_expire_time():
5 | print(utils.parse_cookies_expire_time('session_id=abcd1234; expires=Wed, 09-Jun-2021 10:18:14 GMT'))
6 |
--------------------------------------------------------------------------------