B站小视频批量爬取实战:绕过三重签名获取真实播放地址
简介本资源是一份面向Python初学者与爬虫实践者的轻量级工具脚本聚焦B站小视频的批量采集需求解决网页结构解析、登录态维持与分页数据抓取等典型反爬场景问题。压缩包为2KB的ZIP文件仅含1个核心Python脚本bilibili.py涵盖requests模拟请求、Cookie会话管理、HTML元素定位及基础异常重试逻辑代码简洁可读适合作为入门级爬虫项目二次开发或教学演示素材。已有936人学习下载读者可直接运行调试快速掌握B站动态页面的数据提取流程理解登录鉴权、分页参数构造与视频信息标题、ID、作者结构化提取的关键实现细节并基于此拓展存储为CSV或对接本地数据库。1. 为什么你写的“批量爬B站小视频”脚本跑三天就失效——这不是代码问题是协议层在反制你写了个 Python 脚本用 requests BeautifulSoup 抓首页推荐流再进每个视频页解析video标签、提取src最后用urllib.request.urlretrieve下载——前20个视频成功了第21个开始返回 403到第50个全部变成 412 或空 JSON第七天重跑连首页都拿不到完整 HTML只有一段加密的script和一堆 base64 字符串。这不是你代码写得烂而是 B站小视频即“动态页-短视频流”非主站投稿区的请求链路早已不是“发个 GET 就能拿到 MP4 地址”这么简单。它背后是三重动态校验设备指纹绑定 RefererUA 强校验 视频地址 Token 限时签名。不模拟真实 WebView 行为、不复用登录态 Cookie、不逆向 signature 算法所谓“批量爬取”就是对着空气按回车。本文只讲一件事如何用最少依赖、最稳路径在本地环境可持续获取 B站动态页中「小视频」的真实播放地址与元数据。适合已会写基础 requests 的 Python 开发者也适合被“B站视频下载失败”卡住一周的产品/运营同学——你不需要逆向 JS、不需装安卓模拟器、不需买云手机只需要理解它怎么验证、怎么绕过、怎么长期维护。2. 不碰 Selenium不装 Appium用 Requests 模拟真实 WebView 请求链B站小视频的入口不在bilibili.com/video/而在t.bilibili.com动态页或api.vc.bilibili.com短视频 API。主流误判是以为抓https://www.bilibili.com/v/popular/all就能拿到小视频列表——错。那页全是长视频。真正的小视频竖屏、60s、带“小”角标、来源为“动态”由独立接口承载且必须携带有效登录态和设备标识。2.1 从「动态页」切入定位真实 API 入口与必要 Header打开 Chrome → 登录 B站 → 进入「动态」Tab → 滚动加载 → 打开 DevTools → Network → Filtervc.bilibili.com→ 刷新页面。你会看到多个请求其中最关键的两个是GET https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uidxxxoffsetxxxtype8type8 表示“短视频”类型这是动态页默认加载的小视频流GET https://api.vc.bilibili.com/feed_svr/v1/feed_svr/get_feeds?version2.29.0platformwebweb_location1550101新版 feed 接口更稳定支持分页与过滤注意这两个接口都要求Cookie中含SESSDATA有效期 30 天、bili_jctCSRF token、DedeUserID同时必须携带User-Agent必须是真实浏览器 UA且版本需匹配 B站当前 Web 端策略Referer必须为https://t.bilibili.com/Origin必须为https://t.bilibili.com。下面这段代码是你能跑通的第一步——不登录也能拿到部分公开小视频未登录时 type8 返回为空但 type0 可返回公开动态但要批量获取指定 UP 主的小视频必须登录。import requests import time import json # 1. 准备 headers —— 必须严格匹配否则 403 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Referer: https://t.bilibili.com/, Origin: https://t.bilibili.com, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Sec-Fetch-Dest: empty, Sec-Fetch-Mode: cors, Sec-Fetch-Site: same-site, } # 2. 携带登录 Cookie从浏览器复制非伪造 cookies { SESSDATA: your_sessdata_here, # 登录后 F12 → Application → Cookies → 复制值 bili_jct: your_bili_jct_here, # 同上 DedeUserID: 123456789, # 同上数字字符串 } # 3. 请求动态页小视频流以 uid123456789 为例 url https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new params { uid: 123456789, type: 8, # 8 短视频小视频 offset: 0, # 分页偏移每次 20 need_top: 0 } resp requests.get(url, headersheaders, cookiescookies, paramsparams, timeout10) if resp.status_code 200: data resp.json() print(✅ 成功获取动态小视频列表共, len(data.get(data, {}).get(cards, [])), 条) else: print(❌ 请求失败状态码, resp.status_code, 响应:, resp.text[:200])参数说明SESSDATA是核心凭证30天有效期过期需重新登录获取bili_jct是 CSRF token用于后续 POST 操作如点赞、收藏此处 GET 可省略但建议带上offset是分页关键B站不返回 total靠has_more字段判断是否继续type8是小视频专属标识type0是全部动态含图文、长视频但混杂度高不推荐User-Agent必须与你浏览器实际一致Chrome 124 是 2024 年 Q2 主流版本若用旧版 UA 会被拦截。2.2 解析卡片结构从 JSON 提取小视频 IDaid / bvid与基础信息B站动态卡片结构嵌套深小视频信息藏在card.desc.user_profile.info.official_verify.type 0个人认证或card.desc.user_profile.info.vip.type 2大会员之外更关键的是card.card字段里的item对象。# 续上节 resp.json() cards data.get(data, {}).get(cards, []) for card in cards: try: # 解析 card.card 字段JSON string card_data json.loads(card[card]) item card_data.get(item, {}) # 小视频必有字段 if not item.get(video_play_url): # 无播放地址跳过 continue aid item.get(aid) # av号老ID bvid item.get(bvid) # bv号新ID优先用 desc item.get(description, )[:50] ... duration item.get(duration, 0) # 秒数 cover item.get(cover) # 封面图URL print(f {bvid} | {desc} | {duration}s | 封面: {cover[:40]}...) except (json.JSONDecodeError, KeyError, TypeError) as e: continue # 跳过异常卡片如纯文字动态关键点card[card]是字符串必须json.loads()二次解析item[video_play_url]是伪地址不能直接下载见第3章bvid是唯一稳定标识比aid更可靠所有后续 API 均以bvid为准duration单位为秒小视频通常 ≤ 60可作过滤条件if duration 60:cover是封面图可用于快速预览内容合规性避免爬取违规素材。3. 视频地址不是 URL破解 B站小视频的「三重签名」播放链你拿到item[video_play_url]比如https://upos-sz-mirrorakam.akamaized.net/upgcxcode/xx/xx/xxxxxxx/xxxxxxx-1-16.mp4?e...uipk...nbs...deadline...gen...osakamoi...trid...platformpcupsig...uparamse,uipk,nbs,deadline,gen,os,oi,trid,platform—— 这不是真实 MP4 地址而是CDN 路由跳转地址。直接requests.get()会返回 302 重定向最终跳到一个带Expires和OSSAccessKeyId的临时地址该地址 10 分钟内失效且绑定请求 IP 与 UA。B站真正保护的是upsiguparams这组签名参数。3.1 为什么不能直接下载 video_play_url——看懂 B站播放器的请求链B站 Web 端播放小视频时并非直接请求video_play_url而是先调用GET https://api.bilibili.com/x/player/playurl?bvidBV1xx4y1K7mQqn16fnver0fnval4048fourk1platformhtml5seer1这个接口返回真正的durl[0].url即带完整签名的直链且qn16表示“流畅”360Pqn32是高清720Pqn64是超清1080P——但小视频多数只提供 qn16。def get_playurl(bvid: str, qn: int 16, cookies: dict None) - str: 获取小视频真实播放地址带签名10分钟有效 :param bvid: 视频 BV 号 :param qn: 清晰度16360P, 32720P, 641080P小视频通常仅支持16 :param cookies: 登录态 Cookie :return: 真实 MP4 直链 url https://api.bilibili.com/x/player/playurl params { bvid: bvid, qn: qn, fnver: 0, fnval: 4048, # 4048 flv mp4 dash html5 fourk: 1, platform: html5, seer: 1 } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.bilibili.com/, Origin: https://www.bilibili.com } resp requests.get(url, headersheaders, cookiescookies, paramsparams, timeout10) if resp.status_code ! 200: raise Exception(fplayurl 接口请求失败: {resp.status_code} {resp.text[:100]}) data resp.json() if data.get(code) ! 0: raise Exception(fplayurl 接口返回错误: {data.get(message, unknown)}) durl data[data][durl][0] return durl[url] # 示例调用 real_url get_playurl(BV1xx4y1K7mQ, qn16, cookiescookies) print(✅ 真实播放地址:, real_url[:80] ...)为什么必须用这个接口它返回的durl[0].url是 CDN 直链无重定向可直接requests.get(..., streamTrue)下载fnval4048是关键表示请求 MP4 格式小视频不提供 FLVplatformhtml5匹配 Web 端行为若用android或ios会返回不同格式或报错seer1表示启用“画质自适应”对小视频影响不大但建议带上。3.2 批量下载用 streamTrue 防止内存爆炸小视频单个文件约 2–10MB1000 条就是 5GB。若用response.content全部读入内存再写文件极易 OOM。必须用streamTrue 分块写入。def download_video(url: str, filepath: str, timeout: int 30): 流式下载视频防止内存溢出 :param url: 真实 MP4 直链来自 playurl 接口 :param filepath: 保存路径如 ./videos/BV1xx4y1K7mQ.mp4 :param timeout: 下载超时秒 try: with requests.get(url, streamTrue, timeouttimeout) as r: r.raise_for_status() total_size int(r.headers.get(content-length, 0)) with open(filepath, wb) as f: for chunk in r.iter_content(chunk_size8192): if chunk: f.write(chunk) print(f✅ 已保存: {filepath} ({total_size//1024} KB)) return True except Exception as e: print(f❌ 下载失败 {filepath}: {e}) return False # 批量下载示例 bvid_list [BV1xx4y1K7mQ, BV1yy4y1K7nP, BV1zz4y1K7oR] for bvid in bvid_list: try: real_url get_playurl(bvid, qn16, cookiescookies) filepath f./videos/{bvid}.mp4 download_video(real_url, filepath) time.sleep(1) # 防触发风控每请求间隔 ≥1s except Exception as e: print(f⚠️ {bvid} 处理失败: {e})关键参数说明chunk_size8192是 8KB 缓冲平衡 I/O 效率与内存占用time.sleep(1)是硬性要求B站对/x/player/playurl接口有 QPS 限流约 2qps连续请求会返回{code:-412,message:请求被拦截}total_size用于日志反馈也可用于断点续传本方案暂不实现但留了扩展位文件名用bvid而非aid因 BV 号全局唯一、无重复且 B站搜索/分享均用 BV。4. 避坑B站小视频爬取的 4 个血泪现场与解法B站反爬机制迭代频繁以下问题是过去 6 个月内高频翻车点每一条都来自真实生产环境日志。4.1 现象playurl接口返回{code:-412,message:请求被拦截}原因请求频率过高1s 间隔User-Agent与Referer不匹配如 UA 是 Chrome 124但 Referer 写成https://www.bilibili.com/Cookie 中SESSDATA过期或被服务器标记为“异常登录态”如异地、多设备并发。解决固定time.sleep(1.2)宁慢勿快Referer必须为https://www.bilibili.com/playurl 接口或https://t.bilibili.com/dynamic 接口不可混用每 24 小时检查一次SESSDATA有效性用GET https://api.bilibili.com/x/space/myinfo若返回code-101账号未登录则需人工更新 Cookie。4.2 现象dynamic_new接口返回空cards但手动访问网页正常原因offset参数超出上限B站动态页最多返回最近 1000 条offset 1000 时has_morefalse但cards[]uid对应账号设置了“不公开动态”隐私设置 → 动态 → “谁可以看我的动态” 设为“仅自己”请求头缺失X-Requested-With: XMLHttpRequest部分新版接口强制校验。解决在循环中加入if not data.get(data, {}).get(has_more): break而非硬写offset 20先用GET https://api.bilibili.com/x/space/acc/info?mid{uid}检查data[status] 0用户存在且data[data][live_status]存在非封禁补充 headerX-Requested-With: XMLHttpRequest。4.3 现象下载的 MP4 文件无法播放用ffprobe查看提示moov atom not found原因CDN 直链返回 HTTP 200 但内容为空常见于签名过期或 IP 被限流requests.get(..., streamTrue)未校验r.status_code直接写入空文件网络中断导致iter_content()提前退出文件不完整。解决下载前加校验if r.status_code ! 200 or int(r.headers.get(content-length, 0)) 10240: raise Exception(无效响应)写入后校验文件大小if os.path.getsize(filepath) 10240: os.remove(filepath); raise Exception(文件过小疑似下载失败)用ffmpeg -v error -i file.mp4 -f null - 21做静默校验耗时但可靠。4.4 现象爬到的视频全是“测试视频”“黑屏”“无声”原因type8返回的卡片中部分item[video_play_url]实际指向“审核中”或“已删除”视频playurl接口仍返回地址但内容为占位符B站对低互动视频10 播放不生成真实 MP4只返回 1s 黑帧qn16在部分小视频中不可用需 fallback 到qn0自动选并解析accept_description字段。解决下载后用cv2.VideoCapture(filepath).get(cv2.CAP_PROP_FRAME_COUNT) 10判断是否真有画面需安装 opencv-python若playurl返回data[accept_description]为空或不含360P则跳过该视频加入duration 2过滤B站测试视频常为 1s 黑屏。5. 进阶构建可持续的批量爬取管道——从“能跑”到“能维”写一个能跑通 10 条视频的脚本容易但支撑每天自动抓取 500 个 UP 主、持续 3 个月不中断需要工程化设计。以下是我在 3 个生产项目中沉淀下来的最小可行架构。5.1 Cookie 自动续期用 Puppeteer 无头登录替代手动复制SESSDATA30 天过期人工维护不可行。我们用 PuppeteerNode.js做轻量登录Python 调用其输出 Cookie。步骤安装 Puppeteernpm install puppeteer编写login.js自动输入账号密码等待跳转输出 Cookie JSONPython 中用subprocess.run([node, login.js], capture_outputTrue)获取 Cookie。// login.js const puppeteer require(puppeteer); (async () { const browser await puppeteer.launch({ headless: true }); const page await browser.newPage(); await page.goto(https://passport.bilibili.com/login); // 此处填入你的账号密码明文建议放 .env await page.type(#login-username, your_username); await page.type(#login-passwd, your_password); await page.click(.btn-login); await page.waitForNavigation(); const cookies await page.cookies(); console.log(JSON.stringify(cookies)); await browser.close(); })();Python 端解析import subprocess import json def refresh_cookies(): result subprocess.run( [node, login.js], capture_outputTrue, textTrue, timeout120 ) if result.returncode ! 0: raise Exception(登录失败: result.stderr) cookies_raw json.loads(result.stdout) cookie_dict {c[name]: c[value] for c in cookies_raw} return { SESSDATA: cookie_dict.get(SESSDATA, ), bili_jct: cookie_dict.get(bili_jct, ), DedeUserID: cookie_dict.get(DedeUserID, ) } # 每天凌晨 3 点自动刷新 if datetime.now().hour 3 and not os.path.exists(cookies_last_refresh): cookies refresh_cookies() with open(cookies.json, w) as f: json.dump(cookies, f) open(cookies_last_refresh, w).close()提示Puppeteer 登录需处理滑块验证码B站已接入极验但小视频爬取场景下95% 的账号无需滑块因登录 IP 与常用设备匹配。若遇滑块可接入第三方打码平台如超级鹰但本方案默认跳过——直接人工扫码登录一次后续 30 天免维护。5.2 断点续传与去重用 SQLite 记录已爬 BVID避免重复下载、断网恢复、增量抓取全靠一张表字段类型说明bvidTEXT PRIMARY KEY视频唯一 IDuidINTEGERUP 主 UIDtitleTEXT视频标题用于去重语义durationINTEGER时长秒size_kbINTEGER文件大小KBdownloaded_atDATETIME下载时间statusTEXTsuccess/failed/skippedimport sqlite3 def init_db(): conn sqlite3.connect(bilibili_videos.db) conn.execute( CREATE TABLE IF NOT EXISTS videos ( bvid TEXT PRIMARY KEY, uid INTEGER, title TEXT, duration INTEGER, size_kb INTEGER, downloaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, status TEXT ) ) conn.commit() return conn def is_downloaded(conn, bvid: str) - bool: cur conn.cursor() cur.execute(SELECT 1 FROM videos WHERE bvid ? AND status success, (bvid,)) return cur.fetchone() is not None def mark_downloaded(conn, bvid: str, uid: int, title: str, duration: int, size_kb: int): conn.execute( INSERT OR REPLACE INTO videos (bvid, uid, title, duration, size_kb, status) VALUES (?, ?, ?, ?, ?, success), (bvid, uid, title, duration, size_kb) ) conn.commit()使用逻辑每次解析卡片前先查is_downloaded(conn, bvid)下载成功后调用mark_downloaded(...)失败记录statusfailed便于后续重试每周执行DELETE FROM videos WHERE downloaded_at datetime(now, -30 days)清理旧数据。5.3 防风控节奏控制器基于成功率动态调节 QPS固定sleep(1.2)是下策。更优做法是监控playurl接口成功率动态调整间隔class QPSCalculator: def __init__(self): self.success_count 0 self.total_count 0 self.base_delay 1.2 def record(self, success: bool): self.total_count 1 if success: self.success_count 1 def get_delay(self) - float: if self.total_count 0: return self.base_delay success_rate self.success_count / self.total_count if success_rate 0.8: return min(3.0, self.base_delay * 2) # 降速 elif success_rate 0.95: return max(0.8, self.base_delay * 0.8) # 加速 else: return self.base_delay # 使用 qps QPSCalculator() for bvid in bvid_list: try: real_url get_playurl(bvid, cookiescookies) if download_video(real_url, f./videos/{bvid}.mp4): qps.record(True) else: qps.record(False) except Exception as e: qps.record(False) time.sleep(qps.get_delay())效果当成功率跌至 70%自动延至 3s/次避免被封当连续 50 次成功缩至 0.8s/次提升吞吐日志中记录qps.get_delay()可绘图观察风控强度变化。6. 最后一公里用 FFmpeg 提取封面 语音让小视频真正可用爬下来只是第一步。小视频的价值在于内容可分析封面图识别场景、语音转文字做关键词聚类、时长分布统计活跃度。我一般会在下载后立即做三件事全部用 FFmpeg 命令行完成零 Python 依赖。6.1 提取首帧封面JPG与关键帧缩略图3张# 提取首帧00:00:00.000 ffmpeg -i BV1xx4y1K7mQ.mp4 -ss 00:00:00.000 -vframes 1 -q:v 2 BV1xx4y1K7mQ_cover.jpg # 提取 3 张关键帧视频 1/4、2/4、3/4 位置 ffmpeg -i BV1xx4y1K7mQ.mp4 -vf selecteq(n\,floor(N/4)) eq(n\,floor(N/2)) eq(n\,floor(3*N/4)),setptsN/(FRAME_RATE*TB) -vframes 3 BV1xx4y1K7mQ_keyframe_%01d.jpg参数说明-ss时间戳精度到毫秒00:00:00.000是最稳起点-q:v 2控制 JPG 质量1最好31最差2 是清晰与体积平衡点select表达式用帧号N计算等分点比-vf fps1更准避免音频干扰输出命名%01d生成keyframe_0.jpg,keyframe_1.jpg,keyframe_2.jpg。6.2 提取语音WAV并降噪供 Whisper 转文字# 提取音频流AAC → WAV保留原始采样率 ffmpeg -i BV1xx4y1K7mQ.mp4 -vn -acodec pcm_s16le -ar 16000 BV1xx4y1K7mQ.wav # 降噪用 ffmpeg 的 anlmdn 滤镜需编译支持 ffmpeg -i BV1xx4y1K7mQ.wav -af anlmdnnr1000:sr16000 BV1xx4y1K7mQ_clean.wav为什么用 16kHzWhisper 默认输入采样率是 16kHz直接转换省去 resample 步骤anlmdn是 FFmpeg 5.1 内置降噪滤镜nr1000是噪声强度1000~5000小视频背景音较弱1000 足够。6.3 批量处理脚本用 shell 封装1 行命令搞定整批#!/bin/bash # process_videos.sh INPUT_DIR./videos OUTPUT_DIR./processed mkdir -p $OUTPUT_DIR/covers $OUTPUT_DIR/audios $OUTPUT_DIR/keyframes for mp4 in $INPUT_DIR/*.mp4; do bvid$(basename $mp4 .mp4) # 封面 ffmpeg -i $mp4 -ss 00:00:00.000 -vframes 1 -q:v 2 $OUTPUT_DIR/covers/${bvid}_cover.jpg /dev/null 21 # 关键帧 ffmpeg -i $mp4 -vf selecteq(n\,floor(N/4)) eq(n\,floor(N/2)) eq(n\,floor(3*N/4)),setptsN/(FRAME_RATE*TB) -vframes 3 $OUTPUT_DIR/keyframes/${bvid}_keyframe_%01d.jpg /dev/null 21 # 音频 ffmpeg -i $mp4 -vn -acodec pcm_s16le -ar 16000 $OUTPUT_DIR/audios/${bvid}.wav /dev/null 21 echo ✅ Processed $bvid done运行chmod x process_videos.sh ./process_videos.sh我的习惯是爬虫脚本只负责「获取 raw MP4」FFmpeg 脚本负责「内容提纯」Whisper 脚本负责「语义理解」——三层解耦哪层挂了不影响其他。曾经因为 FFmpeg 版本不兼容导致封面提取失败但 MP4 文件完好重跑 FFmpeg 即可不用重爬。这比把所有逻辑塞进一个 Python 文件里靠谱得多。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →