diff --git a/bilibili_api/__init__.py b/bilibili_api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1bb7b37c95888ac22fcb03ff42abb321e357887e --- /dev/null +++ b/bilibili_api/__init__.py @@ -0,0 +1,166 @@ +""" +bilibili_api + +哔哩哔哩的各种 API 调用便捷整合(视频、动态、直播等),另外附加一些常用的功能。 +""" + +import asyncio +import platform + +from .utils.sync import sync +from .utils.credential_refresh import Credential +from .utils.picture import Picture +from .utils.short import get_real_url +from .utils.parse_link import ResourceType, parse_link +from .utils.aid_bvid_transformer import aid2bvid, bvid2aid +from .utils.danmaku import DmMode, Danmaku, DmFontSize, SpecialDanmaku +from .utils.network import ( + HEADERS, + get_session, + set_session, + get_aiohttp_session, + set_aiohttp_session, + get_httpx_sync_session, + set_httpx_sync_session +) +from .errors import ( + LoginError, + ApiException, + ArgsException, + LiveException, + NetworkException, + ResponseException, + VideoUploadException, + ResponseCodeException, + DanmakuClosedException, + CredentialNoBuvid3Exception, + CredentialNoBiliJctException, + DynamicExceedImagesException, + CredentialNoSessdataException, + CredentialNoDedeUserIDException, +) +from . import ( + app, + ass, + hot, + game, + live, + note, + rank, + show, + user, + vote, + audio, + emoji, + login, + manga, + music, + topic, + video, + cheese, + client, + search, + article, + bangumi, + comment, + dynamic, + session, + festival, + homepage, + settings, + watchroom, + live_area, + video_tag, + black_room, + login_func, + video_zone, + favorite_list, + channel_series, + video_uploader, + creative_center, + article_category, + interactive_video, + audio_uploader, +) + +BILIBILI_API_VERSION = "16.2.0" + +# 如果系统为 Windows,则修改默认策略,以解决代理报错问题 +if "windows" in platform.system().lower(): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # type: ignore + +__all__ = [ + "ApiException", + "ArgsException", + "BILIBILI_API_VERSION", + "Credential", + "CredentialNoBiliJctException", + "CredentialNoBuvid3Exception", + "CredentialNoDedeUserIDException", + "CredentialNoSessdataException", + "Danmaku", + "DanmakuClosedException", + "DmFontSize", + "DmMode", + "DynamicExceedImagesException", + "HEADERS", + "LiveException", + "LoginError", + "NetworkException", + "Picture", + "ResourceType", + "ResponseCodeException", + "ResponseException", + "SpecialDanmaku", + "VideoUploadException", + "aid2bvid", + "app", + "article", + "article_category", + "ass", + "audio", + "audio_uploader", + "bangumi", + "black_room", + "bvid2aid", + "channel_series", + "cheese", + "client", + "comment", + "creative_center", + "dynamic", + "emoji", + "favorite_list", + "festival", + "game", + "get_aiohttp_session", + "get_real_url", + "get_session", + "homepage", + "hot", + "interactive_video", + "live", + "live_area", + "login", + "login_func", + "manga", + "music", + "note", + "parse_link", + "rank", + "search", + "session", + "set_aiohttp_session", + "set_session", + "settings", + "show", + "sync", + "topic", + "user", + "video", + "video_tag", + "video_uploader", + "video_zone", + "vote", + "watchroom", +] diff --git a/bilibili_api/__pycache__/__init__.cpython-38.pyc b/bilibili_api/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06806ebd4f881e695557094eae77a3c542fc4ffd Binary files /dev/null and b/bilibili_api/__pycache__/__init__.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/app.cpython-38.pyc b/bilibili_api/__pycache__/app.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7a69bbc13fa2b4abc2ffdade90313d528fb4a568 Binary files /dev/null and b/bilibili_api/__pycache__/app.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/article.cpython-38.pyc b/bilibili_api/__pycache__/article.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..652c4aca7330e7751485b5143bc50103c351634f Binary files /dev/null and b/bilibili_api/__pycache__/article.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/article_category.cpython-38.pyc b/bilibili_api/__pycache__/article_category.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2c01a01b26bc96f009e5fc7b2f7ce2b660e1734 Binary files /dev/null and b/bilibili_api/__pycache__/article_category.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/ass.cpython-38.pyc b/bilibili_api/__pycache__/ass.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..073077519a13b2d4bd9751dd37b1d6d837fedaad Binary files /dev/null and b/bilibili_api/__pycache__/ass.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/audio.cpython-38.pyc b/bilibili_api/__pycache__/audio.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3cca8f5005362c892e4f8c73d25c35b4a18fe92 Binary files /dev/null and b/bilibili_api/__pycache__/audio.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/audio_uploader.cpython-38.pyc b/bilibili_api/__pycache__/audio_uploader.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ca04159fcd318bd2482f87b1fb0d28e9ddddc07 Binary files /dev/null and b/bilibili_api/__pycache__/audio_uploader.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/bangumi.cpython-38.pyc b/bilibili_api/__pycache__/bangumi.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48919217e8dc25a8e6fe598838e62e266112d8fa Binary files /dev/null and b/bilibili_api/__pycache__/bangumi.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/black_room.cpython-38.pyc b/bilibili_api/__pycache__/black_room.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53c6135fa2fd8c52615caf06da49d9b71f7f5568 Binary files /dev/null and b/bilibili_api/__pycache__/black_room.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/channel_series.cpython-38.pyc b/bilibili_api/__pycache__/channel_series.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b74198e3745e0901e5d4fdd3df7c46bb221d821b Binary files /dev/null and b/bilibili_api/__pycache__/channel_series.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/cheese.cpython-38.pyc b/bilibili_api/__pycache__/cheese.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..abb33baeb04e3c15e11a3bcd6ecc01624ab7ab93 Binary files /dev/null and b/bilibili_api/__pycache__/cheese.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/client.cpython-38.pyc b/bilibili_api/__pycache__/client.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f62b63c1389282e45f70626d442ac12bd99df3c4 Binary files /dev/null and b/bilibili_api/__pycache__/client.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/comment.cpython-38.pyc b/bilibili_api/__pycache__/comment.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..775eedfa6d3a2770ed58bbba9deb6af90ef468dc Binary files /dev/null and b/bilibili_api/__pycache__/comment.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/creative_center.cpython-38.pyc b/bilibili_api/__pycache__/creative_center.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2120e121efaef52384d2d8f73f60c5c70f0ff36d Binary files /dev/null and b/bilibili_api/__pycache__/creative_center.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/dynamic.cpython-38.pyc b/bilibili_api/__pycache__/dynamic.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4cffc3bcea23b75d90a6ecfa1897c7b9cdc1c35a Binary files /dev/null and b/bilibili_api/__pycache__/dynamic.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/emoji.cpython-38.pyc b/bilibili_api/__pycache__/emoji.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..841da0d57a37be198550e4fa2c774470bc31c6c8 Binary files /dev/null and b/bilibili_api/__pycache__/emoji.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/favorite_list.cpython-38.pyc b/bilibili_api/__pycache__/favorite_list.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..952f47671bab28974b5b87b69bbac2c7bc403387 Binary files /dev/null and b/bilibili_api/__pycache__/favorite_list.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/festival.cpython-38.pyc b/bilibili_api/__pycache__/festival.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13d6990cf7208c87a1b7e23094b0608533758fc9 Binary files /dev/null and b/bilibili_api/__pycache__/festival.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/game.cpython-38.pyc b/bilibili_api/__pycache__/game.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5efad5e48b48cdbc7d46d7ba40f4a69eda9179fd Binary files /dev/null and b/bilibili_api/__pycache__/game.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/homepage.cpython-38.pyc b/bilibili_api/__pycache__/homepage.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33d5e5034f29ab6a9a367b4ba94a1a837765e988 Binary files /dev/null and b/bilibili_api/__pycache__/homepage.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/hot.cpython-38.pyc b/bilibili_api/__pycache__/hot.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba9844d57e909ef63a1b7699a617943c6b38c92d Binary files /dev/null and b/bilibili_api/__pycache__/hot.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/interactive_video.cpython-38.pyc b/bilibili_api/__pycache__/interactive_video.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c7e82d2e880a74d268a58e2a8afb351033a3d4d Binary files /dev/null and b/bilibili_api/__pycache__/interactive_video.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/live.cpython-38.pyc b/bilibili_api/__pycache__/live.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c965f3f6dd24a39421b5f730cddfdd0677c703a2 Binary files /dev/null and b/bilibili_api/__pycache__/live.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/live_area.cpython-38.pyc b/bilibili_api/__pycache__/live_area.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd10b9bb71a3764dca9300a418ac55d6ed3a7bf9 Binary files /dev/null and b/bilibili_api/__pycache__/live_area.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/login.cpython-38.pyc b/bilibili_api/__pycache__/login.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..989a8bc92ed80931a96d904fa60a81611d7e552a Binary files /dev/null and b/bilibili_api/__pycache__/login.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/login_func.cpython-38.pyc b/bilibili_api/__pycache__/login_func.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca72d5b92bf622531cbc6fe7d275d92e5bebb071 Binary files /dev/null and b/bilibili_api/__pycache__/login_func.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/manga.cpython-38.pyc b/bilibili_api/__pycache__/manga.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41cdcb22ce95cff66ba437e464c59bceadec55fa Binary files /dev/null and b/bilibili_api/__pycache__/manga.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/music.cpython-38.pyc b/bilibili_api/__pycache__/music.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a0cf256bffac92585f27bf7e06410f5497f5164 Binary files /dev/null and b/bilibili_api/__pycache__/music.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/note.cpython-38.pyc b/bilibili_api/__pycache__/note.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33996046933ff565f3958f0e7941d0f1fbb225e1 Binary files /dev/null and b/bilibili_api/__pycache__/note.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/opus.cpython-38.pyc b/bilibili_api/__pycache__/opus.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8c2fb152d1fa5891aeb3ebc7b6ccff54775c9416 Binary files /dev/null and b/bilibili_api/__pycache__/opus.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/rank.cpython-38.pyc b/bilibili_api/__pycache__/rank.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bd0e51ba9453b60a96daaed00a4589a49198073 Binary files /dev/null and b/bilibili_api/__pycache__/rank.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/search.cpython-38.pyc b/bilibili_api/__pycache__/search.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d22507aab9214ed9ce99a473df44194b343256f2 Binary files /dev/null and b/bilibili_api/__pycache__/search.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/session.cpython-38.pyc b/bilibili_api/__pycache__/session.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6540154033d8c442815b6b6998efbcf7a2858c7 Binary files /dev/null and b/bilibili_api/__pycache__/session.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/settings.cpython-38.pyc b/bilibili_api/__pycache__/settings.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d116cb0db809e9c99bfea0082c7fd21bcc951e5 Binary files /dev/null and b/bilibili_api/__pycache__/settings.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/show.cpython-38.pyc b/bilibili_api/__pycache__/show.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f7022e023913962676bd70b5bce7e9d0eb87c1bc Binary files /dev/null and b/bilibili_api/__pycache__/show.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/topic.cpython-38.pyc b/bilibili_api/__pycache__/topic.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b43811493747bb3fb0968e60b46e94142713fad Binary files /dev/null and b/bilibili_api/__pycache__/topic.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/user.cpython-38.pyc b/bilibili_api/__pycache__/user.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec1ca47999764cdf555a49028cbaa2f6a9fe6ca4 Binary files /dev/null and b/bilibili_api/__pycache__/user.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/video.cpython-38.pyc b/bilibili_api/__pycache__/video.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..320a9b5441921e63da4f64460475b8d41f39223e Binary files /dev/null and b/bilibili_api/__pycache__/video.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/video_tag.cpython-38.pyc b/bilibili_api/__pycache__/video_tag.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3b77e9bbb5fe26ca6b9b98533a8812a81cc2300 Binary files /dev/null and b/bilibili_api/__pycache__/video_tag.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/video_uploader.cpython-38.pyc b/bilibili_api/__pycache__/video_uploader.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0352f7330366d363f1679ef50173fd64ace32adb Binary files /dev/null and b/bilibili_api/__pycache__/video_uploader.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/video_zone.cpython-38.pyc b/bilibili_api/__pycache__/video_zone.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d344759665c6ffe4c41c4e9f56d938957da203b2 Binary files /dev/null and b/bilibili_api/__pycache__/video_zone.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/vote.cpython-38.pyc b/bilibili_api/__pycache__/vote.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..350af01a8e36da43bd0ec6e2fc78c543e80cfe8c Binary files /dev/null and b/bilibili_api/__pycache__/vote.cpython-38.pyc differ diff --git a/bilibili_api/__pycache__/watchroom.cpython-38.pyc b/bilibili_api/__pycache__/watchroom.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ad5a28b54ec99860ba9664607c935c175bb9887 Binary files /dev/null and b/bilibili_api/__pycache__/watchroom.cpython-38.pyc differ diff --git a/bilibili_api/_pyinstaller/__pycache__/entry_points.cpython-38.pyc b/bilibili_api/_pyinstaller/__pycache__/entry_points.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5f9925318caecd0d688b054106240a894d21ff1c Binary files /dev/null and b/bilibili_api/_pyinstaller/__pycache__/entry_points.cpython-38.pyc differ diff --git a/bilibili_api/_pyinstaller/__pycache__/hook-bilibili_api.cpython-38.pyc b/bilibili_api/_pyinstaller/__pycache__/hook-bilibili_api.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36e6f490f5f571ed820e06c6f5c436bf9ef0821e Binary files /dev/null and b/bilibili_api/_pyinstaller/__pycache__/hook-bilibili_api.cpython-38.pyc differ diff --git a/bilibili_api/_pyinstaller/entry_points.py b/bilibili_api/_pyinstaller/entry_points.py new file mode 100644 index 0000000000000000000000000000000000000000..0cbfa3bc4ff2aad330ef3e65b74da1817c925fdd --- /dev/null +++ b/bilibili_api/_pyinstaller/entry_points.py @@ -0,0 +1,6 @@ +import os +from typing import List + + +def get_hook_dirs() -> List[str]: + return [os.path.abspath(os.path.dirname(__file__))] diff --git a/bilibili_api/_pyinstaller/hook-bilibili_api.py b/bilibili_api/_pyinstaller/hook-bilibili_api.py new file mode 100644 index 0000000000000000000000000000000000000000..5e06e4a1d1ea61dd3a118f497723620cc5660fc3 --- /dev/null +++ b/bilibili_api/_pyinstaller/hook-bilibili_api.py @@ -0,0 +1,5 @@ +from typing import List, Tuple + +from PyInstaller.utils.hooks import collect_data_files + +datas: List[Tuple[str, str]] = collect_data_files("bilibili_api") diff --git a/bilibili_api/app.py b/bilibili_api/app.py new file mode 100644 index 0000000000000000000000000000000000000000..f6260253140351358724e2c4d3d94cc84d11072c --- /dev/null +++ b/bilibili_api/app.py @@ -0,0 +1,123 @@ +""" +bilibili_api.app + +手机 APP 相关 +""" + +import time +from hashlib import md5 +from typing import Union + +from .utils.utils import get_api +from .utils.credential import Credential +from .utils.network import Api + +API = get_api("app") + + +async def get_loading_images( + mobi_app: str = "android", + platform: str = "android", + height: int = 1920, + width: int = 1080, + build: int = 999999999, + birth: str = "", + credential: Union[Credential, None] = None, +): + """ + 获取开屏启动画面 + + Args: + build (int, optional) : 客户端内部版本号 + + mobi_app (str, optional) : android / iphone / ipad + + platform (str, optional) : android / ios / ios + + height (int, optional) : 屏幕高度 + + width (int, optional) : 屏幕宽度 + + birth (str, optional) : 生日日期(四位数,例 0101) + + credential (Credential | None, optional): 凭据. Defaults to None. + + Returns: + dict: 调用 API 返回的结果 + """ + credential = credential if credential is not None else Credential() + + api = API["splash"]["list"] + params = { + "build": build, + "mobi_app": mobi_app, + "platform": platform, + "height": height, + "width": width, + "birth": birth, + } + return await Api(**api, credential=credential).update_params(**params).result + + +async def get_loading_images_special( + mobi_app: str = "android", + platform: str = "android", + height: int = 1920, + width: int = 1080, + credential: Union[Credential, None] = None, +): + """ + 获取特殊开屏启动画面 + + Args: + mobi_app (str, optional) : android / iphone / ipad + + platform (str, optional) : android / ios / ios + + height (str, optional) : 屏幕高度 + + width (str, optional) : 屏幕宽度 + + credential (Credential | None, optional): 凭据. Defaults to None. + + Returns: + dict: 调用 API 返回的结果 + """ + APPKEY = "1d8b6e7d45233436" + APPSEC = "560c52ccd288fed045859ed18bffd973" + + ts = int(time.time()) + + credential = credential if credential is not None else Credential() + + api = API["splash"]["brand"] + sign_params = ( + "appkey=" + + APPKEY + + "&mobi_app=" + + mobi_app + + "&platform=" + + platform + + "&screen_height=" + + str(height) + + "&screen_width=" + + str(width) + + "&ts=" + + str(ts) + + APPSEC + ) + + sign = md5() + sign.update(sign_params.encode(encoding="utf-8")) + sign = sign.hexdigest() + + params = { + "appkey": APPKEY, + "mobi_app": mobi_app, + "platform": platform, + "screen_height": height, + "screen_width": width, + "ts": ts, + "sign": sign, + } + return await Api(**api, credential=credential).update_params(**params).result diff --git a/bilibili_api/article.py b/bilibili_api/article.py new file mode 100644 index 0000000000000000000000000000000000000000..c1a0056ae459df5944ece377696415f360c97451 --- /dev/null +++ b/bilibili_api/article.py @@ -0,0 +1,1078 @@ +""" +bilibili_api.article + +专栏相关 +""" + +import re +import json +from copy import copy +from enum import Enum +from html import unescape +from datetime import datetime +from urllib.parse import unquote +from typing import List, Union, TypeVar, overload + +import yaml +import httpx +from yarl import URL +from bs4 import BeautifulSoup, element + +from .utils.initial_state import get_initial_state, get_initial_state_sync +from .utils.utils import get_api, raise_for_statement +from .utils.credential import Credential +from .utils.network import Api, get_session +from .utils import cache_pool +from .exceptions.NetworkException import ApiException, NetworkException +from .video import get_cid_info_sync +from . import note +from . import opus + +API = get_api("article") + +# 文章颜色表 +ARTICLE_COLOR_MAP = { + "default": "222222", + "blue-01": "56c1fe", + "lblue-01": "73fdea", + "green-01": "89fa4e", + "yellow-01": "fff359", + "pink-01": "ff968d", + "purple-01": "ff8cc6", + "blue-02": "02a2ff", + "lblue-02": "18e7cf", + "green-02": "60d837", + "yellow-02": "fbe231", + "pink-02": "ff654e", + "purple-02": "ef5fa8", + "blue-03": "0176ba", + "lblue-03": "068f86", + "green-03": "1db100", + "yellow-03": "f8ba00", + "pink-03": "ee230d", + "purple-03": "cb297a", + "blue-04": "004e80", + "lblue-04": "017c76", + "green-04": "017001", + "yellow-04": "ff9201", + "pink-04": "b41700", + "purple-04": "99195e", + "gray-01": "d6d5d5", + "gray-02": "929292", + "gray-03": "5f5f5f", +} + + +class ArticleType(Enum): + """ + 专栏类型 + + - ARTICLE : 普通专栏,不与 opus 图文兼容。 + - NOTE : 公开笔记 + - SPECIAL_ARTICLE: 特殊专栏,采用笔记格式,且与 opus 图文完全兼容。 + """ + + ARTICLE = 0 + NOTE = 2 + SPECIAL_ARTICLE = 3 + + +class ArticleRankingType(Enum): + """ + 专栏排行榜类型枚举。 + + + MONTH: 月榜 + + WEEK: 周榜 + + DAY_BEFORE_YESTERDAY: 前日榜 + + YESTERDAY: 昨日榜 + """ + + MONTH = 1 + WEEK = 2 + DAY_BEFORE_YESTERDAY = 4 + YESTERDAY = 3 + + +ArticleT = TypeVar("ArticleT", bound="Article") + + +async def get_article_rank( + rank_type: ArticleRankingType = ArticleRankingType.YESTERDAY, +): + """ + 获取专栏排行榜 + + Args: + rank_type (ArticleRankingType): 排行榜类别. Defaults to ArticleRankingType.YESTERDAY. + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["info"]["rank"] + params = {"cid": rank_type.value} + return await Api(**api).update_params(**params).result + + +class ArticleList: + """ + 文集类 + + Attributes: + credential (Credential): 凭据类 + """ + + def __init__(self, rlid: int, credential: Union[Credential, None] = None): + """ + Args: + rlid (int) : 文集 id + + credential (Credential | None, optional): 凭据类. Defaults to None. + """ + self.__rlid = rlid + self.credential = credential + + def get_rlid(self) -> int: + return self.__rlid + + async def get_content(self) -> dict: + """ + 获取专栏文集文章列表 + + Returns: + dict: 调用 API 返回的结果 + """ + credential = self.credential if self.credential is not None else Credential() + + api = API["info"]["list"] + params = {"id": self.__rlid} + return await Api(**api, credential=credential).update_params(**params).result + + +class Article: + """ + 专栏类 + + Attributes: + credential (Credential): 凭据类 + """ + + def __init__(self, cvid: int, credential: Union[Credential, None] = None): + """ + Args: + cvid (int) : cv 号 + + credential (Credential | None, optional): 凭据. Defaults to None. + """ + self.__children: List[Node] = [] + self.credential: Credential = ( + credential if credential is not None else Credential() + ) + self.__meta = None + self.__cvid = cvid + self.__has_parsed: bool = False + + # 设置专栏类别 + if cache_pool.article_is_opus.get(self.__cvid): + self.__type = ArticleType.SPECIAL_ARTICLE + else: + api = API["info"]["view"] + params = {"id": self.__cvid} + resp = Api(**api).update_params(**params).request_sync(raw=True) + + if resp["code"] != 0: + self.__type = ArticleType.ARTICLE + elif resp["data"]["type"] == 0: + self.__type = ArticleType.ARTICLE + elif resp["data"]["type"] == 2: + self.__type = ArticleType.NOTE + else: + self.__type = ArticleType.SPECIAL_ARTICLE + + if cache_pool.article_dyn_id.get(self.__cvid): + self.__dyn_id = cache_pool.article_dyn_id[self.__cvid] + else: + initial_state = get_initial_state_sync( + f"https://www.bilibili.com/read/cv{self.__cvid}" + ) + self.__dyn_id = int(initial_state[0]["readInfo"]["dyn_id_str"]) + + def get_cvid(self) -> int: + return self.__cvid + + def get_type(self) -> ArticleType: + """ + 获取专栏类型(专栏/笔记) + + Returns: + ArticleType: 专栏类型 + """ + return self.__type + + def is_note(self) -> bool: + """ + 检查专栏是否笔记 + + Returns: + bool: 是否笔记 + """ + return self.__type == ArticleType.NOTE + + def turn_to_note(self) -> "note.Note": + """ + 对于完全与 opus 兼容的部分的特殊专栏,将 Article 对象转换为 Dynamic 对象。 + + Returns: + Note: 笔记类 + """ + raise_for_statement( + self.__type == ArticleType.NOTE, "仅支持公开笔记 (ArticleType.NOTE)" + ) + return note.Note( + cvid=self.__cvid, note_type=note.NoteType.PUBLIC, credential=self.credential + ) + + def turn_to_opus(self) -> "opus.Opus": + """ + 对于 SPECIAL_ARTICLE,将其转为图文 + """ + raise_for_statement( + self.__type == ArticleType.SPECIAL_ARTICLE, "仅支持图文专栏" + ) + cache_pool.opus_type[self.__dyn_id] = 1 + cache_pool.opus_info[self.__dyn_id] = {"basic": {"rid_str": str(self.__cvid)}} + return opus.Opus(self.__dyn_id, credential=self.credential) + + def markdown(self) -> str: + """ + 转换为 Markdown + + 请先调用 fetch_content() + + Returns: + str: Markdown 内容 + """ + if not self.__has_parsed: + raise ApiException("请先调用 fetch_content()") + + content = "" + + for node in self.__children: + try: + markdown_text = node.markdown() + except: + continue + else: + content += markdown_text + + meta_yaml = yaml.safe_dump(self.__meta, allow_unicode=True) + content = f"---\n{meta_yaml}\n---\n\n{content}" + return content + + def json(self) -> dict: + """ + 转换为 JSON 数据 + + 请先调用 fetch_content() + + Returns: + dict: JSON 数据 + """ + if not self.__has_parsed: + raise ApiException("请先调用 fetch_content()") + + return { + "type": "Article", + "meta": self.__meta, + "children": list(map(lambda x: x.json(), self.__children)), + } + + async def fetch_content(self) -> None: + """ + 获取并解析专栏内容 + + 该返回不会返回任何值,调用该方法后请再调用 `self.markdown()` 或 `self.json()` 来获取你需要的值。 + """ + + resp = await self.get_all() + + document = BeautifulSoup(f"
q", self.__stream[self.__offset : self.__offset + 8] + ) + self.__offset += 8 + return data[0] + + def ufixed16(self, LE=False) -> int: + """ + 读 Unsigned fixed Int16。 + + Args: + LE (bool): 为小端。 + + Returns: + int。 + """ + data = struct.unpack( + "H", self.__stream[self.__offset : self.__offset + 2] + ) + self.__offset += 2 + return data[0] + + def ufixed32(self, LE=False) -> int: + """ + 读 Unsigned fixed Int32。 + + Args: + LE (bool): 为小端。 + + Returns: + int。 + """ + data = struct.unpack( + "I", self.__stream[self.__offset : self.__offset + 4] + ) + self.__offset += 4 + return data[0] + + def ufixed64(self, LE=False) -> int: + """ + 读 Unsigned fixed Int64。 + + Args: + LE (bool): 为小端。 + + Returns: + int。 + """ + data = struct.unpack( + " Q", self.__stream[self.__offset : self.__offset + 8] + ) + self.__offset += 8 + return data[0] + + def set_pos(self, pos: int) -> None: + """ + 设置读取起始位置。 + + Args: + pos (int): 读取起始位置。 + """ + if pos < 0: + raise Exception("读取位置不能小于 0") + + if pos >= len(self.__stream): + raise Exception("读取位置超过字节流长度") + + self.__offset = pos + + def get_pos(self) -> int: + """ + 获取当前位置。 + + Returns: + int, 当前位置。 + """ + return self.__offset diff --git a/bilibili_api/utils/__init__.py b/bilibili_api/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/bilibili_api/utils/__pycache__/AsyncEvent.cpython-38.pyc b/bilibili_api/utils/__pycache__/AsyncEvent.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa9eddefa89d1bcc2ffc2bf537f7bc587735e3f8 Binary files /dev/null and b/bilibili_api/utils/__pycache__/AsyncEvent.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/BytesReader.cpython-38.pyc b/bilibili_api/utils/__pycache__/BytesReader.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..372404408a7c4c89a7eaa137151a566acf639ffc Binary files /dev/null and b/bilibili_api/utils/__pycache__/BytesReader.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/__init__.cpython-38.pyc b/bilibili_api/utils/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d861481a085f6ebdfcb69b3c86e2aedcece6953 Binary files /dev/null and b/bilibili_api/utils/__pycache__/__init__.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/aid_bvid_transformer.cpython-38.pyc b/bilibili_api/utils/__pycache__/aid_bvid_transformer.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..09ae56e66bba82fa47e1c5f732472d61ee1a1d06 Binary files /dev/null and b/bilibili_api/utils/__pycache__/aid_bvid_transformer.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/cache_pool.cpython-38.pyc b/bilibili_api/utils/__pycache__/cache_pool.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45b838c44fb527db4b1a99346532bd52fe8ff6fc Binary files /dev/null and b/bilibili_api/utils/__pycache__/cache_pool.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/captcha.cpython-38.pyc b/bilibili_api/utils/__pycache__/captcha.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26e6d3bbe749f46fe3ac91acfbf12b8bd9642d9f Binary files /dev/null and b/bilibili_api/utils/__pycache__/captcha.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/credential.cpython-38.pyc b/bilibili_api/utils/__pycache__/credential.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d7664bbe08a9a5cff3b40897c8b7de72ebd3596 Binary files /dev/null and b/bilibili_api/utils/__pycache__/credential.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/credential_refresh.cpython-38.pyc b/bilibili_api/utils/__pycache__/credential_refresh.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fef038a8d9595519d467472de973d09963b69a20 Binary files /dev/null and b/bilibili_api/utils/__pycache__/credential_refresh.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/danmaku.cpython-38.pyc b/bilibili_api/utils/__pycache__/danmaku.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..49ec911b9d9fc26248234c271d6e0632f36bcc6d Binary files /dev/null and b/bilibili_api/utils/__pycache__/danmaku.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/danmaku2ass.cpython-38.pyc b/bilibili_api/utils/__pycache__/danmaku2ass.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0dca29c5e28b2acd4cba70de116f6d87c38e5b88 Binary files /dev/null and b/bilibili_api/utils/__pycache__/danmaku2ass.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/initial_state.cpython-38.pyc b/bilibili_api/utils/__pycache__/initial_state.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f43a9de6bdea1b34ae793b22708647ad4f30d902 Binary files /dev/null and b/bilibili_api/utils/__pycache__/initial_state.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/json2srt.cpython-38.pyc b/bilibili_api/utils/__pycache__/json2srt.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e5aefe722e006a5f19862d728d5ace377efdfc0 Binary files /dev/null and b/bilibili_api/utils/__pycache__/json2srt.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/network.cpython-38.pyc b/bilibili_api/utils/__pycache__/network.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e830deb4eda2ad181ca478030de74f89ec547e19 Binary files /dev/null and b/bilibili_api/utils/__pycache__/network.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/parse_link.cpython-38.pyc b/bilibili_api/utils/__pycache__/parse_link.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e6ef2fc6f53f55f7a5c465987b458747b4bcbfd Binary files /dev/null and b/bilibili_api/utils/__pycache__/parse_link.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/picture.cpython-38.pyc b/bilibili_api/utils/__pycache__/picture.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..803362a2c0a0c1989d2aeb7854a238614e8dd3b5 Binary files /dev/null and b/bilibili_api/utils/__pycache__/picture.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/safecenter_captcha.cpython-38.pyc b/bilibili_api/utils/__pycache__/safecenter_captcha.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b4634519af32ca48fe0ea1ea76e8d99aac9381a1 Binary files /dev/null and b/bilibili_api/utils/__pycache__/safecenter_captcha.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/short.cpython-38.pyc b/bilibili_api/utils/__pycache__/short.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8518e4370f7c718bc337c4d898c6e0b2bd68d825 Binary files /dev/null and b/bilibili_api/utils/__pycache__/short.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/srt2ass.cpython-38.pyc b/bilibili_api/utils/__pycache__/srt2ass.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af2c3b293be147aa726df4ed109c5b9956416a09 Binary files /dev/null and b/bilibili_api/utils/__pycache__/srt2ass.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/sync.cpython-38.pyc b/bilibili_api/utils/__pycache__/sync.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b971c3ca6fa3d7d81cd10b753b093d3617784913 Binary files /dev/null and b/bilibili_api/utils/__pycache__/sync.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/upos.cpython-38.pyc b/bilibili_api/utils/__pycache__/upos.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..681a46841fe6385cbdf12e0d1932efa10ee041ec Binary files /dev/null and b/bilibili_api/utils/__pycache__/upos.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/utils.cpython-38.pyc b/bilibili_api/utils/__pycache__/utils.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..022635240aeaa3c3b595ae8cca2dcba2e09e1eae Binary files /dev/null and b/bilibili_api/utils/__pycache__/utils.cpython-38.pyc differ diff --git a/bilibili_api/utils/__pycache__/varint.cpython-38.pyc b/bilibili_api/utils/__pycache__/varint.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27cfd8b8056ca2a7b0540a19c624bf90b87b5a43 Binary files /dev/null and b/bilibili_api/utils/__pycache__/varint.cpython-38.pyc differ diff --git a/bilibili_api/utils/aid_bvid_transformer.py b/bilibili_api/utils/aid_bvid_transformer.py new file mode 100644 index 0000000000000000000000000000000000000000..d88f316b2fff756302298cad2b1bc8f3615b6701 --- /dev/null +++ b/bilibili_api/utils/aid_bvid_transformer.py @@ -0,0 +1,54 @@ +""" +bilibili_api.utils.aid_bvid_transformer + +av 号和 bv 号互转,代码来源:https://www.zhihu.com/question/381784377/answer/1099438784。 + +此部分代码以 WTFPL 开源。 +""" + +XOR_CODE = 23442827791579 +MASK_CODE = 2251799813685247 +MAX_AID = 1 << 51 + +data = [b'F', b'c', b'w', b'A', b'P', b'N', b'K', b'T', b'M', b'u', b'g', b'3', b'G', b'V', b'5', b'L', b'j', b'7', b'E', b'J', b'n', b'H', b'p', b'W', b's', b'x', b'4', b't', b'b', b'8', b'h', b'a', b'Y', b'e', b'v', b'i', b'q', b'B', b'z', b'6', b'r', b'k', b'C', b'y', b'1', b'2', b'm', b'U', b'S', b'D', b'Q', b'X', b'9', b'R', b'd', b'o', b'Z', b'f'] + +BASE = 58 +BV_LEN = 12 +PREFIX = "BV1" + +def bvid2aid(bvid: str) -> int: + """ + BV 号转 AV 号。 + Args: + bvid (str): BV 号。 + Returns: + int: AV 号。 + """ + bvid = list(bvid) + bvid[3], bvid[9] = bvid[9], bvid[3] + bvid[4], bvid[7] = bvid[7], bvid[4] + bvid = bvid[3:] + tmp = 0 + for i in bvid: + idx = data.index(i.encode()) + tmp = tmp * BASE + idx + return (tmp & MASK_CODE) ^ XOR_CODE + +def aid2bvid(aid: int) -> str: + """ + AV 号转 BV 号。 + Args: + aid (int): AV 号。 + Returns: + str: BV 号。 + """ + bytes = [b'B', b'V', b'1', b'0', b'0', b'0', b'0', b'0', b'0', b'0', b'0', b'0'] + bv_idx = BV_LEN - 1 + tmp = (MAX_AID | aid) ^ XOR_CODE + while int(tmp) != 0: + bytes[bv_idx] = data[int(tmp % BASE)] + tmp /= BASE + bv_idx -= 1 + bytes[3], bytes[9] = bytes[9], bytes[3] + bytes[4], bytes[7] = bytes[7], bytes[4] + return "".join([i.decode() for i in bytes]) diff --git a/bilibili_api/utils/cache_pool.py b/bilibili_api/utils/cache_pool.py new file mode 100644 index 0000000000000000000000000000000000000000..7da6c349730086753427925e9f7878eaef3f716d --- /dev/null +++ b/bilibili_api/utils/cache_pool.py @@ -0,0 +1,5 @@ +article_is_opus = {} +article_dyn_id = {} +dynamic_is_opus = {} +opus_type = {} +opus_info = {} diff --git a/bilibili_api/utils/captcha.py b/bilibili_api/utils/captcha.py new file mode 100644 index 0000000000000000000000000000000000000000..0f590db053ca3b8af3e0672c687c95a784874c22 --- /dev/null +++ b/bilibili_api/utils/captcha.py @@ -0,0 +1,273 @@ +""" +bilibili_api.utils.captcha + +人机测试 +""" +import os +import copy +import json +import time + +from .utils import get_api +from .network import Api + +validate = None +seccode = None +gt = None +challenge = None +key = None +server = None +thread = None + +API = get_api("login") + + +def _geetest_urlhandler(url: str, content_type: str): + """ + 极验验证服务器 html 源获取函数 + """ + global gt, challenge, key + url = url[1:] + if url[:7] == "result/": + global validate, seccode + datas = url[7:] + datas = datas.split("&") + for data in datas: + if data[:8] == "validate": + validate = data[9:] + elif data[:7] == "seccode": + seccode = data[8:].replace("%7C", "|") + with open( + os.path.abspath( + os.path.join( + os.path.dirname(__file__), "..", "data", "geetest", "done.html" + ) + ), + encoding="utf8", + ) as f: + html_source_bytes = f.read() + return html_source_bytes + elif url[:7] == "": + api = API["password"]["captcha"] + json_data = Api(**api).result_sync + gt = json_data["geetest"]["gt"] + challenge = json_data["geetest"]["challenge"] + key = json_data["token"] + with open( + os.path.abspath( + os.path.join( + os.path.dirname(__file__), "..", "data", "geetest", "captcha.html" + ) + ), + encoding="utf8", + ) as f: + html_source_bytes = ( + f.read() + .replace("{ Python_Interface: GT }", f'"{gt}"') + .replace("{ Python_Interface: CHALLENGE }", f'"{challenge}"') + ) + return html_source_bytes + else: + return "" + + +def _start_server(urlhandler, hostname, port): + """Start an HTTP server thread on a specific port. + + Start an HTML/text server thread, so HTML or text documents can be + browsed dynamically and interactively with a web browser. Example use: + + >>> import time + >>> import pydoc + + Define a URL handler. To determine what the client is asking + for, check the URL and content_type. + + Then get or generate some text or HTML code and return it. + + >>> def my_url_handler(url, content_type): + ... text = 'the URL sent was: (%s, %s)' % (url, content_type) + ... return text + + Start server thread on port 0. + If you use port 0, the server will pick a random port number. + You can then use serverthread.port to get the port number. + + >>> port = 0 + >>> serverthread = pydoc._start_server(my_url_handler, port) + + Check that the server is really started. If it is, open browser + and get first page. Use serverthread.url as the starting page. + + >>> if serverthread.serving: + ... import webbrowser + + The next two lines are commented out so a browser doesn't open if + doctest is run on this module. + + #... webbrowser.open(serverthread.url) + #True + + Let the server do its thing. We just need to monitor its status. + Use time.sleep so the loop doesn't hog the CPU. + + >>> starttime = time.monotonic() + >>> timeout = 1 #seconds + + This is a short timeout for testing purposes. + + >>> while serverthread.serving: + ... time.sleep(.01) + ... if serverthread.serving and time.monotonic() - starttime > timeout: + ... serverthread.stop() + ... break + + Print any errors that may have occurred. + + >>> print(serverthread.error) + None + """ + import select + import threading + import http.server + import email.message + + class DocHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + """Process a request from an HTML browser. + + The URL received is in self.path. + Get an HTML page from self.urlhandler and send it. + """ + if self.path.endswith(".css"): + content_type = "text/css" + else: + content_type = "text/html" + self.send_response(200) + self.send_header("Content-Type", "%s; charset=UTF-8" % content_type) + self.end_headers() + self.wfile.write(self.urlhandler(self.path, content_type).encode("utf-8")) # type: ignore + + def log_message(self, *args): + # Don't log messages. + pass + + class DocServer(http.server.HTTPServer): + def __init__(self, host, port, callback): + self.host = host + self.address = (self.host, port) + self.callback = callback + self.base.__init__(self, self.address, self.handler) # type: ignore + self.quit = False + + def serve_until_quit(self): + while not self.quit: + rd, wr, ex = select.select([self.socket.fileno()], [], [], 1) + if rd: + self.handle_request() + self.server_close() + + def server_activate(self): + self.base.server_activate(self) # type: ignore + if self.callback: + self.callback(self) + + class ServerThread(threading.Thread): + def __init__(self, urlhandler, host, port): + self.urlhandler = urlhandler + self.host = host + self.port = int(port) + threading.Thread.__init__(self) + self.serving = False + self.error = None + + def run(self): + """Start the server.""" + try: + DocServer.base = http.server.HTTPServer # type: ignore + DocServer.handler = DocHandler # type: ignore + DocHandler.MessageClass = email.message.Message # type: ignore + DocHandler.urlhandler = staticmethod(self.urlhandler) # type: ignore + docsvr = DocServer(self.host, self.port, self.ready) + self.docserver = docsvr + docsvr.serve_until_quit() + except Exception as e: + self.error = e + + def ready(self, server): + self.serving = True + self.host = server.host + self.port = server.server_port + self.url = "http://%s:%d/" % (self.host, self.port) + + def stop(self): + """Stop the server and this thread nicely""" + if self.docserver != None: + self.docserver.quit = True + self.join() + # explicitly break a reference cycle: DocServer.callback + # has indirectly a reference to ServerThread. + self.docserver = None + self.serving = False + self.url = None + + thread = ServerThread(urlhandler, hostname, port) + thread.start() + # Wait until thread.serving is True to make sure we are + # really up before returning. + while not thread.error and not thread.serving: + time.sleep(0.01) + return thread + + +def start_server(): + """ + 验证码服务打开服务器 + + Returns: + ServerThread: 服务进程 + + 返回值内函数及属性: + - url (str) : 验证码服务地址 + - start (Callable): 开启进程 + - stop (Callable): 结束进程 + """ + global thread + thread = _start_server(_geetest_urlhandler, "127.0.0.1", 0) + print("请打开 " + thread.url + " 进行验证。") # type: ignore + return thread + + +def close_server(): + """ + 关闭服务器 + """ + global thread + thread.stop() # type: ignore + + +def get_result(): + """ + 获取结果 + + Returns: + dict: 验证结果 + """ + global validate, seccode, challenge, gt, key + if ( + validate is None + or seccode is None + or gt is None + or challenge is None + or key is None + ): + return -1 + else: + dct = { + "gt": copy.copy(gt), + "challenge": copy.copy(challenge), + "validate": copy.copy(validate), + "seccode": copy.copy(seccode), + "token": copy.copy(key), + } + return dct diff --git a/bilibili_api/utils/credential.py b/bilibili_api/utils/credential.py new file mode 100644 index 0000000000000000000000000000000000000000..8b96cf1de61c4c53c34ffe4a6956bfd722987a9f --- /dev/null +++ b/bilibili_api/utils/credential.py @@ -0,0 +1,169 @@ +""" +bilibili_api.utils.Credential + +凭据类,用于各种请求操作的验证。 +""" + +import uuid +from typing import Union +import urllib.parse + +from ..exceptions import ( + CredentialNoBuvid3Exception, + CredentialNoBiliJctException, + CredentialNoSessdataException, + CredentialNoDedeUserIDException, + CredentialNoAcTimeValueException, +) + + +class Credential: + """ + 凭据类,用于各种请求操作的验证。 + """ + + def __init__( + self, + sessdata: Union[str, None] = None, + bili_jct: Union[str, None] = None, + buvid3: Union[str, None] = None, + dedeuserid: Union[str, None] = None, + ac_time_value: Union[str, None] = None, + ) -> None: + """ + 各字段获取方式查看:https://nemo2011.github.io/bilibili-api/#/get-credential.md + + Args: + sessdata (str | None, optional): 浏览器 Cookies 中的 SESSDATA 字段值. Defaults to None. + + bili_jct (str | None, optional): 浏览器 Cookies 中的 bili_jct 字段值. Defaults to None. + + buvid3 (str | None, optional): 浏览器 Cookies 中的 BUVID3 字段值. Defaults to None. + + dedeuserid (str | None, optional): 浏览器 Cookies 中的 DedeUserID 字段值. Defaults to None. + + ac_time_value (str | None, optional): 浏览器 Cookies 中的 ac_time_value 字段值. Defaults to None. + """ + self.sessdata = ( + None + if sessdata is None + else ( + sessdata if sessdata.find("%") != -1 else urllib.parse.quote(sessdata) + ) + ) + self.bili_jct = bili_jct + self.buvid3 = buvid3 + self.dedeuserid = dedeuserid + self.ac_time_value = ac_time_value + + def get_cookies(self) -> dict: + """ + 获取请求 Cookies 字典 + + Returns: + dict: 请求 Cookies 字典 + """ + cookies = { + "SESSDATA": self.sessdata, + "buvid3": self.buvid3, + "bili_jct": self.bili_jct, + "ac_time_value": self.ac_time_value, + } + if self.dedeuserid: + cookies.update({"DedeUserID": self.dedeuserid}) + return cookies + + def has_dedeuserid(self) -> bool: + """ + 是否提供 dedeuserid。 + + Returns: + bool。 + """ + return self.dedeuserid is not None and self.sessdata != "" + + def has_sessdata(self) -> bool: + """ + 是否提供 sessdata。 + + Returns: + bool。 + """ + return self.sessdata is not None and self.sessdata != "" + + def has_bili_jct(self) -> bool: + """ + 是否提供 bili_jct。 + + Returns: + bool。 + """ + return self.bili_jct is not None and self.sessdata != "" + + def has_buvid3(self) -> bool: + """ + 是否提供 buvid3 + + Returns: + bool. + """ + return self.buvid3 is not None and self.sessdata != "" + + def has_ac_time_value(self) -> bool: + """ + 是否提供 ac_time_value + + Returns: + bool. + """ + return self.ac_time_value is not None and self.sessdata != "" + + def raise_for_no_sessdata(self): + """ + 没有提供 sessdata 则抛出异常。 + """ + if not self.has_sessdata(): + raise CredentialNoSessdataException() + + def raise_for_no_bili_jct(self): + """ + 没有提供 bili_jct 则抛出异常。 + """ + if not self.has_bili_jct(): + raise CredentialNoBiliJctException() + + def raise_for_no_buvid3(self): + """ + 没有提供 buvid3 时抛出异常。 + """ + if not self.has_buvid3(): + raise CredentialNoBuvid3Exception() + + def raise_for_no_dedeuserid(self): + """ + 没有提供 DedeUserID 时抛出异常。 + """ + if not self.has_dedeuserid(): + raise CredentialNoDedeUserIDException() + + def raise_for_no_ac_time_value(self): + """ + 没有提供 ac_time_value 时抛出异常。 + """ + if not self.has_ac_time_value(): + raise CredentialNoAcTimeValueException() + + async def check_valid(self): + """ + 检查 cookies 是否有效 + + Returns: + bool: cookies 是否有效 + """ + + # def generate_buvid3(self): + # """ + # 生成 buvid3 + # """ + # self.buvid3 = str(uuid.uuid1()) + "infoc" + # 长度都不同了...用 credential.get_spi_buvid diff --git a/bilibili_api/utils/credential_refresh.py b/bilibili_api/utils/credential_refresh.py new file mode 100644 index 0000000000000000000000000000000000000000..1a016f43c5ebe7a19fdcd9dc7f0188805d124f0d --- /dev/null +++ b/bilibili_api/utils/credential_refresh.py @@ -0,0 +1,207 @@ +""" +from bilibili_api import Credential + +凭据操作类 +""" + +import re +import time +import uuid +import binascii +from typing import Union + +from Cryptodome.Hash import SHA256 +from Cryptodome.PublicKey import RSA +from Cryptodome.Cipher import PKCS1_OAEP + +from .credential import Credential as _Credential +from .network import Api, get_api, get_session, HEADERS + +key = RSA.importKey( + """\ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDLgd2OAkcGVtoE3ThUREbio0Eg +Uc/prcajMKXvkCKFCWhJYJcLkcM2DKKcSeFpD/j6Boy538YXnR6VhcuUJOhH2x71 +nzPjfdTcqMz7djHum0qSZA0AyCBDABUqCrfNgCiJ00Ra7GmRj+YCK1NJEuewlb40 +JNrRuoEUXpabUzGB8QIDAQAB +-----END PUBLIC KEY-----""" +) + +API = get_api("credential") + + +class Credential(_Credential): + """ + 凭据操作类,用于各种请求操作。 + """ + + async def check_refresh(self) -> bool: + """ + 检查是否需要刷新 cookies + + Returns: + bool: cookies 是否需要刷新 + """ + return await check_cookies(self) + + async def refresh(self) -> None: + """ + 刷新 cookies + """ + new_cred: Credential = await refresh_cookies(self) + self.sessdata = new_cred.sessdata + self.bili_jct = new_cred.bili_jct + self.dedeuserid = new_cred.dedeuserid + self.ac_time_value = new_cred.ac_time_value + + async def check_valid(self) -> bool: + """ + 检查 cookies 是否有效 + + Returns: + bool: cookies 是否有效 + """ + data = await Api( + credential=self, **get_api("credential")["info"]["valid"] + ).result + return data["isLogin"] + + @staticmethod + def from_cookies(cookies: dict={}) -> "Credential": + """ + 从 cookies 新建 Credential + + Args: + cookies (dict, optional): Cookies. Defaults to {}. + + Returns: + Credential: 凭据类 + """ + c = Credential() + c.sessdata = cookies.get("SESSDATA") + c.bili_jct = cookies.get("bili_jct") + c.buvid3 = cookies.get("buvid3") + c.dedeuserid = cookies.get("DedeUserID") + c.ac_time_value = cookies.get("ac_time_value") + return c + + +""" +Cookies 刷新相关 + +感谢 bilibili-API-collect 提供的刷新 Cookies 的思路 + +https://socialsisteryi.github.io/bilibili-API-collect/docs/login/cookie_refresh.html +""" + + +async def check_cookies(credential: Credential) -> bool: + """ + 检查是否需要刷新 Cookies + + Args: + credential (Credential): 用户凭证 + + Return: + bool: 是否需要刷新 Cookies + """ + api = API["info"]["check_cookies"] + return (await Api(**api, credential=credential).result)["refresh"] + + +def getCorrespondPath() -> str: + """ + 根据时间生成 CorrespondPath + + Return: + str: CorrespondPath + """ + ts = round(time.time() * 1000) + cipher = PKCS1_OAEP.new(key, SHA256) + encrypted = cipher.encrypt(f"refresh_{ts}".encode()) + return binascii.b2a_hex(encrypted).decode() + + +async def get_refresh_csrf(credential: Credential) -> str: + """ + 获取刷新 Cookies 的 csrf + + Return: + str: csrf + """ + correspond_path = getCorrespondPath() + api = API["operate"]["get_refresh_csrf"] + cookies = credential.get_cookies() + cookies["buvid3"] = str(uuid.uuid1()) + cookies["Domain"] = ".bilibili.com" + resp = await get_session().request( + "GET", + api["url"].replace("{correspondPath}", correspond_path), + cookies=cookies, + headers=HEADERS.copy(), + ) + if resp.status_code == 404: + raise Exception("correspondPath 过期或错误。") + elif resp.status_code == 200: + text = resp.text + refresh_csrf = re.findall('(.+?)', text)[0] + return refresh_csrf + elif resp.status_code != 200: + raise Exception("获取刷新 Cookies 的 csrf 失败。") + + +async def refresh_cookies(credential: Credential) -> Credential: + """ + 刷新 Cookies + + Args: + credential (Credential): 用户凭证 + + Return: + Credential: 新的用户凭证 + """ + api = API["operate"]["refresh_cookies"] + credential.raise_for_no_bili_jct() + credential.raise_for_no_ac_time_value() + refresh_csrf = await get_refresh_csrf(credential) + data = { + "csrf": credential.bili_jct, + "refresh_csrf": refresh_csrf, + "refresh_token": credential.ac_time_value, + "source": "main_web", + } + cookies = credential.get_cookies() + cookies["buvid3"] = str(uuid.uuid1()) + cookies["Domain"] = ".bilibili.com" + resp = await get_session().request( + "POST", api["url"], cookies=cookies, data=data, headers=HEADERS.copy() + ) + if resp.status_code != 200 or resp.json()["code"] != 0: + raise Exception("刷新 Cookies 失败") + new_credential = Credential( + sessdata=resp.cookies["SESSDATA"], + bili_jct=resp.cookies["bili_jct"], + dedeuserid=resp.cookies["DedeUserID"], + ac_time_value=resp.json()["data"]["refresh_token"], + ) + await confirm_refresh(credential, new_credential) + return new_credential + + +async def confirm_refresh( + old_credential: Credential, new_credential: Credential +) -> None: + """ + 让旧的refresh_token对应的 Cookie 失效 + + Args: + old_credential (Credential): 旧的用户凭证 + + new_credential (Credential): 新的用户凭证 + """ + api = API["operate"]["confirm_refresh"] + data = { + "csrf": new_credential.bili_jct, + "refresh_token": old_credential.ac_time_value, + } + await Api(**api, credential=new_credential).update_data(**data).result diff --git a/bilibili_api/utils/danmaku.py b/bilibili_api/utils/danmaku.py new file mode 100644 index 0000000000000000000000000000000000000000..3dbecb9a18e698b35c7195c954c8e48dd123a599 --- /dev/null +++ b/bilibili_api/utils/danmaku.py @@ -0,0 +1,178 @@ +""" +bilibili_api.utils.Danmaku + +弹幕类。 +""" + +import time +from enum import Enum +from typing import Union + +from .utils import crack_uid as _crack_uid + + +class DmFontSize(Enum): + """ + 字体大小枚举。 + """ + + EXTREME_SMALL = 12 + SUPER_SMALL = 16 + SMALL = 18 + NORMAL = 25 + BIG = 36 + SUPER_BIG = 45 + EXTREME_BIG = 64 + + +class DmMode(Enum): + """ + 弹幕模式枚举。 + """ + + FLY = 1 + TOP = 5 + BOTTOM = 4 + REVERSE = 6 + SPECIAL = 9 + + +class Danmaku: + """ + 弹幕类。 + """ + + def __init__( + self, + text: str, + dm_time: float = 0.0, + send_time: float = time.time(), + crc32_id: str = "", + color: str = "ffffff", + weight: int = -1, + id_: int = -1, + id_str: str = "", + action: str = "", + mode: Union[DmMode, int] = DmMode.FLY, + font_size: Union[DmFontSize, int] = DmFontSize.NORMAL, + is_sub: bool = False, + pool: int = 0, + attr: int = -1, + uid: int = -1, + ): + """ + Args: + (self.)text (str) : 弹幕文本。 + + (self.)dm_time (float, optional) : 弹幕在视频中的位置,单位为秒。Defaults to 0.0. + + (self.)send_time (float, optional) : 弹幕发送的时间。Defaults to time.time(). + + (self.)crc32_id (str, optional) : 弹幕发送者 UID 经 CRC32 算法取摘要后的值。Defaults to "". + + (self.)color (str, optional) : 弹幕十六进制颜色。Defaults to "ffffff" (如果为大会员专属的颜色则为"special"). + + (self.)weight (int, optional) : 弹幕在弹幕列表显示的权重。Defaults to -1. + + (self.)id_ (int, optional) : 弹幕 ID。Defaults to -1. + + (self.)id_str (str, optional) : 弹幕字符串 ID。Defaults to "". + + (self.)action (str, optional) : 暂不清楚。Defaults to "". + + (self.)mode (Union[DmMode, int], optional) : 弹幕模式。Defaults to Mode.FLY. + + (self.)font_size (Union[DmFontSize, int], optional): 弹幕字体大小。Defaults to FontSize.NORMAL. + + (self.)is_sub (bool, optional) : 是否为字幕弹幕。Defaults to False. + + (self.)pool (int, optional) : 池。Defaults to 0. + + (self.)attr (int, optional) : 暂不清楚。 Defaults to -1. + + (self.)uid (int, optional) : 弹幕发送者 UID。Defaults to -1. + + 大会员专属颜色文字填充:http://i0.hdslb.com/bfs/dm/9dcd329e617035b45d2041ac889c49cb5edd3e44.png + + 大会员专属颜色背景填充:http://i0.hdslb.com/bfs/dm/ba8e32ae03a0a3f70f4e51975a965a9ddce39d50.png + """ + self.text = text + self.dm_time = dm_time + self.send_time = send_time + self.crc32_id = crc32_id + self.color = color + self.weight = weight + self.id_ = id_ + self.id_str = id_str + self.action = action + self.mode = mode.value if isinstance(mode, DmMode) else mode + self.font_size = ( + font_size.value if isinstance(font_size, DmFontSize) else font_size + ) + self.is_sub = is_sub + self.pool = pool + self.attr = attr + self.uid = uid + + def __str__(self): + ret = "%s, %s, %s" % (self.send_time, self.dm_time, self.text) + return ret + + def __len__(self): + return len(self.text) + + @staticmethod + def crack_uid(crc32_id: str): + """ + (@staticmethod) + + 暴力破解 UID,可能存在误差,请慎重使用。 + + 精确至 UID 小于 10000000 的破解。 + + Args: + crc32_id (str): crc32 id + + Returns: + int: 真实 UID。 + """ + return int(_crack_uid(crc32_id)) + + def to_xml(self): + """ + 将弹幕转换为 xml 格式弹幕 + """ + txt = self.text.replace("&", "&").replace("<", "<").replace(">", ">") + string = f'{txt} ' + return string + + +class SpecialDanmaku: + def __init__( + self, + content: str, + id_: int = -1, + id_str: str = "", + mode: Union[DmMode, int] = DmMode.SPECIAL, + pool: int = 2, + ): + """ + Args: + (self.)content (str) : 弹幕内容 + + (self.)id_ (int) : 弹幕 id. Defaults to -1. + + (self.)id_str (str) : 弹幕 id (string 类型). Defaults to "". + + (self.)mode (Union[DmMode, int]): 弹幕类型. Defaults to DmMode.SPECIAL. + + (self.)pool (int) : 弹幕池. Defaults to 2. + """ + self.content = content + self.id_ = id_ + self.id_str = id_str + self.mode = mode.value if isinstance(mode, DmMode) else mode + self.pool = pool + + def __str__(self): + return f"{self.content}" diff --git a/bilibili_api/utils/danmaku2ass.py b/bilibili_api/utils/danmaku2ass.py new file mode 100644 index 0000000000000000000000000000000000000000..bbea273c2952bd4206766ba52d1ad6062d08ccec --- /dev/null +++ b/bilibili_api/utils/danmaku2ass.py @@ -0,0 +1,1343 @@ +#!/usr/bin/env python3 + +# The original author of this program, Danmaku2ASS, is StarBrilliant. +# This file is released under General Public License version 3. +# You should have received a copy of General Public License text alongside with +# this program. If not, you can obtain it at http://gnu.org/copyleft/gpl.html . +# This program comes with no warranty, the author will not be resopnsible for +# any damage or problems caused by this program. + +# You can obtain a latest copy of Danmaku2ASS at: +# https://github.com/m13253/danmaku2ass +# Please update to the latest version before complaining. + +# pylint: skip-file +# type: ignore + +import io +import os +import re +import sys +import json +import math +import time +import random +import gettext +import logging +import argparse +import calendar +import xml.dom.minidom + +if sys.version_info < (3,): + raise RuntimeError("at least Python 3.0 is required") + +gettext.install( + "danmaku2ass", + os.path.join( + os.path.dirname(os.path.abspath(os.path.realpath(sys.argv[0] or "locale"))), + "locale", + ), +) + + +def SeekZero(function): + def decorated_function(file_): + file_.seek(0) + try: + return function(file_) + finally: + file_.seek(0) + + return decorated_function + + +def EOFAsNone(function): + def decorated_function(*args, **kwargs): + try: + return function(*args, **kwargs) + except EOFError: + return None + + return decorated_function + + +@SeekZero +@EOFAsNone +def ProbeCommentFormat(f): + tmp = f.read(1) + if tmp == "[": + return "Acfun" + # It is unwise to wrap a JSON object in an array! + # See this: http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx/ + # Do never follow what Acfun developers did! + elif tmp == "{": + tmp = f.read(14) + if tmp == '"status_code":': + return "Tudou" + elif tmp.strip().startswith('"result'): + return "Tudou2" + elif tmp == "<": + tmp = f.read(1) + if tmp == "?": + tmp = f.read(38) + if tmp == 'xml version="1.0" encoding="UTF-8"?>\n<': + return "Bilibili" # Komica, with the same file format as Bilibili + elif tmp == 'xml version="1.0" encoding="UTF-8"?>\n<': + tmp = f.read(20) + if tmp == "!-- BoonSutazioData=": + return "Niconico" # Niconico videos downloaded with NicoFox + else: + return "MioMio" + elif tmp == "p": + return "Niconico" # Himawari Douga, with the same file format as Niconico Douga + + +# +# ReadComments**** protocol +# +# Input: +# f: Input file +# fontsize: Default font size +# +# Output: +# yield a tuple: +# (timeline, timestamp, no, comment, pos, color, size, height, width) +# timeline: The position when the comment is replayed +# timestamp: The UNIX timestamp when the comment is submitted +# no: A sequence of 1, 2, 3, ..., used for sorting +# comment: The content of the comment +# pos: 0 for regular moving comment, +# 1 for bottom centered comment, +# 2 for top centered comment, +# 3 for reversed moving comment +# color: Font color represented in 0xRRGGBB, +# e.g. 0xffffff for white +# size: Font size +# height: The estimated height in pixels +# i.e. (comment.count('\n')+1)*size +# width: The estimated width in pixels +# i.e. CalculateLength(comment)*size +# +# After implementing ReadComments****, make sure to update ProbeCommentFormat +# and CommentFormatMap. +# + + +def ReadCommentsNiconico(f, fontsize): + NiconicoColorMap = { + "red": 0xFF0000, + "pink": 0xFF8080, + "orange": 0xFFCC00, + "yellow": 0xFFFF00, + "green": 0x00FF00, + "cyan": 0x00FFFF, + "blue": 0x0000FF, + "purple": 0xC000FF, + "black": 0x000000, + "niconicowhite": 0xCCCC99, + "white2": 0xCCCC99, + "truered": 0xCC0033, + "red2": 0xCC0033, + "passionorange": 0xFF6600, + "orange2": 0xFF6600, + "madyellow": 0x999900, + "yellow2": 0x999900, + "elementalgreen": 0x00CC66, + "green2": 0x00CC66, + "marineblue": 0x33FFCC, + "blue2": 0x33FFCC, + "nobleviolet": 0x6633CC, + "purple2": 0x6633CC, + } + dom = xml.dom.minidom.parse(f) + comment_element = dom.getElementsByTagName("chat") + for comment in comment_element: + try: + c = str(comment.childNodes[0].wholeText) + if c.startswith("/"): + continue # ignore advanced comments + pos = 0 + color = 0xFFFFFF + size = fontsize + for mailstyle in str(comment.getAttribute("mail")).split(): + if mailstyle == "ue": + pos = 1 + elif mailstyle == "shita": + pos = 2 + elif mailstyle == "big": + size = fontsize * 1.44 + elif mailstyle == "small": + size = fontsize * 0.64 + elif mailstyle in NiconicoColorMap: + color = NiconicoColorMap[mailstyle] + yield ( + max(int(comment.getAttribute("vpos")), 0) * 0.01, + int(comment.getAttribute("date")), + int(comment.getAttribute("no")), + c, + pos, + color, + size, + (c.count("\n") + 1) * size, + CalculateLength(c) * size, + ) + except (AssertionError, AttributeError, IndexError, TypeError, ValueError): + logging.warning(_("Invalid comment: %s") % comment.toxml()) + continue + + +def ReadCommentsAcfun(f, fontsize): + # comment_element = json.load(f) + # after load acfun comment json file as python list, flatten the list + # comment_element = [c for sublist in comment_element for c in sublist] + comment_elements = json.load(f) + comment_element = comment_elements[2] + for i, comment in enumerate(comment_element): + try: + p = str(comment["c"]).split(",") + assert len(p) >= 6 + assert p[2] in ("1", "2", "4", "5", "7") + size = int(p[3]) * fontsize / 25.0 + if p[2] != "7": + c = str(comment["m"]).replace("\\r", "\n").replace("\r", "\n") + yield ( + float(p[0]), + int(p[5]), + i, + c, + {"1": 0, "2": 0, "4": 2, "5": 1}[p[2]], + int(p[1]), + size, + (c.count("\n") + 1) * size, + CalculateLength(c) * size, + ) + else: + c = dict(json.loads(comment["m"])) + yield (float(p[0]), int(p[5]), i, c, "acfunpos", int(p[1]), size, 0, 0) + except (AssertionError, AttributeError, IndexError, TypeError, ValueError): + logging.warning(_("Invalid comment: %r") % comment) + continue + + +def ReadCommentsBilibili(f, fontsize): + dom = xml.dom.minidom.parse(f) + comment_element = dom.getElementsByTagName("d") + for i, comment in enumerate(comment_element): + try: + p = str(comment.getAttribute("p")).split(",") + assert len(p) >= 5 + assert p[1] in ("1", "4", "5", "6", "7", "8") + if comment.childNodes.length > 0: + if p[1] in ("1", "4", "5", "6"): + c = str(comment.childNodes[0].wholeText).replace("/n", "\n") + size = int(p[2]) * fontsize / 25.0 + yield ( + float(p[0]), + int(p[4]), + i, + c, + {"1": 0, "4": 2, "5": 1, "6": 3}[p[1]], + int(p[3]), + size, + (c.count("\n") + 1) * size, + CalculateLength(c) * size, + ) + elif p[1] == "7": # positioned comment + c = str(comment.childNodes[0].wholeText) + yield ( + float(p[0]), + int(p[4]), + i, + c, + "bilipos", + int(p[3]), + int(p[2]), + 0, + 0, + ) + elif p[1] == "8": + pass # ignore scripted comment + except (AssertionError, AttributeError, IndexError, TypeError, ValueError): + logging.warning(_("Invalid comment: %s") % comment.toxml()) + continue + + +def ReadCommentsBilibili2(f, fontsize): + dom = xml.dom.minidom.parse(f) + comment_element = dom.getElementsByTagName("d") + for i, comment in enumerate(comment_element): + try: + p = str(comment.getAttribute("p")).split(",") + assert len(p) >= 7 + assert p[3] in ("1", "4", "5", "6", "7", "8") + if comment.childNodes.length > 0: + time = float(p[2]) / 1000.0 + if p[3] in ("1", "4", "5", "6"): + c = str(comment.childNodes[0].wholeText).replace("/n", "\n") + size = int(p[4]) * fontsize / 25.0 + yield ( + time, + int(p[6]), + i, + c, + {"1": 0, "4": 2, "5": 1, "6": 3}[p[3]], + int(p[5]), + size, + (c.count("\n") + 1) * size, + CalculateLength(c) * size, + ) + elif p[3] == "7": # positioned comment + c = str(comment.childNodes[0].wholeText) + yield (time, int(p[6]), i, c, "bilipos", int(p[5]), int(p[4]), 0, 0) + elif p[3] == "8": + pass # ignore scripted comment + except (AssertionError, AttributeError, IndexError, TypeError, ValueError): + logging.warning(_("Invalid comment: %s") % comment.toxml()) + continue + + +def ReadCommentsTudou(f, fontsize): + comment_element = json.load(f) + for i, comment in enumerate(comment_element["comment_list"]): + try: + assert comment["pos"] in (3, 4, 6) + c = str(comment["data"]) + assert comment["size"] in (0, 1, 2) + size = {0: 0.64, 1: 1, 2: 1.44}[comment["size"]] * fontsize + yield ( + int(comment["replay_time"] * 0.001), + int(comment["commit_time"]), + i, + c, + {3: 0, 4: 2, 6: 1}[comment["pos"]], + int(comment["color"]), + size, + (c.count("\n") + 1) * size, + CalculateLength(c) * size, + ) + except (AssertionError, AttributeError, IndexError, TypeError, ValueError): + logging.warning(_("Invalid comment: %r") % comment) + continue + + +def ReadCommentsTudou2(f, fontsize): + comment_element = json.load(f) + for i, comment in enumerate(comment_element["result"]): + try: + c = str(comment["content"]) + prop = json.loads(str(comment["propertis"]) or "{}") + size = int(prop.get("size", 1)) + assert size in (0, 1, 2) + size = {0: 0.64, 1: 1, 2: 1.44}[size] * fontsize + pos = int(prop.get("pos", 3)) + assert pos in (0, 3, 4, 6) + yield ( + int(comment["playat"] * 0.001), + int(comment["createtime"] * 0.001), + i, + c, + {0: 0, 3: 0, 4: 2, 6: 1}[pos], + int(prop.get("color", 0xFFFFFF)), + size, + (c.count("\n") + 1) * size, + CalculateLength(c) * size, + ) + except (AssertionError, AttributeError, IndexError, TypeError, ValueError): + logging.warning(_("Invalid comment: %r") % comment) + continue + + +def ReadCommentsMioMio(f, fontsize): + NiconicoColorMap = { + "red": 0xFF0000, + "pink": 0xFF8080, + "orange": 0xFFC000, + "yellow": 0xFFFF00, + "green": 0x00FF00, + "cyan": 0x00FFFF, + "blue": 0x0000FF, + "purple": 0xC000FF, + "black": 0x000000, + } + dom = xml.dom.minidom.parse(f) + comment_element = dom.getElementsByTagName("data") + for i, comment in enumerate(comment_element): + try: + message = comment.getElementsByTagName("message")[0] + c = str(message.childNodes[0].wholeText) + pos = 0 + size = int(message.getAttribute("fontsize")) * fontsize / 25.0 + yield ( + float( + comment.getElementsByTagName("playTime")[0].childNodes[0].wholeText + ), + int( + calendar.timegm( + time.strptime( + comment.getElementsByTagName("times")[0] + .childNodes[0] + .wholeText, + "%Y-%m-%d %H:%M:%S", + ) + ) + ) + - 28800, + i, + c, + {"1": 0, "4": 2, "5": 1}[message.getAttribute("mode")], + int(message.getAttribute("color")), + size, + (c.count("\n") + 1) * size, + CalculateLength(c) * size, + ) + except (AssertionError, AttributeError, IndexError, TypeError, ValueError): + logging.warning(_("Invalid comment: %s") % comment.toxml()) + continue + + +CommentFormatMap = { + "Niconico": ReadCommentsNiconico, + "Acfun": ReadCommentsAcfun, + "Bilibili": ReadCommentsBilibili, + "Bilibili2": ReadCommentsBilibili2, + "Tudou": ReadCommentsTudou, + "Tudou2": ReadCommentsTudou2, + "MioMio": ReadCommentsMioMio, +} + + +def WriteCommentBilibiliPositioned(f, c, width, height, styleid): + # BiliPlayerSize = (512, 384) # Bilibili player version 2010 + # BiliPlayerSize = (540, 384) # Bilibili player version 2012 + BiliPlayerSize = (672, 438) # Bilibili player version 2014 + ZoomFactor = GetZoomFactor(BiliPlayerSize, (width, height)) + + def GetPosition(InputPos, isHeight): + isHeight = int(isHeight) # True -> 1 + if isinstance(InputPos, int): + return ZoomFactor[0] * InputPos + ZoomFactor[isHeight + 1] + elif isinstance(InputPos, float): + if InputPos > 1: + return ZoomFactor[0] * InputPos + ZoomFactor[isHeight + 1] + else: + return ( + BiliPlayerSize[isHeight] * ZoomFactor[0] * InputPos + + ZoomFactor[isHeight + 1] + ) + else: + try: + InputPos = int(InputPos) + except ValueError: + InputPos = float(InputPos) + return GetPosition(InputPos, isHeight) + + try: + comment_args = safe_list(json.loads(c[3])) + text = ASSEscape(str(comment_args[4]).replace("/n", "\n")) + from_x = comment_args.get(0, 0) + from_y = comment_args.get(1, 0) + to_x = comment_args.get(7, from_x) + to_y = comment_args.get(8, from_y) + from_x = GetPosition(from_x, False) + from_y = GetPosition(from_y, True) + to_x = GetPosition(to_x, False) + to_y = GetPosition(to_y, True) + alpha = safe_list(str(comment_args.get(2, "1")).split("-")) + from_alpha = float(alpha.get(0, 1)) + to_alpha = float(alpha.get(1, from_alpha)) + from_alpha = 255 - round(from_alpha * 255) + to_alpha = 255 - round(to_alpha * 255) + rotate_z = int(comment_args.get(5, 0)) + rotate_y = int(comment_args.get(6, 0)) + lifetime = float(comment_args.get(3, 4500)) + duration = int(comment_args.get(9, lifetime * 1000)) + delay = int(comment_args.get(10, 0)) + fontface = comment_args.get(12) + isborder = comment_args.get(11, "true") + from_rotarg = ConvertFlashRotation( + rotate_y, rotate_z, from_x, from_y, width, height + ) + to_rotarg = ConvertFlashRotation(rotate_y, rotate_z, to_x, to_y, width, height) + styles = ["\\org(%d, %d)" % (width / 2, height / 2)] + if from_rotarg[0:2] == to_rotarg[0:2]: + styles.append("\\pos(%.0f, %.0f)" % (from_rotarg[0:2])) + else: + styles.append( + "\\move(%.0f, %.0f, %.0f, %.0f, %.0f, %.0f)" + % (from_rotarg[0:2] + to_rotarg[0:2] + (delay, delay + duration)) + ) + styles.append( + "\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f" % (from_rotarg[2:7]) + ) + if (from_x, from_y) != (to_x, to_y): + styles.append("\\t(%d, %d, " % (delay, delay + duration)) + styles.append( + "\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f" % (to_rotarg[2:7]) + ) + styles.append(")") + if fontface: + styles.append("\\fn%s" % ASSEscape(fontface)) + styles.append("\\fs%.0f" % (c[6] * ZoomFactor[0])) + if c[5] != 0xFFFFFF: + styles.append("\\c&H%s&" % ConvertColor(c[5])) + if c[5] == 0x000000: + styles.append("\\3c&HFFFFFF&") + if from_alpha == to_alpha: + styles.append("\\alpha&H%02X" % from_alpha) + elif (from_alpha, to_alpha) == (255, 0): + styles.append("\\fad(%.0f,0)" % (lifetime * 1000)) + elif (from_alpha, to_alpha) == (0, 255): + styles.append("\\fad(0, %.0f)" % (lifetime * 1000)) + else: + styles.append( + "\\fade(%(from_alpha)d, %(to_alpha)d, %(to_alpha)d, 0, %(end_time).0f, %(end_time).0f, %(end_time).0f)" + % { + "from_alpha": from_alpha, + "to_alpha": to_alpha, + "end_time": lifetime * 1000, + } + ) + if isborder == "false": + styles.append("\\bord0") + f.write( + "Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n" + % { + "start": ConvertTimestamp(c[0]), + "end": ConvertTimestamp(c[0] + lifetime), + "styles": "".join(styles), + "text": text, + "styleid": styleid, + } + ) + except (IndexError, ValueError) as e: + try: + logging.warning(_("Invalid comment: %r") % c[3]) + except IndexError: + logging.warning(_("Invalid comment: %r") % c) + + +def WriteCommentAcfunPositioned(f, c, width, height, styleid): + AcfunPlayerSize = (560, 400) + ZoomFactor = GetZoomFactor(AcfunPlayerSize, (width, height)) + + def GetPosition(InputPos, isHeight): + isHeight = int(isHeight) # True -> 1 + return ( + AcfunPlayerSize[isHeight] * ZoomFactor[0] * InputPos * 0.001 + + ZoomFactor[isHeight + 1] + ) + + def GetTransformStyles( + x=None, + y=None, + scale_x=None, + scale_y=None, + rotate_z=None, + rotate_y=None, + color=None, + alpha=None, + ): + styles = [] + out_x, out_y = x, y + if rotate_z is not None and rotate_y is not None: + assert x is not None + assert y is not None + rotarg = ConvertFlashRotation(rotate_y, rotate_z, x, y, width, height) + out_x, out_y = rotarg[0:2] + if scale_x is None: + scale_x = 1 + if scale_y is None: + scale_y = 1 + styles.append( + "\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f" + % (rotarg[2:5] + (rotarg[5] * scale_x, rotarg[6] * scale_y)) + ) + else: + if scale_x is not None: + styles.append("\\fscx%.0f" % (scale_x * 100)) + if scale_y is not None: + styles.append("\\fscy%.0f" % (scale_y * 100)) + if color is not None: + styles.append("\\c&H%s&" % ConvertColor(color)) + if color == 0x000000: + styles.append("\\3c&HFFFFFF&") + if alpha is not None: + alpha = 255 - round(alpha * 255) + styles.append("\\alpha&H%02X" % alpha) + return out_x, out_y, styles + + def FlushCommentLine(f, text, styles, start_time, end_time, styleid): + if end_time > start_time: + f.write( + "Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n" + % { + "start": ConvertTimestamp(start_time), + "end": ConvertTimestamp(end_time), + "styles": "".join(styles), + "text": text, + "styleid": styleid, + } + ) + + try: + comment_args = c[3] + text = ASSEscape(str(comment_args["n"]).replace("\r", "\n")) + common_styles = ["\org(%d, %d)" % (width / 2, height / 2)] + anchor = {0: 7, 1: 8, 2: 9, 3: 4, 4: 5, 5: 6, 6: 1, 7: 2, 8: 3}.get( + comment_args.get("c", 0), 7 + ) + if anchor != 7: + common_styles.append("\\an%s" % anchor) + font = comment_args.get("w") + if font: + font = dict(font) + fontface = font.get("f") + if fontface: + common_styles.append("\\fn%s" % ASSEscape(str(fontface))) + fontbold = bool(font.get("b")) + if fontbold: + common_styles.append("\\b1") + common_styles.append("\\fs%.0f" % (c[6] * ZoomFactor[0])) + isborder = bool(comment_args.get("b", True)) + if not isborder: + common_styles.append("\\bord0") + to_pos = dict(comment_args.get("p", {"x": 0, "y": 0})) + to_x = round(GetPosition(int(to_pos.get("x", 0)), False)) + to_y = round(GetPosition(int(to_pos.get("y", 0)), True)) + to_scale_x = float(comment_args.get("e", 1.0)) + to_scale_y = float(comment_args.get("f", 1.0)) + to_rotate_z = float(comment_args.get("r", 0.0)) + to_rotate_y = float(comment_args.get("k", 0.0)) + to_color = c[5] + to_alpha = float(comment_args.get("a", 1.0)) + from_time = float(comment_args.get("t", 0.0)) + action_time = float(comment_args.get("l", 3.0)) + actions = list(comment_args.get("z", [])) + to_out_x, to_out_y, transform_styles = GetTransformStyles( + to_x, + to_y, + to_scale_x, + to_scale_y, + to_rotate_z, + to_rotate_y, + to_color, + to_alpha, + ) + FlushCommentLine( + f, + text, + common_styles + + ["\\pos(%.0f, %.0f)" % (to_out_x, to_out_y)] + + transform_styles, + c[0] + from_time, + c[0] + from_time + action_time, + styleid, + ) + action_styles = transform_styles + for action in actions: + action = dict(action) + from_x, from_y = to_x, to_y + from_out_x, from_out_y = to_out_x, to_out_y + from_scale_x, from_scale_y = to_scale_x, to_scale_y + from_rotate_z, from_rotate_y = to_rotate_z, to_rotate_y + from_color, from_alpha = to_color, to_alpha + transform_styles, action_styles = action_styles, [] + from_time += action_time + action_time = float(action.get("l", 0.0)) + if "x" in action: + to_x = round(GetPosition(int(action["x"]), False)) + if "y" in action: + to_y = round(GetPosition(int(action["y"]), True)) + if "f" in action: + to_scale_x = float(action["f"]) + if "g" in action: + to_scale_y = float(action["g"]) + if "c" in action: + to_color = int(action["c"]) + if "t" in action: + to_alpha = float(action["t"]) + if "d" in action: + to_rotate_z = float(action["d"]) + if "e" in action: + to_rotate_y = float(action["e"]) + to_out_x, to_out_y, action_styles = GetTransformStyles( + to_x, + to_y, + from_scale_x, + from_scale_y, + to_rotate_z, + to_rotate_y, + from_color, + from_alpha, + ) + if (from_out_x, from_out_y) == (to_out_x, to_out_y): + pos_style = "\\pos(%.0f, %.0f)" % (to_out_x, to_out_y) + else: + pos_style = "\\move(%.0f, %.0f, %.0f, %.0f)" % ( + from_out_x, + from_out_y, + to_out_x, + to_out_y, + ) + styles = common_styles + transform_styles + styles.append(pos_style) + if action_styles: + styles.append("\\t(%s)" % ("".join(action_styles))) + FlushCommentLine( + f, + text, + styles, + c[0] + from_time, + c[0] + from_time + action_time, + styleid, + ) + except (IndexError, ValueError) as e: + logging.warning(_("Invalid comment: %r") % c[3]) + + +# Result: (f, dx, dy) +# To convert: NewX = f*x+dx, NewY = f*y+dy +def GetZoomFactor(SourceSize, TargetSize): + try: + if (SourceSize, TargetSize) == GetZoomFactor.Cached_Size: + return GetZoomFactor.Cached_Result + except AttributeError: + pass + GetZoomFactor.Cached_Size = (SourceSize, TargetSize) + try: + SourceAspect = SourceSize[0] / SourceSize[1] + TargetAspect = TargetSize[0] / TargetSize[1] + if TargetAspect < SourceAspect: # narrower + ScaleFactor = TargetSize[0] / SourceSize[0] + GetZoomFactor.Cached_Result = ( + ScaleFactor, + 0, + (TargetSize[1] - TargetSize[0] / SourceAspect) / 2, + ) + elif TargetAspect > SourceAspect: # wider + ScaleFactor = TargetSize[1] / SourceSize[1] + GetZoomFactor.Cached_Result = ( + ScaleFactor, + (TargetSize[0] - TargetSize[1] * SourceAspect) / 2, + 0, + ) + else: + GetZoomFactor.Cached_Result = (TargetSize[0] / SourceSize[0], 0, 0) + return GetZoomFactor.Cached_Result + except ZeroDivisionError: + GetZoomFactor.Cached_Result = (1, 0, 0) + return GetZoomFactor.Cached_Result + + +# Calculation is based on https://github.com/jabbany/CommentCoreLibrary/issues/5#issuecomment-40087282 +# and https://github.com/m13253/danmaku2ass/issues/7#issuecomment-41489422 +# ASS FOV = width*4/3.0 +# But Flash FOV = width/math.tan(100*math.pi/360.0)/2 will be used instead +# Result: (transX, transY, rotX, rotY, rotZ, scaleX, scaleY) +def ConvertFlashRotation(rotY, rotZ, X, Y, width, height): + def WrapAngle(deg): + return 180 - ((180 - deg) % 360) + + rotY = WrapAngle(rotY) + rotZ = WrapAngle(rotZ) + if rotY in (90, -90): + rotY -= 1 + if rotY == 0 or rotZ == 0: + outX = 0 + outY = -rotY # Positive value means clockwise in Flash + outZ = -rotZ + rotY *= math.pi / 180.0 + rotZ *= math.pi / 180.0 + else: + rotY *= math.pi / 180.0 + rotZ *= math.pi / 180.0 + outY = ( + math.atan2(-math.sin(rotY) * math.cos(rotZ), math.cos(rotY)) * 180 / math.pi + ) + outZ = ( + math.atan2(-math.cos(rotY) * math.sin(rotZ), math.cos(rotZ)) * 180 / math.pi + ) + outX = math.asin(math.sin(rotY) * math.sin(rotZ)) * 180 / math.pi + trX = ( + (X * math.cos(rotZ) + Y * math.sin(rotZ)) / math.cos(rotY) + + (1 - math.cos(rotZ) / math.cos(rotY)) * width / 2 + - math.sin(rotZ) / math.cos(rotY) * height / 2 + ) + trY = ( + Y * math.cos(rotZ) + - X * math.sin(rotZ) + + math.sin(rotZ) * width / 2 + + (1 - math.cos(rotZ)) * height / 2 + ) + trZ = (trX - width / 2) * math.sin(rotY) + FOV = width * math.tan(2 * math.pi / 9.0) / 2 + try: + scaleXY = FOV / (FOV + trZ) + except ZeroDivisionError: + logging.error("Rotation makes object behind the camera: trZ == %.0f" % trZ) + scaleXY = 1 + trX = (trX - width / 2) * scaleXY + width / 2 + trY = (trY - height / 2) * scaleXY + height / 2 + if scaleXY < 0: + scaleXY = -scaleXY + outX += 180 + outY += 180 + logging.error( + "Rotation makes object behind the camera: trZ == %.0f < %.0f" % (trZ, FOV) + ) + return ( + trX, + trY, + WrapAngle(outX), + WrapAngle(outY), + WrapAngle(outZ), + scaleXY * 100, + scaleXY * 100, + ) + + +def ProcessComments( + comments, + f, + width, + height, + bottomReserved, + fontface, + fontsize, + alpha, + duration_marquee, + duration_still, + filters_regex, + reduced, + progress_callback, +): + styleid = "Danmaku2ASS_%04x" % random.randint(0, 0xFFFF) + WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid) + rows = [[None] * (height - bottomReserved + 1) for i in range(4)] + for idx, i in enumerate(comments): + if progress_callback and idx % 1000 == 0: + progress_callback(idx, len(comments)) + if isinstance(i[4], int): + skip = False + for filter_regex in filters_regex: + if filter_regex and filter_regex.search(i[3]): + skip = True + break + if skip: + continue + row = 0 + rowmax = height - bottomReserved - i[7] + while row <= rowmax: + freerows = TestFreeRows( + rows, + i, + row, + width, + height, + bottomReserved, + duration_marquee, + duration_still, + ) + if freerows >= i[7]: + MarkCommentRow(rows, i, row) + WriteComment( + f, + i, + row, + width, + height, + bottomReserved, + fontsize, + duration_marquee, + duration_still, + styleid, + ) + break + else: + row += freerows or 1 + else: + if not reduced: + row = FindAlternativeRow(rows, i, height, bottomReserved) + MarkCommentRow(rows, i, row) + WriteComment( + f, + i, + row, + width, + height, + bottomReserved, + fontsize, + duration_marquee, + duration_still, + styleid, + ) + elif i[4] == "bilipos": + WriteCommentBilibiliPositioned(f, i, width, height, styleid) + elif i[4] == "acfunpos": + WriteCommentAcfunPositioned(f, i, width, height, styleid) + else: + logging.warning(_("Invalid comment: %r") % i[3]) + if progress_callback: + progress_callback(len(comments), len(comments)) + + +def TestFreeRows( + rows, c, row, width, height, bottomReserved, duration_marquee, duration_still +): + res = 0 + rowmax = height - bottomReserved + targetRow = None + if c[4] in (1, 2): + while row < rowmax and res < c[7]: + if targetRow != rows[c[4]][row]: + targetRow = rows[c[4]][row] + if targetRow and targetRow[0] + duration_still > c[0]: + break + row += 1 + res += 1 + else: + try: + thresholdTime = c[0] - duration_marquee * (1 - width / (c[8] + width)) + except ZeroDivisionError: + thresholdTime = c[0] - duration_marquee + while row < rowmax and res < c[7]: + if targetRow != rows[c[4]][row]: + targetRow = rows[c[4]][row] + try: + if targetRow and ( + targetRow[0] > thresholdTime + or targetRow[0] + + targetRow[8] * duration_marquee / (targetRow[8] + width) + > c[0] + ): + break + except ZeroDivisionError: + pass + row += 1 + res += 1 + return res + + +def FindAlternativeRow(rows, c, height, bottomReserved): + res = 0 + for row in range(height - bottomReserved - math.ceil(c[7])): + if not rows[c[4]][row]: + return row + elif rows[c[4]][row][0] < rows[c[4]][res][0]: + res = row + return res + + +def MarkCommentRow(rows, c, row): + try: + for i in range(row, row + math.ceil(c[7])): + rows[c[4]][i] = c + except IndexError: + pass + + +def WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid): + f.write( + """[Script Info] +; Script generated by Danmaku2ASS +; https://github.com/m13253/danmaku2ass +Script Updated By: Danmaku2ASS (https://github.com/m13253/danmaku2ass) +ScriptType: v4.00+ +PlayResX: %(width)d +PlayResY: %(height)d +Aspect Ratio: %(width)d:%(height)d +Collisions: Normal +WrapStyle: 2 +ScaledBorderAndShadow: yes +YCbCr Matrix: TV.601 +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: %(styleid)s, %(fontface)s, %(fontsize).0f, &H%(alpha)02XFFFFFF, &H%(alpha)02XFFFFFF, &H%(alpha)02X000000, &H%(alpha)02X000000, 0, 0, 0, 0, 100, 100, 0.00, 0.00, 1, %(outline).0f, 0, 7, 0, 0, 0, 0 +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +""" + % { + "width": width, + "height": height, + "fontface": fontface, + "fontsize": fontsize, + "alpha": 255 - round(alpha * 255), + "outline": max(fontsize / 25.0, 1), + "styleid": styleid, + } + ) + + +def WriteComment( + f, + c, + row, + width, + height, + bottomReserved, + fontsize, + duration_marquee, + duration_still, + styleid, +): + text = ASSEscape(c[3]) + styles = [] + if c[4] == 1: + styles.append( + "\\an8\\pos(%(halfwidth)d, %(row)d)" % {"halfwidth": width / 2, "row": row} + ) + duration = duration_still + elif c[4] == 2: + styles.append( + "\\an2\\pos(%(halfwidth)d, %(row)d)" + % {"halfwidth": width / 2, "row": ConvertType2(row, height, bottomReserved)} + ) + duration = duration_still + elif c[4] == 3: + styles.append( + "\\move(%(neglen)d, %(row)d, %(width)d, %(row)d)" + % {"width": width, "row": row, "neglen": -math.ceil(c[8])} + ) + duration = duration_marquee + else: + styles.append( + "\\move(%(width)d, %(row)d, %(neglen)d, %(row)d)" + % {"width": width, "row": row, "neglen": -math.ceil(c[8])} + ) + duration = duration_marquee + if not (-1 < c[6] - fontsize < 1): + styles.append("\\fs%.0f" % c[6]) + if c[5] != 0xFFFFFF: + styles.append("\\c&H%s&" % ConvertColor(c[5])) + if c[5] == 0x000000: + styles.append("\\3c&HFFFFFF&") + f.write( + "Dialogue: 2,%(start)s,%(end)s,%(styleid)s,,0000,0000,0000,,{%(styles)s}%(text)s\n" + % { + "start": ConvertTimestamp(c[0]), + "end": ConvertTimestamp(c[0] + duration), + "styles": "".join(styles), + "text": text, + "styleid": styleid, + } + ) + + +def ASSEscape(s): + def ReplaceLeadingSpace(s): + sstrip = s.strip(" ") + slen = len(s) + if slen == len(sstrip): + return s + else: + llen = slen - len(s.lstrip(" ")) + rlen = slen - len(s.rstrip(" ")) + return "".join(("\u2007" * llen, sstrip, "\u2007" * rlen)) + + return "\\N".join( + ( + ReplaceLeadingSpace(i) or " " + for i in str(s) + .replace("\\", "\\\\") + .replace("{", "\\{") + .replace("}", "\\}") + .split("\n") + ) + ) + + +def CalculateLength(s): + return max(map(len, s.split("\n"))) # May not be accurate + + +def ConvertTimestamp(timestamp): + timestamp = round(timestamp * 100.0) + hour, minute = divmod(timestamp, 360000) + minute, second = divmod(minute, 6000) + second, centsecond = divmod(second, 100) + return "%d:%02d:%02d.%02d" % (int(hour), int(minute), int(second), int(centsecond)) + + +def ConvertColor(RGB, width=1280, height=576): + if RGB == 0x000000: + return "000000" + elif RGB == 0xFFFFFF: + return "FFFFFF" + R = (RGB >> 16) & 0xFF + G = (RGB >> 8) & 0xFF + B = RGB & 0xFF + if width < 1280 and height < 576: + return "%02X%02X%02X" % (B, G, R) + else: # VobSub always uses BT.601 colorspace, convert to BT.709 + ClipByte = lambda x: 255 if x > 255 else 0 if x < 0 else round(x) + return "%02X%02X%02X" % ( + ClipByte( + R * 0.00956384088080656 + + G * 0.03217254540203729 + + B * 0.95826361371715607 + ), + ClipByte( + R * -0.10493933142075390 + + G * 1.17231478191855154 + + B * -0.06737545049779757 + ), + ClipByte( + R * 0.91348912373987645 + + G * 0.07858536372532510 + + B * 0.00792551253479842 + ), + ) + + +def ConvertType2(row, height, bottomReserved): + return height - bottomReserved - row + + +def ConvertToFile(filename_or_file, *args, **kwargs): + if isinstance(filename_or_file, bytes): + filename_or_file = str(bytes(filename_or_file).decode("utf-8", "replace")) + if isinstance(filename_or_file, str): + return open(filename_or_file, *args, **kwargs) + else: + return filename_or_file + + +def FilterBadChars(f): + s = f.read() + s = re.sub("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]", "\ufffd", s) + return io.StringIO(s) + + +class safe_list(list): + def get(self, index, default=None): + try: + return self[index] + except IndexError: + return default + + +def export(func): + global __all__ + try: + __all__.append(func.__name__) + except NameError: + __all__ = [func.__name__] + return func + + +@export +def Danmaku2ASS( + input_files, + input_format, + output_file, + stage_width, + stage_height, + reserve_blank=0, + font_face=_("(FONT) sans-serif")[7:], + font_size=25.0, + text_opacity=1.0, + duration_marquee=5.0, + duration_still=5.0, + comment_filter=None, + comment_filters_file=None, + is_reduce_comments=False, + progress_callback=None, +): + comment_filters = [comment_filter] + if comment_filters_file: + with open(comment_filters_file, "r") as f: + d = f.readlines() + comment_filters.extend([i.strip() for i in d]) + filters_regex = [] + for comment_filter in comment_filters: + try: + if comment_filter: + filters_regex.append(re.compile(comment_filter)) + except: + raise ValueError(_("Invalid regular expression: %s") % comment_filter) + fo = None + comments = ReadComments(input_files, input_format, font_size) + try: + if output_file: + fo = ConvertToFile( + output_file, "w", encoding="utf-8-sig", errors="replace", newline="\r\n" + ) + else: + fo = sys.stdout + ProcessComments( + comments, + fo, + stage_width, + stage_height, + reserve_blank, + font_face, + font_size, + text_opacity, + duration_marquee, + duration_still, + filters_regex, + is_reduce_comments, + progress_callback, + ) + finally: + if output_file and fo != output_file: + fo.close() + + +@export +def ReadComments(input_files, input_format, font_size=25.0, progress_callback=None): + if isinstance(input_files, bytes): + input_files = str(bytes(input_files).decode("utf-8", "replace")) + if isinstance(input_files, str): + input_files = [input_files] + else: + input_files = list(input_files) + comments = [] + for idx, i in enumerate(input_files): + if progress_callback: + progress_callback(idx, len(input_files)) + with ConvertToFile(i, "r", encoding="utf-8", errors="replace") as f: + s = f.read() + str_io = io.StringIO(s) + if input_format == "autodetect": + CommentProcessor = GetCommentProcessor(str_io) + if not CommentProcessor: + raise ValueError(_("Failed to detect comment file format: %s") % i) + else: + CommentProcessor = CommentFormatMap.get(input_format) + if not CommentProcessor: + raise ValueError( + _("Unknown comment file format: %s") % input_format + ) + comments.extend(CommentProcessor(FilterBadChars(str_io), font_size)) + if progress_callback: + progress_callback(len(input_files), len(input_files)) + comments.sort() + return comments + + +@export +def GetCommentProcessor(input_file): + return CommentFormatMap.get(ProbeCommentFormat(input_file)) + + +def main(): + logging.basicConfig(format="%(levelname)s: %(message)s") + if len(sys.argv) == 1: + sys.argv.append("--help") + parser = argparse.ArgumentParser() + parser.add_argument( + "-f", + "--format", + metavar=_("FORMAT"), + help=_("Format of input file (autodetect|%s) [default: autodetect]") + % "|".join(i for i in CommentFormatMap), + default="autodetect", + ) + parser.add_argument("-o", "--output", metavar=_("OUTPUT"), help=_("Output file")) + parser.add_argument( + "-s", + "--size", + metavar=_("WIDTHxHEIGHT"), + required=True, + help=_("Stage size in pixels"), + ) + parser.add_argument( + "-fn", + "--font", + metavar=_("FONT"), + help=_("Specify font face [default: %s]") % _("(FONT) sans-serif")[7:], + default=_("(FONT) sans-serif")[7:], + ) + parser.add_argument( + "-fs", + "--fontsize", + metavar=_("SIZE"), + help=(_("Default font size [default: %s]") % 25), + type=float, + default=25.0, + ) + parser.add_argument( + "-a", + "--alpha", + metavar=_("ALPHA"), + help=_("Text opacity"), + type=float, + default=1.0, + ) + parser.add_argument( + "-dm", + "--duration-marquee", + metavar=_("SECONDS"), + help=_("Duration of scrolling comment display [default: %s]") % 5, + type=float, + default=5.0, + ) + parser.add_argument( + "-ds", + "--duration-still", + metavar=_("SECONDS"), + help=_("Duration of still comment display [default: %s]") % 5, + type=float, + default=5.0, + ) + parser.add_argument( + "-fl", "--filter", help=_("Regular expression to filter comments") + ) + parser.add_argument( + "-flf", + "--filter-file", + help=_("Regular expressions from file (one line one regex) to filter comments"), + ) + parser.add_argument( + "-p", + "--protect", + metavar=_("HEIGHT"), + help=_("Reserve blank on the bottom of the stage"), + type=int, + default=0, + ) + parser.add_argument( + "-r", + "--reduce", + action="store_true", + help=_("Reduce the amount of comments if stage is full"), + ) + parser.add_argument( + "file", metavar=_("FILE"), nargs="+", help=_("Comment file to be processed") + ) + args = parser.parse_args() + try: + width, height = str(args.size).split("x", 1) + width = int(width) + height = int(height) + except ValueError: + raise ValueError(_("Invalid stage size: %r") % args.size) + Danmaku2ASS( + args.file, + args.format, + args.output, + width, + height, + args.protect, + args.font, + args.fontsize, + args.alpha, + args.duration_marquee, + args.duration_still, + args.filter, + args.filter_file, + args.reduce, + ) + + +if __name__ == "__main__": + main() diff --git a/bilibili_api/utils/initial_state.py b/bilibili_api/utils/initial_state.py new file mode 100644 index 0000000000000000000000000000000000000000..d645e6b93971a17909a5a92d97a89ef71f6fbe4b --- /dev/null +++ b/bilibili_api/utils/initial_state.py @@ -0,0 +1,109 @@ +""" +bilibili_api.utils.initial_state + +用于获取页码的初始化信息 +""" +import re +import json +import httpx +from enum import Enum +from typing import Union + +from ..exceptions import * +from .short import get_real_url +from .credential import Credential +from .network import get_session + + +class InitialDataType(Enum): + """ + 识别返回类型 + """ + + INITIAL_STATE = "window.__INITIAL_STATE__" + NEXT_DATA = "__NEXT_DATA__" + + +async def get_initial_state( + url: str, credential: Credential = Credential() +) -> Union[dict, InitialDataType]: + """ + 异步获取初始化信息 + + Args: + url (str): 链接 + + credential (Credential, optional): 用户凭证. Defaults to Credential(). + """ + try: + session = get_session() + resp = await session.get( + url, + cookies=credential.get_cookies(), + headers={"User-Agent": "Mozilla/5.0"}, + follow_redirects=True, + ) + except Exception as e: + raise e + else: + content = resp.text + pattern = re.compile(r"window.__INITIAL_STATE__=(\{.*?\});") + match = re.search(pattern, content) + if match is None: + pattern = re.compile( + pattern=r'' + ) + match = re.search(pattern, content) + content_type = InitialDataType.NEXT_DATA + if match is None: + raise ApiException("未找到相关信息") + else: + content_type = InitialDataType.INITIAL_STATE + try: + content = json.loads(match.group(1)) + except json.JSONDecodeError: + raise ApiException("信息解析错误") + + return content, content_type + + +def get_initial_state_sync( + url: str, credential: Credential = Credential() +) -> Union[dict, InitialDataType]: + """ + 同步获取初始化信息 + + Args: + url (str): 链接 + + credential (Credential, optional): 用户凭证. Defaults to Credential(). + """ + try: + resp = httpx.get( + url, + cookies=credential.get_cookies(), + headers={"User-Agent": "Mozilla/5.0"}, + follow_redirects=True, + ) + except Exception as e: + raise e + else: + content = resp.text + pattern = re.compile(r"window.__INITIAL_STATE__=(\{.*?\});") + match = re.search(pattern, content) + if match is None: + pattern = re.compile( + pattern=r'' + ) + match = re.search(pattern, content) + content_type = InitialDataType.NEXT_DATA + if match is None: + raise ApiException("未找到相关信息") + else: + content_type = InitialDataType.INITIAL_STATE + try: + content = json.loads(match.group(1)) + except json.JSONDecodeError: + raise ApiException("信息解析错误") + + return content, content_type diff --git a/bilibili_api/utils/json2srt.py b/bilibili_api/utils/json2srt.py new file mode 100644 index 0000000000000000000000000000000000000000..3231d70fbf2f2c27663f10fdd116006509adb528 --- /dev/null +++ b/bilibili_api/utils/json2srt.py @@ -0,0 +1,59 @@ +# https://blog.csdn.net/mondaiji/article/details/104294430 +# author: 皓空Fly +# 此文件采用 CC 4.0 BY-SA 协议开源。 + +import os +import json +import math + + +def json2srt(doc: str, out: str): + """ + Args: + doc: 文件 + + Returns: + None + """ + file = "" # 这个变量用来保存数据 + i = 1 + with open(doc, encoding="utf-8") as f: + datas = json.load(f) # 加载文件数据 + f.close() + for data in datas["body"]: + start = data["from"] # 获取开始时间 + stop = data["to"] # 获取结束时F间 + content = data["content"] # 获取字幕内容 + file += "{}\n".format(i) # 加入序号 + hour = math.floor(start) // 3600 + minute = (math.floor(start) - hour * 3600) // 60 + sec = math.floor(start) - hour * 3600 - minute * 60 + minisec = int(math.modf(start)[0] * 100) # 处理开始时间 + file += ( + str(hour).zfill(2) + + ":" + + str(minute).zfill(2) + + ":" + + str(sec).zfill(2) + + "," + + str(minisec).zfill(2) + ) # 将数字填充0并按照格式写入 + file += " --> " + hour = math.floor(stop) // 3600 + minute = (math.floor(stop) - hour * 3600) // 60 + sec = math.floor(stop) - hour * 3600 - minute * 60 + minisec = abs(int(math.modf(stop)[0] * 100 - 1)) # 此处减1是为了防止两个字幕同时出现 + file += ( + str(hour).zfill(2) + + ":" + + str(minute).zfill(2) + + ":" + + str(sec).zfill(2) + + "," + + str(minisec).zfill(2) + ) + file += "\n" + content + "\n\n" # 加入字幕文字 + i += 1 + with open(out, "w+", encoding="utf-8") as f: + f.write(file) # 将数据写入文件 + f.close() diff --git a/bilibili_api/utils/network.py b/bilibili_api/utils/network.py new file mode 100644 index 0000000000000000000000000000000000000000..3d061e4973886d7949092a3a6f40dc646a8c0ef0 --- /dev/null +++ b/bilibili_api/utils/network.py @@ -0,0 +1,922 @@ +""" +bilibili_api.utils.network + +与网络请求相关的模块。能对会话进行管理(复用 TCP 连接)。 +""" + +import re +import json +import time +import atexit +import asyncio +import hashlib +import hmac +from functools import reduce +from urllib.parse import urlencode +from dataclasses import field, dataclass +from typing import Any, Dict, Union, Coroutine, Type +from inspect import iscoroutinefunction as isAsync +from urllib.parse import quote + +import httpx +import aiohttp + +from .sync import sync +from .. import settings +from .utils import get_api +from .credential import Credential +from ..exceptions import ApiException, ResponseCodeException, NetworkException + +__httpx_session_pool: Dict[asyncio.AbstractEventLoop, httpx.AsyncClient] = {} +__aiohttp_session_pool: Dict[asyncio.AbstractEventLoop, aiohttp.ClientSession] = {} +__httpx_sync_session: httpx.Client = None +last_proxy = "" +wbi_mixin_key = "" +buvid3 = "" + +# 获取密钥时的申必数组 +OE = [ + 46, + 47, + 18, + 2, + 53, + 8, + 23, + 32, + 15, + 50, + 10, + 31, + 58, + 3, + 45, + 35, + 27, + 43, + 5, + 49, + 33, + 9, + 42, + 19, + 29, + 28, + 14, + 39, + 12, + 38, + 41, + 13, + 37, + 48, + 7, + 16, + 24, + 55, + 40, + 61, + 26, + 17, + 0, + 1, + 60, + 51, + 30, + 4, + 22, + 25, + 54, + 21, + 56, + 59, + 6, + 63, + 57, + 62, + 11, + 36, + 20, + 34, + 44, + 52, +] +# 使用 Referer 和 UA 请求头以绕过反爬虫机制 +HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.54", + "Referer": "https://www.bilibili.com", +} +API = get_api("credential") + + +def retry_sync(times: int = 3): + """ + 重试装饰器 + + Args: + times (int): 最大重试次数 默认 3 次 负数则一直重试直到成功 + + Returns: + Any: 原函数调用结果 + """ + + def wrapper(func): + def inner(*args, **kwargs): + # 这里必须新建一个变量用来计数!!不能直接对 times 操作!!! + nonlocal times + loop = times + while loop != 0: + if loop != times and settings.request_log: + settings.logger.info("第 %d 次重试", times - loop) + loop -= 1 + try: + return func(*args, **kwargs) + except json.decoder.JSONDecodeError: + # json 解析错误 说明数据获取有误 再给次机会 + continue + except ResponseCodeException as e: + # -403 时尝试重新获取 wbi_mixin_key 可能过期了 + if e.code == -403: + global wbi_mixin_key + wbi_mixin_key = "" + continue + # 不是 -403 错误直接报错 + raise + raise ApiException("重试达到最大次数") + + return inner + + return wrapper + + +def retry(times: int = 3): + """ + 重试装饰器 + + Args: + times (int): 最大重试次数 默认 3 次 负数则一直重试直到成功 + + Returns: + Any: 原函数调用结果 + """ + + def wrapper(func: Coroutine): + async def inner(*args, **kwargs): + # 这里必须新建一个变量用来计数!!不能直接对 times 操作!!! + nonlocal times + loop = times + while loop != 0: + if loop != times and settings.request_log: + settings.logger.info("第 %d 次重试", times - loop) + loop -= 1 + try: + return await func(*args, **kwargs) + except json.decoder.JSONDecodeError: + # json 解析错误 说明数据获取有误 再给次机会 + continue + except ResponseCodeException as e: + # -403 时尝试重新获取 wbi_mixin_key 可能过期了 + if e.code == -403: + global wbi_mixin_key + wbi_mixin_key = "" + continue + # 不是 -403 错误直接报错 + raise + raise ApiException("重试达到最大次数") + + return inner + + if isAsync(times): + # 防呆不防傻 防止有人 @retry() 不打括号 + func = times + times = 3 + return wrapper(func) + + return wrapper + + +@dataclass +class Api: + """ + 用于请求的 Api 类 + + Args: + url (str): 请求地址 + + method (str): 请求方法 + + comment (str, optional): 注释. Defaults to "". + + wbi (bool, optional): 是否使用 wbi 鉴权. Defaults to False. + + verify (bool, optional): 是否验证凭据. Defaults to False. + + no_csrf (bool, optional): 是否不使用 csrf. Defaults to False. + + json_body (bool, optional): 是否使用 json 作为载荷. Defaults to False. + + ignore_code (bool, optional): 是否忽略返回值 code 的检验. Defaults to False. + + data (dict, optional): 请求载荷. Defaults to {}. + + params (dict, optional): 请求参数. Defaults to {}. + + credential (Credential, optional): 凭据. Defaults to Credential(). + """ + + url: str + method: str + comment: str = "" + wbi: bool = False + verify: bool = False + no_csrf: bool = False + json_body: bool = False + ignore_code: bool = False + data: dict = field(default_factory=dict) + params: dict = field(default_factory=dict) + files: dict = field(default_factory=dict) + headers: dict = field(default_factory=dict) + credential: Credential = field(default_factory=Credential) + + def __post_init__(self) -> None: + self.method = self.method.upper() + self.original_data = self.data.copy() + self.original_params = self.params.copy() + self.data = {k: "" for k in self.data.keys()} + self.params = {k: "" for k in self.params.keys()} + self.files = {k: "" for k in self.files.keys()} + self.headers = {k: "" for k in self.headers.keys()} + if self.credential is None: + self.credential = Credential() + self.__result = None + + def __setattr__(self, __name: str, __value: Any) -> None: + """ + 每次更新参数都要把 __result 清除 + """ + if self.initialized and __name != "_Api__result": + self.__result = None + return super().__setattr__(__name, __value) + + @property + def initialized(self): + return "_Api__result" in self.__dict__ + + @property + async def result(self) -> Union[int, str, dict]: + """ + 异步获取请求结果 + + `self.__result` 用来暂存数据 参数不变时获取结果不变 + """ + if self.__result is None: + self.__result = await self.request() + return self.__result + + @property + def result_sync(self) -> Union[int, str, dict]: + """ + 通过 `sync` 同步获取请求结果 + + 一般用于非协程内同步获取数据 + + `self.__result` 用来暂存数据 参数不变时获取结果不变 + """ + if self.__result is None: + self.__result = self.request_sync() + return self.__result + + def update_data(self, **kwargs) -> "Api": + """ + 毫无亮点的更新 data + """ + self.data = kwargs + self.__result = None + return self + + def update_params(self, **kwargs) -> "Api": + """ + 毫无亮点的更新 params + """ + self.params = kwargs + self.__result = None + return self + + def update_files(self, **kwargs) -> "Api": + """ + 毫无亮点的更新 files + """ + self.files = kwargs + self.__result = None + return self + + def update_headers(self, **kwargs) -> "Api": + """ + 毫无亮点的更新 headers + """ + self.headers = kwargs + self.__result = None + return self + + def update(self, **kwargs) -> "Api": + """ + 毫无亮点的自动选择更新,不包括 files, headers + """ + if self.method == "GET": + return self.update_params(**kwargs) + else: + return self.update_data(**kwargs) + + def _prepare_params_data(self) -> None: + new_params, new_data = {}, {} + for key, value in self.params.items(): + if isinstance(value, bool): + new_params[key] = int(value) + elif value != None: + new_params[key] = value + for key, value in self.data.items(): + if isinstance(value, bool): + new_params[key] = int(value) + elif value != None: + new_data[key] = value + self.params, self.data = new_params, new_data + + def _prepare_request_sync(self, **kwargs) -> dict: + """ + 准备请求的配置参数 + + Args: + **kwargs: 其他额外的请求配置参数 + + Returns: + dict: 包含请求的配置参数 + """ + # 如果接口需要 Credential 且未传入则报错 (默认值为 Credential()) + if self.verify: + self.credential.raise_for_no_sessdata() + + # 请求为非 GET 且 no_csrf 不为 True 时要求 bili_jct + if self.method != "GET" and not self.no_csrf: + self.credential.raise_for_no_bili_jct() + + if settings.request_log: + settings.logger.info(self) + + # jsonp + if self.params.get("jsonp") == "jsonp": + self.params["callback"] = "callback" + + if self.wbi: + global wbi_mixin_key + if wbi_mixin_key == "": + wbi_mixin_key = get_mixin_key_sync() + enc_wbi(self.params, wbi_mixin_key) + + # 自动添加 csrf + if ( + not self.no_csrf + and self.verify + and self.method in ["POST", "DELETE", "PATCH"] + ): + self.data["csrf"] = self.credential.bili_jct + self.data["csrf_token"] = self.credential.bili_jct + + cookies = self.credential.get_cookies() + + if self.credential.buvid3 is None: + global buvid3 + if buvid3 == "" and self.url != API["info"]["spi"]["url"]: + buvid3 = get_spi_buvid_sync()["b_3"] + cookies["buvid3"] = buvid3 + else: + cookies["buvid3"] = self.credential.buvid3 + # cookies["Domain"] = ".bilibili.com" + + config = { + "url": self.url, + "method": self.method, + "data": self.data, + "params": self.params, + "files": self.files, + "cookies": cookies, + "headers": HEADERS.copy() if len(self.headers) == 0 else self.headers, + "timeout": settings.timeout, + } + config.update(kwargs) + + if self.json_body: + config["headers"]["Content-Type"] = "application/json" + config["data"] = json.dumps(config["data"]) + + return config + + async def _prepare_request(self, **kwargs) -> dict: + """ + 准备请求的配置参数 + + Args: + **kwargs: 其他额外的请求配置参数 + + Returns: + dict: 包含请求的配置参数 + """ + # 如果接口需要 Credential 且未传入则报错 (默认值为 Credential()) + if self.verify: + self.credential.raise_for_no_sessdata() + + # 请求为非 GET 且 no_csrf 不为 True 时要求 bili_jct + if self.method != "GET" and not self.no_csrf: + self.credential.raise_for_no_bili_jct() + + if settings.request_log: + settings.logger.info(self) + + # jsonp + if self.params.get("jsonp") == "jsonp": + self.params["callback"] = "callback" + + if self.wbi: + global wbi_mixin_key + if wbi_mixin_key == "": + wbi_mixin_key = await get_mixin_key() + enc_wbi(self.params, wbi_mixin_key) + + # 自动添加 csrf + if ( + not self.no_csrf + and self.verify + and self.method in ["POST", "DELETE", "PATCH"] + ): + self.data["csrf"] = self.credential.bili_jct + self.data["csrf_token"] = self.credential.bili_jct + + cookies = self.credential.get_cookies() + + if self.credential.buvid3 is None: + global buvid3 + if buvid3 == "" and self.url != API["info"]["spi"]["url"]: + buvid3 = (await get_spi_buvid())["b_3"] + cookies["buvid3"] = buvid3 + else: + cookies["buvid3"] = self.credential.buvid3 + # cookies["Domain"] = ".bilibili.com" + + config = { + "url": self.url, + "method": self.method, + "data": self.data, + "params": self.params, + "files": self.files, + "cookies": cookies, + "headers": HEADERS.copy() if len(self.headers) == 0 else self.headers, + "timeout": settings.timeout + if settings.http_client == settings.HTTPClient.HTTPX + else aiohttp.ClientTimeout(total=settings.timeout), + } + config.update(kwargs) + + if self.json_body: + config["headers"]["Content-Type"] = "application/json" + config["data"] = json.dumps(config["data"]) + + if settings.http_client == settings.HTTPClient.AIOHTTP and not self.json_body: + config["data"].update(config["files"]) + if config["data"] != {}: + data = aiohttp.FormData() + for key, val in config["data"].items(): + data.add_field(key, val) + config["data"] = data + config.pop("files") + if settings.proxy != "": + config["proxy"] = settings.proxy + elif settings.http_client == settings.HTTPClient.AIOHTTP: + # 舍去 files + config.pop("files") + + return config + + def _get_resp_text_sync(self, resp: httpx.Response): + return resp.text + + async def _get_resp_text(self, resp: Union[httpx.Response, aiohttp.ClientResponse]): + if isinstance(resp, httpx.Response): + return resp.text + else: + return await resp.text() + + @retry_sync(times=settings.wbi_retry_times) + def request_sync(self, raw: bool = False, **kwargs) -> Union[int, str, dict]: + """ + 向接口发送请求。 + + Returns: + 接口未返回数据时,返回 None,否则返回该接口提供的 data 或 result 字段的数据。 + """ + self._prepare_params_data() + config = self._prepare_request_sync(**kwargs) + session = get_httpx_sync_session() + resp = session.request(**config) + try: + resp.raise_for_status() + except httpx.HTTPStatusError as e: + raise NetworkException(resp.status_code, str(resp.status_code)) + real_data = self._process_response( + resp, self._get_resp_text_sync(resp), raw=raw + ) + return real_data + + @retry(times=settings.wbi_retry_times) + async def request(self, raw: bool = False, **kwargs) -> Union[int, str, dict]: + """ + 向接口发送请求。 + + Returns: + 接口未返回数据时,返回 None,否则返回该接口提供的 data 或 result 字段的数据。 + """ + self._prepare_params_data() + config = await self._prepare_request(**kwargs) + session: Union[httpx.AsyncClient, aiohttp.ClientSession] + # 判断http_client的类型 + if settings.http_client == settings.HTTPClient.HTTPX: + session = get_session() + resp = await session.request(**config) + try: + resp.raise_for_status() + except httpx.HTTPStatusError as e: + raise NetworkException(resp.status_code, str(resp.status_code)) + real_data = self._process_response( + resp, await self._get_resp_text(resp), raw=raw + ) + return real_data + elif settings.http_client == settings.HTTPClient.AIOHTTP: + session = get_aiohttp_session() + async with session.request(**config) as resp: + try: + resp.raise_for_status() + except aiohttp.ClientResponseError as e: + raise NetworkException(e.status, e.message) + real_data = self._process_response( + resp, await self._get_resp_text(resp), raw=raw + ) + return real_data + + def _process_response( + self, + resp: Union[httpx.Response, aiohttp.ClientResponse], + resp_text: str, + raw: bool = False, + ) -> Union[int, str, dict]: + """ + 处理接口的响应数据 + """ + # 检查响应头 Content-Length + content_length = resp.headers.get("content-length") + if content_length and int(content_length) == 0: + return None + + if "callback" in self.params: + # JSONP 请求 + resp_data: dict = json.loads( + re.match("^.*?({.*}).*$", resp_text, re.S).group(1) + ) + else: + # JSON + resp_data: dict = json.loads(resp_text) + + if raw: + return resp_data + + OK = resp_data.get("OK") + + # 检查 code + if not self.ignore_code: + if OK is None: + code = resp_data.get("code") + if code is None: + raise ResponseCodeException(-1, "API 返回数据未含 code 字段", resp_data) + if code != 0: + msg = resp_data.get("msg") + if msg is None: + msg = resp_data.get("message") + if msg is None: + msg = "接口未返回错误信息" + raise ResponseCodeException(code, msg, resp_data) + elif OK != 1: + raise ResponseCodeException(-1, "API 返回数据 OK 不为 1", resp_data) + elif settings.request_log: + settings.logger.info(resp_data) + + real_data = resp_data.get("data") if OK is None else resp_data + if real_data is None: + real_data = resp_data.get("result") + return real_data + + @classmethod + def from_file(cls, path: str, credential: Union[Credential, None] = None): + """ + 以 json 文件生成对象 + + Args: + path (str): 例如 user.info.info + + credential (Credential, Optional): 凭据类. Defaults to None. + + Returns: + api (Api): 从文件中读取的 api 信息 + """ + path_list = path.split(".") + api = get_api(path_list.pop(0)) + for key in path_list: + api = api.get(key) + return cls(credential=credential, **api) + + +async def check_valid(credential: Credential) -> bool: + """ + 检查 cookies 是否有效 + + Args: + credential (Credential): 凭据类 + + Returns: + bool: cookies 是否有效 + """ + data = await get_nav(credential) + return data["isLogin"] + + +async def get_spi_buvid() -> dict: + """ + 获取 buvid3 / buvid4 + + Returns: + dict: 账号相关信息 + """ + return await Api(**API["info"]["spi"]).result + + +def get_spi_buvid_sync() -> dict: + """ + 同步获取 buvid3 / buvid4 + + Returns: + dict: 账号相关信息 + """ + return Api(**API["info"]["spi"]).result_sync + + +def get_nav_sync(credential: Union[Credential, None] = None): + """ + 获取导航 + + Args: + credential (Credential, Optional): 凭据类. Defaults to None + + Returns: + dict: 账号相关信息 + """ + return Api(credential=credential, **API["info"]["valid"]).result_sync + + +async def get_nav(credential: Union[Credential, None] = None): + """ + 获取导航 + + Args: + credential (Credential, Optional): 凭据类. Defaults to None + + Returns: + dict: 账号相关信息 + """ + return await Api(credential=credential, **API["info"]["valid"]).result + + +def get_mixin_key_sync() -> str: + """ + 获取混合密钥 + + Returns: + str: 新获取的密钥 + """ + data = get_nav_sync() + wbi_img: Dict[str, str] = data["wbi_img"] + + # 为什么要把里的 lambda 表达式换成函数 这不是一样的吗 + # split = lambda key: wbi_img.get(key).split("/")[-1].split(".")[0] + def split(key): + return wbi_img.get(key).split("/")[-1].split(".")[0] + + ae = split("img_url") + split("sub_url") + le = reduce(lambda s, i: s + (ae[i] if i < len(ae) else ""), OE, "") + return le[:32] + + +async def get_mixin_key() -> str: + """ + 获取混合密钥 + + Returns: + str: 新获取的密钥 + """ + data = await get_nav() + wbi_img: Dict[str, str] = data["wbi_img"] + + # 为什么要把里的 lambda 表达式换成函数 这不是一样的吗 + # split = lambda key: wbi_img.get(key).split("/")[-1].split(".")[0] + def split(key): + return wbi_img.get(key).split("/")[-1].split(".")[0] + + ae = split("img_url") + split("sub_url") + le = reduce(lambda s, i: s + (ae[i] if i < len(ae) else ""), OE, "") + return le[:32] + + +def enc_wbi(params: dict, mixin_key: str): + """ + 更新请求参数 + + Args: + params (dict): 原请求参数 + + mixin_key (str): 混合密钥 + """ + params.pop("w_rid", None) # 重试时先把原有 w_rid 去除 + params["wts"] = int(time.time()) + # web_location 因为没被列入参数可能炸一些接口 比如 video.get_ai_conclusion + params["web_location"] = 1550101 + Ae = urlencode(sorted(params.items())) + params["w_rid"] = hashlib.md5((Ae + mixin_key).encode(encoding="utf-8")).hexdigest() + + +def hmac_sha256(key: str, message: str) -> str: + """ + 使用HMAC-SHA256算法对给定的消息进行加密 + :param key: 密钥 + :param message: 要加密的消息 + :return: 加密后的哈希值 + """ + # 将密钥和消息转换为字节串 + key = key.encode("utf-8") + message = message.encode("utf-8") + + # 创建HMAC对象,使用SHA256哈希算法 + hmac_obj = hmac.new(key, message, hashlib.sha256) + + # 计算哈希值 + hash_value = hmac_obj.digest() + + # 将哈希值转换为十六进制字符串 + hash_hex = hash_value.hex() + + return hash_hex + + +async def get_bili_ticket() -> str: + """ + 获取 bili_ticket,但目前没用到,暂时不启用 + + https://github.com/SocialSisterYi/bilibili-API-collect/issues/903 + + Returns: + str: bili_ticket + """ + o = hmac_sha256("XgwSnGZ1p", f"ts{int(time.time())}") + url = "https://api.bilibili.com/bapis/bilibili.api.ticket.v1.Ticket/GenWebTicket" + params = { + "key_id": "ec02", + "hexsign": o, + "context[ts]": f"{int(time.time())}", + "csrf": "", + } + return ( + await Api(method="POST", url=url, no_csrf=True).update_params(**params).result + )["ticket"] + + +def get_httpx_sync_session() -> httpx.Client: + """ + 获取当前模块的 httpx.Client 对象,用于自定义请求 + + Returns: + httpx.Client + """ + global __httpx_sync_session + global last_proxy + + if __httpx_sync_session is None or last_proxy != settings.proxy: + if settings.proxy != "": + last_proxy = settings.proxy + proxies = {"all://": settings.proxy} + session = httpx.Client(proxies=proxies) # type: ignore + else: + last_proxy = "" + session = httpx.Client() + __httpx_sync_session = session + + return __httpx_sync_session + + +def set_httpx_sync_session(session: httpx.Client) -> None: + """ + 用户手动设置 Session + + Args: + session (httpx.Client): httpx.Client 实例。 + """ + global __httpx_sync_session + __httpx_sync_session = session + + +def get_session() -> httpx.AsyncClient: + """ + 获取当前模块的 httpx.AsyncClient 对象,用于自定义请求 + + Returns: + httpx.AsyncClient + """ + global __httpx_session_pool, last_proxy + loop = asyncio.get_event_loop() + session = __httpx_session_pool.get(loop, None) + if session is None or last_proxy != settings.proxy: + if settings.proxy != "": + last_proxy = settings.proxy + proxies = {"all://": settings.proxy} + session = httpx.AsyncClient(proxies=proxies) # type: ignore + else: + last_proxy = "" + session = httpx.AsyncClient() + __httpx_session_pool[loop] = session + + return session + + +def set_session(session: httpx.AsyncClient) -> None: + """ + 用户手动设置 Session + + Args: + session (httpx.AsyncClient): httpx.AsyncClient 实例。 + """ + loop = asyncio.get_event_loop() + __httpx_session_pool[loop] = session + + +def get_aiohttp_session() -> aiohttp.ClientSession: + """ + 获取当前模块的 aiohttp.ClientSession 对象,用于自定义请求 + + Returns: + aiohttp.ClientSession + """ + loop = asyncio.get_event_loop() + session = __aiohttp_session_pool.get(loop, None) + if session is None: + session = aiohttp.ClientSession( + loop=loop, connector=aiohttp.TCPConnector(), trust_env=True + ) + __aiohttp_session_pool[loop] = session + + return session + + +def set_aiohttp_session(session: aiohttp.ClientSession) -> None: + """ + 用户手动设置 Session + + Args: + session (aiohttp.ClientSession): aiohttp.ClientSession 实例。 + """ + loop = asyncio.get_event_loop() + __aiohttp_session_pool[loop] = session + + +def to_form_urlencoded(data: dict) -> str: + temp = [] + for [k, v] in data.items(): + temp.append(f'{k}={quote(str(v)).replace("/", "%2F")}') + + return "&".join(temp) + + +@atexit.register +def __clean() -> None: + """ + 程序退出清理操作。 + """ + try: + loop = asyncio.get_event_loop() + except RuntimeError: + return + + async def __clean_task(): + s0 = __aiohttp_session_pool.get(loop, None) + if s0 is not None: + await s0.close() + s1 = __httpx_session_pool.get(loop, None) + if s1 is not None: + await s1.aclose() + + if loop.is_closed(): + loop.run_until_complete(__clean_task()) + else: + loop.create_task(__clean_task()) diff --git a/bilibili_api/utils/parse_link.py b/bilibili_api/utils/parse_link.py new file mode 100644 index 0000000000000000000000000000000000000000..25b181a15c5ee9334a5e170b078608da0e3341bf --- /dev/null +++ b/bilibili_api/utils/parse_link.py @@ -0,0 +1,661 @@ +""" +bilibili_api.utils.parse_link + +链接资源解析。 +""" + +import re +import json +from enum import Enum +from typing import Tuple, Union, Literal + +import httpx +from yarl import URL + +from .network import Api +from ..game import Game +from ..manga import Manga +from ..topic import Topic +from ..video import Video +from ..exceptions import * +from .utils import get_api +from ..live import LiveRoom +from ..dynamic import Dynamic +from .short import get_real_url +from ..note import Note, NoteType +from ..black_room import BlackRoom +from .credential import Credential +from ..audio import Audio, AudioList +from ..bangumi import Bangumi, Episode +from ..article import Article, ArticleList +from ..cheese import CheeseList, CheeseVideo +from ..interactive_video import InteractiveVideo +from ..favorite_list import FavoriteList, FavoriteListType +from ..user import User, ChannelSeries, ChannelSeriesType, get_self_info + +from .initial_state import get_initial_state + + +class ResourceType(Enum): + """ + 链接类型类。 + + + VIDEO: 视频 + + BANGUMI: 番剧 + + EPISODE: 番剧剧集 + + FAVORITE_LIST: 视频收藏夹 + + CHEESE: 课程 + + CHEESE_VIDEO: 课程视频 + + AUDIO: 音频 + + AUDIO_LIST: 歌单 + + ARTICLE: 专栏 + + USER: 用户 + + LIVE: 直播间 + + CHANNEL_SERIES: 合集与列表 + + BLACK_ROOM: 小黑屋 + + GAME: 游戏 + + TOPIC: 话题 + + MANGA: 漫画 + + NOTE: 笔记 + + FAILED: 错误 + """ + + VIDEO = "video" + INTERACTIVE_VIDEO = "interactive_video" + BANGUMI = "bangumi" + EPISODE = "episode" + FAVORITE_LIST = "favorite_list" + CHEESE_VIDEO = "cheese_video" + AUDIO = "audio" + AUDIO_LIST = "audio_list" + ARTICLE = "article" + USER = "user" + LIVE = "live" + CHANNEL_SERIES = "channel_series" + ARTICLE_LIST = "article_list" + DYNAMIC = "dynamic" + BLACK_ROOM = "black_room" + GAME = "game" + TOPIC = "topic" + MANGA = "manga" + NOTE = "note" + FAILED = "failed" + + +async def parse_link( + url: str, credential: Union[Credential, None] = None +) -> Union[ + Tuple[Video, Literal[ResourceType.VIDEO]], + Tuple[InteractiveVideo, Literal[ResourceType.INTERACTIVE_VIDEO]], + Tuple[Bangumi, Literal[ResourceType.BANGUMI]], + Tuple[Episode, Literal[ResourceType.EPISODE]], + Tuple[FavoriteList, Literal[ResourceType.FAVORITE_LIST]], + Tuple[CheeseVideo, Literal[ResourceType.CHEESE_VIDEO]], + Tuple[Audio, Literal[ResourceType.AUDIO]], + Tuple[AudioList, Literal[ResourceType.AUDIO_LIST]], + Tuple[Article, Literal[ResourceType.ARTICLE]], + Tuple[User, Literal[ResourceType.USER]], + Tuple[LiveRoom, Literal[ResourceType.LIVE]], + Tuple[ChannelSeries, Literal[ResourceType.CHANNEL_SERIES]], + Tuple[ArticleList, Literal[ResourceType.ARTICLE_LIST]], + Tuple[Dynamic, Literal[ResourceType.DYNAMIC]], + Tuple[BlackRoom, Literal[ResourceType.BLACK_ROOM]], + Tuple[Game, Literal[ResourceType.GAME]], + Tuple[Topic, Literal[ResourceType.TOPIC]], + Tuple[Manga, Literal[ResourceType.MANGA]], + Tuple[Note, Literal[ResourceType.NOTE]], + Tuple[Literal[-1], Literal[ResourceType.FAILED]], +]: + """ + 调用 yarl 解析 bilibili url 的函数。 + + Args: + url(str) : 链接 + + credential(Credential): 凭据类 + + Returns: + Tuple[obj, ResourceType]: (对象,类型) 或 -1,-1 表示出错 + """ + credential = credential if credential else Credential() + url = url.replace("\\", "/") # 说多了都是泪 + try: + obj = None + + # 排除 bvxxxxxxxxxx 等缩写 + sobj = await check_short_name(url, credential) + if sobj != -1: + sobj[0].credential = credential + return sobj + + # 删去首尾部空格 + url = url.strip() + # 添加 https: 协议头 + if url.lstrip("https:") == url: + url = "https:" + url + + # 转换为 yarl + url = URL(url) # type: ignore + + # 排除小黑屋 + black_room = parse_black_room(url, credential) # type: ignore + if not black_room == -1: + obj = (black_room, ResourceType.BLACK_ROOM) + return obj # type: ignore + + # 过滤 https://space.bilibili.com/ + if url.host == "space.bilibili.com" and url.path == "/" or url.path == "": # type: ignore + try: + info = await get_self_info(credential) + except Exception as e: + return (-1, ResourceType.FAILED) + else: + return (User(info["mid"], credential=credential), ResourceType.USER) + + channel = parse_season_series( + url, credential # type: ignore + ) # 不需要 real_url,提前处理 + if channel != -1: + return (channel, ResourceType.CHANNEL_SERIES) # type: ignore + + url = await get_real_url(str(url)) # type: ignore + url = URL(url) # type: ignore + + fl_space = parse_space_favorite_list(url, credential) # type: ignore + if fl_space != -1: + return fl_space # type: ignore + game = parse_game(url, credential) # type: ignore + if game != -1: + game.credential = credential # type: ignore + return (game, ResourceType.GAME) # type: ignore + topic = parse_topic(url, credential) # type: ignore + if topic != -1: + topic.credential = credential # type: ignore + return (topic, ResourceType.TOPIC) # type: ignore + festival_video = await parse_festival(url, credential) # type: ignore + if festival_video != -1: + festival_video.credential = credential # type: ignore + return (festival_video, ResourceType.VIDEO) # type: ignore + note = parse_note(url, credential) # type: ignore + if note != -1: + return (note, ResourceType.NOTE) # type: ignore + + obj = None + video = await parse_video(url, credential) # type: ignore + if not video == -1: + obj = video # auto_convert_video 会判断类型 + bangumi = parse_bangumi(url, credential) # type: ignore + if not bangumi == -1: + obj = (bangumi, ResourceType.BANGUMI) + episode = await parse_episode(url, credential) # type: ignore + if not episode == -1: + obj = (episode, ResourceType.EPISODE) + favorite_list = parse_favorite_list(url, credential) # type: ignore + if not favorite_list == -1: + obj = (favorite_list, ResourceType.FAVORITE_LIST) + cheese_video = await parse_cheese_video(url, credential) # type: ignore + if not cheese_video == -1: + obj = (cheese_video, ResourceType.CHEESE_VIDEO) + audio = parse_audio(url, credential) # type: ignore + if not audio == -1: + obj = (audio, ResourceType.AUDIO) + audio_list = parse_audio_list(url, credential) # type: ignore + if not audio_list == -1: + obj = (audio_list, ResourceType.AUDIO_LIST) + article = parse_article(url, credential) # type: ignore + if not article == -1: + obj = (article, ResourceType.ARTICLE) + article_list = parse_article_list(url, credential) # type: ignore + if not article_list == -1: + obj = (article_list, ResourceType.ARTICLE_LIST) + user = parse_user(url, credential) # type: ignore + if not user == -1: + obj = (user, ResourceType.USER) + live = parse_live(url, credential) # type: ignore + if not live == -1: + obj = (live, ResourceType.LIVE) + dynamic = parse_dynamic(url, credential) # type: ignore + if not dynamic == -1: + obj = (dynamic, ResourceType.DYNAMIC) + manga = parse_manga(url, credential) # type: ignore + if not manga == -1: + obj = (manga, ResourceType.MANGA) + opus_dynamic = parse_opus_dynamic(url, credential) # type: ignore + if not opus_dynamic == -1: + obj = (opus_dynamic, ResourceType.DYNAMIC) + + if obj == None or obj[0] == None: + return (-1, ResourceType.FAILED) + else: + obj[0].credential = credential # type: ignore + return obj # type: ignore + except Exception as e: + raise e + # return (-1, ResourceType.FAILED) + + +async def auto_convert_video( + video: Video, credential: Union[Credential, None] = None +) -> Tuple[Union[Video, Episode, InteractiveVideo], ResourceType]: + # check interactive video + video_info = await video.get_info() + if video_info["rights"]["is_stein_gate"] == 1: + return ( + InteractiveVideo(video.get_bvid(), credential=credential), + ResourceType.INTERACTIVE_VIDEO, + ) + + # check episode + if "redirect_url" in video_info: + reparse_link = await parse_link( + await get_real_url(video_info["redirect_url"]), credential=credential + ) # type: ignore + return reparse_link # type: ignore + + # return video + return (video, ResourceType.VIDEO) + + +async def check_short_name( + name: str, credential: Credential +) -> Union[ + Tuple[Video, Literal[ResourceType.VIDEO]], + Tuple[Episode, Literal[ResourceType.EPISODE]], + Tuple[CheeseVideo, Literal[ResourceType.CHEESE_VIDEO]], + Tuple[FavoriteList, Literal[ResourceType.FAVORITE_LIST]], + Tuple[User, Literal[ResourceType.USER]], + Tuple[Article, Literal[ResourceType.ARTICLE]], + Tuple[Audio, Literal[ResourceType.AUDIO]], + Tuple[AudioList, Literal[ResourceType.AUDIO_LIST]], + Tuple[ArticleList, Literal[ResourceType.ARTICLE_LIST]], + Literal[-1], +]: + """ + 解析: + - mlxxxxxxxxxx + - uidxxxxxxxxx + - cvxxxxxxxxxx + - auxxxxxxxxxx + - amxxxxxxxxxx + - rlxxxxxxxxxx + """ + if name[:2].upper() == "AV": + v = Video(aid=int(name[2:]), credential=credential) + return await auto_convert_video(v, credential=credential) # type: ignore + elif name[:2].upper() == "BV": + v = Video(bvid=name, credential=credential) + return await auto_convert_video(v, credential=credential) # type: ignore + elif name[:2].upper() == "ML": + return ( + FavoriteList(FavoriteListType.VIDEO, int(name[2:]), credential=credential), + ResourceType.FAVORITE_LIST, + ) + elif name[:3].upper() == "UID": + return (User(int(name[3:]), credential=credential), ResourceType.USER) + elif name[:2].upper() == "CV": + return (Article(int(name[2:]), credential=credential), ResourceType.ARTICLE) + elif name[:2].upper() == "AU": + return (Audio(int(name[2:]), credential=credential), ResourceType.AUDIO) + elif name[:2].upper() == "AM": + return ( + AudioList(int(name[2:]), credential=credential), + ResourceType.AUDIO_LIST, + ) + elif name[:2].upper() == "RL": + return ( + ArticleList(int(name[2:]), credential=credential), + ResourceType.ARTICLE_LIST, + ) + else: + return -1 + + +async def parse_video( + url: URL, credential: Credential +) -> Union[Tuple[Union[Video, Episode, InteractiveVideo], ResourceType], Literal[-1]]: + """ + 解析视频,如果不是返回 -1,否则返回对应类 + """ + if url.host == "www.bilibili.com" and url.parts[1] == "video": + raw_video_id = url.parts[2] + if raw_video_id[:2].upper() == "AV": + aid = int(raw_video_id[2:]) + v = Video(aid=aid, credential=credential) + elif raw_video_id[:2].upper() == "BV": + v = Video(bvid=raw_video_id, credential=credential) + else: + return -1 + return await auto_convert_video(v, credential=credential) + else: + return -1 + + +def parse_bangumi(url: URL, credential: Credential) -> Union[Bangumi, int]: + """ + 解析番剧,如果不是返回 -1,否则返回对应类 + """ + if url.host == "www.bilibili.com" and len(url.parts) >= 4: + if url.parts[:3] == ("/", "bangumi", "media"): + media_id = int(url.parts[3][2:]) + return Bangumi(media_id=media_id, credential=credential) + return -1 + + +async def parse_episode(url: URL, credential: Credential) -> Union[Episode, int]: + """ + 解析番剧剧集,如果不是返回 -1,否则返回对应类 + """ + if url.host == "www.bilibili.com" and len(url.parts) >= 3: + if url.parts[1] == "bangumi" and url.parts[2] == "play": + video_short_id = url.parts[3] + + if video_short_id[:2].upper() == "EP": + epid = int(video_short_id[2:]) + return Episode(epid=epid) + elif video_short_id[:2].upper() == "SS": + bangumi = Bangumi(ssid=int(video_short_id[2:])) + epid = (await bangumi.get_episodes())[0].get_epid() + return Episode(epid=epid) + return -1 + + +def parse_favorite_list(url: URL, credential: Credential) -> Union[FavoriteList, int]: + """ + 解析收藏夹,如果不是返回 -1,否则返回对应类 + """ + if url.host == "www.bilibili.com" and len(url.parts) >= 4: + if url.parts[:3] == ("/", "medialist", "detail"): + media_id = int(url.parts[3][2:]) + return FavoriteList(media_id=media_id, credential=credential) + return -1 + + +async def parse_cheese_video( + url: URL, credential: Credential +) -> Union[CheeseVideo, int]: + """ + 解析课程视频,如果不是返回 -1,否则返回对应类 + """ + if url.host == "www.bilibili.com" and len(url.parts) >= 4: + if url.parts[1] == "cheese" and url.parts[2] == "play": + if url.parts[3][:2].upper() == "EP": + epid = int(url.parts[3][2:]) + return CheeseVideo(epid=epid, credential=credential) + elif url.parts[3][:2].upper() == "SS": + clid = int(url.parts[3][2:]) + cl = CheeseList(season_id=clid, credential=credential) + return CheeseVideo( + epid=(await cl.get_list_raw())["items"][0]["id"], + credential=credential, + ) + return -1 + + +def parse_audio(url: URL, credential: Credential) -> Union[Audio, int]: + """ + 解析音频,如果不是返回 -1,否则返回对应类 + """ + if url.host == "www.bilibili.com" and url.parts[1] == "audio": + if url.parts[2][:2].upper() == "AU": + auid = int(url.parts[2][2:]) + return Audio(auid=auid, credential=credential) + return -1 + + +def parse_audio_list(url: URL, credential: Credential) -> Union[AudioList, int]: + """ + 解析歌单,如果不是返回 -1,否则返回对应类 + """ + if url.host == "www.bilibili.com" and url.parts[1] == "audio": + if url.parts[2][:2].upper() == "AM": + amid = int(url.parts[2][2:]) + return AudioList(amid=amid, credential=credential) + return -1 + + +def parse_article(url: URL, credential: Credential) -> Union[Article, int]: + """ + 解析专栏,如果不是返回 -1,否则返回对应类 + """ + if url.host == "www.bilibili.com" and len(url.parts) >= 3: + if url.parts[1] == "read" and url.parts[2][:2].upper() == "CV": + cvid = int(url.parts[2][2:]) + return Article(cvid=cvid, credential=credential) + return -1 + + +def parse_user(url: URL, credential: Credential) -> Union[User, int]: + if url.host == "space.bilibili.com": + if len(url.parts) >= 2: + uid = url.parts[1] + return User(uid=int(uid), credential=credential) + return -1 + + +def parse_live(url: URL, credential: Credential) -> Union[LiveRoom, int]: + if url.host == "live.bilibili.com": + if len(url.parts) >= 2: + room_display_id = int(url.parts[1]) + return LiveRoom(room_display_id=room_display_id, credential=credential) + return -1 + + +def parse_season_series(url: URL, credential: Credential) -> Union[ChannelSeries, int]: + if url.host == "space.bilibili.com": + if len(url.parts) >= 2: # path 存在 uid + try: + uid = int(url.parts[1]) + except: + pass # uid 无效 + else: + if len(url.parts) >= 4: # path 存在 collectiondetail 或者 seriesdetail + if url.parts[3] == "collectiondetail": + # https://space.bilibili.com/51537052/channel/collectiondetail?sid=22780&ctype=0 + if url.query.get("sid") is not None: + sid = int(url.query["sid"]) + return ChannelSeries( + uid, + ChannelSeriesType.SEASON, + id_=sid, + credential=credential, + ) + elif url.parts[3] == "seriesdetail": + # https://space.bilibili.com/558830935/channel/seriesdetail?sid=2972810&ctype=0 + if url.query.get("sid") is not None: + sid = int(url.query["sid"]) + return ChannelSeries( + uid, + ChannelSeriesType.SERIES, + id_=sid, + credential=credential, + ) + elif url.host == "www.bilibili.com": + if url.parts[1] == "list": + # https://www.bilibili.com/list/660303135?sid=2908236 旧版合集,不需要 real_url + if len(url.parts) >= 3: + uid = int(url.parts[2]) + if "sid" in url.query: + sid = int(url.query["sid"]) + return ChannelSeries( + uid, ChannelSeriesType.SERIES, id_=sid, credential=credential + ) + # https://www.bilibili.com/medialist/play/660303135?business=space 新版合集 + elif url.parts[1] == "medialist" and url.parts[2] == "play": + if len(url.parts) >= 4: + uid = int(url.parts[3]) + if "business_id" in url.query: + sid = int(url.query["business_id"]) + return ChannelSeries( + uid, ChannelSeriesType.SERIES, id_=sid, credential=credential + ) + return -1 + + +def parse_space_favorite_list( + url: URL, credential: Credential +) -> Union[ + Tuple[FavoriteList, ResourceType], Tuple[ChannelSeries, ResourceType], Literal[-1] +]: + if url.host == "space.bilibili.com": + uid = url.parts[1] # 获取 uid + if len(url.parts) >= 3: # path 存在 favlist + if url.parts[2] == "favlist": + if ( + len(url.parts) == 3 and url.query.get("fid") == None + ): # query 中不存在 fid 则返回默认收藏夹 + api = get_api("favorite-list")["info"]["list_list"] + params = {"up_mid": uid, "type": 2} + favorite_lists = ( + Api(**api, credential=credential) + .update_params(**params) + .result_sync + ) + + if favorite_lists == None: + return -1 + else: + default_favorite_id = int(favorite_lists["list"][0]["id"]) + return ( + FavoriteList( + media_id=default_favorite_id, credential=credential + ), + ResourceType.FAVORITE_LIST, + ) + + elif len(url.query) != 0: + fid = url.query.get("fid") # 未知数据类型 + ctype = url.query.get("ctype") + try: # 尝试转换为 int 类型并设置 fid_is_int + fid = int(fid) # type: ignore + fid_is_int = True + except: + fid_is_int = False + if ctype is None and fid_is_int: + # 我的视频收藏夹 + fid = int(fid) # type: ignore + return (FavoriteList(media_id=fid), ResourceType.FAVORITE_LIST) + elif fid_is_int: + if int(ctype) == 11: # type: ignore + fid = int(fid) # 转换为 int 类型 # type: ignore + fid_is_int = True + return ( + FavoriteList(media_id=fid, credential=credential), + ResourceType.FAVORITE_LIST, + ) + elif int(ctype) == 21: # type: ignore + fid = int(fid) # type: ignore + fid_is_int = True + return ( + ChannelSeries( + id_=fid, + type_=ChannelSeriesType.SEASON, + credential=credential, + ), + ResourceType.CHANNEL_SERIES, + ) + elif fid_is_int == False: + # ctype 不存在且 fid 非 int 类型 + if fid == FavoriteListType.ARTICLE.value: + return ( + FavoriteList( + FavoriteListType.ARTICLE, credential=credential + ), + ResourceType.FAVORITE_LIST, + ) + elif fid == FavoriteListType.CHEESE.value: + return ( + FavoriteList( + FavoriteListType.CHEESE, credential=credential + ), + ResourceType.FAVORITE_LIST, + ) + return -1 + + +def parse_article_list(url: URL, credential: Credential) -> Union[ArticleList, int]: + if url.host == "www.bilibili.com" and len(url.parts) >= 3: + if url.parts[:3] == ("/", "read", "readlist"): + rlid = int(url.parts[3][2:]) + return ArticleList(rlid=rlid, credential=credential) + return -1 + + +def parse_dynamic(url: URL, credential: Credential) -> Union[Dynamic, int]: + if url.host == "t.bilibili.com": + if len(url.parts) >= 2: + dynamic_id = int(url.parts[1]) + return Dynamic(dynamic_id, credential=credential) + return -1 + + +def parse_black_room(url: URL, credential: Credential) -> Union[BlackRoom, int]: + if len(url.parts) >= 3: + if url.parts[:3] == ("/", "blackroom", "ban"): + if len(url.parts) >= 4: # 存在 id + return BlackRoom(int(url.parts[3]), credential=credential) + return -1 + + +def parse_game(url: URL, credential: Credential) -> Union[Game, int]: + if ( + url.host == "www.biligame.com" + and url.parts[1] == "detail" + and url.query.get("id") is not None + ): + return Game(int(url.query["id"]), credential=credential) + return -1 + + +def parse_topic(url: URL, credential: Credential) -> Union[Topic, int]: + if url.host == "www.bilibili.com" and len(url.parts) >= 4: + if ( + url.parts[:4] == ("/", "v", "topic", "detail") + and url.query.get("topic_id") is not None + ): + return Topic(int(url.query["topic_id"]), credential=credential) + return -1 + + +def parse_manga(url: URL, credential: Credential) -> Union[Manga, int]: + if url.host == "manga.bilibili.com" and url.parts[1] == "detail": + return Manga(int(url.parts[2][2:]), credential=credential) + return -1 + + +async def parse_festival(url: URL, credential: Credential) -> Union[Video, int]: + bvid = url.query.get("bvid") + if bvid is not None: # get bvid if provided + return Video(bvid, credential=credential) + + if ( + url.host == "www.bilibili.com" and url.parts[1] == "festival" + ): # use __initial_state__ to fetch + content, content_type = await get_initial_state( + url=str(url), credential=credential + ) + return Video( + content["videoSections"][0]["episodes"][0]["bvid"], credential=credential + ) # 返回当前第一个视频 + return -1 + + +def parse_note(url: URL, credential: Credential) -> Union[Note, int]: + # https://www.bilibili.com/h5/note-app/view?cvid=21385583 + if url.host == "www.bilibili.com" and url.parts[1:4] == ("h5", "note-app", "view"): + if url.query.get("cvid") == None: + return -1 + return Note(cvid=int(url.query.get("cvid")), note_type=NoteType.PUBLIC, credential=credential) # type: ignore + return -1 + + +def parse_nianshizhiwang(url: URL) -> None: + # https://www.bilibili.com/festival/nianshizhiwang?bvid=BV1yt4y1Q7SS&spm_id_from=trigger_reload + pass + # 貌似 parse_bnj 已经可以判断了 + + +def parse_opus_dynamic(url: URL, credential: Credential) -> Union[Dynamic, int]: + # https://www.bilibili.com/opus/767674573455884292 + if url.host == "www.bilibili.com" and url.parts[:2] == ("/", "opus"): + return Dynamic(dynamic_id=int(url.parts[-1]), credential=credential) + return -1 diff --git a/bilibili_api/utils/picture.py b/bilibili_api/utils/picture.py new file mode 100644 index 0000000000000000000000000000000000000000..ce12cc7b6745300909f5c42f0aa4a11f207b9837 --- /dev/null +++ b/bilibili_api/utils/picture.py @@ -0,0 +1,244 @@ +import os +import tempfile +from typing import Any +from dataclasses import dataclass + +import httpx +from yarl import URL +from PIL import Image +import io + +from .utils import get_api +from .credential import Credential + + +@dataclass +class Picture: + """ + (@dataclasses.dataclass) + + 图片类,包含图片链接、尺寸以及下载操作。 + + Args: + height (int) : 高度 + + imageType (str) : 格式,例如: png + + size (Any) : 尺寸 + + url (str) : 图片链接 + + width (int) : 宽度 + + content (bytes): 图片内容 + + 可以不实例化,用 `from_url`, `from_content` 或 `from_file` 加载图片。 + """ + + height: int = -1 + imageType: str = "" + size: Any = "" + url: str = "" + width: int = -1 + content: bytes = b"" + + def __repr__(self) -> str: + # no content... + return f"Picture(height='{self.height}', width='{self.width}', imageType='{self.imageType}', size={self.size}, url='{self.url}')" + + def __set_picture_meta_from_bytes(self, imgtype: str) -> None: + tmp_dir = tempfile.gettempdir() + img_path = os.path.join(tmp_dir, "test." + imgtype) + with open(img_path, "wb+") as file: + file.write(self.content) + img = Image.open(img_path) + self.size = int(round(os.path.getsize(img_path) / 1024, 0)) + self.height = img.height + self.width = img.width + self.imageType = imgtype + + @staticmethod + async def async_load_url(url: str) -> "Picture": + """ + 加载网络图片。(async 方法) + + Args: + url (str): 图片链接 + + Returns: + Picture: 加载后的图片对象 + """ + if URL(url).scheme == "": + url = "https:" + url + obj = Picture() + session = httpx.AsyncClient() + resp = await session.get( + url, + headers={"User-Agent": "Mozilla/5.0"}, + ) + obj.content = resp.read() + obj.url = url + obj.__set_picture_meta_from_bytes(url.split("/")[-1].split(".")[1]) + return obj + + @staticmethod + def from_url(url: str) -> "Picture": + """ + 加载网络图片。 + + Args: + url (str): 图片链接 + + Returns: + Picture: 加载后的图片对象 + """ + if URL(url).scheme == "": + url = "https:" + url + obj = Picture() + session = httpx.Client() + resp = session.get( + url, + headers={"User-Agent": "Mozilla/5.0"}, + ) + obj.content = resp.read() + obj.url = url + obj.__set_picture_meta_from_bytes(url.split("/")[-1].split(".")[1]) + return obj + + @staticmethod + def from_file(path: str) -> "Picture": + """ + 加载本地图片。 + + Args: + path (str): 图片地址 + + Returns: + Picture: 加载后的图片对象 + """ + obj = Picture() + with open(path, "rb") as file: + obj.content = file.read() + obj.url = "file://" + path + obj.__set_picture_meta_from_bytes(os.path.basename(path).split(".")[1]) + return obj + + @staticmethod + def from_content(content: bytes, format: str) -> "Picture": + """ + 加载字节数据 + + Args: + content (str): 图片内容 + + format (str): 图片后缀名,如 `webp`, `jpg`, `ico` + + Returns: + Picture: 加载后的图片对象 + """ + obj = Picture() + obj.content = content + obj.url = "bytes://" + content.decode("utf-8", errors="ignore") + obj.__set_picture_meta_from_bytes(format) + return obj + + def _write_to_temp_file(self): + tmp_dir = tempfile.gettempdir() + img_path = os.path.join(tmp_dir, "test." + self.imageType) + open(img_path, "wb").write(self.content) + return img_path + + async def upload_file(self, credential: Credential, data: dict = None) -> "Picture": + """ + 上传图片至 B 站。 + + Args: + credential (Credential): 凭据类。 + + Returns: + Picture: `self` + """ + from ..dynamic import upload_image + + res = await upload_image(self, credential, data) + self.height = res["image_height"] + self.width = res["image_width"] + self.url = res["image_url"] + self.content = self.from_url(self.url).content + return self + + def upload_file_sync(self, credential: Credential) -> "Picture": + """ + 上传图片至 B 站。(同步函数) + + Args: + credential (Credential): 凭据类。 + + Returns: + Picture: `self` + """ + from ..dynamic import upload_image_sync + + res = upload_image_sync(self, credential) + self.url = res["image_url"] + self.height = res["image_height"] + self.width = res["image_width"] + self.content = self.from_url(self.url).content + return self + + def download_sync(self, path: str) -> "Picture": + """ + 下载图片至本地。(同步函数) + + Args: + path (str): 下载地址。 + + Returns: + Picture: `self` + """ + tmp_dir = tempfile.gettempdir() + img_path = os.path.join(tmp_dir, "test." + self.imageType) + open(img_path, "wb").write(self.content) + img = Image.open(img_path) + img.save(path, save_all=(True if self.imageType in ["webp", "gif"] else False)) + self.url = "file://" + path + return self + + def convert_format(self, new_format: str) -> "Picture": + """ + 将图片转换为另一种格式。 + + Args: + new_format (str): 新的格式。例:`png`, `ico`, `webp`. + + Returns: + Picture: `self` + """ + tmp_dir = tempfile.gettempdir() + img_path = os.path.join(tmp_dir, "test." + self.imageType) + open(img_path, "wb").write(self.content) + img = Image.open(img_path) + new_img_path = os.path.join(tmp_dir, "test." + new_format) + img.save(new_img_path) + with open(new_img_path, "rb") as file: + self.content = file.read() + self.__set_picture_meta_from_bytes(new_format) + return self + + async def download(self, path: str) -> "Picture": + """ + 下载图片至本地。 + + Args: + path (str): 下载地址。 + + Returns: + Picture: `self` + """ + tmp_dir = tempfile.gettempdir() + img_path = os.path.join(tmp_dir, "test." + self.imageType) + open(img_path, "wb").write(self.content) + img = Image.open(img_path) + img.save(path, save_all=(True if self.imageType in ["webp", "gif"] else False)) + self.url = "file://" + path + return self diff --git a/bilibili_api/utils/safecenter_captcha.py b/bilibili_api/utils/safecenter_captcha.py new file mode 100644 index 0000000000000000000000000000000000000000..def64e701db43227e8bfe9f8d63392ffe0cce71f --- /dev/null +++ b/bilibili_api/utils/safecenter_captcha.py @@ -0,0 +1,279 @@ +""" +bilibili_api.utils.safecenter_captcha + +验证人机测试 +""" +import os +import copy +import json +import time + +from .utils import get_api +from .network import Api + +validate = None +seccode = None +gt = None +challenge = None +key = None +server = None +thread = None + +API = get_api("login") + + +def set_gt_challenge(gee_gt: str, gee_challenge: str): + global gt, challenge + gt = gee_gt + challenge = gee_challenge + + +def _geetest_urlhandler(url: str, content_type: str): + """ + 极验验证服务器 html 源获取函数 + """ + global gt, challenge, key + url = url[1:] + if url[:7] == "result/": + global validate, seccode + datas = url[7:] + datas = datas.split("&") + for data in datas: + if data[:8] == "validate": + validate = data[9:] + elif data[:7] == "seccode": + seccode = data[8:].replace("%7C", "|") + with open( + os.path.abspath( + os.path.join( + os.path.dirname(__file__), "..", "data", "geetest", "done.html" + ) + ), + encoding="utf8", + ) as f: + html_source_bytes = f.read() + return html_source_bytes + elif url[:7] == "": + api = API["safecenter"]["captcha"] + json_data = Api(**api).result_sync + gt = json_data["gee_gt"] + challenge = json_data["gee_challenge"] + key = json_data["recaptcha_token"] + with open( + os.path.abspath( + os.path.join( + os.path.dirname(__file__), "..", "data", "geetest", "captcha.html" + ) + ), + encoding="utf8", + ) as f: + html_source_bytes = ( + f.read() + .replace("{ Python_Interface: GT }", f'"{gt}"') + .replace("{ Python_Interface: CHALLENGE }", f'"{challenge}"') + ) + return html_source_bytes + else: + return "" + + +def _start_server(urlhandler, hostname, port): + """Start an HTTP server thread on a specific port. + + Start an HTML/text server thread, so HTML or text documents can be + browsed dynamically and interactively with a web browser. Example use: + + >>> import time + >>> import pydoc + + Define a URL handler. To determine what the client is asking + for, check the URL and content_type. + + Then get or generate some text or HTML code and return it. + + >>> def my_url_handler(url, content_type): + ... text = 'the URL sent was: (%s, %s)' % (url, content_type) + ... return text + + Start server thread on port 0. + If you use port 0, the server will pick a random port number. + You can then use serverthread.port to get the port number. + + >>> port = 0 + >>> serverthread = pydoc._start_server(my_url_handler, port) + + Check that the server is really started. If it is, open browser + and get first page. Use serverthread.url as the starting page. + + >>> if serverthread.serving: + ... import webbrowser + + The next two lines are commented out so a browser doesn't open if + doctest is run on this module. + + #... webbrowser.open(serverthread.url) + #True + + Let the server do its thing. We just need to monitor its status. + Use time.sleep so the loop doesn't hog the CPU. + + >>> starttime = time.monotonic() + >>> timeout = 1 #seconds + + This is a short timeout for testing purposes. + + >>> while serverthread.serving: + ... time.sleep(.01) + ... if serverthread.serving and time.monotonic() - starttime > timeout: + ... serverthread.stop() + ... break + + Print any errors that may have occurred. + + >>> print(serverthread.error) + None + """ + import select + import threading + import http.server + import email.message + + class DocHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + """Process a request from an HTML browser. + + The URL received is in self.path. + Get an HTML page from self.urlhandler and send it. + """ + if self.path.endswith(".css"): + content_type = "text/css" + else: + content_type = "text/html" + self.send_response(200) + self.send_header("Content-Type", "%s; charset=UTF-8" % content_type) + self.end_headers() + self.wfile.write(self.urlhandler(self.path, content_type).encode("utf-8")) # type: ignore + + def log_message(self, *args): + # Don't log messages. + pass + + class DocServer(http.server.HTTPServer): + def __init__(self, host, port, callback): + self.host = host + self.address = (self.host, port) + self.callback = callback + self.base.__init__(self, self.address, self.handler) # type: ignore + self.quit = False + + def serve_until_quit(self): + while not self.quit: + rd, wr, ex = select.select([self.socket.fileno()], [], [], 1) + if rd: + self.handle_request() + self.server_close() + + def server_activate(self): + self.base.server_activate(self) # type: ignore + if self.callback: + self.callback(self) + + class ServerThread(threading.Thread): + def __init__(self, urlhandler, host, port): + self.urlhandler = urlhandler + self.host = host + self.port = int(port) + threading.Thread.__init__(self) + self.serving = False + self.error = None + + def run(self): + """Start the server.""" + try: + DocServer.base = http.server.HTTPServer # type: ignore + DocServer.handler = DocHandler # type: ignore + DocHandler.MessageClass = email.message.Message # type: ignore + DocHandler.urlhandler = staticmethod(self.urlhandler) # type: ignore + docsvr = DocServer(self.host, self.port, self.ready) + self.docserver = docsvr + docsvr.serve_until_quit() + except Exception as e: + self.error = e + + def ready(self, server): + self.serving = True + self.host = server.host + self.port = server.server_port + self.url = "http://%s:%d/" % (self.host, self.port) + + def stop(self): + """Stop the server and this thread nicely""" + if self.docserver != None: + self.docserver.quit = True + self.join() + # explicitly break a reference cycle: DocServer.callback + # has indirectly a reference to ServerThread. + self.docserver = None + self.serving = False + self.url = None + + thread = ServerThread(urlhandler, hostname, port) + thread.start() + # Wait until thread.serving is True to make sure we are + # really up before returning. + while not thread.error and not thread.serving: + time.sleep(0.01) + return thread + + +def start_server(): + """ + 验证码服务打开服务器 + + Returns: + ServerThread: 服务进程 + + 返回值内函数及属性: + - url (str) : 验证码服务地址 + - start (Callable): 开启进程 + - stop (Callable): 结束进程 + """ + global thread + thread = _start_server(_geetest_urlhandler, "127.0.0.1", 0) + print("请打开 " + thread.url + " 进行验证。") # type: ignore + return thread + + +def close_server(): + """ + 关闭服务器 + """ + global thread + thread.stop() # type: ignore + + +def get_result(): + """ + 获取结果 + + Returns: + dict: 验证结果 + """ + global validate, seccode, challenge, gt, key + if ( + validate is None + or seccode is None + or gt is None + or challenge is None + or key is None + ): + return -1 + else: + dct = { + "gt": copy.copy(gt), + "challenge": copy.copy(challenge), + "validate": copy.copy(validate), + "seccode": copy.copy(seccode), + "token": copy.copy(key), + } + return dct diff --git a/bilibili_api/utils/short.py b/bilibili_api/utils/short.py new file mode 100644 index 0000000000000000000000000000000000000000..e7ef47bac9b23e5502e53e38524acb4a4e666bb9 --- /dev/null +++ b/bilibili_api/utils/short.py @@ -0,0 +1,40 @@ +""" +bilibili_api.utils.short + +一个很简单的处理短链接的模块,主要是读取跳转链接。 +""" +from typing import Optional + +from .. import settings +from .credential import Credential +from .network import get_session, get_aiohttp_session + + +async def get_real_url(short_url: str, credential: Optional[Credential] = None) -> str: + """ + 获取短链接跳转目标,以进行操作。 + + Args: + short_url(str): 短链接。 + + credential(Credential \| None): 凭据类。 + + Returns: + 目标链接(如果不是有效的链接会报错) + + 返回值为原 url 类型 + """ + credential = credential if credential else Credential() + + try: + if settings.http_client == settings.HTTPClient.HTTPX: + resp = await get_session().head(url=str(short_url), follow_redirects=True) + else: + resp = await get_aiohttp_session().head( + url=str(short_url), allow_redirects=True + ) + u = resp.url + + return str(u) + except Exception as e: + raise e diff --git a/bilibili_api/utils/srt2ass.py b/bilibili_api/utils/srt2ass.py new file mode 100644 index 0000000000000000000000000000000000000000..f298be8c84c5a425cb7e3b5412a1183d528ce8de --- /dev/null +++ b/bilibili_api/utils/srt2ass.py @@ -0,0 +1,154 @@ +# type: ignore +# pylint: skip-file + +import io +import os +import re +import sys +import getopt + +DEBUG = False + + +def fileopen(input_file): + encodings = ["utf-32", "utf-16", "utf-8", "cp1252", "gb2312", "gbk", "big5"] + tmp = "" + for enc in encodings: + try: + with io.open(input_file, mode="r", encoding=enc) as fd: + tmp = fd.read() + break + except: + if DEBUG: + print(enc + " failed") + continue + return [tmp, enc] + + +def srt2ass(input_file, out_file, sub_type): + if ".ass" in input_file: + return input_file + + src = fileopen(input_file) + tmp = src[0] + encoding = src[1] + src = "" + utf8bom = "" + + if "\ufeff" in tmp: + tmp = tmp.replace("\ufeff", "") + utf8bom = "\ufeff" + + tmp = tmp.replace("\r", "") + lines = [x.strip() for x in tmp.split("\n") if x.strip()] + subLines = "" + tmpLines = "" + lineCount = 0 + output_file = out_file + + for ln in range(len(lines)): + line = lines[ln] + if ( + line.isdigit() + and ln + 1 < len(lines) + and re.match("-?\d\d:\d\d:\d\d", lines[(ln + 1)]) + ): + if tmpLines: + subLines += tmpLines + "\n" + tmpLines = "" + lineCount = 0 + continue + else: + if re.match("-?\d\d:\d\d:\d\d", line): + line = line.replace("-0", "0").replace(",", ".") + tmpLines += "Dialogue: 0," + line + ",Default,,0,0,0,," + else: + if lineCount < 2: + tmpLines += line + else: + tmpLines += "\\N" + line + lineCount += 1 + ln += 1 + + subLines += tmpLines + "\n" + + subLines = re.sub(r"\d(\d:\d{2}:\d{2}),(\d{2})\d", "\\1.\\2", subLines) + subLines = re.sub(r"\s+-->\s+", ",", subLines) + # replace style + subLines = re.sub(r"<([ubi])>", "{\\\\\g<1>1}", subLines) + subLines = re.sub(r"([ubi])>", "{\\\\\g<1>0}", subLines) + subLines = re.sub( + r'', "{\\\\c&H\\3\\2\\1&}", subLines + ) + subLines = re.sub(r"", "", subLines) + + if sub_type == "movie" or sub_type == "serie": + head_str = """[Script Info] +; This is an Advanced Sub Station Alpha v4+ script. +Title: Default Aegisub file +ScriptType: v4.00+ +Collisions: Normal +PlayDepth: 0 +PlayResX: 1920 +PlayResY: 1080 +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Verdana,60,&H00FFFFFF,&H000000FF,&H00282828,&H00000000,-1,0,0,0,100,100,0,0,1,3.75,0,2,0,0,70,1 +[Events] +Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text""" + + if sub_type == "anime": + head_str = """[Script Info] +; This is an Advanced Sub Station Alpha v4+ script. +Title: Default Aegisub file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +YCbCr Matrix: TV.709 +PlayResX: 1920 +PlayResY: 1080 +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Verdana,60,&H00FFFFFF,&H000000FF,&H00282828,&H00000000,-1,0,0,0,100.2,100,0,0,1,3.75,0,2,0,0,79,1 +Style: BottomRight,Arial,30,&H00FFFFFF,&H000000FF,&H00282828,&H00000000,0,0,0,0,100,100,0,0,1,2,2,3,10,10,10,1 +Style: TopLeft,Arial,30,&H00FFFFFF,&H000000FF,&H00282828,&H00000000,0,0,0,0,100,100,0,0,1,2,2,7,10,10,10,1 +Style: TopRight,Arial,30,&H00FFFFFF,&H000000FF,&H00282828,&H00000000,0,0,0,0,100,100,0,0,1,2,2,9,10,10,10,1 +[Events] +Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text""" + output_str = utf8bom + head_str + "\n" + subLines + # output_str = output_str.encode(encoding) + + with io.open(output_file, "w", encoding="utf8") as output: + output.write(output_str) + + output_file = output_file.replace("\\", "\\\\") + output_file = output_file.replace("/", "//") + return output_file + + +def print_helper(): + print("str2ass.py -t
-i inputfile") + print("Available types: anime, serie, movie") + + +if __name__ == "__main__": + try: + opts, args = getopt.getopt(sys.argv[1:], "t:i:", ["type=", "input="]) + except getopt.GetoptError: + print_helper() + sys.exit(2) + + sub_type = "anime" + input_file = None + for opt, arg in opts: + if opt in ("-t", "--type"): + sub_type = arg + elif opt in ("-i", "--input"): + input_file = arg + + if not input_file: + print_helper() + sys.exit(2) + + filenameOutput = srt2ass(input_file, sub_type) + print("Output: " + filenameOutput) diff --git a/bilibili_api/utils/sync.py b/bilibili_api/utils/sync.py new file mode 100644 index 0000000000000000000000000000000000000000..068df4cdb0ad43231b613dff64cea8c20c036472 --- /dev/null +++ b/bilibili_api/utils/sync.py @@ -0,0 +1,33 @@ +""" +bilibili_api.utils.sync + +同步执行异步函数 +""" + +import asyncio +from typing import Any, TypeVar, Coroutine + +T = TypeVar("T") + + +def __ensure_event_loop() -> None: + try: + asyncio.get_event_loop() + + except: + asyncio.set_event_loop(asyncio.new_event_loop()) + + +def sync(coroutine: Coroutine[Any, Any, T]) -> T: + """ + 同步执行异步函数,使用可参考 [同步执行异步代码](https://nemo2011.github.io/bilibili-api/#/sync-executor) + + Args: + coroutine (Coroutine): 异步函数 + + Returns: + 该异步函数的返回值 + """ + __ensure_event_loop() + loop = asyncio.get_event_loop() + return loop.run_until_complete(coroutine) diff --git a/bilibili_api/utils/upos.py b/bilibili_api/utils/upos.py new file mode 100644 index 0000000000000000000000000000000000000000..7d46a7b173ffecbed5299a7fb8a7925237c49435 --- /dev/null +++ b/bilibili_api/utils/upos.py @@ -0,0 +1,237 @@ +""" +bilibili_api.utils.upos +""" +import os +import json +import httpx +import asyncio +from asyncio.tasks import create_task + +from .utils import get_api +from .network import get_session +from ..exceptions.NetworkException import NetworkException +from ..exceptions.ResponseCodeException import ResponseCodeException +from ..exceptions.ApiException import ApiException + + +class UposFile: + """ + Upos 文件对象 + """ + + path: str + size: int + + def __init__(self, path: str) -> None: + self.path = path + self.size = self._get_size() + + def _get_size(self) -> int: + """ + 获取文件大小 + + Returns: + int: 文件大小 + """ + + size: int = 0 + stream = open(self.path, "rb") + while True: + s: bytes = stream.read(1024) + + if not s: + break + + size += len(s) + + stream.close() + return size + + +class UposFileUploader: + """ + Upos 文件上传 + """ + + _upload_id: str + _upload_url: str + _session: httpx.AsyncClient + + def __init__(self, file: UposFile, preupload: dict) -> None: + self.file = file + self.preupload = preupload + self._upload_id = preupload["upload_id"] + self._upload_url = f'https:{preupload["endpoint"]}/{preupload["upos_uri"].removeprefix("upos://")}' + self._session = get_session() + + async def upload(self) -> dict: + """ + 上传文件 + + Returns: + dict: filename, cid + """ + page_size = self.file.size + # 所有分块起始位置 + chunk_offset_list = list(range(0, page_size, self.preupload["chunk_size"])) + # 分块总数 + total_chunk_count = len(chunk_offset_list) + # 并发上传分块 + chunk_number = 0 + # 上传队列 + chunks_pending = [] + + for offset in chunk_offset_list: + chunks_pending.insert( + 0, + self._upload_chunk(offset, chunk_number, total_chunk_count), + ) + chunk_number += 1 + + while chunks_pending: + tasks = [] + + while len(tasks) < self.preupload["threads"] and len(chunks_pending) > 0: + tasks.append(create_task(chunks_pending.pop())) + + result = await asyncio.gather(*tasks) + + for r in result: + if not r["ok"]: + chunks_pending.insert( + 0, + self._upload_chunk( + r["offset"], + r["chunk_number"], + total_chunk_count, + ), + ) + + data = await self._complete_file(total_chunk_count) + + return data + + + + async def _upload_chunk( + self, + offset: int, + chunk_number: int, + total_chunk_count: int, + ) -> dict: + """ + 上传视频分块 + + Args: + offset (int): 分块起始位置 + + chunk_number (int): 分块编号 + + total_chunk_count (int): 总分块数 + + + Returns: + dict: 上传结果和分块信息。 + """ + chunk_event_callback_data = { + "offset": offset, + "chunk_number": chunk_number, + "total_chunk_count": total_chunk_count, + } + + stream = open(self.file.path, "rb") + stream.seek(offset) + chunk = stream.read(self.preupload["chunk_size"]) + stream.close() + + err_return = { + "ok": False, + "chunk_number": chunk_number, + "offset": offset, + } + + real_chunk_size = len(chunk) + + params = { + "partNumber": str(chunk_number + 1), + "uploadId": str(self._upload_id), + "chunk": str(chunk_number), + "chunks": str(total_chunk_count), + "size": str(real_chunk_size), + "start": str(offset), + "end": str(offset + real_chunk_size), + "total": self.file.size, + } + + ok_return = { + "ok": True, + "chunk_number": chunk_number, + "offset": offset, + } + + try: + resp = await self._session.put( + self._upload_url, + data=chunk, # type: ignore + params=params, + headers={"x-upos-auth": self.preupload["auth"]}, + ) + if resp.status_code >= 400: + chunk_event_callback_data["info"] = f"Status {resp.status_code}" + return err_return + + data = resp.text + + if data != "MULTIPART_PUT_SUCCESS" and data != "": + chunk_event_callback_data["info"] = "分块上传失败" + return err_return + + except Exception as e: + chunk_event_callback_data["info"] = str(e) + return err_return + + return ok_return + + async def _complete_file(self, chunks: int) -> dict: + """ + 提交文件 + + Args: + chunks (int): 分块数量 + + Returns: + dict: filename: 该分 P 的标识符,用于最后提交视频。cid: 分 P 的 cid + """ + + data = { + "parts": list( + map(lambda x: {"partNumber": x, "eTag": "etag"}, range(1, chunks + 1)) + ) + } + + params = { + "output": "json", + "name": os.path.basename(os.path.split(self.file.path)[1]), + "profile": "ugcfx/bup", + "uploadId": self._upload_id, + "biz_id": self.preupload["biz_id"], + } + + resp = await self._session.post( + url=self._upload_url, + data=json.dumps(data), # type: ignore + headers={ + "x-upos-auth": self.preupload["auth"], + "content-type": "application/json; charset=UTF-8", + }, + params=params, + ) + if resp.status_code >= 400: + err = NetworkException(resp.status_code, "状态码错误,提交分 P 失败") + raise err + + data = json.loads(resp.read()) + + if data["OK"] != 1: + err = ResponseCodeException(-1, f'提交分 P 失败,原因: {data["message"]}') + raise err \ No newline at end of file diff --git a/bilibili_api/utils/utils.py b/bilibili_api/utils/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..bab054ad4b1510017aa71dc21e6e96d0a979e5c2 --- /dev/null +++ b/bilibili_api/utils/utils.py @@ -0,0 +1,212 @@ +""" +bilibili_api.utils.utils + +通用工具库。 +""" + +import json +import os +import random +from typing import List, TypeVar +from ..exceptions import StatementException + + +def get_api(field: str, *args) -> dict: + """ + 获取 API。 + + Args: + field (str): API 所属分类,即 data/api 下的文件名(不含后缀名) + + Returns: + dict, 该 API 的内容。 + """ + path = os.path.abspath( + os.path.join( + os.path.dirname(__file__), "..", "data", "api", f"{field.lower()}.json" + ) + ) + if os.path.exists(path): + with open(path, encoding="utf8") as f: + data = json.load(f) + for arg in args: + data = data[arg] + return data + else: + return {} + + +def crack_uid(crc32: str): + """ + 弹幕中的 CRC32 ID 转换成用户 UID。 + + 警告,破解后的 UID 不一定准确,有存在误差,仅供参考。 + + 代码翻译自:https://github.com/esterTion/BiliBili_crc2mid。 + + Args: + crc32 (str): crc32 计算摘要后的 UID。 + + Returns: + int, 真实用户 UID,不一定准确。 + """ + __CRCPOLYNOMIAL = 0xEDB88320 + __crctable = [None] * 256 + __index = [None] * 4 + + def __create_table(): + for i in range(256): + crcreg = i + for j in range(8): + if (crcreg & 1) != 0: + crcreg = __CRCPOLYNOMIAL ^ (crcreg >> 1) + else: + crcreg >>= 1 + __crctable[i] = crcreg + + __create_table() + + def __crc32(input_): + if type(input_) != str: + input_ = str(input_) + crcstart = 0xFFFFFFFF + len_ = len(input_) + for i in range(len_): + index = (crcstart ^ ord(input_[i])) & 0xFF + crcstart = (crcstart >> 8) ^ __crctable[index] + return crcstart + + def __crc32lastindex(input_): + if type(input_) != str: + input_ = str(input_) + crcstart = 0xFFFFFFFF + len_ = len(input_) + index = None + for i in range(len_): + index = (crcstart ^ ord(input_[i])) & 0xFF + crcstart = (crcstart >> 8) ^ __crctable[index] + return index + + def __getcrcindex(t): + for i in range(256): + if __crctable[i] >> 24 == t: + return i + return -1 + + def __deepCheck(i, index): + tc = 0x00 + str_ = "" + hash_ = __crc32(i) + tc = hash_ & 0xFF ^ index[2] + if not (57 >= tc >= 48): + return [0] + str_ += str(tc - 48) + hash_ = __crctable[index[2]] ^ (hash_ >> 8) + + tc = hash_ & 0xFF ^ index[1] + if not (57 >= tc >= 48): + return [0] + str_ += str(tc - 48) + hash_ = __crctable[index[1]] ^ (hash_ >> 8) + + tc = hash_ & 0xFF ^ index[0] + if not (57 >= tc >= 48): + return [0] + str_ += str(tc - 48) + hash_ = __crctable[index[0]] ^ (hash_ >> 8) + + return [1, str_] + + ht = int(crc32, 16) ^ 0xFFFFFFFF + i = 3 + while i >= 0: + __index[3 - i] = __getcrcindex(ht >> (i * 8)) + # pylint: disable=invalid-sequence-index + snum = __crctable[__index[3 - i]] + ht ^= snum >> ((3 - i) * 8) + i -= 1 + for i in range(10000000): + lastindex = __crc32lastindex(i) + if lastindex == __index[3]: + deepCheckData = __deepCheck(i, __index) + if deepCheckData[0]: + break + if i == 10000000: + return -1 + return str(i) + deepCheckData[1] + + +def join(seperator: str, array: list): + """ + 用指定字符连接数组 + + Args: + seperator (str) : 分隔字符 + + array (list): 数组 + + Returns: + str: 连接结果 + """ + return seperator.join(map(lambda x: str(x), array)) + + +ChunkT = TypeVar("ChunkT", List, List) + + +def chunk(arr: ChunkT, size: int) -> List[ChunkT]: + if size <= 0: + raise Exception('Parameter "size" must greater than 0') + + result = [] + temp = [] + + for i in range(len(arr)): + temp.append(arr[i]) + + if i % size == size - 1: + result.append(temp) + temp = [] + + if temp: + result.append(temp) + + return result + + +def get_deviceid(separator: str = "-", is_lowercase: bool = False) -> str: + """ + 获取随机 deviceid (dev_id) + + Args: + separator (str) : 分隔符 默认为 "-" + + is_lowercase (bool) : 是否以小写形式 默认为False + + 参考: https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/message/private_msg.md#发送私信web端 + + Returns: + str: device_id + """ + template = ["xxxxxxxx", "xxxx", "4xxx", "yxxx", "xxxxxxxxxxxx"] + dev_id_group = [] + for i in range(len(template)): + s = "" + group = template[i] + for k in group: + rand: int = int(16 * random.random()) + if k in "xy": + if k == "x": + s += hex(rand)[2:] + else: + s += hex(3 & rand | 8)[2:] + else: + s += "4" + dev_id_group.append(s) + res = join(separator, dev_id_group) + return res if is_lowercase else res.upper() + + +def raise_for_statement(statement: bool, msg: str="未满足条件") -> None: + if not statement: + raise StatementException(msg=msg) diff --git a/bilibili_api/utils/varint.py b/bilibili_api/utils/varint.py new file mode 100644 index 0000000000000000000000000000000000000000..069ba375b13ebd64589ca14054b8a4f2a7860cfa --- /dev/null +++ b/bilibili_api/utils/varint.py @@ -0,0 +1,32 @@ +""" +bilibili_api.utils.varint + +变长数字字节相关。 +""" + +from typing import Tuple + + +def read_varint(stream: bytes) -> Tuple[int, int]: + """ + 读取 varint。 + + Args: + stream (bytes): 字节流。 + + Returns: + Tuple[int, int],真实值和占用长度。 + """ + value = 0 + position = 0 + shift = 0 + while True: + if position >= len(stream): + break + byte = stream[position] + value += (byte & 0b01111111) << shift + if byte & 0b10000000 == 0: + break + position += 1 + shift += 7 + return value, position + 1 diff --git a/bilibili_api/video.py b/bilibili_api/video.py new file mode 100644 index 0000000000000000000000000000000000000000..a15bbaa6efd301c9fe6b7586fa47f033fb519ab1 --- /dev/null +++ b/bilibili_api/video.py @@ -0,0 +1,2517 @@ +""" +bilibili_api.video + +视频相关操作 + +注意,同时存在 page_index 和 cid 的参数,两者至少提供一个。 +""" + +import re +import os +import json +import struct +import asyncio +import logging +import datetime +from enum import Enum +from inspect import isfunction +from functools import cmp_to_key +from dataclasses import dataclass +from typing import Any, List, Union, Optional + +import httpx +import aiohttp + +from . import settings +from .utils.aid_bvid_transformer import bvid2aid, aid2bvid +from .utils.utils import get_api +from .utils.AsyncEvent import AsyncEvent +from .utils.credential import Credential +from .utils.BytesReader import BytesReader +from .utils.danmaku import Danmaku, SpecialDanmaku +from .utils.network import get_aiohttp_session, Api, get_session +from .exceptions import ( + ArgsException, + NetworkException, + ResponseException, + DanmakuClosedException, +) + +API = get_api("video") + + +def get_cid_info_sync(cid: int): + """ + 获取 cid 信息 (对应的视频,具体分 P 序号,up 等) + + Returns: + dict: 调用 https://hd.biliplus.com 的 API 返回的结果 + """ + api = API["info"]["cid_info"] + params = {"cid": cid} + return Api(**api).update_params(**params).result_sync + + +async def get_cid_info(cid: int): + """ + 获取 cid 信息 (对应的视频,具体分 P 序号,up 等) + + Returns: + dict: 调用 https://hd.biliplus.com 的 API 返回的结果 + """ + api = API["info"]["cid_info"] + params = {"cid": cid} + return await Api(**api).update_params(**params).result + + +class DanmakuOperatorType(Enum): + """ + 弹幕操作枚举 + + + DELETE - 删除弹幕 + + PROTECT - 保护弹幕 + + UNPROTECT - 取消保护弹幕 + """ + + DELETE = 1 + PROTECT = 2 + UNPROTECT = 3 + + +class VideoAppealReasonType: + """ + 视频投诉原因枚举 + + 注意: 每一项均为函数,部分项有参数,没有参数的函数无需调用函数,直接传入即可,有参数的函数请调用结果之后传入。 + + - ILLEGAL(): 违法违禁 + - PRON(): 色情 + - VULGAR(): 低俗 + - GAMBLED_SCAMS(): 赌博诈骗 + - VIOLENT(): 血腥暴力 + - PERSONAL_ATTACK(): 人身攻击 + - PLAGIARISM(bvid: str): 与站内其他视频撞车 + - BAD_FOR_YOUNGS(): 青少年不良信息 + - CLICKBAIT(): 不良封面/标题 + - POLITICAL_RUMORS(): 涉政谣言 + - SOCIAL_RUMORS(): 涉社会事件谣言 + - COVID_RUMORS(): 疫情谣言 + - UNREAL_EVENT(): 虚假不实消息 + - OTHER(): 有其他问题 + - LEAD_WAR(): 引战 + - CANNOT_CHARGE(): 不能参加充电 + - UNREAL_COPYRIGHT(source: str): 转载/自制类型错误 + """ + + ILLEGAL = lambda: 2 + PRON = lambda: 3 + VULGAR = lambda: 4 + GAMBLED_SCAMS = lambda: 5 + VIOLENT = lambda: 6 + PERSONAL_ATTACK = lambda: 7 + BAD_FOR_YOUNGS = lambda: 10000 + CLICKBAIT = lambda: 10013 + POLITICAL_RUMORS = lambda: 10014 + SOCIAL_RUMORS = lambda: 10015 + COVID_RUMORS = lambda: 10016 + UNREAL_EVENT = lambda: 10017 + OTHER = lambda: 1 + LEAD_WAR = lambda: 9 + CANNOT_CHARGE = lambda: 10 + + @staticmethod + def PLAGIARISM(bvid: str): + """ + 与站内其他视频撞车 + + Args: + bvid (str): 撞车对象 + """ + return {"tid": 8, "撞车对象": bvid} + + @staticmethod + def UNREAL_COPYRIGHT(source: str): + """ + 转载/自制类型错误 + + Args: + source (str): 原创视频出处 + """ + return {"tid": 52, "出处": source} + + +class Video: + """ + 视频类,各种对视频的操作均在里面。 + """ + + def __init__( + self, + bvid: Union[None, str] = None, + aid: Union[None, int] = None, + credential: Union[None, Credential] = None, + ): + """ + Args: + bvid (str | None, optional) : BV 号. bvid 和 aid 必须提供其中之一。 + + aid (int | None, optional) : AV 号. bvid 和 aid 必须提供其中之一。 + + credential (Credential | None, optional): Credential 类. Defaults to None. + """ + # ID 检查 + if bvid is not None: + self.set_bvid(bvid) + elif aid is not None: + self.set_aid(aid) + else: + # 未提供任一 ID + raise ArgsException("请至少提供 bvid 和 aid 中的其中一个参数。") + + # 未提供 credential 时初始化该类 + self.credential: Credential = Credential() if credential is None else credential + + # 用于存储视频信息,避免接口依赖视频信息时重复调用 + self.__info: Union[dict, None] = None + + def set_bvid(self, bvid: str) -> None: + """ + 设置 bvid。 + + Args: + bvid (str): 要设置的 bvid。 + """ + # 检查 bvid 是否有效 + if not re.search("^BV[a-zA-Z0-9]{10}$", bvid): + raise ArgsException("bvid 提供错误,必须是以 BV 开头的纯字母和数字组成的 12 位字符串(大小写敏感)。") + self.__bvid = bvid + self.__aid = bvid2aid(bvid) + + def get_bvid(self) -> str: + """ + 获取 BVID。 + + Returns: + str: BVID。 + """ + return self.__bvid + + def set_aid(self, aid: int) -> None: + """ + 设置 aid。 + + Args: + aid (int): AV 号。 + """ + if aid <= 0: + raise ArgsException("aid 不能小于或等于 0。") + + self.__aid = aid + self.__bvid = aid2bvid(aid) + + def get_aid(self) -> int: + """ + 获取 AID。 + + Returns: + int: aid。 + """ + return self.__aid + + async def get_info(self) -> dict: + """ + 获取视频信息。 + + Returns: + dict: 调用 API 返回的结果。 + """ + api = API["info"]["info"] + params = {"bvid": self.get_bvid(), "aid": self.get_aid()} + resp = ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + # 存入 self.__info 中以备后续调用 + self.__info = resp + return resp + + async def get_detail(self) -> dict: + """ + 获取视频详细信息 + + Returns: + dict: 调用 API 返回的结果。 + """ + api = API["info"]["detail"] + params = { + "bvid": self.get_bvid(), + "aid": self.get_aid(), + "need_operation_card": 0, + "need_elec": 0, + } + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def __get_info_cached(self) -> dict: + """ + 获取视频信息,如果已获取过则使用之前获取的信息,没有则重新获取。 + + Returns: + dict: 调用 API 返回的结果。 + """ + if self.__info is None: + return await self.get_info() + return self.__info + + # get_stat 403/404 https://github.com/SocialSisterYi/bilibili-API-collect/issues/797 等恢复 + # async def get_stat(self) -> dict: + # """ + # 获取视频统计数据(播放量,点赞数等)。 + + # Returns: + # dict: 调用 API 返回的结果。 + # """ + # api = API["info"]["stat"] + # params = {"bvid": self.get_bvid(), "aid": self.get_aid()} + # return await Api(**api, credential=self.credential).update_params(**params).result + + async def get_up_mid(self) -> int: + """ + 获取视频 up 主的 mid。 + + Returns: + int: up_mid + """ + info = await self.__get_info_cached() + return info["owner"]["mid"] + + async def get_tags( + self, page_index: Union[int, None] = 0, cid: Union[int, None] = None + ) -> List[dict]: + """ + 获取视频标签。 + + Args: + page_index (int | None): 分 P 序号. Defaults to 0. + + cid (int | None): 分 P 编码. Defaults to None. + + Returns: + List[dict]: 调用 API 返回的结果。 + """ + if cid == None: + if page_index == None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.get_cid(page_index=page_index) + api = API["info"]["tags"] + params = {"bvid": self.get_bvid(), "aid": self.get_aid(), "cid": cid} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def get_chargers(self) -> dict: + """ + 获取视频充电用户。 + + Returns: + dict: 调用 API 返回的结果。 + """ + info = await self.__get_info_cached() + mid = info["owner"]["mid"] + api = API["info"]["chargers"] + params = {"aid": self.get_aid(), "bvid": self.get_bvid(), "mid": mid} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def get_pages(self) -> List[dict]: + """ + 获取分 P 信息。 + + Returns: + dict: 调用 API 返回的结果。 + """ + api = API["info"]["pages"] + params = {"aid": self.get_aid(), "bvid": self.get_bvid()} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def __get_cid_by_index(self, page_index: int) -> int: + """ + 根据分 p 号获取 cid。 + + Args: + page_index (int): 分 P 号,从 0 开始。 + + Returns: + int: cid 分 P 的唯一 ID。 + """ + if page_index < 0: + raise ArgsException("分 p 号必须大于或等于 0。") + + info = await self.__get_info_cached() + pages = info["pages"] + + if len(pages) <= page_index: + raise ArgsException("不存在该分 p。") + + page = pages[page_index] + cid = page["cid"] + return cid + + async def get_video_snapshot( + self, + cid: Union[int, None] = None, + json_index: bool = False, + pvideo: bool = True, + ) -> dict: + """ + 获取视频快照(视频各个时间段的截图拼图) + + Args: + cid(int): 分 P CID(可选) + + json_index(bool): json 数组截取时间表 True 为需要,False 不需要 + + pvideo(bool): 是否只获取预览 + + Returns: + dict: 调用 API 返回的结果,数据中 Url 没有 http 头 + """ + params: dict[str, Any] = {"aid": self.get_aid()} + if pvideo: + api = API["info"]["video_snapshot_pvideo"] + else: + params["bvid"] = self.get_bvid() + if json_index: + params["index"] = 1 + if cid: + params["cid"] = cid + api = API["info"]["video_snapshot"] + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def get_cid(self, page_index: int) -> int: + """ + 获取稿件 cid + + Args: + page_index(int): 分 P + + Returns: + int: cid + """ + return await self.__get_cid_by_index(page_index) + + async def get_download_url( + self, + page_index: Union[int, None] = None, + cid: Union[int, None] = None, + html5: bool = False, + ) -> dict: + """ + 获取视频下载信息。 + + 返回结果可以传入 `VideoDownloadURLDataDetecter` 进行解析。 + + page_index 和 cid 至少提供其中一个,其中 cid 优先级最高 + + Args: + page_index (int | None, optional) : 分 P 号,从 0 开始。Defaults to None + + cid (int | None, optional) : 分 P 的 ID。Defaults to None + + html5 (bool, optional): 是否以 html5 平台访问,这样子能直接在网页中播放,但是链接少。 + + Returns: + dict: 调用 API 返回的结果。 + """ + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + api = API["info"]["playurl"] + if html5: + params = { + "avid": self.get_aid(), + "cid": cid, + "qn": "127", + "otype": "json", + "fnval": 4048, + "fourk": 1, + "platform": "html5", + "high_quality": "1", + } + else: + params = { + "avid": self.get_aid(), + "cid": cid, + "qn": "127", + "otype": "json", + "fnval": 4048, + "fourk": 1, + } + result = ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + result.update({"is_html5": True} if html5 else {}) + return result + + async def get_related(self) -> dict: + """ + 获取相关视频信息。 + + Returns: + dict: 调用 API 返回的结果。 + """ + api = API["info"]["related"] + params = {"aid": self.get_aid(), "bvid": self.get_bvid()} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def get_relation(self) -> dict: + """ + 获取用户与视频的关系 + + Returns: + dict: 调用 API 返回的结果。 + """ + self.credential.raise_for_no_sessdata() + api = API["info"]["relation"] + params = {"aid": self.get_aid(), "bvid": self.get_bvid()} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def has_liked(self) -> bool: + """ + 视频是否点赞过。 + + Returns: + bool: 视频是否点赞过。 + """ + self.credential.raise_for_no_sessdata() + + api = API["info"]["has_liked"] + params = {"bvid": self.get_bvid(), "aid": self.get_aid()} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def get_pay_coins(self) -> int: + """ + 获取视频已投币数量。 + + Returns: + int: 视频已投币数量。 + """ + self.credential.raise_for_no_sessdata() + + api = API["info"]["get_pay_coins"] + params = {"bvid": self.get_bvid(), "aid": self.get_aid()} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + )["multiply"] + + async def has_favoured(self) -> bool: + """ + 是否已收藏。 + + Returns: + bool: 视频是否已收藏。 + """ + self.credential.raise_for_no_sessdata() + + api = API["info"]["has_favoured"] + params = {"bvid": self.get_bvid(), "aid": self.get_aid()} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + )["favoured"] + + async def is_forbid_note(self) -> bool: + """ + 是否禁止笔记。 + + Returns: + bool: 是否禁止笔记。 + """ + api = API["info"]["is_forbid"] + params = {"aid": self.get_aid()} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + )["forbid_note_entrance"] + + async def get_private_notes_list(self) -> list: + """ + 获取稿件私有笔记列表。 + + Returns: + list: note_Ids。 + """ + self.credential.raise_for_no_sessdata() + + api = API["info"]["private_notes"] + params = {"oid": self.get_aid(), "oid_type": 0} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + )["noteIds"] + + async def get_public_notes_list(self, pn: int, ps: int) -> dict: + """ + 获取稿件公开笔记列表。 + + Args: + pn (int): 页码 + + ps (int): 每页项数 + + Returns: + dict: 调用 API 返回的结果。 + """ + + api = API["info"]["public_notes"] + params = {"oid": self.get_aid(), "oid_type": 0, "pn": pn, "ps": ps} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def get_ai_conclusion(self, cid: Optional[int] = None, page_index: Optional[int] = None, + up_mid: Optional[int] = None) -> dict: + """ + 获取稿件 AI 总结结果。 + + cid 和 page_index 至少提供其中一个,其中 cid 优先级最高 + + Args: + cid (Optional, int): 分 P 的 cid。 + + page_index (Optional, int): 分 P 号,从 0 开始。 + + up_mid (Optional, int): up 主的 mid。 + + Returns: + dict: 调用 API 返回的结果。 + """ + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + api = API["info"]["ai_conclusion"] + params = {"aid": self.get_aid(), "bvid": self.get_bvid(), "cid": cid, + "up_mid": await self.get_up_mid() if up_mid is None else up_mid} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def get_danmaku_view( + self, page_index: Union[int, None] = None, cid: Union[int, None] = None + ) -> dict: + """ + 获取弹幕设置、特殊弹幕、弹幕数量、弹幕分段等信息。 + + Args: + page_index (int, optional): 分 P 号,从 0 开始。Defaults to None + + cid (int, optional): 分 P 的 ID。Defaults to None + + Returns: + dict: 调用 API 返回的结果。 + """ + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + session = get_session() + api = API["danmaku"]["view"] + + config = {} + config["url"] = api["url"] + config["params"] = {"type": 1, "oid": cid, "pid": self.get_aid()} + config["cookies"] = self.credential.get_cookies() + config["headers"] = { + "Referer": "https://www.bilibili.com", + "User-Agent": "Mozilla/5.0", + } + + try: + resp = await session.get(**config) + except Exception as e: + raise NetworkException(-1, str(e)) + + resp_data = resp.read() + json_data = {} + reader = BytesReader(resp_data) + + # 解析二进制数据流 + + def read_dm_seg(stream: bytes): + reader_ = BytesReader(stream) + data = {} + while not reader_.has_end(): + t = reader_.varint() >> 3 + if t == 1: + data["page_size"] = reader_.varint() + elif t == 2: + data["total"] = reader_.varint() + else: + continue + return data + + def read_flag(stream: bytes): + reader_ = BytesReader(stream) + data = {} + while not reader_.has_end(): + t = reader_.varint() >> 3 + if t == 1: + data["rec_flag"] = reader_.varint() + elif t == 2: + data["rec_text"] = reader_.string() + elif t == 3: + data["rec_switch"] = reader_.varint() + else: + continue + return data + + def read_command_danmakus(stream: bytes): + reader_ = BytesReader(stream) + data = {} + while not reader_.has_end(): + t = reader_.varint() >> 3 + if t == 1: + data["id"] = reader_.varint() + elif t == 2: + data["oid"] = reader_.varint() + elif t == 3: + data["mid"] = reader_.varint() + elif t == 4: + data["commend"] = reader_.string() + elif t == 5: + data["content"] = reader_.string() + elif t == 6: + data["progress"] = reader_.varint() + elif t == 7: + data["ctime"] = reader_.string() + elif t == 8: + data["mtime"] = reader_.string() + elif t == 9: + data["extra"] = json.loads(reader_.string()) + + elif t == 10: + data["id_str"] = reader_.string() + else: + continue + return data + + def read_settings(stream: bytes): + reader_ = BytesReader(stream) + data = {} + while not reader_.has_end(): + t = reader_.varint() >> 3 + + if t == 1: + data["dm_switch"] = reader_.bool() + elif t == 2: + data["ai_switch"] = reader_.bool() + elif t == 3: + data["ai_level"] = reader_.varint() + elif t == 4: + data["enable_top"] = reader_.bool() + elif t == 5: + data["enable_scroll"] = reader_.bool() + elif t == 6: + data["enable_bottom"] = reader_.bool() + elif t == 7: + data["enable_color"] = reader_.bool() + elif t == 8: + data["enable_special"] = reader_.bool() + elif t == 9: + data["prevent_shade"] = reader_.bool() + elif t == 10: + data["dmask"] = reader_.bool() + elif t == 11: + data["opacity"] = reader_.float(True) + elif t == 12: + data["dm_area"] = reader_.varint() + elif t == 13: + data["speed_plus"] = reader_.float(True) + elif t == 14: + data["font_size"] = reader_.float(True) + elif t == 15: + data["screen_sync"] = reader_.bool() + elif t == 16: + data["speed_sync"] = reader_.bool() + elif t == 17: + data["font_family"] = reader_.string() + elif t == 18: + data["bold"] = reader_.bool() + elif t == 19: + data["font_border"] = reader_.varint() + elif t == 20: + data["draw_type"] = reader_.string() + else: + continue + return data + + def read_image_danmakus(string: bytes): + image_list = [] + reader_ = BytesReader(string) + while not reader_.has_end(): + type_ = reader_.varint() >> 3 + if type_ == 1: + details_dict: dict[Any, Any] = {"texts": []} + img_details = reader_.bytes_string() + reader_details = BytesReader(img_details) + while not reader_details.has_end(): + type_details = reader_details.varint() >> 3 + if type_details == 1: + details_dict["texts"].append(reader_details.string()) + elif type_details == 2: + details_dict["image"] = reader_details.string() + elif type_details == 3: + id_string = reader_details.bytes_string() + id_reader = BytesReader(id_string) + while not id_reader.has_end(): + type_id = id_reader.varint() >> 3 + if type_id == 2: + details_dict["id"] = id_reader.varint() + else: + raise ResponseException("解析响应数据错误") + image_list.append(details_dict) + else: + raise ResponseException("解析响应数据错误") + return image_list + + while not reader.has_end(): + type_ = reader.varint() >> 3 + + if type_ == 1: + json_data["state"] = reader.varint() + elif type_ == 2: + json_data["text"] = reader.string() + elif type_ == 3: + json_data["text_side"] = reader.string() + elif type_ == 4: + json_data["dm_seg"] = read_dm_seg(reader.bytes_string()) + elif type_ == 5: + json_data["flag"] = read_flag(reader.bytes_string()) + elif type_ == 6: + if "special_dms" not in json_data: + json_data["special_dms"] = [] + json_data["special_dms"].append(reader.string()) + elif type_ == 7: + json_data["check_box"] = reader.bool() + elif type_ == 8: + json_data["count"] = reader.varint() + elif type_ == 9: + if "command_dms" not in json_data: + json_data["command_dms"] = [] + json_data["command_dms"].append( + read_command_danmakus(reader.bytes_string()) + ) + elif type_ == 10: + json_data["dm_setting"] = read_settings(reader.bytes_string()) + elif type_ == 12: + json_data["image_dms"] = read_image_danmakus(reader.bytes_string()) + else: + continue + return json_data + + async def get_danmakus( + self, + page_index: int = 0, + date: Union[datetime.date, None] = None, + cid: Union[int, None] = None, + from_seg: Union[int, None] = None, + to_seg: Union[int, None] = None, + ) -> List[Danmaku]: + """ + 获取弹幕。 + + Args: + page_index (int, optional): 分 P 号,从 0 开始。Defaults to None + + date (datetime.Date | None, optional): 指定日期后为获取历史弹幕,精确到年月日。Defaults to None. + + cid (int | None, optional): 分 P 的 ID。Defaults to None + + from_seg (int, optional): 从第几段开始(0 开始编号,None 为从第一段开始,一段 6 分钟). Defaults to None. + + to_seg (int, optional): 到第几段结束(0 开始编号,None 为到最后一段,包含编号的段,一段 6 分钟). Defaults to None. + + 注意: + - 1. 段数可以使用 `get_danmaku_view()["dm_seg"]["total"]` 查询。 + - 2. `from_seg` 和 `to_seg` 仅对 `date == None` 的时候有效果。 + - 3. 例:取前 `12` 分钟的弹幕:`from_seg=0, to_seg=1` + + Returns: + List[Danmaku]: Danmaku 类的列表。 + """ + if date is not None: + self.credential.raise_for_no_sessdata() + + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + session = get_session() + aid = self.get_aid() + params: dict[str, Any] = {"oid": cid, "type": 1, "pid": aid} + if date is not None: + # 获取历史弹幕 + api = API["danmaku"]["get_history_danmaku"] + params["date"] = date.strftime("%Y-%m-%d") + params["type"] = 1 + from_seg = to_seg = 0 + else: + api = API["danmaku"]["get_danmaku"] + if from_seg == None: + from_seg = 0 + if to_seg == None: + view = await self.get_danmaku_view(cid=cid) + to_seg = view["dm_seg"]["total"] - 1 + + danmakus = [] + + for seg in range(from_seg, to_seg + 1): + if date is None: + # 仅当获取当前弹幕时需要该参数 + params["segment_index"] = seg + 1 + + config = {} + config["url"] = api["url"] + config["params"] = params + config["headers"] = { + "Referer": "https://www.bilibili.com", + "User-Agent": "Mozilla/5.0", + } + config["cookies"] = self.credential.get_cookies() + + try: + req = await session.get(**config) + except Exception as e: + raise NetworkException(-1, str(e)) + + if "content-type" not in req.headers.keys(): + break + else: + content_type = req.headers["content-type"] + if content_type != "application/octet-stream": + raise ResponseException("返回数据类型错误:") + + # 解析二进制流数据 + data = req.read() + if data == b"\x10\x01": + # 视频弹幕被关闭 + raise DanmakuClosedException() + + reader = BytesReader(data) + while not reader.has_end(): + type_ = reader.varint() >> 3 + if type_ != 1: + if type_ == 4: + reader.bytes_string() + # 什么鬼?我用 protoc 解析出乱码! + elif type_ == 5: + # 大会员专属颜色 + reader.varint() + reader.varint() + reader.varint() + reader.bytes_string() + elif type_ == 13: + # ??? + continue + else: + raise ResponseException("解析响应数据错误") + + dm = Danmaku("") + dm_pack_data = reader.bytes_string() + dm_reader = BytesReader(dm_pack_data) + + while not dm_reader.has_end(): + data_type = dm_reader.varint() >> 3 + + if data_type == 1: + dm.id_ = dm_reader.varint() + elif data_type == 2: + dm.dm_time = dm_reader.varint() / 1000 + elif data_type == 3: + dm.mode = dm_reader.varint() + elif data_type == 4: + dm.font_size = dm_reader.varint() + elif data_type == 5: + color = dm_reader.varint() + if color != 60001: + color = hex(color)[2:] + else: + color = "special" + dm.color = color + elif data_type == 6: + dm.crc32_id = dm_reader.string() + elif data_type == 7: + dm.text = dm_reader.string() + elif data_type == 8: + dm.send_time = dm_reader.varint() + elif data_type == 9: + dm.weight = dm_reader.varint() + elif data_type == 10: + dm.action = str(dm_reader.string()) + elif data_type == 11: + dm.pool = dm_reader.varint() + elif data_type == 12: + dm.id_str = dm_reader.string() + elif data_type == 13: + dm.attr = dm_reader.varint() + elif data_type == 14: + dm.uid = dm_reader.varint() + elif data_type == 15: + dm_reader.varint() + elif data_type == 20: + dm_reader.bytes_string() + elif data_type == 21: + dm_reader.bytes_string() + elif data_type == 22: + dm_reader.bytes_string() + elif data_type == 25: + dm_reader.varint() + elif data_type == 26: + dm_reader.varint() + else: + break + danmakus.append(dm) + return danmakus + + async def get_special_dms( + self, page_index: int = 0, cid: Union[int, None] = None + ) -> List[SpecialDanmaku]: + """ + 获取特殊弹幕 + + Args: + page_index (int, optional) : 分 P 号. Defaults to 0. + + cid (int | None, optional): 分 P id. Defaults to None. + + Returns: + List[SpecialDanmaku]: 调用接口解析后的结果 + """ + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + view = await self.get_danmaku_view(cid=cid) + special_dms = view["special_dms"][0] + if settings.proxy != "": + sess = httpx.AsyncClient(proxies={"all://": settings.proxy}) + else: + sess = httpx.AsyncClient() + dm_content = await sess.get(special_dms, cookies=self.credential.get_cookies()) + dm_content.raise_for_status() + reader = BytesReader(dm_content.content) + dms: List[SpecialDanmaku] = [] + while not reader.has_end(): + spec_dm = SpecialDanmaku("") + type_ = reader.varint() >> 3 + if type_ == 1: + reader_ = BytesReader(reader.bytes_string()) + while not reader_.has_end(): + type__ = reader_.varint() >> 3 + if type__ == 1: + spec_dm.id_ = reader_.varint() + elif type__ == 3: + spec_dm.mode = reader_.varint() + elif type__ == 4: + reader_.varint() + elif type__ == 5: + reader_.varint() + elif type__ == 6: + reader_.string() + elif type__ == 7: + spec_dm.content = reader_.string() + elif type__ == 8: + reader_.varint() + elif type__ == 11: + spec_dm.pool = reader_.varint() + elif type__ == 12: + spec_dm.id_str = reader_.string() + else: + continue + else: + continue + dms.append(spec_dm) + return dms + + async def get_history_danmaku_index( + self, + page_index: Union[int, None] = None, + date: Union[datetime.date, None] = None, + cid: Union[int, None] = None, + ) -> Union[None, List[str]]: + """ + 获取特定月份存在历史弹幕的日期。 + + Args: + page_index (int | None, optional): 分 P 号,从 0 开始。Defaults to None + + date (datetime.date | None): 精确到年月. Defaults to None。 + + cid (int | None, optional): 分 P 的 ID。Defaults to None + + Returns: + None | List[str]: 调用 API 返回的结果。不存在时为 None。 + """ + if date is None: + raise ArgsException("请提供 date 参数") + + self.credential.raise_for_no_sessdata() + + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + api = API["danmaku"]["get_history_danmaku_index"] + params = {"oid": cid, "month": date.strftime("%Y-%m"), "type": 1} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def has_liked_danmakus( + self, + page_index: Union[int, None] = None, + ids: Union[List[int], None] = None, + cid: Union[int, None] = None, + ) -> dict: + """ + 是否已点赞弹幕。 + + Args: + page_index (int | None, optional): 分 P 号,从 0 开始。Defaults to None + + ids (List[int] | None): 要查询的弹幕 ID 列表。 + + cid (int | None, optional): 分 P 的 ID。Defaults to None + + Returns: + dict: 调用 API 返回的结果。 + """ + if ids is None or len(ids) == 0: + raise ArgsException("请提供 ids 参数并至少有一个元素") + + self.credential.raise_for_no_sessdata() + + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + api = API["danmaku"]["has_liked_danmaku"] + params = {"oid": cid, "ids": ",".join(ids)} # type: ignore + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def send_danmaku( + self, + page_index: Union[int, None] = None, + danmaku: Union[Danmaku, None] = None, + cid: Union[int, None] = None, + ) -> dict: + """ + 发送弹幕。 + + Args: + page_index (int | None, optional): 分 P 号,从 0 开始。Defaults to None + + danmaku (Danmaku | None) : Danmaku 类。 + + cid (int | None, optional): 分 P 的 ID。Defaults to None + + Returns: + dict: 调用 API 返回的结果。 + """ + + if danmaku is None: + raise ArgsException("请提供 danmaku 参数") + + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + api = API["danmaku"]["send_danmaku"] + + if danmaku.is_sub: + pool = 1 + else: + pool = 0 + data = { + "type": 1, + "oid": cid, + "msg": danmaku.text, + "aid": self.get_aid(), + "bvid": self.get_bvid(), + "progress": int(danmaku.dm_time * 1000), + "color": int(danmaku.color, 16), + "fontsize": danmaku.font_size, + "pool": pool, + "mode": danmaku.mode, + "plat": 1, + } + return await Api(**api, credential=self.credential).update_data(**data).result + + async def get_danmaku_xml( + self, page_index: Union[int, None] = None, cid: Union[int, None] = None + ) -> str: + """ + 获取所有弹幕的 xml 源文件(非装填) + + Args: + page_index (int, optional) : 分 P 序号. Defaults to 0. + + cid (int | None, optional): cid. Defaults to None. + + Return: + xml 文件源 + """ + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + url = f"https://comment.bilibili.com/{cid}.xml" + sess = get_session() + config: dict[Any, Any] = {"url": url} + # 代理 + if settings.proxy: + config["proxies"] = {"all://", settings.proxy} + resp = await sess.get(**config) + return resp.content.decode("utf-8") + + async def like_danmaku( + self, + page_index: Union[int, None] = None, + dmid: Union[int, None] = None, + status: Union[bool, None] = True, + cid: Union[int, None] = None, + ) -> dict: + """ + 点赞弹幕。 + + Args: + page_index (int | None, optional) : 分 P 号,从 0 开始。Defaults to None + + dmid (int | None) : 弹幕 ID。 + + status (bool | None, optional): 点赞状态。Defaults to True + + cid (int | None, optional) : 分 P 的 ID。Defaults to None + + Returns: + dict: 调用 API 返回的结果。 + """ + if dmid is None: + raise ArgsException("请提供 dmid 参数") + + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + api = API["danmaku"]["like_danmaku"] + + data = { + "dmid": dmid, + "oid": cid, + "op": 1 if status else 2, + "platform": "web_player", + } + return await Api(**api, credential=self.credential).update_data(**data).result + + async def get_online(self, cid: Optional[int] = None, page_index: Optional[int] = 0) -> dict: + """ + 获取实时在线人数 + + Returns: + dict: 调用 API 返回的结果。 + """ + api = API["info"]["online"] + params = {"aid": self.get_aid(), "bvid": self.get_bvid(), + "cid": cid if cid is not None else await self.get_cid(page_index=page_index)} + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def operate_danmaku( + self, + page_index: Union[int, None] = None, + dmids: Union[List[int], None] = None, + cid: Union[int, None] = None, + type_: Union[DanmakuOperatorType, None] = None, + ) -> dict: + """ + 操作弹幕 + + Args: + page_index (int | None, optional) : 分 P 号,从 0 开始。Defaults to None + + dmids (List[int] | None) : 弹幕 ID 列表。 + + cid (int | None, optional) : 分 P 的 ID。Defaults to None + + type_ (DanmakuOperatorType | None): 操作类型 + + Returns: + dict: 调用 API 返回的结果。 + """ + + if dmids is None or len(dmids) == 0: + raise ArgsException("请提供 dmid 参数") + + if type_ is None: + raise ArgsException("请提供 type_ 参数") + + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + api = API["danmaku"]["edit_danmaku"] + + data = { + "type": 1, + "dmids": ",".join(map(lambda x: str(x), dmids)), + "oid": cid, + "state": type_.value, + } + + return await Api(**api, credential=self.credential).update_data(**data).result + + async def like(self, status: bool = True) -> dict: + """ + 点赞视频。 + + Args: + status (bool, optional): 点赞状态。Defaults to True. + + Returns: + dict: 调用 API 返回的结果。 + """ + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + api = API["operate"]["like"] + data = {"aid": self.get_aid(), "like": 1 if status else 2} + return await Api(**api, credential=self.credential).update_data(**data).result + + async def pay_coin(self, num: int = 1, like: bool = False) -> dict: + """ + 投币。 + + Args: + num (int, optional) : 硬币数量,为 1 ~ 2 个。Defaults to 1. + + like (bool, optional): 是否同时点赞。Defaults to False. + + Returns: + dict: 调用 API 返回的结果。 + """ + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + if num not in (1, 2): + raise ArgsException("投币数量只能是 1 ~ 2 个。") + + api = API["operate"]["coin"] + data = { + "aid": self.get_aid(), + "bvid": self.get_bvid(), + "multiply": num, + "like": 1 if like else 0, + } + return await Api(**api, credential=self.credential).update_data(**data).result + + async def share(self) -> int: + """ + 分享视频 + + Returns: + int: 当前分享数 + """ + api = API["operate"]["share"] + data = { + "bvid": self.get_bvid(), + "aid": self.get_aid(), + "csrf": self.credential.bili_jct, + } + return await Api(**api, credential=self.credential).update_data(**data).result + + async def triple(self) -> dict: + """ + 给阿婆主送上一键三连 + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["operate"]["yjsl"] + data = {"bvid": self.get_bvid(), "aid": self.get_aid()} + return await Api(**api, credential=self.credential).update_data(**data).result + + async def add_tag(self, name: str) -> dict: + """ + 添加标签。 + + Args: + name (str): 标签名字。 + + Returns: + dict: 调用 API 返回的结果。会返回标签 ID。 + """ + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + api = API["operate"]["add_tag"] + data = {"aid": self.get_aid(), "bvid": self.get_bvid(), "tag_name": name} + return await Api(**api, credential=self.credential).update_data(**data).result + + async def delete_tag(self, tag_id: int) -> dict: + """ + 删除标签。 + + Args: + tag_id (int): 标签 ID。 + + Returns: + dict: 调用 API 返回的结果。 + """ + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + api = API["operate"]["del_tag"] + + data = {"tag_id": tag_id, "aid": self.get_aid(), "bvid": self.get_bvid()} + return await Api(**api, credential=self.credential).update_data(**data).result + + async def appeal(self, reason: Any, detail: str): + """ + 投诉稿件 + + Args: + reason (Any): 投诉类型。传入 VideoAppealReasonType 中的项目即可。 + + detail (str): 详情信息。 + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["operate"]["appeal"] + data = {"aid": self.get_aid(), "desc": detail} + if isfunction(reason): + reason = reason() + if isinstance(reason, int): + reason = {"tid": reason} + data.update(reason) + # XXX: 暂不支持上传附件 + return await Api(**api, credential=self.credential).update_data(**data).result + + async def set_favorite( + self, add_media_ids: List[int] = [], del_media_ids: List[int] = [] + ) -> dict: + """ + 设置视频收藏状况。 + + Args: + add_media_ids (List[int], optional): 要添加到的收藏夹 ID. Defaults to []. + + del_media_ids (List[int], optional): 要移出的收藏夹 ID. Defaults to []. + + Returns: + dict: 调用 API 返回结果。 + """ + if len(add_media_ids) + len(del_media_ids) == 0: + raise ArgsException("对收藏夹无修改。请至少提供 add_media_ids 和 del_media_ids 中的其中一个。") + + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + api = API["operate"]["favorite"] + data = { + "rid": self.get_aid(), + "type": 2, + "add_media_ids": ",".join(map(lambda x: str(x), add_media_ids)), + "del_media_ids": ",".join(map(lambda x: str(x), del_media_ids)), + } + return await Api(**api, credential=self.credential).update_data(**data).result + + async def get_subtitle( + self, + cid: Union[int, None] = None, + ) -> dict: + """ + 获取视频上一次播放的记录,字幕和地区信息。需要分集的 cid, 返回数据中含有json字幕的链接 + + Args: + cid (int | None): 分 P ID,从视频信息中获取 + + Returns: + 调用 API 返回的结果 + """ + if cid is None: + raise ArgsException("需要 cid") + + return (await self.get_player_info(cid=cid)).get("subtitle") + + async def get_player_info( + self, + cid: Union[int, None] = None, + epid: Union[int, None] = None, + ) -> dict: + """ + 获取字幕信息 + + Args: + cid (int | None): 分 P ID,从视频信息中获取 + + epid (int | None): 番剧分集 ID,从番剧信息中获取 + + Returns: + 调用 API 返回的结果 + """ + if cid is None: + raise ArgsException("需要 cid") + api = API["info"]["get_player_info"] + + params = { + "aid": self.get_aid(), + "cid": cid, + } + + if epid: + params["epid"] = epid + + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def submit_subtitle( + self, + lan: str, + data: dict, + submit: bool, + sign: bool, + page_index: Union[int, None] = None, + cid: Union[int, None] = None, + ) -> dict: + """ + 上传字幕 + + 字幕数据 data 参考: + + ```json + { + "font_size": "float: 字体大小,默认 0.4", + "font_color": "str: 字体颜色,默认 \"#FFFFFF\"", + "background_alpha": "float: 背景不透明度,默认 0.5", + "background_color": "str: 背景颜色,默认 \"#9C27B0\"", + "Stroke": "str: 描边,目前作用未知,默认为 \"none\"", + "body": [ + { + "from": "int: 字幕开始时间(秒)", + "to": "int: 字幕结束时间(秒)", + "location": "int: 字幕位置,默认为 2", + "content": "str: 字幕内容" + } + ] + } + ``` + + Args: + lan (str) : 字幕语言代码,参考 https://s1.hdslb.com/bfs/subtitle/subtitle_lan.json + + data (dict) : 字幕数据 + + submit (bool) : 是否提交,不提交为草稿 + + sign (bool) : 是否署名 + + page_index (int | None, optional): 分 P 索引. Defaults to None. + + cid (int | None, optional): 分 P id. Defaults to None. + + Returns: + dict: API 调用返回结果 + + """ + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + api = API["operate"]["submit_subtitle"] + + # lan check,应该是这里面的语言代码 + with open( + os.path.join(os.path.dirname(__file__), "data/subtitle_lan.json"), + encoding="utf-8", + ) as f: + subtitle_lans = json.load(f) + for lan in subtitle_lans: + if lan["lan"] == lan: + break + else: + raise ArgsException("lan 参数错误,请参见 https://s1.hdslb.com/bfs/subtitle/subtitle_lan.json") + + payload = { + "type": 1, + "oid": cid, + "lan": lan, + "data": json.dumps(data), + "submit": submit, + "sign": sign, + "bvid": self.get_bvid(), + } + + return await Api(**api, credential=self.credential).update_data(**payload).result + + async def get_danmaku_snapshot(self) -> dict: + """ + 获取弹幕快照 + + Returns: + 调用 API 返回的结果 + """ + api = API["danmaku"]["snapshot"] + + params = {"aid": self.get_aid()} + + return ( + await Api(**api, credential=self.credential).update_params(**params).result + ) + + async def recall_danmaku( + self, + page_index: Union[int, None] = None, + dmid: int = 0, + cid: Union[int, None] = None, + ) -> dict: + """ + 撤回弹幕 + + Args: + page_index(int | None, optional): 分 P 号 + + dmid(int) : 弹幕 id + + cid(int | None, optional) : 分 P 编码 + Returns: + 调用 API 返回的结果 + """ + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + api = API["danmaku"]["recall"] + data = {"dmid": dmid, "cid": cid} + + return await Api(**api, credential=self.credential).update_data(**data).result + + async def get_pbp( + self, page_index: Union[int, None] = None, cid: Union[int, None] = None + ) -> dict: + """ + 获取高能进度条 + + Args: + page_index(int | None): 分 P 号 + + cid(int | None) : 分 P 编码 + + Returns: + 调用 API 返回的结果 + """ + if cid is None: + if page_index is None: + raise ArgsException("page_index 和 cid 至少提供一个。") + + cid = await self.__get_cid_by_index(page_index) + + api = API["info"]["pbp"] + + params = {"cid": cid} + + session = get_session() + + return json.loads( + ( + await session.get( + api["url"], params=params, cookies=self.credential.get_cookies() + ) + ).text + ) + + async def add_to_toview(self) -> dict: + """ + 添加视频至稍后再看列表 + + Returns: + 调用 API 返回的结果 + """ + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + api = get_api("toview")["operate"]["add"] + datas = { + "aid": self.get_aid(), + } + return await Api(**api, credential=self.credential).update_data(**datas).result + + async def delete_from_toview(self) -> dict: + """ + 从稍后再看列表删除视频 + + Returns: + 调用 API 返回的结果 + """ + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + api = get_api("toview")["operate"]["del"] + datas = {"viewed": "false", "aid": self.get_aid()} + return await Api(**api, credential=self.credential).update_data(**datas).result + + +class VideoOnlineMonitor(AsyncEvent): + """ + 视频在线人数实时监测。 + + 示例代码: + + ```python + import asyncio + from bilibili_api import video + + # 实例化 + r = video.VideoOnlineMonitor("BV1Bf4y1Q7QP") + + # 装饰器方法注册事件监听器 + @r.on("ONLINE") + async def handler(data): + print(data) + + # 函数方法注册事件监听器 + async def handler2(data): + print(data) + + r.add_event_listener("ONLINE", handler2) + + asyncio.get_event_loop().run_until_complete(r.connect()) + + ``` + + Extends: AsyncEvent + + Events: + ONLINE: 在线人数更新。 CallbackData: dict。 + DANMAKU: 收到实时弹幕。 CallbackData: Danmaku。 + DISCONNECTED: 正常断开连接。 CallbackData: None。 + ERROR: 发生错误。 CallbackData: aiohttp.ClientWebSocketResponse。 + CONNECTED: 成功连接。 CallbackData: None。 + """ + + class Datapack(Enum): + """ + 数据包类型枚举。 + + + CLIENT_VERIFY : 客户端发送验证信息。 + + SERVER_VERIFY : 服务端响应验证信息。 + + CLIENT_HEARTBEAT: 客户端发送心跳包。 + + SERVER_HEARTBEAT: 服务端响应心跳包。 + + DANMAKU : 实时弹幕更新。 + """ + + CLIENT_VERIFY = 0x7 + SERVER_VERIFY = 0x8 + CLIENT_HEARTBEAT = 0x2 + SERVER_HEARTBEAT = 0x3 + DANMAKU = 0x3E8 + + def __init__( + self, + bvid: Union[str, None] = None, + aid: Union[int, None] = None, + page_index: int = 0, + credential: Union[Credential, None] = None, + debug: bool = False, + ): + """ + Args: + bvid (str | None, optional) : BVID. Defaults to None. + + aid (int | None, optional) : AID. Defaults to None. + + page_index (int, optional) : 分 P 序号. Defaults to 0. + + credential (Credential | None, optional): Credential 类. Defaults to None. + + debug (bool, optional) : 调试模式,将输出更详细信息. Defaults to False. + """ + super().__init__() + self.credential = credential + self.__video = Video(bvid, aid, credential=credential) + + # 智能选择在 log 中展示的 ID。 + id_showed = None + if bvid is not None: + id_showed = bvid + else: + id_showed = aid + + # logger 初始化 + self.logger = logging.getLogger(f"VideoOnlineMonitor-{id_showed}") + if not self.logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter( + logging.Formatter( + "[" + str(id_showed) + "][%(asctime)s][%(levelname)s] %(message)s" + ) + ) + self.logger.addHandler(handler) + self.logger.setLevel(logging.INFO if not debug else logging.DEBUG) + + self.__page_index = page_index + self.__tasks = [] + + async def connect(self): + """ + 连接服务器 + """ + await self.__main() + + async def disconnect(self): + """ + 断开服务器 + """ + self.logger.info("主动断开连接。") + self.dispatch("DISCONNECTED") + await self.__cancel_all_tasks() + await self.__ws.close() + + async def __main(self): + """ + 入口。 + """ + # 获取分 P id + pages = await self.__video.get_pages() + if self.__page_index >= len(pages): + raise ArgsException("不存在该分 P。") + cid = pages[self.__page_index]["cid"] + + # 获取服务器信息 + self.logger.debug(f"准备连接:{self.__video.get_bvid()}") + self.logger.debug(f"获取服务器信息中...") + + api = API["info"]["video_online_broadcast_servers"] + resp = await Api(**api, credential=self.credential).result + + uri = f"wss://{resp['domain']}:{resp['wss_port']}/sub" + self.__heartbeat_interval = resp["heartbeat"] + self.logger.debug(f"服务器信息获取成功,URI:{uri}") + + # 连接服务器 + self.logger.debug("准备连接服务器...") + session = get_aiohttp_session() + async with session.ws_connect(uri) as ws: + self.__ws = ws + + # 发送认证信息 + self.logger.debug("服务器连接成功,准备发送认证信息...") + verify_info = { + "room_id": f"video://{self.__video.get_aid()}/{cid}", + "platform": "web", + "accepts": [1000, 1015], + } + verify_info = json.dumps(verify_info, separators=(",", ":")) + await ws.send_bytes( + self.__pack( + VideoOnlineMonitor.Datapack.CLIENT_VERIFY, 1, verify_info.encode() + ) + ) + + # 循环接收消息 + async for msg in ws: + if msg.type == aiohttp.WSMsgType.BINARY: + data = self.__unpack(msg.data) + self.logger.debug(f"收到消息:{data}") + await self.__handle_data(data) # type: ignore + + elif msg.type == aiohttp.WSMsgType.ERROR: + self.logger.warning("连接被异常断开") + await self.__cancel_all_tasks() + self.dispatch("ERROR", msg) + break + + async def __handle_data(self, data: List[dict]): + """ + 处理数据。 + + Args: + data (List[dict]): 收到的数据(已解析好)。 + """ + for d in data: + if d["type"] == VideoOnlineMonitor.Datapack.SERVER_VERIFY.value: + # 服务器认证反馈。 + if d["data"]["code"] == 0: + # 创建心跳 Task + heartbeat = asyncio.create_task(self.__heartbeat_task()) + self.__tasks.append(heartbeat) + + self.logger.info("连接服务器并验证成功") + + elif d["type"] == VideoOnlineMonitor.Datapack.SERVER_HEARTBEAT.value: + # 心跳包反馈,同时包含在线人数。 + self.logger.debug(f'收到服务器心跳包反馈,编号:{d["number"]}') + self.logger.info(f'实时观看人数:{d["data"]["data"]["room"]["online"]}') + self.dispatch("ONLINE", d["data"]) + + elif d["type"] == VideoOnlineMonitor.Datapack.DANMAKU.value: + # 实时弹幕。 + info = d["data"][0].split(",") + text = d["data"][1] + if info[5] == "0": + is_sub = False + else: + is_sub = True + dm = Danmaku( + dm_time=float(info[0]), + send_time=int(info[4]), + crc32_id=info[6], + color=info[3], + mode=info[1], + font_size=info[2], + is_sub=is_sub, + text=text, + ) + self.logger.info(f"收到实时弹幕:{dm.text}") + self.dispatch("DANMAKU", dm) + + else: + # 未知类型数据包 + self.logger.warning("收到未知的数据包类型,无法解析:" + json.dumps(d)) + + async def __heartbeat_task(self): + """ + 心跳 Task。 + """ + index = 2 + while True: + self.logger.debug(f"发送心跳包,编号:{index}") + await self.__ws.send_bytes( + self.__pack( + VideoOnlineMonitor.Datapack.CLIENT_HEARTBEAT, + index, + b"[object Object]", + ) + ) + index += 1 + await asyncio.sleep(self.__heartbeat_interval) + + async def __cancel_all_tasks(self): + """ + 取消所有 Task。 + """ + for task in self.__tasks: + task.cancel() + + @staticmethod + def __pack(data_type: Datapack, number: int, data: bytes): + """ + 打包数据。 + + # 数据包格式: + + 16B 头部: + + | offset(bytes) | length(bytes) | type | description | + | ------------- | ------------- | ---- | ------------------- | + | 0 | 4 | I | 数据包长度 | + | 4 | 4 | I | 固定 0x00120001 | + | 8 | 4 | I | 数据包类型 | + | 12 | 4 | I | 递增数据包编号 | + | 16 | 2 | H | 固定 0x0000 | + + 之后是有效载荷。 + + # 数据包类型表: + + + 0x7 客户端发送认证信息 + + 0x8 服务端回应认证结果 + + 0x2 客户端发送心跳包,有效载荷:'[object Object]' + + 0x3 服务端回应心跳包,会带上在线人数等信息,返回 JSON + + 0x3e8 实时弹幕更新,返回列表,[0]弹幕信息,[1]弹幕文本 + + Args: + data_type (VideoOnlineMonitor.DataType): 数据包类型枚举。 + + Returns: + bytes: 打包好的数据。 + """ + packed_data = bytearray() + packed_data += struct.pack(">I", 0x00120001) + packed_data += struct.pack(">I", data_type.value) + packed_data += struct.pack(">I", number) + packed_data += struct.pack(">H", 0) + packed_data += data + packed_data = struct.pack(">I", len(packed_data) + 4) + packed_data + return bytes(packed_data) + + @staticmethod + def __unpack(data: bytes): + """ + 解包数据。 + + Args: + data (bytes): 原始数据。 + + Returns: + tuple(dict): 解包后的数据。 + """ + offset = 0 + real_data = [] + while offset < len(data): + region_header = struct.unpack(">IIII", data[:16]) + region_data = data[offset: offset + region_header[0]] + real_data.append( + { + "type": region_header[2], + "number": region_header[3], + "data": json.loads( + region_data[offset + 18: offset + 18 + (region_header[0] - 16)] + ), + } + ) + offset += region_header[0] + return tuple(real_data) + + +class VideoQuality(Enum): + """ + 视频的视频流分辨率枚举 + + - _360P: 流畅 360P + - _480P: 清晰 480P + - _720P: 高清 720P60 + - _1080P: 高清 1080P + - _1080P_PLUS: 高清 1080P 高码率 + - _1080P_60: 高清 1080P 60 帧码率 + - _4K: 超清 4K + - HDR: 真彩 HDR + - DOLBY: 杜比视界 + - _8K: 超高清 8K + """ + + _360P = 16 + _480P = 32 + _720P = 64 + _1080P = 80 + _1080P_PLUS = 112 + _1080P_60 = 116 + _4K = 120 + HDR = 125 + DOLBY = 126 + _8K = 127 + + +class VideoCodecs(Enum): + """ + 视频的视频流编码枚举 + + - HEV: HEVC(H.265) + - AVC: AVC(H.264) + - AV1: AV1 + """ + + HEV = "hev" + AVC = "avc" + AV1 = "av01" + + +class AudioQuality(Enum): + """ + 视频的音频流清晰度枚举 + + - _64K: 64K + - _132K: 132K + - _192K: 192K + - HI_RES: Hi-Res 无损 + - DOLBY: 杜比全景声 + """ + + _64K = 30216 + _132K = 30232 + DOLBY = 30250 + HI_RES = 30251 + _192K = 30280 + + +@dataclass +class VideoStreamDownloadURL: + """ + (@dataclass) + + 视频流 URL 类 + + Attributes: + url (str) : 视频流 url + video_quality (VideoQuality): 视频流清晰度 + video_codecs (VideoCodecs) : 视频流编码 + """ + + url: str + video_quality: VideoQuality + video_codecs: VideoCodecs + + +@dataclass +class AudioStreamDownloadURL: + """ + (@dataclass) + + 音频流 URL 类 + + Attributes: + url (str) : 音频流 url + audio_quality (AudioQuality): 音频流清晰度 + """ + + url: str + audio_quality: AudioQuality + + +@dataclass +class FLVStreamDownloadURL: + """ + (@dataclass) + + FLV 视频流 + + Attributes: + url (str): FLV 流 url + """ + + url: str + + +@dataclass +class HTML5MP4DownloadURL: + """ + (@dataclass) + + 可供 HTML5 播放的 mp4 视频流 + + Attributes: + url (str): HTML5 mp4 视频流 + """ + + url: str + + +@dataclass +class EpisodeTryMP4DownloadURL: + """ + (@dataclass) + + 番剧/课程试看的 mp4 播放流 + + Attributes: + url (str): 番剧试看的 mp4 播放流 + """ + + url: str + + +class VideoDownloadURLDataDetecter: + """ + `Video.get_download_url` 返回结果解析类。 + + 在调用 `Video.get_download_url` 之后可以将代入 `VideoDownloadURLDataDetecter`,此类将一键解析。 + + 目前支持: + - 视频清晰度: 360P, 480P, 720P, 1080P, 1080P 高码率, 1080P 60 帧, 4K, HDR, 杜比视界, 8K + - 视频编码: HEVC(H.265), AVC(H.264), AV1 + - 音频清晰度: 64K, 132K, Hi-Res 无损音效, 杜比全景声, 192K + - FLV 视频流 + - 番剧/课程试看视频流 + """ + + def __init__(self, data: dict): + """ + Args: + data (dict): `Video.get_download_url` 返回的结果 + """ + self.__data = data + + def check_video_and_audio_stream(self) -> bool: + """ + 判断是否为音视频分离流 + + Returns: + bool: 是否为音视频分离流 + """ + if "dash" in self.__data.keys(): + return True + return False + + def check_flv_stream(self) -> bool: + """ + 判断是否为 FLV 视频流 + + Returns: + bool: 是否为 FLV 视频流 + """ + if "durl" in self.__data.keys(): + if self.__data["format"].startswith("flv"): + return True + return False + + def check_html5_mp4_stream(self) -> bool: + """ + 判断是否为 HTML5 可播放的 mp4 视频流 + + Returns: + bool: 是否为 HTML5 可播放的 mp4 视频流 + """ + if "durl" in self.__data.keys(): + if self.__data["format"].startswith("mp4"): + if self.__data.get("is_html5") == True: + return True + return False + + def check_episode_try_mp4_stream(self): + """ + 判断是否为番剧/课程试看的 mp4 视频流 + + Returns: + bool: 是否为番剧试看的 mp4 视频流 + """ + if "durl" in self.__data.keys(): + if self.__data["format"].startswith("mp4"): + if self.__data.get("is_html5") != True: + return True + return False + + def detect_all(self): + """ + 解析并返回所有数据 + + Returns: + List[VideoStreamDownloadURL | AudioStreamDownloadURL | FLVStreamDownloadURL | HTML5MP4DownloadURL | EpisodeTryMP4DownloadURL]: 所有的视频/音频流 + """ + return self.detect() + + def detect( + self, + video_max_quality: VideoQuality = VideoQuality._8K, + audio_max_quality: AudioQuality = AudioQuality._192K, + video_min_quality: VideoQuality = VideoQuality._360P, + audio_min_quality: AudioQuality = AudioQuality._64K, + video_accepted_qualities: List[VideoQuality] = [ + item + for _, item in VideoQuality.__dict__.items() + if isinstance(item, VideoQuality) + ], + audio_accepted_qualities: List[AudioQuality] = [ + item + for _, item in AudioQuality.__dict__.items() + if isinstance(item, AudioQuality) + ], + codecs: List[VideoCodecs] = [VideoCodecs.AV1, VideoCodecs.AVC, VideoCodecs.HEV], + no_dolby_video: bool = False, + no_dolby_audio: bool = False, + no_hdr: bool = False, + no_hires: bool = False, + ) -> List[ + Union[ + VideoStreamDownloadURL, + AudioStreamDownloadURL, + FLVStreamDownloadURL, + HTML5MP4DownloadURL, + EpisodeTryMP4DownloadURL, + ] + ]: + """ + 解析数据 + + Args: + **以下参数仅能在音视频流分离的情况下产生作用,flv / mp4 试看流 / html5 mp4 流下以下参数均没有作用** + + video_max_quality (VideoQuality, optional) : 设置提取的视频流清晰度最大值,设置此参数绝对不会禁止 HDR/杜比. Defaults to VideoQuality._8K. + + audio_max_quality (AudioQuality, optional) : 设置提取的音频流清晰度最大值. 设置此参数绝对不会禁止 Hi-Res/杜比. Defaults to AudioQuality._192K. + + video_min_quality (VideoQuality, optional) : 设置提取的视频流清晰度最小值,设置此参数绝对不会禁止 HDR/杜比. Defaults to VideoQuality._360P. + + audio_min_quality (AudioQuality, optional) : 设置提取的音频流清晰度最小值. 设置此参数绝对不会禁止 Hi-Res/杜比. Defaults to AudioQuality._64K. + + video_accepted_qualities(List[VideoQuality], optional): 设置允许的所有视频流清晰度. Defaults to ALL. + + audio_accepted_qualities(List[AudioQuality], optional): 设置允许的所有音频清晰度. Defaults to ALL. + + codecs (List[VideoCodecs], optional) : 设置所有允许提取出来的视频编码. 此项不会忽略 HDR/杜比. Defaults to ALL codecs. + + no_dolby_video (bool, optional) : 是否禁止提取杜比视界视频流. Defaults to False. + + no_dolby_audio (bool, optional) : 是否禁止提取杜比全景声音频流. Defaults to False. + + no_hdr (bool, optional) : 是否禁止提取 HDR 视频流. Defaults to False. + + no_hires (bool, optional) : 是否禁止提取 Hi-Res 音频流. Defaults to False. + + Returns: + List[VideoStreamDownloadURL | AudioStreamDownloadURL | FLVStreamDownloadURL | HTML5MP4DownloadURL | EpisodeTryMP4DownloadURL]: 提取出来的视频/音频流 + """ + if "durl" in self.__data.keys(): + if self.__data["format"].startswith("flv"): + # FLV 视频流 + return [FLVStreamDownloadURL(url=self.__data["durl"][0]["url"])] + else: + if self.check_html5_mp4_stream(): + # HTML5 MP4 视频流 + return [HTML5MP4DownloadURL(url=self.__data["durl"][0]["url"])] + else: + # 会员番剧试看 MP4 流 + return [EpisodeTryMP4DownloadURL(url=self.__data["durl"][0]["url"])] + else: + # 正常情况 + streams = [] + videos_data = self.__data["dash"]["video"] + audios_data = self.__data["dash"]["audio"] + flac_data = self.__data["dash"]["flac"] + dolby_data = self.__data["dash"]["dolby"] + for video_data in videos_data: + video_stream_url = video_data["baseUrl"] + video_stream_quality = VideoQuality(video_data["id"]) + if video_stream_quality == VideoQuality.HDR and no_hdr: + continue + if video_stream_quality == VideoQuality.DOLBY and no_dolby_video: + continue + if ( + video_stream_quality != VideoQuality.DOLBY + and video_stream_quality != VideoQuality.HDR + and video_stream_quality.value > video_max_quality.value + ): + continue + if ( + video_stream_quality != VideoQuality.DOLBY + and video_stream_quality != VideoQuality.HDR + and video_stream_quality.value < video_min_quality.value + ): + continue + if ( + video_stream_quality != VideoQuality.DOLBY + and video_stream_quality != VideoQuality.HDR + and (not video_stream_quality in video_accepted_qualities) + ): + continue + video_stream_codecs = None + for val in VideoCodecs: + if val.value in video_data["codecs"]: + video_stream_codecs = val + if (not video_stream_codecs in codecs) and ( + video_stream_codecs != None + ): + continue + video_stream = VideoStreamDownloadURL( + url=video_stream_url, + video_quality=video_stream_quality, + video_codecs=video_stream_codecs, # type: ignore + ) + streams.append(video_stream) + if audios_data: + for audio_data in audios_data: + audio_stream_url = audio_data["baseUrl"] + audio_stream_quality = AudioQuality(audio_data["id"]) + if audio_stream_quality.value > audio_max_quality.value: + continue + if audio_stream_quality.value < audio_min_quality.value: + continue + if not audio_stream_quality in audio_accepted_qualities: + continue + audio_stream = AudioStreamDownloadURL( + url=audio_stream_url, audio_quality=audio_stream_quality + ) + streams.append(audio_stream) + if flac_data and (not no_hires): + if flac_data["audio"]: + flac_stream_url = flac_data["audio"]["baseUrl"] + flac_stream_quality = AudioQuality(flac_data["audio"]["id"]) + flac_stream = AudioStreamDownloadURL( + url=flac_stream_url, audio_quality=flac_stream_quality + ) + streams.append(flac_stream) + if dolby_data and (not no_dolby_audio): + if dolby_data["audio"]: + dolby_stream_data = dolby_data["audio"][0] + dolby_stream_url = dolby_stream_data["baseUrl"] + dolby_stream_quality = AudioQuality(dolby_stream_data["id"]) + dolby_stream = AudioStreamDownloadURL( + url=dolby_stream_url, audio_quality=dolby_stream_quality + ) + streams.append(dolby_stream) + return streams + + def detect_best_streams( + self, + video_max_quality: VideoQuality = VideoQuality._8K, + audio_max_quality: AudioQuality = AudioQuality._192K, + video_min_quality: VideoQuality = VideoQuality._360P, + audio_min_quality: AudioQuality = AudioQuality._64K, + video_accepted_qualities: List[VideoQuality] = [ + item + for _, item in VideoQuality.__dict__.items() + if isinstance(item, VideoQuality) + ], + audio_accepted_qualities: List[AudioQuality] = [ + item + for _, item in AudioQuality.__dict__.items() + if isinstance(item, AudioQuality) + ], + codecs: List[VideoCodecs] = [VideoCodecs.AV1, VideoCodecs.AVC, VideoCodecs.HEV], + no_dolby_video: bool = False, + no_dolby_audio: bool = False, + no_hdr: bool = False, + no_hires: bool = False, + ) -> Union[ + List[FLVStreamDownloadURL], + List[HTML5MP4DownloadURL], + List[EpisodeTryMP4DownloadURL], + List[Union[VideoStreamDownloadURL, AudioStreamDownloadURL, None]], + ]: + """ + 提取出分辨率、音质等信息最好的音视频流。 + + Args: + **以下参数仅能在音视频流分离的情况下产生作用,flv / mp4 试看流 / html5 mp4 流下以下参数均没有作用** + + video_max_quality (VideoQuality) : 设置提取的视频流清晰度最大值,设置此参数绝对不会禁止 HDR/杜比. Defaults to VideoQuality._8K. + + audio_max_quality (AudioQuality) : 设置提取的音频流清晰度最大值. 设置此参数绝对不会禁止 Hi-Res/杜比. Defaults to AudioQuality._192K. + + video_min_quality (VideoQuality, optional) : 设置提取的视频流清晰度最小值,设置此参数绝对不会禁止 HDR/杜比. Defaults to VideoQuality._360P. + + audio_min_quality (AudioQuality, optional) : 设置提取的音频流清晰度最小值. 设置此参数绝对不会禁止 Hi-Res/杜比. Defaults to AudioQuality._64K. + + video_accepted_qualities(List[VideoQuality], optional): 设置允许的所有视频流清晰度. Defaults to ALL. + + audio_accepted_qualities(List[AudioQuality], optional): 设置允许的所有音频清晰度. Defaults to ALL. + + codecs (List[VideoCodecs]) : 设置所有允许提取出来的视频编码. 在数组中越靠前的编码选择优先级越高. 此项不会忽略 HDR/杜比. Defaults to [VideoCodecs.AV1, VideoCodecs.AVC, VideoCodecs.HEV]. + + no_dolby_video (bool) : 是否禁止提取杜比视界视频流. Defaults to False. + + no_dolby_audio (bool) : 是否禁止提取杜比全景声音频流. Defaults to False. + + no_hdr (bool) : 是否禁止提取 HDR 视频流. Defaults to False. + + no_hires (bool) : 是否禁止提取 Hi-Res 音频流. Defaults to False. + + Returns: + List[VideoStreamDownloadURL | AudioStreamDownloadURL | FLVStreamDownloadURL | HTML5MP4DownloadURL | None]: FLV 视频流 / HTML5 MP4 视频流 / 番剧或课程试看 MP4 视频流返回 `[FLVStreamDownloadURL | HTML5MP4StreamDownloadURL | EpisodeTryMP4DownloadURL]`, 否则为 `[VideoStreamDownloadURL, AudioStreamDownloadURL]`, 如果未匹配上任何合适的流则对应的位置位 `None` + """ + if self.check_flv_stream(): + return self.detect_all() # type: ignore + elif self.check_html5_mp4_stream(): + return self.detect_all() # type: ignore + elif self.check_episode_try_mp4_stream(): + return self.detect_all() # type: ignore + else: + data = self.detect( + video_max_quality=video_max_quality, + audio_max_quality=audio_max_quality, + video_min_quality=video_min_quality, + audio_min_quality=audio_min_quality, + video_accepted_qualities=video_accepted_qualities, + audio_accepted_qualities=audio_accepted_qualities, + codecs=codecs, + ) + video_streams = [] + audio_streams = [] + for stream in data: + if isinstance(stream, VideoStreamDownloadURL): + video_streams.append(stream) + if isinstance(stream, AudioStreamDownloadURL): + audio_streams.append(stream) + + def video_stream_cmp( + s1: VideoStreamDownloadURL, s2: VideoStreamDownloadURL + ): + # 杜比/HDR 优先 + if s1.video_quality == VideoQuality.DOLBY and (not no_dolby_video): + return 1 + elif s2.video_quality == VideoQuality.DOLBY and (not no_dolby_video): + return -1 + elif s1.video_quality == VideoQuality.HDR and (not no_hdr): + return 1 + elif s2.video_quality == VideoQuality.HDR and (not no_hdr): + return -1 + if s1.video_quality.value != s2.video_quality.value: + return s1.video_quality.value - s2.video_quality.value + # Detect the high quality stream to the end. + elif s1.video_codecs.value != s2.video_codecs.value: + return codecs.index(s2.video_codecs) - codecs.index(s1.video_codecs) + return -1 + + def audio_stream_cmp( + s1: AudioStreamDownloadURL, s2: AudioStreamDownloadURL + ): + # 杜比/Hi-Res 优先 + if s1.audio_quality == AudioQuality.DOLBY and (not no_dolby_audio): + return 1 + if s2.audio_quality == AudioQuality.DOLBY and (not no_dolby_audio): + return -1 + if s1.audio_quality == AudioQuality.HI_RES and (not no_hires): + return 1 + if s2.audio_quality == AudioQuality.HI_RES and (not no_hires): + return -1 + return s1.audio_quality.value - s2.audio_quality.value + + video_streams.sort(key=cmp_to_key(video_stream_cmp), reverse=True) + audio_streams.sort(key=cmp_to_key(audio_stream_cmp), reverse=True) + if len(video_streams) == 0: + video_streams = [None] + if len(audio_streams) == 0: + audio_streams = [None] + return [video_streams[0], audio_streams[0]] diff --git a/bilibili_api/video_tag.py b/bilibili_api/video_tag.py new file mode 100644 index 0000000000000000000000000000000000000000..e951ae47b91c0d5a96533d00523090018c68d394 --- /dev/null +++ b/bilibili_api/video_tag.py @@ -0,0 +1,130 @@ +""" +bilibili_api.video_tag + +视频标签相关,部分的标签的 id 与同名的频道的 id 一模一样。 +""" + +from typing import Optional + +import httpx + +from .errors import * +from .utils.utils import get_api +from .utils.credential import Credential +from .utils.network import Api + +API = get_api("video_tag") +API_video = get_api("video") + + +class Tag: + """ + 标签类 + """ + + def __init__( + self, + tag_name: Optional[str] = None, + tag_id: Optional[int] = None, + credential: Optional[Credential] = None, + ): + """ + Args: + tag_name (str | None): 标签名. Defaults to None. + + tag_id (int | None): 标签 id. Defaults to None. + + credential (Credential): 凭据类. Defaults to None. + + 注意:tag_name 和 tag_id 任选一个传入即可。tag_id 优先使用。 + """ + if tag_id == None: + if tag_name == None: + raise ArgsException("tag_name 和 tag_id 需要提供一个。") + self.__tag_id = self.__get_tag_info_sync(tag_name)["tag_id"] + else: + self.__tag_id = tag_id + credential = credential if credential else Credential() + self.credential = credential + + def get_tag_id(self) -> int: + return self.__tag_id + + def __get_tag_info_sync(self, tag_name: str) -> dict: + api = API["info"]["tag_info"] + params = {"tag_name": tag_name} + return Api(**api).update_params(**params).result_sync + + async def get_tag_info(self) -> dict: + """ + 获取标签信息。 + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["info"]["tag_info"] + params = {"tag_id": self.get_tag_id()} + return await Api(**api).update_params(**params).result + + async def get_similar_tags(self) -> dict: + """ + 获取相关的标签 + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["info"]["get_similar"] + params = {"tag_id": self.get_tag_id()} + return await Api(**api).update_params(**params).result + + # async def get_cards(self) -> dict: + # """ + # 获取标签下的视频/动态 + + # Returns: + # dict: 调用 API 返回的结果 + # """ + # api = API["info"]["get_list"] + # params = {"topic_id": self.get_tag_id()} + # return await Api(**api).update_params(**params).result + + async def get_history_cards(self, offset_dynamic_id: int) -> dict: + """ + 获取标签下,指定dynamic_id的视频的后一个视频/动态作为起始的视频/动态 + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["info"]["get_history_list"] + params = {"topic_id": self.get_tag_id(), "offset_dynamic_id": offset_dynamic_id} + return await Api(**api).update_params(**params).result + + async def subscribe_tag(self) -> dict: + """ + 关注标签。 + + Returns: + dict: 调用 API 返回的结果。 + """ + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + api = API_video["operate"]["subscribe_tag"] + + data = {"tag_id": self.__tag_id} + return await Api(**api, credential=self.credential).update_data(**data).result + + async def unsubscribe_tag(self) -> dict: + """ + 取关标签。 + + Returns: + dict: 调用 API 返回的结果。 + """ + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + + api = API_video["operate"]["unsubscribe_tag"] + + data = {"tag_id": self.__tag_id} + return await Api(**api, credential=self.credential).update_data(**data).result diff --git a/bilibili_api/video_uploader.py b/bilibili_api/video_uploader.py new file mode 100644 index 0000000000000000000000000000000000000000..1c099963802cb0c3c30bc3eac88a68b190c0292f --- /dev/null +++ b/bilibili_api/video_uploader.py @@ -0,0 +1,1596 @@ +""" +bilibili_api.video_uploader + +视频上传 +""" +import os +import json +import time +import base64 +import re +import asyncio +import httpx +from enum import Enum +from typing import List, Union, Optional +from copy import copy, deepcopy +from asyncio.tasks import Task, create_task +from asyncio.exceptions import CancelledError +from datetime import datetime + +from .video import Video +from .topic import Topic +from .utils.utils import get_api +from .utils.picture import Picture +from .utils.AsyncEvent import AsyncEvent +from .utils.credential import Credential +from .utils.aid_bvid_transformer import bvid2aid +from .exceptions.ApiException import ApiException +from .utils.network import Api, get_session +from .exceptions.NetworkException import NetworkException +from .exceptions.ResponseCodeException import ResponseCodeException + +_API = get_api("video_uploader") + + +async def upload_cover(cover: Picture, credential: Credential) -> str: + """ + 上传封面 + + Returns: + str: 封面 URL + """ + credential.raise_for_no_bili_jct() + api = _API["cover_up"] + pic = cover if isinstance(cover, Picture) else Picture().from_file(cover) + cover = pic.convert_format("png") + data = { + "cover": f'data:image/png;base64,{base64.b64encode(pic.content).decode("utf-8")}' + } + return (await Api(**api, credential=credential).update_data(**data).result)["url"] + + +class Lines(Enum): + """ + 可选线路 + + bupfetch 模式下 kodo 目前弃用 `{'error': 'no such bucket'}` + + + BDA2:百度 + + QN:七牛 + + WS:网宿 + + BLDSA:bldsa + """ + + BDA2 = "bda2" + QN = "qn" + WS = "ws" + BLDSA = "bldsa" + + +with open( + os.path.join(os.path.dirname(__file__), "data/video_uploader_lines.json"), + encoding="utf8", +) as f: + LINES_INFO = json.loads(f.read()) + + +async def _probe() -> dict: + """ + 测试所有线路 + + 测速网页 https://member.bilibili.com/preupload?r=ping + """ + # api = _API["probe"] + # info = await Api(**api).update_params(r="probe").result # 不实时获取线路直接用 LINES_INFO + min_cost, fastest_line = 30, None + for line in LINES_INFO.values(): + start = time.perf_counter() + data = bytes(int(1024 * 0.1 * 1024)) # post 0.1MB + httpx.post(f'https:{line["probe_url"]}', data=data, timeout=30) + cost_time = time.perf_counter() - start + if cost_time < min_cost: + min_cost, fastest_line = cost_time, line + return fastest_line + + +async def _choose_line(line: Lines) -> dict: + """ + 选择线路,不存在则直接测速自动选择 + """ + if isinstance(line, Lines): + line_info = LINES_INFO.get(line.value) + if line_info is not None: + return line_info + return await _probe() + + +LINES_INFO = { + "bda2": { + "os": "upos", + "upcdn": "bda2", + "probe_version": 20221109, + "query": "probe_version=20221109&upcdn=bda2", + "probe_url": "//upos-cs-upcdnbda2.bilivideo.com/OK", + }, + "bldsa": { + "os": "upos", + "upcdn": "bldsa", + "probe_version": 20221109, + "query": "upcdn=bldsa&probe_version=20221109", + "probe_url": "//upos-cs-upcdnbldsa.bilivideo.com/OK", + }, + "qn": { + "os": "upos", + "upcdn": "qn", + "probe_version": 20221109, + "query": "probe_version=20221109&upcdn=qn", + "probe_url": "//upos-cs-upcdnqn.bilivideo.com/OK", + }, + "ws": { + "os": "upos", + "upcdn": "ws", + "probe_version": 20221109, + "query": "upcdn=ws&probe_version=20221109", + "probe_url": "//upos-cs-upcdnws.bilivideo.com/OK", + }, +} + + +async def _probe() -> dict: + """ + 测试所有线路 + + 测速网页 https://member.bilibili.com/preupload?r=ping + """ + # api = _API["probe"] + # info = await Api(**api).update_params(r="probe").result # 不实时获取线路直接用 LINES_INFO + min_cost, fastest_line = 30, None + for line in LINES_INFO.values(): + start = time.perf_counter() + data = bytes(int(1024 * 0.1 * 1024)) # post 0.1MB + httpx.post(f'https:{line["probe_url"]}', data=data, timeout=30) + cost_time = time.perf_counter() - start + if cost_time < min_cost: + min_cost, fastest_line = cost_time, line + return fastest_line + + +async def _choose_line(line: Lines) -> dict: + """ + 选择线路,不存在则直接测速自动选择 + """ + if isinstance(line, Lines): + line_info = LINES_INFO.get(line.value) + if line_info is not None: + return line_info + return await _probe() + + +class VideoUploaderPage: + """ + 分 P 对象 + """ + + def __init__(self, path: str, title: str, description: str = ""): + """ + Args: + path (str): 视频文件路径 + title (str) : 视频标题 + description (str, optional) : 视频简介. Defaults to "". + """ + self.path = path + self.title: str = title + self.description: str = description + + self.cached_size: Union[int, None] = None + + def get_size(self) -> int: + """ + 获取文件大小 + + Returns: + int: 文件大小 + """ + if self.cached_size is not None: + return self.cached_size + + size: int = 0 + stream = open(self.path, "rb") + while True: + s: bytes = stream.read(1024) + + if not s: + break + + size += len(s) + + stream.close() + + self.cached_size = size + return size + + +class VideoUploaderEvents(Enum): + """ + 上传事件枚举 + + Events: + + PRE_PAGE 上传分 P 前 + + PREUPLOAD 获取上传信息 + + PREUPLOAD_FAILED 获取上传信息失败 + + PRE_CHUNK 上传分块前 + + AFTER_CHUNK 上传分块后 + + CHUNK_FAILED 区块上传失败 + + PRE_PAGE_SUBMIT 提交分 P 前 + + PAGE_SUBMIT_FAILED 提交分 P 失败 + + AFTER_PAGE_SUBMIT 提交分 P 后 + + AFTER_PAGE 上传分 P 后 + + PRE_COVER 上传封面前 + + AFTER_COVER 上传封面后 + + COVER_FAILED 上传封面失败 + + PRE_SUBMIT 提交视频前 + + SUBMIT_FAILED 提交视频失败 + + AFTER_SUBMIT 提交视频后 + + COMPLETED 完成上传 + + ABORTED 用户中止 + + FAILED 上传失败 + """ + + PREUPLOAD = "PREUPLOAD" + PREUPLOAD_FAILED = "PREUPLOAD_FAILED" + PRE_PAGE = "PRE_PAGE" + + PRE_CHUNK = "PRE_CHUNK" + AFTER_CHUNK = "AFTER_CHUNK" + CHUNK_FAILED = "CHUNK_FAILED" + + PRE_PAGE_SUBMIT = "PRE_PAGE_SUBMIT" + PAGE_SUBMIT_FAILED = "PAGE_SUBMIT_FAILED" + AFTER_PAGE_SUBMIT = "AFTER_PAGE_SUBMIT" + + AFTER_PAGE = "AFTER_PAGE" + + PRE_COVER = "PRE_COVER" + AFTER_COVER = "AFTER_COVER" + COVER_FAILED = "COVER_FAILED" + + PRE_SUBMIT = "PRE_SUBMIT" + SUBMIT_FAILED = "SUBMIT_FAILED" + AFTER_SUBMIT = "AFTER_SUBMIT" + + COMPLETED = "COMPLETE" + ABORTED = "ABORTED" + FAILED = "FAILED" + + +async def get_available_topics(tid: int, credential: Credential) -> List[dict]: + """ + 获取可用 topic 列表 + """ + credential.raise_for_no_sessdata() + api = _API["available_topics"] + params = {"type_id": tid, "pn": 0, "ps": 200} # 一次性获取完 + return (await Api(**api, credential=credential).update_params(**params).result)[ + "topics" + ] + + +class VideoPorderType: + """ + 视频商业类型 + + + FIREWORK: 花火 + + OTHER: 其他 + """ + + FIREWORK = {"flow_id": 1} + OTHER = { + "flow_id": 1, + "industry_id": None, + "official": None, + "brand_name": None, + "show_type": [], + } + + +class VideoPorderIndustry(Enum): + """ + 商单行业 + + + MOBILE_GAME: 手游 + + CONSOLE_GAME: 主机游戏 + + WEB_GAME: 网页游戏 + + PC_GAME: PC单机游戏 + + PC_NETWORK_GAME: PC网络游戏 + + SOFTWARE_APPLICATION: 软件应用 + + DAILY_NECESSITIES_AND_COSMETICS: 日用品化妆品 + + CLOTHING_SHOES_AND_HATS: 服装鞋帽 + + LUGGAGE_AND_ACCESSORIES: 箱包饰品 + + FOOD_AND_BEVERAGE: 食品饮料 + + PUBLISHING_AND_MEDIA: 出版传媒 + + COMPUTER_HARDWARE: 电脑硬件 + + OTHER: 其他 + + MEDICAL: 医疗类 + + FINANCE: 金融 + """ + + MOBILE_GAME = 1 + CONSOLE_GAME = 20 + WEB_GAME = 21 + PC_GAME = 22 + PC_NETWORK_GAME = 23 + SOFTWARE_APPLICATION = 2 + DAILY_NECESSITIES_AND_COSMETICS = 3 + CLOTHING_SHOES_AND_HATS = 4 + LUGGAGE_AND_ACCESSORIES = 5 + FOOD_AND_BEVERAGE = 6 + PUBLISHING_AND_MEDIA = 7 + COMPUTER_HARDWARE = 8 + OTHER = 9 + MEDICAL = 213 + FINANCE = 214 + + +class VideoPorderShowType(Enum): + """ + 商单形式 + + + LOGO: Logo + + OTHER: 其他 + + SPOKEN_AD: 口播 + + PATCH: 贴片 + + TVC_IMBEDDED: TVC植入 + + CUSTOMIZED_AD: 定制软广 + + PROGRAM_SPONSORSHIP: 节目赞助 + + SLOGAN: SLOGAN + + QR_CODE: 二维码 + + SUBTITLE_PROMOTION: 字幕推广 + """ + + LOGO = 15 + OTHER = 10 + SPOKEN_AD = 11 + PATCH = 12 + TVC_IMBEDDED = 14 + CUSTOMIZED_AD = 19 + PROGRAM_SPONSORSHIP = 18 + SLOGAN = 17 + QR_CODE = 16 + SUBTITLE_PROMOTION = 13 + + +class VideoPorderMeta: + flow_id: int + industry_id: Optional[int] = None + official: Optional[int] = None + brand_name: Optional[str] = None + show_types: List[VideoPorderShowType] = [] + + __info: dict = None + + def __init__( + self, + porden_type: VideoPorderType = VideoPorderType.FIREWORK, + industry_type: Optional[VideoPorderIndustry] = None, + brand_name: Optional[str] = None, + show_types: List[VideoPorderShowType] = [], + ): + self.flow_id = 1 + self.__info = porden_type.value + if porden_type == VideoPorderType.OTHER: + self.__info["industry"] = industry_type.value + self.__info["brand_name"] = brand_name + self.__info["show_types"] = ",".join( + [show_type.value for show_type in show_types] + ) + + def __dict__(self) -> dict: + return self.__info + + +class VideoMeta: + tid: int # 分区 ID。可以使用 channel 模块进行查询。 + title: str # 视频标题 + desc: str # 视频简介。 + cover: Picture # 封面 URL + tags: Union[List[str], str] # 视频标签。使用英文半角逗号分隔的标签组。 + topic_id: Optional[int] = None # 可选,话题 ID。 + mission_id: Optional[int] = None # 可选,任务 ID。 + original: bool = True # 可选,是否为原创视频。 + source: Optional[str] = None # 可选,视频来源。 + recreate: Optional[bool] = False # 可选,是否允许重新上传。 + no_reprint: Optional[bool] = False # 可选,是否禁止转载。 + open_elec: Optional[bool] = False # 可选,是否展示充电信息。 + up_selection_reply: Optional[bool] = False # 可选,是否开启评论精选。 + up_close_danmu: Optional[bool] = False # 可选,是否关闭弹幕。 + up_close_reply: Optional[bool] = False # 可选,是否关闭评论。 + lossless_music: Optional[bool] = False # 可选,是否启用无损音乐。 + dolby: Optional[bool] = False # 可选,是否启用杜比音效。 + subtitle: Optional[dict] = None # 可选,字幕设置。 + dynamic: Optional[str] = None # 可选,动态信息。 + neutral_mark: Optional[str] = None # 可选,创作者声明。 + delay_time: Optional[Union[int, datetime]] = None # 可选,定时发布时间戳(秒)。 + porder: Optional[VideoPorderMeta] = None # 可选,商业相关参数。 + + __credential: Credential + __pre_info = dict + + def __init__( + self, + tid: int, # 分区 ID。可以使用 channel 模块进行查询。 + title: str, # 视频标题 + desc: str, # 视频简介。 + cover: Union[Picture, str], # 封面 URL + tags: Union[List[str], str], # 视频标签。使用英文半角逗号分隔的标签组。 + topic: Optional[Union[int, Topic]] = None, # 可选,话题 ID。 + mission_id: Optional[int] = None, # 可选,任务 ID。 + original: bool = True, # 可选,是否为原创视频。 + source: Optional[str] = None, # 可选,视频来源。 + recreate: Optional[bool] = False, # 可选,是否允许重新上传。 + no_reprint: Optional[bool] = False, # 可选,是否禁止转载。 + open_elec: Optional[bool] = False, # 可选,是否展示充电信息。 + up_selection_reply: Optional[bool] = False, # 可选,是否开启评论精选。 + up_close_danmu: Optional[bool] = False, # 可选,是否关闭弹幕。 + up_close_reply: Optional[bool] = False, # 可选,是否关闭评论。 + lossless_music: Optional[bool] = False, # 可选,是否启用无损音乐。 + dolby: Optional[bool] = False, # 可选,是否启用杜比音效。 + subtitle: Optional[dict] = None, # 可选,字幕设置。 + dynamic: Optional[str] = None, # 可选,动态信息。 + neutral_mark: Optional[str] = None, # 可选,中性化标签。 + delay_time: Optional[Union[int, datetime]] = None, # 可选,定时发布时间戳(秒)。 + porder: Optional[VideoPorderMeta] = None, # 可选,商业相关参数。 + ) -> None: + """ + 基本视频上传参数 + + 可调用 VideoMeta.verify() 验证部分参数是否可用 + + Args: + tid (int): 分区 id + + title (str): 视频标题,最多 80 字 + + desc (str): 视频简介,最多 2000 字 + + cover (Union[Picture, str]): 封面,可以传入路径 + + tags (List[str], str): 标签列表,传入 List 或者传入 str 以 "," 为分隔符,至少 1 个 Tag,最多 10 个 + + topic (Optional[Union[int, Topic]]): 活动主题,应该从 video_uploader.get_available_topics(tid) 获取,可选 + + mission_id (Optional[int]): 任务 id,与 topic 一同获取传入 + + original (bool): 是否原创,默认原创 + + source (Optional[str]): 转载来源,非原创应该提供 + + recreate (Optional[bool]): 是否允许转载. 可选,默认为不允许二创 + + no_reprint (Optional[bool]): 未经允许是否禁止转载. 可选,默认为允许转载 + + open_elec (Optional[bool]): 是否开启充电. 可选,默认为关闭充电 + + up_selection_reply (Optional[bool]): 是否开启评论精选. 可选,默认为关闭评论精选 + + up_close_danmu (Optional[bool]): 是否关闭弹幕. 可选,默认为开启弹幕 + + up_close_reply (Optional[bool]): 是否关闭评论. 可选,默认为开启评论 + + lossless_music (Optional[bool]): 是否开启无损音乐. 可选,默认为关闭无损音乐 + + dolby (Optional[bool]): 是否开启杜比音效. 可选,默认为关闭杜比音效 + + subtitle (Optional[dict]): 字幕信息,可选 + + dynamic (Optional[str]): 粉丝动态,可选,最多 233 字 + + neutral_mark (Optional[str]): 创作者声明,可选 + + delay_time (Optional[Union[int, datetime]]): 定时发布时间,可选 + + porder (Optional[VideoPorderMeta]): 商业相关参数,可选 + """ + if isinstance(tid, int): + self.tid = tid + if isinstance(title, str) and len(title) <= 80: + self.title = title + else: + raise ValueError("title 不合法或者大于 80 字") + + if tags is None: + raise ValueError("tags 不能为空") + elif isinstance(tags, str): + if "," in tags: + self.tags = tags.split(",") + else: + self.tags = [tags] + elif isinstance(tags, list) and len(tags) <= 10: + self.tags = tags + else: + raise ValueError("tags 不合法或者多于 10 个") + + if isinstance(cover, str): + self.cover = Picture().from_file(cover) + elif isinstance(cover, Picture): + self.cover = cover + if topic is not None: + self.mission_id = mission_id + if isinstance(topic, int): + self.topic_id = topic + elif isinstance(topic, Topic): + self.topic_id = topic.get_topic_id() + + if isinstance(desc, str) and len(desc) <= 2000: + self.desc = desc + else: + raise ValueError("desc 不合法或者大于 2000 字") + + self.original = original if isinstance(original, bool) else True + if not self.original: + if source is not None: + if isinstance(source, str) and len(source) <= 200: + self.source = source + else: + raise ValueError("source 不合法或者大于 200 字") + + self.recreate = recreate if isinstance(recreate, bool) else False + self.no_reprint = no_reprint if isinstance(no_reprint, bool) else False + self.open_elec = open_elec if isinstance(open_elec, bool) else False + self.up_selection_reply = ( + up_selection_reply if isinstance(up_selection_reply, bool) else False + ) + self.up_close_danmu = ( + up_close_danmu if isinstance(up_close_danmu, bool) else False + ) + self.up_close_reply = ( + up_close_reply if isinstance(up_close_reply, bool) else False + ) + self.lossless_music = ( + lossless_music if isinstance(lossless_music, bool) else False + ) + self.dolby = dolby if isinstance(dolby, bool) else False + self.subtitle = subtitle if isinstance(subtitle, dict) else None + self.dynamic = ( + dynamic if isinstance(dynamic, str) and len(dynamic) <= 233 else None + ) + self.neutral_mark = neutral_mark if isinstance(neutral_mark, str) else None + if isinstance(delay_time, int): + self.delay_time = delay_time + elif isinstance(delay_time, datetime): + self.delay_time = int(delay_time.timestamp()) + self.porder = porder if isinstance(porder, dict) else None + + def __dict__(self) -> dict: + meta = { + "title": self.title, + "copyright": 1 if self.original else 2, + "tid": self.tid, + "tag": ",".join(self.tags), + "mission_id": self.mission_id, # 根据 topic 对应任务 + "topic_id": self.topic_id, + "topic_detail": { + "from_topic_id": self.topic_id, + "from_source": "arc.web.recommend", + }, + "desc_format_id": 9999, + "desc": self.desc, + "dtime": self.delay_time, + "recreate": 1 if self.recreate else -1, + "dynamic": self.dynamic, + "interactive": 0, + "act_reserve_create": 0, # unknown + "no_disturbance": 0, # unknown + "porder": self.porder.__dict__(), + "adorder_type": 9, # unknown + "no_reprint": 1 if self.no_reprint else 0, + "subtitle": self.subtitle + if self.subtitle is not None + else { + "open": 0, + "lan": "", + }, # 莫名其妙没法上传 srt 字幕,显示格式错误,不校验 + "subtitle": self.subtitle, + "neutral_mark": self.neutral_mark, # 不知道能不能随便写文本 + "dolby": 1 if self.dolby else 0, + "lossless_music": 1 if self.lossless_music else 0, + "up_selection_reply": self.up_close_reply, + "up_close_reply": self.up_close_reply, + "up_close_danmu": self.up_close_danmu, + "web_os": 1, # const 1 + } + for k in copy(meta).keys(): + if meta[k] is None: + del meta[k] + return meta + + async def _pre(self) -> dict: + """ + 获取上传参数基本信息 + + 包括活动等在内,固定信息已经缓存于 data/video_uploader_meta_pre.json + """ + api = _API["pre"] + self.__pre_info = await Api(**api, credential=self.__credential).result + return self.__pre_info + + def _check_tid(self) -> bool: + """ + 检查 tid 是否合法 + """ + with open( + os.path.join( + os.path.dirname(__file__), "data/video_uploader_meta_pre.json" + ), + encoding="utf8", + ) as f: + self.__pre_info = json.load(f) + type_list = self.__pre_info["tid_list"] + for parent_type in type_list: + for child_type in parent_type["children"]: + if child_type["id"] == self.tid: + return True + return False + + async def _check_cover(self) -> bool: + """ + 检查封面是否合法 + """ + try: + await upload_cover(self.cover, self.__credential) + return True + except Exception: + return False + + @staticmethod + async def _check_tag_name(name: str, credential: Credential) -> bool: + """ + 检查 tag 是否合法 + + 需要登录 + """ + api = _API["check_tag_name"] + return ( + await Api(**api, credential=credential, ignore_code=True) + .update_params(t=name) + .result + )["code"] == 0 + + async def _check_tags(self) -> List[str]: + """ + 检查所有 tag 是否合法 + """ + return [ + tag + for tag in self.tags + if await self._check_tag_name(tag, self.__credential) + ] + + async def _check_topic_to_mission(self) -> Union[int, bool]: + """ + 检查 topic -> mission 是否存在 + """ + # 只知道能从这里获取...不确定其他地方的 topic -> mission 能否传入 + all_topic_info = await get_available_topics( + tid=self.tid, credential=self.__credential + ) + for topic in all_topic_info: + if topic["topic_id"] == self.topic_id: + return topic["mission_id"] + else: + return False + + async def verify(self, credential: Credential) -> bool: + """ + 验证参数是否可用,仅供参考 + + 检测 tags、delay_time、topic -> mission、cover 和 tid + + 验证失败会抛出异常 + """ + credential.raise_for_no_sessdata() + self.__credential = credential + + # await self._pre() # 缓存于 bilibili_api\data\video_uploader_meta_pre.json + error_tags = await self._check_tags() + if len(error_tags) != 0: + raise ValueError(f'以下 tags 不合法: {",".join(error_tags)}') + + if not self._check_tid(): + raise ValueError(f"tid {self.tid} 不合法") + + topic_to_mission = await self._check_topic_to_mission() + if isinstance(topic_to_mission, int): + self.mission_id = topic_to_mission + elif not topic_to_mission: + raise ValueError( + f"topic -> mission 不存在: {self.topic_id} -> {self.mission_id}" + ) + + if not await self._check_cover(): + raise ValueError(f"封面不合法 {self.cover.__repr__()}") + + if self.delay_time is not None: + if self.delay_time < int(time.time()) + 7200: + raise ValueError("delay_time 不能小于两小时") + if self.delay_time > int(time.time()) + 3600 * 24 * 15: + raise ValueError("delay_time 不能大于十五天") + return True + + +class VideoUploader(AsyncEvent): + """ + 视频上传 + + Attributes: + pages (List[VideoUploaderPage]): 分 P 列表 + + meta (VideoMeta, dict) : 视频信息 + + credential (Credential) : 凭据 + + cover_path (str) : 封面路径 + + line (Lines, Optional) : 线路. Defaults to None. 不选择则自动测速选择 + """ + + def __init__( + self, + pages: List[VideoUploaderPage], + meta: Union[VideoMeta, dict], + credential: Credential, + cover: Optional[Union[str, Picture]] = "", + line: Optional[Lines] = None, + ): + """ + Args: + pages (List[VideoUploaderPage]): 分 P 列表 + + meta (VideoMeta, dict) : 视频信息 + + credential (Credential) : 凭据 + + cover (Union[str, Picture]) : 封面路径或者封面对象. Defaults to "",传入 meta 类型为 VideoMeta 时可不传 + + line: (Lines, Optional) : 线路. Defaults to None. 不选择则自动测速选择 + + 建议传入 VideoMeta 对象,避免参数有误 + + meta 参数示例: + + ```json + { + "title": "", + "copyright": 1, + "tid": 130, + "tag": "", + "desc_format_id": 9999, + "desc": "", + "recreate": -1, + "dynamic": "", + "interactive": 0, + "act_reserve_create": 0, + "no_disturbance": 0, + "no_reprint": 1, + "subtitle": { + "open": 0, + "lan": "", + }, + "dolby": 0, + "lossless_music": 0, + "web_os": 1, + } + ``` + + meta 保留字段:videos, cover + """ + super().__init__() + self.meta = meta + self.pages = pages + self.credential = credential + self.cover = ( + self.meta.cover + if isinstance(self.meta, VideoMeta) + else cover + if isinstance(cover, Picture) + else Picture().from_file(cover) + ) + self.line = line + self.__task: Union[Task, None] = None + + async def _preupload(self, page: VideoUploaderPage) -> dict: + """ + 分 P 上传初始化 + + Returns: + dict: 初始化信息 + """ + self.dispatch(VideoUploaderEvents.PREUPLOAD.value, {"page": page}) + api = _API["preupload"] + + # 首先获取视频文件预检信息 + session = get_session() + + resp = await session.get( + api["url"], + params={ + "profile": "ugcfx/bup", + "name": os.path.basename(page.path), + "size": page.get_size(), + "r": self.line["os"], + "ssl": "0", + "version": "2.14.0", + "build": "2100400", + "upcdn": self.line["upcdn"], + "probe_version": self.line["probe_version"], + }, + cookies=self.credential.get_cookies(), + headers={ + "User-Agent": "Mozilla/5.0", + "Referer": "https://www.bilibili.com", + }, + ) + if resp.status_code >= 400: + self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {"page": page}) + raise NetworkException(resp.status_code, resp.reason_phrase) + + preupload = resp.json() + + if preupload["OK"] != 1: + self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {"page": page}) + raise ApiException(json.dumps(preupload)) + + preupload = self._switch_upload_endpoint(preupload, self.line) + url = self._get_upload_url(preupload) + + # 获取 upload_id + resp = await session.post( + url, + headers={ + "x-upos-auth": preupload["auth"], + "user-agent": "Mozilla/5.0", + "referer": "https://www.bilibili.com", + }, + params={ + "uploads": "", + "output": "json", + "profile": "ugcfx/bup", + "filesize": page.get_size(), + "partsize": preupload["chunk_size"], + "biz_id": preupload["biz_id"], + }, + ) + if resp.status_code >= 400: + self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {"page": page}) + raise ApiException("获取 upload_id 错误") + + data = json.loads(resp.text) + + if data["OK"] != 1: + self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {"page": page}) + raise ApiException("获取 upload_id 错误:" + json.dumps(data)) + + preupload["upload_id"] = data["upload_id"] + + # # 读取并上传视频元数据,这段代码暂时用不上 + # meta = ffmpeg.probe(page.path) + # meta_format = meta["format"] + # meta_video = list(map(lambda x: x if x["codec_type"] == "video" else None, meta["streams"])) + # meta_video.remove(None) + # meta_video = meta_video[0] + + # meta_audio = list(map(lambda x: x if x["codec_type"] == "audio" else None, meta["streams"])) + # meta_audio.remove(None) + # meta_audio = meta_audio[0] + + # meta_to_upload = json.dumps({ + # "code": 0, + # "filename": os.path.splitext(os.path.basename(preupload["upos_uri"]))[0], + # "filesize": int(meta_format["size"]), + # "key_frames": [], + # "meta": { + # "audio_meta": meta_audio, + # "video_meta": meta_video, + # "container_meta": { + # "duration": round(float(meta_format["duration"]), 2), + # "format_name": meta_format["format_name"] + # } + # }, + # "version": "2.3.7", + # "webVersion": "1.0.0" + # }) + + # # 预检元数据上传 + # async with session.get(api["url"], params={ + # "name": "BUploader_meta.txt", + # "size": len(meta_to_upload), + # "r": "upos", + # "profile": "fxmeta/bup", + # "ssl": "0", + # "version": "2.10.3", + # "build": "2100300", + # }, cookies=self.credential.get_cookies(), + # headers={ + # "User-Agent": "Mozilla/5.0", + # "Referer": "https://www.bilibili.com" + # }, proxy=settings.proxy + # ) as resp: + # if resp.status >= 400: + # self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {page: page}) + # raise NetworkException(resp.status, resp.reason) + + # preupload_m = await resp.json() + + # if preupload_m['OK'] != 1: + # self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {page: page}) + # raise ApiException(json.dumps(preupload_m)) + + # url = self._get_upload_url(preupload_m) + + # # 获取 upload_id + # async with session.post(url, params={ + # "uploads": "", + # "output": "json" + # }, headers={ + # "x-upos-auth": preupload_m["auth"] + # }, proxy=settings.proxy) as resp: + # if resp.status >= 400: + # self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {page: page}) + # raise NetworkException(resp.status, resp.reason) + + # data = json.loads(await resp.text()) + # if preupload_m['OK'] != 1: + # self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {page: page}) + # raise ApiException(json.dumps(preupload_m)) + + # upload_id = data["upload_id"] + + # size = len(meta_to_upload) + # async with session.put(url, params={ + # "partNumber": 1, + # "uploadId": upload_id, + # "chunk": 0, + # "chunks": 1, + # "size": size, + # "start": 0, + # "end": size, + # "total": size + # }, headers={ + # "x-upos-auth": preupload_m["auth"] + # }, data=meta_to_upload, proxy=settings.proxy) as resp: + # if resp.status >= 400: + # self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {page: page}) + # raise NetworkException(resp.status, resp.reason) + + # data = await resp.text() + + # if data != 'MULTIPART_PUT_SUCCESS': + # self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {page: page}) + # raise ApiException(json.dumps(preupload_m)) + + # async with session.post(url, + # data=json.dumps({"parts": [{"partNumber": 1, "eTag": "etag"}]}), + # params={ + # "output": "json", + # "name": "BUploader_meta.txt", + # "profile": "", + # "uploadId": upload_id, + # "biz_id": "" + # }, + # headers={ + # "x-upos-auth": preupload_m["auth"] + # }, proxy=settings.proxy + # ) as resp: + # if resp.status >= 400: + # self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {page: page}) + # raise NetworkException(resp.status, resp.reason) + + # data = json.loads(await resp.text()) + + # if data['OK'] != 1: + # self.dispatch(VideoUploaderEvents.PREUPLOAD_FAILED.value, {page: page}) + # raise ApiException(json.dumps(data)) + + return preupload + + async def _main(self) -> dict: + videos = [] + for page in self.pages: + data = await self._upload_page(page) + videos.append( + { + "title": page.title, + "desc": page.description, + "filename": data["filename"], # type: ignore + "cid": data["cid"], # type: ignore + } + ) + + cover_url = await self._upload_cover() + + result = await self._submit(videos, cover_url) + + self.dispatch(VideoUploaderEvents.COMPLETED.value, result) + return result + + async def start(self) -> dict: # type: ignore + """ + 开始上传 + + Returns: + dict: 返回带有 bvid 和 aid 的字典。 + """ + + self.line = await _choose_line(self.line) + task = create_task(self._main()) + self.__task = task + + try: + result = await task + self.__task = None + return result + except CancelledError: + # 忽略 task 取消异常 + pass + except Exception as e: + self.dispatch(VideoUploaderEvents.FAILED.value, {"err": e}) + raise e + + async def _upload_cover(self) -> str: + """ + 上传封面 + + Returns: + str: 封面 URL + """ + self.dispatch(VideoUploaderEvents.PRE_COVER.value, None) + try: + cover_url = await upload_cover(cover=self.cover, credential=self.credential) + self.dispatch(VideoUploaderEvents.AFTER_COVER.value, {"url": cover_url}) + return cover_url + except Exception as e: + self.dispatch(VideoUploaderEvents.COVER_FAILED.value, {"err": e}) + raise e + + async def _upload_page(self, page: VideoUploaderPage) -> dict: + """ + 上传分 P + + Args: + page (VideoUploaderPage): 分 P 对象 + + Returns: + str: 分 P 文件 ID,用于 submit 时的 $.videos[n].filename 字段使用。 + """ + preupload = await self._preupload(page) + self.dispatch(VideoUploaderEvents.PRE_PAGE.value, {"page": page}) + + page_size = page.get_size() + # 所有分块起始位置 + chunk_offset_list = list(range(0, page_size, preupload["chunk_size"])) + # 分块总数 + total_chunk_count = len(chunk_offset_list) + # 并发上传分块 + chunk_number = 0 + # 上传队列 + chunks_pending = [] + # 缓存 upload_id,这玩意只能从上传的分块预检结果获得 + upload_id = preupload["upload_id"] + for offset in chunk_offset_list: + chunks_pending.insert( + 0, + self._upload_chunk( + page, offset, chunk_number, total_chunk_count, preupload + ), + ) + chunk_number += 1 + + while chunks_pending: + tasks = [] + + while len(tasks) < preupload["threads"] and len(chunks_pending) > 0: + tasks.append(create_task(chunks_pending.pop())) + + result = await asyncio.gather(*tasks) + + for r in result: + if not r["ok"]: + chunks_pending.insert( + 0, + self._upload_chunk( + page, + r["offset"], + r["chunk_number"], + total_chunk_count, + preupload, + ), + ) + + data = await self._complete_page(page, total_chunk_count, preupload, upload_id) + + self.dispatch(VideoUploaderEvents.AFTER_PAGE.value, {"page": page}) + + return data + + @staticmethod + def _switch_upload_endpoint(preupload: dict, line: dict = None) -> dict: + # 替换线路 endpoint + if line is not None and re.match( + r"//upos-(sz|cs)-upcdn(bda2|ws|qn)\.bilivideo\.com", preupload["endpoint"] + ): + preupload["endpoint"] = re.sub( + r"upcdn(bda2|qn|ws)", f'upcdn{line["upcdn"]}', preupload["endpoint"] + ) + return preupload # tbh not needed since it is ref type + + @staticmethod + def _get_upload_url(preupload: dict) -> str: + # 上传目标 URL + # return f'https:{preupload["endpoint"]}/{preupload["upos_uri"].removeprefix("upos://")}' + return f'https:{preupload["endpoint"]}/{preupload["upos_uri"].replace("upos://","")}' + + async def _upload_chunk( + self, + page: VideoUploaderPage, + offset: int, + chunk_number: int, + total_chunk_count: int, + preupload: dict, + ) -> dict: + """ + 上传视频分块 + + Args: + page (VideoUploaderPage): 分 P 对象 + offset (int): 分块起始位置 + chunk_number (int): 分块编号 + total_chunk_count (int): 总分块数 + preupload (dict): preupload 数据 + + Returns: + dict: 上传结果和分块信息。 + """ + chunk_event_callback_data = { + "page": page, + "offset": offset, + "chunk_number": chunk_number, + "total_chunk_count": total_chunk_count, + } + self.dispatch(VideoUploaderEvents.PRE_CHUNK.value, chunk_event_callback_data) + session = get_session() + + stream = open(page.path, "rb") + stream.seek(offset) + chunk = stream.read(preupload["chunk_size"]) + stream.close() + + # 上传目标 URL + preupload = self._switch_upload_endpoint(preupload, self.line) + url = self._get_upload_url(preupload) + + err_return = { + "ok": False, + "chunk_number": chunk_number, + "offset": offset, + "page": page, + } + + real_chunk_size = len(chunk) + + params = { + "partNumber": str(chunk_number + 1), + "uploadId": str(preupload["upload_id"]), + "chunk": str(chunk_number), + "chunks": str(total_chunk_count), + "size": str(real_chunk_size), + "start": str(offset), + "end": str(offset + real_chunk_size), + "total": page.get_size(), + } + + ok_return = { + "ok": True, + "chunk_number": chunk_number, + "offset": offset, + "page": page, + } + + try: + resp = await session.put( + url, + data=chunk, # type: ignore + params=params, + headers={"x-upos-auth": preupload["auth"]}, + ) + if resp.status_code >= 400: + chunk_event_callback_data["info"] = f"Status {resp.status_code}" + self.dispatch( + VideoUploaderEvents.CHUNK_FAILED.value, + chunk_event_callback_data, + ) + return err_return + + data = resp.text + + if data != "MULTIPART_PUT_SUCCESS" and data != "": + chunk_event_callback_data["info"] = "分块上传失败" + self.dispatch( + VideoUploaderEvents.CHUNK_FAILED.value, + chunk_event_callback_data, + ) + return err_return + + except Exception as e: + chunk_event_callback_data["info"] = str(e) + self.dispatch( + VideoUploaderEvents.CHUNK_FAILED.value, chunk_event_callback_data + ) + return err_return + + self.dispatch(VideoUploaderEvents.AFTER_CHUNK.value, chunk_event_callback_data) + return ok_return + + async def _complete_page( + self, page: VideoUploaderPage, chunks: int, preupload: dict, upload_id: str + ) -> dict: + """ + 提交分 P 上传 + + Args: + page (VideoUploaderPage): 分 P 对象 + + chunks (int): 分块数量 + + preupload (dict): preupload 数据 + + upload_id (str): upload_id + + Returns: + dict: filename: 该分 P 的标识符,用于最后提交视频。cid: 分 P 的 cid + """ + self.dispatch(VideoUploaderEvents.PRE_PAGE_SUBMIT.value, {"page": page}) + + data = { + "parts": list( + map(lambda x: {"partNumber": x, "eTag": "etag"}, range(1, chunks + 1)) + ) + } + + params = { + "output": "json", + "name": os.path.basename(page.path), + "profile": "ugcfx/bup", + "uploadId": upload_id, + "biz_id": preupload["biz_id"], + } + + preupload = self._switch_upload_endpoint(preupload, self.line) + url = self._get_upload_url(preupload) + + session = get_session() + + resp = await session.post( + url=url, + data=json.dumps(data), # type: ignore + headers={ + "x-upos-auth": preupload["auth"], + "content-type": "application/json; charset=UTF-8", + }, + params=params, + ) + if resp.status_code >= 400: + err = NetworkException(resp.status_code, "状态码错误,提交分 P 失败") + self.dispatch( + VideoUploaderEvents.PAGE_SUBMIT_FAILED.value, + {"page": page, "err": err}, + ) + raise err + + data = json.loads(resp.read()) + + if data["OK"] != 1: + err = ResponseCodeException(-1, f'提交分 P 失败,原因: {data["message"]}') + self.dispatch( + VideoUploaderEvents.PAGE_SUBMIT_FAILED.value, + {"page": page, "err": err}, + ) + raise err + + self.dispatch(VideoUploaderEvents.AFTER_PAGE_SUBMIT.value, {"page": page}) + + # return { + # "filename": os.path.splitext(data["key"].removeprefix("/"))[0], + # "cid": preupload["biz_id"], + # } + return { + "filename": os.path.splitext(data["key"].replace("/",""))[0], + "cid": preupload["biz_id"], + } + + async def _submit(self, videos: list, cover_url: str = "") -> dict: + """ + 提交视频 + + Args: + videos (list): 视频列表 + + cover_url (str, optional): 封面 URL. + + Returns: + dict: 含 bvid 和 aid 的字典 + """ + meta = copy( + self.meta.__dict__() if isinstance(self.meta, VideoMeta) else self.meta + ) + meta["cover"] = cover_url + meta["videos"] = videos + + self.dispatch(VideoUploaderEvents.PRE_SUBMIT.value, deepcopy(meta)) + + meta["csrf"] = self.credential.bili_jct # csrf 不需要 print + api = _API["submit"] + + try: + params = {"csrf": self.credential.bili_jct, "t": time.time() * 1000} + # headers = {"content-type": "application/json"} + # 已有 json_body,似乎不需要单独设置 content-type + resp = ( + await Api( + **api, credential=self.credential, no_csrf=True, json_body=True + ) + .update_params(**params) + .update_data(**meta) + # .update_headers(**headers) + .result + ) + self.dispatch(VideoUploaderEvents.AFTER_SUBMIT.value, resp) + return resp + + except Exception as err: + self.dispatch(VideoUploaderEvents.SUBMIT_FAILED.value, {"err": err}) + raise err + + async def abort(self): + """ + 中断上传 + """ + if self.__task: + self.__task.cancel("用户手动取消") + + self.dispatch(VideoUploaderEvents.ABORTED.value, None) + + +async def get_missions( + tid: int = 0, credential: Union[Credential, None] = None +) -> dict: + """ + 获取活动信息 + + Args: + tid (int, optional) : 分区 ID. Defaults to 0. + + credential (Credential, optional): 凭据. Defaults to None. + + Returns: + dict API 调用返回结果 + """ + api = _API["missions"] + + params = {"tid": tid} + + return await Api(**api, credential=credential).update_params(**params).result + + +class VideoEditorEvents(Enum): + """ + 视频稿件编辑事件枚举 + + + PRELOAD : 加载数据前 + + AFTER_PRELOAD : 加载成功 + + PRELOAD_FAILED: 加载失败 + + PRE_COVER : 上传封面前 + + AFTER_COVER : 上传封面后 + + COVER_FAILED : 上传封面失败 + + PRE_SUBMIT : 提交前 + + AFTER_SUBMIT : 提交后 + + SUBMIT_FAILED : 提交失败 + + COMPLETED : 完成 + + ABOTRED : 停止 + + FAILED : 失败 + """ + + PRELOAD = "PRELOAD" + AFTER_PRELOAD = "AFTER_PRELOAD" + PRELOAD_FAILED = "PRELOAD_FAILED" + + PRE_COVER = "PRE_COVER" + AFTER_COVER = "AFTER_COVER" + COVER_FAILED = "COVER_FAILED" + + PRE_SUBMIT = "PRE_SUBMIT" + SUBMIT_FAILED = "SUBMIT_FAILED" + AFTER_SUBMIT = "AFTER_SUBMIT" + + COMPLETED = "COMPLETE" + ABORTED = "ABORTED" + FAILED = "FAILED" + + +class VideoEditor(AsyncEvent): + """ + 视频稿件编辑 + + Attributes: + bvid (str) : 稿件 BVID + + meta (dict) : 视频信息 + + cover_path (str) : 封面路径. Defaults to None(不更换封面). + + credential (Credential): 凭据类. Defaults to None. + """ + + def __init__( + self, + bvid: str, + meta: dict, + cover: Union[str, Picture] = "", + credential: Union[Credential, None] = None, + ): + """ + Args: + bvid (str) : 稿件 BVID + + meta (dict) : 视频信息 + + cover (str | Picture) : 封面地址. Defaults to None(不更改封面). + + credential (Credential | None): 凭据类. Defaults to None. + + meta 参数示例: (保留 video, cover, tid, aid 字段) + + ``` json + { + "title": "str: 标题", + "copyright": "int: 是否原创,0 否 1 是", + "tag": "标签. 用,隔开. ", + "desc_format_id": "const int: 0", + "desc": "str: 描述", + "dynamic": "str: 动态信息", + "interactive": "const int: 0", + "new_web_edit": "const int: 1", + "act_reserve_create": "const int: 0", + "handle_staff": "const bool: false", + "topic_grey": "const int: 1", + "no_reprint": "int: 是否显示“未经允许禁止转载”. 0 否 1 是", + "subtitles # 字幕设置": { + "lan": "str: 字幕投稿语言,不清楚作用请将该项设置为空", + "open": "int: 是否启用字幕投稿,1 or 0" + }, + "web_os": "const int: 2" + } + ``` + """ + super().__init__() + self.bvid = bvid + self.meta = meta + self.credential = credential if credential else Credential() + self.cover_path = cover + self.__old_configs = {} + self.meta["aid"] = bvid2aid(bvid) + self.__task: Union[Task, None] = None + + async def _fetch_configs(self): + """ + 在本地缓存原来的上传信息 + """ + self.dispatch(VideoEditorEvents.PRELOAD.value) + try: + api = _API["upload_args"] + params = {"bvid": self.bvid} + self.__old_configs = ( + await Api(**api, credential=self.credential) + .update_params(**params) + .result + ) + except Exception as e: + self.dispatch(VideoEditorEvents.PRELOAD_FAILED.value, {"err", e}) + raise e + self.dispatch( + VideoEditorEvents.AFTER_PRELOAD.value, {"data": self.__old_configs} + ) + + async def _change_cover(self) -> None: + """ + 更换封面 + + Returns: + None + """ + if self.cover_path == "": + return + self.dispatch(VideoEditorEvents.PRE_COVER.value, None) + try: + pic = ( + self.cover_path + if isinstance(self.cover_path, Picture) + else Picture().from_file(self.cover_path) + ) + resp = await upload_cover(pic, self.credential) + self.dispatch(VideoEditorEvents.AFTER_COVER.value, {"url": resp["url"]}) + # not sure if this key changed to "url" as well + self.meta["cover"] = resp["image_url"] + except Exception as e: + self.dispatch(VideoEditorEvents.COVER_FAILED.value, {"err": e}) + raise e + + async def _submit(self): + api = _API["edit"] + data = self.meta + data["csrf"] = self.credential.bili_jct + self.dispatch(VideoEditorEvents.PRE_SUBMIT.value) + try: + params = {"csrf": self.credential.bili_jct, "t": int(time.time())} + headers = { + "content-type": "application/json;charset=UTF-8", + "referer": "https://member.bilibili.com", + "user-agent": "Mozilla/5.0", + } + resp = ( + await Api(**api, credential=self.credential, no_csrf=True) + .update_params(**params) + .update_data(**data) + .update_headers(**headers) + .result + ) + self.dispatch(VideoEditorEvents.AFTER_SUBMIT.value, resp) + except Exception as e: + self.dispatch(VideoEditorEvents.SUBMIT_FAILED.value, {"err", e}) + raise e + + async def _main(self) -> dict: + await self._fetch_configs() + self.meta["videos"] = [] + cnt = 0 + for v in self.__old_configs["videos"]: + self.meta["videos"].append( + {"title": v["title"], "desc": v["desc"], "filename": v["filename"]} + ) + self.meta["videos"][-1]["cid"] = await Video(self.bvid).get_cid(cnt) + cnt += 1 + self.meta["cover"] = self.__old_configs["archive"]["cover"] + self.meta["tid"] = self.__old_configs["archive"]["tid"] + await self._change_cover() + await self._submit() + self.dispatch(VideoEditorEvents.COMPLETED.value) + return {"bvid": self.bvid} + + async def start(self) -> dict: # type: ignore + """ + 开始更改 + + Returns: + dict: 返回带有 bvid 和 aid 的字典。 + """ + + task = create_task(self._main()) + self.__task = task + + try: + result = await task + self.__task = None + return result + except CancelledError: + # 忽略 task 取消异常 + pass + except Exception as e: + self.dispatch(VideoEditorEvents.FAILED.value, {"err": e}) + raise e + + async def abort(self): + """ + 中断更改 + """ + if self.__task: + self.__task.cancel("用户手动取消") + + self.dispatch(VideoEditorEvents.ABORTED.value, None) diff --git a/bilibili_api/video_zone.py b/bilibili_api/video_zone.py new file mode 100644 index 0000000000000000000000000000000000000000..ecbedc0654c3f804de65240aa792f4cc14f61e8d --- /dev/null +++ b/bilibili_api/video_zone.py @@ -0,0 +1,481 @@ +""" +bilibili_api.video_zone + +分区相关操作,与频道不互通。 +""" + +import os +import copy +import enum +import json +from typing import Dict, List, Tuple, Union + +from .utils.utils import get_api +from .exceptions import ArgsException +from .utils.credential import Credential +from .utils.network import Api + +API = get_api("video_zone") + + +def get_zone_info_by_tid(tid: int) -> Tuple[Union[dict, None], Union[dict, None]]: + """ + 根据 tid 获取分区信息。 + + Args: + tid (int): 频道的 tid。 + + Returns: + `Tuple[dict | None, dict | None]`: 第一个是主分区,第二个是子分区,没有时返回 None。 + """ + with open( + os.path.join(os.path.dirname(__file__), "data/video_zone.json"), encoding="utf8" + ) as f: + channel = json.loads(f.read()) + + for main_ch in channel: + if "tid" not in main_ch: + continue + if tid == int(main_ch["tid"]): + return main_ch, None + + # 搜索子分区 + if "sub" in main_ch.keys(): + for sub_ch in main_ch["sub"]: + if "tid" not in sub_ch: + continue + if tid == sub_ch["tid"]: + return main_ch, sub_ch + else: + return None, None + + +def get_zone_info_by_name(name: str) -> Tuple[Union[dict, None], Union[dict, None]]: + """ + 根据分区名称获取分区信息。 + + Args: + name (str): 频道的名称。 + + Returns: + Tuple[dict | None, dict | None]: 第一个是主分区,第二个是子分区,没有时返回 None。 + """ + with open( + os.path.join(os.path.dirname(__file__), "data/video_zone.json"), encoding="utf8" + ) as f: + channel = json.loads(f.read()) + + for main_ch in channel: + if name in main_ch["name"]: + return main_ch, None + if "sub" in main_ch.keys(): + for sub_ch in main_ch["sub"]: + if name in sub_ch["name"]: + return main_ch, sub_ch + else: + return None, None + + +async def get_zone_top10( + tid: int, day: int = 7, credential: Union[Credential, None] = None +) -> dict: + """ + 获取分区前十排行榜。 + + Args: + tid (int) : 频道的 tid。 + + day (int, optional) : 3 天排行还是 7 天排行。 Defaults to 7. + + credential (Credential | None, optional): Credential 类。Defaults to None. + + Returns: + list: 前 10 的视频信息。 + """ + if credential is None: + credential = Credential() + if day not in (3, 7): + raise ArgsException("参数 day 只能是 3,7。") + + api = API["ranking"]["get_top10"] + params = {"rid": tid, "day": day} + return await Api(**api, credential=credential).update_params(**params).result + + +def get_zone_list() -> List[Dict]: + """ + 获取所有分区的数据 + + Returns: + List[dict]: 所有分区的数据 + """ + with open( + os.path.join(os.path.dirname(__file__), "data/video_zone.json"), encoding="utf8" + ) as f: + channel = json.loads(f.read()) + channel_list = [] + for channel_big in channel: + channel_big_copy = copy.copy(channel_big) + channel_list.append(channel_big_copy) + if "sub" in channel_big.keys(): + channel_big_copy.pop("sub") + for channel_sub in channel_big["sub"]: + channel_sub_copy = copy.copy(channel_sub) + channel_sub_copy["father"] = channel_big_copy + channel_list.append(channel_sub_copy) + return channel_list + + +def get_zone_list_sub() -> dict: + """ + 获取所有分区的数据 + 含父子关系(即一层次只有主分区) + + Returns: + dict: 所有分区的数据 + """ + with open( + os.path.join(os.path.dirname(__file__), "data/video_zone.json"), encoding="utf8" + ) as f: + channel = json.loads(f.read()) + return channel + + +async def get_zone_videos_count_today( + credential: Union[Credential, None] = None +) -> dict: + """ + 获取每个分区当日最新投稿数量 + + Args: + credential (Credential | None): 凭据类 + + Returns: + dict: 调用 API 返回的结果 + """ + credential = credential if credential else Credential() + api = API["count"] + return (await Api(**api, credential=credential).result)["region_count"] + + +async def get_zone_new_videos(tid: int, page_num: int = 1, page_size: int = 10) -> dict: + """ + 获取分区最新投稿 + + Args: + tid (int) : 分区 id + + page_num (int) : 第几页. Defaults to 1. + + page_size (int) : 每页的数据大小. Defaults to 10. + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["new"] + params = {"rid": tid, "pn": page_num, "ps": page_size} + return await Api(**api).update_params(**params).result + + +async def get_zone_hot_tags(tid: int) -> List[dict]: + """ + 获取分区热门标签 + + Args: + tid (int) : 分区 id + + Returns: + List[dict]: 热门标签 + """ + + api = API["get_hot_tags"] + params = {"rid": tid} + return (await Api(**api).update_params(**params).result)[0] + + +class VideoZoneTypes(enum.Enum): + """ + 所有分区枚举 + + - MAINPAGE: 主页 + - ANIME: 番剧 + - ANIME_SERIAL: 连载中番剧 + - ANIME_FINISH: 已完结番剧 + - ANIME_INFORMATION: 资讯 + - ANIME_OFFICAL: 官方延伸 + - MOVIE: 电影 + - GUOCHUANG: 国创 + - GUOCHUANG_CHINESE: 国产动画 + - GUOCHUANG_ORIGINAL: 国产原创相关 + - GUOCHUANG_PUPPETRY: 布袋戏 + - GUOCHUANG_MOTIONCOMIC: 动态漫·广播剧 + - GUOCHUANG_INFORMATION: 资讯 + - TELEPLAY: 电视剧 + - DOCUMENTARY: 纪录片 + - DOUGA: 动画 + - DOUGA_MAD: MAD·AMV + - DOUGA_MMD: MMD·3D + - DOUGA_HANDDRAWN: 短片·手书 + - DOUGA_VOICE: 配音 + - DOUGA_GARAGE_KIT: 手办·模玩 + - DOUGA_TOKUSATSU: 特摄 + - DOUGA_ACGNTALKS: 动漫杂谈 + - DOUGA_OTHER: 综合 + - GAME: 游戏 + - GAME_STAND_ALONE: 单机游戏 + - GAME_ESPORTS: 电子竞技 + - GAME_MOBILE: 手机游戏 + - GAME_ONLINE: 网络游戏 + - GAME_BOARD: 桌游棋牌 + - GAME_GMV: GMV + - GAME_MUSIC: 音游 + - GAME_MUGEN: Mugen + - KICHIKU: 鬼畜 + - KICHIKU_GUIDE: 鬼畜调教 + - KICHIKU_MAD: 音MAD + - KICHIKU_MANUAL_VOCALOID: 人力VOCALOID + - KICHIKU_THEATRE: 鬼畜剧场 + - KICHIKU_COURSE: 教程演示 + - MUSIC: 音乐 + - MUSIC_ORIGINAL: 原创音乐 + - MUSIC_COVER: 翻唱 + - MUSIC_PERFORM: 演奏 + - MUSIC_VOCALOID: VOCALOID·UTAU + - MUSIC_LIVE: 音乐现场 + - MUSIC_MV: MV + - MUSIC_COMMENTARY: 乐评盘点 + - MUSIC_TUTORIAL: 音乐教学 + - MUSIC_OTHER: 音乐综合 + - DANCE: 舞蹈 + - DANCE_OTAKU: 宅舞 + - DANCE_HIPHOP: 街舞 + - DANCE_STAR: 明星舞蹈 + - DANCE_CHINA: 中国舞 + - DANCE_THREE_D: 舞蹈综合 + - DANCE_DEMO: 舞蹈教程 + - DANGE_GESTURES: 手势·网红舞 + - CINEPHILE: 影视 + - CINEPHILE_CINECISM: 影视杂谈 + - CINEPHILE_MONTAGE: 影视剪辑 + - CINEPHILE_SHORTFILM: 短片 + - CINEPHILE_SHORTPLAY: 小剧场 + - CINEPHILE_TRAILER_INFO: 预告·资讯 + - ENT: 娱乐 + - ENT_VARIETY: 综艺 + - ENT_TALKER: 娱乐杂谈 + - ENT_FANS: 粉丝创作 + - ENT_CELEBRITY: 明星综合 + - KNOWLEDGE: 知识 + - KNOWLEDGE_SCIENCE: 科学科普 + - KNOWLEDGE_SOCIAL_SCIENCE: 社科·法律·心理 + - KNOWLEDGE_HUMANITY_HISTORY: 人文历史 + - KNOWLEDGE_BUSINESS: 财经商业 + - KNOWLEDGE_CAMPUS: 校园学习 + - KNOWLEDGE_CAREER: 职业职场 + - KNOWLEDGE_DESIGN: 设计·创意 + - KNOWLEDGE_SKILL: 野生技能协会 + - TECH: 科技 + - TECH_DIGITAL: 数码 + - TECH_APPLICATION: 软件应用 + - TECH_COMPUTER_TECH: 计算机技术 + - TECH_INDUSTRY: 科工机械 + - INFORMATION: 资讯 + - INFORMATION_HOTSPOT: 热点 + - INFORMATION_GLOBAL: 环球 + - INFORMATION_SOCIAL: 社会 + - INFORMATION_MULTIPLE: 综合 + - FOOD: 美食 + - FOOD_MAKE: 美食制作 + - FOOD_DETECTIVE: 美食侦探 + - FOOD_MEASUREMENT: 美食测评 + - FOOD_RURAL: 田园美食 + - FOOD_RECORD: 美食记录 + - LIFE: 生活 + - LIFE_FUNNY: 搞笑 + - LIFE_TRAVEL: 出行 + - LIFE_RURALLIFE: 三农 + - LIFE_HOME: 家居房产 + - LIFE_HANDMAKE: 手工 + - LIFE_PAINTING: 绘画 + - LIFE_DAILY: 日常 + - CAR: 汽车 + - CAR_RACING: 赛车 + - CAR_MODIFIEDVEHICLE: 改装玩车 + - CAR_NEWENERGYVEHICLE: 新能源车 + - CAR_TOURINGCAR: 房车 + - CAR_MOTORCYCLE: 摩托车 + - CAR_STRATEGY: 购车攻略 + - CAR_LIFE: 汽车生活 + - FASHION: 时尚 + - FASHION_MAKEUP: 美妆护肤 + - FASHION_COS: 仿妆cos + - FASHION_CLOTHING: 穿搭 + - FASHION_TREND: 时尚潮流 + - SPORTS: 运动 + - SPORTS_BASKETBALL: 篮球 + - SPORTS_FOOTBALL: 足球 + - SPORTS_AEROBICS: 健身 + - SPORTS_ATHLETIC: 竞技体育 + - SPORTS_CULTURE: 运动文化 + - SPORTS_COMPREHENSIVE: 运动综合 + - ANIMAL: 动物圈 + - ANIMAL_CAT: 喵星人 + - ANIMAL_DOG: 汪星人 + - ANIMAL_PANDA: 大熊猫 + - ANIMAL_WILD_ANIMAL: 野生动物 + - ANIMAL_REPTILES: 爬宠 + - ANIMAL_COMPOSITE: 动物综合 + - VLOG: VLOG + """ + + MAINPAGE = 0 + + ANIME = 13 + ANIME_SERIAL = 33 + ANIME_FINISH = 32 + ANIME_INFORMATION = 51 + ANIME_OFFICAL = 152 + + MOVIE = 23 + + GUOCHUANG = 167 + GUOCHUANG_CHINESE = 153 + GUOCHUANG_ORIGINAL = 168 + GUOCHUANG_PUPPETRY = 169 + GUOCHUANG_MOTIONCOMIC = 195 + GUOCHUANG_INFORMATION = 170 + + TELEPLAY = 11 + + DOCUMENTARY = 177 + + DOUGA = 1 + DOUGA_MAD = 24 + DOUGA_MMD = 25 + DOUGA_HANDDRAWN = 47 + DOUGA_VOICE = 257 + DOUGA_GARAGE_KIT = 210 + DOUGA_TOKUSATSU = 86 + DOUGA_ACGNTALKS = 253 + DOUGA_OTHER = 27 + + GAME = 4 + GAME_STAND_ALONE = 17 + GAME_ESPORTS = 171 + GAME_MOBILE = 172 + GAME_ONLINE = 65 + GAME_BOARD = 173 + GAME_GMV = 121 + GAME_MUSIC = 136 + GAME_MUGEN = 19 + + KICHIKU = 119 + KICHIKU_GUIDE = 22 + KICHIKU_MAD = 26 + KICHIKU_MANUAL_VOCALOID = 126 + KICHIKU_THEATRE = 216 + KICHIKU_COURSE = 127 + + MUSIC = 3 + MUSIC_ORIGINAL = 28 + MUSIC_COVER = 31 + MUSIC_PERFORM = 59 + MUSIC_VOCALOID = 30 + MUSIC_LIVE = 29 + MUSIC_MV = 193 + MUSIC_COMMENTARY = 243 + MUSIC_TUTORIAL = 244 + MUSIC_OTHER = 130 + + DANCE = 129 + DANCE_OTAKU = 20 + DANCE_HIPHOP = 198 + DANCE_STAR = 199 + DANCE_CHINA = 200 + DANCE_THREE_D = 154 + DANCE_DEMO = 156 + DANCE_GESTURES = 255 + + CINEPHILE = 181 + CINEPHILE_CINECISM = 182 + CINEPHILE_MONTAGE = 183 + CINEPHILE_SHORTPLAY = 85 + CINEPHILE_SHORTFILM = 256 + CINEPHILE_TRAILER_INFO = 184 + + ENT = 5 + ENT_VARIETY = 71 + ENT_TALKER = 241 + ENT_FANS = 242 + ENT_CELEBRITY = 137 + + KNOWLEDGE = 36 + KNOWLEDGE_SCIENCE = 201 + KNOWLEDGE_SOCIAL_SCIENCE = 124 + KNOWLEDGE_HUMANITY_HISTORY = 228 + KNOWLEDGE_BUSINESS = 207 + KNOWLEDGE_CAMPUS = 208 + KNOWLEDGE_CAREER = 209 + KNOWLEDGE_DESIGN = 229 + KNOWLEDGE_SKILL = 122 + + TECH = 188 + TECH_DIGITAL = 95 + TECH_APPLICATION = 230 + TECH_COMPUTER_TECH = 231 + TECH_INDUSTRY = 232 + + INFORMATION = 202 + INFORMATION_HOTSPOT = 203 + INFORMATION_GLOBAL = 204 + INFORMATION_SOCIAL = 205 + INFORMATION_MULTIPLE = 206 + + FOOD = 211 + FOOD_MAKE = 76 + FOOD_DETECTIVE = 212 + FOOD_MEASUREMENT = 213 + FOOD_RURAL = 214 + FOOD_RECORD = 215 + + LIFE = 160 + LIFE_FUNNY = 138 + LIFE_TRAVEL = 250 + LIFE_RURALLIFE = 251 + LIFE_HOME = 239 + LIFE_HANDMAKE = 161 + LIFE_PAINTING = 162 + LIFE_DAILY = 21 + + CAR = 223 + CAR_RACING = 245 + CAR_MODIFIEDVEHICLE = 246 + CAR_NEWENERGYVEHICLE = 246 + CAR_TOURINGCAR = 248 + CAR_MOTORCYCLE = 240 + CAR_STRATEGY = 227 + CAR_LIFE = 176 + + FASHION = 155 + FASHION_MAKEUP = 157 + FASHION_COS = 252 + FASHION_CLOTHING = 158 + FASHION_TREND = 159 + + SPORTS = 234 + SPORTS_BASKETBALL = 235 + SPORTS_FOOTBALL = 249 + SPORTS_AEROBICS = 164 + SPORTS_ATHLETIC = 236 + SPORTS_CULTURE = 237 + SPORTS_COMPREHENSIVE = 238 + + ANIMAL = 217 + ANIMAL_CAT = 218 + ANIMAL_DOG = 219 + ANIMAL_PANDA = 220 + ANIMAL_WILD_ANIMAL = 221 + ANIMAL_REPTILES = 222 + ANIMAL_COMPOSITE = 75 + + VLOG = 19 diff --git a/bilibili_api/vote.py b/bilibili_api/vote.py new file mode 100644 index 0000000000000000000000000000000000000000..e7c7a236fbf79568edd7264f7e50bd0e928f96da --- /dev/null +++ b/bilibili_api/vote.py @@ -0,0 +1,233 @@ +""" +bilibili_api.vote + +投票相关操作。 + +需要 vote_id,获取 vote_id: https://nemo2011.github.io/bilibili-api/#/vote_id +""" +from enum import Enum +from typing import Union, Optional + +from .utils.utils import get_api +from .utils.picture import Picture +from .utils.credential import Credential +from .utils.network import Api + +API = get_api("vote") + + +class VoteType(Enum): + """ + 投票类型枚举类 + + + TEXT: 文字投票 + + IMAGE: 图片投票 + """ + + TEXT = 0 + IMAGE = 1 + + +class VoteChoices: + """ + 投票选项类 + """ + + def __init__(self) -> None: + self.choices = [] + + def add_choice( + self, desc: str, image: Optional[Union[str, Picture]] = None + ) -> "VoteChoices": + """ + 往 VoteChoices 添加选项 + + Args: + desc (str): 选项描述 + + image (str, Picture, optional): 选项的图片链接,用于图片投票。支持 Picture 类. Defaults to None. + """ + if isinstance(image, Picture): + image = image.url + self.choices.append({"desc": desc, "img_url": image}) + return self + + def remove_choice(self, index: int) -> "VoteChoices": + """ + 从 VoteChoices 移除选项 + + Args: + index (int): 选项索引 + """ + self.choices.remove(index) + return self + + def get_choices(self) -> dict: + """ + 获取 VoteChoices 的 choices + + Returns: + dict: choices + """ + results = {} + for i in range(len(self.choices)): + choice_key_name = f"info[options][{i}]" + results[f"{choice_key_name}[desc]"] = self.choices[i]["desc"] + results[f"{choice_key_name}[img_url]"] = self.choices[i]["img_url"] + return results + + +class Vote: + """ + 投票类 + + Attributes: + vote_id (int): vote_id, 获取:https://nemo2011.github.io/bilibili-api/#/vote_id + + credential (Credential): 凭据类 + """ + + def __init__(self, vote_id: int, credential: Credential = Credential()) -> None: + """ + Args: + vote_id (int): vote_id, 获取:https://nemo2011.github.io/bilibili-api/#/vote_id + + credential (Credential): 凭据类,非必要. + """ + self.__vote_id = vote_id + self.credential = credential + self.title: Optional[str] = None + + def get_vote_id(self) -> int: + return self.__vote_id + + def get_info_sync(self) -> dict: + """ + 获取投票详情 + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["info"]["vote_info"] + params = {"vote_id": self.get_vote_id()} + info = Api(**api, params=params).result_sync + self.title = info["info"]["title"] # 为 dynmaic.BuildDnamic.add_vote 缓存 title + return info + + async def get_info(self) -> dict: + """ + 获取投票详情 + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["info"]["vote_info"] + params = {"vote_id": self.get_vote_id()} + info = await Api(**api).update_params(**params).result + self.title = info["info"]["title"] # 为 dynmaic.BuildDnamic.add_vote 缓存 title + return info + + async def get_title(self) -> str: + """ + 快速获取投票标题 + + Returns: + str: 投票标题 + """ + if self.title is None: + return (await self.get_info())["info"]["title"] + return self.title + + async def update_vote( + self, + title: str, + _type: VoteType, + choice_cnt: int, + duration: int, + choices: VoteChoices, + desc: Optional[str] = None, + ) -> dict: + """ + 更新投票内容 + + Args: + vote_id (int): vote_id + + title (str): 投票标题 + + _type (VoteType): 投票类型 + + choice_cnt (int): 最多几项 + + duration (int): 投票持续秒数 常用: 三天:259200 七天:604800 三十天:2592000 + + choices (VoteChoices): 投票选项 + + credential (Credential): Credential 枚举类 + + desc (Optional[str], optional): 投票描述. Defaults to None. + + Returns: + dict: 调用 API 返回的结果 + """ + self.credential.raise_for_no_sessdata() + api = API["operate"]["update"] + data = { + "info[title]": title, + "info[desc]": desc, + "info[type]": _type.value, + "info[choice_cnt]": choice_cnt, + "info[duration]": duration, + "info[vote_id]": self.get_vote_id(), + } + data.update(choices.get_choices()) + if choice_cnt > len(choices.choices): + raise ValueError("choice_cnt 大于 choices 选项数") + return await Api(**api, credential=self.credential).update_data(**data).result + + +async def create_vote( + title: str, + _type: VoteType, + choice_cnt: int, + duration: int, + choices: VoteChoices, + credential: Credential, + desc: Optional[str] = None, +) -> Vote: + """ + 创建投票 + + Args: + title (str): 投票标题 + + _type (VoteType): 投票类型 + + choice_cnt (int): 最多几项 + + duration (int): 投票持续秒数 常用: 三天:259200 七天:604800 三十天:2592000 + + choices (VoteChoices): 投票选项 + + credential (Credential): Credential + + desc (Optional[str], optional): 投票描述. Defaults to None. + + Returns: + Vote: Vote 类 + """ + api = API["operate"]["create"] + data = { + "info[title]": title, + "info[desc]": desc, + "info[type]": _type.value, + "info[choice_cnt]": choice_cnt, + "info[duration]": duration, + } + data.update(choices.get_choices()) + if choice_cnt > len(choices.choices): + raise ValueError("choice_cnt 大于 choices 选项数") + vote_id = (await Api(**api, credential=credential).update_data(**data).result)[ + "vote_id" + ] + return Vote(vote_id=vote_id, credential=credential) diff --git a/bilibili_api/watchroom.py b/bilibili_api/watchroom.py new file mode 100644 index 0000000000000000000000000000000000000000..6dc56429b05ff41aec48f58135866b5a8b538d6c --- /dev/null +++ b/bilibili_api/watchroom.py @@ -0,0 +1,398 @@ +""" +bilibili_api.watchroom + +放映室相关 API + +注意,此类操作务必传入 `Credential` 并且要求传入 `buvid3` 否则可能无法鉴权 +""" +import time +from enum import Enum +from typing import Dict, List, Union + +from .utils.credential import Credential +from .utils.network import Api +from .utils.utils import get_api + +API = get_api("watchroom") + + +watch_room_bangumi_cache: Dict[int, List[int]] = {} + + +class SeasonType(Enum): + """ + 季度类型 + + + ANIME: 番剧 + + MOVIE: 电影 + + DOCUMENTARY: 纪录片 + + GUOCHUANG: 国创 + + TV: 电视剧 + + VARIETY: 综艺 + """ + + ANIME = 1 + MOVIE = 2 + DOCUMENTARY = 3 + GUOCHUANG = 4 + TV = 5 + VARIETY = 7 + + +class MessageType(Enum): + """ + 消息类型 + + + PLAIN: 纯文本 + + EMOJI: 表情 + """ + + PLAIN = "plain" + EMOJI = "emoji" + + +class MessageSegment: + """ + 消息片段 + + Args: + msg (str) : 信息 + + is_emoji (bool): 是否为表情包 + """ + + def __init__(self, msg: str, is_emoji: bool = False): + self.msg = msg + self.msg_type = MessageType.EMOJI if is_emoji else MessageType.PLAIN + + def __repr__(self) -> str: + if self.msg_type == MessageType.EMOJI: + return f"[{self.msg}]" + return self.msg + + +class Message: + """ + 消息集合 + """ + + def __init__(self, *messages: Union[MessageSegment, str]): + self.msg_list: List[MessageSegment] = [] + for msg in messages: + if isinstance(msg, str): + self.msg_list.append(MessageSegment(msg)) + else: + self.msg_list.append(msg) + + def __add__(self, msg: Union[MessageSegment, "Message"]): + if isinstance(msg, MessageSegment): + return Message(*self.msg_list, msg) + elif isinstance(msg, Message): + return Message(*self.msg_list, *msg.msg_list) + raise TypeError + + def __str__(self) -> str: + return "".join(str(msg) for msg in self.msg_list) + + def __repr__(self) -> str: + return str(self.msg_list) + + +class WatchRoom: + """ + 放映室类 + """ + + __season_id: int + __episode_id: int + + def __init__(self, room_id: int, credential: Credential = None): + """ + Args: + + credential (Credential): 凭据类 (大部分用户操作都需要与之匹配的 buvid3 值,务必在 credential 传入) + + room_id (int) : 放映室 id + """ + credential = credential if credential else Credential() + self.__room_id = room_id + self.credential = credential + self.credential.raise_for_no_sessdata() + self.credential.raise_for_no_bili_jct() + self.credential.raise_for_no_buvid3() + if room_id in watch_room_bangumi_cache.keys(): + self.set_season_id(watch_room_bangumi_cache[room_id][0]) + self.set_episode_id(watch_room_bangumi_cache[room_id][1]) + else: + params = {"room_id": self.get_room_id(), "platform": "web"} + info: dict = ( + Api(credential=self.credential, **API["info"]["info"]) + .update_params(**params) + .result_sync + ) + self.set_season_id(info["status"]["season_id"]) + self.set_episode_id(info["status"]["episode_id"]) + + def set_season_id(self, season_id: int): + self.__season_id = season_id + + def set_episode_id(self, episode_id: int): + self.__episode_id = episode_id + + def get_season_id(self): + return self.__season_id + + def get_episode_id(self): + return self.__episode_id + + def get_room_id(self): + return self.__room_id + + async def get_info(self) -> dict: + """ + 获取放映室信息,播放进度等 + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["info"]["info"] + params = {"room_id": self.get_room_id(), "platform": "web"} + return ( + await Api(credential=self.credential, **api).update_params(**params).result + ) + + async def open(self) -> None: + """ + 开放放映室 + """ + api = API["operate"]["open"] + data = { + "room_id": self.get_room_id(), + "is_open": 1, + "csrf": self.credential.bili_jct, + "platform": "web", + } + return ( + await Api(credential=self.credential, no_csrf=True, **api) + .update_data(**data) + .result + ) + + async def close(self) -> None: + """ + 关闭放映室 + """ + api = API["operate"]["open"] + data = { + "room_id": self.get_room_id(), + "is_open": 0, + "csrf": self.credential.bili_jct, + "platform": "web", + } + return ( + await Api(credential=self.credential, no_csrf=True, **api) + .update_data(**data) + .result + ) + + async def progress(self, progress: int = None, status: int = 1) -> None: + """ + 设置播放状态,包括暂停与进度条 + + Args: + + progress (int, None) 进度,单位为秒 + + status (bool, None) 播放状态 1 播放中 0 暂停中 2 已结束 + """ + api = API["operate"]["progress"] + data = { + "room_id": self.get_room_id(), + "progress": progress, + "status": status if status in [1, 2, 0] else 1, + "csrf": self.credential.bili_jct, + "platform": "web", + } + return ( + await Api(credential=self.credential, no_csrf=True, **api) + .update_data(**data) + .result + ) + + async def join(self, token: str = "") -> dict: + """ + 加入放映室 + + Args: + + token (str, Optional) 邀请 Token + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["operate"]["join"] + data = { + "room_id": self.get_room_id(), + "token": token, + "csrf": self.credential.bili_jct, + "platform": "web", + } + res = ( + await Api(credential=self.credential, no_csrf=True, **api) + .update_data(**data) + .result + ) + self.set_season_id(res["season_id"]) + self.set_episode_id(res["episode_id"]) + return res + + async def send(self, msg: Message) -> dict: + """ + 发送消息 + + Args: + + msg (Message) 消息 + + Returns: + dict: 调用 API 返回的结果 + """ + data = { + "room_id": self.get_room_id(), + "content_type": 0, + "content": '{"text":"%s"}' % msg, + "req_id": int(time.time()) * 1000, + "platform": "web", + "csrf": self.credential.bili_jct, + } + api = API["operate"]["send"] + return ( + await Api(credential=self.credential, no_csrf=True, **api) + .update_data(**data) + .result + ) + + async def kickout(self, uid: int) -> dict: + """ + 踢出放映室 + + Args: + + uid (int) 用户 uid + + Returns: + dict: 调用 API 返回的结果 + """ + api = API["operate"]["kickout"] + data = { + "room_id": self.get_room_id(), + "mid": uid, + "csrf": self.credential.bili_jct, + "platform": "web", + } + return ( + await Api(credential=self.credential, no_csrf=True, **api) + .update_data(**data) + .result + ) + + async def share(self) -> str: + """ + 获取邀请 Token + + Returns: + str: 邀请 Token + """ + api = API["info"]["season"] + params = { + "room_id": self.get_room_id(), + "season_id": self.get_season_id(), + "ep_id": self.get_episode_id(), + "csrf": self.credential.bili_jct, + "platform": "web", + } + res = ( + await Api(credential=self.credential, no_csrf=True, **api) + .update_params(**params) + .result + ) + return res["room_info"]["share_url"].split("&token=")[-1] + + +async def create( + season_id: int, + episode_id: int, + is_open: bool = False, + credential: Credential = None, +) -> WatchRoom: + """ + 创建放映室 + + Args: + + season_id (int) 每季度的 ID + + ep_id (int) 剧集 ID + + is_open (bool) 是否公开 + + credential (Credential) 凭据 + + Returns: + Watchroom:放映室 + """ + global watch_room_bangumi_cache + + if credential is None: + credential = Credential() + + api = API["operate"]["create"] + data = { + "season_id": season_id, + "episode_id": episode_id, + "is_open": 1 if is_open else 0, + "csrf": credential.bili_jct, + "platform": "web", + } + room_id = ( + await Api(credential=credential, no_csrf=True, **api).update_data(**data).result + )["room_id"] + watch_room_bangumi_cache[room_id] = [season_id, episode_id] + return WatchRoom(room_id=room_id, credential=credential) + + +async def match( + season_id: int, + season_type: SeasonType = SeasonType.ANIME, + credential: Credential = None, +) -> WatchRoom: + """ + 匹配放映室 + + Args: + + season_id (int) 季度 ID + + season_type (str) 季度类型 + + Returns: + Watchroom:放映室 + """ + if credential is None: + credential = Credential() + + api = API["operate"]["match"] + data = { + "season_id": season_id, + "season_type": season_type.value, + "csrf": credential.bili_jct, + "platform": "web", + } + return WatchRoom( + ( + await Api(credential=credential, no_csrf=True, **api) + .update_data(**data) + .result + )["room_id"], + credential=credential, + )