第一课
作业 01 只取一张封面;此次则欲取某一 UP 主页上全部投稿之封面。目标页如左:
https://space.bilibili.com/86735192/upload/video
一、先说踩坑
**错误做法:**把整页 HTML 下载下来,再用正则或解析库把 <img> 抠出来。
其所以不可行,约有两端:
其一,封面并非写死在 HTML 里。浏览器先打开一个空壳页面,再用 JavaScript 去请求「投稿列表接口」,拿到 JSON 之后才把封面画出来。故而右键「查看网页源代码」,往往看不到完整封面。
其二,空间页常被验证码或 412 拦住。程序直接去要这份 HTML,比自己用 Chrome 打开更容易被拦。
二、MID 是什么
https://space.bilibili.com/86735192/upload/video ^^^^^^^^ 这串数字就是 MIDMID 即此 UP 主在 B 站的用户 ID。ID 不同,则 UP 主有异。接口问「这个人有哪些投稿」,所靠即此。
故于脚本中先列二常量,以供后用:
SPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192三、用 F12 抓真正的列表接口
步骤如左:
- 用 Chrome 或 Edge 打开上面的
SPACE_URL。 - 按 F12 打开开发者工具,切到 Network(网络)。
- 勾选 Fetch/XHR。列表里便只留下接口请求,网页、图片、CSS 都先藏起来。
- 按 F5 刷新。若弹出验证码,在页面上点完,再刷新一次。
- 在筛选框里输入
arc或search。 - 找到名字里带
arc/search的那一条(完整路径一般是/x/space/wbi/arc/search)。 - 点它,右侧切到 Response(响应),可见以
{开头的一大段 JSON。 - 全选复制,保存为
arc_search.json,与主文件demo01.py放在同一文件夹。
存放后,JSON 最外层通常长这样:
{ "code": 0, "data": { "list": { "vlist": [ { "bvid": "BV1...", "title": "视频标题", "pic": "//i1.hdslb.com/bfs/archive/xxxx.jpg" } ] }, "page": { "count": 17, "pn": 1, "ps": 30 } }}封面地址就在每一项的 pic 里,前面补上 https: 即可请求。
四、demo01.py 在做什么
其工作原理不过是:读 JSON → 走进 vlist → 打印 title / pic。
先确认文件在不在:
json_path = Path(__file__).with_name("arc_search.json")if not json_path.exists(): raise SystemExit("还没有 arc_search.json。")再把文本变成字典。read_text 读到的是字符串,json.loads 之后才能用 payload["data"] 往下钻:
payload = json.loads(json_path.read_text(encoding="utf-8"))# 先看最外层有哪些键,确认自己存的是完整响应,而不是某一层的片段if isinstance(payload, dict): print("JSON 最外层的键:", list(payload.keys()))else: raise SystemExit(f"最外层不是对象,而是 {type(payload)}。请保存完整 Response。")data = payload.get("data")if not isinstance(data, dict): raise SystemExit("没有 data 字段。请保存完整 Response,不要只复制 Preview 里的某一层。")若打印出来最外层有 code、data,说明存对了。若直接是一个列表,或只有 vlist,说明复制时少了一层,需重新保存完整 Response。
按路径取出投稿列表:
# 封面地址在:data → list → vlist → 每一项的 picvlist = (data.get("list") or {}).get("vlist") or []print(f"MID={MID}")print(f"解析到投稿 {len(vlist)} 个")print("-" * 40)for i, item in enumerate(vlist[:5], 1): print(f"{i}. {item.get('title')}") print(f" bvid: {item.get('bvid')}") print(f" pic : {item.get('pic')}")这里用 .get 而不是 ["list"],是为了缺字段时得到空列表,而不是甩出一长串 KeyError。此 UP 大约 17 个投稿,一页即可拿完,故本课不必考虑翻页。
五、运行
输出大致如左:
JSON 最外层的键: ['code', 'message', 'ttl', 'data']MID=86735192解析到投稿 17 个----------------------------------------1. 某个标题 bvid: BV1... pic : //i1.hdslb.com/bfs/archive/xxxx.jpg六、余论
一张封面,手抠 src 即可;一组封面,便须承认页面是「空壳 + 接口」。F12 里那条 arc/search,才是名单所在。名单既得,下载不过是作业 01 的循环而已。
第二课
第一课已证:封面不在 HTML 里,而在 arc_search.json 的 vlist[].pic。这一课把「打印前 5 条」收成函数,交出一份能直接拿去下载的列表。
先说与这份 JSON 有关的一件事:存下来的 pic 已经是完整的 http://i1.hdslb.com/...jpg,并无 @672w。清洗函数看起来像「什么也没干」,却仍须写——换一个入口(搜索页、热门页、带压缩参数的封面)时,pic 常常是 //...jpg@672w_378h_...。作业 01 的 bilibili_photo_spider.py,用的便是那种地址。
一、这一课要交出来的东西
不是图片文件,而是一份干净的 Python 列表。每一项长这样:
{ "bvid": "BV1mq4Zz4Eb5", "title": "羽生迷子 - ぼくのかみさま", "pic": "https://i1.hdslb.com/bfs/archive/b0ae9444bdee463b16981b25bf1304afccad2a64.jpg", "created": 1754441364, "length": "02:56"}注意:pic 已是 https://,没有问号,也没有 @672w。下一课的下载循环,只认这个结构。
二、为何要洗封面地址
封面地址大约有四种「脏」法,函数按顺序处理:
| 原始样子 | 问题 | 处理 |
|---|---|---|
//i1.hdslb.com/...jpg |
浏览器会补当前页的协议,requests 不会 |
前面加 https: |
http://i1.hdslb.com/...jpg |
这份 JSON 便是这种;部分环境会拦明文图片 | 改成 https:// |
...jpg?imageView2/2/w/672 |
? 后面是查询参数,不是文件本身 |
按 ? 切开,丢掉后半 |
...jpg@672w_378h_1c_!web-search-common-cover |
CDN 按列表预览尺寸裁出来的图 | 从最后一个 @ 截掉 |
对应代码:
def original_cover_url(url: str) -> str: """去掉协议、查询参数、CDN 压缩后缀,尽量留下原图地址。""" if not url: return "" if url.startswith("//"): url = "https:" + url elif url.startswith("http://"): url = "https://" + url[len("http://") :] url = url.split("?")[0] filename = url.rsplit("/", 1)[-1] if "@" in filename: url = url[: url.rfind("@")] return urlmain() 开头有三条假地址作演示。运行后可见:真实 pic 只是 http 变成 https;带 // 与 @672w 的那条,会被洗成干净的原图地址。
三、从 JSON 抽出列表
第一课把路径写死为 payload["data"]["list"]["vlist"]。这一课收成函数,并兼容以后会碰到的三种存法:
- 完整响应:
{ "code": 0, "data": { "list": { "vlist": [...] } } }—— 现在这份即是 - 只复制了
data那一层 - 只复制了
vlist数组
if isinstance(payload, list): vlist = payloadelse: data = payload.get("data") if isinstance(payload.get("data"), dict) else payload vlist = ((data.get("list") or {}).get("vlist")) or data.get("vlist") or []读法是:最外层已是列表,便直接当作 vlist;否则若有 data 对象就走进 data,没有就把当前对象当作 data;再取 list.vlist,取不到再试 vlist。
循环里只保留下载所需的五个字段。pic 为空的项直接跳过,免得下一课对空 URL 发请求。
原始 JSON 里还有 play、comment、description 等。不是不能留,而是这一课的目标是封面——字段越少,下一课的 for 越清楚。
四、读文件的入口
fetch_videos_by_json 本身很短:读文本 → json.loads → 交给 parse_videos。单独做成函数,是因为后面会有三种取列表的办法(json / api / browser),最后都要交到同一个 parse_videos。今日先把 json 这条路走通。
解析结果若是空列表,立刻报错。常见原因是存成了别的接口,或文件名对、内容却是 HTML。
五、运行
运行 demo01.py。先看「清洗函数演示」三条前后对比,再看全部投稿清洗后的地址,大致如左:
清洗函数演示: 前: http://i1.hdslb.com/bfs/archive/b0ae9444bdee463b16981b25bf1304afccad2a64.jpg 后: https://i1.hdslb.com/bfs/archive/b0ae9444bdee463b16981b25bf1304afccad2a64.jpg 前: //i1.hdslb.com/bfs/archive/xxxx.jpg@672w_378h_1c_!web-search-common-cover 后: https://i1.hdslb.com/bfs/archive/xxxx.jpg 前: https://i1.hdslb.com/bfs/archive/xxxx.jpg?imageView2/2/w/672 后: https://i1.hdslb.com/bfs/archive/xxxx.jpgMID=86735192 解析到投稿 17 个----------------------------------------01. 羽生迷子 - ぼくのかみさま BV1mq4Zz4Eb5 02:56 https://i1.hdslb.com/bfs/archive/b0ae9444bdee463b16981b25bf1304afccad2a64.jpg……六、完整代码
import jsonfrom pathlib import PathSPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192def original_cover_url(url: str) -> str: """去掉协议、查询参数、CDN 压缩后缀,尽量留下原图地址。""" if not url: return "" # 有的接口返回 //i1.hdslb.com/... 浏览器能开,requests 必须带 https: if url.startswith("//"): url = "https:" + url # 你这份 JSON 里是 http:// ,统一改成 https://,避免部分环境拦截明文图片 elif url.startswith("http://"): url = "https://" + url[len("http://") :] # 问号后面是查询参数,封面文件名用不到 url = url.split("?")[0] # @672w_378h_... 是 CDN 按预览尺寸裁出来的图,不是原图 filename = url.rsplit("/", 1)[-1] if "@" in filename: url = url[: url.rfind("@")] return urldef parse_videos(payload: dict) -> list[dict]: """从投稿列表 JSON 里抽出封面。兼容完整响应 / 只有 data / 只有 vlist。""" if isinstance(payload, list): vlist = payload else: data = payload.get("data") if isinstance(payload.get("data"), dict) else payload vlist = ((data.get("list") or {}).get("vlist")) or data.get("vlist") or [] videos = [] for item in vlist: pic = original_cover_url(item.get("pic") or "") if not pic: continue videos.append( { "bvid": item.get("bvid"), "title": item.get("title"), "pic": pic, "created": item.get("created"), "length": item.get("length"), } ) return videosdef fetch_videos_by_json(path: Path) -> list[dict]: payload = json.loads(path.read_text(encoding="utf-8")) videos = parse_videos(payload) if not videos: raise RuntimeError(f"{path.name} 里没有解析到 vlist/pic,确认保存的是 arc/search 的 JSON。") return videosdef main() -> None: # 先用三条假地址,看清洗函数到底改了什么 samples = [ "http://i1.hdslb.com/bfs/archive/b0ae9444bdee463b16981b25bf1304afccad2a64.jpg", "//i1.hdslb.com/bfs/archive/xxxx.jpg@672w_378h_1c_!web-search-common-cover", "https://i1.hdslb.com/bfs/archive/xxxx.jpg?imageView2/2/w/672", ] print("清洗函数演示:") for raw in samples: print(" 前:", raw) print(" 后:", original_cover_url(raw)) print() json_path = Path(__file__).with_name("arc_search.json") videos = fetch_videos_by_json(json_path) print(f"MID={MID} 解析到投稿 {len(videos)} 个") print("-" * 40) for index, video in enumerate(videos, start=1): print(f"{index:02d}. {video['title']}") print(f" {video['bvid']} {video['length']}") print(f" {video['pic']}") print("-" * 40) print("这一课只拿到列表。下一课用 for 循环把这些 pic 下载到 covers/ 文件夹。")if __name__ == "__main__": main()七、余论
名单既净,下载便只剩循环。接口可以换,这一课交出的五个字段,下一课不必再改。
第三课
第二课已得封面 URL 之列表;这一课不过在其外再套一层 for,把作业 01 里写过的「requests.get + 写文件」重复十七次。
按此脚本试跑一遍,十七张已落入 第1课/UP_Photo/covers/。本地再运行一次 demo01.py 即可核对(同名文件会被覆盖)。
一、下载循环并无新魔法
对照从前的 wallpaper.py:
# 第1课/photowall/wallpaper.pyresponse = requests.get(url, headers=headers, timeout=15)content = response.content...save_path.write_bytes(content)如今只是 URL 来自列表,文件名带上序号与标题:
# 第1课/UP_Photo/demo01.pyfor index, video in enumerate(videos, start=1): url = video["pic"] ... response = requests.get(url, headers=HEADERS, timeout=30) ... path.write_bytes(response.content)成品文件名形如:
01_BV1mq4Zz4Eb5_羽生迷子 - ぼくのかみさま.jpg序号便于排序,bvid 保证不重名,标题便于人看。
请求头里的 User-Agent 与 Referer,与作业 01 下单张图是同一类伪装:告诉 CDN「吾像浏览器,并且是从空间页过来的」。
二、标题不能直接当文件名
Windows 文件名禁止这些字符:< > : " / \ | ? *
此 UP 的标题里,恰好有现成的例子:
| 原标题片段 | 问题 | 处理后 |
|---|---|---|
(Ujico* Remix) |
* 非法 |
(Ujico_ Remix) |
ケガレの唄 / flower |
/ 会被当成路径分隔 |
ケガレの唄 _ flower |
此外还有三事:
- 连续空白收成一个空格,免得文件名里一长串空格。
- 只留前 40 个字符。标题再长,路径也容易超过 Windows 的限制。
- 末尾的
.和空格要去掉,Windows 不允许文件名以点结尾。
若标题被洗得一点不剩(极端情况),便用 cover_01 这类后备名,保证一定能落盘。此即 safe_filename 的用处。
三、看文件头,不看 URL
wallpaper.py 里已经见过这件事:服务器自称 jpeg,文件头却是 WebP,存成 .jpg 便会提示损坏。
判断靠的是文件开头几个字节(魔数),不是 Content-Type,也不是 URL 里的 .jpg:
| 文件头 | 格式 |
|---|---|
RIFF....WEBP |
.webp |
\xff\xd8\xff |
.jpg |
\x89PNG |
.png |
GIF8 |
.gif |
B 站封面这一次实际都是 jpg,函数却仍须写:换一批图,或 CDN 改了格式,不必回头改保存逻辑。此即 image_suffix 的用处。
四、礼貌间隔、控制台编码、超时重试
REQUEST_INTERVAL = 0.8:每张之间停 0.8 秒。请求过快容易被封;封面又不是抢票,不必打满带宽。
sys.stdout.reconfigure(encoding="utf-8"):Windows 控制台默认 GBK,标题里的日文组合字符(如 ぼ)会直接把 print 打崩。成品脚本 main() 开头那一段,便是为此。试跑时第一次就是在这里炸掉的。
超时重试:试跑时第二张读 CDN 超时。网络会抖,故同一张最多试三次。这不是把爬虫写复杂,而是下载循环里最常见的一种失败。
五、covers.json 是给人对的
main() 先把解析结果写成 covers.json(mid、条数、每条的 bvid / title / pic),再下载图片。以后若疑心少下了某张,先打开这份 JSON 对一下列表,不必重读 arc_search.json 里那些无关字段。
folder.mkdir(parents=True, exist_ok=True):文件夹不存在就建;已经存在也不报错。
六、代码
完整代码如左:
import jsonimport reimport timefrom pathlib import Pathimport requestsSPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192REQUEST_INTERVAL = 0.8HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ), "Referer": SPACE_URL,}def original_cover_url(url: str) -> str: """去掉协议、查询参数、CDN 压缩后缀,尽量留下原图地址。""" if not url: return "" if url.startswith("//"): url = "https:" + url elif url.startswith("http://"): url = "https://" + url[len("http://") :] url = url.split("?")[0] filename = url.rsplit("/", 1)[-1] if "@" in filename: url = url[: url.rfind("@")] return urldef parse_videos(payload: dict) -> list[dict]: """从投稿列表 JSON 里抽出封面。兼容完整响应 / 只有 data / 只有 vlist。""" if isinstance(payload, list): vlist = payload else: data = payload.get("data") if isinstance(payload.get("data"), dict) else payload vlist = ((data.get("list") or {}).get("vlist")) or data.get("vlist") or [] videos = [] for item in vlist: pic = original_cover_url(item.get("pic") or "") if not pic: continue videos.append( { "bvid": item.get("bvid"), "title": item.get("title"), "pic": pic, "created": item.get("created"), "length": item.get("length"), } ) return videosdef fetch_videos_by_json(path: Path) -> list[dict]: payload = json.loads(path.read_text(encoding="utf-8")) videos = parse_videos(payload) if not videos: raise RuntimeError(f"{path.name} 里没有解析到 vlist/pic,确认保存的是 arc/search 的 JSON。") return videosdef safe_filename(text: str, fallback: str) -> str: """标题不能直接当文件名:Windows 禁止 <>:\"/\\|?* ,过长也会出问题。""" text = re.sub(r'[<>:"/\\|?*]', "_", text).strip() text = re.sub(r"\s+", " ", text) return (text[:40] or fallback).rstrip(". ")def image_suffix(content: bytes) -> str: """看文件头判断真实格式,不要只信 URL 里的 .jpg 或响应头 Content-Type。""" if content.startswith(b"RIFF") and content[8:12] == b"WEBP": return ".webp" if content.startswith(b"\xff\xd8\xff"): return ".jpg" if content.startswith(b"\x89PNG"): return ".png" if content.startswith(b"GIF8"): return ".gif" return ".jpg"def download_covers(videos: list[dict], folder: Path) -> int: folder.mkdir(parents=True, exist_ok=True) saved = 0 for index, video in enumerate(videos, start=1): url = video["pic"] title = safe_filename(video.get("title") or "", f"cover_{index:02d}") bvid = video.get("bvid") or f"{index:02d}" response = None last_error = None for attempt in range(1, 4): try: response = requests.get(url, headers=HEADERS, timeout=30) response.raise_for_status() break except (requests.Timeout, requests.ConnectionError) as exc: last_error = exc print(f"第 {index} 张超时,重试 {attempt}/3 …") time.sleep(REQUEST_INTERVAL * 2) if response is None: raise last_error suffix = image_suffix(response.content) path = folder / f"{index:02d}_{bvid}_{title}{suffix}" path.write_bytes(response.content) saved += 1 print(f"已保存 {path.name} ({len(response.content)} bytes)") if index < len(videos): time.sleep(REQUEST_INTERVAL) return saveddef main() -> None: try: import sys sys.stdout.reconfigure(encoding="utf-8") except Exception: pass json_path = Path(__file__).with_name("arc_search.json") videos = fetch_videos_by_json(json_path) print(f"MID={MID} 解析到投稿 {len(videos)} 个") save_dir = Path(__file__).with_name("covers") meta_path = Path(__file__).with_name("covers.json") meta_path.write_text( json.dumps({"mid": MID, "count": len(videos), "videos": videos}, ensure_ascii=False, indent=2), encoding="utf-8", ) print(f"元数据已写入 {meta_path.name}") saved = download_covers(videos, save_dir) print(f"完成:共保存 {saved} 张封面到 {save_dir}")if __name__ == "__main__": main()运行 demo01.py,打开 covers/ 文件夹,确认:
- 大约 17 张图,能正常打开,不是「损坏的 jpg」。
- 文件名没有
/、*这类非法字符。 - 同目录多了一份
covers.json,条数和图片一致。
做到这里,「用本地 JSON 下载这个 UP 的全部封面」已经完成。接下来要处理另一件事:不靠手工 F12 存 JSON,直接请求官方投稿接口——以及它为什么常常 412。
七、余论
循环本身无秘。麻烦的是文件名、后缀、编码与超时。名单既净,十七张落地,不过是作业 01 乘以 N。接口那一头,且留待下一课。
第四课
第三课已能把封面全部下完,数据却仍来自手工 F12 存下的 arc_search.json。换一个 UP,总不能每次都复制。这一课改成程序自己请求同一条接口。
一、不必再手抄 w_rid
若把 w_rid、wts 从浏览器里复制进 params,那两个值会过期,抄一次只能用一小段时间。
投稿列表接口是同一类做法,地址如左:
https://api.bilibili.com/x/space/wbi/arc/search返回的 JSON 形状与第一课所存的 arc_search.json 相同,仍然交给 parse_videos。变的只是:wts / w_rid 每次现算,不要手抄。
直连、不签名、不带 Cookie,常见结果是 HTTP 412,或 JSON 里 code=-352。
二、第一关:buvid3,不是登录
# 第1课/UP_Photo/demo01.pydef session() -> requests.Session: """投稿接口需要非空 buvid3,否则容易风控。这不是登录 Cookie。""" sess = requests.Session() sess.headers.update(HEADERS) sess.cookies.set("buvid3", f"{uuid.uuid4()}infoc") return sess浏览器第一次打开 B 站,会给自己发一个设备号,Cookie 名便是 buvid3。没有它,接口更容易把请求当作脚本。
uuid4() 生成一串随机 ID,后面拼上 infoc,格式接近浏览器真实值。这里没有 SESSDATA,不登录,也不碰账号。
Session 还会把 Cookie 与请求头在多次请求之间复用:先向 /x/web-interface/nav 拿密钥,再请求 arc/search,两次必须是同一个会话。
三、第二关:WBI 签名
WBI 是 B 站给部分接口加的参数签名。乱序表来自前端 JS,不必从零发明。须弄清的是三次调用的顺序:
get_wbi_keys() → 拿到 img_key、sub_keymixin_key() → 把两把钥匙打乱,切出 32 位盐enc_wbi() → 给参数加上 wts,算出 w_rid第一步:拿钥匙。 请求导航接口,从图片 URL 的文件名里抠出两段哈希:
https://i0.hdslb.com/bfs/wbi/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.png ↑ 去掉路径和 .png,这就是 img_keysub_key 同理。密钥会变,故每次运行都重新取,不要写死。
第二步:打乱。 MIXIN_KEY_ENC_TAB 是 0~63 的重排顺序。把 img_key + sub_key(64 个字符)按此顺序重排,再取前 32 位。成品脚本用 reduce 写了同一句话,demo01.py 里改成更好读的:
"".join(orig[index] for index in MIXIN_KEY_ENC_TAB)[:32]第三步:签名。
- 拷贝参数,加上
wts(当前 Unix 时间戳) - 按键名排序(保证同样参数每次拼出同一条 query)
- 去掉值里的
!'()*(B 站前端也这么滤) urlencode得到 query 字符串w_rid = md5(query + mixin_key)的十六进制
没有合法 w_rid 的请求,服务器直接当作弊。
参数里 mid、pn、ps 是业务字段:谁的空间、第几页、每页多少条。platform、web_location 是网页端会带的标记,缺了有时也会被拦。
四、翻页:一页够用,函数仍按「可能很多页」来写
pn = 页码(从 1 开始)ps = 每页条数(这里 30)data.page.count = 投稿总数循环是:签一次名 → 请求一页 → parse_videos → 累加。这一页空了,或已经拿到 count 条,便停。页与页之间同样 sleep(0.8)。
此 UP 大约 17 个投稿,ps=30 时只会打 1 页。换一个几百投稿的 UP,这段才会真正转起来。
code != 0 和 HTTP 412 都当成风控,打印下一步怎么走,而不是只丢一行 KeyError。
五、本课先走 api
# 第1课/UP_Photo/demo01.pyFETCH_MODE = "api"main() 里:
api:调用fetch_videos_by_api- 抛出风控错误,且本地有
arc_search.json:回退到json - 也可自己改成
"json",完全不打接口
第五课再加 "browser"。本课故意不写「发现本地 JSON 就跳过接口」,否则永远看不见 412 / -352。
后面的 parse_videos、download_covers、covers.json 与第三课相同。变的只是列表从哪来。
六、完整代码
"""第 4 课时:不靠手工复制 JSON,直接请求官方投稿接口。第 3 课已经能从 arc_search.json 下载全部封面。这一课换成请求: https://api.bilibili.com/x/space/wbi/arc/search拿到的 JSON 仍然交给 parse_videos / download_covers。接口要过两关,否则常见 412: 1. Cookie 里带一个非空 buvid3(不是登录) 2. 参数经过 WBI 签名(wts + w_rid)FETCH_MODE = "api" 会先打接口;被风控时回退到本地 JSON。"""import jsonimport reimport timeimport uuidimport urllib.parsefrom hashlib import md5from pathlib import Pathimport requestsSPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192FETCH_MODE = "api"PAGE_SIZE = 30REQUEST_INTERVAL = 0.8HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ), "Referer": SPACE_URL, "Origin": "https://space.bilibili.com", "Accept": "application/json, text/plain, */*",}# 这张表来自 B 站前端 JS,不是自己编的。作用:把 img_key+sub_key 打乱后取前 32 位。MIXIN_KEY_ENC_TAB = [ 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,]def session() -> requests.Session: """投稿接口需要非空 buvid3,否则容易风控。这不是登录 Cookie。""" sess = requests.Session() sess.headers.update(HEADERS) sess.cookies.set("buvid3", f"{uuid.uuid4()}infoc") return sessdef mixin_key(orig: str) -> str: # 成品脚本用 reduce 写了同一句话:按表里的下标重排,再切 32 位 return "".join(orig[index] for index in MIXIN_KEY_ENC_TAB)[:32]def enc_wbi(params: dict, img_key: str, sub_key: str) -> dict: signed = dict(params) signed["wts"] = round(time.time()) signed = dict(sorted(signed.items())) signed = { key: "".join(ch for ch in str(value) if ch not in "!'()*") for key, value in signed.items() } query = urllib.parse.urlencode(signed) signed["w_rid"] = md5((query + mixin_key(img_key + sub_key)).encode()).hexdigest() return signeddef get_wbi_keys(sess: requests.Session) -> tuple[str, str]: data = sess.get("https://api.bilibili.com/x/web-interface/nav", timeout=20).json() wbi = (data.get("data") or {}).get("wbi_img") or {} img_url = wbi.get("img_url") or "" sub_url = wbi.get("sub_url") or "" if not img_url or not sub_url: raise RuntimeError("没有拿到 WBI 密钥,稍后重试。") img_key = img_url.rsplit("/", 1)[-1].split(".")[0] sub_key = sub_url.rsplit("/", 1)[-1].split(".")[0] return img_key, sub_keydef original_cover_url(url: str) -> str: """去掉协议、查询参数、CDN 压缩后缀,尽量留下原图地址。""" if not url: return "" if url.startswith("//"): url = "https:" + url elif url.startswith("http://"): url = "https://" + url[len("http://") :] url = url.split("?")[0] filename = url.rsplit("/", 1)[-1] if "@" in filename: url = url[: url.rfind("@")] return urldef parse_videos(payload: dict) -> list[dict]: """从投稿列表 JSON 里抽出封面。兼容完整响应 / 只有 data / 只有 vlist。""" if isinstance(payload, list): vlist = payload else: data = payload.get("data") if isinstance(payload.get("data"), dict) else payload vlist = ((data.get("list") or {}).get("vlist")) or data.get("vlist") or [] videos = [] for item in vlist: pic = original_cover_url(item.get("pic") or "") if not pic: continue videos.append( { "bvid": item.get("bvid"), "title": item.get("title"), "pic": pic, "created": item.get("created"), "length": item.get("length"), } ) return videosdef fetch_videos_by_json(path: Path) -> list[dict]: payload = json.loads(path.read_text(encoding="utf-8")) videos = parse_videos(payload) if not videos: raise RuntimeError(f"{path.name} 里没有解析到 vlist/pic,确认保存的是 arc/search 的 JSON。") return videosdef api_blocked_hint() -> str: return ( "投稿列表接口被风控(常见 412)。任选一种方式:\n" "1) 下一课把 FETCH_MODE 改成 \"browser\",用本机 Chrome 打开空间页拦截接口\n" "2) 继续用第 1 课保存的 arc_search.json(FETCH_MODE = \"json\")" )def fetch_videos_by_api(sess: requests.Session, mid: int) -> list[dict]: img_key, sub_key = get_wbi_keys(sess) videos: list[dict] = [] page_no = 1 while True: params = enc_wbi( { "mid": mid, "ps": PAGE_SIZE, "tid": 0, "pn": page_no, "keyword": "", "order": "pubdate", "platform": "web", "web_location": "1550101", "order_avoided": "true", }, img_key, sub_key, ) response = sess.get( "https://api.bilibili.com/x/space/wbi/arc/search", params=params, timeout=20, ) if response.status_code == 412 or "错误号: 412" in response.text: raise RuntimeError(api_blocked_hint()) response.raise_for_status() payload = response.json() if payload.get("code") != 0: raise RuntimeError( f"接口返回异常: code={payload.get('code')} msg={payload.get('message')}\n" + api_blocked_hint() ) page_videos = parse_videos(payload) data = payload.get("data") or {} total = int((data.get("page") or {}).get("count") or 0) videos.extend(page_videos) print(f"第 {page_no} 页: +{len(page_videos)} 条,累计 {len(videos)} / {total or '?'}") if not page_videos or (total and len(videos) >= total): break page_no += 1 time.sleep(REQUEST_INTERVAL) return videosdef safe_filename(text: str, fallback: str) -> str: """标题不能直接当文件名:Windows 禁止 <>:\"/\\|?* ,过长也会出问题。""" text = re.sub(r'[<>:"/\\|?*]', "_", text).strip() text = re.sub(r"\s+", " ", text) return (text[:40] or fallback).rstrip(". ")def image_suffix(content: bytes) -> str: """看文件头判断真实格式,不要只信 URL 里的 .jpg 或响应头 Content-Type。""" if content.startswith(b"RIFF") and content[8:12] == b"WEBP": return ".webp" if content.startswith(b"\xff\xd8\xff"): return ".jpg" if content.startswith(b"\x89PNG"): return ".png" if content.startswith(b"GIF8"): return ".gif" return ".jpg"def download_covers(videos: list[dict], folder: Path) -> int: folder.mkdir(parents=True, exist_ok=True) saved = 0 for index, video in enumerate(videos, start=1): url = video["pic"] title = safe_filename(video.get("title") or "", f"cover_{index:02d}") bvid = video.get("bvid") or f"{index:02d}" response = None last_error = None for attempt in range(1, 4): try: response = requests.get(url, headers=HEADERS, timeout=30) response.raise_for_status() break except (requests.Timeout, requests.ConnectionError) as exc: last_error = exc print(f"第 {index} 张超时,重试 {attempt}/3 …") time.sleep(REQUEST_INTERVAL * 2) if response is None: raise last_error suffix = image_suffix(response.content) path = folder / f"{index:02d}_{bvid}_{title}{suffix}" path.write_bytes(response.content) saved += 1 print(f"已保存 {path.name} ({len(response.content)} bytes)") if index < len(videos): time.sleep(REQUEST_INTERVAL) return saveddef main() -> None: try: import sys sys.stdout.reconfigure(encoding="utf-8") except Exception: pass json_path = Path(__file__).with_name("arc_search.json") mode = FETCH_MODE if mode == "api": try: print("正在用 WBI 签名请求投稿列表接口…") videos = fetch_videos_by_api(session(), MID) except RuntimeError as exc: print(exc) if json_path.exists(): print("接口被拦,回退到第 1 课保存的 arc_search.json") videos = fetch_videos_by_json(json_path) else: raise elif mode == "json": videos = fetch_videos_by_json(json_path) else: raise SystemExit(f"未知 FETCH_MODE: {mode},本课请用 api 或 json") print(f"MID={MID} 解析到投稿 {len(videos)} 个") if not videos: raise SystemExit("没有解析到视频。") save_dir = Path(__file__).with_name("covers") meta_path = Path(__file__).with_name("covers.json") meta_path.write_text( json.dumps({"mid": MID, "count": len(videos), "videos": videos}, ensure_ascii=False, indent=2), encoding="utf-8", ) print(f"元数据已写入 {meta_path.name}") saved = download_covers(videos, save_dir) print(f"完成:共保存 {saved} 张封面到 {save_dir}")if __name__ == "__main__": main()七、运行
运行 demo01.py,对照输出:
- 若接口放行:会出现
第 1 页: +17 条,累计 17 / 17,然后照常下载。 - 若被拦(常见即
-352):会先打印风控说明,再提示回退到arc_search.json,封面仍能下完。
两种结果都算本课完成。终端里与接口有关的那几行不妨留下:成功则是分页日志,失败则是 code= 或 412 那段。下一课专门处理「接口死活不放行」——用本机 Chrome 拦截页面自己发出的 arc/search。
八、余论
名单可以手抄,也可以现算。buvid3 不是登录,WBI 也不是加密投稿内容,只是参数签名。被拦并不可耻,回退到 JSON,十七张照样落地。真要跟页面同一条请求,且留待浏览器那一课。
第五课
第四课已用 WBI 直打接口。这一课补上被拦时的两条退路,并把三种取列表的办法接到同一个 main() 里。做到这里,demo01.py 与成品 UP_Photo_spider.py 便是同一条流水线。
当前 FETCH_MODE 已改成 "browser",运行后会弹出本机 Chrome。
一、为何要三种方式
三种模式拿到的都是同一份投稿 JSON,差别只在「列表从哪来」:
| 模式 | 列表从哪来 | 什么时候用 |
|---|---|---|
json |
读第一课保存的 arc_search.json |
已经存过响应,不想再打接口 |
api |
程序自己签 WBI、请求 arc/search |
换 UP、要翻页、不想开浏览器 |
browser |
用真实 Chrome 打开空间页,拦截页面自己发出的 arc/search |
api 返回 412 / -352 时 |
封面下载仍然走第三课的 download_covers。换取数方式,不必重写保存逻辑。
第四课为了看见接口成功或风控,即使本地已有 JSON 也会打 api。成品与本课改回更稳妥的做法:FETCH_MODE="api" 且已经有 arc_search.json 时,改用本地文件,免得重复踩风控。browser 不会被这份 JSON 拦住,否则永远试不到拦截。
二、browser:自动化的 F12
第一课是人肉操作:打开空间页 → Network → 找到 arc/search → 复制 Response。
fetch_videos_by_browser 把这四步交给程序:
# 第1课/UP_Photo/demo01.pydef fetch_videos_by_browser(space_url: str) -> list[dict]: ... tab.listen.start("arc/search") print("正在用 Chrome 打开空间页,若出现验证码请在浏览器里完成…") tab.get(space_url) packet = tab.listen.wait(timeout=90) ... videos = parse_videos(body)按执行顺序读:
Chromium():启动或接管本机 Chrome。走的是真实浏览器,Cookie、TLS、验证码与手工打开时一样,故比脚本直连更不容易立刻 412。listen.start("arc/search"):先开始监听,再打开页面。顺序不能反。先get再listen,请求可能已经发完,什么都截不到。tab.get(space_url):打开投稿列表页。页面上的 JS 自己去请求带 WBI 的接口。listen.wait(timeout=90):最多等 90 秒,直到出现 URL 里带arc/search的那一包。弹出验证码时,在窗口里点完即可,监听还在继续。packet.response.body便是第一课复制的那份 JSON,交给同一个parse_videos。
这不是去解析空间页 HTML,仍然是拦截那条列表接口。
本机还没装库时:
pip install DrissionPage没装的话,报错会提示改用 json 模式。
有一个限制要心里有数:这段代码只等 第一包 arc/search。此 UP 十七条、一页就够;投稿上百时,页面不会一次性请求全部,成品目前也不会自动点「下一页」。那种情况用 api 翻页,或继续用 F12 把每一页存成 JSON。
三、main() 怎么选路
# 第1课/UP_Photo/demo01.pymode = FETCH_MODEif mode == "api" and json_path.exists(): print(f"发现 {json_path.name},改用本地 JSON,避免重复打接口。") mode = "json"if mode == "json": videos = fetch_videos_by_json(json_path)elif mode == "browser": videos = fetch_videos_by_browser(SPACE_URL)elif mode == "api": ...读法:先看指定的模式;若是 api 且本地已有列表,降级为 json;然后三选一。之后写 covers.json、下载封面,三课以来没变。
换一个 UP,只改两处:
SPACE_URL = "https://space.bilibili.com/对方MID/upload/video"MID = 对方MIDjson 模式还要重新保存一份新的 arc_search.json,旧文件是上一个 UP 的。
四、脚本是怎样堆起来的
对照成品,每一段对应哪一课:
| 代码 | 课时 | 解决的问题 |
|---|---|---|
SPACE_URL / MID、读 arc_search.json |
第一课 | 封面不在 HTML 里,在 vlist[].pic |
original_cover_url / parse_videos / fetch_videos_by_json |
第二课 | 洗 URL、抽出下载用的列表 |
safe_filename / image_suffix / download_covers |
第三课 | 循环保存、文件名、文件头 |
session / WBI 三件套 / fetch_videos_by_api |
第四课 | 自己签参数打接口 |
fetch_videos_by_browser、三种 FETCH_MODE |
第五课 | 被拦时换路,收成完整程序 |
核心始终是那句话:先拿到封面 URL 列表,再 for 循环做作业 01 就会的那件事。
五、这一课要核对的
- 若未安装:
pip install DrissionPage。 - 确认
FETCH_MODE = "browser",运行demo01.py。 - 会弹出 Chrome 并打开该 UP 的投稿页。有验证码就在窗口里完成。
- 终端应出现
解析到投稿 17 个,然后开始保存封面。
常见翻车:
- 90 秒超时:页面停在验证码,或开在动态 / 首页,而不是「投稿」。
- 找不到 Chrome:安装官方 Chrome,不要只装 Edge 的便携目录却让库找不到。
- 只想跳过弹窗:把
FETCH_MODE改成"json",用已有arc_search.json仍然能下完。
浏览器拦到十七条(或改用 json 跑通)之后,整门课便收束了。若要对照,可直接并排看 demo01.py 与 UP_Photo_spider.py。
六、完整代码
import jsonimport reimport timeimport uuidimport urllib.parsefrom hashlib import md5from pathlib import Pathimport requestsSPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192FETCH_MODE = "browser" # 本课默认;可改成 "api" 或 "json"PAGE_SIZE = 30REQUEST_INTERVAL = 0.8HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" ), "Referer": SPACE_URL, "Origin": "https://space.bilibili.com", "Accept": "application/json, text/plain, */*",}# 这张表来自 B 站前端 JS,不是自己编的。作用:把 img_key+sub_key 打乱后取前 32 位。MIXIN_KEY_ENC_TAB = [ 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,]def session() -> requests.Session: """投稿接口需要非空 buvid3,否则容易风控。这不是登录 Cookie。""" sess = requests.Session() sess.headers.update(HEADERS) sess.cookies.set("buvid3", f"{uuid.uuid4()}infoc") return sessdef mixin_key(orig: str) -> str: # 成品脚本用 reduce 写了同一句话:按表里的下标重排,再切 32 位 return "".join(orig[index] for index in MIXIN_KEY_ENC_TAB)[:32]def enc_wbi(params: dict, img_key: str, sub_key: str) -> dict: signed = dict(params) signed["wts"] = round(time.time()) signed = dict(sorted(signed.items())) signed = { key: "".join(ch for ch in str(value) if ch not in "!'()*") for key, value in signed.items() } query = urllib.parse.urlencode(signed) signed["w_rid"] = md5((query + mixin_key(img_key + sub_key)).encode()).hexdigest() return signeddef get_wbi_keys(sess: requests.Session) -> tuple[str, str]: data = sess.get("https://api.bilibili.com/x/web-interface/nav", timeout=20).json() wbi = (data.get("data") or {}).get("wbi_img") or {} img_url = wbi.get("img_url") or "" sub_url = wbi.get("sub_url") or "" if not img_url or not sub_url: raise RuntimeError("没有拿到 WBI 密钥,稍后重试。") img_key = img_url.rsplit("/", 1)[-1].split(".")[0] sub_key = sub_url.rsplit("/", 1)[-1].split(".")[0] return img_key, sub_keydef original_cover_url(url: str) -> str: """去掉协议、查询参数、CDN 压缩后缀,尽量留下原图地址。""" if not url: return "" if url.startswith("//"): url = "https:" + url elif url.startswith("http://"): url = "https://" + url[len("http://") :] url = url.split("?")[0] filename = url.rsplit("/", 1)[-1] if "@" in filename: url = url[: url.rfind("@")] return urldef parse_videos(payload: dict) -> list[dict]: """从投稿列表 JSON 里抽出封面。兼容完整响应 / 只有 data / 只有 vlist。""" if isinstance(payload, list): vlist = payload else: data = payload.get("data") if isinstance(payload.get("data"), dict) else payload vlist = ((data.get("list") or {}).get("vlist")) or data.get("vlist") or [] videos = [] for item in vlist: pic = original_cover_url(item.get("pic") or "") if not pic: continue videos.append( { "bvid": item.get("bvid"), "title": item.get("title"), "pic": pic, "created": item.get("created"), "length": item.get("length"), } ) return videosdef fetch_videos_by_json(path: Path) -> list[dict]: payload = json.loads(path.read_text(encoding="utf-8")) videos = parse_videos(payload) if not videos: raise RuntimeError(f"{path.name} 里没有解析到 vlist/pic,确认保存的是 arc/search 的 JSON。") return videosdef api_blocked_hint() -> str: return ( "投稿列表接口被风控(常见 412 / -352)。任选一种方式:\n" "1) 把脚本顶部 FETCH_MODE 改成 \"browser\",用本机 Chrome 打开空间页拦截接口\n" "2) 浏览器打开空间页 → F12 → Network → Fetch/XHR → 刷新," "找到 arc/search → Response 全文保存为同目录 arc_search.json," "再把 FETCH_MODE 改成 \"json\"" )def fetch_videos_by_api(sess: requests.Session, mid: int) -> list[dict]: img_key, sub_key = get_wbi_keys(sess) videos: list[dict] = [] page_no = 1 while True: params = enc_wbi( { "mid": mid, "ps": PAGE_SIZE, "tid": 0, "pn": page_no, "keyword": "", "order": "pubdate", "platform": "web", "web_location": "1550101", "order_avoided": "true", }, img_key, sub_key, ) response = sess.get( "https://api.bilibili.com/x/space/wbi/arc/search", params=params, timeout=20, ) if response.status_code == 412 or "错误号: 412" in response.text: raise RuntimeError(api_blocked_hint()) response.raise_for_status() payload = response.json() if payload.get("code") != 0: raise RuntimeError( f"接口返回异常: code={payload.get('code')} msg={payload.get('message')}\n" + api_blocked_hint() ) page_videos = parse_videos(payload) data = payload.get("data") or {} total = int((data.get("page") or {}).get("count") or 0) videos.extend(page_videos) print(f"第 {page_no} 页: +{len(page_videos)} 条,累计 {len(videos)} / {total or '?'}") if not page_videos or (total and len(videos) >= total): break page_no += 1 time.sleep(REQUEST_INTERVAL) return videosdef fetch_videos_by_browser(space_url: str) -> list[dict]: """打开真实浏览器,拦截页面请求的投稿列表。验证码出现时在弹出的窗口里点一下即可。""" try: from DrissionPage import Chromium except ImportError as exc: raise RuntimeError( '未安装 DrissionPage。可改用 FETCH_MODE="json",或 pip install DrissionPage' ) from exc browser = Chromium() tab = browser.latest_tab tab.listen.start("arc/search") print("正在用 Chrome 打开空间页,若出现验证码请在浏览器里完成…") tab.get(space_url) packet = tab.listen.wait(timeout=90) if packet is None: raise RuntimeError( "90 秒内没有等到 arc/search 数据包。确认页面已打开投稿列表,或改用 json 模式。" ) body = packet.response.body if isinstance(body, (bytes, str)): body = json.loads(body) videos = parse_videos(body) tab.listen.stop() if not videos: raise RuntimeError("拦截到接口但没有封面字段。可改把该响应保存为 arc_search.json。") return videosdef safe_filename(text: str, fallback: str) -> str: """标题不能直接当文件名:Windows 禁止 <>:\"/\\|?* ,过长也会出问题。""" text = re.sub(r'[<>:"/\\|?*]', "_", text).strip() text = re.sub(r"\s+", " ", text) return (text[:40] or fallback).rstrip(". ")def image_suffix(content: bytes) -> str: """看文件头判断真实格式,不要只信 URL 里的 .jpg 或响应头 Content-Type。""" if content.startswith(b"RIFF") and content[8:12] == b"WEBP": return ".webp" if content.startswith(b"\xff\xd8\xff"): return ".jpg" if content.startswith(b"\x89PNG"): return ".png" if content.startswith(b"GIF8"): return ".gif" return ".jpg"def download_covers(videos: list[dict], folder: Path) -> int: folder.mkdir(parents=True, exist_ok=True) saved = 0 for index, video in enumerate(videos, start=1): url = video["pic"] title = safe_filename(video.get("title") or "", f"cover_{index:02d}") bvid = video.get("bvid") or f"{index:02d}" response = None last_error = None for attempt in range(1, 4): try: response = requests.get(url, headers=HEADERS, timeout=30) response.raise_for_status() break except (requests.Timeout, requests.ConnectionError) as exc: last_error = exc print(f"第 {index} 张超时,重试 {attempt}/3 …") time.sleep(REQUEST_INTERVAL * 2) if response is None: raise last_error suffix = image_suffix(response.content) path = folder / f"{index:02d}_{bvid}_{title}{suffix}" path.write_bytes(response.content) saved += 1 print(f"已保存 {path.name} ({len(response.content)} bytes)") if index < len(videos): time.sleep(REQUEST_INTERVAL) return saveddef main() -> None: try: import sys sys.stdout.reconfigure(encoding="utf-8") except Exception: pass json_path = Path(__file__).with_name("arc_search.json") mode = FETCH_MODE # 本地已经有列表时,不必再打会风控的接口 if mode == "api" and json_path.exists(): print(f"发现 {json_path.name},改用本地 JSON,避免重复打接口。") mode = "json" if mode == "json": videos = fetch_videos_by_json(json_path) elif mode == "browser": videos = fetch_videos_by_browser(SPACE_URL) elif mode == "api": print("正在用 WBI 签名请求投稿列表接口…") videos = fetch_videos_by_api(session(), MID) else: raise SystemExit(f"未知 FETCH_MODE: {mode},请用 api / browser / json") print(f"MID={MID} 解析到投稿 {len(videos)} 个") if not videos: raise SystemExit("没有解析到视频。") save_dir = Path(__file__).with_name("covers") meta_path = Path(__file__).with_name("covers.json") meta_path.write_text( json.dumps({"mid": MID, "count": len(videos), "videos": videos}, ensure_ascii=False, indent=2), encoding="utf-8", ) print(f"元数据已写入 {meta_path.name}") saved = download_covers(videos, save_dir) print(f"完成:共保存 {saved} 张封面到 {save_dir}")if __name__ == "__main__": main()七、余论
三条路,一份名单。直连会被拦,便去跟页面同一条请求;名单已在手边,便不必再惊动接口。封面始终是作业 01 的循环。课止于此。