diff --git a/README-en.md b/README-en.md index 5e497ba..977ba5c 100644 --- a/README-en.md +++ b/README-en.md @@ -2,9 +2,9 @@ [简体中文](./README.md) | English -> Welcome to use, feel free to provide feedback, and contribute to this project via PR. Please do not use it for any purpose that violates the community rules. +> Welcome to use, provide feedback, and contribute to this project via PR. Please do not use it for purposes that violate community guidelines. -`bilitool` is a Python toolkit for logging in, downloading videos, uploading videos to Bilibili, and more. It can be operated via command-line CLI or used as a library in other projects. +`bilitool` is a Python toolkit that provides functionalities such as persistent login, video download, and video upload to Bilibili. It can be operated via command-line interface (CLI) or used as a library in other projects. ## Features @@ -13,19 +13,26 @@ - `logout` Log out - `check` Check login status - `upload` Upload videos - - Supports various custom parameters for uploading - - Supports YAML configuration and parsing for video uploads + - Supports various custom parameters for upload + - Supports video upload via YAML configuration and parsing + - Displays upload progress bar - `download` Download videos + - Supports downloading by `bvid` and `avid` - Supports downloading danmaku (comments) - Supports downloading in various qualities - Supports downloading multi-part videos + - Displays download progress bar - `ip` Display request IP address - - Supports querying specified IP addresses -- `list` Query the status of past video submissions + - Supports querying specified IP address +- `list` Query the status of past uploaded videos of the account + - Supports querying videos with various statuses - If a video fails review, the reason will be displayed -- Display published video information (planned support) -- Display upload progress (in development) -- Append videos to existing videos (in development) +- `convert` Convert video IDs + - Supports conversion between `bvid` and `avid` +- `show` Display detailed video information + - Supports viewing basic video information and interaction status data +- Add more detailed log logs (planned support) +- Append videos to existing videos (planned support) ## Usage @@ -44,10 +51,10 @@ Help information: ``` usage: bilitool [-h] [-V] {login,logout,upload,check,download,list,ip} ... -The Python toolkit package and cli designed for interaction with Bilibili +The Python toolkit package and CLI designed for interaction with Bilibili positional arguments: - {login,logout,upload,check,download,list,ip} + {login,logout,upload,check,download,list,show,convert,ip} Subcommands login Login and save the cookie logout Logout the current account @@ -55,7 +62,9 @@ positional arguments: check Check if the user is logged in download Download the video list Get the uploaded video list - ip Get the ip info + show Show the video detailed info + convert Convert between avid and bvid + ip Get the IP info options: -h, --help show this help message and exit @@ -100,7 +109,7 @@ bilitool logout ### Upload -> Note: The upload function requires login first. After logging in, the login status will be remembered, so you don't need to log in again for the next upload. +> Note: The upload function requires login first. After logging in, the login status will be remembered, and you won't need to log in again for the next upload. `bilitool upload -h` prints help information: @@ -144,7 +153,7 @@ bilitool upload /path/to/your/video.mp4 -y /path/to/your/upload/template.yaml ### Download -> Note: To download videos in high quality or above, you need to log in first to obtain the download. +> Note: To download videos in high definition or above, you need to log in first to obtain the download. `bilitool download -h` prints help information: @@ -166,11 +175,11 @@ options: Example: ```bash -# Download the video with bvid, download danmaku, set quality to 1080p HD, chunk size to 1024, and download all videos if there are multiple parts +# Download the video with the bvid, download danmaku, set quality to 1080p HD, chunk size to 1024, and download all videos if there are multiple parts bilitool download bvid --danmaku --quality 80 --chunksize 1024 --multiple ``` -### Query Recent Video Submission Status +### Query Recent Video Upload Status `bilitool list -h` prints help information: @@ -186,12 +195,54 @@ options: Example: ```bash -# By default, display the recent 20 video submissions +# By default, display information of the 20 most recent uploaded videos bilitool list -# Query the recent 10 video submissions that failed review +# Query the 10 most recent videos that failed review bilitool list --size 10 --status not_pubed ``` +### Query Detailed Video Information + +`bilitool show -h` prints help information: + +``` +usage: bilitool show [-h] bvid + +positional arguments: + vid The avid or bvid of the video + +options: + -h, --help show this help message and exit +``` + +Example: + +```bash +# Query detailed video information +bilitool show +``` + +### Convert Video ID + +`bilitool convert -h` prints help information: + +``` +usage: bilitool convert [-h] vid + +positional arguments: + vid The avid or bvid of the video + +options: + -h, --help show this help message and exit +``` + +Example: + +```bash +# Convert video ID: input bvid to output avid, input avid to output bvid +bilitool convert +``` + ### Query IP Address `bilitool ip -h` prints help information: @@ -214,5 +265,4 @@ bilitool ip --ip 8.8.8.8 ## Acknowledgments -- Thanks to [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) for providing the API collection. -- Thanks to [biliup-rs](https://github.com/biliup/biliup-rs) for providing direction. \ No newline at end of file +- Thanks to [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) for providing the API collection. \ No newline at end of file diff --git a/README.md b/README.md index 0958eb6..6db0c30 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,24 @@ - `upload` 上传视频 - 支持多种自定义参数上传 - 支持上传视频的 yaml 配置与解析 + - 显示上传进度条 - `download` 下载视频 + - 支持 `bvid` 和 `avid` 两种编号下载 - 支持下载弹幕 - 支持下载多种画质 - 支持下载多 p 视频 + - 显示下载进度条 - `ip` 显示请求 IP 地址 - 支持查询指定 IP 地址 -- `list` 查询账号过往投稿视频状态 +- `list` 查询本账号过往投稿视频状态 + - 支持查询多种状态的视频 - 若视频审核未通过,同时会显示原因 -- 显示已发布的视频信息(预计支持) -- 显示上传进度(正在开发) -- 追加视频到已有的视频(正在开发) +- `convert` 查询转换视频编号 + - 支持 `bvid` 和 `avid` 两种编号互转 +- `show` 显示视频详细信息 + - 支持查看视频基本信息以及互动状态数据 +- 添加更详细的 log 日志(预计支持) +- 追加视频到已有的视频(预计支持) ## 使用方法 @@ -47,7 +54,7 @@ usage: bilitool [-h] [-V] {login,logout,upload,check,download,list,ip} ... The Python toolkit package and cli designed for interaction with Bilibili positional arguments: - {login,logout,upload,check,download,list,ip} + {login,logout,upload,check,download,list,show,convert,ip} Subcommands login Login and save the cookie logout Logout the current account @@ -55,6 +62,8 @@ positional arguments: check Check if the user is logged in download Download the video list Get the uploaded video list + show Show the video detailed info + convert Convert between avid and bvid ip Get the ip info options: @@ -192,6 +201,48 @@ bilitool list bilitool list --size 10 --status not_pubed ``` +### 查询视频详细信息 + +`bilitool show -h ` 打印帮助信息: + +```bash +usage: bilitool show [-h] bvid + +positional arguments: + vid The avid or bvid of the video + +options: + -h, --help show this help message and exit +``` + +示例: + +```bash +# 查询视频详细信息 +bilitool show +``` + +### 查询转换视频编号 + +`bilitool convert -h ` 打印帮助信息: + +```bash +usage: bilitool convert [-h] vid + +positional arguments: + vid The avid or bvid of the video + +options: + -h, --help show this help message and exit +``` + +示例: + +```bash +# 转换视频编号:输入 bvid 输出 avid,输入 avid 输出 bvid +bilitool convert +``` + ### 查询 IP 地址 `bilitool ip -h ` 打印帮助信息: @@ -215,4 +266,3 @@ bilitool ip --ip 8.8.8.8 ## Acknowledgments - 感谢 [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) 提供的 API 集合。 -- 感谢 [biliup-rs](https://github.com/biliup/biliup-rs) 提供的方向。 diff --git a/bilitool/cli.py b/bilitool/cli.py index 7383213..25568eb 100644 --- a/bilitool/cli.py +++ b/bilitool/cli.py @@ -15,6 +15,7 @@ from bilitool.controller.download_controller import DownloadController from bilitool.utils.get_ip_info import IPInfo from bilitool.feed.bili_video_list import BiliVideoList +from bilitool.utils.check_format import CheckFormat def cli(): logging.basicConfig( @@ -22,7 +23,7 @@ def cli(): level=logging.INFO ) parser = argparse.ArgumentParser(description='The Python toolkit package and cli designed for interaction with Bilibili') - parser.add_argument('-V', '--version', action='version', version='bilitool 0.1.0', help='Print version information') + parser.add_argument('-V', '--version', action='version', version='bilitool 0.1.1', help='Print version information') subparsers = parser.add_subparsers(dest='subcommand', help='Subcommands') @@ -53,7 +54,7 @@ def cli(): # Download subcommand download_parser = subparsers.add_parser('download', help='Download the video') - download_parser.add_argument('bvid', help='(required) the bvid of video') + download_parser.add_argument('vid', help='(required) the bvid or avid of video') download_parser.add_argument('--danmaku', action='store_true', help='(default is false) download the danmaku of video') download_parser.add_argument('--quality', type=int, default=64, help='(default is 64) the resolution of video') download_parser.add_argument('--chunksize', type=int, default=1024, help='(default is 1024) the chunk size of video') @@ -64,6 +65,14 @@ def cli(): list_parser.add_argument('--size', type=int, default=20, help='(default is 20) the size of video list') list_parser.add_argument('--status', default='pubed,not_pubed,is_pubing', help='(default is all) the status of video list: pubed, not_pubed, is_pubing') + # Show subcommand + show_parser = subparsers.add_parser('show', help='Show the video detailed info') + show_parser.add_argument('vid', help='The avid or bvid of the video') + + # Convert subcommand + convert_parser = subparsers.add_parser('convert', help='Convert between avid and bvid') + convert_parser.add_argument('vid', help='The avid or bvid of the video') + # IP subcommand ip_parser = subparsers.add_parser('ip', help='Get the ip info') ip_parser.add_argument('--ip', default='', help='(default is your request ip) The ip address') @@ -103,8 +112,9 @@ def cli(): # print(args) download_metadata = DownloadController.package_download_metadata(args.danmaku, args.quality, args.chunksize, args.multiple) ioer().update_multiple_config(args.subcommand, download_metadata) + bvid = CheckFormat().only_bvid(args.vid) download_controller = DownloadController() - download_controller.download_video(args.bvid) + download_controller.download_video(bvid) if args.subcommand == 'ip': IPInfo.get_ip_address(args.ip) @@ -112,6 +122,13 @@ def cli(): if args.subcommand == 'list': bili = BiliVideoList() bili.print_video_list_info(bili.get_member_video_list(args.size, args.status)) + + if args.subcommand == 'convert': + CheckFormat().convert_bv_and_av(args.vid) + + if args.subcommand == 'show': + bvid = CheckFormat().only_bvid(args.vid) + BiliVideoList().print_video_info_via_bvid(bvid) if __name__ == '__main__': cli() \ No newline at end of file diff --git a/bilitool/download/bili_download.py b/bilitool/download/bili_download.py index 8b9bfca..a7d6b81 100644 --- a/bilitool/download/bili_download.py +++ b/bilitool/download/bili_download.py @@ -3,14 +3,9 @@ import requests import time import sys +from tqdm import tqdm from bilitool.authenticate.ioer import ioer -def print_progress(progress, total): - width = 40 - filled = int(progress / total * width) - empty = width - filled - return "■" * filled + " " * empty - class BiliDownloader: def __init__(self) -> None: @@ -37,21 +32,12 @@ def download_video(self, url, name): if response.status_code == 200: with open(name, 'wb') as file: content_length = int(response.headers['Content-Length']) - progress = 0 - start_time = time.time() + progress_bar = tqdm(total=content_length, unit='B', unit_scale=True, desc=name) for chunk in response.iter_content(chunk_size=self.config["download"]["chunksize"]): file.write(chunk) - progress += len(chunk) - now_time = time.time() - estimated_time = (content_length - progress) / \ - progress * (now_time - start_time) - sys.stdout.write("\r[{}] {:.2f}% Already {:.0f}s and estimated {:.0f}s".format( - print_progress(progress, content_length), - progress / content_length * 100, - now_time - start_time, - estimated_time)) - sys.stdout.flush() - sys.stdout.write("\nDownload completed") + progress_bar.update(len(chunk)) + progress_bar.close() + print("\nDownload completed") else: print(name, "Download failed") diff --git a/bilitool/feed/__init__.py b/bilitool/feed/__init__.py index 4002795..0618058 100644 --- a/bilitool/feed/__init__.py +++ b/bilitool/feed/__init__.py @@ -37,4 +37,22 @@ def VideoListInfo(): -40: "定时发布", -50: "仅UP主可见", -100: "用户删除" +} + +video_info_dict = { + 'title': '标题', + 'desc': '描述', + 'duration': '时长', + 'pubdate': '发布日期', + 'owner_name': '作者名称', + 'tname': '分区', + 'copyright': '版权', + 'width': '宽', + 'height': '高', + 'stat_view': '观看数', + 'stat_danmaku': '弹幕数', + 'stat_reply': '评论数', + 'stat_coin': '硬币数', + 'stat_share': '分享数', + 'stat_like': '点赞数' } \ No newline at end of file diff --git a/bilitool/feed/bili_video_list.py b/bilitool/feed/bili_video_list.py index 402dda1..b1177c4 100644 --- a/bilitool/feed/bili_video_list.py +++ b/bilitool/feed/bili_video_list.py @@ -4,7 +4,7 @@ import requests from bilitool.authenticate.ioer import ioer from bilitool.authenticate.wbi_sign import WbiSign -from bilitool.feed import VideoListInfo, state_dict +from bilitool.feed import VideoListInfo, state_dict, video_info_dict class BiliVideoList(object): @@ -73,3 +73,44 @@ def get_video_info(self, bvid: str) -> dict: if resp.status_code != 200: raise Exception('HTTP ERROR') return resp.json() + + @staticmethod + def extract_video_info(response_data): + data = response_data.get('data', {}) + + video_info = { + # video info + 'title': data.get('title'), + 'desc': data.get('desc'), + 'duration': data.get('duration'), + 'pubdate': data.get('pubdate'), + 'owner_name': data.get('owner', {}).get('name'), + 'tname': data.get('tname'), + 'copyright': data.get('copyright'), + 'width': data.get('dimension', {}).get('width'), + 'height': data.get('dimension', {}).get('height'), + # video status + 'stat_view': data.get('stat', {}).get('view'), + 'stat_danmaku': data.get('stat', {}).get('danmaku'), + 'stat_reply': data.get('stat', {}).get('reply'), + 'stat_coin': data.get('stat', {}).get('coin'), + 'stat_share': data.get('stat', {}).get('share'), + 'stat_like': data.get('stat', {}).get('like') + } + + return video_info + + @staticmethod + def print_video_info(video_info): + for key, value in video_info.items(): + if key == 'duration': + value = f"{value // 60}:{value % 60}" + elif key == 'pubdate': + value = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(value)) + elif key == 'copyright': + value = '原创' if value == 1 else '转载' + label = video_info_dict.get(key, key) + print(f"{label}: {value}") + + def print_video_info_via_bvid(self, bvid: str): + BiliVideoList.print_video_info(BiliVideoList.extract_video_info(self.get_video_info(bvid))) \ No newline at end of file diff --git a/bilitool/upload/bili_upload.py b/bilitool/upload/bili_upload.py index 713f67a..b22d690 100644 --- a/bilitool/upload/bili_upload.py +++ b/bilitool/upload/bili_upload.py @@ -7,8 +7,8 @@ from math import ceil from json import dumps from pathlib import Path -from time import sleep import requests +from tqdm import tqdm from bilitool.utils.parse_cookies import parse_cookies # you can test your best cdn line https://member.bilibili.com/preupload?r=ping @@ -123,19 +123,21 @@ def upload_video_in_chunks(self, *, upos_uri, auth, upload_id, fileio, filesize, 'total': filesize, } # Single thread upload - for chunknum in range(chunks): - start = fileio.tell() - batchbytes = fileio.read(chunk_size) - params['partNumber'] = chunknum + 1 - params['chunk'] = chunknum - params['size'] = len(batchbytes) - params['start'] = start - params['end'] = fileio.tell() - res = self.session.put(url, params=params, data=batchbytes, headers={ - 'X-Upos-Auth': auth}) - assert res.status_code == 200 - self.logger.debug(f'Completed chunk{chunknum+1} uploading') - # print(res) + with tqdm(total=filesize, desc="Uploading video", unit="B", unit_scale=True) as pbar: + for chunknum in range(chunks): + start = fileio.tell() + batchbytes = fileio.read(chunk_size) + params['partNumber'] = chunknum + 1 + params['chunk'] = chunknum + params['size'] = len(batchbytes) + params['start'] = start + params['end'] = fileio.tell() + res = self.session.put(url, params=params, data=batchbytes, headers={ + 'X-Upos-Auth': auth}) + assert res.status_code == 200 + self.logger.debug(f'Completed chunk{chunknum+1} uploading') + pbar.update(len(batchbytes)) + # print(res) def finish_upload(self, *, upos_uri, auth, filename, upload_id, biz_id, chunks): """Notify the all chunks have been uploaded. diff --git a/bilitool/utils/check_format.py b/bilitool/utils/check_format.py new file mode 100644 index 0000000..bc2b12c --- /dev/null +++ b/bilitool/utils/check_format.py @@ -0,0 +1,62 @@ +# Copyright (c) 2025 bilitool + +class CheckFormat(object): + def __init__(self): + self.XOR_CODE = 23442827791579 + self.MASK_CODE = 2251799813685247 + self.MAX_AID = 1 << 51 + self.ALPHABET = "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf" + self.ENCODE_MAP = 8, 7, 0, 5, 1, 3, 2, 4, 6 + self.DECODE_MAP = tuple(reversed(self.ENCODE_MAP)) + + self.BASE = len(self.ALPHABET) + self.PREFIX = "BV1" + self.PREFIX_LEN = len(self.PREFIX) + self.CODE_LEN = len(self.ENCODE_MAP) + + @staticmethod + def is_bvid(bvid: str) -> bool: + if len(bvid) != 12: + return False + if bvid[0:2] != 'BV': + return False + return True + + @staticmethod + def is_chinese(word: str) -> bool: + for ch in word: + if '\u4e00' <= ch <= '\u9fff': + return True + return False + + # https://github.com/SocialSisterYi/bilibili-API-collect/blob/e5fbfed42807605115c6a9b96447f6328ca263c5/docs/misc/bvid_desc.md + + def av2bv(self, aid: int) -> str: + bvid = [""] * 9 + tmp = (self.MAX_AID | aid) ^ self.XOR_CODE + for i in range(self.CODE_LEN): + bvid[self.ENCODE_MAP[i]] = self.ALPHABET[tmp % self.BASE] + tmp //= self.BASE + return self.PREFIX + "".join(bvid) + + def bv2av(self, bvid: str) -> int: + assert bvid[:3] == self.PREFIX + + bvid = bvid[3:] + tmp = 0 + for i in range(self.CODE_LEN): + idx = self.ALPHABET.index(bvid[self.DECODE_MAP[i]]) + tmp = tmp * self.BASE + idx + return (tmp & self.MASK_CODE) ^ self.XOR_CODE + + def convert_bv_and_av(self, vid: str): + if self.is_bvid(str(vid)): + print("The avid of the video is: ", self.bv2av(str(vid))) + else: + print("The bvid of the video is: ", self.av2bv(int(vid))) + + def only_bvid(self, vid: str): + if self.is_bvid(str(vid)): + return vid + else: + return self.av2bv(int(vid)) diff --git a/bilitool/utils/get_ip_info.py b/bilitool/utils/get_ip_info.py index f81ef78..e19b772 100644 --- a/bilitool/utils/get_ip_info.py +++ b/bilitool/utils/get_ip_info.py @@ -3,6 +3,23 @@ import http.client import urllib.parse import json +import inspect + +def suppress_print_in_unittest(func): + def wrapper(*args, **kwargs): + # Check if the caller is a unittest + for frame_info in inspect.stack(): + if 'unittest' in frame_info.filename: + # If called from unittest, suppress print + return func(*args, **kwargs) + + result = func(*args, **kwargs) + if result: + addr, isp, location, position = result + print(f"IP: {addr}, ISP: {isp}, Location: {location}, Position: {position}") + return result + return wrapper + class IPInfo: @staticmethod @@ -28,6 +45,7 @@ def get_ip_address(ip=None): return IPInfo.print_ip_info(data) @staticmethod + @suppress_print_in_unittest def print_ip_info(ip_info): if ip_info['code'] != 0: return None @@ -36,5 +54,4 @@ def print_ip_info(ip_info): isp = ip_info['data']['isp'] location = ip_info['data']['country'] + ip_info['data']['province'] + ip_info['data']['city'] position = ip_info['data']['latitude'] + ',' + ip_info['data']['longitude'] - print(f"IP: {addr}, ISP: {isp}, Location: {location}, Position: {position}") return addr, isp, location, position \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 314648f..7242af7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bilitool" # make sure your module name is unique -version = "0.1.0" +version = "0.1.1" authors = [ { name="timerring"}, ] @@ -20,7 +20,8 @@ classifiers = [ dependencies = [ "PyYAML==6.0.2", "qrcode==8.0", - "Requests==2.32.3" + "Requests==2.32.3", + "tqdm==4.67.1" ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index 0cd7c5c..dbb1d39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ PyYAML==6.0.2 qrcode==8.0 -Requests==2.32.3 \ No newline at end of file +Requests==2.32.3 +tqdm==4.67.1 \ No newline at end of file diff --git a/tests/test_feed.py b/tests/test_feed.py index 76ec73d..d2186ed 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -6,4 +6,8 @@ class TestBiliList(unittest.TestCase): def test_get_bili_video_list(self): bili = BiliVideoList() - bili.print_video_list_info(bili.get_bili_video_list(50, 'not_pubed')) \ No newline at end of file + bili.print_video_list_info(bili.get_bili_video_list(50, 'not_pubed')) + + def test_print_video_info_via_bvid(self): + bili = BiliVideoList() + bili.print_video_info_via_bvid('BV1pCr6YcEgD') \ No newline at end of file diff --git a/tests/test_get_ip_info.py b/tests/test_get_ip_info.py deleted file mode 100644 index 4692b0d..0000000 --- a/tests/test_get_ip_info.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2025 bilitool - -import unittest -from bilitool.utils.get_ip_info import IPInfo - -class TestIPInfo(unittest.TestCase): - def test_get_ip_address(self): - self.assertEqual(IPInfo.get_ip_address('12.12.12.12'), - ('12.12.12.12', 'att.com', '美国阿拉斯加州安克雷奇', '61.108841,-149.373145')) - \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..6f99571 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,21 @@ +# Copyright (c) 2025 bilitool + +import unittest +from bilitool.utils.get_ip_info import IPInfo +from bilitool.utils.check_format import CheckFormat + + +class TestIPInfo(unittest.TestCase): + def test_get_ip_address(self): + self.assertEqual(IPInfo.get_ip_address('12.12.12.12'), + ('12.12.12.12', 'att.com', '美国阿拉斯加州安克雷奇', '61.108841,-149.373145')) + + +class TestCheckFormat(unittest.TestCase): + def test_av2bv(self): + check_format = CheckFormat() + self.assertEqual(check_format.av2bv(2), 'BV1xx411c7mD') + + def test_bv2av(self): + check_format = CheckFormat() + self.assertEqual(check_format.bv2av('BV1y7411Q7Eq'), 99999999)