├── .gitignore ├── .travis.yml ├── BiliDrive ├── __init__.py ├── __main__.py └── bilibili.py ├── LICENSE ├── README.md ├── icon.ico ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | if: 'tag IS blank' 2 | env: 3 | global: 4 | - TRAVIS_TAG=v1.8 5 | jobs: 6 | include: 7 | - 8 | name: 'Python 3.7.5 on Linux (AMD64)' 9 | os: linux 10 | dist: bionic 11 | arch: amd64 12 | language: python 13 | python: 3.7.5 14 | env: RELEASE_FILENAME=bilidrive-$TRAVIS_TAG-linux-amd64.tar.gz 15 | - 16 | name: 'Python 3.7.5 on Linux (ARM64)' 17 | os: linux 18 | dist: bionic 19 | arch: arm64 20 | language: python 21 | python: 3.7.5 22 | env: ['PATH=~/.ruby/bin:$PATH', GEM_HOME=~/.ruby, RELEASE_FILENAME=bilidrive-$TRAVIS_TAG-linux-arm64.tar.gz] 23 | - 24 | name: 'Python 3.7.4 on macOS (AMD64)' 25 | os: osx 26 | osx_image: xcode11.2 27 | arch: amd64 28 | language: shell 29 | env: RELEASE_FILENAME=bilidrive-$TRAVIS_TAG-macos-amd64.zip 30 | - 31 | name: 'Python 3.7.5 on Windows (AMD64)' 32 | os: windows 33 | arch: amd64 34 | language: shell 35 | env: ['PATH=/c/Python37:/c/Python37/Scripts:$PATH', RELEASE_FILENAME=bilidrive-$TRAVIS_TAG-windows-amd64.zip] 36 | before_install: 'choco install python --version 3.7.5' 37 | install: 38 | - 'if [ "$TRAVIS_OS_NAME" = "windows" ]; then python -m pip install -U pip; else pip3 install -U pip; fi' 39 | - 'pip3 install -r requirements.txt' 40 | - 'pip3 install pyinstaller' 41 | script: 42 | - 'if [ "$TRAVIS_OS_NAME" = "windows" ]; then python -m BiliDrive -v; else python3 -m BiliDrive -v; fi' 43 | - 'pyinstaller -F -n BiliDrive -i icon.ico BiliDrive/__main__.py' 44 | - 'mkdir -p release/BiliDrive' 45 | - 'cp {dist/*,LICENSE,README.md} release/BiliDrive' 46 | - 'cd release' 47 | - 'if [ "$TRAVIS_OS_NAME" = "windows" ]; then 7z a -tzip $RELEASE_FILENAME BiliDrive; elif [ "$TRAVIS_OS_NAME" = "osx" ]; then zip -r $RELEASE_FILENAME BiliDrive; else tar -czvf $RELEASE_FILENAME BiliDrive; fi' 48 | - 'cd ..' 49 | deploy: 50 | - 51 | provider: releases 52 | api_key: $GITHUB_OAUTH_TOKEN 53 | file: release/$RELEASE_FILENAME 54 | overwrite: true 55 | skip_cleanup: true 56 | - 57 | provider: pypi 58 | user: __token__ 59 | password: $PYPI_API_TOKEN 60 | distributions: 'sdist bdist_wheel' 61 | skip_existing: true 62 | on: 63 | condition: ['$TRAVIS_OS_NAME = linux', '$TRAVIS_CPU_ARCH = amd64'] 64 | -------------------------------------------------------------------------------- /BiliDrive/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.7 2 | # -*- coding: utf-8 -*- 3 | 4 | """BiliDrive 哔哩哔哩云 5 | https://github.com/Hsury/BiliDrive""" 6 | 7 | __author__ = "Hsury" 8 | __email__ = "i@hsury.com" 9 | __license__ = "SATA" 10 | __version__ = "2019.12.22" 11 | -------------------------------------------------------------------------------- /BiliDrive/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.7 2 | # -*- coding: utf-8 -*- 3 | 4 | import argparse 5 | import hashlib 6 | import json 7 | import math 8 | import os 9 | import re 10 | import requests 11 | import shlex 12 | import signal 13 | import struct 14 | import sys 15 | import threading 16 | import time 17 | import traceback 18 | import types 19 | from BiliDrive import __version__ 20 | from BiliDrive.bilibili import Bilibili 21 | 22 | log = Bilibili._log 23 | 24 | bundle_dir = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__)) 25 | 26 | default_url = lambda sha1: f"http://i0.hdslb.com/bfs/album/{sha1}.x-ms-bmp" 27 | meta_string = lambda url: ("bdrive://" + re.findall(r"[a-fA-F0-9]{40}", url)[0]) if re.match(r"^http(s?)://i0.hdslb.com/bfs/album/[a-fA-F0-9]{40}.x-ms-bmp$", url) else url 28 | size_string = lambda byte: f"{byte / 1024 / 1024 / 1024:.2f} GB" if byte > 1024 * 1024 * 1024 else f"{byte / 1024 / 1024:.2f} MB" if byte > 1024 * 1024 else f"{byte / 1024:.2f} KB" if byte > 1024 else f"{int(byte)} B" 29 | 30 | def bmp_header(data): 31 | return b"BM" \ 32 | + struct.pack(" 5: 99 | return None 100 | content.append(chunk) 101 | last_chunk_time = time.time() 102 | return b"".join(content) 103 | except: 104 | return None 105 | 106 | def read_history(): 107 | try: 108 | with open(os.path.join(bundle_dir, "history.json"), "r", encoding="utf-8") as f: 109 | history = json.loads(f.read()) 110 | except: 111 | history = {} 112 | return history 113 | 114 | def read_in_chunk(file_name, chunk_size=16 * 1024 * 1024, chunk_number=-1): 115 | chunk_counter = 0 116 | with open(file_name, "rb") as f: 117 | while True: 118 | data = f.read(chunk_size) 119 | if data != b"" and (chunk_number == -1 or chunk_counter < chunk_number): 120 | yield data 121 | chunk_counter += 1 122 | else: 123 | return 124 | 125 | def login_handle(args): 126 | bilibili = Bilibili() 127 | if bilibili.login(username=args.username, password=args.password): 128 | bilibili.get_user_info() 129 | with open(os.path.join(bundle_dir, "cookies.json"), "w", encoding="utf-8") as f: 130 | f.write(json.dumps(bilibili.get_cookies(), ensure_ascii=False, indent=2)) 131 | 132 | def upload_handle(args): 133 | def core(index, block): 134 | try: 135 | block_sha1 = calc_sha1(block, hexdigest=True) 136 | full_block = bmp_header(block) + block 137 | full_block_sha1 = calc_sha1(full_block, hexdigest=True) 138 | url = is_skippable(full_block_sha1) 139 | if url: 140 | log(f"分块{index + 1}/{block_num}上传完毕") 141 | block_dict[index] = { 142 | 'url': url, 143 | 'size': len(block), 144 | 'sha1': block_sha1, 145 | } 146 | else: 147 | # log(f"分块{index + 1}/{block_num}开始上传") 148 | for _ in range(10): 149 | if terminate_flag.is_set(): 150 | return 151 | response = image_upload(full_block, cookies) 152 | if response: 153 | if response['code'] == 0: 154 | url = response['data']['image_url'] 155 | log(f"分块{index + 1}/{block_num}上传完毕") 156 | block_dict[index] = { 157 | 'url': url, 158 | 'size': len(block), 159 | 'sha1': block_sha1, 160 | } 161 | return 162 | elif response['code'] == -4: 163 | terminate_flag.set() 164 | log(f"分块{index + 1}/{block_num}第{_ + 1}次上传失败, 请重新登录") 165 | return 166 | log(f"分块{index + 1}/{block_num}第{_ + 1}次上传失败") 167 | else: 168 | terminate_flag.set() 169 | except: 170 | terminate_flag.set() 171 | traceback.print_exc() 172 | finally: 173 | done_flag.release() 174 | 175 | def is_skippable(sha1): 176 | url = default_url(sha1) 177 | headers = { 178 | 'Referer': "http://t.bilibili.com/", 179 | 'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.79 Safari/537.36", 180 | } 181 | for _ in range(5): 182 | try: 183 | response = requests.head(url, headers=headers, timeout=10) 184 | return url if response.status_code == 200 else None 185 | except: 186 | pass 187 | return None 188 | 189 | def write_history(first_4mb_sha1, meta_dict, url): 190 | history = read_history() 191 | history[first_4mb_sha1] = meta_dict 192 | history[first_4mb_sha1]['url'] = url 193 | with open(os.path.join(bundle_dir, "history.json"), "w", encoding="utf-8") as f: 194 | f.write(json.dumps(history, ensure_ascii=False, indent=2)) 195 | 196 | start_time = time.time() 197 | file_name = args.file 198 | if not os.path.exists(file_name): 199 | log(f"文件{file_name}不存在") 200 | return None 201 | if os.path.isdir(file_name): 202 | log("暂不支持上传文件夹") 203 | return None 204 | log(f"上传: {os.path.basename(file_name)} ({size_string(os.path.getsize(file_name))})") 205 | first_4mb_sha1 = calc_sha1(read_in_chunk(file_name, chunk_size=4 * 1024 * 1024, chunk_number=1), hexdigest=True) 206 | history = read_history() 207 | if first_4mb_sha1 in history: 208 | url = history[first_4mb_sha1]['url'] 209 | log(f"文件已于{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(history[first_4mb_sha1]['time']))}上传, 共有{len(history[first_4mb_sha1]['block'])}个分块") 210 | log(f"META URL -> {meta_string(url)}") 211 | return url 212 | try: 213 | with open(os.path.join(bundle_dir, "cookies.json"), "r", encoding="utf-8") as f: 214 | cookies = json.loads(f.read()) 215 | except: 216 | log("Cookies加载失败, 请先登录") 217 | return None 218 | log(f"线程数: {args.thread}") 219 | done_flag = threading.Semaphore(0) 220 | terminate_flag = threading.Event() 221 | thread_pool = [] 222 | block_dict = {} 223 | block_num = math.ceil(os.path.getsize(file_name) / (args.block_size * 1024 * 1024)) 224 | for index, block in enumerate(read_in_chunk(file_name, chunk_size=args.block_size * 1024 * 1024)): 225 | if len(thread_pool) >= args.thread: 226 | done_flag.acquire() 227 | if not terminate_flag.is_set(): 228 | thread_pool.append(threading.Thread(target=core, args=(index, block))) 229 | thread_pool[-1].start() 230 | else: 231 | log("已终止上传, 等待线程回收") 232 | break 233 | for thread in thread_pool: 234 | thread.join() 235 | if terminate_flag.is_set(): 236 | return None 237 | sha1 = calc_sha1(read_in_chunk(file_name), hexdigest=True) 238 | meta_dict = { 239 | 'time': int(time.time()), 240 | 'filename': os.path.basename(file_name), 241 | 'size': os.path.getsize(file_name), 242 | 'sha1': sha1, 243 | 'block': [block_dict[i] for i in range(len(block_dict))], 244 | } 245 | meta = json.dumps(meta_dict, ensure_ascii=False).encode("utf-8") 246 | full_meta = bmp_header(meta) + meta 247 | for _ in range(10): 248 | response = image_upload(full_meta, cookies) 249 | if response and response['code'] == 0: 250 | url = response['data']['image_url'] 251 | log("元数据上传完毕") 252 | log(f"{meta_dict['filename']} ({size_string(meta_dict['size'])}) 上传完毕, 用时{time.time() - start_time:.1f}秒, 平均速度{size_string(meta_dict['size'] / (time.time() - start_time))}/s") 253 | log(f"META URL -> {meta_string(url)}") 254 | write_history(first_4mb_sha1, meta_dict, url) 255 | return url 256 | log(f"元数据第{_ + 1}次上传失败") 257 | else: 258 | return None 259 | 260 | def download_handle(args): 261 | def core(index, block_dict): 262 | try: 263 | # log(f"分块{index + 1}/{len(meta_dict['block'])}开始下载") 264 | for _ in range(10): 265 | if terminate_flag.is_set(): 266 | return 267 | block = image_download(block_dict['url']) 268 | if block: 269 | block = block[62:] 270 | if calc_sha1(block, hexdigest=True) == block_dict['sha1']: 271 | file_lock.acquire() 272 | f.seek(block_offset(index)) 273 | f.write(block) 274 | file_lock.release() 275 | log(f"分块{index + 1}/{len(meta_dict['block'])}下载完毕") 276 | return 277 | else: 278 | log(f"分块{index + 1}/{len(meta_dict['block'])}校验未通过") 279 | else: 280 | log(f"分块{index + 1}/{len(meta_dict['block'])}第{_ + 1}次下载失败") 281 | else: 282 | terminate_flag.set() 283 | except: 284 | terminate_flag.set() 285 | traceback.print_exc() 286 | finally: 287 | done_flag.release() 288 | 289 | def block_offset(index): 290 | return sum(meta_dict['block'][i]['size'] for i in range(index)) 291 | 292 | def is_overwritable(file_name): 293 | if args.force: 294 | return True 295 | else: 296 | return (input("文件已存在, 是否覆盖? [y/N] ") in ["y", "Y"]) 297 | 298 | start_time = time.time() 299 | meta_dict = fetch_meta(args.meta) 300 | if meta_dict: 301 | file_name = args.file if args.file else meta_dict['filename'] 302 | log(f"下载: {os.path.basename(file_name)} ({size_string(meta_dict['size'])}), 共有{len(meta_dict['block'])}个分块, 上传于{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(meta_dict['time']))}") 303 | else: 304 | log("元数据解析失败") 305 | return None 306 | log(f"线程数: {args.thread}") 307 | download_block_list = [] 308 | if os.path.exists(file_name): 309 | if os.path.getsize(file_name) == meta_dict['size'] and calc_sha1(read_in_chunk(file_name), hexdigest=True) == meta_dict['sha1']: 310 | log("文件已存在, 且与服务器端内容一致") 311 | return file_name 312 | elif is_overwritable(file_name): 313 | with open(file_name, "rb") as f: 314 | for index, block_dict in enumerate(meta_dict['block']): 315 | f.seek(block_offset(index)) 316 | if calc_sha1(f.read(block_dict['size']), hexdigest=True) == block_dict['sha1']: 317 | # log(f"分块{index + 1}/{len(meta_dict['block'])}校验通过") 318 | pass 319 | else: 320 | # log(f"分块{index + 1}/{len(meta_dict['block'])}校验未通过") 321 | download_block_list.append(index) 322 | log(f"{len(download_block_list)}/{len(meta_dict['block'])}个分块待下载") 323 | else: 324 | return None 325 | else: 326 | download_block_list = list(range(len(meta_dict['block']))) 327 | done_flag = threading.Semaphore(0) 328 | terminate_flag = threading.Event() 329 | file_lock = threading.Lock() 330 | thread_pool = [] 331 | with open(file_name, "r+b" if os.path.exists(file_name) else "wb") as f: 332 | for index in download_block_list: 333 | if len(thread_pool) >= args.thread: 334 | done_flag.acquire() 335 | if not terminate_flag.is_set(): 336 | thread_pool.append(threading.Thread(target=core, args=(index, meta_dict['block'][index]))) 337 | thread_pool[-1].start() 338 | else: 339 | log("已终止下载, 等待线程回收") 340 | break 341 | for thread in thread_pool: 342 | thread.join() 343 | if terminate_flag.is_set(): 344 | return None 345 | f.truncate(sum(block['size'] for block in meta_dict['block'])) 346 | log(f"{os.path.basename(file_name)} ({size_string(meta_dict['size'])}) 下载完毕, 用时{time.time() - start_time:.1f}秒, 平均速度{size_string(meta_dict['size'] / (time.time() - start_time))}/s") 347 | sha1 = calc_sha1(read_in_chunk(file_name), hexdigest=True) 348 | if sha1 == meta_dict['sha1']: 349 | log("文件校验通过") 350 | return file_name 351 | else: 352 | log("文件校验未通过") 353 | return None 354 | 355 | def info_handle(args): 356 | meta_dict = fetch_meta(args.meta) 357 | if meta_dict: 358 | print(f"文件名: {meta_dict['filename']}") 359 | print(f"大小: {size_string(meta_dict['size'])}") 360 | print(f"SHA-1: {meta_dict['sha1']}") 361 | print(f"上传时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(meta_dict['time']))}") 362 | print(f"分块数: {len(meta_dict['block'])}") 363 | for index, block_dict in enumerate(meta_dict['block']): 364 | print(f"分块{index + 1} ({size_string(block_dict['size'])}) URL: {block_dict['url']}") 365 | else: 366 | print("元数据解析失败") 367 | 368 | def history_handle(args): 369 | history = read_history() 370 | if history: 371 | for index, meta_dict in enumerate(history.values()): 372 | prefix = f"[{index + 1}]" 373 | print(f"{prefix} {meta_dict['filename']} ({size_string(meta_dict['size'])}), 共有{len(meta_dict['block'])}个分块, 上传于{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(meta_dict['time']))}") 374 | print(f"{' ' * len(prefix)} META URL -> {meta_string(meta_dict['url'])}") 375 | else: 376 | print(f"暂无历史记录") 377 | 378 | def main(): 379 | signal.signal(signal.SIGINT, lambda signum, frame: os.kill(os.getpid(), 9)) 380 | parser = argparse.ArgumentParser(prog="BiliDrive", description="Make Bilibili A Great Cloud Storage!", formatter_class=argparse.RawDescriptionHelpFormatter) 381 | parser.add_argument("-v", "--version", action="version", version=f"BiliDrive version: {__version__}") 382 | subparsers = parser.add_subparsers() 383 | login_parser = subparsers.add_parser("login", help="log in to bilibili") 384 | login_parser.add_argument("username", help="your bilibili username") 385 | login_parser.add_argument("password", help="your bilibili password") 386 | login_parser.set_defaults(func=login_handle) 387 | upload_parser = subparsers.add_parser("upload", help="upload a file") 388 | upload_parser.add_argument("file", help="name of the file to upload") 389 | upload_parser.add_argument("-b", "--block-size", default=4, type=int, help="block size in MB") 390 | upload_parser.add_argument("-t", "--thread", default=4, type=int, help="upload thread number") 391 | upload_parser.set_defaults(func=upload_handle) 392 | download_parser = subparsers.add_parser("download", help="download a file") 393 | download_parser.add_argument("meta", help="meta url") 394 | download_parser.add_argument("file", nargs="?", default="", help="new file name") 395 | download_parser.add_argument("-f", "--force", action="store_true", help="force to overwrite if file exists") 396 | download_parser.add_argument("-t", "--thread", default=8, type=int, help="download thread number") 397 | download_parser.set_defaults(func=download_handle) 398 | info_parser = subparsers.add_parser("info", help="show meta info") 399 | info_parser.add_argument("meta", help="meta url") 400 | info_parser.set_defaults(func=info_handle) 401 | history_parser = subparsers.add_parser("history", help="show upload history") 402 | history_parser.set_defaults(func=history_handle) 403 | shell = False 404 | while True: 405 | if shell: 406 | args = shlex.split(input("BiliDrive > ")) 407 | try: 408 | args = parser.parse_args(args) 409 | args.func(args) 410 | except: 411 | pass 412 | else: 413 | args = parser.parse_args() 414 | try: 415 | args.func(args) 416 | break 417 | except AttributeError: 418 | shell = True 419 | subparsers.add_parser("help", help="show this help message").set_defaults(func=lambda _: parser.parse_args(["--help"]).func()) 420 | subparsers.add_parser("version", help="show program's version number").set_defaults(func=lambda _: parser.parse_args(["--version"]).func()) 421 | subparsers.add_parser("exit", help="exit program").set_defaults(func=lambda _: os._exit(0)) 422 | parser.print_help() 423 | 424 | if __name__ == "__main__": 425 | main() 426 | -------------------------------------------------------------------------------- /BiliDrive/bilibili.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.7 2 | # -*- coding: utf-8 -*- 3 | 4 | import base64 5 | import hashlib 6 | import random 7 | import requests 8 | import rsa 9 | import time 10 | from urllib import parse 11 | 12 | class Bilibili: 13 | app_key = "1d8b6e7d45233436" 14 | 15 | def __init__(self): 16 | self._session = requests.Session() 17 | self._session.headers.update({'User-Agent': "Mozilla/5.0 BiliDroid/5.51.1 (bbcallen@gmail.com)"}) 18 | self.get_cookies = lambda: self._session.cookies.get_dict(domain=".bilibili.com") 19 | self.get_uid = lambda: self.get_cookies().get("DedeUserID", "") 20 | self.username = "" 21 | self.password = "" 22 | self.info = { 23 | 'ban': False, 24 | 'coins': 0, 25 | 'experience': { 26 | 'current': 0, 27 | 'next': 0, 28 | }, 29 | 'face': "", 30 | 'level': 0, 31 | 'nickname': "", 32 | } 33 | 34 | @staticmethod 35 | def _log(message): 36 | print(f"[{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}] {message}") 37 | 38 | def _requests(self, method, url, decode_level=2, retry=0, timeout=10, **kwargs): 39 | if method in ["get", "post"]: 40 | for _ in range(retry + 1): 41 | try: 42 | response = getattr(self._session, method)(url, timeout=timeout, **kwargs) 43 | return response.json() if decode_level == 2 else response.content if decode_level == 1 else response 44 | except: 45 | pass 46 | return None 47 | 48 | def _solve_captcha(self, image): 49 | url = "https://bili.dev:2233/captcha" 50 | payload = {'image': base64.b64encode(image).decode("utf-8")} 51 | response = self._requests("post", url, json=payload) 52 | return response['message'] if response and response.get("code") == 0 else None 53 | 54 | @staticmethod 55 | def calc_sign(param): 56 | salt = "560c52ccd288fed045859ed18bffd973" 57 | sign_hash = hashlib.md5() 58 | sign_hash.update(f"{param}{salt}".encode()) 59 | return sign_hash.hexdigest() 60 | 61 | # 登录 62 | def login(self, username, password): 63 | def get_key(): 64 | url = f"https://passport.bilibili.com/api/oauth2/getKey" 65 | payload = { 66 | 'appkey': Bilibili.app_key, 67 | 'sign': self.calc_sign(f"appkey={Bilibili.app_key}"), 68 | } 69 | while True: 70 | response = self._requests("post", url, data=payload) 71 | if response and response.get("code") == 0: 72 | return { 73 | 'key_hash': response['data']['hash'], 74 | 'pub_key': rsa.PublicKey.load_pkcs1_openssl_pem(response['data']['key'].encode()), 75 | } 76 | else: 77 | time.sleep(1) 78 | 79 | self.username = username 80 | self.password = password 81 | 82 | while True: 83 | key = get_key() 84 | key_hash, pub_key = key['key_hash'], key['pub_key'] 85 | url = f"https://passport.bilibili.com/api/v2/oauth2/login" 86 | param = f"appkey={Bilibili.app_key}&password={parse.quote_plus(base64.b64encode(rsa.encrypt(f'{key_hash}{self.password}'.encode(), pub_key)))}&username={parse.quote_plus(self.username)}" 87 | payload = f"{param}&sign={self.calc_sign(param)}" 88 | headers = {'Content-type': "application/x-www-form-urlencoded"} 89 | response = self._requests("post", url, data=payload, headers=headers) 90 | while True: 91 | if response and response.get("code") is not None: 92 | if response['code'] == -105: 93 | url = f"https://passport.bilibili.com/captcha" 94 | headers = {'Host': "passport.bilibili.com"} 95 | response = self._requests("get", url, headers=headers, decode_level=1) 96 | captcha = self._solve_captcha(response) 97 | if captcha: 98 | self._log(f"登录验证码识别结果: {captcha}") 99 | key = get_key() 100 | key_hash, pub_key = key['key_hash'], key['pub_key'] 101 | url = f"https://passport.bilibili.com/api/v2/oauth2/login" 102 | param = f"appkey={Bilibili.app_key}&captcha={captcha}&password={parse.quote_plus(base64.b64encode(rsa.encrypt(f'{key_hash}{self.password}'.encode(), pub_key)))}&username={parse.quote_plus(self.username)}" 103 | payload = f"{param}&sign={self.calc_sign(param)}" 104 | headers = {'Content-type': "application/x-www-form-urlencoded"} 105 | response = self._requests("post", url, data=payload, headers=headers) 106 | else: 107 | self._log(f"登录验证码识别服务暂时不可用, 10秒后重试") 108 | time.sleep(10) 109 | break 110 | elif response['code'] == -449: 111 | time.sleep(1) 112 | response = self._requests("post", url, data=payload, headers=headers) 113 | elif response['code'] == 0 and response['data']['status'] == 0: 114 | for cookie in response['data']['cookie_info']['cookies']: 115 | self._session.cookies.set(cookie['name'], cookie['value'], domain=".bilibili.com") 116 | self._log("登录成功") 117 | return True 118 | else: 119 | self._log(f"登录失败 {response}") 120 | return False 121 | else: 122 | self._log(f"当前IP登录过于频繁, 1分钟后重试") 123 | time.sleep(60) 124 | break 125 | 126 | # 获取用户信息 127 | def get_user_info(self): 128 | url = f"https://api.bilibili.com/x/space/myinfo?jsonp=jsonp" 129 | headers = { 130 | 'Host': "api.bilibili.com", 131 | 'Referer': f"https://space.bilibili.com/{self.get_uid()}/", 132 | } 133 | response = self._requests("get", url, headers=headers) 134 | if response and response.get("code") == 0: 135 | self.info['ban'] = bool(response['data']['silence']) 136 | self.info['coins'] = response['data']['coins'] 137 | self.info['experience']['current'] = response['data']['level_exp']['current_exp'] 138 | self.info['experience']['next'] = response['data']['level_exp']['next_exp'] 139 | self.info['face'] = response['data']['face'] 140 | self.info['level'] = response['data']['level'] 141 | self.info['nickname'] = response['data']['name'] 142 | self._log(f"{self.info['nickname']}(UID={self.get_uid()}), Lv.{self.info['level']}({self.info['experience']['current']}/{self.info['experience']['next']}), 拥有{self.info['coins']}枚硬币, 账号{'状态正常' if not self.info['ban'] else '被封禁'}") 143 | return True 144 | else: 145 | self._log("用户信息获取失败") 146 | return False 147 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Star And Thank Author License (SATA) 2 | 3 | Copyright © 2019 Hsury(i@hsury.com) 4 | 5 | Project Url: https://github.com/Hsury/BiliDrive 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | And wait, the most important, you shall star/+1/like the project(s) in project url 18 | section above first, and then thank the author(s) in Copyright section. 19 | 20 | Here are some suggested ways: 21 | 22 | - Email the authors a thank-you letter, and make friends with him/her/them. 23 | - Report bugs or issues. 24 | - Tell friends what a wonderful project this is. 25 | - And, sure, you can just express thanks in your mind without telling the world. 26 | 27 | Contributors of this project by forking have the option to add his/her name and 28 | forked project url at copyright and project url sections, but shall not delete 29 | or modify anything else in these two sections. 30 | 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 32 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 33 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 34 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 35 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 36 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 37 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

- BiliDrive -

6 | 7 |

☁️ 哔哩哔哩云,支持任意文件的全速上传与下载 ☁️

8 | 9 |

10 | 11 | 12 | 13 |

14 | 15 |

16 | 17 |

18 | 19 | ## 特色 20 | 21 | - 轻量:无复杂依赖,资源占用少 22 | - 自由:无文件格式与大小限制,无容量限制 23 | - 安全:上传的文件需要通过生成的META URL才能访问,他人无法随意查看 24 | - 稳定:带有分块校验与超时重试机制,在较差的网络环境中依然能确保文件的完整性 25 | - 快速:支持多线程传输与断点续传,同时借助B站的CDN资源,能最大化地利用网络环境进行上传与下载 26 | 27 | ## 使用指南 28 | 29 | ### 准备 30 | 31 | 前往[发布页](https://github.com/Hsury/BiliDrive/releases/latest)获取可直接运行的二进制文件 32 | 33 | 或使用Python软件包管理器pip从[PyPI仓库](https://pypi.org/project/BiliDrive/)安装 34 | 35 | 亦可下载[源代码](https://github.com/Hsury/BiliDrive/archive/master.zip)后使用Python 3.6或更高版本运行 36 | 37 | ### 登录 38 | 39 | ``` 40 | python -m BiliDrive login [-h] username password 41 | 42 | username: Bilibili用户名 43 | password: Bilibili密码 44 | ``` 45 | 46 | ### 上传 47 | 48 | ``` 49 | python -m BiliDrive upload [-h] [-b BLOCK_SIZE] [-t THREAD] file 50 | 51 | file: 待上传的文件路径 52 | 53 | -b BLOCK_SIZE: 分块大小(MB), 默认值为4 54 | -t THREAD: 上传线程数, 默认值为4 55 | ``` 56 | 57 | 上传完毕后,终端会打印一串META URL(通常以`bdrive://`开头)用于下载或分享,请妥善保管 58 | 59 | ### 下载 60 | 61 | ``` 62 | python -m BiliDrive download [-h] [-f] [-t THREAD] meta [file] 63 | 64 | meta: META URL(通常以bdrive://开头) 65 | file: 另存为新的文件名, 不指定则保存为上传时的文件名 66 | 67 | -f: 覆盖已有文件 68 | -t THREAD: 下载线程数, 默认值为8 69 | ``` 70 | 71 | 下载完毕后会自动进行文件完整性校验,对于大文件该过程可能需要较长时间,若不愿等待可直接退出 72 | 73 | ### 查看文件元数据 74 | 75 | ``` 76 | python -m BiliDrive info [-h] meta 77 | 78 | meta: META URL(通常以bdrive://开头) 79 | ``` 80 | 81 | ### 查看历史记录 82 | 83 | ``` 84 | python -m BiliDrive history [-h] 85 | ``` 86 | 87 | ### 交互模式 88 | 89 | 不传入任何命令行参数,直接运行程序即可进入交互模式 90 | 91 | 该模式下,程序会打印命令提示符`BiliDrive > `,并等待用户输入命令 92 | 93 | ## 技术实现 94 | 95 | 将任意文件分块编码为图片后上传至B站,对该操作逆序即可下载并还原文件 96 | 97 | ## 性能指标 98 | 99 | ### 测试文件 100 | 101 | 文件名:[Vmoe]Hatsune Miku「Magical Mirai 2017」[BDrip][1920x1080p][HEVC_YUV420p10_60fps_2FLAC_5.1ch&2.0ch_Chapter][Effect Subtitles].mkv 102 | 103 | 大小:14.5 GB (14918.37 MB) 104 | 105 | 分块:10 MB * 1492 106 | 107 | META URL:bdrive://d28784bff1086450a6c331fb322accccd382228e 108 | 109 | ### 上传 110 | 111 | 地理位置:四川成都 112 | 113 | 运营商:教育网 114 | 115 | 上行速率:20 Mbps 116 | 117 | 用时:02:16:39 118 | 119 | 平均速度:1.82 MB/s 120 | 121 | ### 下载 122 | 123 | ### 测试点1 124 | 125 | 地理位置:福建福州 126 | 127 | 运营商:中国电信 128 | 129 | 下行速率:100 Mbps 130 | 131 | 用时:00:18:15 132 | 133 | 平均速度:13.62 MB/s 134 | 135 | ### 测试点2 136 | 137 | 地理位置:上海 138 | 139 | 运营商:中国电信 140 | 141 | 下行速率:1 Gbps 142 | 143 | 用时:00:02:22 144 | 145 | 平均速度:104.97 MB/s 146 | 147 | ## 免责声明 148 | 149 | 请自行对重要文件做好本地备份 150 | 151 | 请勿使用本项目上传不符合社会主义核心价值观的文件 152 | 153 | 请合理使用本项目,避免对哔哩哔哩的存储与带宽资源造成无意义的浪费 154 | 155 | 该项目仅用于学习和技术交流,开发者不承担任何由使用者的行为带来的法律责任 156 | 157 | ## 许可证 158 | 159 | BiliDrive is under The Star And Thank Author License (SATA) 160 | 161 | 本项目基于MIT协议发布,并增加了SATA协议 162 | 163 | 您有义务为此开源项目点赞,并考虑额外给予作者适当的奖励 ∠( ᐛ 」∠)_ 164 | -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hsury/BiliDrive/a00799051d801b639910752b2347042e0e24d60e/icon.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | rsa 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.7 2 | # -*- coding: utf-8 -*- 3 | 4 | import setuptools 5 | import BiliDrive 6 | 7 | with open("README.md", "r", encoding="utf-8") as fh: 8 | long_description = fh.read() 9 | 10 | with open("requirements.txt", "r", encoding="utf-8") as fh: 11 | install_requires = fh.read().splitlines() 12 | 13 | setuptools.setup( 14 | name="BiliDrive", 15 | version=BiliDrive.__version__, 16 | url="https://github.com/Hsury/BiliDrive", 17 | author=BiliDrive.__author__, 18 | author_email=BiliDrive.__email__, 19 | classifiers=[ 20 | "Development Status :: 4 - Beta", 21 | "Environment :: Console", 22 | "Intended Audience :: Developers", 23 | "Intended Audience :: End Users/Desktop", 24 | "License :: OSI Approved :: MIT License", 25 | "Natural Language :: Chinese (Simplified)", 26 | "Natural Language :: English", 27 | "Operating System :: OS Independent", 28 | "Programming Language :: Python :: 3.7", 29 | "Topic :: Communications :: File Sharing", 30 | "Topic :: Internet :: WWW/HTTP", 31 | "Topic :: Utilities", 32 | ], 33 | description="☁️ 哔哩哔哩云,支持任意文件的全速上传与下载", 34 | long_description=long_description, 35 | long_description_content_type="text/markdown", 36 | keywords=[ 37 | "bilibili", 38 | "cloud", 39 | "disk", 40 | "drive", 41 | "storage", 42 | "pan", 43 | "yun", 44 | "B站", 45 | "哔哩哔哩", 46 | "网盘", 47 | "云", 48 | ], 49 | install_requires=install_requires, 50 | python_requires=">=3.6", 51 | entry_points={ 52 | 'console_scripts': [ 53 | "BiliDrive=BiliDrive.__main__:main", 54 | ], 55 | }, 56 | packages=setuptools.find_packages(), 57 | ) 58 | --------------------------------------------------------------------------------