B 站动态列表不是传统分页。请求下一页时,必须把上一页响应里的 offset 原样带回去。自己拿动态 ID 拼参数,或者写个 page += 1,跑出来的数据不是重复,就是到十几条直接停。
目前网页端空间动态使用的是下面这个接口:
GET /x/polymer/web-dynamic/v1/feed/space
请求里主要看两个参数:
host_mid:UP 主 UID
offset:上一页返回的游标,第一页留空
响应中的 has_more 决定是否继续,offset 则是下一次请求的游标。2026 年的抓包记录中,这套游标翻页方式仍在使用。
这里我不打算上 Selenium。动态数据本来就是 JSON,再启动一个浏览器、滚动页面、等 DOM 渲染,纯属给自己添堵。
先安装依赖:
pip install requests
下面这段脚本会做两件事:完整响应写入 JSONL,方便以后重新解析;常用字段写入 CSV,平时筛选和统计直接打开就能看。
import csv
import json
import os
import sys
import time
from datetime import datetime
import requests
API = "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space"
defcollect_text(item):
module = item.get("modules", {}).get("module_dynamic", {})
parts = []
desc = module.get("desc") or {}
if desc.get("text"):
parts.append(desc["text"].strip())
major = module.get("major") or {}
for card in major.values():
ifnot isinstance(card, dict):
continue
for key in ("title", "desc"):
value = card.get(key)
if isinstance(value, str) and value.strip():
parts.append(value.strip())
summary = card.get("summary")
if isinstance(summary, dict) and summary.get("text"):
parts.append(summary["text"].strip())
return"\n".join(dict.fromkeys(parts))
defcollect_media(item):
major = (
item.get("modules", {})
.get("module_dynamic", {})
.get("major") or {}
)
result = set()
defscan(node):
if isinstance(node, dict):
for key, value in node.items():
if key in {"url", "src"} and isinstance(value, str):
if value.startswith("//"):
value = "https:" + value
if value.startswith("http"):
result.add(value)
else:
scan(value)
elif isinstance(node, list):
for child in node:
scan(child)
scan(major)
return list(result)
defrequest_page(session, uid, offset):
response = session.get(
API,
params={
"host_mid": uid,
"offset": offset,
"timezone_offset": -480,
},
timeout=15,
)
response.raise_for_status()
payload = response.json()
if payload.get("code") != 0:
raise RuntimeError(
f"接口返回异常:code={payload.get('code')},"
f"message={payload.get('message')}"
)
return payload["data"]
defrun(uid):
session = requests.Session()
session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/138.0 Safari/537.36"
),
"Referer": f"https://space.bilibili.com/{uid}/dynamic",
})
cookie = os.getenv("BILI_COOKIE", "").strip()
if cookie:
session.headers["Cookie"] = cookie
offset = ""
seen = set()
page_no = 0
with open("dynamic_raw.jsonl", "w", encoding="utf-8") as raw_file, \
open("dynamic_index.csv", "w", encoding="utf-8-sig", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=[
"dynamic_id", "type", "publish_time",
"text", "link", "media"
])
writer.writeheader()
whileTrue:
data = request_page(session, uid, offset)
items = data.get("items") or []
page_no += 1
for item in items:
dynamic_id = item.get("id_str")
ifnot dynamic_id or dynamic_id in seen:
continue
seen.add(dynamic_id)
raw_file.write(
json.dumps(item, ensure_ascii=False) + "\n"
)
author = item.get("modules", {}).get("module_author", {})
timestamp = author.get("pub_ts")
publish_time = (
datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
if timestamp else""
)
jump_url = item.get("basic", {}).get("jump_url", "")
if jump_url.startswith("//"):
jump_url = "https:" + jump_url
writer.writerow({
"dynamic_id": dynamic_id,
"type": item.get("type", ""),
"publish_time": publish_time,
"text": collect_text(item),
"link": jump_url,
"media": json.dumps(
collect_media(item), ensure_ascii=False
),
})
print(
f"第 {page_no} 页完成,"
f"本页 {len(items)} 条,累计 {len(seen)} 条"
)
ifnot data.get("has_more"):
break
next_offset = data.get("offset")
ifnot next_offset or next_offset == offset:
raise RuntimeError("分页游标没有变化,停止请求,避免死循环")
offset = next_offset
time.sleep(1.2)
if __name__ == "__main__":
if len(sys.argv) != 2ornot sys.argv[1].isdigit():
raise SystemExit("用法:python bili_dynamic_dump.py UP主UID")
run(sys.argv[1])
运行时把空间地址里的数字 UID 传进去:
python bili_dynamic_dump.py 123456789
我特意没有只保存 desc.text。
B 站动态卡片的结构并不统一。纯文字动态、图文、视频投稿、专栏和转发动态,正文可能落在不同节点里。只盯着一个字段,脚本看起来跑完了,CSV 里却会出现一堆空内容。这种问题最麻烦,不报错,但数据已经缺了。
所以脚本保留了 dynamic_raw.jsonl。CSV 只是方便查看的索引,原始 JSON 才是后面补字段时的退路。哪天接口卡片结构调整,也不用重新把几千条动态再请求一遍。
还有两个地方别省。
第一是去重。接口翻页过程中如果 UP 主正好发布或删除动态,列表位置可能发生变化。用动态 ID 做一次去重,成本不高。
第二是请求间隔。这里每页停了 1.2 秒,不要改成几十个线程硬冲。该接口存在登录状态和风控校验,未登录请求并不保证始终稳定。
碰到接口返回异常,先停,不要一看报错就换代理、堆重试。确实需要登录态时,可以通过 BILI_COOKIE 环境变量传入自己的会话信息,但不要把 Cookie 直接写进脚本,更不要提交到代码仓库。
最后还得把“所有动态”这四个字说清楚:脚本拿到的是当前账号有权限查看、并且接口仍能翻到的公开动态。已经删除、仅自己可见或受权限限制的内容,爬虫不会凭空把它们找回来。
能正常翻页、原始数据留底、请求失败及时停,这个脚本就够用了。再往上堆并发和反爬花活,通常不是优化,是在给下次故障埋雷。