Chenerdy的幻想乡
首页文章归档风说书笔记说说友链项目相册关于
← 返回文章列表

爬虫小作业02草稿:爬取N张UP主视频封面

2026-09-13 21:00:00

第一课

作业 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                         ^^^^^^^^                         这串数字就是 MID

MID 即此 UP 主在 B 站的用户 ID。ID 不同,则 UP 主有异。接口问「这个人有哪些投稿」,所靠即此。

故于脚本中先列二常量,以供后用:

Python 实例
SPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192

三、用 F12 抓真正的列表接口

步骤如左:

  1. 用 Chrome 或 Edge 打开上面的 SPACE_URL。
  2. 按 F12 打开开发者工具,切到 Network(网络)。
  3. 勾选 Fetch/XHR。列表里便只留下接口请求,网页、图片、CSS 都先藏起来。
  4. 按 F5 刷新。若弹出验证码,在页面上点完,再刷新一次。
  5. 在筛选框里输入 arc 或 search。
  6. 找到名字里带 arc/search 的那一条(完整路径一般是 /x/space/wbi/arc/search)。
  7. 点它,右侧切到 Response(响应),可见以 { 开头的一大段 JSON。
  8. 全选复制,保存为 arc_search.json,与主文件 demo01.py 放在同一文件夹。

存放后,JSON 最外层通常长这样:

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。

先确认文件在不在:

Python 实例
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"] 往下钻:

Python 实例
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。

按路径取出投稿列表:

Python 实例
# 封面地址在: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 列表。每一项长这样:

JSON 实例
{  "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 按列表预览尺寸裁出来的图 从最后一个 @ 截掉

对应代码:

Python 实例
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 url

main() 开头有三条假地址作演示。运行后可见:真实 pic 只是 http 变成 https;带 // 与 @672w 的那条,会被洗成干净的原图地址。

三、从 JSON 抽出列表

第一课把路径写死为 payload["data"]["list"]["vlist"]。这一课收成函数,并兼容以后会碰到的三种存法:

  1. 完整响应:{ "code": 0, "data": { "list": { "vlist": [...] } } } —— 现在这份即是
  2. 只复制了 data 那一层
  3. 只复制了 vlist 数组
Python 实例
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.jpg​MID=86735192  解析到投稿 17 个----------------------------------------01. 羽生迷子 - ぼくのかみさま    BV1mq4Zz4Eb5  02:56    https://i1.hdslb.com/bfs/archive/b0ae9444bdee463b16981b25bf1304afccad2a64.jpg……

六、完整代码

Python 实例
import jsonfrom pathlib import Path​SPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192​​def 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 url​​def 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 videos​​def 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 videos​​def 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:

Python 实例
# 第1课/photowall/wallpaper.pyresponse = requests.get(url, headers=headers, timeout=15)content = response.content...save_path.write_bytes(content)

如今只是 URL 来自列表,文件名带上序号与标题:

Python 实例
# 第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):文件夹不存在就建;已经存在也不报错。

六、代码

完整代码如左:

Python 实例
import jsonimport reimport timefrom pathlib import Path​import requests​SPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192REQUEST_INTERVAL = 0.8​HEADERS = {    "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 url​​def 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 videos​​def 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 videos​​def 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 saved​​def 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/ 文件夹,确认:

  1. 大约 17 张图,能正常打开,不是「损坏的 jpg」。
  2. 文件名没有 /、* 这类非法字符。
  3. 同目录多了一份 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,不是登录

Python 实例
# 第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_key

sub_key 同理。密钥会变,故每次运行都重新取,不要写死。

第二步:打乱。 MIXIN_KEY_ENC_TAB 是 0~63 的重排顺序。把 img_key + sub_key(64 个字符)按此顺序重排,再取前 32 位。成品脚本用 reduce 写了同一句话,demo01.py 里改成更好读的:

Python 实例
"".join(orig[index] for index in MIXIN_KEY_ENC_TAB)[:32]

第三步:签名。

  1. 拷贝参数,加上 wts(当前 Unix 时间戳)
  2. 按键名排序(保证同样参数每次拼出同一条 query)
  3. 去掉值里的 !'()*(B 站前端也这么滤)
  4. urlencode 得到 query 字符串
  5. 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

Python 实例
# 第1课/UP_Photo/demo01.pyFETCH_MODE = "api"

main() 里:

  1. api:调用 fetch_videos_by_api
  2. 抛出风控错误,且本地有 arc_search.json:回退到 json
  3. 也可自己改成 "json",完全不打接口

第五课再加 "browser"。本课故意不写「发现本地 JSON 就跳过接口」,否则永远看不见 412 / -352。

后面的 parse_videos、download_covers、covers.json 与第三课相同。变的只是列表从哪来。

六、完整代码

Python 实例
"""第 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 Path​import requests​SPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192FETCH_MODE = "api"PAGE_SIZE = 30REQUEST_INTERVAL = 0.8​HEADERS = {    "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 sess​​def 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 signed​​def 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_key​​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 url​​def 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 videos​​def 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 videos​​def 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 videos​​def 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 saved​​def 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. 若接口放行:会出现 第 1 页: +17 条,累计 17 / 17,然后照常下载。
  2. 若被拦(常见即 -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 把这四步交给程序:

Python 实例
# 第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)

按执行顺序读:

  1. Chromium():启动或接管本机 Chrome。走的是真实浏览器,Cookie、TLS、验证码与手工打开时一样,故比脚本直连更不容易立刻 412。
  2. listen.start("arc/search"):先开始监听,再打开页面。顺序不能反。先 get 再 listen,请求可能已经发完,什么都截不到。
  3. tab.get(space_url):打开投稿列表页。页面上的 JS 自己去请求带 WBI 的接口。
  4. listen.wait(timeout=90):最多等 90 秒,直到出现 URL 里带 arc/search 的那一包。弹出验证码时,在窗口里点完即可,监听还在继续。
  5. packet.response.body 便是第一课复制的那份 JSON,交给同一个 parse_videos。

这不是去解析空间页 HTML,仍然是拦截那条列表接口。

本机还没装库时:

实例
pip install DrissionPage

没装的话,报错会提示改用 json 模式。

有一个限制要心里有数:这段代码只等 第一包 arc/search。此 UP 十七条、一页就够;投稿上百时,页面不会一次性请求全部,成品目前也不会自动点「下一页」。那种情况用 api 翻页,或继续用 F12 把每一页存成 JSON。

三、main() 怎么选路

Python 实例
# 第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 = 对方MID

json 模式还要重新保存一份新的 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 就会的那件事。

五、这一课要核对的

  1. 若未安装:pip install DrissionPage。
  2. 确认 FETCH_MODE = "browser",运行 demo01.py。
  3. 会弹出 Chrome 并打开该 UP 的投稿页。有验证码就在窗口里完成。
  4. 终端应出现 解析到投稿 17 个,然后开始保存封面。

常见翻车:

  • 90 秒超时:页面停在验证码,或开在动态 / 首页,而不是「投稿」。
  • 找不到 Chrome:安装官方 Chrome,不要只装 Edge 的便携目录却让库找不到。
  • 只想跳过弹窗:把 FETCH_MODE 改成 "json",用已有 arc_search.json 仍然能下完。

浏览器拦到十七条(或改用 json 跑通)之后,整门课便收束了。若要对照,可直接并排看 demo01.py 与 UP_Photo_spider.py。

六、完整代码

Python 实例
import jsonimport reimport timeimport uuidimport urllib.parsefrom hashlib import md5from pathlib import Path​import requests​SPACE_URL = "https://space.bilibili.com/86735192/upload/video"MID = 86735192FETCH_MODE = "browser"  # 本课默认;可改成 "api" 或 "json"PAGE_SIZE = 30REQUEST_INTERVAL = 0.8​HEADERS = {    "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 sess​​def 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 signed​​def 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_key​​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 url​​def 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 videos​​def 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 videos​​def 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 videos​​def 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 videos​​def 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 saved​​def 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 的循环。课止于此。

留言

目录