├── api ├── __init__.py ├── comic │ └── __init__.py ├── core │ ├── __init__.py │ ├── music.py │ ├── comic.py │ ├── abc.py │ ├── cache.py │ ├── danmaku.py │ ├── scheduler.py │ ├── loader.py │ ├── helper.py │ ├── agent.py │ ├── proxy.py │ └── anime.py ├── danmaku │ ├── __init__.py │ ├── bilibili │ │ ├── danmaku.proto │ │ ├── __init__.py │ │ └── danmaku_pb2.py │ ├── bimibimi.py │ ├── bahamut.py │ ├── iqiyi.py │ ├── tencent.py │ └── youku.py ├── iptv │ ├── __init__.py │ ├── iptv.py │ └── bupt.edu.json ├── music │ └── __init__.py ├── update │ ├── __init__.py │ ├── models.py │ └── bangumi.py ├── utils │ ├── __init__.py │ ├── storage.json │ ├── logger.py │ ├── storage.py │ └── tool.py ├── anime │ ├── __init__.py │ ├── _eyunzhu.py │ ├── 4kya.py │ ├── libvio.py │ ├── _meijuxia.py │ ├── yhdm.py │ ├── zzzfun.py │ ├── k1080.py │ ├── afang.py │ ├── agefans.py │ ├── _bde4.py │ └── bimibimi.py ├── templates │ ├── interface.txt │ └── player.html ├── config.py ├── config.json └── router.py ├── docs ├── requirements.txt ├── _static │ └── logo.ico ├── user │ ├── disclaimer.rst │ ├── class.rst │ ├── skills.rst │ ├── install.rst │ ├── tools.rst │ └── extension.rst ├── Makefile ├── index.rst ├── make.bat └── conf.py ├── app.py ├── .gitignore ├── config.py ├── requirements.txt ├── .readthedocs.yaml ├── LICENSE └── README.md /api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/comic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/danmaku/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/iptv/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/music/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/update/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/anime/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /api/utils/storage.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-automodapi==0.13 -------------------------------------------------------------------------------- /docs/_static/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zaxtyson/Anime-API/HEAD/docs/_static/logo.ico -------------------------------------------------------------------------------- /api/core/music.py: -------------------------------------------------------------------------------- 1 | from api.core.helper import HtmlParseHelper 2 | 3 | 4 | class MusicSearcher(HtmlParseHelper): 5 | pass 6 | 7 | 8 | class MusicUrlParser(HtmlParseHelper): 9 | pass 10 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from api.router import APIRouter 2 | from config import * 3 | 4 | if __name__ == '__main__': 5 | app = APIRouter(host, port) 6 | app.set_domain(domain) 7 | app.set_proxy_host(proxy_prefix) 8 | app.run() 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Pycharm 6 | .idea/ 7 | 8 | # Logs 9 | logs/ 10 | 11 | # Sphinx build cache 12 | docs/_build/ 13 | docs/api 14 | 15 | # Environment 16 | venv/ 17 | -------------------------------------------------------------------------------- /docs/user/disclaimer.rst: -------------------------------------------------------------------------------- 1 | .. _disclaimer: 2 | 3 | ================== 4 | 免责声明 5 | ================== 6 | 7 | 本项目并不会存储和缓存任何资源数据, 接口返回的数据是实时抓取的, 资源均来自其它站点。 8 | 9 | 同时, 不建议各位将资源存储到自己的服务器上, 以免给自己带来麻烦。 10 | 11 | 请不要使用本项目进行任何商业用途, 由此导致的法律风险请自行承担。 12 | -------------------------------------------------------------------------------- /api/core/comic.py: -------------------------------------------------------------------------------- 1 | from api.core.helper import HtmlParseHelper 2 | 3 | 4 | class ComicSearcher(HtmlParseHelper): 5 | pass 6 | 7 | 8 | class ComicDetailParser(HtmlParseHelper): 9 | pass 10 | 11 | 12 | class ComicGalleryParser(HtmlParseHelper): 13 | pass 14 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | """ 2 | 全局配置文件 3 | """ 4 | 5 | # 绑定的 IP, 服务器端请使用公网 IP 6 | # 如果不确定可以使用 0.0.0.0 7 | host = "127.0.0.1" 8 | 9 | # API 服务的端口 10 | port = 6001 11 | 12 | # 设置资源路径的域名部分, 端口使用 port 13 | # 如: http://www.foo.bar 14 | domain = "http://localhost" 15 | 16 | # 设置资源路径的前缀, 结尾不加 "/" 17 | # 反向代理时使用, 该选项会覆盖 domain 的设置 18 | # 如: http://www.foo.bar/anime-api 19 | proxy_prefix = "" 20 | -------------------------------------------------------------------------------- /docs/user/class.rst: -------------------------------------------------------------------------------- 1 | .. _class: 2 | 3 | ================== 4 | 相关类 5 | ================== 6 | 7 | .. automodapi:: helper 8 | :members: 9 | 10 | ---------------------------------------- 11 | 12 | .. automodapi:: anime 13 | :members: 14 | 15 | --------------------------------------- 16 | 17 | .. automodapi:: danmaku 18 | :members: 19 | 20 | ----------------------------------------- 21 | 22 | .. automodapi:: cache 23 | :members: 24 | 25 | ----------------------------------------- 26 | 27 | .. automodapi:: proxy 28 | :members: -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pympler==0.9.0 2 | aiofiles==0.6.0 3 | aiohttp==3.7.3 4 | async-timeout==3.0.1 5 | attrs==20.3.0 6 | blinker==1.4 7 | chardet==3.0.4 8 | click==7.1.2 9 | h11==0.12.0 10 | h2==4.0.0 11 | hpack==4.0.0 12 | Hypercorn==0.11.2 13 | hyperframe==6.0.0 14 | idna==3.1 15 | itsdangerous==1.1.0 16 | Jinja2==2.11.3 17 | lxml==4.6.2 18 | MarkupSafe==1.1.1 19 | multidict==5.1.0 20 | priority==1.3.0 21 | Quart==0.14.1 22 | toml==0.10.2 23 | typing-extensions==3.7.4.3 24 | Werkzeug==1.0.1 25 | wsproto==1.0.0 26 | yarl==1.6.3 27 | zhconv==1.4.1 28 | protobuf==3.15.6 29 | pycryptodome==3.10.1 -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Optionally build your docs in additional formats such as PDF 13 | # formats: 14 | # - pdf 15 | 16 | # Optionally set the version of Python and requirements required to build your docs 17 | python: 18 | version: "3.8" 19 | install: 20 | - requirements: docs/requirements.txt 21 | - requirements: requirements.txt -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /api/iptv/iptv.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os.path import dirname 3 | 4 | # TODO: 实现 m3u8 视频流代理播放, 寻找更优质的 IPTV 源 5 | 6 | __all__ = ["get_sources", "TVSource"] 7 | 8 | from typing import List 9 | 10 | 11 | class TVSource(object): 12 | 13 | def __init__(self, name: str, url: str): 14 | self.name = name 15 | self.url = url 16 | 17 | def __repr__(self): 18 | return f"" 19 | 20 | 21 | def get_sources() -> List[TVSource]: 22 | """获取 IPTV 源""" 23 | path = f"{dirname(__file__)}/bupt.edu.json" 24 | sources = [] 25 | with open(path, encoding="utf-8") as f: 26 | for item in json.load(f): 27 | tv = TVSource(item["name"], item["url"]) 28 | sources.append(tv) 29 | return sources 30 | -------------------------------------------------------------------------------- /api/core/abc.py: -------------------------------------------------------------------------------- 1 | __all__ = ["Tokenizable", "singleton"] 2 | 3 | 4 | def singleton(cls): 5 | """ 6 | 单例模式装饰器, 不考虑线程安全 7 | 8 | :param cls: 待装饰的类 9 | :return: 装饰的类 10 | """ 11 | instance = cls() 12 | cls.__new__ = cls.__call__ = lambda cls: instance 13 | cls.__init__ = lambda self: None 14 | return instance 15 | 16 | 17 | class Tokenizable(object): 18 | 19 | @property 20 | def token(self) -> str: 21 | """ 22 | 计算对象的唯一标识 23 | 24 | :return: 可唯一标识对象本身的字符串 25 | """ 26 | return "" 27 | 28 | @classmethod 29 | def build_from(cls, token: str): 30 | """ 31 | 从标识逆向构建一个对象 32 | 33 | :param token: 对象标识 34 | :return: 本类对象(只含必要信息) 35 | """ 36 | pass 37 | -------------------------------------------------------------------------------- /api/update/models.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | 4 | class AnimeUpdateInfo(object): 5 | """更新的番剧信息""" 6 | 7 | def __init__(self): 8 | self.title = "" # 番剧名 9 | self.cover_url = "" # 番剧封面 10 | self.update_time = "" # 更新时间 %Y-%m-%d %H:%M:%S 11 | self.update_to = "" # 更新到第几集 12 | 13 | 14 | class BangumiOneDay(object): 15 | """一天更新的番剧信息""" 16 | 17 | def __init__(self): 18 | self.date = "" # 这一天的日期 %Y-%m-%d 19 | self.day_of_week = "" # 这一天是星期几 20 | self.is_today = False # 是今天吗 21 | self.updates: List[AnimeUpdateInfo] = [] # 这一天更新的番剧列表 22 | 23 | def append(self, anime: AnimeUpdateInfo): 24 | self.updates.append(anime) 25 | 26 | def __iter__(self): 27 | return iter(self.updates) 28 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. _index: 2 | 3 | ====================== 4 | Anime-API Documents 5 | ====================== 6 | 7 | Anime-API 是一个异步的资源解析框架, 基于 asyncio 和 aiohttp 8 | 9 | 用于组织各类爬虫抓取互联网上的资源, 为前端提供格式统一的接口服务 10 | 11 | 因为前期以动漫、弹幕抓取为主, 所以叫 *Anime-API* 12 | 13 | 后面会加入漫画、小说、音乐等抓取功能哦 :) 14 | 15 | ------------------- 16 | 17 | **创建 API 服务**: 18 | 19 | >>> from api.router import APIRouter 20 | >>> app = APIRouter("127.0.0.1", 6001) 21 | >>> app.set_domain("http://localhost") 22 | >>> app.run() 23 | 24 | ---------------------- 25 | 26 | 27 | 食用指南 28 | ============ 29 | 30 | 那么,这个破玩意到底怎么用呢? 31 | 32 | .. toctree:: 33 | :maxdepth: 2 34 | 35 | user/disclaimer 36 | user/install 37 | user/interface 38 | user/extension 39 | user/tools 40 | user/skills 41 | user/class 42 | -------------------------------------------------------------------------------- /docs/user/skills.rst: -------------------------------------------------------------------------------- 1 | .. _skills: 2 | 3 | ================= 4 | 爬虫技巧 5 | ================= 6 | 7 | 8 | 优先抓取 APP 接口 9 | ============================= 10 | 11 | 待编辑 12 | 13 | 抓包工具的使用 14 | ========================== 15 | 16 | 待编辑 17 | 18 | APP 逆向 19 | ============================= 20 | 待编辑 21 | 22 | 浏览器反调试怎么办 23 | ========================= 24 | 25 | 待编辑 26 | 27 | 去哪里提取参数 28 | ========================= 29 | 30 | 待编辑 31 | 32 | JS 被加密了怎么办 33 | =================== 34 | 35 | 待编辑 36 | 37 | 留意 Cookies 中的小把戏 38 | ================================ 39 | 40 | 待编辑 41 | 42 | 做好伪装,防止被 ban 43 | =============================== 44 | 45 | 待编辑 46 | 47 | 并行处理,加快速度 48 | ============================ 49 | 50 | 待编辑 51 | 52 | 代理访问,绕过防盗链 53 | ====================== 54 | 55 | 待编辑 56 | 57 | HLS 流图片隐写 58 | ==================== 59 | 60 | 待编辑 -------------------------------------------------------------------------------- /api/danmaku/bilibili/danmaku.proto: -------------------------------------------------------------------------------- 1 | // Thanks: https://github.com/Passkou/bilibili-protobuf 2 | 3 | syntax = "proto3"; 4 | 5 | package bilibili.danmaku; 6 | 7 | // 弹幕分段数据的信息 8 | message SegmentConfig { 9 | optional int64 duration = 1; // 每段时长(ms) 10 | optional int64 total = 2; // 共有几段 11 | } 12 | 13 | // 弹幕库信息 14 | message DanmakuInfo { 15 | optional int32 state = 1; // 状态, 为 1 时视频弹幕被关闭 16 | optional SegmentConfig seg = 4; // 弹幕分段设置 17 | optional int64 count = 8; // 当前弹幕数量 18 | } 19 | 20 | message Bullet{ 21 | int64 id = 1; 22 | int32 progress = 2; // 弹幕出现的时间点(ms) 23 | int32 mode = 3; 24 | // int32 font_size = 4; 25 | uint32 color = 5; // 弹幕颜色 26 | // string mid_hash = 6; 27 | string content = 7; // 弹幕内容 28 | } 29 | 30 | message DanmakuData{ 31 | repeated Bullet bullet = 1; 32 | } -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /api/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from logging.handlers import TimedRotatingFileHandler 4 | 5 | __all__ = ["logger"] 6 | 7 | # 调试日志设置 8 | logger = logging.getLogger('anime') 9 | logger.setLevel(logging.DEBUG) 10 | 11 | formatter = logging.Formatter( 12 | fmt="[%(asctime)s] [%(levelname)s] [%(filename)s:%(lineno)d] %(message)s", 13 | datefmt="%Y-%m-%d %H:%M:%S") 14 | 15 | # 输出日志到控制台 16 | console_handler = logging.StreamHandler() 17 | console_handler.setFormatter(formatter) 18 | console_handler.setLevel(logging.ERROR) 19 | logger.addHandler(console_handler) 20 | 21 | # 输出日志到文件 保存最近 3 小时的日志 22 | logging_path = os.path.dirname(__file__) + "/../logs" 23 | if not os.path.exists(logging_path): 24 | os.makedirs(logging_path) 25 | file_handler = TimedRotatingFileHandler(filename=logging_path + "/api.log", when="H", interval=1, backupCount=3, 26 | encoding="utf-8") 27 | file_handler.setFormatter(formatter) 28 | file_handler.setLevel(logging.DEBUG) 29 | logger.addHandler(file_handler) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 zaxtyson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /api/utils/storage.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os.path import dirname 3 | from typing import Any 4 | 5 | from api.core.abc import singleton 6 | from api.utils.logger import logger 7 | 8 | __all__ = ["Storage"] 9 | 10 | 11 | @singleton 12 | class Storage: 13 | """给前端持久化配置用""" 14 | 15 | def __init__(self): 16 | self._file = f"{dirname(__file__)}/storage.json" 17 | self._dict = {} 18 | self._load_storage() 19 | 20 | def _load_storage(self): 21 | logger.info(f"Loading storage from {self._file}") 22 | with open(self._file, "r", encoding="utf-8") as f: 23 | self._dict = json.load(f) 24 | 25 | def _save_config(self): 26 | logger.info(f"Save storage to {self._file}") 27 | with open(self._file, "w", encoding="utf-8") as f: 28 | json.dump(self._dict, f, indent=4, ensure_ascii=False) 29 | 30 | def get(self, key: str) -> Any: 31 | return self._dict.get(key) 32 | 33 | def set(self, key: str, value: Any): 34 | self._dict[key] = value 35 | self._save_config() 36 | 37 | def delete(self, key: str): 38 | if key not in self._dict: 39 | return False 40 | self._dict.pop(key) 41 | self._save_config() 42 | return True 43 | -------------------------------------------------------------------------------- /docs/user/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | ====================== 4 | 安装和部署 5 | ====================== 6 | 7 | 当然还是先把 demo 跑起来再说 8 | 9 | 本地使用 10 | =============== 11 | 12 | 克隆仓库,安装依赖,运行即可。 13 | 14 | 注意,本项目需要使用 Python3.8+ 运行, 15 | 16 | .. code-block:: bash 17 | 18 | git clone https://github.com.cnpmjs.org/zaxtyson/Anime-API.git 19 | pip install -r requirements.txt 20 | python3.8 app.py 21 | 22 | 23 | 服务器端部署 24 | ================= 25 | 26 | 服务器端部署 API 服务时,系统中可能存在多个 Python 版本,请指名具体的 Python 版本运行, 27 | 防止依赖安装到其它版本中。 28 | 29 | .. code-block:: bash 30 | 31 | git clone https://github.com.cnpmjs.org/zaxtyson/Anime-API.git 32 | cd Anime-API 33 | python3.8 -m pip install -r requirements.txt 34 | 35 | 修改 `config.py`,按照提示修改 IP 和域名信息 36 | 37 | .. code-block:: python 38 | 39 | # 绑定的 IP, 服务器端请使用公网 IP 40 | # 如果不确定可以使用 0.0.0.0 41 | host = "127.0.0.1" 42 | 43 | # API 服务的端口 44 | port = 6001 45 | 46 | # 设置资源路径的域名部分, 端口使用 port 47 | # 如: http://www.foo.bar 48 | domain = "http://localhost" 49 | 50 | # 设置资源路径的前缀, 结尾不加 "/" 51 | # 反向代理时使用, 该选项会覆盖 domain 的设置 52 | # 如: http://www.foo.bar/anime-api 53 | proxy_prefix = "" 54 | 55 | 运行服务即可 56 | 57 | .. code-block:: bash 58 | 59 | python3.8 app.py 60 | 61 | 如果需要保持后台服务,可使用 nohup 62 | 63 | .. code-block:: bash 64 | 65 | nohup python3.8 app.py & 66 | 67 | 68 | 浏览器打开 `ip:port` 或者 `domain:port` 就可以看到效果了 69 | 70 | 默认的配置为 `127.0.0.1:6001` -------------------------------------------------------------------------------- /api/utils/tool.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode as _b64encode 2 | from hashlib import md5 as _md5 3 | from urllib.parse import urlparse 4 | 5 | from zhconv import convert 6 | 7 | __all__ = ["convert_to_zh", "convert_to_tw", "md5", "b64encode", "extract_domain"] 8 | 9 | 10 | def convert_to_zh(text: str) -> str: 11 | """ 12 | 繁体中文转简体中文 13 | 14 | :param text: 繁体字符串 15 | :return: 简体字符串 16 | 17 | >>> convert_to_zh("從零開始的異世界") 18 | '从零开始的异世界' 19 | """ 20 | return convert(text, "zh-cn") 21 | 22 | 23 | def convert_to_tw(text: str) -> str: 24 | """ 25 | 简体中文转繁体中文 26 | 27 | :param text: 简体字符串 28 | :return: 繁体字符串 29 | 30 | >>> convert_to_tw("从零开始的异世界") 31 | '從零開始的異世界' 32 | """ 33 | return convert(text, "zh-tw") 34 | 35 | 36 | def md5(text: str) -> str: 37 | """ 38 | 计算字符串的 MD5 值 39 | 40 | :param text: 待计算的文本 41 | :return: md5 字符串 42 | 43 | >>> md5("从零开始的异世界") 44 | '6ce5c6d9c3c445e8ad93bcbb7453e590' 45 | """ 46 | return _md5(text.encode("utf-8")).hexdigest() 47 | 48 | 49 | def b64encode(text: str) -> str: 50 | """计算 Base64 值 51 | 52 | :param text: 待计算的文本 53 | :return: base64 字符串 54 | 55 | >>> b64encode("从零开始的异世界") 56 | '5LuO6Zu25byA5aeL55qE5byC5LiW55WM' 57 | """ 58 | return _b64encode(text.encode("utf-8")).decode("utf-8") 59 | 60 | 61 | def extract_domain(url: str) -> str: 62 | """ 63 | 提取 URL 的域名部分 64 | 65 | :param url: 完整域名 66 | :return: 域名 67 | 68 | >>> extract_domain("www.foo.bar:6000/foo/bar?a=b") 69 | 'http://www.foo.bar:6000' 70 | """ 71 | if not url.startswith("http"): 72 | url = "http://" + url 73 | url = urlparse(url) 74 | return url.scheme + "://" + url.netloc 75 | -------------------------------------------------------------------------------- /api/core/cache.py: -------------------------------------------------------------------------------- 1 | from hashlib import md5 2 | from typing import Any 3 | 4 | import pympler.asizeof as asizeof 5 | 6 | from api.utils.logger import logger 7 | 8 | __all__ = ["CacheDB"] 9 | 10 | 11 | class CacheDB(object): 12 | """用于保存临时数据的键值对数据库""" 13 | 14 | def __init__(self): 15 | self._db = {} 16 | 17 | def is_empty(self): 18 | return not self._db 19 | 20 | def store(self, obj: Any, key: str = None, overwrite: bool = False) -> str: 21 | """ 22 | 存储一个对象, 返回其 key 23 | 24 | :param obj: 待存储的对象 25 | :param key: 若不指定, 随机生成一个运行期间不会重复的 key 26 | :param overwrite: 存在相同的 key 时是否覆盖 27 | :return: 对象的 key 28 | """ 29 | if not key: 30 | hash_str = str(id(obj)) 31 | key = md5(hash_str.encode("utf-8")).hexdigest() 32 | 33 | exist = key in self._db 34 | if (not exist) or (exist and overwrite): 35 | logger.debug(f"Store {obj} -> ") 36 | self._db[key] = obj 37 | return key 38 | 39 | def fetch(self, key: str) -> Any: 40 | """从数据库读取一个对象""" 41 | ret = self._db.get(key) 42 | logger.debug(f"Fetch -> {ret if ret else 'Nothing Found'}") 43 | return ret 44 | 45 | def update(self, key: str, value: Any) -> str: 46 | """更新 key 绑定的对象""" 47 | if key in self._db: 48 | logger.debug(f"Update -> {value}") 49 | self._db[key] = value 50 | return key 51 | 52 | def size(self) -> float: 53 | """获取缓存对象的大小(KB)""" 54 | return asizeof.asizeof(self._db) / 1024 55 | 56 | def clear(self) -> float: 57 | """清空数据, 返回清理的内存大小(KB)""" 58 | logger.warning(f"CacheDB has been cleared, object in total: {len(self._db)}") 59 | size = self.size() 60 | self._db.clear() 61 | return size 62 | -------------------------------------------------------------------------------- /api/anime/_eyunzhu.py: -------------------------------------------------------------------------------- 1 | # 本模块已弃用 2 | from api.core.anime import * 3 | 4 | 5 | class EYunZun(AnimeSearcher): 6 | 7 | async def search(self, keyword: str): 8 | params = {"kw": keyword, "per_page": 100, "page": 1} 9 | api = "https://api.eyunzhu.com/api/vatfs/resource_site_collect/search" 10 | resp = await self.get(api, params=params) # 取前 100 条结果 11 | if not resp or resp.status != 200: 12 | return 13 | data = await resp.json(content_type=None) 14 | if data["code"] != 1: 15 | return 16 | 17 | for meta in data["data"]["data"]: 18 | anime = AnimeMeta() 19 | anime.title = meta["name"] 20 | anime.cover_url = meta["pic"] 21 | anime.category = meta["type"] 22 | anime.detail_url = str(meta["vid"]) 23 | anime.desc = meta["label"] 24 | yield anime 25 | 26 | 27 | class EYunZunDetailParser(AnimeDetailParser): 28 | 29 | async def parse(self, detail_url: str): 30 | detail = AnimeDetail() 31 | api = "https://api.eyunzhu.com/api/vatfs/resource_site_collect/getVDetail" 32 | resp = await self.get(api, params={"vid": detail_url}) 33 | if not resp or resp.status != 200: 34 | return detail 35 | data = await resp.json(content_type=None) 36 | if data["code"] != 1: 37 | return detail 38 | 39 | data = data["data"] # 视频详情信息 40 | detail = AnimeDetail() 41 | detail.title = data["name"] 42 | detail.cover_url = data["pic"] 43 | detail.desc = data["label"] 44 | detail.category = data["type"] 45 | 46 | playlist = AnimePlayList() 47 | playlist.name = "视频列表" 48 | video_set = dict(data["playUrl"]) 49 | for name, url in video_set.items(): 50 | playlist.append(Anime(name, url)) 51 | detail.append_playlist(playlist) 52 | return detail 53 | -------------------------------------------------------------------------------- /api/anime/4kya.py: -------------------------------------------------------------------------------- 1 | """ 2 | 4K鸭奈飞: https://app.4kya.com/ 3 | """ 4 | 5 | from api.core.anime import * 6 | 7 | 8 | class YaNetflix(AnimeSearcher): 9 | async def search(self, keyword: str): 10 | api = "http://4kya.net:6969/api.php/v2/videos" 11 | payload = { 12 | "start": 0, 13 | "num": 10, 14 | "key": keyword, 15 | "paging": True 16 | } 17 | resp = await self.post(api, json=payload, headers={"User-Agent": "okhttp/3.11.0"}) 18 | if not resp or resp.status != 200: 19 | return 20 | data = await resp.json(content_type=None) 21 | data = data["result"]["rows"] 22 | for item in data: 23 | meta = AnimeMeta() 24 | meta.cover_url = item["pic"].replace("mac://", "http://") 25 | meta.title = item["title"] 26 | meta.detail_url = item["id"] 27 | meta.desc = item["blurb"] 28 | meta.category = " ".join(item["ext_types"]) 29 | yield meta 30 | 31 | 32 | class YaNetflixDetailParser(AnimeDetailParser): 33 | 34 | async def parse(self, detail_url: str): 35 | detail = AnimeDetail() 36 | api = "http://4kya.net:6969/api.php/v2/video" 37 | payload = {"video_id": detail_url} 38 | resp = await self.post(api, json=payload, headers={"User-Agent": "okhttp/3.11.0"}) 39 | if not resp or resp.status != 200: 40 | return detail 41 | data = await resp.json(content_type=None) 42 | data = data["result"] 43 | detail.cover_url = data["pic"].replace("mac://", "http://") 44 | detail.title = data["title"] 45 | detail.desc = data["blurb"] 46 | detail.category = " ".join(data["ext_types"]) 47 | 48 | for source in data["players"]: 49 | playlist = AnimePlayList() 50 | playlist.name = source["name"] 51 | for video in source["datas"]: 52 | if isinstance(video, bool): 53 | continue # 有时候列表里会带有布尔值 54 | anime = Anime() 55 | anime.name = video["text"] 56 | anime.raw_url = video["play_url"] 57 | playlist.append(anime) 58 | if not playlist.is_empty(): 59 | detail.append_playlist(playlist) 60 | return detail 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

- Anime API -

3 | 4 |

5 | 6 | 7 | Documentation Status 8 | 9 |

10 | 11 | 12 | ## 简介 13 | 14 | Anime-API 是一个异步的资源解析框架, 用于组织各类爬虫抓取互联网上的资源, 为前端提供格式统一的接口服务 15 | 16 | 因为前期以动漫、弹幕抓取为主, 所以叫 Anime-API, 但是后面会加入漫画、小说、音乐等抓取功能~ 17 | 18 | 19 | ## API 文档 20 | 21 | [点我点我](https://anime-api.readthedocs.io/zh_CN/latest/#) 22 | 23 | ## 更新日志 24 | 25 | ## `v1.4.3` 26 | 27 | - 新增视频引擎 `4K鸭奈菲(4Kya)` 28 | - 新增视频引擎 `Lib在线(libvio)` 29 | - 新增视频引擎 `阿房影视(afang)` 30 | - 修复视频引擎 `AgeFans` 域名映射异常的问题 31 | - 修复视频引擎 `ZzzFun` 部分视频无法播放的问题 32 | - 修复视频引擎 `bimibimi` 无法搜索和播放的问题 33 | - 修复视频引擎 `K1080` 部分视频无法播放的问题 34 | - 弃用视频引擎 `Meijuxia`, 质量太差 35 | - 弃用视频引擎 `bde4` 36 | 37 | ### `v1.4.2` 38 | 39 | - 修复视频引擎 `K1080` 失效的问题 40 | 41 | ### `v1.4.1` 42 | 43 | - 修复视频引擎 `ZzzFun` 资源迁移导致无法使用的问题 44 | - 修复视频引擎 `K1080` 部分视频无法播放的问题 45 | - 修复视频引擎 `樱花动漫(yhdm)` 域名变更的问题 46 | - 视频视频引擎 `哔嘀影视(bde4)` 切换备用域名 47 | - 过滤视频引擎 `AgeFans` 部分无效视频源 48 | - 过滤弹幕引擎 `爱奇艺(iqiyi)` 和 `腾讯视频(tencent)` 无关搜索结果 49 | - 修复弹幕引擎 `哔哩哔哩(bilibili)` 部分番剧弹幕抓取失败的问题[#12](https://github.com/zaxtyson/AnimeSearcher/issues/12) 50 | - 移除失效视频源 `eyunzhu` 和弹幕源 `哔咪哔咪(bimibimi)` 51 | 52 | ### `v1.4.0` 53 | 54 | - 支持 HLS 格式视频流量代理, 支持混淆流量解码 55 | - 对经常被墙的网站做了 A 记录, 防止域名失效, 加快 DNS 解析速度 56 | - 新增视频搜索引擎 `哔嘀影视(bde4)` 57 | - 新增弹幕搜索引擎 `爱奇艺(iqiyi)` 58 | - 修复引擎 `k1080`, `meijuxia`, 与官方保存同步 59 | - 修复 `优酷(youku)` 弹幕搜索无结果的问题 60 | - 修复 `哔哩哔哩(bilibili)` 120分钟之后无弹幕的问题[#9](https://github.com/zaxtyson/AnimeSearcher/issues/9) 61 | - 修复 `ZZZFun` 播放路线1 62 | 63 | ### `v1.3.1` 64 | 65 | - 过滤了 k1080 部分无效视 66 | - 使用续命大法修复部分源播放途中直链失效的问题 67 | 68 | ### `v1.3.0` 69 | 70 | - API 完全改用异步框架重构, 效率大幅提升, 支持服务器端部署 71 | - 修复了几个番剧模块出现的问题, 与源网站同步更新 72 | - 修复了弹幕库的一些问题, 结果更多更准, 过滤了弹幕中无关的内容 73 | - 修复了直链有效期太短导致视频播到一半失败的问题 74 | 75 | ### `v1.1.8` 76 | 77 | - 新增引擎 k1080p 78 | - 修复 youku 某些弹幕解析失败的问题 79 | - 修复 bilibili 用户上传视频弹幕解析失败的问题 80 | - 修复 bahamut 网站更新导致弹幕抓取失败的问题 81 | - 搜索结果异步加载 82 | - 历史记录功能增强, 自动解析上次访问页面 83 | 84 | ### `v0.7.1` 85 | 86 | - 修复 agefans 视频解析异常的问题 87 | - 修复 bimibimi 部分视频解析失败的问题 88 | 89 | ### `v0.7.0` 90 | 91 | - 修复 bimibimi 部分视频解析失败的问题和弹幕 undefined 的问题 92 | - 补充 bilibili 影视区弹幕 93 | - 新增引擎 meijuxia 94 | - 新增弹幕源 youku 95 | - 新增弹幕源 tencent 96 | - 增加新番更新表接口 -------------------------------------------------------------------------------- /api/danmaku/bimibimi.py: -------------------------------------------------------------------------------- 1 | # 官方弹幕已失效 2 | import re 3 | 4 | from api.core.danmaku import * 5 | 6 | 7 | class Bimibimi(DanmakuSearcher): 8 | 9 | async def search(self, keyword: str): 10 | api = "https://proxy.app.maoyuncloud.com/app/video/search" 11 | params = {"limit": "100", "key": keyword, "page": "1"} 12 | headers = {"User-Agent": "Dart/2.7 (dart:io)", "appid": "4150439554430555"} 13 | resp = await self.get(api, params=params, headers=headers) 14 | if not resp or resp.status != 200: 15 | return 16 | 17 | data = await resp.json(content_type=None) 18 | if data["data"]["total"] == 0: 19 | return 20 | 21 | data = data["data"]["items"] 22 | for anime in data: 23 | meta = DanmakuMeta() 24 | meta.title = anime["name"] 25 | meta.play_url = str(anime["id"]) 26 | meta.num = anime["total"] or -1 # 集数未知的为 0, 我们统一用 -1 表示 27 | yield meta 28 | 29 | 30 | class BimiDanmakuDetailParser(DanmakuDetailParser): 31 | 32 | async def parse(self, play_url: str): 33 | detail = DanmakuDetail() 34 | api = "https://proxy.app.maoyuncloud.com/app/video/detail" 35 | headers = {"User-Agent": "Dart/2.7 (dart:io)", "appid": "4150439554430555"} 36 | resp = await self.get(api, params={"id": play_url}, headers=headers) 37 | if not resp or resp.status != 200: 38 | return detail 39 | 40 | data = await resp.json(content_type=None) 41 | data = data["data"] # 视频详情信息 42 | ep_fid = data["fid"] # 本番的弹幕池 id 2818 43 | ep_videos = data["parts"][0]["part"] # 各集视频的名字, 如第 x 话 44 | for i, name in enumerate(ep_videos, 1): 45 | danmaku = Danmaku() 46 | danmaku.name = name 47 | danmaku.cid = f"{ep_fid}/{ep_fid}-{i}" # 2818/2818-1 弹幕id/弹幕id-集数 48 | detail.append(danmaku) 49 | return detail 50 | 51 | 52 | class BimiDanmakuDataParser(DanmakuDataParser): 53 | 54 | async def parse(self, cid: str): 55 | result = DanmakuData() 56 | api = f"http://49.234.56.246/danmu/dm/{cid}.php" 57 | resp = await self.get(api) 58 | if not resp or resp.status != 200: 59 | return result 60 | text = await resp.text() 61 | bullets = re.findall(r"p='(\d+\.?\d*?),\d,\d\d,(\d+?),\d+,(\d),.+?>(.+?)", text) 62 | for bullet in bullets: 63 | result.append_bullet( 64 | time=float(bullet[0]), 65 | pos=int(bullet[2]), 66 | color=int(bullet[1]), 67 | message=bullet[3] 68 | ) 69 | return result 70 | -------------------------------------------------------------------------------- /api/templates/interface.txt: -------------------------------------------------------------------------------- 1 | Hello, AnimeSearcher! 2 | 3 | 本页面是 API 的接口功能摘要, 以便参考 4 | 如果有任何问题或者建议, 欢迎到 Github Issue 页面讨论 5 | 如果需要帮助, 请附上系统运行日志发送至我的邮箱 6 | 7 | Email: zaxtyson@foxmail.com 8 | Github: https://github.com/zaxtyson/AnimeSearcher 9 | Issues: https://github.com/zaxtyson/AnimeSearcher/issues 10 | 11 | 12 | 13 | =================== 14 | Anime API Interface 15 | =================== 16 | 17 | WS /anime/search 异步视频搜索接口, 通过 Websocket 即时推送搜索结果 18 | GET /anime/search/ 视频搜索接口, 阻塞至所有引擎处理完成, 返回全部结果 19 | GET /anime/ 剧集详情接口, 返回播放列表等信息 20 | GET /anime/// 获取视频直链等信息 21 | GET /anime/bangumi/updates 番组表接口, 获取最近更新的番剧信息 22 | GET /anime////player 视频播放测试 23 | 24 | 25 | ===================== 26 | Danmaku API Interface 27 | ===================== 28 | 29 | WS /danmaku/search 异步弹幕搜索接口, 通过 Websocket 即时推送搜索结果 30 | GET /danmaku/search/ 弹幕搜索接口, 阻塞至所有引擎处理完成, 返回全部结果 31 | GET /danmaku/ 弹幕详情接口, 返回弹幕播放列表 32 | GET /danmaku///v3/ 弹幕数据接口, 返回一集视频的弹幕(Dplayer格式) 33 | 34 | 35 | =================== 36 | Comic API Interface 37 | =================== 38 | 39 | WS /comic/search 异步漫画搜索接口 40 | GET /comic/search/ 漫画搜索接口 41 | GET /comic/ 获取漫画详情页信息 42 | GET /comic// 获取一章漫画的信息 43 | GET /comic/// 重定向到本页图片的直链 44 | GET /comic/recommends 近期热门漫画推荐 45 | GET /comic/reader// 漫画阅读测试 46 | 47 | 48 | ============== 49 | IPTV Interface 50 | ============== 51 | 52 | GET /iptv/list 获取 IPTV 源列表 53 | 54 | 55 | =============== 56 | Proxy Interface 57 | =============== 58 | 59 | GET /proxy/image/ 代理访问跨域的图片资源 60 | GET /proxy/stream/// 代理普通视频数据流, 用于解决防盗链和跨域问题 61 | GET /proxy/hls/// 代理 HLS 视频数据流, 用于解决防盗链和跨域问题 62 | 63 | ================ 64 | System Interface 65 | ================ 66 | 67 | GET /system/logs API 运行日志 68 | GET /system/version 获取系统版本信息 69 | GET /system/clear 清空 API 缓存, 返回释放内存大小(KB) 70 | GET /system/modules 获取引擎模块信息 71 | POST /system/modules 启用/停用指定的引擎模块 72 | <= Json [{"module": "api.xxx.xxx", "enable": true}, ...] -------------------------------------------------------------------------------- /api/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os.path import dirname 3 | from typing import List, Any 4 | 5 | from api.core.abc import singleton 6 | from api.utils.logger import logger 7 | 8 | __all__ = ["Config"] 9 | 10 | 11 | @singleton 12 | class Config: 13 | """ 14 | 配置管理 15 | """ 16 | 17 | def __init__(self): 18 | self._file = f"{dirname(__file__)}/config.json" 19 | self._dict = {} 20 | self._load_config() 21 | 22 | def _load_config(self): 23 | logger.info(f"Loading config from {self._file}") 24 | with open(self._file, "r", encoding="utf-8") as f: 25 | self._dict = json.load(f) 26 | 27 | def _save_config(self): 28 | logger.info(f"Save config to {self._file}") 29 | with open(self._file, "w", encoding="utf-8") as f: 30 | json.dump(self._dict, f, indent=4, ensure_ascii=False) 31 | 32 | def get(self, key: str) -> Any: 33 | return self._dict.get(key) 34 | 35 | def get_version(self) -> dict: 36 | """系统版本信息""" 37 | return self._dict["version"] 38 | 39 | def get_modules_status(self) -> dict: 40 | """获取模块信息""" 41 | status = { 42 | "anime": self._dict["anime"], 43 | "danmaku": self._dict["danmaku"], 44 | "comic": self._dict["comic"], 45 | "music": self._dict["music"] 46 | } 47 | return status 48 | 49 | def get_all_modules(self) -> List[str]: 50 | """ 51 | 获取已经启用的引擎模块名 52 | :return: ["api.xxx.foo", "api.xxx.bar"] 53 | """ 54 | engines = [] 55 | for e_type in ["anime", "danmaku", "comic", "music"]: 56 | for item in self._dict.get(e_type): 57 | engines.append(item["module"]) 58 | return engines 59 | 60 | def get_enabled_modules(self) -> List[str]: 61 | """获取已经启用的引擎模块名""" 62 | engines = [] 63 | for e_type in ["anime", "danmaku", "comic"]: 64 | for item in self._dict.get(e_type): 65 | if item["enable"]: 66 | engines.append(item["module"]) 67 | return engines 68 | 69 | def update_module_state(self, module: str, enable: bool) -> bool: 70 | """ 71 | 启用或禁用指定引擎模块 72 | :param module: 模块名, 如 api.anime.xxx 73 | :param enable: 目标状态 74 | :return: 若模块已经处于目标状态返回 True 75 | """ 76 | if module not in self.get_all_modules(): # 模块名非法 77 | return False 78 | 79 | module_types = ["anime", "danmaku", "comic", "music"] 80 | for module_type in module_types: 81 | for info in self._dict[module_type]: 82 | if info["module"] == module and info["enable"] != enable: # 与目标状态不一致时更新配置文件 83 | info["enable"] = enable 84 | logger.info(f" has {'loaded' if enable else 'unloaded'}") 85 | self._save_config() 86 | return True 87 | 88 | def disable_engine(self, module: str) -> bool: 89 | """禁用某个引擎""" 90 | return self.update_module_state(module, False) 91 | 92 | def enable_engine(self, module: str) -> bool: 93 | """启用某个引擎""" 94 | return self.update_module_state(module, True) 95 | -------------------------------------------------------------------------------- /api/danmaku/bahamut.py: -------------------------------------------------------------------------------- 1 | from api.core.danmaku import * 2 | from api.utils.tool import convert_to_zh, convert_to_tw 3 | 4 | 5 | class Bahamut(DanmakuSearcher): 6 | """台湾动漫站巴哈姆特, 返回结果会转换为简体中文""" 7 | 8 | async def search(self, keyword: str): 9 | api = "https://ani.gamer.com.tw/search.php" 10 | keyword = convert_to_tw(keyword) # 使用繁体搜索, 否则没什么结果 11 | resp = await self.get(api, params={"kw": keyword}) 12 | if not resp or resp.status != 200: 13 | return 14 | 15 | html = await resp.text() 16 | anime_list = self.xpath(html, '//a[contains(@href, "animeRef")]') 17 | for anime in anime_list: 18 | meta = DanmakuMeta() 19 | meta.title = convert_to_zh(anime.xpath('div[@class="theme-info-block"]/p/text()')[0]) # 转简体 20 | meta.play_url = anime.xpath('@href')[0] # /animeRef.php?sn=111487 21 | num_str = anime.xpath('.//span[@class="theme-number"]/text()')[0] # 第14集 22 | meta.num = int(num_str.strip().replace("第", "").replace("集", "")) # 14 23 | yield meta 24 | 25 | 26 | class BahamutDetailParser(DanmakuDetailParser): 27 | 28 | async def parse(self, play_url: str): 29 | api = "https://ani.gamer.com.tw/animeRef.php" 30 | sn = play_url.split("=")[-1] # 111487 31 | detail = DanmakuDetail() 32 | resp = await self.get(api, params={"sn": sn}, allow_redirects=True) # 这里有一次重定向 33 | if not resp or resp.status != 200: 34 | return detail 35 | 36 | html = await resp.text() 37 | season = self.xpath(html, '//section[@class="season"]//li') 38 | if season: # 番剧播放列表存在的话 39 | for ep in season: 40 | danmaku = Danmaku() 41 | danmaku.name = convert_to_zh(ep.xpath("./a/text()")[0]) 42 | sn_str = ep.xpath("./a/@href")[0] # ?sn=16240 43 | danmaku.cid = sn_str.split("=")[-1] 44 | detail.append(danmaku) 45 | return detail 46 | 47 | # 电影等情况, 只有1集视频 48 | danmaku = Danmaku() 49 | name = self.xpath(html, '//div[@class="anime_name"]/h1/text()')[0] 50 | danmaku.name = convert_to_zh(name) 51 | this_url = self.xpath(html, '//meta[@property="og:url"]/@content')[0] 52 | danmaku.cid = this_url.split("=")[-1] 53 | detail.append(danmaku) 54 | return detail 55 | 56 | 57 | class BahamutDanmakuDataParser(DanmakuDataParser): 58 | 59 | async def parse(self, cid: str): 60 | api = "https://ani.gamer.com.tw/ajax/danmuGet.php" 61 | result = DanmakuData() 62 | resp = await self.post(api, data={"sn": cid}) 63 | if not resp or resp.status != 200: 64 | return result 65 | 66 | data = await resp.json(content_type=None) 67 | for item in data: 68 | message = convert_to_zh(item["text"]) # 弹幕繁体转简体 69 | if "签" in message: # 一堆签到弹幕, 扔掉~~ 70 | continue 71 | 72 | result.append_bullet( 73 | time=item["time"], 74 | pos=item["position"], 75 | color=int(item["color"][1:], 16), # 16 进制颜色转 10 进制 76 | message=message 77 | ) 78 | return result 79 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath('..')) 17 | sys.path.insert(0, os.path.abspath('../api')) 18 | sys.path.insert(0, os.path.abspath('../api/core')) 19 | sys.path.insert(0, os.path.abspath('../api/utils')) 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'Anime-API' 24 | copyright = '2021, zaxtyson' 25 | author = 'zaxtyson' 26 | 27 | # The full version, including alpha/beta/rc tags 28 | release = '1.4.3' 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx_automodapi.automodapi', 37 | 'sphinx.ext.intersphinx', 38 | 'sphinx.ext.viewcode' 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The language for content autogenerated by Sphinx. Refer to documentation 45 | # for a list of supported languages. 46 | # 47 | # This is also used if you do content translation via gettext catalogs. 48 | # Usually you set "language" from the command line for these cases. 49 | language = 'zh_CN' 50 | 51 | # List of patterns, relative to source directory, that match files and 52 | # directories to ignore when looking for source files. 53 | # This pattern also affects html_static_path and html_extra_path. 54 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 55 | 56 | # -- Options for HTML output ------------------------------------------------- 57 | 58 | # The theme to use for HTML and HTML Help pages. See the documentation for 59 | # a list of builtin themes. 60 | # 61 | html_theme = 'alabaster' 62 | 63 | # Theme options are theme-specific and customize the look and feel of a theme 64 | # further. For a list of options available for each theme, see the 65 | # documentation. 66 | html_theme_options = { 67 | 'show_powered_by': False, 68 | 'logo': 'logo.ico', 69 | 'logo_name': True, 70 | 'logo_text_align': 'center', 71 | 'fixed_sidebar': False, 72 | 'github_user': 'zaxtyson', 73 | 'github_repo': 'Anime-API', 74 | 'github_type': 'star', 75 | 'github_count': 'true', 76 | 'github_button': True, 77 | 'github_banner': True, 78 | 'description': "爆裂吧,现实!粉碎吧,精神!放逐这个世界!" 79 | } 80 | 81 | # Add any paths that contain custom static files (such as style sheets) here, 82 | # relative to this directory. They are copied after the builtin static files, 83 | # so a file named "default.css" will overwrite the builtin "default.css". 84 | html_static_path = ['_static'] 85 | 86 | # If true, links to the reST sources are added to the pages. 87 | html_show_sourcelink = False 88 | -------------------------------------------------------------------------------- /api/anime/libvio.py: -------------------------------------------------------------------------------- 1 | """ 2 | LIB 在线: https://www.libvio.com/ 3 | """ 4 | import json 5 | import re 6 | import time 7 | from urllib.parse import urlparse, unquote 8 | 9 | from api.core.anime import * 10 | from api.core.proxy import AnimeProxy 11 | from api.utils.tool import md5 12 | 13 | 14 | class LibVio(AnimeSearcher): 15 | 16 | async def search(self, keyword: str): 17 | api = "https://www.libvio.com/search/-------------.html" 18 | params = {"wd": keyword, "submit": ""} 19 | resp = await self.get(api, params=params) 20 | if not resp or resp.status != 200: 21 | return 22 | html = await resp.text() 23 | rets = self.xpath(html, '//div[@class="stui-vodlist__box"]') 24 | for ret in rets: 25 | meta = AnimeMeta() 26 | meta.title = ret.xpath('a/@title')[0] 27 | meta.detail_url = ret.xpath('a/@href')[0] # /detail/5060.html 28 | meta.cover_url = ret.xpath('a/@data-original')[0] 29 | meta.desc = ret.xpath('a/span[@class="pic-text text-right"]/text()')[0] 30 | yield meta 31 | 32 | 33 | class LibVioDetailParser(AnimeDetailParser): 34 | 35 | async def parse(self, detail_url: str): 36 | detail = AnimeDetail() 37 | api = "https://www.libvio.com" + detail_url 38 | resp = await self.get(api) 39 | if not resp or resp.status != 200: 40 | return detail 41 | html = await resp.text() 42 | detail_info = self.xpath(html, '//div[@class="stui-content__detail"]')[0] 43 | detail.cover_url = self.xpath(html, '//a[@class="pic"]/img/@data-original')[0] 44 | detail.title = detail_info.xpath('h1[@class="title"]/text()')[0] 45 | detail.desc = detail_info.xpath('//span[@class="detail-content"]/text()')[0] 46 | category = detail_info.xpath('p[1]/text()')[0] 47 | detail.category = category.split('/')[0].replace('类型:', '') 48 | 49 | playlist_names = self.xpath(html, '//div[@class="stui-pannel__head clearfix"]/h3/text()') 50 | playlists = self.xpath(html, '//ul[@class="stui-content__playlist clearfix"]') 51 | for i, playlist in enumerate(playlists): 52 | pl = AnimePlayList() 53 | pl.name = playlist_names[i].replace(" ", "").replace("\r\n", "") 54 | data = playlist.xpath('.//li') 55 | for ep in data: 56 | anime = Anime() 57 | anime.name = ep.xpath('a/text()')[0] 58 | anime.raw_url = ep.xpath('a/@href')[0] # /play/4988-1-11.html 59 | pl.append(anime) 60 | detail.append_playlist(pl) 61 | return detail 62 | 63 | 64 | class LibVioUrlParser(AnimeUrlParser): 65 | 66 | @staticmethod 67 | def get_signed_url(url: str) -> str: 68 | """计算 URL 的签名""" 69 | # 加密算法见: https://www.libvio.com/static/img/load.html 70 | # 代码使用 sojson v6 进行混淆 71 | path = urlparse(url).path 72 | t = format(int(time.time()) + 300, 'x') # 时间戳的十六进制表示 73 | key = "y4nZpZYXK7SOr3wWlvyD0RTl8ti61IbeVFTjpLQv21hPKKTy" 74 | sign = md5(key + path + t).lower() 75 | return f"{url}?sign={sign}&t={t}" 76 | 77 | async def parse(self, raw_url: str): 78 | api = "https://www.libvio.com" + raw_url 79 | resp = await self.get(api) 80 | if not resp or resp.status != 200: 81 | return "" 82 | html = await resp.text() 83 | data = re.search(r"player_aaaa=({.+?})<", html) 84 | if not data: 85 | return "" 86 | data = json.loads(data.group(1)) 87 | url = unquote(data["url"]) 88 | url = self.get_signed_url(url) 89 | return AnimeInfo(url, lifetime=300) 90 | 91 | 92 | class LibVioProxy(AnimeProxy): 93 | 94 | def enforce_proxy(self, url: str) -> bool: 95 | if "chinacloudapi.cn" in url: 96 | return True 97 | return False 98 | -------------------------------------------------------------------------------- /api/anime/_meijuxia.py: -------------------------------------------------------------------------------- 1 | """ 2 | 美剧侠的质量越来越差了, 返回点结果没几个能正常解析的, 弃用 3 | """ 4 | 5 | from json import JSONDecodeError 6 | 7 | from api.core.anime import * 8 | from api.core.proxy import AnimeProxy 9 | from api.utils.tool import extract_domain 10 | 11 | 12 | class Meijuxia(AnimeSearcher): 13 | 14 | async def search(self, keyword: str): 15 | payload = { 16 | "service": "App.Vod.Search", 17 | "search": keyword, 18 | "page": 1, 19 | "perpage": 24, 20 | "versionCode": 1000, 21 | "time": "1626701869178", # 搜索和详情使用的时间不能相同 22 | "md5": "6fb6c8296bb2bacbc7b385f343adf3c6", 23 | "sign": "fff4d7b9366d25c42aa8f49dd3433211" 24 | } 25 | api = "http://27.124.4.42/" 26 | headers = { 27 | "Host": "27.124.4.42:8808", 28 | "User-Agent": "okhttp/3.12.1" # 为了伪装的更像 29 | } 30 | resp = await self.post(api, data=payload, headers=headers) 31 | if not resp or resp.status != 200: 32 | return 33 | data = [] 34 | try: 35 | data = await resp.json(content_type=None) 36 | data = list(filter(lambda x: x["type"] == "vod", data["data"]))[0] 37 | data = data["videos"] 38 | except JSONDecodeError: # 没结果的时候, 返回的json格式错误 39 | pass 40 | for item in data: 41 | meta = AnimeMeta() 42 | meta.title = item["vod_name"] 43 | meta.category = item["vod_type"] 44 | meta.cover_url = item["vod_pic"] 45 | meta.desc = item["vod_keywords"] 46 | meta.detail_url = str(item["vod_id"]) # 详情页id参数 47 | yield meta 48 | 49 | 50 | class MeijuxiaDetailParser(AnimeDetailParser): 51 | 52 | async def parse(self, detail_url: str): 53 | detail = AnimeDetail() 54 | payload = { 55 | "service": "App.Vod.Video", 56 | "id": detail_url, 57 | "versionCode": 1000, 58 | "time": "1626701872503", 59 | "md5": "3ef146bf72e916b3490dc503b4655bd3", 60 | "sign": "5e84d109f2df774b04d85ff08d053e70" 61 | } 62 | api = "http://27.124.4.42/" 63 | headers = { 64 | "Host": "27.124.4.42:8808", 65 | "User-Agent": "okhttp/3.12.1" 66 | } 67 | resp = await self.post(api, data=payload, headers=headers) 68 | if not resp or resp.status != 200: 69 | return detail 70 | data = await resp.json(content_type=None) 71 | data = list(filter(lambda x: x["type"] == "player", data["data"])) 72 | info = data[0]["player_vod"] 73 | detail.title = info["vod_name"] 74 | detail.desc = self.desc_format(info["vod_content"]) 75 | detail.cover_url = info["vod_pic"] 76 | for playlist in info["vod_play"]: 77 | pl = AnimePlayList() 78 | pl.name = playlist["player_name_zh"] + playlist["title"] 79 | if pl.name in ["畅播", "酷播"]: 80 | continue # 垃圾资源 81 | for video in playlist["players"]: 82 | url = video["url"].split("=")[-1] 83 | pl.append(Anime(video["title"], url)) 84 | detail.append_playlist(pl) 85 | return detail 86 | 87 | @staticmethod 88 | def desc_format(text: str): 89 | """去除简介中的HTML符号""" 90 | return text.replace("

", "").replace("

", ""). \ 91 | replace("·", "·").replace("“", "") 92 | 93 | 94 | class MeijuxiaProxy(AnimeProxy): 95 | 96 | async def get_m3u8_text(self, index_url: str) -> str: 97 | if "dious.cc" in index_url: # 需要再跳转一次 98 | text = await self.read_text(index_url) 99 | index_url = extract_domain(index_url) + text.split()[-1] 100 | 101 | return await self.read_text(index_url) 102 | -------------------------------------------------------------------------------- /api/anime/yhdm.py: -------------------------------------------------------------------------------- 1 | from api.core.anime import * 2 | 3 | 4 | class SakuraAnime(AnimeSearcher): 5 | 6 | async def fetch_html(self, keyword: str, page: int): 7 | api = f"http://www.yhdm.so/search/{keyword}" 8 | resp = await self.get(api, params={"page": page}) 9 | if not resp or resp.status != 200: 10 | return "" 11 | html = await resp.text() 12 | if "文件不存在" in html: # 网站日常抛锚 13 | return "" 14 | return html 15 | 16 | def parse_last_page_index(self, html: str) -> int: 17 | max_page = self.xpath(html, '//div[@class="pages"]/a[@id="lastn"]/text()') # ['12'] 或 [] 18 | if not max_page: 19 | return 1 # 搜索结果只有一页 20 | return int(max_page[0]) 21 | 22 | def parse_anime_metas(self, html: str): 23 | meta_list = self.xpath(html, '//div[@class="lpic"]//li') 24 | ret = [] 25 | for meta in meta_list: 26 | anime = AnimeMeta() 27 | anime.title = " ".join(meta.xpath(".//h2/a/@title")) 28 | anime.cover_url = meta.xpath("./a/img/@src")[0] 29 | anime.category = " ".join(meta.xpath("./span[2]/a/text()")) 30 | anime.desc = meta.xpath("./p/text()")[0] 31 | anime.detail_url = meta.xpath("./a/@href")[0] # /show/5031.html 32 | ret.append(anime) 33 | return ret 34 | 35 | async def parse_one_page(self, keyword: str, page: int): 36 | html = await self.fetch_html(keyword, page) 37 | return self.parse_anime_metas(html) 38 | 39 | async def search(self, keyword: str): 40 | html = await self.fetch_html(keyword, 1) 41 | if not html: 42 | return 43 | for item in self.parse_anime_metas(html): 44 | yield item 45 | 46 | pages = self.parse_last_page_index(html) 47 | if pages > 1: 48 | tasks = [self.parse_one_page(keyword, p) for p in range(2, pages + 1)] 49 | async for item in self.as_iter_completed(tasks): 50 | yield item 51 | 52 | 53 | class SakuraDetailParser(AnimeDetailParser): 54 | 55 | async def parse(self, detail_url: str): 56 | detail = AnimeDetail() 57 | url = "http://www.yhdm.so" + detail_url 58 | resp = await self.get(url) 59 | if not resp or resp.status != 200: 60 | return detail 61 | 62 | html = await resp.text() 63 | body = self.xpath(html, '//div[@class="fire l"]')[0] 64 | detail.title = body.xpath("./div/h1/text()")[0] 65 | detail.category = " ".join(body.xpath('.//div[@class="sinfo"]/span[3]/a/text()')) 66 | detail.desc = body.xpath('.//div[@class="info"]/text()')[0].replace("\r\n", "").strip() 67 | detail.cover_url = body.xpath('.//div[@class="thumb l"]/img/@src')[0] 68 | playlist = AnimePlayList() 69 | playlist.name = "播放列表" 70 | video_blocks = body.xpath('.//div[@class="movurl"]//li') 71 | for block in video_blocks: 72 | video = Anime() 73 | video.name = block.xpath("./a/text()")[0] 74 | video.raw_url = block.xpath("./a/@href")[0] # '/v/3849-162.html' 75 | playlist.append(video) 76 | detail.append_playlist(playlist) 77 | return detail 78 | 79 | 80 | class SakuraUrlParser(AnimeUrlParser): 81 | 82 | async def parse(self, raw_url: str): 83 | url = "http://www.yhdm.so/" + raw_url 84 | resp = await self.get(url) 85 | if not resp or resp.status != 200: 86 | return "" 87 | html = await resp.text() 88 | video_url = self.xpath(html, '//div[@id="playbox"]/@data-vid')[0] # "url$format" 89 | video_url = video_url.split("$")[0] # "http://quan.qq.com/video/1098_ae4be38407bf9d8227748e145a8f97a5" 90 | if not video_url.startswith("http"): # 偶尔出现一些无效视频 91 | return "" 92 | resp = await self.head(video_url, allow_redirects=True) # 获取直链时会重定向 2 次 93 | if not resp or resp.status != 200: 94 | return "" 95 | return resp.url.human_repr() # 重定向之后的视频直链 96 | -------------------------------------------------------------------------------- /api/anime/zzzfun.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from api.core.anime import * 4 | from api.core.proxy import AnimeProxy 5 | from api.utils.tool import md5 6 | 7 | 8 | class ZZZFun(AnimeSearcher): 9 | 10 | async def search(self, keyword: str): 11 | api = "http://service-agbhuggw-1259251677.gz.apigw.tencentcs.com/android/search" 12 | resp = await self.post(api, data={"userid": "", "key": keyword}, headers={"User-Agent": "okhttp/3.12.0"}) 13 | if not resp or resp.status != 200: 14 | return 15 | data = await resp.json(content_type=None) 16 | for meta in data["data"]: 17 | anime = AnimeMeta() 18 | anime.title = meta["videoName"] 19 | anime.cover_url = meta["videoImg"] 20 | anime.category = meta["videoClass"] 21 | anime.detail_url = meta["videoId"] 22 | yield anime 23 | 24 | 25 | class ZZZFunDetailParser(AnimeDetailParser): 26 | 27 | async def parse(self, detail_url: str): 28 | detail = AnimeDetail() 29 | api = "http://service-agbhuggw-1259251677.gz.apigw.tencentcs.com/android/video/list_ios" 30 | resp = await self.get(api, params={"userid": "", "videoId": detail_url}, 31 | headers={"User-Agent": "okhttp/3.12.0"}) 32 | if not resp or resp.status != 200: 33 | return detail 34 | data = await resp.json(content_type=None) 35 | data = data["data"] # 视频详情信息 36 | detail.title = data["videoName"] 37 | detail.cover_url = data["videoImg"] 38 | detail.desc = data["videoDoc"].replace("\r\n", "") # 完整的简介 39 | detail.category = data["videoClass"] 40 | for video_set in data["videoSets"]: 41 | playlist = AnimePlayList() # 番剧的视频列表 42 | playlist.name = video_set["load"] # 列表名, 线路 I, 线路 II 43 | for video in video_set["list"]: 44 | playlist.append(Anime(video["ji"], video["playid"])) 45 | detail.append_playlist(playlist) 46 | return detail 47 | 48 | 49 | class ZZZFunUrlParser(AnimeUrlParser): 50 | 51 | async def parse(self, raw_url: str): 52 | # 加密算法 Smali 位置 53 | # .class final Lorg/daimhim/zzzfun/data/remote/HttpRequestManager$getVideoPlayInfo$2; 54 | # .source "HttpRequestManager.kt" 55 | # .method public final invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; 56 | # .line 460 ~ .line 463 57 | secret_key = "zan109drdddzz" 58 | now = int(time.time() * 1000) # 13 位时间戳 59 | sing = md5(secret_key + str(now)) 60 | 61 | # 接口随 App 更新变化 62 | play_api = "http://service-agbhuggw-1259251677.gz.apigw.tencentcs.com/android/video/112play" 63 | payload = {"playid": raw_url, "userid": "", "apptoken": "", "sing": sing, "map": now} 64 | resp = await self.post(play_api, data=payload, headers={"User-Agent": "okhttp/3.12.0"}) 65 | if not resp or resp.status != 200: 66 | return "" 67 | data = await resp.json(content_type=None) 68 | if not data["data"]: 69 | return "" 70 | real_url = data["data"]["videoplayurl"] 71 | if "alicdn" in real_url or "zzzhls" in real_url: 72 | # m3u8 格式, 该资源解析后访问一次立刻失效, 内部视频片段不会立刻失效 73 | return AnimeInfo(real_url, volatile=True) 74 | return AnimeInfo(real_url) 75 | 76 | 77 | class ZZZFunProxy(AnimeProxy): 78 | 79 | def enforce_proxy(self, url: str) -> bool: 80 | if "alicdn" in url or "zzzhls" in url: 81 | return True # 图片隐写视频流, 强制代理播放 82 | if "chaoxing.com" in url: # 学习通视频 83 | return True 84 | return False 85 | 86 | def fix_chunk_data(self, url: str, chunk: bytes) -> bytes: 87 | if "pgc-image" in url: 88 | return chunk[0xd4:] # 前面是 gif 文件 89 | return chunk 90 | 91 | def set_proxy_headers(self, url: str) -> dict: 92 | if "chaoxing.com" in url: # 视频放在学习通网盘 93 | return { 94 | "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 6.0.1; Pro 7 Build/V417IR)" 95 | } 96 | -------------------------------------------------------------------------------- /api/templates/player.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Play Test 8 | 9 | 10 | 11 | 12 | 13 | 20 | 21 |
22 | 109 | 110 | -------------------------------------------------------------------------------- /api/anime/k1080.py: -------------------------------------------------------------------------------- 1 | """ 2 | 新版本改名 闪电影视: https://www.ak1080.com/ 3 | 4 | 旧版本视频详情接口于 2021/07/20 失效 5 | 6 | 新版本接口: 7 | GET http://app.kssp.net/api.php/app/search?pg=1&text=从零开始的异世界&token= 8 | GET http://app.kssp.net/api.php/app/video_detail?id=606&token= 9 | 10 | 请求返回的数据使用 Base64 加密后, 还需解密, 由于 APP 使用 flutter 开发, 11 | 具体逻辑在 libapp.so 中, 暂时无法逆向 12 | Dart version: e4a09dbf2bb120fe4674e0576617a0dc 13 | 14 | 旧版本接口于 2021/07/23 恢复, 本模块继续启用 15 | """ 16 | 17 | from api.core.anime import * 18 | from api.core.proxy import AnimeProxy 19 | 20 | 21 | class K1080(AnimeSearcher): 22 | 23 | async def search(self, keyword: str): 24 | api = "http://myapp.hanmiys.net/search/result" 25 | payload = { 26 | "page_num": "1", # 只要一页, 减少结果数量 27 | "keyword": keyword, 28 | "page_size": "12" 29 | } 30 | resp = await self.post(api, json=payload, headers={"User-Agent": "okhttp/4.1.0"}) 31 | if not resp or resp.status != 200: 32 | return 33 | data = await resp.json(content_type=None) 34 | data = data["data"]["list"] 35 | for item in data: 36 | meta = AnimeMeta() 37 | meta.cover_url = item["cover"] 38 | meta.title = item["video_name"] 39 | meta.detail_url = item["video_id"] # such as "60014" 40 | meta.desc = item["intro"] or "无简介" 41 | meta.category = item["category"] 42 | yield meta 43 | 44 | 45 | class K1080DetailParser(AnimeDetailParser): 46 | 47 | async def parse(self, detail_url: str): 48 | detail = AnimeDetail() 49 | api = "http://myapp.hanmiys.net/video/info" 50 | payload = {"video_id": detail_url} 51 | resp = await self.post(api, json=payload, headers={"User-Agent": "okhttp/4.1.0"}) 52 | if not resp or resp.status != 200: 53 | return detail 54 | data = await resp.json(content_type=None) 55 | info = data["data"]["info"] 56 | detail.cover_url = info["cover"] 57 | detail.title = info["video_name"] 58 | detail.desc = info["intro"].replace("

", "").replace("

", "") 59 | detail.category = info["category"] 60 | 61 | videos = info["videos"] 62 | for source in info["source"]: 63 | playlist = AnimePlayList() 64 | playlist.name = source["name"] 65 | for video in videos: 66 | anime = Anime() 67 | anime.name = video["title"] 68 | anime.raw_url = str(source["source_id"]) + '|' + str(video["chapter_id"]) + '|' + str(video["video_id"]) 69 | playlist.append(anime) 70 | if not playlist.is_empty(): 71 | detail.append_playlist(playlist) 72 | return detail 73 | 74 | 75 | class K1080UrlParser(AnimeUrlParser): 76 | 77 | async def parse(self, raw_url: str): 78 | api = "http://myapp.hanmiys.net/video/parse" 79 | source_id, chapter_id, video_id = raw_url.split('|') 80 | payload = { 81 | "source_id": source_id, 82 | "chapter_id": chapter_id, 83 | "video_id": video_id 84 | } 85 | resp = await self.post(api, json=payload, headers={"User-Agent": "okhttp/4.1.0"}) 86 | if not resp or resp.status != 200: 87 | return "" 88 | data = await resp.json(content_type=None) 89 | return data["data"]["url"] 90 | 91 | 92 | class K1080Proxy(AnimeProxy): 93 | 94 | def fix_chunk_data(self, url: str, chunk: bytes) -> bytes: 95 | if "gtimg.com" in url: 96 | return chunk[0x303:] # 前面是图片数据 97 | if "ydstatic.com" in url: 98 | return chunk[0x3BF:] 99 | if "pstatp.com" in url or "qpic.cn" in url: 100 | return chunk[0x13A:] 101 | return chunk 102 | 103 | def enforce_proxy(self, url: str) -> bool: 104 | if "byingtime.com" in url: 105 | return True 106 | if "paofans" in url: 107 | return True 108 | if "hanmiys" in url: 109 | return True 110 | return False 111 | -------------------------------------------------------------------------------- /api/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": { 3 | "tag": "1.4.3", 4 | "time": "2021-07-23", 5 | "desc": "- 新增视频引擎 `4K鸭奈菲(4Kya)`\n- 新增视频引擎 `Lib在线(libvio)` \n- 新增视频引擎 `阿房影视(afang)` \n- 修复视频引擎 `AgeFans` 域名映射异常的问题\n- 修复视频引擎 `ZzzFun` 部分视频无法播放的问题\n- 修复视频引擎 `bimibimi` 无法搜索和播放的问题\n- 修复视频引擎 `K1080` 部分视频无法播放的问题\n- 弃用视频引擎 `Meijuxia`, 质量太差\n- 弃用视频引擎 `bde4`", 6 | "update": "https://gitee.com/api/v5/repos/zaxtyson/AnimeSearcher/releases/latest" 7 | }, 8 | "domain_mapping": { 9 | "www.yhdm.so": "yhdm.wegather.tk" 10 | }, 11 | "anime": [ 12 | { 13 | "name": "阿房影视", 14 | "notes": "资源质量非常好", 15 | "module": "api.anime.afang", 16 | "type": [ 17 | "综合" 18 | ], 19 | "enable": true, 20 | "quality": 9 21 | }, 22 | { 23 | "name": "1080影视", 24 | "notes": "资源丰富, 质量真的不错", 25 | "module": "api.anime.k1080", 26 | "type": [ 27 | "综合" 28 | ], 29 | "enable": true, 30 | "quality": 9 31 | }, 32 | { 33 | "name": "4K鸭奈飞", 34 | "notes": "资源比较少,质量非常好", 35 | "module": "api.anime.4kya", 36 | "type": [ 37 | "综合" 38 | ], 39 | "enable": true, 40 | "quality": 9 41 | }, 42 | { 43 | "name": "LIB在线", 44 | "notes": "质量和速度得很好, 只有热门影视剧", 45 | "module": "api.anime.libvio", 46 | "type": [ 47 | "电影", 48 | "美剧", 49 | "动漫" 50 | ], 51 | "enable": true, 52 | "quality": 9 53 | }, 54 | { 55 | "name": "ZzzFun", 56 | "notes": "资源质量还行, 响应速度快", 57 | "module": "api.anime.zzzfun", 58 | "type": [ 59 | "动漫" 60 | ], 61 | "enable": true, 62 | "quality": 8 63 | }, 64 | { 65 | "name": "哔咪动漫", 66 | "notes": "资源质量还行, 响应速度快", 67 | "module": "api.anime.bimibimi", 68 | "type": [ 69 | "动漫" 70 | ], 71 | "enable": true, 72 | "quality": 8 73 | }, 74 | { 75 | "name": "AGE动漫", 76 | "notes": "少部分资源存在水印,响应速度也不错", 77 | "module": "api.anime.agefans", 78 | "type": [ 79 | "动漫" 80 | ], 81 | "enable": true, 82 | "quality": 8 83 | }, 84 | { 85 | "name": "樱花动漫", 86 | "notes": "网站早上和晚上经常宕机, 响应速度慢, 不建议启用", 87 | "module": "api.anime.yhdm", 88 | "type": [ 89 | "动漫" 90 | ], 91 | "enable": false, 92 | "quality": 6 93 | } 94 | ], 95 | "danmaku": [ 96 | { 97 | "name": "哔哩哔哩", 98 | "notes": "提供B站官方和用户上传番剧的弹幕", 99 | "module": "api.danmaku.bilibili", 100 | "enable": true, 101 | "quality": 10 102 | }, 103 | { 104 | "name": "巴哈姆特", 105 | "notes": "台湾的弹幕网站, 大陆访问速度一般", 106 | "module": "api.danmaku.bahamut", 107 | "enable": true, 108 | "quality": 8 109 | }, 110 | { 111 | "name": "优酷", 112 | "notes": "这世界很酷的那个优酷, 反爬机制较强", 113 | "module": "api.danmaku.youku", 114 | "enable": true, 115 | "quality": 8 116 | }, 117 | { 118 | "name": "腾讯视频", 119 | "notes": "弹幕中无关紧要的信息已经过滤, 放心食用", 120 | "module": "api.danmaku.tencent", 121 | "enable": true, 122 | "quality": 8 123 | }, 124 | { 125 | "name": "爱奇艺", 126 | "notes": "悦享品质的那个爱奇艺", 127 | "module": "api.danmaku.iqiyi", 128 | "enable": true, 129 | "quality": 8 130 | } 131 | ], 132 | "comic": [], 133 | "music": [] 134 | } -------------------------------------------------------------------------------- /api/anime/afang.py: -------------------------------------------------------------------------------- 1 | """ 2 | 阿房影视: https://bwl87.com/ 3 | 后端接口与 K1080 一模一样 4 | """ 5 | import json 6 | from json import JSONDecodeError 7 | 8 | from api.core.anime import * 9 | from api.core.proxy import AnimeProxy 10 | from api.utils.logger import logger 11 | 12 | 13 | def parse_response(data: str) -> dict: 14 | """处理接口响应数据""" 15 | try: 16 | # 3s内频繁搜索会导致接口返回异常提醒 17 | # 有时候部分数据未加载导致 json 格式错误 18 | data = data.replace("&comments&", "").replace("&is_fav&", "0") 19 | return json.loads(data) 20 | except JSONDecodeError: 21 | logger.info("Please wait for 3s...") 22 | return {} 23 | 24 | 25 | class Afang(AnimeSearcher): 26 | async def search(self, keyword: str): 27 | api = "https://app.bwl87.com/search/result" 28 | payload = { 29 | "page_num": "1", # 取一页即可, 不用太多数据 30 | "keyword": keyword, 31 | "page_size": "12" 32 | } 33 | resp = await self.post(api, json=payload, headers={"User-Agent": "okhttp/4.1.0"}) 34 | if not resp or resp.status != 200: 35 | return 36 | data = parse_response(await resp.text()) 37 | if not data: 38 | return 39 | data = data["data"]["list"] 40 | for item in data: 41 | meta = AnimeMeta() 42 | meta.cover_url = item["cover"] 43 | meta.title = item["video_name"] 44 | meta.detail_url = item["video_id"] 45 | meta.desc = item["intro"] or "无简介" 46 | meta.category = item["category"] 47 | yield meta 48 | 49 | 50 | class AfangDetailParser(AnimeDetailParser): 51 | 52 | async def parse(self, detail_url: str): 53 | detail = AnimeDetail() 54 | api = "https://app.bwl87.com/video/info" 55 | payload = {"video_id": detail_url} 56 | resp = await self.get(api, json=payload, headers={"User-Agent": "okhttp/4.1.0"}) 57 | if not resp or resp.status != 200: 58 | return detail 59 | data = parse_response(await resp.text()) 60 | if not data: 61 | return 62 | info = data["data"]["info"] 63 | detail.title = info["video_name"] 64 | detail.cover_url = info["cover"] 65 | detail.desc = info["intro"].replace("

", "").replace("

", "") 66 | detail.category = info["category"] 67 | 68 | videos = info["videos"] 69 | for source in info["source"]: 70 | playlist = AnimePlayList() 71 | playlist.name = source["name"] 72 | for video in videos: 73 | anime = Anime() 74 | anime.name = video["title"] 75 | anime.raw_url = str(source["source_id"]) + '|' + str(video["chapter_id"]) + '|' + str(video["video_id"]) 76 | playlist.append(anime) 77 | if not playlist.is_empty(): 78 | detail.append_playlist(playlist) 79 | return detail 80 | 81 | 82 | class AfangUrlParser(AnimeUrlParser): 83 | 84 | async def parse(self, raw_url: str): 85 | api = "https://app.bwl87.com/video/parse" 86 | source_id, chapter_id, video_id = raw_url.split('|') 87 | payload = { 88 | "source_id": source_id, 89 | "chapter_id": chapter_id, 90 | "video_id": video_id 91 | } 92 | resp = await self.get(api, json=payload, headers={"User-Agent": "okhttp/4.1.0"}) 93 | if not resp or resp.status != 200: 94 | return "" 95 | data = parse_response(await resp.text()) 96 | if not data: 97 | return "" 98 | return data["data"]["url"] 99 | 100 | 101 | class AfangProxy(AnimeProxy): 102 | 103 | def enforce_proxy(self, url: str) -> bool: 104 | if "hanmiys" in url: 105 | return True 106 | return False 107 | 108 | def fix_chunk_data(self, url: str, chunk: bytes) -> bytes: 109 | if "gtimg.com" in url: 110 | return chunk[0x303:] 111 | if "ydstatic.com" in url: 112 | return chunk[0x3BF:] 113 | if "pstatp.com" in url or "qpic.cn" in url: 114 | return chunk[0x13A:] 115 | return chunk 116 | -------------------------------------------------------------------------------- /api/update/bangumi.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import List 3 | 4 | from api.core.danmaku import HtmlParseHelper 5 | from api.update.models import AnimeUpdateInfo, BangumiOneDay 6 | from api.utils.tool import convert_to_zh 7 | 8 | __all__ = ["Bangumi"] 9 | 10 | 11 | class Bangumi(HtmlParseHelper): 12 | """新番更新时间表""" 13 | 14 | def __init__(self): 15 | super().__init__() 16 | self._bili_mainland = "https://bangumi.bilibili.com/web_api/timeline_cn" 17 | self._bili_overseas = "https://bangumi.bilibili.com/web_api/timeline_global" 18 | # self._bimi_update = "http://api.tianbo17.com/app/video/list" # 接口变更, 数据已加密 19 | 20 | async def get_bangumi_updates(self) -> List[BangumiOneDay]: 21 | """获取最近一段时间的新番更新时间表""" 22 | results = [] 23 | update_dict = {} 24 | await self.init_session() 25 | update_info = await self._get_all_bangumi() 26 | await self.close_session() 27 | for anime in update_info: 28 | up_date = anime.update_time.split()[0] # %Y-%m-%d 29 | update_dict.setdefault(up_date, []) 30 | update_dict[up_date].append(anime) 31 | 32 | for up_date, anime_list in update_dict.items(): 33 | one_day = BangumiOneDay() 34 | one_day.date = up_date 35 | one_day.day_of_week = time.strftime("%w", time.strptime(up_date, "%Y-%m-%d")) 36 | one_day.is_today = True if up_date == time.strftime("%Y-%m-%d", time.localtime()) else False 37 | one_day.updates = anime_list 38 | results.append(one_day) 39 | results.sort(key=lambda x: x.date) 40 | return results 41 | 42 | async def _get_bili_bangumi(self, api: str) -> List[AnimeUpdateInfo]: 43 | """获取哔哩哔哩的番剧更新时间表""" 44 | result = [] # 结果 45 | resp = await self.get(api) 46 | if not resp or resp.status != 200: 47 | return result 48 | data = await resp.json(content_type=None) 49 | data = data.get("result") or [] 50 | 51 | for item in data: 52 | for season in item["seasons"]: 53 | if season["delay"] == 1: # 本周停更, 不记录 54 | continue 55 | anime = AnimeUpdateInfo() 56 | title = season["title"].replace("(僅限台灣地區)", "").replace("(僅限港澳台地區)", "") 57 | anime.title = convert_to_zh(title) 58 | anime.cover_url = season["cover"] # 番剧封面, season["square_cover"] 正方形小封面 59 | anime.update_time = self._time_format(str(season["pub_ts"])) 60 | anime.update_to = season["pub_index"] 61 | result.append(anime) 62 | return result 63 | 64 | # async def _get_bimi_bangumi(self) -> List[AnimeUpdateInfo]: 65 | # """获取 bimibimi 番剧的更新表""" 66 | # result = [] 67 | # params = {"channel": "1", "sort": "addtime", "limit": "0", "page": "1"} 68 | # headers = {"User-Agent": "Dart/2.7 (dart:io)", "appid": "4150439554430555"} 69 | # resp = await self.get(self._bimi_update, params=params, headers=headers) 70 | # if not resp or resp.status != 200: 71 | # return result 72 | # data = await resp.json(content_type=None) 73 | # data = data["data"]["items"] 74 | # 75 | # for item in data: 76 | # anime = AnimeUpdateInfo() 77 | # anime.title = item["name"] 78 | # anime.cover_url = item["pic"] 79 | # anime.update_time = self._time_format(item["updated_at"]) 80 | # anime.update_to = item["continu"].replace("更新至", "") 81 | # result.append(anime) 82 | # return result 83 | 84 | async def _get_all_bangumi(self) -> List[AnimeUpdateInfo]: 85 | """获取全部更新的番剧信息""" 86 | tasks = [ 87 | self._get_bili_bangumi(self._bili_mainland), 88 | self._get_bili_bangumi(self._bili_overseas), 89 | # self._get_bimi_bangumi() 90 | ] 91 | 92 | task_ret = [] 93 | async for item in self.as_iter_completed(tasks): 94 | task_ret.append(item) 95 | 96 | # 信息合并去重 97 | title_list = [] 98 | result = [] 99 | for anime in task_ret: 100 | if anime.title not in title_list: 101 | result.append(anime) 102 | title_list.append(anime.title) 103 | 104 | return result 105 | 106 | @staticmethod 107 | def _time_format(time_str: str): 108 | """将时间处理成 %Y-%m-%d %H:%M:%S 的格式""" 109 | if time_str.isdigit(): 110 | tm_struct = time.localtime(int(time_str)) # 哔哩哔哩 111 | else: 112 | tm_struct = time.strptime(time_str, "%Y-%m-%dT%H:%M:%S+08:00") # Bimibimi 113 | return time.strftime("%Y-%m-%d %H:%M:%S", tm_struct) 114 | -------------------------------------------------------------------------------- /api/anime/agefans.py: -------------------------------------------------------------------------------- 1 | from api.core.anime import * 2 | from api.utils.tool import md5 3 | 4 | 5 | class AgeFans(AnimeSearcher): 6 | 7 | async def search(self, keyword: str): 8 | first_page = await self.fetch_one_page(keyword, 1) 9 | data = self.parse_one_page(first_page) 10 | for meta in data: 11 | yield meta 12 | pages = first_page["SeaCnt"] // 24 + 1 13 | if pages == 1: 14 | return # 如果存在多页就继续 15 | tasks = [self.process_one_page(keyword, p) for p in range(2, pages + 1)] 16 | async for meta in self.as_iter_completed(tasks): 17 | yield meta 18 | 19 | async def process_one_page(self, keyword: str, page: int): 20 | data = await self.fetch_one_page(keyword, page) 21 | if not data: 22 | return [] 23 | return self.parse_one_page(data) 24 | 25 | async def fetch_one_page(self, keyword: str, page: int): 26 | api = f"https://api.agefans.app/v2/search?page={page}&query={keyword}" 27 | headers = { 28 | "Origin": "https://web.age-spa.com:8443", 29 | "Referer": "https://web.age-spa.com:8443/" 30 | } 31 | resp = await self.get(api, headers=headers) 32 | if not resp or resp.status != 200: 33 | return 34 | return await resp.json(content_type=None) 35 | 36 | @staticmethod 37 | def parse_one_page(data: dict): 38 | data = data["AniPreL"] 39 | result = [] 40 | for item in data: 41 | meta = AnimeMeta() 42 | meta.title = item["R动画名称"] 43 | meta.category = " ".join(item["R剧情类型"]) 44 | meta.desc = item["R简介"] 45 | meta.cover_url = item["R封面图小"] 46 | meta.detail_url = item["AID"] 47 | result.append(meta) 48 | return result 49 | 50 | 51 | class AgeFansAppDetailParser(AnimeDetailParser): 52 | 53 | @staticmethod 54 | def drop_this(play_id: str) -> bool: 55 | key_list = ["接口", "QLIVE"] 56 | for key in key_list: 57 | if key in play_id: 58 | return True 59 | return False 60 | 61 | async def parse(self, detail_url: str): 62 | detail = AnimeDetail() 63 | api = f"https://api.agefans.app/v2/detail/{detail_url}" # 20120029 64 | headers = { 65 | "Origin": "https://web.age-spa.com:8443", 66 | "Referer": "https://web.age-spa.com:8443/" 67 | } 68 | resp = await self.get(api, headers=headers) 69 | if not resp or resp.status != 200: 70 | return detail 71 | data = await resp.json(content_type=None) 72 | data = data["AniInfo"] 73 | detail.title = data["R动画名称"] 74 | detail.cover_url = "http:" + data["R封面图"] 75 | detail.desc = data["R简介"] 76 | detail.category = data["R标签"] 77 | 78 | for playlist in data["R在线播放All"]: 79 | if not playlist: 80 | continue 81 | pl = AnimePlayList() 82 | play_id = playlist[0]["PlayId"] 83 | if self.drop_this(play_id): 84 | continue 85 | pl.name = play_id.replace("", "").replace("", "").upper() 86 | for item in playlist: 87 | anime = Anime() 88 | anime.name = item["Title_l"] or item["Title"] 89 | play_id = item["PlayId"] # web_mp4|tieba|... 90 | play_vid = item["PlayVid"] # url or token 91 | anime.raw_url = play_id + "|" + play_vid 92 | pl.append(anime) 93 | detail.append_playlist(pl) 94 | return detail 95 | 96 | 97 | class AgeFansUrlParser(AnimeUrlParser): 98 | 99 | async def parse(self, raw_url: str): 100 | play_id, play_vid = raw_url.split("|") 101 | if play_vid.startswith("http"): 102 | return play_vid # 不用处理了 103 | 104 | api = "https://api.agefans.app/v2/_getplay" 105 | resp = await self.get(api) 106 | if not resp or resp.status != 200: 107 | return "" 108 | 109 | data = await resp.json(content_type=None) 110 | next_api = data.get("Location") 111 | if not next_api: 112 | return "" 113 | 114 | # 参数 kp 算法见 https://vip.cqkeb.com/agefans/js/chunk-3a9344fa.3f1985c3.js 115 | play_key = "agefans3382-getplay-1719" 116 | timestamp = data["ServerTime"] 117 | kp = md5(str(timestamp) + "{|}" + play_id + "{|}" + play_vid + "{|}" + play_key) 118 | params = {"playid": play_id, "vid": play_vid, "kt": timestamp, "kp": kp} 119 | resp = await self.get(next_api, params=params) 120 | if not resp or resp.status != 200: 121 | return "" 122 | data = await resp.json(content_type=None) 123 | p_url = data.get("purlf", "") 124 | v_url = data.get("vurl", "") 125 | url = p_url + v_url 126 | if not url: 127 | return "" 128 | return url.split("?url=")[-1] 129 | -------------------------------------------------------------------------------- /api/anime/_bde4.py: -------------------------------------------------------------------------------- 1 | """ 2 | 哔嘀影视新域名: https://www.mp4er.com/ 3 | 网页端加入了验证码, 考虑到验证码形式容易改变, 故未使用机器学习识别 4 | 5 | 新版本接口: 6 | GET https://www.mp4er.com/api/v1/search/$(keyword)/1 7 | token: B92506BB0E76A7A4BDC6581...(需计算) 8 | 9 | token := encrypt(timestamp, key) 10 | timestamp 为时间戳字符串经过 UTF-8 编码的二进制数组 11 | key 为 { 73, 76, 79, 86, 69, 66, 73, 68, 73, 89, 73, 78, 71, 82, 72, 73 }, 即 "ILOVEBIDIYINGRHI" 12 | 13 | Response 返回的 JSON 中 data 段需用 Base64 解码, 再经过 zlib 解压二进制流, 14 | 再经过某个算法解密, 最后得到数据. 该算法来自于 微信 内部使用的加密算法(似乎有所修改): 15 | 16 | See: https://github.com/save95/WeChatRE/blob/4b249bce4062e1f338f3e4bbee273b2a88814bf3/src/com/tencent/qqpim/utils/Cryptor.java 17 | 18 | 反汇编的 Smali 码看吐了, 不管了 19 | """ 20 | 21 | import re 22 | from gzip import decompress 23 | 24 | from api.core.anime import * 25 | from api.core.proxy import AnimeProxy 26 | 27 | 28 | class BDE4(AnimeSearcher): 29 | 30 | async def search(self, keyword: str): 31 | api = f"https://bde4.icu/search/{keyword}/1" # 只要第一页数据, 垃圾数据太多 32 | resp = await self.get(api) 33 | if not resp or resp.status != 200: 34 | return 35 | html = await resp.text() 36 | meta_list = self.xpath(html, '//div[@class="card"]') 37 | if not meta_list: 38 | return 39 | 40 | for meta in meta_list: 41 | content = meta.xpath('div[@class="content"]')[0] 42 | title = content.xpath('a[@class="header"]/@title')[0] 43 | info = "|".join(content.xpath('div[@class="description"]//text()')) 44 | if keyword not in title or "◎" in info: 45 | continue # 无关结果或者无法播放的老旧资源 46 | anime = AnimeMeta() 47 | anime.title = title 48 | cat_str = re.search(r'类型:\s?(.+?)\|', info) 49 | cat_str = cat_str.group(1) if cat_str else "" 50 | anime.category = cat_str.replace("\xa0", "").replace(" / ", "/") 51 | anime.cover_url = meta.xpath("a/img/@src")[0] 52 | anime.detail_url = meta.xpath('a[@class="image"]/@href')[0].split(";")[0] 53 | yield anime 54 | 55 | 56 | class BDE4DetailParser(AnimeDetailParser): 57 | 58 | async def parse(self, detail_url: str): 59 | detail = AnimeDetail() 60 | url = "https://bde4.icu" + detail_url 61 | resp = await self.get(url) 62 | if not resp or resp.status != 200: 63 | return detail 64 | 65 | html = await resp.text() 66 | info = self.xpath(html, '//div[@class="info0"]')[0] 67 | title = info.xpath("//h2/text()")[0] 68 | title_ = re.search(r'《(.+?)》', title) 69 | detail.title = title_.group(1) if title_ else title 70 | desc = "".join(info.xpath('div[@class="summary"]/text()')) 71 | detail.desc = desc.replace("\u3000", "").replace("剧情简介:", "").strip() 72 | detail.cover_url = info.xpath('img/@src')[0] 73 | playlist = AnimePlayList() 74 | playlist.name = "在线播放" 75 | video_blocks = self.xpath(html, '//div[@class="info1"]/a') 76 | for block in video_blocks: 77 | video = Anime() 78 | video.name = block.xpath("text()")[0] 79 | video.raw_url = block.xpath("@href")[0].split(';')[0] 80 | playlist.append(video) 81 | detail.append_playlist(playlist) 82 | return detail 83 | 84 | 85 | class BDE4UrlParser(AnimeUrlParser): 86 | 87 | async def parse(self, raw_url: str): 88 | url = "https://bde4.icu" + raw_url 89 | resp = await self.get(url) 90 | if not resp or resp.status != 200: 91 | return "" 92 | html = await resp.text() 93 | m3u8_url = re.search(r"(http.+?\.m3u8)", html) 94 | if m3u8_url: 95 | return m3u8_url.group(1).replace(r"\/", "/") 96 | # 没有 m3u8 视频, 还需要跳转一次 97 | token = re.search(r'ptoken\s?=\s?"(\w+?)"', html) 98 | if not token: 99 | return "" # 没搞头 100 | token = token.group(1) 101 | next_url = f"https://bde4.icu/god/{token}" 102 | resp = await self.get(next_url, allow_redirects=True) 103 | if not resp or resp.status != 200: 104 | return "" 105 | data = await resp.json(content_type=None) 106 | url = data.get("url", "") 107 | if "?rkey=" in url: 108 | # 该链接访问后立刻失效, url会发生细微变化(rkey几个大小变化) 109 | # H.265 编码的视频, 可能网页端无法播放 110 | return AnimeInfo(url=url, volatile=True) 111 | return AnimeInfo(url=url) 112 | 113 | 114 | class BDE4Proxy(AnimeProxy): 115 | 116 | def enforce_proxy(self, url: str) -> bool: 117 | if url.endswith(".m3u8"): 118 | return True # 正常访问行不通, 强制代理 119 | return False 120 | 121 | async def get_m3u8_text(self, index_url: str) -> str: 122 | data = await self.read_data(index_url) 123 | if not data: 124 | return "" 125 | gzip_data = data[3354:] # 前面是二维码的图片数据 126 | m3u8_text = decompress(gzip_data).decode("utf-8") 127 | return m3u8_text 128 | 129 | def fix_chunk_data(self, url: str, chunk: bytes) -> bytes: 130 | if "bde4" in url: 131 | return chunk[120:] 132 | return chunk 133 | -------------------------------------------------------------------------------- /docs/user/tools.rst: -------------------------------------------------------------------------------- 1 | .. _tools: 2 | 3 | ======================== 4 | 网页处理工具 5 | ======================== 6 | 7 | HEAD/GET/POST 8 | ========================= 9 | 10 | 引擎模板中所有的类, 都继承自 `HtmlParseHelper` , 它封装了不少工具, 11 | 可以帮助你处理网页。当然,也提供了基本的请求 `HEAD` / `GET` / `POST` 方法, 12 | 这些方法来自 `aiohttp` 库的 `ClientSession` 。 13 | 14 | `HtmlParseHelper` 类提供了下面 3 个成员函数: 15 | 16 | - async def head(self, url: str, params: dict = None, \*\*kwargs) -> Optional[ClientResponse] 17 | 18 | - async def get(self, url: str, params: dict = None, \*\*kwargs) -> Optional[ClientResponse] 19 | 20 | - async def post(self, url: str, data: dict = None, \*\*kwargs) -> Optional[ClientResponse] 21 | 22 | 参数见 `aiohttp 库的文档 `_ 23 | 24 | 为了复用 `ClientSession` 内部的连接池,每一个类内部创建了一个 Session 对象, 25 | 在请求发出前会自动完成一次初始化,并且捕获了处理过程中可能出现的异常。 26 | 如果未设置 Headers, 或者 Headers 中缺少 User-Agent, 27 | 将自动为每一个请求设置随机的 User-Agent,默认超时被设置为 `ClientTimeout(total=30, sock_connect=5)`。 28 | 如果请求过程中出现异常,这些方法将返回 `None`,所以在使用 Response 之前,最好检查一下它是否存在。 29 | 30 | 每一个请求的参数和响应的信息都记录在日志文件中,如果有问题,可以去 `api/logs/` 下查看。 31 | (控制台的日志等级为 `INFO`, 日志文件为 `DEBUG`) 32 | 33 | 需要注意的一个地方是,许多网页返回的 JSON 并不规范,在使用 ClientResponse 对象的 `.json()` 方法时可能出现问题, 34 | 最好加上参数 `content_type=None`。 35 | 36 | .. code-block:: python 37 | 38 | ... 39 | resp = await self.get("http://foo.bar") 40 | if not resp or resp.status != 200: 41 | return "" 42 | data = await resp.json(content_type=None) 43 | 44 | 指定 DNS 服务器 45 | ========================= 46 | 47 | 由于网络环境的复杂性以及一些众所周知的原因, 你所使用的 DNS 服务器可能 48 | 无法正确解析某些网站的域名。`HtmlParseHelper` 允许你在使用 49 | `HEAD` `GET` `POST` 时使用指定的 DNS 服务器解析域名。 50 | 51 | 引擎模板中所有的类都可以重写该方法, 每个类设置的 DNS 服务器只对本类发出的请求生效。 52 | 如 `AnimeSearcher` 设置了 DNS 服务器, 但 `AnimeDetailParser` 没有设置, 53 | 所以它仍然使用你系统的 DNS 服务器。 54 | 55 | .. code-block:: python 56 | 57 | def set_dns_server(self) -> List[str]: 58 | """设置自定义的 DNS 服务器地址""" 59 | return ["8.8.8.8", "8.8.4.4"] 60 | 61 | 62 | XPath 63 | =================== 64 | 65 | XPath 是提取网页数据的利器,当然 `HtmlParseHelper` 也封装了这个功能,它来自 `lxml` 库。 66 | 67 | 关于 XPath 的语法,参见 `w3school `_ , 68 | lxml的文档参见 `lxml.de `_ 69 | 70 | `HtmlParseHelper` 提供了下面的静态成员函数: 71 | 72 | - def xpath(html: str, xpath: str) -> Optional[etree.Element] 73 | 74 | 同样的,它捕获了处理中可能发生的异常,如果出错,返回 `None`,错误详情见日志。 75 | 76 | .. code-block:: python 77 | 78 | html = """ 79 |
80 | 81 | 82 |
83 | """ 84 | result = HtmlParseHelper.xpath(html, "//div[@class='container']/img") 85 | if not result: 86 | print("not result") 87 | print(f"Elements count: {len(result)}") 88 | for item in result: 89 | url = item.xpath("@src")[0] 90 | print(url) 91 | 92 | .. code-block:: bash 93 | 94 | Elements count: 2 95 | http://foo.bar.1 96 | http://foo.bar.2 97 | 98 | 并行处理 99 | ========================== 100 | 很多时候我们希望能并行解析很多网页,当然 `HtmlParseHelper` 提供了相关的功能。 101 | 102 | 下面两个静态成员函数用于处理并行任务,使用过多线/进程库的伙计可能对 `as_completed` 这个名字很熟悉, 103 | 没错,还是熟悉的味道,不过任务类型不再是函数,而是协程(Coroutine)对象。它返回一个迭代器, 104 | 每当提交的并行任务中有一个完成了,它立刻返回这个任务的结果。 105 | 106 | - async def as_completed(tasks: Iterable[Task]) -> AsyncIterator[T] 107 | - async def as_iter_completed(tasks: Iterable[IterTask]) -> AsyncIterator[T] 108 | 109 | 那么,下面的 `as_iter_completed` 又是什么鬼? 110 | 111 | 答:他们两个的参数不一样。`as_completed` 接受一个协程列表(可迭代的对象均可), 112 | 协程任务返回的结果类型为 `T` , 函数返回 `T` 的异步生成器。 113 | 而 `as_iter_completed` 接受的协程任务返回的结果为 `Iterable[T]` , 114 | 函数返回也是 `T` 的异步生成器, 自动对结果进行了迭代,并行提取网页数据的时候,我们需要用到它。 115 | 116 | 这是 `as_completed` 的例子: 117 | 118 | .. code-block:: python 119 | 120 | async def worker(): 121 | data = [1, 2, 3] 122 | return data 123 | 124 | 125 | async def test(): 126 | tasks = [worker(), worker(), worker()] 127 | async for item in HtmlParseHelper.as_completed(tasks): 128 | print(item, end=' ') 129 | 130 | 131 | asyncio.run(test()) 132 | 133 | .. code-block:: bash 134 | 135 | [1, 2, 3] [1, 2, 3] [1, 2, 3] 136 | 137 | 来看看 `as_iter_completed` 的效果: 138 | 139 | .. code-block:: python 140 | 141 | async def worker(): 142 | data = [1, 2, 3] 143 | return data 144 | 145 | 146 | async def test(): 147 | tasks = [worker(), worker(), worker()] 148 | async for item in HtmlParseHelper.as_iter_completed(tasks): 149 | print(item, end=' ') 150 | 151 | 152 | asyncio.run(test()) 153 | 154 | .. code-block:: bash 155 | 156 | 1 2 3 1 2 3 1 2 3 157 | 158 | 159 | 繁简转换 160 | ========================= 161 | 用于繁体中文和简体中文的转换的小工具,由 `zhconv` 库提供支持。 162 | 163 | 有时候我们抓取的网站并非大陆网站,这个时候需要将关键词转化为繁体, 164 | 将处理结果转换为简体。 165 | 166 | .. code-block:: python 167 | 168 | from api.utils.tool import * 169 | 170 | if __name__ == '__main__': 171 | print(convert_to_tw("进击的巨人")) 172 | print(convert_to_zh("從零開始的異世界")) 173 | 174 | .. code-block:: bash 175 | 176 | 進擊的巨人 177 | 从零开始的异世界 178 | 179 | 其它工具 180 | ========================== 181 | 对 MD5 和 BASE64 的简单封装,方便使用。 182 | 183 | .. code-block:: python 184 | 185 | from api.utils.tool import * 186 | 187 | if __name__ == '__main__': 188 | print(md5("进击的巨人")) 189 | print(b64encode("從零開始的異世界")) 190 | 191 | .. code-block:: bash 192 | 193 | d54146a0ddfdbc16ccfd28d7bdf74806 194 | 5b6e6Zu26ZaL5aeL55qE55Ww5LiW55WM -------------------------------------------------------------------------------- /api/core/danmaku.py: -------------------------------------------------------------------------------- 1 | from base64 import b16encode, b16decode 2 | from inspect import currentframe 3 | from typing import AsyncIterator, Optional 4 | from typing import List 5 | 6 | from api.core.abc import Tokenizable 7 | from api.core.helper import HtmlParseHelper 8 | from api.utils.logger import logger 9 | 10 | 11 | class Danmaku(object): 12 | """视频的弹幕库, 包含弹幕的 id 信息, 用于进一步解析出弹幕数据""" 13 | 14 | def __init__(self): 15 | #: 视频名 16 | self.name = "" 17 | #: 弹幕 id, 用于解析出弹幕 18 | self.cid = "" 19 | self.module = "" 20 | 21 | def __repr__(self): 22 | return f"" 23 | 24 | 25 | class DanmakuMeta(Tokenizable): 26 | """番剧弹幕的元信息, 包含指向播放页的链接, 用于进一步处理""" 27 | 28 | def __init__(self): 29 | #: 弹幕库名字(番剧名) 30 | self.title = "" 31 | #: 视频数量 32 | self.num = 0 33 | #: 播放页的链接或者参数 34 | self.play_url = "" 35 | self.module = currentframe().f_back.f_globals["__name__"] 36 | 37 | @property 38 | def token(self) -> str: 39 | """通过引擎名和详情页信息生成, 可唯一表示本资源位置""" 40 | name = self.module.split('.')[-1] 41 | sign = f"{name}|{self.play_url}".encode("utf-8") 42 | return b16encode(sign).decode("utf-8").lower() 43 | 44 | @classmethod 45 | def build_from(cls, token: str) -> "DanmakuMeta": 46 | name, play_url = b16decode(token.upper()).decode("utf-8").split("|", 1) 47 | meta = DanmakuMeta() 48 | meta.module = "api.danmaku." + name 49 | meta.play_url = play_url 50 | return meta 51 | 52 | def __repr__(self): 53 | return f"" 54 | 55 | 56 | class DanmakuDetail(object): 57 | """一部番剧所有视频的 Danmaku 集合""" 58 | 59 | def __init__(self): 60 | #: 弹幕库名字(番剧名) 61 | self.title = "" 62 | #: 视频数量 63 | self.num = 0 64 | self.module = currentframe().f_back.f_globals["__name__"] 65 | self._dmk_list: List[Danmaku] = [] # 弹幕对象列表 66 | 67 | def append(self, danmaku: Danmaku): 68 | danmaku.module = self.module 69 | self._dmk_list.append(danmaku) 70 | self.num += 1 71 | 72 | def is_empty(self) -> bool: 73 | return not self._dmk_list 74 | 75 | def get_danmaku(self, index: int) -> Optional[Danmaku]: 76 | try: 77 | return self._dmk_list[index] 78 | except IndexError: 79 | logger.error(f"IndexError, danmaku index: {index}") 80 | return None 81 | 82 | def __iter__(self): 83 | return iter(self._dmk_list) 84 | 85 | def __repr__(self): 86 | return f"" 87 | 88 | 89 | class DanmakuData(object): 90 | """ 91 | 一集视频的弹幕内容, 92 | 按照 Dplayer v1.26.0 格式设计, 93 | 弹幕格式为: [time, pos, color, user, message], 94 | 距离视频开头的秒数(float), 位置参数(0右边, 1上边, 2底部), 颜色码 10 进制, 用户名, 弹幕内容 95 | """ 96 | 97 | def __init__(self): 98 | self.num = 0 # 弹幕条数 99 | self.data = [] 100 | 101 | def append_bullet(self, time: float, pos: int, color: int, message: str): 102 | """ 103 | 添加一条弹幕 104 | 105 | :param time: 此条弹幕出现的时间点(秒) 106 | :param pos: 弹幕出现的位置(0右边, 1上边, 2底部) 107 | :param color: 弹幕颜色码(10进制表示) 108 | :param message: 弹幕内容 109 | """ 110 | self.data.append([time, pos, color, "", message]) 111 | self.num += 1 112 | 113 | def append(self, bullet: List): 114 | """添加一条弹幕""" 115 | self.data.append(bullet) 116 | self.num += 1 117 | 118 | def extend(self, other): 119 | """合并另一个弹幕数据""" 120 | for bullet in other: 121 | self.data.append(bullet) 122 | self.num += other.num 123 | 124 | def is_empty(self): 125 | return self.num == 0 126 | 127 | def __iter__(self): 128 | return iter(self.data) 129 | 130 | def __repr__(self): 131 | return f"" 132 | 133 | 134 | class DanmakuSearcher(HtmlParseHelper): 135 | """ 136 | 弹幕库引擎基类, 用户自定义的引擎应该继承它 137 | """ 138 | 139 | async def search(self, keyword: str) -> AsyncIterator[DanmakuMeta]: 140 | """搜索相关番剧, 返回指向番剧详情页的信息""" 141 | yield 142 | 143 | async def _search(self, keyword: str) -> AsyncIterator[DanmakuMeta]: 144 | """引擎管理器负责调用, 捕获异常""" 145 | try: 146 | await self.init_session() 147 | async for item in self.search(keyword): 148 | yield item 149 | except Exception as e: 150 | logger.exception(e) 151 | return 152 | 153 | 154 | class DanmakuDetailParser(HtmlParseHelper): 155 | 156 | async def parse(self, play_url: str) -> DanmakuDetail: 157 | pass 158 | 159 | async def _parse(self, play_url: str) -> DanmakuDetail: 160 | try: 161 | await self.init_session() 162 | return await self.parse(play_url) 163 | except Exception as e: 164 | logger.exception(e) 165 | return DanmakuDetail() 166 | 167 | 168 | class DanmakuDataParser(HtmlParseHelper): 169 | 170 | async def parse(self, cid: str) -> DanmakuData: 171 | """ 172 | 提供弹幕的 id, 解析出弹幕的内容, 并处理成 DPlayer 支持的格式 173 | """ 174 | pass 175 | 176 | async def _parse(self, cid: str) -> DanmakuData: 177 | """引擎管理器负责调用, 捕获异常""" 178 | try: 179 | await self.init_session() 180 | return await self.parse(cid) 181 | except Exception as e: 182 | logger.exception(e) 183 | return DanmakuData() 184 | -------------------------------------------------------------------------------- /api/danmaku/iqiyi.py: -------------------------------------------------------------------------------- 1 | import re 2 | from zlib import decompress 3 | 4 | from api.core.danmaku import * 5 | 6 | 7 | class IQIYI(DanmakuSearcher): 8 | 9 | async def search(self, keyword: str): 10 | api = "https://search.video.iqiyi.com/o" 11 | params = { 12 | "if": "html5", 13 | "key": keyword, 14 | "pageNum": 1, 15 | "pageSize": 100, 16 | "video_allow_3rd": 0 # 似乎没有用 17 | } 18 | resp = await self.get(api, params=params) 19 | if not resp or resp.status != 200: 20 | return 21 | data = await resp.json(content_type=None) 22 | data = data["data"] 23 | if "search result is empty" in data: 24 | return # 没有结果 25 | data = data["docinfos"] 26 | for item in data: 27 | if self.drop_this(item): 28 | continue 29 | 30 | item = item["albumDocInfo"] 31 | meta = DanmakuMeta() 32 | meta.num = int(item["itemTotalNumber"]) 33 | meta.title = item["albumTitle"] 34 | # 这里可以拿到 albumId 值, 但是对于老旧资源, 下一步无法获取详情数据 35 | meta.play_url = str(item["albumId"]) + '|' + item["albumLink"] 36 | yield meta 37 | 38 | def drop_this(self, item: dict) -> bool: 39 | if item["score"] < 2: 40 | return True # 相关度太低 41 | 42 | item = item["albumDocInfo"] 43 | if item.get("siteId", "") != "iqiyi": 44 | return True # 其它平台的 45 | if "生活" in item["channel"]: 46 | return True # 经常出现无关内容 47 | if not item.get("itemTotalNumber"): 48 | return True # 没有用的视频 49 | title = item["albumTitle"] 50 | if "精彩看点" in title or "精彩片段" in title: 51 | return True # 垃圾数据 52 | return False 53 | 54 | 55 | class IQIYIDanmakuDetailParser(DanmakuDetailParser): 56 | 57 | async def parse(self, play_url: str): 58 | aid, url = play_url.split('|') 59 | detail = await self.get_detail_with_aid(aid) 60 | if not detail.is_empty(): 61 | return detail # 通过参数拿到了数据 62 | 63 | # 对于电影, 无法获取到播放列表的详细情况 64 | detail = await self.get_movie_detail(aid) 65 | if not detail.is_empty(): 66 | return detail 67 | 68 | # 老旧资源, aid 无效, 需要去网页提取 69 | resp = await self.get(url) 70 | if not resp or resp.status != 200: 71 | return detail 72 | 73 | html = await resp.text() 74 | aid = re.search(r'albumId:\s*"(\d+?)"', html) 75 | if not aid: 76 | return detail # 没搞头 77 | return await self.get_detail_with_aid(aid.group(1)) 78 | 79 | async def get_detail_with_aid(self, aid: str): 80 | detail = DanmakuDetail() 81 | api = "https://pub.m.iqiyi.com/h5/main/videoList/album/" 82 | params = {"albumId": aid, "size": 500, "page": 1} 83 | resp = await self.get(api, params=params) 84 | data = await resp.json(content_type=None) 85 | data = data["data"] 86 | if not data: 87 | return detail 88 | 89 | for item in data["videos"]: 90 | danmaku = Danmaku() 91 | danmaku.name = item["subTitle"] 92 | danmaku.cid = f'{item["id"]}|{item["duration"]}' # id|duration 93 | detail.append(danmaku) 94 | return detail 95 | 96 | async def get_movie_detail(self, aid: str): 97 | detail = DanmakuDetail() 98 | api = f"https://pcw-api.iqiyi.com/video/video/baseinfo/{aid}" 99 | resp = await self.get(api) 100 | if not resp or resp.status != 200: 101 | return detail 102 | data = await resp.json(content_type=None) 103 | data = data["data"] 104 | if not data or data == "参数错误": 105 | return detail 106 | danmaku = Danmaku() 107 | danmaku.name = data["name"] 108 | danmaku.cid = str(data["albumId"]) + "|" + data["duration"] 109 | detail.append(danmaku) 110 | return detail 111 | 112 | 113 | class IQIYIDanmakuDataParser(DanmakuDataParser): 114 | 115 | async def parse(self, cid: str): 116 | result = DanmakuData() 117 | vid, duration = cid.split('|') 118 | arg = f"{vid[-4:-2]}/{vid[-2:]}/{vid}" 119 | count = self.duration2sec(duration) // 300 + 1 120 | tasks = [self.get_5_min_bullets(arg, i) for i in range(1, count + 1)] 121 | async for ret in self.as_iter_completed(tasks): 122 | result.append(ret) 123 | return result 124 | 125 | @staticmethod 126 | def duration2sec(duration: str): 127 | # "24:15" "01:31:46" => seconds 128 | tm = duration.split(':') 129 | if len(tm) == 2: 130 | tm.insert(0, "0") # 小时为 0 131 | tm = list(map(int, tm)) 132 | return tm[0] * 3600 + tm[1] * 60 + tm[2] 133 | 134 | async def get_5_min_bullets(self, arg: str, start_at: int): 135 | result = DanmakuData() 136 | # 一次拿 5 分钟的弹幕 137 | api = f"https://cmts.iqiyi.com/bullet/{arg}_300_{start_at}.z" 138 | resp = await self.get(api) 139 | if not resp or resp.status != 200: 140 | return 141 | data = await resp.read() 142 | data = decompress(data) 143 | for item in self.xml_xpath(data, "//bulletInfo"): 144 | time = float(item.xpath("showTime/text()")[0]) 145 | position = int(item.xpath("position/text()")[0]) 146 | content = item.xpath("content/text()")[0] 147 | color = int(item.xpath("color/text()")[0], 16) 148 | result.append_bullet(time=time, pos=position, message=content, color=color) 149 | 150 | return result 151 | -------------------------------------------------------------------------------- /api/core/scheduler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from time import perf_counter 3 | from typing import Callable, Coroutine, Type 4 | 5 | from api.core.anime import * 6 | from api.core.danmaku import * 7 | from api.core.loader import ModuleLoader 8 | from api.core.proxy import AnimeProxy 9 | from api.utils.logger import logger 10 | 11 | 12 | class Scheduler: 13 | """ 14 | 调度器, 负责调度引擎搜索、解析资源 15 | """ 16 | 17 | def __init__(self): 18 | self._loader = ModuleLoader() 19 | 20 | async def search_anime( 21 | self, 22 | keyword: str, 23 | *, 24 | callback: Callable[[AnimeMeta], None] = None, 25 | co_callback: Callable[[AnimeMeta], Coroutine] = None 26 | ) -> None: 27 | """ 28 | 异步搜索动漫 29 | 30 | :param keyword: 关键词 31 | :param callback: 处理搜索结果的回调函数 32 | :param co_callback: 处理搜索结果的协程函数 33 | 34 | 如果设置了 callback, 忽视 co_callback 35 | """ 36 | if not keyword: 37 | return 38 | 39 | async def run(searcher: AnimeSearcher): 40 | logger.info(f"{searcher.__class__.__name__} is searching for [{keyword}]") 41 | if callback is not None: 42 | async for item in searcher._search(keyword): 43 | callback(item) # 每产生一个搜索结果, 通过回调函数处理 44 | return 45 | if co_callback is not None: 46 | async for item in searcher._search(keyword): 47 | await co_callback(item) 48 | 49 | searchers = self._loader.get_anime_searchers() 50 | if not searchers: 51 | logger.warning(f"No anime searcher enabled") 52 | return 53 | 54 | logger.info(f"Searching Anime -> [{keyword}], enabled engines: {len(searchers)}") 55 | start_time = perf_counter() 56 | await asyncio.wait([run(s) for s in searchers]) 57 | end_time = perf_counter() 58 | logger.info(f"Searching anime finished in {end_time - start_time:.2f}s") 59 | 60 | async def search_danmaku( 61 | self, 62 | keyword: str, 63 | *, 64 | callback: Callable[[DanmakuMeta], None] = None, 65 | co_callback: Callable[[DanmakuMeta], Coroutine] = None 66 | ) -> None: 67 | """ 68 | 搜索弹幕库 69 | """ 70 | 71 | async def run(searcher: DanmakuSearcher): 72 | logger.info(f"{searcher.__class__.__name__} is searching for [{keyword}]") 73 | if callback is not None: 74 | async for item in searcher._search(keyword): 75 | callback(item) 76 | return 77 | if co_callback is not None: 78 | async for item in searcher._search(keyword): 79 | await co_callback(item) 80 | 81 | searchers = self._loader.get_danmaku_searcher() 82 | if not searchers: 83 | logger.warning(f"No danmaku searcher enabled") 84 | return 85 | 86 | logger.info(f"Searching Danmaku -> [{keyword}], enabled engines: {len(searchers)}") 87 | start_time = perf_counter() 88 | await asyncio.wait([run(s) for s in searchers]) 89 | end_time = perf_counter() 90 | logger.info(f"Searching danmaku finished in {end_time - start_time:.2f}s") 91 | 92 | async def parse_anime_detail(self, meta: AnimeMeta) -> AnimeDetail: 93 | """解析番剧详情页信息""" 94 | detail_parser = self._loader.get_anime_detail_parser(meta.module) 95 | if not detail_parser: # 直接访问直链, 且配置文件已关闭模块, 把工具类加载起来完成解析 96 | self._loader.load_utils_module(meta.module) 97 | detail_parser = self._loader.get_anime_detail_parser(meta.module) 98 | logger.info(f"{detail_parser.__class__.__name__} parsing {meta.detail_url}") 99 | if detail_parser is not None: 100 | return await detail_parser._parse(meta.detail_url) 101 | return AnimeDetail() 102 | 103 | async def parse_anime_real_url(self, anime: Anime) -> AnimeInfo: 104 | """解析一集视频的直链""" 105 | url_parser = self._loader.get_anime_url_parser(anime.module) 106 | logger.info(f"{url_parser.__class__.__name__} parsing {anime.raw_url}") 107 | url = await url_parser._parse(anime.raw_url) 108 | if url.is_available(): 109 | return url 110 | logger.warning(f"Parse real url failed") 111 | return AnimeInfo() 112 | 113 | def get_anime_proxy_class(self, meta: AnimeMeta) -> Type[AnimeProxy]: 114 | """获取视频代理器类""" 115 | return self._loader.get_anime_proxy_class(meta.module) 116 | 117 | async def parse_danmaku_detail(self, meta: DanmakuMeta) -> DanmakuDetail: 118 | """解析弹幕库详情信息""" 119 | detail_parser = self._loader.get_danmaku_detail_parser(meta.module) 120 | if not detail_parser: 121 | self._loader.load_utils_module(meta.module) 122 | detail_parser = self._loader.get_danmaku_detail_parser(meta.module) 123 | logger.info(f"{detail_parser.__class__.__name__} parsing {meta.play_url}") 124 | if detail_parser is not None: 125 | return await detail_parser._parse(meta.play_url) 126 | return DanmakuDetail() 127 | 128 | async def parse_danmaku_data(self, danmaku: Danmaku) -> DanmakuData: 129 | """解析一集弹幕的数据""" 130 | data_parser = self._loader.get_danmaku_data_parser(danmaku.module) 131 | logger.debug(f"{data_parser.__class__.__name__} parsing {danmaku.cid}") 132 | if data_parser is not None: 133 | start_time = perf_counter() 134 | data = await data_parser._parse(danmaku.cid) 135 | end_time = perf_counter() 136 | logger.info(f"Reading danmaku data finished in {end_time - start_time:.2f}s") 137 | return data 138 | return DanmakuData() 139 | 140 | def change_module_state(self, module: str, enable: bool): 141 | """设置模块启用状态""" 142 | return self._loader.change_module_state(module, enable) 143 | -------------------------------------------------------------------------------- /api/core/loader.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from inspect import getmembers 3 | from inspect import isclass 4 | from typing import Dict, Type 5 | 6 | from api.config import Config 7 | from api.core.abc import singleton 8 | from api.core.anime import * 9 | from api.core.danmaku import * 10 | from api.core.proxy import AnimeProxy 11 | from api.utils.logger import logger 12 | 13 | 14 | @singleton 15 | class ModuleLoader(object): 16 | """ 17 | 模块加载器, 负责动态加载/卸载各个模块 18 | """ 19 | 20 | def __init__(self): 21 | # Anime 22 | self._config = Config() 23 | self._anime_searchers: Dict[str, AnimeSearcher] = {} 24 | self._anime_detail_parsers: Dict[str, AnimeDetailParser] = {} 25 | self._anime_url_parsers: Dict[str, AnimeUrlParser] = {} 26 | self._anime_proxy_cls: Dict[str, Type[AnimeProxy]] = {} 27 | # Danmaku 28 | self._danmaku_searchers: Dict[str, DanmakuSearcher] = {} 29 | self._danmaku_detail_parsers: Dict[str, DanmakuDetailParser] = {} 30 | self._danmaku_data_parsers: Dict[str, DanmakuDataParser] = {} 31 | 32 | for module in self._config.get_enabled_modules(): 33 | self.load_full_module(module) 34 | 35 | def load_full_module(self, module: str): 36 | """ 37 | 加载模块中所以的类 38 | :param module: 引擎引擎模块名, api.xxx.xxx 39 | """ 40 | self.load_search_module(module) 41 | self.load_utils_module(module) 42 | 43 | def load_search_module(self, module: str): 44 | """ 45 | 只加载搜索引擎模块, 用于搜索资源 46 | """ 47 | py_module = import_module(module) 48 | for name, cls in getmembers(py_module, isclass): 49 | 50 | if issubclass(cls, AnimeSearcher) and cls != AnimeSearcher \ 51 | and module not in self._anime_searchers: 52 | self._anime_searchers[module] = cls() # 创建 Searcher 对象, 程序执行期间一直使用 53 | logger.info(f"Loading {name}: {cls}") 54 | 55 | if issubclass(cls, DanmakuSearcher) and cls != DanmakuSearcher \ 56 | and module not in self._danmaku_searchers: 57 | self._danmaku_searchers[module] = cls() 58 | logger.info(f"Loading {name}: {cls}") 59 | 60 | def load_utils_module(self, module: str): 61 | """ 62 | 只加载工具模块, 用于资源数据的解析 63 | """ 64 | py_module = import_module(module) 65 | for name, cls in getmembers(py_module, isclass): 66 | 67 | if issubclass(cls, AnimeDetailParser) and cls != AnimeDetailParser \ 68 | and module not in self._anime_detail_parsers: 69 | self._anime_detail_parsers[module] = cls() 70 | logger.info(f"Loading {name}: {cls}") 71 | 72 | if issubclass(cls, AnimeUrlParser) and cls != AnimeUrlParser \ 73 | and module not in self._anime_url_parsers: 74 | self._anime_url_parsers[module] = cls() 75 | logger.info(f"Loading {name}: {cls}") 76 | 77 | if issubclass(cls, AnimeProxy) and cls != AnimeProxy \ 78 | and module not in self._anime_proxy_cls: 79 | self._anime_proxy_cls[module] = cls # 只加载 class, 动态创建 80 | logger.info(f"Loading {name}: {cls}") 81 | 82 | if issubclass(cls, DanmakuDetailParser) and cls != DanmakuDetailParser \ 83 | and module not in self._danmaku_detail_parsers: 84 | self._danmaku_detail_parsers[module] = cls() 85 | logger.info(f"Loading {name}: {cls}") 86 | 87 | if issubclass(cls, DanmakuDataParser) and cls != DanmakuDataParser \ 88 | and module not in self._danmaku_data_parsers: 89 | self._danmaku_data_parsers[module] = cls() 90 | logger.info(f"Loading {name}: {cls}") 91 | 92 | def unload_full_module(self, module: str) -> None: 93 | """卸载模块名对应的引擎""" 94 | if module.startswith("api.anime"): 95 | self._anime_searchers.pop(module, None) 96 | self._anime_detail_parsers.pop(module, None) 97 | self._anime_url_parsers.pop(module, None) 98 | self._anime_proxy_cls.pop(module, None) 99 | if module.startswith("api.danmaku"): 100 | self._danmaku_searchers.pop(module, None) 101 | self._danmaku_detail_parsers.pop(module, None) 102 | self._danmaku_data_parsers.pop(module, None) 103 | logger.info(f"Unloaded ") 104 | 105 | def change_module_state(self, module: str, enable: bool) -> bool: 106 | """动态加载/卸载引擎, 并更新配置文件""" 107 | try: 108 | if enable: # 加载引擎 109 | self.load_full_module(module) 110 | return self._config.update_module_state(module, True) 111 | else: # 卸载引擎 112 | self.unload_full_module(module) 113 | return self._config.update_module_state(module, False) 114 | except ModuleNotFoundError: 115 | logger.error(f"Module not found: {module}") 116 | return False 117 | 118 | def get_anime_searchers(self) -> List[AnimeSearcher]: 119 | return list(self._anime_searchers.values()) 120 | 121 | def get_anime_detail_parser(self, module: str) -> Optional[AnimeDetailParser]: 122 | return self._anime_detail_parsers.get(module) 123 | 124 | def get_anime_url_parser(self, module: str) -> AnimeUrlParser: 125 | return self._anime_url_parsers.get(module) or AnimeUrlParser() # 没有就有默认的 126 | 127 | def get_anime_proxy_class(self, module: str) -> Type[AnimeProxy]: 128 | return self._anime_proxy_cls.get(module) or AnimeProxy 129 | 130 | def get_danmaku_searcher(self) -> List[DanmakuSearcher]: 131 | return list(self._danmaku_searchers.values()) 132 | 133 | def get_danmaku_detail_parser(self, module: str) -> Optional[DanmakuDetailParser]: 134 | return self._danmaku_detail_parsers.get(module) 135 | 136 | def get_danmaku_data_parser(self, module: str) -> Optional[DanmakuDataParser]: 137 | return self._danmaku_data_parsers.get(module) 138 | -------------------------------------------------------------------------------- /api/danmaku/bilibili/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | from json import loads 3 | 4 | from google.protobuf.json_format import MessageToDict 5 | 6 | from api.core.danmaku import * 7 | from . import danmaku_pb2 8 | 9 | 10 | class BiliBili(DanmakuSearcher): 11 | """搜索哔哩哔哩官方和用户上传的番剧弹幕""" 12 | 13 | async def get_data_with_params(self, params: dict): 14 | api = "https://api.bilibili.com/x/web-interface/search/type" 15 | resp = await self.get(api, params=params) 16 | if not resp or resp.status != 200: 17 | return 18 | 19 | data = await resp.json(content_type=None) 20 | if data["code"] != 0 or data["data"]["numResults"] == 0: 21 | return 22 | 23 | for item in data["data"]["result"]: 24 | if '' not in item["title"]: # 没有匹配关键字, 是B站的推广视频 25 | continue 26 | if "港澳台" in item["title"]: 27 | continue # 港澳台地区弹幕少的可怜 28 | title = item["title"].replace(r'', "").replace("", "") # 番剧标题 29 | num = int(item.get("ep_size") or -1) # 集数, 未知的时候用 -1 表示 30 | play_num = int(item.get("play") or -1) # 用户投稿的视频播放数 31 | play_url = item.get("goto_url") or item.get("arcurl") # 番剧播放页链接 32 | play_url = re.sub(r"https?://www.bilibili.com", "", play_url) # 缩短一些, 只留关键信息 33 | yield title, num, play_num, play_url 34 | 35 | async def search_from_official(self, params: dict): 36 | """官方番剧区数据, 全部接收""" 37 | results = [] 38 | async for item in self.get_data_with_params(params): 39 | title, num, _, play_url = item 40 | meta = DanmakuMeta() 41 | meta.title = title 42 | meta.play_url = play_url 43 | meta.num = num 44 | results.append(meta) 45 | return results 46 | 47 | async def search_from_users(self, params: dict): 48 | """用户投稿的番剧, 说不定有好东西""" 49 | results = [] 50 | async for item in self.get_data_with_params(params): 51 | title, num, play_num, play_url = item 52 | if play_num > 100_000: # 播放量大于 10w 的留着 53 | meta = DanmakuMeta() 54 | meta.title = title 55 | meta.play_url = play_url 56 | meta.num = num 57 | results.append(meta) 58 | return results 59 | 60 | async def search(self, keyword: str): 61 | params1 = {"keyword": keyword, "search_type": "media_bangumi", "page": 1} # 搜索番剧 62 | params2 = {"keyword": keyword, "search_type": "media_ft", "page": 1} # 搜索影视 63 | params3 = {"keyword": keyword, "search_type": "video", "tids": 13, "order": "dm", 64 | "page": 1, "duration": 4} # 用户上传的 60 分钟以上的视频, 按弹幕数量排序 65 | tasks = [ 66 | self.search_from_official(params1), 67 | self.search_from_official(params2), 68 | self.search_from_users(params3) 69 | ] 70 | async for item in self.as_iter_completed(tasks): 71 | yield item 72 | 73 | 74 | class BiliDanmakuDetailParser(DanmakuDetailParser): 75 | 76 | async def parse(self, play_url: str) -> DanmakuDetail: 77 | detail = DanmakuDetail() 78 | play_url = "https://www.bilibili.com" + play_url 79 | resp = await self.get(play_url) 80 | if not resp or resp.status != 200: 81 | return detail 82 | 83 | html = await resp.text() 84 | data_json = re.search(r"window.__INITIAL_STATE__=({.+?});\(function\(\)", html) 85 | data_json = loads(data_json.group(1)) 86 | ep_list = data_json.get("epList") # 官方番剧数据 87 | if not ep_list and data_json.get("sections"): 88 | ep_list = data_json["sections"][0]["epList"] # PV 的数据位置不一样 89 | if ep_list: # 官方番剧 90 | for ep in ep_list: 91 | danmaku = Danmaku() 92 | danmaku.name = ep["titleFormat"] + ep["longTitle"] 93 | danmaku.cid = f'{ep["cid"]}|{ep["aid"]}' # 新版api需要两个参数 94 | detail.append(danmaku) 95 | return detail 96 | # 用户上传的视频 97 | ep_list = data_json.get("videoData").get("pages") 98 | aid = data_json.get("aid") 99 | for ep in ep_list: # 用户上传的视频 100 | danmaku = Danmaku() 101 | danmaku.name = ep.get("part") or ep.get("from") 102 | danmaku.cid = f'{ep["cid"]}|{aid}' 103 | detail.append(danmaku) 104 | return detail 105 | 106 | 107 | class BiliDanmakuDataParser(DanmakuDataParser): 108 | 109 | async def parse(self, cid: str) -> DanmakuData: 110 | result = DanmakuData() 111 | cid, aid = cid.split('|') 112 | info_api_v2 = f"https://api.bilibili.com/x/v2/dm/web/view" 113 | params = {"oid": cid, "pid": aid, "type": 1} 114 | resp = await self.get(info_api_v2, params) 115 | if not resp or resp.status != 200: 116 | return result 117 | 118 | data = await resp.read() 119 | pb2 = danmaku_pb2.DanmakuInfo() 120 | pb2.ParseFromString(data) 121 | data = MessageToDict(pb2) 122 | total = int(data["seg"]["total"]) 123 | tasks = [self.get_one_page_bullet(params, p) for p in range(1, total + 1)] 124 | async for ret in self.as_iter_completed(tasks): 125 | result.append(ret) 126 | result.data.sort(key=lambda x: x[0], reverse=True) 127 | return result 128 | 129 | async def get_one_page_bullet(self, params: dict, page: int): 130 | result = DanmakuData() 131 | data_api_v2 = "https://api.bilibili.com/x/v2/dm/web/seg.so" 132 | params = params.copy() 133 | params.update({"segment_index": page}) 134 | resp = await self.get(data_api_v2, params=params) 135 | if not resp or resp.status != 200: 136 | return result 137 | data = await resp.read() 138 | pb2 = danmaku_pb2.DanmakuData() 139 | pb2.ParseFromString(data) 140 | data = MessageToDict(pb2) 141 | # 哔哩哔哩: 1 飞行弹幕, 4 底部弹幕, 5 顶部弹幕, 6 逆向飞行弹幕 142 | # Dplayer: 0 飞行弹幕, 1 顶部弹幕, 2 底部弹幕 143 | pos_fix = {1: 0, 5: 1, 4: 2, 6: 0} 144 | for bullet in data.get("bullet", []): # 可能这一页没有数据 145 | result.append_bullet( 146 | time=bullet.get("progress", 0) / 1000, 147 | pos=pos_fix.get(bullet["mode"], 0), 148 | color=bullet.get("color", 16777215), 149 | message=bullet.get("content", "") 150 | ) 151 | return result 152 | -------------------------------------------------------------------------------- /api/danmaku/tencent.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | from api.core.danmaku import * 5 | 6 | 7 | class Tencent(DanmakuSearcher): 8 | 9 | async def search(self, keyword: str) -> AsyncIterator[DanmakuMeta]: 10 | tasks = [self.search_one_page(keyword, p) for p in range(5)] # 取前10页 11 | async for meta in self.as_iter_completed(tasks): 12 | yield meta 13 | 14 | async def search_one_page(self, keyword: str, page: int): 15 | api = f"http://node.video.qq.com/x/api/msearch" 16 | params = { 17 | "keyWord": keyword, 18 | "callback": f"jsonp{page}", 19 | "contextValue": f"last_end={page * 15}&response=1", 20 | "contextType": 2 21 | } 22 | resp = await self.get(api, params=params) 23 | if not resp or resp.status != 200: 24 | return 25 | jsonp = await resp.text() 26 | data = re.search(r".+\(({.+})\)", jsonp).group(1) 27 | data = json.loads(data) 28 | data = data["uiData"] 29 | results = [] 30 | for item in data: 31 | item = item["data"] 32 | if not item: # 有时没有 33 | continue 34 | item = item[0] 35 | if not item.get("videoSrcName"): 36 | continue # 没用的视频 37 | if "redirect" in item["webPlayUrl"]: 38 | continue # 其它平台的 39 | title = item["coverTitle"].replace("\u0005", "").replace("\u0006", "") 40 | if not title: 41 | continue 42 | meta = DanmakuMeta() 43 | meta.title = item["coverTitle"].replace("\u0005", "").replace("\u0006", "") 44 | meta.play_url = re.search(r"/([^/]+?)\.html", item["webPlayUrl"]).group(1) 45 | meta.num = item["videoSrcName"][0]["totalEpisode"] 46 | results.append(meta) 47 | return results 48 | 49 | 50 | class TencentDanmakuDetailParser(DanmakuDetailParser): 51 | 52 | async def parse(self, play_url: str) -> DanmakuDetail: 53 | detail = DanmakuDetail() 54 | api = "http://s.video.qq.com/get_playsource" 55 | params = { 56 | "id": play_url, 57 | "type": 4, 58 | "range": "1-99999", 59 | "otype": "json" 60 | } 61 | resp = await self.get(api, params=params) 62 | if not resp or resp.status != 200: 63 | return detail 64 | text = await resp.text() 65 | data = text.lstrip("QZOutputJson=").rstrip(";") 66 | data = json.loads(data) 67 | data = data["PlaylistItem"] 68 | if not data: 69 | return detail 70 | data = data["videoPlayList"] 71 | for item in data: 72 | danmaku = Danmaku() 73 | danmaku.name = item["title"] 74 | if "预告片" in danmaku.name: 75 | continue # 预告片不要 76 | danmaku.cid = item["id"] # 视频id "j31520vrtpw" 77 | detail.append(danmaku) 78 | return detail 79 | 80 | 81 | class TencentDanmakuDataParser(DanmakuDataParser): 82 | 83 | async def parse(self, cid: str) -> DanmakuData: 84 | """获取视频的全部弹幕""" 85 | result = DanmakuData() 86 | title, duration, target_id = await self.get_video_info(cid) 87 | if not target_id: 88 | return result 89 | count = duration // 30 + 1 90 | tasks = [self.get_30s_bullets(cid, target_id, t * 30) for t in range(count)] 91 | async for ret in self.as_iter_completed(tasks): 92 | result.append(ret) 93 | return result 94 | 95 | async def get_30s_bullets(self, video_id: str, target_id: str, start_at: int): 96 | """获取某个时间点后的 30s 弹幕数据 97 | :params video_id 视频 url 中的id 98 | :params target_id 视频的数字id 99 | :params start_at 弹幕起始时间点(s) 100 | """ 101 | result = DanmakuData() 102 | api = "https://mfm.video.qq.com/danmu" 103 | params = { 104 | "otype": "json", 105 | "target_id": f"{target_id}&vid={video_id}", 106 | "session_key": "0,0,0", 107 | "timestamp": start_at 108 | } 109 | # sleep(0.1) # 太快了有可能被 ban 110 | resp = await self.get(api, params=params) 111 | if not resp or resp.status != 200: 112 | return result 113 | 114 | data = await resp.text() 115 | data = json.loads(data, strict=False) # 可能有特殊字符 116 | for item in data["comments"]: 117 | play_at = item["timepoint"] # 弹幕出现的时间点(s) 118 | content = item["content"].replace("\xa0", "").strip() # 弹幕内容 119 | content = re.sub(r"^(.*?:)|^(.*?:)|\[.+?\]", "", content) 120 | if not content: 121 | continue 122 | style = item["content_style"] 123 | if not style: 124 | color = "ffffff" 125 | position = 0 126 | else: 127 | style = json.loads(style) 128 | color = style.get("color", "ffffff") # 10 进制颜色 129 | position = style["position"] 130 | 131 | result.append_bullet( 132 | time=play_at, 133 | pos=position, 134 | color=int(color, 16), 135 | message=content 136 | ) 137 | return result 138 | 139 | async def get_video_info(self, video_id: str): 140 | """获取视频的信息""" 141 | no_result = ("", 0, "") # 视频标题, 时长, 对应的数字id 142 | api = "http://union.video.qq.com/fcgi-bin/data" 143 | params = { 144 | "tid": "98", 145 | "appid": "10001005", 146 | "appkey": "0d1a9ddd94de871b", 147 | "idlist": video_id, 148 | "otype": "json" 149 | } 150 | resp = await self.get(api, params=params) 151 | if not resp or resp.status != 200: 152 | return no_result 153 | text = await resp.text() 154 | data = json.loads(text.lstrip("QZOutputJson=").rstrip(";")) 155 | data = data["results"][0]["fields"] 156 | title = data["title"] # 视频标题 斗罗大陆_051 157 | duration = int(data["duration"]) # 视频时长 1256 158 | # 获取视频对应的 targetid 159 | api = "http://bullet.video.qq.com/fcgi-bin/target/regist" 160 | params = {"otype": "json", "vid": video_id} 161 | resp = await self.get(api, params=params) 162 | if not resp or resp.status != 200: 163 | return no_result 164 | text = await resp.text() 165 | data = json.loads(text.lstrip("QZOutputJson=").rstrip(";")) 166 | target_id = data["targetid"] # "3881562420" 167 | return title, duration, target_id 168 | -------------------------------------------------------------------------------- /api/anime/bimibimi.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from base64 import b64decode 4 | 5 | from Crypto.Cipher import AES 6 | from Crypto.Cipher import PKCS1_v1_5 7 | from Crypto.PublicKey import RSA 8 | 9 | from api.core.anime import * 10 | 11 | 12 | def rsa_decrypt(encrypted_text: str, private_key: str) -> bytes: 13 | """使用 RSA 解密, 模式 RSA/ECB/PKCS1Padding""" 14 | key = b64decode(private_key) 15 | key = RSA.importKey(key) 16 | cipher = PKCS1_v1_5.new(key) 17 | return cipher.decrypt(b64decode(encrypted_text), b"decrypt error") 18 | 19 | 20 | def aes_decrypt(encrypted_text: str, aes_key: bytes, iv: bytes) -> str: 21 | """使用 AES 解密, 模式 AES/CBC/PKCS7Padding""" 22 | enc = b64decode(encrypted_text) 23 | cipher = AES.new(aes_key, AES.MODE_CBC, iv) 24 | text = cipher.decrypt(enc).decode() 25 | return text[0:-ord(text[-1])] # 手动去除 padding, 因为 decrypt 方法不会自动去除 26 | 27 | 28 | def decrypt_response(data: str) -> dict: 29 | """解密响应数据""" 30 | split0, split1 = data.split(".") 31 | # RSA 私钥 32 | private_key = """ 33 | MIIEpQIBAAKCAQEAyNfIuJdlkgV+Loba/PrWW52i+DaQt2MYkHRRRCKM8JpOR85x 34 | mKwxOWiYeneuDmeM9uoen4emI0SmR0wA+oPPkvUpAiif9AmVpw4Gzoyq9YxprSvd 35 | HHplWtI75TOSeL83IkVl1+TLOp6lmHtX/nmMvS8lIUthHR2HrB6bxJ+qYooSEKtx 36 | USj47fJOgo18mRuZ+Hw7zDo0G70+3IqUUYkiUhKTb7Z+dhglY8Tg1VIQJIJXXvyA 37 | 9UDT2jVAcX1UJSzjVVFjOgaaGYpHpVtFM9yB6MmqQiouv5xYzowiy/jDs56FmMTz 38 | snldZac4YPBlDg+hNZdSmLbSikWjt0Z6L7K5/QIDAQABAoIBADMcZuJC9QAyEah5 39 | fSVAGGj8Nsr/59gjic7JKx0xxbg9LIqtiM8XkvdPHO6dolfcFk2Hyv9CIA99must 40 | 9lnKTXrSlPsNp5cNEV6P/T93INKYRxRgw0ZKB50TP1bWxwGfd8Jq8r38ZZOnZ/Dk 41 | AsKp4B0M8GAGtNIZ/7rXl0B0eYHVucl7Z1pzbALLGHJRuU6ViAvLGSFtdIOq3aVx 42 | dkIAFeonGKInp98M6CTrp0UOwtx946SSCwo25TMJTeZfxOpJ/oc8pKm8HgOB5gX1 43 | SuY5oAZagWoe+H8U5U/5C/12NU6XnmS71FUg15qpRmRc7rqAh2Aw0p1QxqwaGqr/ 44 | DzuivmkCgYEA6Yf3YyM/VFf7hozYzXn4l4kO+P9Y80Y48tWXgxdXoGq2VTy2RgQ1 45 | F8G39o+7GHgc6qUJG4cUcvp59Yn6x1Xb41UkE7Eibk12v8sPQgNf+zUXg1H90wjK 46 | G/+eRJWXuLpTv23GKss6lKSZpDD1BJ40jqjB3GQJpv5B9h4FS6Ic6aMCgYEA3Cqv 47 | MS23cVh8w4R0QJmdB2NgC89jY0LgZ6bQQlLPDoCIj3inGbwi27/koow6WXYrhis8 48 | QE+oz5tTg+MIcJiwTujEKu+BqVTnVMWGLJRH/wMbPR/AQdV/vjEZdhqX4/FjYTFw 49 | SzTVqL4XqabUIGBlESB+WyCEN7iT3C7bCtbdR98CgYEA2VfDtC6fyB3CaC05sbKs 50 | 3Eug9bigznkykz6arlTRJulqHNZORcewqhWO4xhN5q4TK4bBfS8wpvna+9yY22Bb 51 | L66TzwfypXnO5R1Va/i8IY39/igW9YuenoQ+hlI7TJ+NRgIihr1yHdk7bQZrYwri 52 | m0sQcc9g9Fx6g1bZUtTj18UCgYEApveD9xLJjJ7jt07q7tbQXHsDqtEzeWKNVm4O 53 | gE3WkxPs/IkuiHjCIs8LMC6STagtZ8nAHrGKvy73jgyOKP3Sr3Uc18bdGTK3YPWP 54 | RJ2LYBzV+mvq3MJx5yXLPmL6j7ZPfLUGiTJfWmIXBeTr+EXCP9PZn3gwbSWAlLnA 55 | Ch9anxcCgYEAiE/EDUjRJgmSCq5Vqe117uxZAXNPvkT0RJpWZkBCe30aVt4WfKtC 56 | kQQMrAu231SCQMCni1c8nm/EUDYwx+jBWHT88iDhWBps/aO9u69TQQHky1MzHgrD 57 | 7Kx6g2MNJ6+lqCcCklMu8jJJK7M6KUKGiTX475GW8DnYp4wPEAlMHxc= 58 | """.replace("\n", "").replace(" ", "") 59 | 60 | # 先用 RSA 解密出 AES 的密钥 61 | aes_key = rsa_decrypt(split0, private_key) 62 | # 再用 AES 解密数据 63 | # 偏移量 iv 是 aes_key 倒转过来的, 长度 16 位 64 | iv = aes_key[::-1][:16] 65 | text = aes_decrypt(split1, aes_key, iv) 66 | return json.loads(text) 67 | 68 | 69 | class Bimibimi(AnimeSearcher): 70 | 71 | async def search(self, keyword: str): 72 | api = "http://api.tianbo17.com/app/video/search" 73 | headers = {"User-Agent": "okhttp/3.14.2", "appid": "4150439554430555"} 74 | params = {"limit": "100", "key": keyword, "page": "1"} 75 | resp = await self.get(api, params=params, headers=headers) 76 | if not resp or resp.status != 200: 77 | return 78 | data = await resp.text() 79 | data = decrypt_response(data) 80 | if data["data"]["total"] == 0: 81 | return 82 | for meta in data["data"]["items"]: 83 | anime = AnimeMeta() 84 | anime.title = meta["name"] 85 | anime.cover_url = meta["pic"] 86 | anime.category = meta["type"] 87 | anime.detail_url = meta["id"] 88 | yield anime 89 | 90 | 91 | class BimiDetailParser(AnimeDetailParser): 92 | 93 | async def parse(self, detail_url: str) -> AnimeDetail: 94 | detail = AnimeDetail() 95 | api = "http://api.tianbo17.com/app/video/detail" 96 | headers = {"User-Agent": "okhttp/3.14.2", "appid": "4150439554430555"} 97 | resp = await self.get(api, params={"id": detail_url}, headers=headers) 98 | if not resp or resp.status != 200: 99 | return detail 100 | data = await resp.text() 101 | data = decrypt_response(data) 102 | data = data["data"] 103 | detail.title = data["name"] 104 | detail.cover_url = data["pic"] 105 | detail.desc = data["content"] # 完整的简介 106 | detail.category = data["type"] 107 | for playlist in data["parts"]: 108 | pl = AnimePlayList() # 番剧的视频列表 109 | pl.name = playlist["play_zh"] # 列表名, 线路 I, 线路 II 110 | for name in playlist["part"]: 111 | video_params = f"?id={detail_url}&play={playlist['play']}&part={name}" 112 | pl.append(Anime(name, video_params)) 113 | detail.append_playlist(pl) 114 | return detail 115 | 116 | 117 | class BimiUrlParser(AnimeUrlParser): 118 | 119 | async def parse(self, raw_url: str): 120 | play_url = "http://api.tianbo17.com/app/video/play" + raw_url 121 | headers = {"User-Agent": "okhttp/3.14.2", "appid": "4150439554430555"} 122 | resp = await self.get(play_url, headers=headers) 123 | if not resp or resp.status != 200: 124 | return "" 125 | data = await resp.text() 126 | data = decrypt_response(data) 127 | real_url = data["data"][0]["url"] 128 | 129 | if "parse" in data["data"][0]: # 如果存在此字段, 说明上面不是最后的直链 130 | parse_js = data["data"][0]["parse"] # 这里会有一段 js 用于进一步解析 131 | parse_apis = re.findall(r'"(https?://.+?)"', parse_js) # 可能存在多个解析接口 132 | for api in parse_apis: 133 | url = api + real_url 134 | resp = await self.get(url, allow_redirects=True) 135 | if not resp or resp.status != 200: 136 | return "" 137 | data = await resp.json(content_type=None) 138 | real_url = data.get("url", "") 139 | if real_url: 140 | break # 已经得到了真正的直链 141 | 142 | # 这种链接还要再重定向之后才是直链 143 | if "quan.qq.com" in real_url: 144 | resp = await self.head(real_url, allow_redirects=True) 145 | real_url = resp.url.human_repr() 146 | return real_url 147 | -------------------------------------------------------------------------------- /api/iptv/bupt.edu.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "CCTV-10科教", 4 | "url": "http://ivi.bupt.edu.cn/hls/cctv10.m3u8" 5 | }, 6 | { 7 | "name": "CCTV-11戏曲", 8 | "url": "http://ivi.bupt.edu.cn/hls/cctv11.m3u8" 9 | }, 10 | { 11 | "name": "CCTV-12社会与法", 12 | "url": "http://ivi.bupt.edu.cn/hls/cctv12.m3u8" 13 | }, 14 | { 15 | "name": "CCTV-13新闻", 16 | "url": "http://ivi.bupt.edu.cn/hls/cctv13.m3u8" 17 | }, 18 | { 19 | "name": "CCTV-14少儿", 20 | "url": "http://ivi.bupt.edu.cn/hls/cctv14.m3u8" 21 | }, 22 | { 23 | "name": "CCTV-15音乐", 24 | "url": "http://ivi.bupt.edu.cn/hls/cctv15.m3u8" 25 | }, 26 | { 27 | "name": "CCTV-1综合HD", 28 | "url": "http://ivi.bupt.edu.cn/hls/cctv1hd.m3u8" 29 | }, 30 | { 31 | "name": "CCTV-2财经", 32 | "url": "http://ivi.bupt.edu.cn/hls/cctv2.m3u8" 33 | }, 34 | { 35 | "name": "CCTV-3综艺", 36 | "url": "http://ivi.bupt.edu.cn/hls/cctv3hd.m3u8" 37 | }, 38 | { 39 | "name": "CCTV-4国际", 40 | "url": "http://ivi.bupt.edu.cn/hls/cctv4.m3u8" 41 | }, 42 | { 43 | "name": "CCTV-5+体育", 44 | "url": "http://ivi.bupt.edu.cn/hls/cctv5phd.m3u8" 45 | }, 46 | { 47 | "name": "CCTV-5体育", 48 | "url": "http://ivi.bupt.edu.cn/hls/cctv5hd.m3u8" 49 | }, 50 | { 51 | "name": "CCTV-6高清", 52 | "url": "http://ivi.bupt.edu.cn/hls/cctv6hd.m3u8" 53 | }, 54 | { 55 | "name": "CCTV-7军事农业", 56 | "url": "http://ivi.bupt.edu.cn/hls/cctv7.m3u8" 57 | }, 58 | { 59 | "name": "CCTV-8高清", 60 | "url": "http://ivi.bupt.edu.cn/hls/cctv8hd.m3u8" 61 | }, 62 | { 63 | "name": "CCTV-9纪录", 64 | "url": "http://ivi.bupt.edu.cn/hls/cctv9.m3u8" 65 | }, 66 | { 67 | "name": "东方卫视", 68 | "url": "http://ivi.bupt.edu.cn/hls/dftv.m3u8" 69 | }, 70 | { 71 | "name": "东方卫视高清", 72 | "url": "http://ivi.bupt.edu.cn/hls/dfhd.m3u8" 73 | }, 74 | { 75 | "name": "云南卫视", 76 | "url": "http://ivi.bupt.edu.cn/hls/yntv.m3u8" 77 | }, 78 | { 79 | "name": "内蒙古卫视", 80 | "url": "http://ivi.bupt.edu.cn/hls/nmtv.m3u8" 81 | }, 82 | { 83 | "name": "北京卡酷少儿", 84 | "url": "http://ivi.bupt.edu.cn/hls/btv10.m3u8" 85 | }, 86 | { 87 | "name": "北京卫视", 88 | "url": "http://ivi.bupt.edu.cn/hls/btv1.m3u8" 89 | }, 90 | { 91 | "name": "北京卫视高清", 92 | "url": "http://ivi.bupt.edu.cn/hls/btv1hd.m3u8" 93 | }, 94 | { 95 | "name": "北京影视", 96 | "url": "http://ivi.bupt.edu.cn/hls/btv4.m3u8" 97 | }, 98 | { 99 | "name": "北京文艺", 100 | "url": "http://ivi.bupt.edu.cn/hls/btv2.m3u8" 101 | }, 102 | { 103 | "name": "北京文艺高清", 104 | "url": "http://ivi.bupt.edu.cn/hls/btv2hd.m3u8" 105 | }, 106 | { 107 | "name": "北京新闻", 108 | "url": "http://ivi.bupt.edu.cn/hls/btv9.m3u8" 109 | }, 110 | { 111 | "name": "北京生活", 112 | "url": "http://ivi.bupt.edu.cn/hls/btv7.m3u8" 113 | }, 114 | { 115 | "name": "北京科教", 116 | "url": "http://ivi.bupt.edu.cn/hls/btv3.m3u8" 117 | }, 118 | { 119 | "name": "北京纪实高清", 120 | "url": "http://ivi.bupt.edu.cn/hls/btv11hd.m3u8" 121 | }, 122 | { 123 | "name": "北京财经", 124 | "url": "http://ivi.bupt.edu.cn/hls/btv5.m3u8" 125 | }, 126 | { 127 | "name": "北京青年", 128 | "url": "http://ivi.bupt.edu.cn/hls/btv8.m3u8" 129 | }, 130 | { 131 | "name": "厦门卫视", 132 | "url": "http://ivi.bupt.edu.cn/hls/xmtv.m3u8" 133 | }, 134 | { 135 | "name": "吉林卫视", 136 | "url": "http://ivi.bupt.edu.cn/hls/jltv.m3u8" 137 | }, 138 | { 139 | "name": "吉林大学", 140 | "url": "http://tv.jlu.edu.cn/stream/zhibo/sd/live.m3u8" 141 | }, 142 | { 143 | "name": "四川卫视", 144 | "url": "http://ivi.bupt.edu.cn/hls/sctv.m3u8" 145 | }, 146 | { 147 | "name": "天津卫视", 148 | "url": "http://ivi.bupt.edu.cn/hls/tjtv.m3u8" 149 | }, 150 | { 151 | "name": "天津卫视高清", 152 | "url": "http://ivi.bupt.edu.cn/hls/tjhd.m3u8" 153 | }, 154 | { 155 | "name": "安徽卫视", 156 | "url": "http://ivi.bupt.edu.cn/hls/ahtv.m3u8" 157 | }, 158 | { 159 | "name": "安徽卫视高清", 160 | "url": "http://ivi.bupt.edu.cn/hls/ahhd.m3u8" 161 | }, 162 | { 163 | "name": "山东卫视", 164 | "url": "http://ivi.bupt.edu.cn/hls/sdtv.m3u8" 165 | }, 166 | { 167 | "name": "山东卫视高清", 168 | "url": "http://ivi.bupt.edu.cn/hls/sdhd.m3u8" 169 | }, 170 | { 171 | "name": "山西卫视", 172 | "url": "http://ivi.bupt.edu.cn/hls/sxrtv.m3u8" 173 | }, 174 | { 175 | "name": "广东卫视", 176 | "url": "http://ivi.bupt.edu.cn/hls/gdtv.m3u8" 177 | }, 178 | { 179 | "name": "广东卫视高清", 180 | "url": "http://ivi.bupt.edu.cn/hls/gdhd.m3u8" 181 | }, 182 | { 183 | "name": "广西卫视", 184 | "url": "http://ivi.bupt.edu.cn/hls/gxtv.m3u8" 185 | }, 186 | { 187 | "name": "新疆卫视", 188 | "url": "http://ivi.bupt.edu.cn/hls/xjtv.m3u8" 189 | }, 190 | { 191 | "name": "江苏卫视", 192 | "url": "http://ivi.bupt.edu.cn/hls/jstv.m3u8" 193 | }, 194 | { 195 | "name": "江苏卫视高清", 196 | "url": "http://ivi.bupt.edu.cn/hls/jshd.m3u8" 197 | }, 198 | { 199 | "name": "江西卫视", 200 | "url": "http://ivi.bupt.edu.cn/hls/jxtv.m3u8" 201 | }, 202 | { 203 | "name": "河北卫视", 204 | "url": "http://ivi.bupt.edu.cn/hls/hebtv.m3u8" 205 | }, 206 | { 207 | "name": "河南卫视", 208 | "url": "http://ivi.bupt.edu.cn/hls/hntv.m3u8" 209 | }, 210 | { 211 | "name": "浙江卫视", 212 | "url": "http://ivi.bupt.edu.cn/hls/zjtv.m3u8" 213 | }, 214 | { 215 | "name": "浙江卫视高清", 216 | "url": "http://ivi.bupt.edu.cn/hls/zjhd.m3u8" 217 | }, 218 | { 219 | "name": "深圳卫视", 220 | "url": "http://ivi.bupt.edu.cn/hls/sztv.m3u8" 221 | }, 222 | { 223 | "name": "深圳卫视高清", 224 | "url": "http://ivi.bupt.edu.cn/hls/szhd.m3u8" 225 | }, 226 | { 227 | "name": "湖北卫视", 228 | "url": "http://ivi.bupt.edu.cn/hls/hbtv.m3u8" 229 | }, 230 | { 231 | "name": "湖北卫视高清", 232 | "url": "http://ivi.bupt.edu.cn/hls/hbhd.m3u8" 233 | }, 234 | { 235 | "name": "湖南卫视", 236 | "url": "http://ivi.bupt.edu.cn/hls/hunantv.m3u8" 237 | }, 238 | { 239 | "name": "湖南卫视高清", 240 | "url": "http://ivi.bupt.edu.cn/hls/hunanhd.m3u8" 241 | }, 242 | { 243 | "name": "甘肃卫视", 244 | "url": "http://ivi.bupt.edu.cn/hls/gstv.m3u8" 245 | }, 246 | { 247 | "name": "福建东南卫视", 248 | "url": "http://ivi.bupt.edu.cn/hls/dntv.m3u8" 249 | }, 250 | { 251 | "name": "西藏卫视", 252 | "url": "http://ivi.bupt.edu.cn/hls/xztv.m3u8" 253 | }, 254 | { 255 | "name": "贵州卫视", 256 | "url": "http://ivi.bupt.edu.cn/hls/gztv.m3u8" 257 | }, 258 | { 259 | "name": "辽宁卫视", 260 | "url": "http://ivi.bupt.edu.cn/hls/lntv.m3u8" 261 | }, 262 | { 263 | "name": "辽宁卫视高清", 264 | "url": "http://ivi.bupt.edu.cn/hls/lnhd.m3u8" 265 | }, 266 | { 267 | "name": "重庆卫视高清", 268 | "url": "http://ivi.bupt.edu.cn/hls/cqhd.m3u8" 269 | }, 270 | { 271 | "name": "陕西卫视", 272 | "url": "http://ivi.bupt.edu.cn/hls/sxtv.m3u8" 273 | }, 274 | { 275 | "name": "青海卫视", 276 | "url": "http://ivi.bupt.edu.cn/hls/qhtv.m3u8" 277 | }, 278 | { 279 | "name": "黑龙江卫视", 280 | "url": "http://ivi.bupt.edu.cn/hls/hljtv.m3u8" 281 | }, 282 | { 283 | "name": "黑龙江卫视高清", 284 | "url": "http://ivi.bupt.edu.cn/hls/hljhd.m3u8" 285 | } 286 | ] -------------------------------------------------------------------------------- /api/danmaku/youku.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import time 4 | 5 | from api.core.danmaku import * 6 | from api.utils.logger import logger 7 | from api.utils.tool import md5, b64encode 8 | 9 | 10 | class Youku(DanmakuSearcher): 11 | 12 | async def search(self, keyword: str): 13 | api = "https://search.youku.com/search_video" 14 | resp = await self.get(api, params={"keyword": keyword}) 15 | if not resp or resp.status != 200: 16 | return 17 | 18 | html = await resp.text() 19 | data = re.search(r"__INITIAL_DATA__\s*?=\s*?({.+?});\s*?window._SSRERR_", html) 20 | data = json.loads(data.group(1)) # 这是我见过最恶心的 json 21 | data = data["pageComponentList"] 22 | for item in data: 23 | info = item.get("commonData") 24 | if not info or not info.get("leftButtonDTO"): 25 | continue 26 | meta = DanmakuMeta() 27 | meta.title = info["titleDTO"]["displayName"].replace("\t", "") 28 | play_url = info["leftButtonDTO"]["action"]["value"] 29 | if not play_url or "youku.com" not in play_url: 30 | continue # 有时候返回 qq 的播放链接, 有时候该字段为 null, 我的老天爷 31 | meta.play_url = play_url 32 | num = re.search(r"(\d+?)集", info.get("stripeBottom", "")) # 该字段可能不存在 33 | meta.num = int(num.group(1)) if num else 0 34 | yield meta 35 | 36 | 37 | class YoukuDanmakuDetailParser(DanmakuDetailParser): 38 | 39 | async def parse(self, play_url: str): 40 | """获取视频详情""" 41 | detail = DanmakuDetail() 42 | resp = await self.get(play_url) 43 | if not resp or resp.status != 200: 44 | return detail 45 | 46 | html = await resp.text() 47 | data = re.search(r"__INITIAL_DATA__\s*?=\s*?({.+?});", html) 48 | if not data: # 多半是碰到反爬机制了 49 | logger.warning("We are blocked by youku") 50 | return detail 51 | 52 | # 下面是 data 和 node 结点的疯狂套娃, 我们需要的数据在第 13 层 53 | # 我的圣母玛利亚, 这是一坨何其冗余庞杂的 shit 54 | # 写出这个代码的程序员应该被送上绞刑架, 然后用文火慢炖 55 | data = json.loads(data.group(1)) 56 | data = data["data"]["data"]["nodes"][0]["nodes"] 57 | # nodes 是一个列表, 其中 type == 10013 的元素才是视频播放列表的数据 58 | data = list(filter(lambda x: x["type"] == 10013, data))[0] 59 | # 数据在这个结点的 nodes 节点下 60 | data = data["nodes"] 61 | for item in data: 62 | info = item["data"] 63 | if info["videoType"] != "正片": 64 | continue # 可能混入预告片什么的 65 | danmaku = Danmaku() 66 | danmaku.name = info["title"] 67 | danmaku.cid = info["action"]["value"] # 视频id "XMzk4NDE2Njc4OA==" 68 | detail.append(danmaku) 69 | return detail 70 | 71 | 72 | class YoukuDanmakuDataParser(DanmakuDataParser): 73 | 74 | async def parse(self, cid: str): 75 | vid = cid 76 | title, duration = await self.get_video_info(vid) 77 | token = await self.get_token() 78 | minutes = int(duration) // 60 # 视频分钟数 79 | tasks = [self.get_60s_bullets(vid, token, m) for m in range(minutes + 1)] 80 | result = DanmakuData() 81 | async for ret in self.as_iter_completed(tasks): 82 | result.append(ret) 83 | return result 84 | 85 | async def get_token(self): 86 | """获取 cookie 中的必要参数""" 87 | cna_api = "https://log.mmstat.com/eg.js" 88 | cookie_api = "https://acs.youku.com/h5/mtop.com.youku.aplatform.weakget/1.0/?jsv=2.5.1&appKey=24679788" 89 | tokens = {"_m_h5_tk": "", "_m_h5_tk_enc": "", "cna": ""} 90 | resp = await self.head(cna_api) 91 | if not resp or resp.status != 200: 92 | return tokens 93 | tokens["cna"] = resp.cookies.get("cna").value 94 | resp = await self.get(cookie_api) 95 | if not resp or resp.status != 200: 96 | return tokens 97 | # 只要 Cookie 的 _m_h5_tk 和 _m_h5_tk_enc 就行 98 | tokens["_m_h5_tk"] = resp.cookies.get("_m_h5_tk").value 99 | tokens["_m_h5_tk_enc"] = resp.cookies.get("_m_h5_tk_enc").value 100 | return tokens 101 | 102 | async def get_video_info(self, vid: str): 103 | """获取视频 vid, 标题, 时长信息""" 104 | api = "https://openapi.youku.com/v2/videos/show.json" 105 | params = {"client_id": "53e6cc67237fc59a", "package": "com.huawei.hwvplayer.youku", "video_id": vid} 106 | resp = await self.get(api, params=params) 107 | if not resp or resp.status != 200: 108 | return "", 0 109 | data = await resp.json(content_type=None) 110 | duration = float(data.get("duration", 0)) # 视频时长(s) 111 | title = data.get("title", "") # 视频标题 112 | return title, duration 113 | 114 | async def get_60s_bullets(self, vid: str, tokens: dict, min_at: int): 115 | """获取一分钟的弹幕""" 116 | app_key = "24679788" 117 | api = "https://acs.youku.com/h5/mopen.youku.danmu.list/1.0/" 118 | timestamp = str(int(time.time() * 1000)) 119 | msg = { 120 | "ctime": timestamp, 121 | "ctype": 10004, 122 | "cver": "v1.0", 123 | "guid": tokens["cna"], 124 | "mat": min_at, 125 | "mcount": 1, 126 | "pid": 0, 127 | "sver": "3.1.0", 128 | "type": 1, 129 | "vid": vid 130 | } 131 | # 将 msg 转换为 base64 后作为 msg 的一个键值对 132 | msg_b64 = b64encode(json.dumps(msg, separators=(',', ':'))) 133 | sign = md5(msg_b64 + "MkmC9SoIw6xCkSKHhJ7b5D2r51kBiREr") # 计算签名 134 | msg.update({"msg": msg_b64, "sign": sign}) 135 | timestamp = str(int(time.time() * 1000)) 136 | data = json.dumps(msg, separators=(',', ':')) 137 | sign = md5(f"{tokens['_m_h5_tk'][:32]}&{timestamp}&{app_key}&{data}") 138 | 139 | params = { 140 | "jsv": "2.5.6", 141 | "appKey": app_key, 142 | "t": timestamp, 143 | "sign": sign, 144 | "api": "mopen.youku.danmu.list", 145 | "v": "1.0", 146 | "type": "originaljson", 147 | "dataType": "jsonp", 148 | "timeout": "20000", 149 | "jsonpIncPrefix": "utility" 150 | } 151 | self.session.cookie_jar.update_cookies(tokens) 152 | headers = {"Referer": "https://v.youku.com"} 153 | resp = await self.post(api, params=params, data={"data": data}, headers=headers) 154 | if not resp or resp.status != 200: 155 | return DanmakuData() 156 | 157 | result = DanmakuData() 158 | data = await resp.json(content_type=None) 159 | data = data["data"] # 带转义的 json 160 | comments = data.get("result") # 可能没有 161 | if not comments: 162 | return result 163 | comments = json.loads(comments)["data"]["result"] # 返回的数据层层套娃 :( 164 | 165 | for comment in comments: 166 | properties = json.loads(comment["propertis"]) # 弹幕的属性(官方拼写错误) 167 | color = properties["color"] 168 | position = properties["pos"] 169 | # size = properties["size"] 170 | result.append_bullet( 171 | time=comment["playat"] // 1000, 172 | pos=position, 173 | color=color, 174 | message=comment["content"] 175 | ) 176 | return result 177 | -------------------------------------------------------------------------------- /api/core/helper.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional, AsyncIterable, List, Dict 3 | from typing import TypeVar, Iterable, Coroutine, AsyncIterator, Any 4 | from urllib.parse import urlparse 5 | 6 | from aiohttp import ClientSession, ClientResponse, TCPConnector, CookieJar, ClientTimeout 7 | from aiohttp.resolver import AsyncResolver 8 | from lxml import etree 9 | 10 | from api.config import Config 11 | from api.utils.logger import logger 12 | from api.utils.useragent import get_random_ua 13 | 14 | __all__ = ["HtmlParseHelper"] 15 | 16 | 17 | class HtmlParseHelper: 18 | """ 19 | 提供网页数据获取、解析、并行处理的工具 20 | """ 21 | 22 | def __init__(self): 23 | self.session: Optional[ClientSession] = None 24 | self._domain_mapping: Dict[str, str] = Config().get("domain_mapping") 25 | self._dns_server = [] 26 | 27 | def set_dns_server(self) -> List[str]: 28 | """设置自定义的 DNS 服务器地址""" 29 | return [] 30 | 31 | async def _before_init(self): 32 | """session 初始化之前的操作""" 33 | self._dns_server = self.set_dns_server() 34 | 35 | async def init_session(self, session: Optional[ClientSession] = None): 36 | """ 37 | 初始化 ClientSession, 使用 get/post/head 方法之前需要调用一次, 38 | ClientSession 内部维护了连接池, 因此不建议每一个请求创建一个 session, 39 | 这里默认为每一个类创建一个 persistent session, 或者手动设置一个, 以实现复用, 40 | 在 __init__.py 中初始化 session 会出现 warning, 官方在 aiohttp 4.0 之后将只允许在协程中创建 session, 41 | See: 42 | 43 | https://github.com/aio-libs/aiohttp/issues/3658 44 | https://github.com/aio-libs/aiohttp/issues/4932 45 | 46 | :param session: 用于复用的 ClientSession 对象 47 | """ 48 | if not self.session: 49 | if session: 50 | self.session = session 51 | return 52 | 53 | if self._dns_server: 54 | logger.debug(f"Use custom DNS Server: {self._dns_server}") 55 | resolver = AsyncResolver(nameservers=self._dns_server) 56 | con = TCPConnector(ssl=False, ttl_dns_cache=300, resolver=resolver) 57 | else: 58 | con = TCPConnector(ssl=False, ttl_dns_cache=300) 59 | 60 | jar = CookieJar(unsafe=True) 61 | self.session = ClientSession(connector=con, cookie_jar=jar) 62 | 63 | async def close_session(self): 64 | """关闭 ClientSession""" 65 | if self.session: 66 | await self.session.close() 67 | self.session = None 68 | 69 | def _url_mapping(self, raw_url: str) -> (str, str): 70 | """ 71 | URL 域名映射(含端口号), 为那些域名经常被 ban 的网站和 DNS 解析 72 | 非常慢的网站单独设置了 A 记录, 这里将请求中的 URL 替换掉 73 | 74 | :param raw_url: 原始 URL 75 | :return: (映射后的 URL, 原始 host:port) 76 | """ 77 | url = urlparse(raw_url) 78 | netloc = url.netloc # www.foo.bar:1234 79 | new_netloc = self._domain_mapping.get(netloc) 80 | if not new_netloc: 81 | return raw_url, netloc # 无需映射 82 | return raw_url.replace(netloc, new_netloc), netloc 83 | 84 | def set_headers(self, url: str, kwargs: dict) -> str: 85 | """为请求设置 headers, 使用随机 User-Agent""" 86 | kwargs.setdefault("timeout", ClientTimeout(total=30, sock_connect=5)) # 连接超时 87 | 88 | if "headers" not in kwargs: # 没有设置 Headers 89 | kwargs["headers"] = {"User-Agent": get_random_ua()} 90 | else: 91 | keys = [key.lower() for key in kwargs.get("headers")] 92 | if "user-agent" not in keys: # 有 Header, 无 User-Agent 93 | kwargs["headers"]["user-agent"] = get_random_ua() 94 | 95 | new_url, netloc = self._url_mapping(url) 96 | if new_url != url: # 需要映射 97 | kwargs["headers"]["host"] = netloc 98 | return new_url 99 | 100 | async def head(self, url: str, params: dict = None, **kwargs) -> Optional[ClientResponse]: 101 | """ 102 | HEAD 方法, 使用随机 User-Agent, 出现异常时返回 None 103 | """ 104 | try: 105 | url = self.set_headers(url, kwargs) 106 | logger.debug(f"HEAD {url} | Params: {params} | Args: {kwargs}") 107 | resp = await self.session.head(url, params=params, **kwargs) 108 | logger.debug(f"Code: {resp.status} | Type: {resp.content_type} | Length: {resp.content_length} ({url})") 109 | return resp 110 | except Exception as e: 111 | logger.warning(f"Exception in {self.__class__}: {e}") 112 | 113 | async def get(self, url: str, params: dict = None, **kwargs) -> Optional[ClientResponse]: 114 | """ 115 | GET 方法, 使用随机 User-Agent, 出现异常时返回 None 116 | """ 117 | try: 118 | url = self.set_headers(url, kwargs) 119 | logger.debug(f"GET {url} | Params: {params} | Args: {kwargs}") 120 | resp = await self.session.get(url, params=params, **kwargs) 121 | logger.debug(f"Code: {resp.status} | Type: {resp.content_type} | Length: {resp.content_length} ({url})") 122 | return resp 123 | except Exception as e: 124 | logger.warning(f"Exception in {self.__class__}: {e}") 125 | 126 | async def post(self, url: str, data: dict = None, **kwargs) -> Optional[ClientResponse]: 127 | """ 128 | POST 方法, 使用随机 User-Agent, 出现异常时返回 None 129 | """ 130 | try: 131 | url = self.set_headers(url, kwargs) 132 | logger.debug(f"POST {url} | Data: {data} | Args: {kwargs}") 133 | resp = await self.session.post(url, data=data, **kwargs) 134 | logger.debug(f"Code: {resp.status} | Type: {resp.content_type} | Length: {resp.content_length} ({url})") 135 | return resp 136 | except Exception as e: 137 | logger.warning(f"Exception in {self.__class__}: {e}") 138 | 139 | @staticmethod 140 | def xpath(html: str, xpath: str) -> Optional[etree.Element]: 141 | """支持 XPath 方便处理网页""" 142 | if not html: 143 | return None 144 | try: 145 | return etree.HTML(html).xpath(xpath) 146 | except Exception as e: 147 | logger.exception(e) 148 | return None 149 | 150 | @staticmethod 151 | def xml_xpath(xml_text: Any, xpath: str) -> Optional[etree.Element]: 152 | """支持 XPath 方便处理 Xml""" 153 | if not xml_text: 154 | return None 155 | try: 156 | return etree.XML(xml_text).xpath(xpath) 157 | except Exception as e: 158 | logger.exception(e) 159 | return None 160 | 161 | T = TypeVar("T") # 提交任务的返回类型 162 | Task = Coroutine[Any, Any, T] 163 | IterTask = Coroutine[Any, Any, Iterable[T]] 164 | AsyncIterTask = Coroutine[Any, Any, AsyncIterable[T]] 165 | 166 | @staticmethod 167 | async def as_completed(tasks: Iterable[Task]) -> AsyncIterator[T]: 168 | """ 169 | 将多个协程任务加入事件循环并发运行, 返回异步生成器 170 | 每次迭代返回一个已经完成的协程结果, 返回结果不保证顺序 171 | 172 | :param tasks: 协程列表, 协程返回类型为 T 173 | :return: 异步生成器, 元素类型为 T 174 | """ 175 | for task in asyncio.as_completed(tasks): 176 | yield await task 177 | 178 | @staticmethod 179 | async def as_iter_completed(tasks: Iterable[IterTask]) -> AsyncIterator[T]: 180 | """ 181 | 将多个协程任务加入事件循环并发运行, 返回异步生成器 182 | 每次迭代返回一个已经完成的协程``结果中的元素``, 返回结果不保证顺序 183 | 184 | :param tasks: 协程列表, 协程的返回类型为 Iterable[T] 185 | :return: 异步生成器, 元素类型为 T 186 | """ 187 | for task in asyncio.as_completed(tasks): 188 | for item in await task: 189 | yield item 190 | -------------------------------------------------------------------------------- /api/core/agent.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Coroutine 2 | 3 | from api.config import Config 4 | from api.core.anime import * 5 | from api.core.cache import CacheDB 6 | from api.core.danmaku import * 7 | from api.core.proxy import AnimeProxy 8 | from api.core.scheduler import Scheduler 9 | from api.iptv.iptv import TVSource, get_sources 10 | from api.update.bangumi import Bangumi 11 | 12 | 13 | class Agent: 14 | """ 15 | 代理人, 代理响应路由的请求 16 | 从调度器获取、过滤、缓存数据, 发送给路由 17 | """ 18 | 19 | def __init__(self): 20 | self._scheduler = Scheduler() 21 | self._bangumi = Bangumi() 22 | self._config = Config() 23 | # Memory Database for cache 24 | self._anime_db = CacheDB() 25 | self._danmaku_db = CacheDB() 26 | self._proxy_db = CacheDB() 27 | self._others_db = CacheDB() 28 | 29 | def cache_clear(self) -> float: 30 | """清空缓存, 返回释放的内存(KB)""" 31 | mem_free = 0 32 | mem_free += self._anime_db.clear() 33 | mem_free += self._danmaku_db.clear() 34 | mem_free += self._proxy_db.clear() 35 | mem_free += self._others_db.clear() 36 | return mem_free 37 | 38 | def get_global_config(self): 39 | """获取全局配置""" 40 | return self._config.all_configs 41 | 42 | def change_module_state(self, module: str, enable: bool): 43 | """设置模块启用状态""" 44 | return self._scheduler.change_module_state(module, enable) 45 | 46 | async def get_bangumi_updates(self): 47 | """获取番组表信息""" 48 | bangumi = self._others_db.fetch("bangumi") 49 | if not bangumi: # 缓存起来 50 | bangumi = await self._bangumi.get_bangumi_updates() 51 | self._others_db.store(bangumi, "bangumi") 52 | return bangumi 53 | 54 | def get_iptv_sources(self) -> List[TVSource]: 55 | """获取 IPTV 源列表""" 56 | return get_sources() 57 | 58 | async def get_anime_metas( 59 | self, 60 | keyword: str, 61 | *, 62 | callback: Callable[[AnimeMeta], None] = None, 63 | co_callback: Callable[[AnimeMeta], Coroutine] = None 64 | ) -> None: 65 | """搜索番剧, 返回摘要信息, 过滤相似度低的数据""" 66 | # 番剧搜索不缓存, 异步推送 67 | return await self._scheduler.search_anime(keyword, callback=callback, co_callback=co_callback) 68 | 69 | async def get_danmaku_metas( 70 | self, 71 | keyword: str, 72 | *, 73 | callback: Callable[[DanmakuMeta], None] = None, 74 | co_callback: Callable[[DanmakuMeta], Coroutine] = None 75 | ) -> None: 76 | """搜索弹幕库, 返回摘要信息, 过滤相似度低的数据""" 77 | # TODO: Implement data filter 78 | 79 | # 番剧搜索结果是相似的, 对应的弹幕搜索结果相对固定, 缓存备用 80 | if metas := self._danmaku_db.fetch(keyword): 81 | if callback is not None: 82 | for meta in metas: 83 | callback(meta) 84 | return 85 | if co_callback is not None: 86 | for meta in metas: 87 | await co_callback(meta) 88 | return 89 | 90 | # 没有缓存, 搜索一次 91 | metas = [] 92 | 93 | def _callback(_meta): 94 | metas.append(_meta) # 缓存一份 95 | callback(_meta) 96 | 97 | async def _co_callback(_meta): 98 | metas.append(_meta) 99 | await co_callback(_meta) 100 | 101 | if callback is not None: 102 | await self._scheduler.search_danmaku(keyword, callback=_callback) 103 | elif co_callback is not None: 104 | await self._scheduler.search_danmaku(keyword, co_callback=_co_callback) 105 | if metas: 106 | self._danmaku_db.store(metas, keyword) 107 | 108 | async def get_anime_detail(self, token: str) -> Optional[AnimeDetail]: 109 | """获取番剧详情信息, 如果有缓存, 使用缓存的值""" 110 | detail: AnimeDetail = self._anime_db.fetch(token) 111 | if detail is not None: 112 | logger.info(f"Using cached {detail}") 113 | return detail 114 | # 没有缓存, 通过 token 构建 AnimeMeta 对象, 解析一次 115 | meta = AnimeMeta.build_from(token) 116 | logger.debug(f"Build AnimeMeta from token: {meta.module} | {meta.detail_url}") 117 | detail = await self._scheduler.parse_anime_detail(meta) 118 | if not detail or detail.is_empty(): # 没解析出来或者解析出来是空信息 119 | logger.error(f"Parse anime detail info failed") 120 | return None 121 | self._anime_db.store(detail, token) # 解析成功, 缓存起来 122 | return detail 123 | 124 | async def get_anime_real_url(self, token: str, playlist: int, episode: int) -> AnimeInfo: 125 | """获取资源直链, 如果存在未过期的缓存, 使用缓存的值, 否则重新解析""" 126 | url_token = f"{token}|{playlist}|{episode}" 127 | url: AnimeInfo = self._anime_db.fetch(url_token) 128 | if url and url.is_available(): # 存在缓存且未过期 129 | logger.info(f"Using cached real url: {url}") 130 | return url 131 | # 没有发现缓存或者缓存的直链过期, 解析一次 132 | detail = await self.get_anime_detail(token) 133 | if detail is not None: 134 | anime: Anime = detail.get_anime(int(playlist), int(episode)) 135 | if anime is not None: 136 | url = await self._scheduler.parse_anime_real_url(anime) 137 | if url.is_available(): 138 | self._anime_db.store(url, url_token) 139 | return url 140 | # 其它各种情况, 解析失败 141 | return AnimeInfo() 142 | 143 | async def get_anime_proxy(self, token: str, playlist: int, episode: int) -> Optional[AnimeProxy]: 144 | """获取视频数据流代理器对象""" 145 | proxy_token = f"{token}|{playlist}|{episode}" 146 | proxy: AnimeProxy = self._proxy_db.fetch(proxy_token) 147 | if proxy and proxy.is_available(): # 缓存的 proxy 对象可用 148 | return proxy 149 | 150 | url = await self.get_anime_real_url(token, int(playlist), int(episode)) 151 | if not url.is_available(): 152 | return 153 | meta = AnimeMeta.build_from(token) 154 | proxy_cls = self._scheduler.get_anime_proxy_class(meta) 155 | proxy: AnimeProxy = proxy_cls(url) # 重新构建一个 156 | self._proxy_db.store(proxy, proxy_token) 157 | return proxy 158 | 159 | async def get_danmaku_detail(self, token: str) -> DanmakuDetail: 160 | """获取弹幕库详情信息, 如果存在缓存, 使用缓存的值""" 161 | detail: DanmakuDetail = self._danmaku_db.fetch(token) 162 | if detail is not None: 163 | logger.info(f"Using cached {detail}") 164 | return detail 165 | # 没有缓存, 通过 token 构建 AnimeMeta 对象, 解析一次 166 | meta = DanmakuMeta.build_from(token) 167 | logger.debug(f"Build DanmakuMeta from token: {meta.module} | {meta.play_url}") 168 | detail = await self._scheduler.parse_danmaku_detail(meta) 169 | if detail.is_empty(): # 没解析出来或者解析出来是空信息 170 | logger.error(f"Parse anime detail info failed") 171 | return detail 172 | self._danmaku_db.store(detail, token) # 解析成功, 缓存起来 173 | return detail 174 | 175 | async def get_danmaku_data(self, token: str, episode: int) -> DanmakuData: 176 | """获取弹幕数据, 如果有缓存, 使用缓存的值""" 177 | danmaku_token = f"{token}|{episode}" 178 | data_token = f"{danmaku_token}|data" 179 | data: DanmakuData = self._danmaku_db.fetch(data_token) 180 | if data is not None: 181 | logger.info(f"Using cached danmaku data: {data}") 182 | return data 183 | detail: DanmakuDetail = await self.get_danmaku_detail(token) 184 | if not detail.is_empty(): 185 | danmaku = detail.get_danmaku(int(episode)) 186 | if danmaku is not None: 187 | data = await self._scheduler.parse_danmaku_data(danmaku) 188 | if not data.is_empty(): # 如果有数据就缓存起来 189 | self._danmaku_db.store(data, data_token) 190 | return data 191 | return DanmakuData() 192 | -------------------------------------------------------------------------------- /api/core/proxy.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from quart import Response, stream_with_context 4 | 5 | from api.core.anime import AnimeInfo 6 | from api.core.helper import HtmlParseHelper 7 | from api.utils.logger import logger 8 | from api.utils.tool import extract_domain 9 | from api.utils.useragent import get_random_ua 10 | 11 | __all__ = ["RequestProxy", "AnimeProxy"] 12 | 13 | 14 | class HttpProxy(HtmlParseHelper): 15 | 16 | def __init__(self): 17 | super().__init__() 18 | 19 | def set_proxy_headers(self, url: str) -> dict: 20 | """ 21 | 为特定的直链设置代理 Headers, 如果服务器存在防盗链, 可以尝试重写本方法, 22 | 若本方法返回空则使用默认 Headers, 23 | 若设置的 Headers 不包含 User-Agent 则随机生成一个 24 | """ 25 | return {} 26 | 27 | def _get_proxy_headers(self, url: str) -> dict: 28 | """获取代理访问使用的 Headers""" 29 | headers = self.set_proxy_headers(url) 30 | if not headers: 31 | return {"User-Agent": get_random_ua()} 32 | if "user-agent" not in (key.lower() for key in headers.keys()): 33 | headers["User-Agent"] = get_random_ua() 34 | return headers 35 | 36 | 37 | class RequestProxy(HttpProxy): 38 | 39 | async def make_response(self, url: str) -> Response: 40 | """代理访问远程请求""" 41 | await self.init_session() 42 | resp = await self.get(url, allow_redirects=True) 43 | if not resp or resp.status != 200: 44 | return Response("resource maybe not available", status=404) 45 | data = await resp.read() 46 | resp_headers = { 47 | "Content-Type": resp.content_type, 48 | "Content-Length": resp.content_length 49 | } 50 | return Response(data, headers=resp_headers, status=200) 51 | 52 | async def close(self): 53 | await self.close_session() 54 | 55 | 56 | class BaseAnimeProxy(HttpProxy): 57 | 58 | def __init__(self, info: AnimeInfo): 59 | super().__init__() 60 | self._info = info 61 | 62 | def is_available(self) -> bool: 63 | return self._info.is_available() 64 | 65 | def get_stream_format(self) -> str: 66 | return self._info.format 67 | 68 | def get_url_info(self) -> AnimeInfo: 69 | """获取资源信息""" 70 | return self._info 71 | 72 | def get_real_url(self) -> str: 73 | """获取被代理的资源直链""" 74 | if self.is_available(): 75 | return self._info.real_url 76 | return "" 77 | 78 | def enforce_proxy(self, url: str) -> bool: 79 | """ 80 | 对于某些已知的无法直接访问的资源, 强制代理其流量 81 | 82 | :param url: 资源的链接 83 | :return: 是否强制代理 84 | """ 85 | return False 86 | 87 | def is_enforce_proxy(self) -> bool: 88 | if self.enforce_proxy(self.get_real_url()): 89 | logger.info(f"Forced proxy the video stream: {self.get_real_url()}") 90 | return True 91 | return False 92 | 93 | 94 | class StreamProxy(BaseAnimeProxy): 95 | """ 96 | 支持 MP4, FLV 之类的数据流代理播放 97 | """ 98 | 99 | async def make_response_with_range(self, range_field: str = None) -> Response: 100 | """ 101 | 读取远程的视频流,并伪装成本地的响应返回给客户端, 102 | 206 连续请求会导致连接中断, asyncio 库在 Windows 平台触发 ConnectionAbortedError, 103 | 偶尔出现 LocalProtocolError, 是 RFC2616 与 RFC7231 HEAD 请求冲突导致, 104 | See: 105 | 106 | https://bugs.python.org/issue26509 107 | https://gitlab.com/pgjones/quart/-/issues/45 108 | """ 109 | url = self._info.real_url 110 | proxy_headers = self._get_proxy_headers(url) 111 | if range_field is not None: 112 | proxy_headers["range"] = range_field 113 | logger.debug(f"Client request stream range: {range_field}") 114 | 115 | await self.init_session() 116 | resp = await self.get(url, headers=proxy_headers) 117 | if not resp: 118 | return Response(b"", status=404) 119 | 120 | @stream_with_context 121 | async def stream_iter(): 122 | while chunk := await resp.content.read(4096): 123 | yield chunk 124 | 125 | status = 206 if self._info.format == "mp4" else 200 # 否则无法拖到进度条 126 | return Response(stream_iter(), headers=dict(resp.headers), status=status) 127 | 128 | 129 | class M3U8Proxy(BaseAnimeProxy): 130 | """ 131 | 支持 M3u8 视频代理播放 132 | """ 133 | 134 | def __init__(self, info: AnimeInfo): 135 | super().__init__(info) 136 | self._chunk_proxy_router = "" 137 | self._cache_m3u8_text = "" 138 | 139 | def set_chunk_proxy_router(self, domain: str): 140 | """设置 m3u8 数据块代理路由地址""" 141 | self._chunk_proxy_router = domain 142 | 143 | async def read_data(self, url: str) -> bytes: 144 | """使用设置的 Header 读取 URL 对应的资源, 返回原始二进制数据""" 145 | await self.init_session() 146 | proxy_headers = self._get_proxy_headers(url) 147 | resp = await self.get(url, headers=proxy_headers, allow_redirects=True) 148 | if not resp or resp.status != 200: 149 | return b"" 150 | return await resp.read() 151 | 152 | async def read_text(self, url: str, encoding="utf-8") -> str: 153 | """使用设置的 Header 读取 URL 对应的资源, 返回编码后的文本""" 154 | return (await self.read_data(url)).decode(encoding) 155 | 156 | async def get_m3u8_text(self, index_url: str) -> str: 157 | """ 158 | 获取 index.m3u8 文件的内容, 如果该文件需要进一步处理, 159 | 比如需要跳转一次才能得到 m3u8 的内容, 160 | 或者接口返回的数据经过加密、压缩时, 请重写本方法以获取 m3u8 文件的真实内容 161 | 162 | :param index_url: index.m3u8 文件的链接 163 | :return: index.m3u8 的内容 164 | """ 165 | return await self.read_text(index_url) 166 | 167 | def fix_m3u8_key_url(self, index_url: str, key_url: str) -> str: 168 | """ 169 | 修复 m3u8 密钥的链接(通常使用 AES-128 加密数据流), 170 | 默认以 index.m3u8 同级路径补全 key 的链接, 171 | 其它情况请重写本方法 172 | 173 | :param index_url: index.m3u8 的链接 174 | :param key_url: 密钥的链接(可能不完整) 175 | :return: 密钥的完整链接 176 | """ 177 | if key_url.startswith("http"): 178 | return key_url 179 | 180 | path = '/'.join(index_url.split('/')[:-1]) 181 | return path + '/' + key_url 182 | 183 | def fix_m3u8_chunk_url(self, index_url: str, chunk_url: str) -> str: 184 | """ 185 | 替换 m3u8 文件中数据块的链接, 通常需要补全域名, 186 | 默认情况使用 index.m3u8 的域名补全数据块域名部分, 187 | 其它情况请重新此方法 188 | 189 | :param index_url: index.m3u8 的链接 190 | :param chunk_url: m3u8 文件中数据块的链接(通常不完整) 191 | :return: 修复完成的 m3u8 文件 192 | """ 193 | if chunk_url.startswith("http"): # url 无需补全 194 | return chunk_url 195 | elif chunk_url.startswith('/'): 196 | return extract_domain(index_url) + chunk_url 197 | else: 198 | return extract_domain(index_url) + '/' + chunk_url 199 | 200 | def fix_chunk_data(self, url: str, chunk: bytes) -> bytes: 201 | """ 202 | 修复数 m3u8 数据据块, 用于解除数据混淆 203 | 比如常见的图片隐写, 每一段视频数据存放于一张图片中, 需要剔除图片的数据 204 | 可使用 binwalk 等工具对二进制数据进行分析, 以确定图像与视频流的边界位置 205 | 206 | :param url: 数据块的链接 207 | :param chunk: 数据块的二进制数据 208 | :return: 修复完成的二进制数据 209 | """ 210 | return chunk 211 | 212 | async def _fix_m3u8_text(self, text: str) -> str: 213 | fixed_m3u8_text = [] 214 | for line in text.splitlines(): 215 | if line.startswith("#EXT-X-KEY"): # 修复密钥链接 216 | key_url = re.search(r'URI="(.+?)"', line).group(1) 217 | fixed_key_url = self._chunk_proxy_router + '/' + self.fix_m3u8_key_url(self._info.real_url, key_url) 218 | line = line.replace(key_url, fixed_key_url) 219 | if not line.startswith("#"): # 修复数据块链接 220 | line = self._chunk_proxy_router + '/' + self.fix_m3u8_chunk_url(self._info.real_url, line) 221 | fixed_m3u8_text.append(line) 222 | return '\n'.join(fixed_m3u8_text) 223 | 224 | async def _get_fixed_m3u8_text(self) -> str: 225 | text = await self.get_m3u8_text(self._info.real_url) 226 | return await self._fix_m3u8_text(text) 227 | 228 | async def make_response_for_m3u8(self) -> Response: 229 | if not self._cache_m3u8_text: 230 | self._cache_m3u8_text = await self._get_fixed_m3u8_text() 231 | logger.debug(f"Cache m3u8 text, size: {len(self._cache_m3u8_text) // 1024}kb") 232 | return Response(self._cache_m3u8_text, mimetype="application/vnd.apple.mpegurl") 233 | 234 | async def make_response_for_chunk(self, url: str, params: dict = None) -> Response: 235 | proxy_headers = self._get_proxy_headers(url) 236 | resp = await self.get(url, params=params, headers=proxy_headers) 237 | if not resp: 238 | return Response(b"", status=404) 239 | chunk = self.fix_chunk_data(url, await resp.read()) 240 | return Response(chunk, headers=dict(resp.headers), status=200) 241 | 242 | 243 | class AnimeProxy(StreamProxy, M3U8Proxy): 244 | """ 245 | 代理访问视频数据流, 以绕过资源服务器的防盗链和本地浏览器跨域策略 246 | """ 247 | 248 | def __init__(self, info: AnimeInfo): 249 | super(AnimeProxy, self).__init__(info) 250 | -------------------------------------------------------------------------------- /docs/user/extension.rst: -------------------------------------------------------------------------------- 1 | .. _extension: 2 | 3 | ============ 4 | 引擎模板 5 | ============ 6 | 7 | 对于定向的资源爬取任务,往往有特定的模式可循。API 框架将视频、弹幕、小说、漫画、音乐 8 | 这些抓取任务的工作模式进行了抽象,将数据抓取、处理所需要的常用功能进行了封装, 9 | 减少编写引擎的难度。 10 | 11 | 框架会自动伪装每一个请求、对获取的数据加标记、过滤清洗,最后以统一的 JSON 接口展现出来。 12 | 数据的解析是逐步进行的,只有当你访问某个的接口时,它才会调度相关引擎去完成进一步的解析。 13 | 解析过程中某些数据会自动缓存,以加快处理速度。对于寿命短暂的直链,API 通过动态重定向为资源续命。 14 | 对于存在防盗链的资源,它也能作为代理服务器,访问原始数据流并返回响应给前端,完成视频的播放...... 15 | 16 | 这里面有许多繁琐无聊的事情, 好在 API 框架为你做了这些脏活,你只需要按照特定的模式, 17 | 一步一步提取数据就好。但是,这仍然是一件具有挑战性的事情, 18 | 因为你无法预料目标网站或者APP使用了何种手段阻止你抓取数据,不知道它将某些关键参数藏到了哪里, 19 | 即便已经接近成功,它仍可能返回一些稀奇古怪的数据迷惑你。数据的抓取是最困难的事情, 20 | 你可能遇到网页端反调试、js加密、接口数据加密、APP加壳、资源数据混淆等等问题, 21 | 这里有不少的坑,我个人的一些经验总结在了 22 | :ref:`爬虫技巧 ` 一栏, 23 | 如果遇到问题,可以看看,说不定能得到启发。 24 | 25 | OK,说了一大堆,现在我们终于要开始了! 26 | 27 | 现在,你盯上了一个网站或者APP,觉得它的资源很不错,可是广告满天飞, 28 | 于是你想把它做成一个引擎添加进来... 29 | 30 | 如何添加引擎 31 | ===================== 32 | 33 | 写好的引擎不要扔,裹上鸡蛋液,粘上面包糠, 扔到下面对应的目录下: 34 | 35 | .. code-block:: 36 | 37 | api 38 | ├─anime # 视频搜索 39 | ├─danmaku # 弹幕搜索 40 | ├─comic # 漫画搜索 41 | └─music # 音乐搜索 42 | 43 | 然后, 在 `api/config.json` 中增加一个对应的配置项, 如: 44 | 45 | .. code-block:: json 46 | 47 | { 48 | "anime": [ 49 | { 50 | "name": "歪比巴卜", 51 | "notes": "这里写点简介", 52 | "module": "api.anime.wbbb", 53 | "type": [ 54 | "动漫" 55 | ], 56 | "enable": false, 57 | "quality": 8 58 | } 59 | ] 60 | } 61 | 62 | 63 | - `module` 是该引擎的模块名,与文件路径保持一致 64 | - `enable` 表示是否启用该引擎 65 | - `quality` 表示资源的整体质量 0~10 分 66 | 67 | 视频搜索引擎 68 | ====================== 69 | 视频的搜索解析模板: 70 | 71 | .. code-block:: python 72 | 73 | from api.core.anime import * 74 | from api.core.proxy import StreamProxy 75 | 76 | class MyEngine(AnimeSearcher): 77 | 78 | async def search(self, keyword: str): 79 | """ 80 | 实现本方法, 搜索剧集摘要信息, 返回异步生成器 81 | """ 82 | html = "keyword 对应的网页内容" 83 | items = ["剧集1的摘要信息", "剧集2的摘要信息", "剧集3的摘要信息"] 84 | for item in items: 85 | meta = AnimeMeta() 86 | # 对 item 进行解析, 提取以下数据 87 | meta.title = "番剧名称" 88 | meta.category = "分类" 89 | meta.desc = "简介" 90 | meta.cover_url = "封面图 URL" 91 | meta.detail_url = "详情页链接或参数" 92 | yield meta # 产生一个结果交给下一级处理 93 | 94 | 95 | class MyDetailParser(AnimeDetailParser): 96 | 97 | async def parse(self, detail_url: str): 98 | """ 99 | 实现本方法, 解析摘要信息中的链接, 获取详情页数据 100 | """ 101 | detail = AnimeDetail() 102 | 103 | # detail_url 就是上面搜索结果中提取的内容 104 | # 从详情页提取以下信息 105 | detail.title = "番剧名称" 106 | detail.cover_url = "封面图 URL" 107 | detail.desc = "简介" 108 | detail.category = "分类" 109 | 110 | playlists = ["播放列表1的信息", "播放列表2的信息"] 111 | for playlist in playlists: 112 | pl = AnimePlayList() 113 | # 解析播放列表的 html, 提取播放列表名和列表内容 114 | pl.name = "播放列表名" 115 | for item in playlist: 116 | anime = Anime() 117 | # 解析列表中的一集视频, 提取视频名字和 URL 信息 118 | anime.name = "某一集视频的名字" 119 | anime.raw_url = "视频的原始链接或者参数" 120 | pl.append(anime) 121 | detail.append_playlist(pl) 122 | return detail 123 | 124 | class MyUrlParser(AnimeUrlParser): 125 | 126 | async def parse(self, raw_url: str) -> Union[AnimeInfo, str]: 127 | """ 128 | 实现本方法, 解析某一集视频的原始链接, 获取直链和有效期 129 | 如果在详情页已经提取到了有效的直链, 可以不写这个类, 但通常是需要的 130 | """ 131 | # raw_url 是从详情页提取的信息 132 | real_url = "根据 raw_url 找到的视频直链" 133 | return real_url # 直接返回直链是可以的, 框架会尝试自行推断该直链对应的视频信息 134 | 135 | # 如果你知道这个直链的信息就最好不过了, 省得框架去推测, 因为这不一定准确 136 | # lifetime 视频的剩余寿命(秒), 如果视频过期, 框架将重新解析一次直链 137 | # fmt 是视频格式, 可选 mp4 flv hls, 这将给前端播放器一个提示, 以便选择正确的解码器播放 138 | # volatile 表示视频直链是否在访问后立即失效, 如果为 True, 则每次前端请求视频数据时, 框架都会重新解析直链 139 | # 这些参数不要求全部提供, 你知道多少填多少(当然越多越好), 剩下的交给框架去推测 140 | return AnimeInfo(real_url, lifetime=600, fmt="mp4", volatile=True) # 返回 AnimeInfo 对象 141 | 142 | class MyVideoProxy(StreamProxy): 143 | """ 144 | 本类用于实现视频流量的代理, 下面的方法按需重写 145 | 146 | 框架默认的实现可以应付大多数情况, 如果碰到一些稀奇古怪的情况, 你可能通过重写下面的某些方法 147 | 通常 mp4 视频需要绕过防盗链, 重写 set_proxy_headers 方法即可 148 | 许多 APP 会将 hls(m3u8) 视频片段隐藏到图片中, 需要重写 fix_chunk_data 方法剔除图片数据 149 | 其它方法大都用于处理 m3u8 文本文件, 正常情况无需重写 150 | """ 151 | 152 | def set_proxy_headers(self, real_url: str) -> dict: 153 | """ 154 | 如果服务器存在防盗链, 需要检测 Referer 和 User-Agent, 可以尝试重写本方法 155 | 本方法可为特定的直链设置代理 Headers 156 | 若本方法返回空则使用默认 Headers 157 | 若设置的 Headers 不包含 User-Agent 则随机生成一个 158 | """ 159 | 160 | if "foo.bar" in real_url: 161 | return {"Referer": "http://www.foo.bar"} 162 | 163 | async def get_m3u8_text(self, index_url: str) -> str: 164 | """ 165 | 获取 index.m3u8 文件的内容, 如果该文件需要进一步处理, 166 | 比如需要跳转一次才能得到 m3u8 的内容, 167 | 或者接口返回的数据经过加密、压缩时, 请重写本方法以获取 m3u8 文件的真实内容 168 | 169 | :param index_url: index.m3u8 文件的链接 170 | :return: index.m3u8 的内容 171 | """ 172 | return await self.read_text(index_url) 173 | 174 | def fix_m3u8_key_url(self, index_url: str, key_url: str) -> str: 175 | """ 176 | 修复 m3u8 密钥的链接(通常使用 AES-128 加密数据流), 177 | 默认以 index.m3u8 同级路径补全 key 的链接, 178 | 其它情况请重写本方法 179 | 180 | :param index_url: index.m3u8 的链接 181 | :param key_url: 密钥的链接(可能不完整) 182 | :return: 密钥的完整链接 183 | """ 184 | if key_url.startswith("http"): 185 | return key_url 186 | 187 | path = '/'.join(index_url.split('/')[:-1]) 188 | return path + '/' + key_url 189 | 190 | def fix_m3u8_chunk_url(self, index_url: str, chunk_url: str) -> str: 191 | """ 192 | 替换 m3u8 文件中数据块的链接, 通常需要补全域名, 193 | 默认情况使用 index.m3u8 的域名补全数据块域名部分, 194 | 其它情况请重新此方法 195 | 196 | :param index_url: index.m3u8 的链接 197 | :param chunk_url: m3u8 文件中数据块的链接(通常不完整) 198 | :return: 修复完成的 m3u8 文件 199 | """ 200 | if chunk_url.startswith("http"): # url 无需补全 201 | return chunk_url 202 | elif chunk_url.startswith('/'): 203 | return extract_domain(index_url) + chunk_url 204 | else: 205 | return extract_domain(index_url) + '/' + chunk_url 206 | 207 | def fix_chunk_data(self, url: str, chunk: bytes) -> bytes: 208 | """ 209 | 修复数 m3u8 数据据块, 用于解除数据混淆 210 | 比如常见的图片隐写, 每一段视频数据存放于一张图片中, 需要剔除图片的数据 211 | 可使用 binwalk 等工具对二进制数据进行分析, 以确定图像与视频流的边界位置 212 | 213 | :param url: 数据块的链接 214 | :param chunk: 数据块的二进制数据 215 | :return: 修复完成的二进制数据 216 | """ 217 | return chunk 218 | 219 | 220 | 弹幕搜索引擎 221 | ======================= 222 | 弹幕引擎模板: 223 | 224 | .. code-block:: python 225 | 226 | from api.core.danmaku import * 227 | 228 | class MyEngine(DanmakuSearcher): 229 | 230 | async def search(self, keyword: str): 231 | """ 232 | 实现本方法, 搜索弹幕摘要信息, 返回异步生成器 233 | """ 234 | html = "keyword 对应的网页内容" 235 | items = ["番剧1的弹幕信息", "番剧2的弹幕信息"] 236 | for item in items: 237 | meta = DanmakuMeta() 238 | # 解析 item 提取下列信息 239 | meta.title = "番剧名称" 240 | meta.play_url = "播放页链接或参数" 241 | meta.num = 10 # 包含的集数 242 | yield meta # 产生一个结果就交给上一级处理 243 | 244 | class MyDetailParser(DanmakuDetailParser): 245 | 246 | async def parse(self, play_url: str): 247 | """ 248 | 解析番剧对应的弹幕的播放列表 249 | """ 250 | detail = DanmakuDetail() 251 | 252 | items = ["第1集的弹幕信息", "第2集的弹幕信息"] 253 | for ep in items: 254 | danmaku = Danmaku() 255 | danmaku.name = "本集视频的名字" 256 | danmaku.cid = "解析弹幕数据需要的参数或链接" 257 | detail.append(danmaku) 258 | 259 | return detail 260 | 261 | 262 | class MyDanmakuDataParser(DanmakuDataParser): 263 | 264 | async def parse(self, cid: str): 265 | """ 266 | 解析弹幕数据 267 | """ 268 | result = DanmakuData() 269 | 270 | data = ["一条弹幕", "一条弹幕"] 271 | 272 | for item in data: 273 | result.append_bullet( 274 | time=31.4, # 距离视频开头的秒数(float) 275 | pos=1, # 位置参数(0右边, 1上边, 2底部) 276 | color=int("ffffff", 16), # 如果颜色是 16 进制, 先转 10 进制 277 | message="弹幕内容" 278 | ) 279 | # 也可以使用 append 方法添加弹幕 280 | result.append([123, 1, 16777215, "弹幕内容"]) 281 | return result 282 | 283 | 漫画搜索引擎 284 | ======================= 285 | 286 | 还没开始整,再等等~~ 287 | 288 | 小说搜索引擎 289 | ======================= 290 | 291 | 还没开始整,再等等~~ 292 | 293 | 音乐搜索引擎 294 | ======================= 295 | 296 | 还没开始整,再等等~~ -------------------------------------------------------------------------------- /api/core/anime.py: -------------------------------------------------------------------------------- 1 | import re 2 | from base64 import b16encode, b16decode 3 | from inspect import currentframe 4 | from time import time 5 | from typing import AsyncIterator, List, Optional, Union 6 | from urllib.parse import unquote 7 | 8 | from aiohttp import ClientResponse 9 | 10 | from api.core.abc import Tokenizable 11 | from api.core.helper import HtmlParseHelper 12 | from api.utils.logger import logger 13 | 14 | __all__ = ["Anime", "AnimeMeta", "AnimeDetail", "AnimePlayList", "AnimeInfo", 15 | "AnimeSearcher", "AnimeDetailParser", "AnimeUrlParser"] 16 | 17 | 18 | class Anime(object): 19 | """单集视频对象""" 20 | 21 | def __init__(self, name: str = "", raw_url: str = ""): 22 | #: 视频名, 比如 "第1集" 23 | self.name = name 24 | #: 视频原始 url, 可能需要进一步处理 25 | self.raw_url = raw_url 26 | self.module = "" 27 | 28 | def __repr__(self): 29 | return f"" 30 | 31 | 32 | class AnimePlayList(object): 33 | """播放列表""" 34 | 35 | def __init__(self): 36 | #: 播放列表名, 比如 "播放线路1" 37 | self.name = "" 38 | #: 视频集数, 不确定时为 -1 39 | self.num = 0 40 | self.module = "" 41 | self._anime_list: List[Anime] = [] 42 | 43 | def append(self, anime: Anime): 44 | """添加一集视频""" 45 | self._anime_list.append(anime) 46 | self.num += 1 47 | 48 | def is_empty(self): 49 | """播放列表判空""" 50 | return not self._anime_list 51 | 52 | def __iter__(self): 53 | return iter(self._anime_list) 54 | 55 | def __getitem__(self, idx: int) -> Anime: 56 | return self._anime_list[idx] 57 | 58 | def __repr__(self): 59 | return f"" 60 | 61 | 62 | class AnimeMeta(Tokenizable): 63 | """ 64 | 番剧的摘要信息, 不包括视频播放列表, 只用于表示搜索结果 65 | """ 66 | 67 | def __init__(self, ): 68 | #: 番剧标题 69 | self.title = "" 70 | #: 封面图片链接 71 | self.cover_url = "" 72 | #: 番剧的分类 73 | self.category = "" 74 | #: 简介信息 75 | self.desc = "" 76 | #: 详情页面的链接或者相关参数 77 | self.detail_url = "" 78 | #: 当前模块名(调度器使用) 79 | self.module = currentframe().f_back.f_globals["__name__"] 80 | 81 | @property 82 | def token(self) -> str: 83 | """通过引擎名和详情页信息生成, 可唯一表示本资源位置""" 84 | name = self.module.split('.')[-1] # 缩短 token 长度, 只保留引擎名 85 | sign = f"{name}|{self.detail_url}".encode("utf-8") 86 | return b16encode(sign).decode("utf-8").lower() 87 | 88 | @classmethod 89 | def build_from(cls, token: str) -> "AnimeMeta": 90 | """使用 token 构建一个不完整但可以被解析的 AnimeMeta 对象""" 91 | name, detail_url = b16decode(token.upper()).decode("utf-8").split("|", 1) 92 | meta = AnimeMeta() 93 | meta.module = "api.anime." + name 94 | meta.detail_url = detail_url 95 | return meta 96 | 97 | def __repr__(self): 98 | return f"" 99 | 100 | 101 | class AnimeDetail(object): 102 | """ 103 | 番剧详细页的信息, 包括多个视频播放列表, 番剧的描述、分类等信息 104 | """ 105 | 106 | def __init__(self): 107 | #: 番剧标题 108 | self.title = "" 109 | #: 封面图片链接 110 | self.cover_url = "" 111 | #: 番剧的分类 112 | self.category = "" 113 | #: 番剧的简介信息 114 | self.desc = "" 115 | # self.filtered = False # 播放列表是否经过过滤 116 | self.module = currentframe().f_back.f_globals["__name__"] # 自动设置当前模块名 117 | self._playlists: List[AnimePlayList] = [] # 一部番剧可能有多条播放列表 118 | 119 | def get_anime(self, p_index: int, ep_index: int) -> Optional[Anime]: 120 | """获取某一个播放列表的某个视频对象""" 121 | try: 122 | return self[p_index][ep_index] 123 | except IndexError: 124 | logger.error(f"IndexError, anime index: {p_index} {ep_index}") 125 | return None 126 | 127 | def append_playlist(self, playlist: AnimePlayList): 128 | """添加一个播放列表""" 129 | playlist.module = self.module 130 | for anime in playlist: 131 | anime.module = self.module 132 | self._playlists.append(playlist) 133 | 134 | def is_empty(self): 135 | return not self._playlists 136 | 137 | def __getitem__(self, p_index: int) -> AnimePlayList: 138 | return self._playlists[p_index] 139 | 140 | def __iter__(self): 141 | return iter(self._playlists) 142 | 143 | def __repr__(self): 144 | return f"" 145 | 146 | 147 | class AnimeInfo(HtmlParseHelper): 148 | """ 149 | 解析之后的视频, 保存了链接和有效时间等信息 150 | """ 151 | 152 | def __init__(self, url: str = "", lifetime: int = 86400, fmt: str = "", volatile: bool = False): 153 | super().__init__() 154 | self._url = unquote(url) # 直链 155 | self._parse_time = time() # 解析出直链的时刻 156 | self._format = fmt # 视频格式 157 | self._lifetime = lifetime 158 | self._size = 0 159 | self._volatile = volatile # 直链是否在访问后失效 160 | # self._resolution = "0x0" 161 | 162 | @property 163 | def real_url(self) -> str: 164 | return self._url 165 | 166 | @property 167 | def left_lifetime(self) -> int: 168 | """直链剩余寿命""" 169 | seconds = int(self._parse_time + self._lifetime - time()) 170 | return seconds if seconds > 0 else 0 171 | 172 | @property 173 | def format(self) -> str: 174 | """获取视频格式""" 175 | return self._format 176 | 177 | @property 178 | def size(self) -> float: 179 | return self._size 180 | 181 | # @property 182 | # def resolution(self) -> str: 183 | # return self._resolution 184 | 185 | def is_available(self) -> bool: 186 | """视频直链是有效""" 187 | return self._url.startswith("http") and self.left_lifetime > 0 188 | 189 | async def detect_more_info(self): 190 | self._format = self._detect_format_from_url() 191 | # 一些资源解析后只能被访问一次, 如果探测文件信息, 会导致直链失效 192 | if self._volatile: 193 | return 194 | 195 | logger.info("Detect information of video...") 196 | await self.init_session() 197 | self._lifetime = self._detect_lifetime_from_url() 198 | resp = await self.head(self._url, allow_redirects=True) 199 | if resp and resp.status == 200: 200 | self._format = self._format or self._detect_format_from_resp(resp) 201 | self._size = self._detect_size_from_resp(resp) 202 | # chunk = await resp.content.read(512) 203 | # self._resolution = self._detect_resolution(chunk) 204 | await self.close_session() 205 | 206 | def _detect_lifetime_from_url(self) -> int: 207 | """尝试从直链中找到资源失效时间戳, 计算直链寿命""" 208 | ts_start = int(time() / 1e5) # 当前时间戳的前5位 209 | stamps = re.findall(rf"{ts_start}\d{{5}}", self._url) 210 | for stamp in stamps: 211 | lifetime = int(stamp) - int(time()) 212 | if lifetime > 60: # 有效期大于 1 分钟的算有效 213 | logger.info(f"Found timestamp in real url, resource left lifetime: {lifetime}s") 214 | return lifetime 215 | return self._lifetime 216 | 217 | def _detect_format_from_url(self) -> str: 218 | """尝试从直链获取视频的格式信息""" 219 | fmt_table = {".m3u8": "hls", ".flv": "flv", ".mpd": "dash", ".mp4": "mp4"} 220 | for k, v in fmt_table.items(): 221 | if k in self._url: 222 | return v 223 | return "" 224 | 225 | def _detect_format_from_resp(self, resp: ClientResponse) -> str: 226 | c_type = resp.content_type 227 | if c_type in ["application/vnd.apple.mpegurl", "application/x-mpegurl"]: 228 | return "hls" 229 | if c_type in ["video/mp4", "application/octet-stream"]: 230 | return "mp4" 231 | return "" 232 | 233 | def _detect_size_from_resp(self, resp: ClientResponse) -> int: 234 | return resp.content_length or -1 235 | 236 | # def _detect_resolution(self, data: bytes) -> str: 237 | # # TODO: detect video resolution from meta block, MPEG-TS/MPEG-4 238 | # if self._format == "hls": 239 | # text = data.decode("utf-8") 240 | # if ret := re.search(r"RESOLUTION=(\d+x\d+)", text): 241 | # self._resolution = ret.group(1) 242 | # elif self._format == "mp4": 243 | # tkhd_box_pos = data.find(b"\x74\x6B\x68\x64") 244 | # if tkhd_box_pos != -1: 245 | # start = tkhd_box_pos + 0x4E 246 | # width = int(data[start:start + 4].hex(), 16) 247 | # height = int(data[start + 4:start + 8].hex(), 16) 248 | # self._resolution = f"{width}x{height}" 249 | # logger.debug(f"Find video resolution in tkhd box(MPEG-4): {self._resolution}") 250 | # return self._resolution 251 | 252 | def __repr__(self): 253 | return f"" 254 | 255 | 256 | class AnimeSearcher(HtmlParseHelper): 257 | """ 258 | 番剧搜索引擎 259 | """ 260 | 261 | async def search(self, keyword: str) -> AsyncIterator[AnimeMeta]: 262 | """ 263 | 搜索番剧, 提取关键词对应的全部番剧摘要信息 264 | 265 | :param keyword: 搜索关键词 266 | :return: 元素为番剧摘要信息类 AnimeMeta 的异步生成器 267 | """ 268 | yield 269 | 270 | async def _search(self, keyword: str) -> AsyncIterator[AnimeMeta]: 271 | """本方法由引擎管理器负责调用, 创建 session, 捕获异常并记录""" 272 | try: 273 | await self._before_init() 274 | await self.init_session() 275 | async for item in self.search(keyword): 276 | yield item 277 | except Exception as e: 278 | logger.exception(e) 279 | return 280 | 281 | 282 | class AnimeDetailParser(HtmlParseHelper): 283 | """ 284 | 番剧详情页面解析器 285 | """ 286 | 287 | async def parse(self, detail_url: str) -> AnimeDetail: 288 | """ 289 | 解析番剧的详情页面, 提取视频播放列表和其它信息 290 | 291 | :param detail_url: 详情页面的 URL(可能并不完整) 292 | :return: 番剧详情信息类 AnimeDetail 293 | """ 294 | pass 295 | 296 | async def _parse(self, detail_url: str) -> AnimeDetail: 297 | """本方法由引擎管理器负责调用, 创建 session, 捕获异常并记录""" 298 | try: 299 | await self._before_init() 300 | await self.init_session() 301 | return await self.parse(detail_url) 302 | except Exception as e: 303 | logger.exception(e) 304 | return AnimeDetail() 305 | 306 | 307 | class AnimeUrlParser(HtmlParseHelper): 308 | """ 309 | 视频直链解析器 310 | """ 311 | 312 | async def parse(self, raw_url: str) -> Union[AnimeInfo, str]: 313 | """ 314 | 重写此方法以实现直链的解析和有效期提取工作 315 | 316 | :param raw_url: 原始链接 317 | :return: 视频直链对象(含直链和有效期) 318 | """ 319 | return AnimeInfo(raw_url) 320 | 321 | async def _parse(self, raw_url: str) -> AnimeInfo: 322 | """解析直链, 捕获引擎模块未处理的异常""" 323 | try: 324 | await self._before_init() 325 | await self.init_session() 326 | info = await self.parse(raw_url) 327 | if not isinstance(info, AnimeInfo): 328 | info = AnimeInfo(info) # 方便 parse 直接返回字符串链接 329 | await info.detect_more_info() 330 | if info.is_available(): # 解析成功 331 | logger.info(f"Parse success: {info}") 332 | logger.info(f"Real url: {info.real_url}") 333 | return info 334 | logger.error(f"Parse failed: {info}") 335 | return AnimeInfo() 336 | except Exception as e: 337 | logger.exception(e) 338 | return AnimeInfo() 339 | -------------------------------------------------------------------------------- /api/danmaku/bilibili/danmaku_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: danmaku.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import message as _message 7 | from google.protobuf import reflection as _reflection 8 | from google.protobuf import symbol_database as _symbol_database 9 | 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | DESCRIPTOR = _descriptor.FileDescriptor( 15 | name='danmaku.proto', 16 | package='bilibili.danmaku', 17 | syntax='proto3', 18 | serialized_options=None, 19 | create_key=_descriptor._internal_create_key, 20 | serialized_pb=b'\n\rdanmaku.proto\x12\x10\x62ilibili.danmaku\"Q\n\rSegmentConfig\x12\x15\n\x08\x64uration\x18\x01 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\x05total\x18\x02 \x01(\x03H\x01\x88\x01\x01\x42\x0b\n\t_durationB\x08\n\x06_total\"\x84\x01\n\x0b\x44\x61nmakuInfo\x12\x12\n\x05state\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x31\n\x03seg\x18\x04 \x01(\x0b\x32\x1f.bilibili.danmaku.SegmentConfigH\x01\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x08 \x01(\x03H\x02\x88\x01\x01\x42\x08\n\x06_stateB\x06\n\x04_segB\x08\n\x06_count\"H\n\x06\x42ullet\x12\x10\n\x08progress\x18\x02 \x01(\x05\x12\x0c\n\x04mode\x18\x03 \x01(\x05\x12\r\n\x05\x63olor\x18\x05 \x01(\r\x12\x0f\n\x07\x63ontent\x18\x07 \x01(\t\"7\n\x0b\x44\x61nmakuData\x12(\n\x06\x62ullet\x18\x01 \x03(\x0b\x32\x18.bilibili.danmaku.Bulletb\x06proto3' 21 | ) 22 | 23 | _SEGMENTCONFIG = _descriptor.Descriptor( 24 | name='SegmentConfig', 25 | full_name='bilibili.danmaku.SegmentConfig', 26 | filename=None, 27 | file=DESCRIPTOR, 28 | containing_type=None, 29 | create_key=_descriptor._internal_create_key, 30 | fields=[ 31 | _descriptor.FieldDescriptor( 32 | name='duration', full_name='bilibili.danmaku.SegmentConfig.duration', index=0, 33 | number=1, type=3, cpp_type=2, label=1, 34 | has_default_value=False, default_value=0, 35 | message_type=None, enum_type=None, containing_type=None, 36 | is_extension=False, extension_scope=None, 37 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 38 | _descriptor.FieldDescriptor( 39 | name='total', full_name='bilibili.danmaku.SegmentConfig.total', index=1, 40 | number=2, type=3, cpp_type=2, label=1, 41 | has_default_value=False, default_value=0, 42 | message_type=None, enum_type=None, containing_type=None, 43 | is_extension=False, extension_scope=None, 44 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 45 | ], 46 | extensions=[ 47 | ], 48 | nested_types=[], 49 | enum_types=[ 50 | ], 51 | serialized_options=None, 52 | is_extendable=False, 53 | syntax='proto3', 54 | extension_ranges=[], 55 | oneofs=[ 56 | _descriptor.OneofDescriptor( 57 | name='_duration', full_name='bilibili.danmaku.SegmentConfig._duration', 58 | index=0, containing_type=None, 59 | create_key=_descriptor._internal_create_key, 60 | fields=[]), 61 | _descriptor.OneofDescriptor( 62 | name='_total', full_name='bilibili.danmaku.SegmentConfig._total', 63 | index=1, containing_type=None, 64 | create_key=_descriptor._internal_create_key, 65 | fields=[]), 66 | ], 67 | serialized_start=35, 68 | serialized_end=116, 69 | ) 70 | 71 | _DANMAKUINFO = _descriptor.Descriptor( 72 | name='DanmakuInfo', 73 | full_name='bilibili.danmaku.DanmakuInfo', 74 | filename=None, 75 | file=DESCRIPTOR, 76 | containing_type=None, 77 | create_key=_descriptor._internal_create_key, 78 | fields=[ 79 | _descriptor.FieldDescriptor( 80 | name='state', full_name='bilibili.danmaku.DanmakuInfo.state', index=0, 81 | number=1, type=5, cpp_type=1, label=1, 82 | has_default_value=False, default_value=0, 83 | message_type=None, enum_type=None, containing_type=None, 84 | is_extension=False, extension_scope=None, 85 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 86 | _descriptor.FieldDescriptor( 87 | name='seg', full_name='bilibili.danmaku.DanmakuInfo.seg', index=1, 88 | number=4, type=11, cpp_type=10, label=1, 89 | has_default_value=False, default_value=None, 90 | message_type=None, enum_type=None, containing_type=None, 91 | is_extension=False, extension_scope=None, 92 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 93 | _descriptor.FieldDescriptor( 94 | name='count', full_name='bilibili.danmaku.DanmakuInfo.count', index=2, 95 | number=8, type=3, cpp_type=2, label=1, 96 | has_default_value=False, default_value=0, 97 | message_type=None, enum_type=None, containing_type=None, 98 | is_extension=False, extension_scope=None, 99 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 100 | ], 101 | extensions=[ 102 | ], 103 | nested_types=[], 104 | enum_types=[ 105 | ], 106 | serialized_options=None, 107 | is_extendable=False, 108 | syntax='proto3', 109 | extension_ranges=[], 110 | oneofs=[ 111 | _descriptor.OneofDescriptor( 112 | name='_state', full_name='bilibili.danmaku.DanmakuInfo._state', 113 | index=0, containing_type=None, 114 | create_key=_descriptor._internal_create_key, 115 | fields=[]), 116 | _descriptor.OneofDescriptor( 117 | name='_seg', full_name='bilibili.danmaku.DanmakuInfo._seg', 118 | index=1, containing_type=None, 119 | create_key=_descriptor._internal_create_key, 120 | fields=[]), 121 | _descriptor.OneofDescriptor( 122 | name='_count', full_name='bilibili.danmaku.DanmakuInfo._count', 123 | index=2, containing_type=None, 124 | create_key=_descriptor._internal_create_key, 125 | fields=[]), 126 | ], 127 | serialized_start=119, 128 | serialized_end=251, 129 | ) 130 | 131 | _BULLET = _descriptor.Descriptor( 132 | name='Bullet', 133 | full_name='bilibili.danmaku.Bullet', 134 | filename=None, 135 | file=DESCRIPTOR, 136 | containing_type=None, 137 | create_key=_descriptor._internal_create_key, 138 | fields=[ 139 | _descriptor.FieldDescriptor( 140 | name='progress', full_name='bilibili.danmaku.Bullet.progress', index=0, 141 | number=2, type=5, cpp_type=1, label=1, 142 | has_default_value=False, default_value=0, 143 | message_type=None, enum_type=None, containing_type=None, 144 | is_extension=False, extension_scope=None, 145 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 146 | _descriptor.FieldDescriptor( 147 | name='mode', full_name='bilibili.danmaku.Bullet.mode', index=1, 148 | number=3, type=5, cpp_type=1, label=1, 149 | has_default_value=False, default_value=0, 150 | message_type=None, enum_type=None, containing_type=None, 151 | is_extension=False, extension_scope=None, 152 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 153 | _descriptor.FieldDescriptor( 154 | name='color', full_name='bilibili.danmaku.Bullet.color', index=2, 155 | number=5, type=13, cpp_type=3, label=1, 156 | has_default_value=False, default_value=0, 157 | message_type=None, enum_type=None, containing_type=None, 158 | is_extension=False, extension_scope=None, 159 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 160 | _descriptor.FieldDescriptor( 161 | name='content', full_name='bilibili.danmaku.Bullet.content', index=3, 162 | number=7, type=9, cpp_type=9, label=1, 163 | has_default_value=False, default_value=b"".decode('utf-8'), 164 | message_type=None, enum_type=None, containing_type=None, 165 | is_extension=False, extension_scope=None, 166 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 167 | ], 168 | extensions=[ 169 | ], 170 | nested_types=[], 171 | enum_types=[ 172 | ], 173 | serialized_options=None, 174 | is_extendable=False, 175 | syntax='proto3', 176 | extension_ranges=[], 177 | oneofs=[ 178 | ], 179 | serialized_start=253, 180 | serialized_end=325, 181 | ) 182 | 183 | _DANMAKUDATA = _descriptor.Descriptor( 184 | name='DanmakuData', 185 | full_name='bilibili.danmaku.DanmakuData', 186 | filename=None, 187 | file=DESCRIPTOR, 188 | containing_type=None, 189 | create_key=_descriptor._internal_create_key, 190 | fields=[ 191 | _descriptor.FieldDescriptor( 192 | name='bullet', full_name='bilibili.danmaku.DanmakuData.bullet', index=0, 193 | number=1, type=11, cpp_type=10, label=3, 194 | has_default_value=False, default_value=[], 195 | message_type=None, enum_type=None, containing_type=None, 196 | is_extension=False, extension_scope=None, 197 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 198 | ], 199 | extensions=[ 200 | ], 201 | nested_types=[], 202 | enum_types=[ 203 | ], 204 | serialized_options=None, 205 | is_extendable=False, 206 | syntax='proto3', 207 | extension_ranges=[], 208 | oneofs=[ 209 | ], 210 | serialized_start=327, 211 | serialized_end=382, 212 | ) 213 | 214 | _SEGMENTCONFIG.oneofs_by_name['_duration'].fields.append( 215 | _SEGMENTCONFIG.fields_by_name['duration']) 216 | _SEGMENTCONFIG.fields_by_name['duration'].containing_oneof = _SEGMENTCONFIG.oneofs_by_name['_duration'] 217 | _SEGMENTCONFIG.oneofs_by_name['_total'].fields.append( 218 | _SEGMENTCONFIG.fields_by_name['total']) 219 | _SEGMENTCONFIG.fields_by_name['total'].containing_oneof = _SEGMENTCONFIG.oneofs_by_name['_total'] 220 | _DANMAKUINFO.fields_by_name['seg'].message_type = _SEGMENTCONFIG 221 | _DANMAKUINFO.oneofs_by_name['_state'].fields.append( 222 | _DANMAKUINFO.fields_by_name['state']) 223 | _DANMAKUINFO.fields_by_name['state'].containing_oneof = _DANMAKUINFO.oneofs_by_name['_state'] 224 | _DANMAKUINFO.oneofs_by_name['_seg'].fields.append( 225 | _DANMAKUINFO.fields_by_name['seg']) 226 | _DANMAKUINFO.fields_by_name['seg'].containing_oneof = _DANMAKUINFO.oneofs_by_name['_seg'] 227 | _DANMAKUINFO.oneofs_by_name['_count'].fields.append( 228 | _DANMAKUINFO.fields_by_name['count']) 229 | _DANMAKUINFO.fields_by_name['count'].containing_oneof = _DANMAKUINFO.oneofs_by_name['_count'] 230 | _DANMAKUDATA.fields_by_name['bullet'].message_type = _BULLET 231 | DESCRIPTOR.message_types_by_name['SegmentConfig'] = _SEGMENTCONFIG 232 | DESCRIPTOR.message_types_by_name['DanmakuInfo'] = _DANMAKUINFO 233 | DESCRIPTOR.message_types_by_name['Bullet'] = _BULLET 234 | DESCRIPTOR.message_types_by_name['DanmakuData'] = _DANMAKUDATA 235 | _sym_db.RegisterFileDescriptor(DESCRIPTOR) 236 | 237 | SegmentConfig = _reflection.GeneratedProtocolMessageType('SegmentConfig', (_message.Message,), { 238 | 'DESCRIPTOR': _SEGMENTCONFIG, 239 | '__module__': 'danmaku_pb2' 240 | # @@protoc_insertion_point(class_scope:bilibili.danmaku.SegmentConfig) 241 | }) 242 | _sym_db.RegisterMessage(SegmentConfig) 243 | 244 | DanmakuInfo = _reflection.GeneratedProtocolMessageType('DanmakuInfo', (_message.Message,), { 245 | 'DESCRIPTOR': _DANMAKUINFO, 246 | '__module__': 'danmaku_pb2' 247 | # @@protoc_insertion_point(class_scope:bilibili.danmaku.DanmakuInfo) 248 | }) 249 | _sym_db.RegisterMessage(DanmakuInfo) 250 | 251 | Bullet = _reflection.GeneratedProtocolMessageType('Bullet', (_message.Message,), { 252 | 'DESCRIPTOR': _BULLET, 253 | '__module__': 'danmaku_pb2' 254 | # @@protoc_insertion_point(class_scope:bilibili.danmaku.Bullet) 255 | }) 256 | _sym_db.RegisterMessage(Bullet) 257 | 258 | DanmakuData = _reflection.GeneratedProtocolMessageType('DanmakuData', (_message.Message,), { 259 | 'DESCRIPTOR': _DANMAKUDATA, 260 | '__module__': 'danmaku_pb2' 261 | # @@protoc_insertion_point(class_scope:bilibili.danmaku.DanmakuData) 262 | }) 263 | _sym_db.RegisterMessage(DanmakuData) 264 | 265 | # @@protoc_insertion_point(module_scope) 266 | -------------------------------------------------------------------------------- /api/router.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from os.path import dirname 4 | 5 | from quart import Quart, jsonify, request, render_template, \ 6 | Response, websocket, redirect 7 | 8 | from api.config import Config 9 | from api.core.agent import Agent 10 | from api.core.anime import * 11 | from api.core.danmaku import * 12 | from api.core.proxy import RequestProxy 13 | from api.utils.storage import Storage 14 | 15 | 16 | class APIRouter: 17 | 18 | def __init__(self, host: str, port: int): 19 | self._root = dirname(__file__) 20 | self._app = Quart(__name__) 21 | self._debug = False 22 | self._host = host 23 | self._port = port 24 | self._domain = f"http://{host}:{port}" 25 | self._agent = Agent() 26 | self._config = Config() 27 | self._storage = Storage() 28 | self._proxy = RequestProxy() 29 | 30 | def set_domain(self, domain: str): 31 | """ 32 | 设置 API 返回的资源链接的域名, 域名含协议头不含端口号 33 | 如: http://www.foo.bar 34 | """ 35 | self._domain = f"{domain}:{self._port}" if domain else self._domain 36 | 37 | def set_proxy_host(self, path_prefix: str): 38 | """ 39 | 服务器端反向代理时使用, 设置 API 资源链接的路径前缀, 40 | 如: http://www.foo.bar/anime-api, 结尾不加 "/" 41 | """ 42 | self._domain = path_prefix if path_prefix else self._domain 43 | 44 | def run(self): 45 | """启动 API 解析服务""" 46 | 47 | def exception_handler(_loop, context): 48 | logger.debug(context) 49 | 50 | self._init_routers() 51 | # 为了解决事件循环内部出现的异常 52 | if os.name == "nt": 53 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 54 | loop = asyncio.new_event_loop() 55 | loop.set_exception_handler(exception_handler) 56 | asyncio.set_event_loop(loop) 57 | self._app.run(host=self._host, port=self._port, debug=False, use_reloader=False, loop=loop) 58 | 59 | def _init_routers(self): 60 | """创建路由接口""" 61 | 62 | @self._app.after_request 63 | async def apply_caching(resp: Response): 64 | """设置响应的全局 headers, 允许跨域""" 65 | resp.headers["Server"] = "Anime-API" 66 | resp.headers["Access-Control-Allow-Origin"] = "*" 67 | resp.headers["Access-Control-Allow-Headers"] = "*" 68 | return resp 69 | 70 | @self._app.route("/") 71 | async def index(): 72 | """API 主页显示帮助信息""" 73 | file = f"{self._root}/templates/interface.txt" 74 | with open(file, encoding="utf-8") as f: 75 | text = f.read() 76 | return Response(text, mimetype="text/plain") 77 | 78 | # ======================== Anime Interface =============================== 79 | 80 | @self._app.route("/anime/bangumi/updates") 81 | async def get_bangumi_updates(): 82 | """获取番剧更新时间表""" 83 | bangumi_list = await self._agent.get_bangumi_updates() 84 | data = [] 85 | for bangumi in bangumi_list: 86 | one_day = { 87 | "date": bangumi.date, 88 | "day_of_week": bangumi.day_of_week, 89 | "is_today": bangumi.is_today, 90 | "updates": [] 91 | } 92 | for info in bangumi: 93 | one_day["updates"].append({ 94 | "title": info.title, 95 | "cover_url": f"{info.cover_url}", # 图片一律走代理, 防止浏览器跨域拦截 96 | "update_time": info.update_time, 97 | "update_to": info.update_to 98 | }) 99 | data.append(one_day) 100 | return jsonify(data) 101 | 102 | @self._app.route("/anime/search/") 103 | async def search_anime(keyword): 104 | """番剧搜索, 该方法回阻塞直到所有引擎数据返回""" 105 | result: List[AnimeMeta] = [] 106 | await self._agent.get_anime_metas(keyword.strip(), callback=lambda m: result.append(m)) 107 | ret = [] 108 | for meta in result: 109 | ret.append({ 110 | "title": meta.title, 111 | "cover_url": f"{meta.cover_url}", 112 | "category": meta.category, 113 | "description": meta.desc, 114 | "score": 80, # TODO: 番剧质量评分机制 115 | "module": meta.module, 116 | "url": f"{self._domain}/anime/{meta.token}" 117 | }) 118 | return jsonify(ret) 119 | 120 | @self._app.websocket("/anime/search") 121 | async def ws_search_anime(): 122 | async def push(meta: AnimeMeta): 123 | await websocket.send_json({ 124 | "title": meta.title, 125 | "cover_url": f"{meta.cover_url}", 126 | "category": meta.category, 127 | "description": meta.desc, 128 | "score": 80, 129 | "engine": meta.module, 130 | "url": f"{self._domain}/anime/{meta.token}" 131 | }) 132 | 133 | # route path 不能有中文, 客户端 send 关键字 134 | keyword = await websocket.receive() 135 | await self._agent.get_anime_metas(keyword.strip(), co_callback=push) 136 | 137 | @self._app.route("/anime/") 138 | async def get_anime_detail(token): 139 | """返回番剧详情页面信息""" 140 | detail = await self._agent.get_anime_detail(token) 141 | if not detail: 142 | return Response("Parse detail failed", status=404) 143 | 144 | ret = { 145 | "title": detail.title, 146 | "cover_url": f"{detail.cover_url}", 147 | "description": detail.desc, 148 | "category": detail.category, 149 | "module": detail.module, 150 | "play_lists": [] 151 | } 152 | for idx, playlist in enumerate(detail): 153 | lst = { 154 | "name": playlist.name, 155 | "num": playlist.num, 156 | "video_list": [] 157 | } # 一个播放列表 158 | for episode, video in enumerate(playlist): 159 | video_path = f"{token}/{idx}/{episode}" 160 | lst["video_list"].append({ 161 | "name": video.name, 162 | "info": f"{self._domain}/anime/{video_path}", 163 | "player": f"{self._domain}/anime/{video_path}/player", 164 | }) 165 | ret["play_lists"].append(lst) 166 | return jsonify(ret) 167 | 168 | @self._app.route("/anime///") 169 | async def parse_anime_info(token: str, playlist: str, episode: str): 170 | """获取视频信息""" 171 | url = await self._agent.get_anime_real_url(token, int(playlist), int(episode)) 172 | info = { 173 | "raw_url": f"{self._domain}/anime/{token}/{playlist}/{episode}/url", 174 | "proxy_url": f"{self._domain}/proxy/anime/{token}/{playlist}/{episode}", 175 | "format": url.format, 176 | # "resolution": url.resolution, 177 | "size": url.size, 178 | "lifetime": url.left_lifetime 179 | } 180 | return jsonify(info) 181 | 182 | @self._app.route("/anime////url") 183 | async def redirect_to_real_url(token: str, playlist: str, episode: str): 184 | """重定向到视频直链, 防止直链过期导致播放器无法播放""" 185 | proxy = await self._agent.get_anime_proxy(token, int(playlist), int(episode)) 186 | if not proxy or not proxy.is_available(): 187 | return Response("Resource not available", status=404) 188 | if proxy.is_enforce_proxy(): # 该资源启用了强制代理 189 | return redirect(f"/proxy/anime/{token}/{playlist}/{episode}") 190 | return redirect(proxy.get_real_url()) 191 | 192 | @self._app.route("/anime////player") 193 | async def player_without_proxy(token, playlist, episode): 194 | """视频直链播放测试""" 195 | url = f"{self._domain}/anime/{token}/{playlist}/{episode}" 196 | return await render_template("player.html", info_url=url) 197 | 198 | # ======================== Danmaku Interface =============================== 199 | 200 | @self._app.route("/danmaku/search/") 201 | async def search_danmaku(keyword): 202 | """搜索番剧弹幕库""" 203 | result: List[DanmakuMeta] = [] 204 | await self._agent.get_danmaku_metas(keyword.strip(), callback=lambda m: result.append(m)) 205 | data = [] 206 | for meta in result: 207 | data.append({ 208 | "title": meta.title, 209 | "num": meta.num, 210 | "module": meta.module, 211 | "score": 80, # TODO: 弹幕质量评分机制 212 | "url": f"{self._domain}/danmaku/{meta.token}" 213 | }) 214 | return jsonify(data) 215 | 216 | @self._app.websocket("/danmaku/search") 217 | async def ws_search_danmaku(): 218 | """搜索番剧弹幕库""" 219 | 220 | async def push(meta: DanmakuMeta): 221 | await websocket.send_json({ 222 | "title": meta.title, 223 | "num": meta.num, 224 | "module": meta.module, 225 | "score": 80, 226 | "url": f"{self._domain}/danmaku/{meta.token}" 227 | }) 228 | 229 | keyword = await websocket.receive() 230 | await self._agent.get_danmaku_metas(keyword.strip(), co_callback=push) 231 | 232 | @self._app.route("/danmaku/") 233 | async def get_danmaku_detail(token): 234 | """获取番剧各集对应的弹幕库信息""" 235 | detail = await self._agent.get_danmaku_detail(token) 236 | if detail.is_empty(): 237 | return Response("Parse danmaku detail failed", status=404) 238 | 239 | data = [] 240 | for episode, danmaku in enumerate(detail): 241 | data.append({ 242 | "name": danmaku.name, 243 | "url": f"{self._domain}/danmaku/{token}/{episode}", # Dplayer 会自动添加 /v3/ 244 | "data": f"{self._domain}/danmaku/{token}/{episode}/v3/" # 调试用 245 | }) 246 | return jsonify(data) 247 | 248 | @self._app.route("/danmaku///v3/") 249 | async def get_danmaku_data(token, episode): 250 | """解析视频的弹幕库信息, 返回 DPlayer 支持的弹幕格式""" 251 | data = await self._agent.get_danmaku_data(token, int(episode)) 252 | ret = {"code": 0, "data": data.data, "num": data.num} 253 | return jsonify(ret) 254 | 255 | # ======================== IPTV Interface =============================== 256 | 257 | @self._app.route("/iptv/list") 258 | async def get_iptv_list(): 259 | """IPTV 直播源""" 260 | sources = self._agent.get_iptv_sources() 261 | data = [] 262 | for source in sources: 263 | data.append({ 264 | "name": source.name, 265 | "url": source.url 266 | }) 267 | return jsonify(data) 268 | 269 | # ======================== Proxy Interface =============================== 270 | 271 | @self._app.route("/proxy/image/") 272 | async def image_proxy(raw_url): 273 | """对跨域图片进行代理访问, 返回数据""" 274 | return await self._proxy.make_response(raw_url) 275 | 276 | @self._app.route("/proxy/anime///") 277 | async def anime_stream_proxy(token, playlist, episode): 278 | """代理访问普通的视频数据流""" 279 | proxy = await self._agent.get_anime_proxy(token, int(playlist), int(episode)) 280 | if not proxy: 281 | return Response("proxy error", status=404) 282 | 283 | if proxy.get_stream_format() == "hls": # m3u8 代理 284 | proxy.set_chunk_proxy_router(f"{self._domain}/proxy/hls/{token}/{playlist}/{episode}") 285 | return await proxy.make_response_for_m3u8() 286 | else: # mp4 代理 287 | range_field = request.headers.get("range") 288 | return await proxy.make_response_with_range(range_field) 289 | 290 | @self._app.route("/proxy/hls////") 291 | async def m3u8_chunk_proxy(token, playlist, episode, url): 292 | """代理访问视频的某一块数据""" 293 | proxy = await self._agent.get_anime_proxy(token, int(playlist), int(episode)) 294 | if not proxy: 295 | return Response("m3u8 chunk proxy error", status=404) 296 | return await proxy.make_response_for_chunk(url, request.args.to_dict()) 297 | 298 | # ======================== System Interface =============================== 299 | 300 | @self._app.route("/system/logs") 301 | async def show_logs(): 302 | file = f"{self._root}/logs/api.log" 303 | with open(file, encoding="utf-8") as f: 304 | text = f.read() 305 | return Response(text, mimetype="text/plain") 306 | 307 | @self._app.route("/system/version") 308 | async def show_system_version(): 309 | return jsonify(self._config.get_version()) 310 | 311 | @self._app.route("/system/clear") 312 | async def clear_system_cache(): 313 | """清空 API 的临时缓存数据""" 314 | mem_free = self._agent.cache_clear() 315 | return jsonify({"clear": "success", "free": mem_free}) 316 | 317 | @self._app.route("/system/modules", methods=["GET", "POST", "OPTIONS"]) 318 | async def show_global_settings(): 319 | if request.method == "GET": 320 | return jsonify(self._config.get_modules_status()) 321 | elif request.method == "POST": 322 | options = await request.json 323 | ret = {} 324 | for option in options: 325 | module = option.get("module") 326 | enable = option.get("enable") 327 | if not module: 328 | continue 329 | ok = self._agent.change_module_state(module, enable) 330 | ret[module] = "success" if ok else "failed" 331 | return jsonify(ret) 332 | elif request.method == "OPTIONS": 333 | return Response("") 334 | 335 | @self._app.route("/system/storage", methods=["POST", "OPTIONS"]) 336 | async def web_storage(): 337 | """给前端持久化配置用""" 338 | if request.method == "OPTIONS": 339 | return Response("") 340 | if request.method == "POST": 341 | payload = await request.json 342 | if not payload: 343 | return jsonify({"msg": "payload format error"}) 344 | 345 | action: str = payload.get("action", "") 346 | key: str = payload.get("key", "") 347 | data: str = payload.get("data", "") 348 | 349 | if not key: 350 | return jsonify({"msg": "key is invalid"}) 351 | 352 | if action.lower() == "get": 353 | return jsonify({ 354 | "msg": "ok", 355 | "key": key, 356 | "data": self._storage.get(key) 357 | }) 358 | elif action.lower() == "set": 359 | self._storage.set(key, data) 360 | return jsonify({ 361 | "msg": "ok", 362 | "key": key, 363 | "data": data, 364 | }) 365 | elif action.lower() == "del": 366 | return jsonify({ 367 | "msg": "ok" if self._storage.delete(key) else "no data binds this key", 368 | "key": key 369 | }) 370 | else: 371 | return jsonify({ 372 | "msg": "action is not supported", 373 | "action": action 374 | }) 375 | --------------------------------------------------------------------------------