B站视频批量下载工具

工具是另一种财富。


概述

在 B 站遇到好的视频总想下载下来。不过很多浏览器插件只能每次下一个,批量下载才省力。有了 AI 的辅助,其实编写小工具已经非常方便了。 不过我们还是需要对生成的工具有要求的。

(1) 命令行选项;(2)可复用。(3)可组合

这其实是从 Linux 系统得来的启示。 命令行,就是可以通过命令行灵活定制行为,可复用,就是每个工具是小的但是可以复用的,当需要时不必重写或修改;可组合,是说命令行可以组合起来实现不同的工具。当我们把任务仔细拆分之后,就能更容易达到这个目标。

要批量下载视频,需要三个工具和一个数据源:

(1)数据解析

  • JSON PATH 语法
  • HTML select 语法

(2)数据拼接

  • URL 拼接

(3)下载工具

  • you-get
  • ffmpeg

(4) 数据源

  • HTML 网页
  • API 返回的 JSON 数据。

本文主要讲述如何解析 API 返回的 JSON 数据,拼接成所需的 URL,然后用下载工具下载。

工具开发

数据解析

比如说,要下载个人收藏的视频。

打开B站个人收藏,查看哪个URL 返回了页面数据。

API 返回的 JSON 数据如下:这里面 bvid 就是B站视频的标识,加上前缀即是可访问的B站视频地址。

{
	"code": 0,
	"message": "0",
	"ttl": 1,
	"data": {
		"info": {
			"id": 3133010051,
			"fid": 31330100,
			"mid": 183260251,
			"attr": 22,
			"title": "文艺",
			"cover": "http://i2.hdslb.com/bfs/archive/843055b47a4cc46e80047ab9087b4bea06491f47.jpg",
			"upper": {
				"mid": 183260251,
				"name": "琴水玉",
				"face": "https://i1.hdslb.com/bfs/face/fd135f95e066ea357969cac54468c0273baea6a1.jpg",
				"followed": false,
				"vip_type": 2,
				"vip_statue": 1
			},
			"cover_type": 2,
			"cnt_info": {
				"collect": 0,
				"play": 0,
				"thumb_up": 0,
				"share": 0
			},
			"type": 11,
			"intro": "",
			"ctime": 1709329450,
			"mtime": 1709329450,
			"state": 0,
			"fav_state": 0,
			"like_state": 0,
			"media_count": 26,
			"is_top": false
		},
		"medias": [{
				"id": 113892432876371,
				"type": 2,
				"title": "中式美学:沙鸥径去鱼儿饱,野鸟相呼柿子红。",
				"cover": "http://i2.hdslb.com/bfs/archive/843055b47a4cc46e80047ab9087b4bea06491f47.jpg",
				"intro": "中式美学:沙鸥径去鱼儿饱,野鸟相呼柿子红。",
				"page": 1,
				"duration": 99,
				"upper": {
					"mid": 1580957455,
					"name": "中式美学分享",
					"face": "https://i1.hdslb.com/bfs/face/f8e2fec9c18501d4e577bd7f60030f97c2c8fe54.jpg",
					"jump_link": ""
				},
				"attr": 0,
				"cnt_info": {
					"collect": 5961,
					"play": 104689,
					"danmaku": 52,
					"vt": 0,
					"play_switch": 0,
					"reply": 0,
					"view_text_1": "10.5万"
				},
				"link": "bilibili://video/113892432876371",
				"ctime": 1737861192,
				"pubtime": 1737861192,
				"fav_time": 1738047373,
				"bv_id": "BV1bvFAe2Efz",
				"bvid": "BV1bvFAe2Efz",
				"season": null,
				"ogv": null,
				"ugc": {
					"first_cid": 28085587803
				},
				"media_list_link": "bilibili://music/playlist/playpage/3316270251?page_type=3\u0026oid=113892432876371\u0026otype=2"
			}
		]
	}
}

请 AI 写一个解析 JSON 文件的工具,能够指定路径来获取指定数据。

import json
import jmespath

def get_value_by_path(data, path):
    """通过路径获取JSON中的值
  
    Args:
        data (dict): JSON数据
        path (str): JsonPath表达式,如 'data.result[?result_type=='video'].data[]'
      
    Returns:
        list: 返回所有匹配的值的列表
    """
    try:
        # 解析并执行JsonPath表达式
        print(path)
        result = jmespath.search(path, data)
        return result
  
    except Exception as e:
        print(f"错误: 无法从路径 '{path}' 获取值: {str(e)}")
        return []

def extract_values(json_file, path):
    """从指定的JSON文件中提取指定路径的值
  
    Args:
        json_file (str): JSON文件的路径
        path (str): 以点号分隔的路径
      
    Returns:
        list: 包含所有匹配值的列表
    """
    try:
        # 读取JSON文件
        with open(json_file, 'r', encoding='utf-8') as f:
            data = json.load(f)
          
        # 获取指定路径的值
        values = get_value_by_path(data, path)
      
        # 确保返回值是列表
        if not isinstance(values, list):
            values = [values]
          
        return values
      
    except FileNotFoundError:
        print(f"错误: 找不到文件 '{json_file}'")
        return []
    except json.JSONDecodeError:
        print(f"错误: '{json_file}' 不是有效的JSON文件")
        return []

import json
import argparse
from pytools.tools.dw_video import download_video
from pytools.common.jsonparse import extract_values

# ------------------------------------------------------------
# up 主视频
# bf -f video.json -p 'data.list.vlist[*].bvid'
# ------------------------------------------------------------

def genurl(bvids):
    urls = []
    for bvid in bvids:
        urls.append("https://www.bilibili.com/video/" + bvid)
    return urls

def main():
    # 设置命令行参数
    parser = argparse.ArgumentParser(description='从JSON文件中提取指定路径的值')
    parser.add_argument('-f', '--file', required=True, help='JSON文件路径')
    parser.add_argument('-p', '--path', required=True, help='以点号分隔的JSON路径,例如:data.list.vlist[*].bvid')
  
    args = parser.parse_args()
  
    # 提取值
    values = extract_values(args.file, args.path)
    print(values)
  
    # 打印结果
    if values:
        print("\n".join(str(v) for v in values))
        # 如果提取的是bvid,则下载视频
        urls = genurl(values)
        for url in urls:
            download_video(url)
          

if __name__ == '__main__':
    main()


AI 很快就写出来了。只要执行 python3 bf.py -f video.json -p "data.list.vlist[*].bvid" 就可以生成该页的所有视频的 B 站网址。这里用到了 JsonPath 语法。可以去学习下:““JsonPath简明教程”。须知软件工程师要十八般武艺样样会一点。

为什么写成这样呢,因为这个是可以复用的。

咱们再来看搜索个人UP主视频返回什么格式。她的 bvid 藏在 ​data.list.vlist[*].bvid​ 里。

可以使用 python3 bf.py -f video.json -p "data.list.vlist[*].bvid"

{
	"code": 0,
	"message": "0",
	"ttl": 1,
	"data": {
		"list": {
			"slist": [],
			"tlist": {
				"160": {
					"tid": 160,
					"count": 2,
					"name": "生活"
				},
				"36": {
					"tid": 36,
					"count": 140,
					"name": "知识"
				}
			},
			"vlist": [{
					"comment": 4,
					"typeid": 228,
					"play": 1688,
					"pic": "http://i2.hdslb.com/bfs/archive/2bf4a4f7ed75aa137c2d999998db6d0123481ad2.jpg",
					"subtitle": "",
					"description": "中式美学:正浪吟、不觉回桡,水花风叶两悠悠。",
					"copyright": "1",
					"title": "中式美学:正浪吟、不觉回桡,水花风叶两悠悠。",
					"review": 0,
					"author": "中式美学分享",
					"mid": 1580957455,
					"created": 1747611930,
					"length": "02:10",
					"video_review": 1,
					"aid": 114531426636976,
					"bvid": "BV1kAESzuEXc",
					"hide_click": false,
					"is_pay": 0,
					"is_union_video": 0,
					"is_steins_gate": 0,
					"is_live_playback": 0,
					"is_lesson_video": 0,
					"is_lesson_finished": 0,
					"lesson_update_info": "",
					"jump_url": "",
					"meta": null,
					"is_avoided": 0,
					"season_id": 0,
					"attribute": 8405120,
					"is_charging_arc": false,
					"elec_arc_type": 0,
					"elec_arc_badge": "",
					"vt": 0,
					"enable_vt": 0,
					"vt_display": "",
					"playback_position": 0,
					"is_self_view": false
				},
				{
					"comment": 1,
					"typeid": 228,
					"play": 2122,
					"pic": "http://i2.hdslb.com/bfs/archive/b5da93822dd42959ef4103b944ad089593cbc928.jpg",
					"subtitle": "",
					"description": "中式美学:落日熔金,暮云合璧,人在何处。染柳烟浓,吹梅笛怨,春意知几许。",
					"copyright": "1",
					"title": "中式美学:落日熔金,暮云合璧,人在何处。染柳烟浓,吹梅笛怨,春意知几许。",
					"review": 0,
					"author": "中式美学分享",
					"mid": 1580957455,
					"created": 1747434610,
					"length": "01:40",
					"video_review": 3,
					"aid": 114519833580541,
					"bvid": "BV1YSE8zKE4Q",
					"hide_click": false,
					"is_pay": 0,
					"is_union_video": 0,
					"is_steins_gate": 0,
					"is_live_playback": 0,
					"is_lesson_video": 0,
					"is_lesson_finished": 0,
					"lesson_update_info": "",
					"jump_url": "",
					"meta": null,
					"is_avoided": 0,
					"season_id": 0,
					"attribute": 8405120,
					"is_charging_arc": false,
					"elec_arc_type": 0,
					"elec_arc_badge": "",
					"vt": 0,
					"enable_vt": 0,
					"vt_display": "",
					"playback_position": 0,
					"is_self_view": false
				}
			}
		}

这里可以对 python3 bf.py 做了一个 alias ,在 ~/.zshrc 里添加

alias bf="python3 /Users/qinshu/tools/pytools/pytools/tools/bf.py"

添加到Path里,然后 source ~/.zshrc

就可以直接使用 bf -f video.json -p "data.list.vlist[*].bvid",这就是可复用的威力,不需要改代码,就可以适应不同的变化。

读者还可以去看看根据关键字搜索B站视频的JSON返回数据,可以用 bss -f video.json -p 'data.result[?result_type=="video"].data[]'​来实现。可谓一个程序能解决三种场景。

视频下载


#!/usr/bin/env python3

import subprocess
import shlex
from pathlib import Path
from typing import Optional, Union
import time

def download_video(
    video_url: str,
    output_dir: Union[str, Path] = Path.cwd(),
    timeout: int = 3600,  # 1小时超时
    retries: int = 3,
    verbose: bool = True
) -> Optional[Path]:
    """
    使用 y 命令下载视频
    
    参数:
        video_url: 视频URL (e.g. "https://www.bilibili.com/video/BV1xx411x7xx")
        output_dir: 输出目录 (默认当前目录)
        timeout: 超时时间(秒)
        retries: 重试次数
        verbose: 显示下载进度
    
    返回:
        成功时返回下载的视频路径,失败返回None
    """
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    
    cmd = f"y {shlex.quote(video_url)}"
    if verbose:
        print(f"开始下载: {video_url}")
        print(f"保存到: {output_dir.resolve()}")
        print(f"执行命令: {cmd}")
    
    for attempt in range(1, retries + 1):
        try:
            start_time = time.time()
            
            # 使用Popen实现实时输出
            process = subprocess.Popen(
                cmd,
                shell=True,
                cwd=str(output_dir),
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                universal_newlines=True,
                bufsize=1
            )
            
            # 实时打印输出
            while True:
                output = process.stdout.readline()
                if output == '' and process.poll() is not None:
                    break
                if output and verbose:
                    print(output.strip())
                
                # 检查超时
                if time.time() - start_time > timeout:
                    process.terminate()
                    raise subprocess.TimeoutExpired(cmd, timeout)
            
            # 检查返回码
            if process.returncode == 0:
                if verbose:
                    print(f"下载成功 (尝试 {attempt}/{retries})")
                return _find_downloaded_file(output_dir, video_url)
            else:
                raise subprocess.CalledProcessError(process.returncode, cmd)
                
        except (subprocess.TimeoutExpired, subprocess.CalledProcessError) as e:
            if attempt < retries:
                wait_time = min(attempt * 10, 60)  # 指数退避
                if verbose:
                    print(f"尝试 {attempt}/{retries} 失败,{wait_time}秒后重试...")
                    print(f"错误: {str(e)}")
                time.sleep(wait_time)
            else:
                if verbose:
                    print(f"下载失败: {str(e)}")
                return None

def _find_downloaded_file(directory: Path, video_url: str) -> Optional[Path]:
    """尝试自动查找下载的文件"""
    # 这里可以根据实际y命令的输出文件名模式进行调整
    # 示例:查找最近修改的视频文件
    video_files = sorted(
        directory.glob("*.mp4"),
        key=lambda f: f.stat().st_mtime,
        reverse=True
    )
    return video_files[0] if video_files else None

这里采用的是直接调用命令行,命令行 y 是如下文件。 加上 chmod +x y

--cookies 里的文件,是firefox 里用 Cookie-Editor 导出的数据复制进去的。如果你有其它的下载工具,也可以替换为自己的下载工具。这里咱们就不单独开发下载工具了。

link=$1
you-get $link --cookies "/Users/qinshu/privateqin/cookies.txt"  -f -o /Users/qinshu/joy/dance/bili

获取网络数据

其实还有一步,获取网络数据源,可以通过 python requests 库实现。不过B站加了风控校验,导致我之前的方法失效了。乐趣减少了很多。只能算做了一半工作。后续还要看看怎么突破网站防线拉取数据。


小结

工程师不比程序员,他需要的是十八般武艺样样都会一点,这样会获得更大的自由度。虽然学艺不精,那有什么关系呢?什么问题用什么工具最适合解决。对于手里没钱的码农,多写一点工具让自己的生活更轻松,未尝不是一种选择呢。

posted @ 2025-06-15 21:22  琴水玉  阅读(444)  评论(0)    收藏  举报