当前位置:首页>python>Python 爬取 B 站 UP 主的全部动态,我没有用 Selenium

Python 爬取 B 站 UP 主的全部动态,我没有用 Selenium

  • 2026-09-09 02:46:00
Python 爬取 B 站 UP 主的全部动态,我没有用 Selenium
第一页返回正常,第二页却不能把页码改成 2。

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 直接写进脚本,更不要提交到代码仓库。

最后还得把“所有动态”这四个字说清楚:脚本拿到的是当前账号有权限查看、并且接口仍能翻到的公开动态。已经删除、仅自己可见或受权限限制的内容,爬虫不会凭空把它们找回来。

能正常翻页、原始数据留底、请求失败及时停,这个脚本就够用了。再往上堆并发和反爬花活,通常不是优化,是在给下次故障埋雷。

最新文章

随机文章