B站视频链接抓取避坑指南:从API分析到数据存储的全流程解析
最近在做一个内容分析项目,需要批量获取B站上特定主题的视频信息。本以为用Python写个爬虫脚本是件轻松活儿,没想到实际操作起来,从请求构造到数据存储,每一步都藏着不少“坑”。如果你也正打算从B站获取视频数据,并且已经对Python和HTTP请求有了基本了解,那么这篇文章或许能帮你省下不少调试和排查的时间。我们不会停留在简单的代码复制粘贴,而是会深入分析B站API的响应结构,探讨如何优雅地处理反爬机制,并设计一个健壮、高效的数据存储流程。整个过程,更像是一次技术探险,我们一起来拆解那些看似简单实则暗藏玄机的细节。
1. 理解B站搜索API:不止是URL那么简单
很多人拿到一个API链接就开始写循环请求,但往往忽略了理解这个接口到底返回了什么,以及它的限制在哪里。B站的搜索API看似直接,但其返回的JSON结构嵌套较深,且不同接口版本可能存在差异。
首先,我们得搞清楚从哪里找到可用的API。B站的前端页面通过XHR请求加载数据,我们可以通过浏览器的开发者工具(F12打开,切换到Network标签,筛选XHR)来捕捉这些请求。当你进行搜索时,会发现一个包含search/type关键词的请求,这就是我们要找的搜索接口。但直接使用抓包到的URL可能不够灵活,我们需要分析其查询参数(Query Parameters)。
一个典型的搜索API URL可能包含如下关键参数:
keyword: 搜索关键词,需要URL编码。page: 页码,从1开始。page_size: 每页返回的视频数量,通常有上限(比如42)。search_type: 搜索类型,如video表示视频。- 其他参数如
platform、highlight等,可能影响返回结果的格式或排序。
重点在于,B站的API并非完全公开的官方文档,其稳定性无法保证。这意味着今天能用的接口,明天可能就变了参数名或者返回结构。因此,我们的代码需要具备一定的容错性和适应性,不能把所有的逻辑都硬编码死。
来看一个实际的响应数据片段解析。假设我们请求的是视频搜索,返回的JSON中,我们最关心的数据通常藏在 data -> result 路径下。每一个视频项(card)本身又是一个包含大量信息的对象。
{
"code": 0,
"data": {
"result": [
{
"type": "video",
"id": 1234567890,
"arcurl": "https://www.bilibili.com/video/BV1xx411x7xx",
"title": "这是一个视频标题",
"duration": "15:30",
"pubdate": 1640995200,
// ... 其他许多字段,如作者、播放量、弹幕数等
}
],
// ... 分页信息等
}
}
注意:
arcurl字段是视频的详情页链接,而id(有时是aid或bvid)是视频的唯一标识。在后续处理中,根据你的需求选择存储哪个。如果需要进行更深入的元数据获取(如视频简介、标签),你可能需要根据这个arcurl或bvid再发起一次请求。
仅仅解析出链接和标题还不够。一个健壮的爬虫应该能处理API可能返回的各种异常情况。例如,code字段不为0表示请求失败或没有结果;result字段可能为空列表;甚至整个JSON的结构都可能因为接口更新而发生变化。因此,在解析数据之前,先进行状态检查是必不可少的步骤。
2. 构建稳健的请求层:与反爬机制共舞
直接使用requests.get()发送请求,大概率会在几分钟内收到429(请求过多)状态码,或者更直接地,IP被暂时限制访问。B站的反爬策略虽然不算最严苛,但对于高频、规律的请求非常敏感。构建稳健的请求层,核心思路是“模拟真人,分散请求”。
首先,请求头(Headers)是敲门砖。 只设置一个随机的User-Agent是远远不够的。现代网站会检查一系列头部信息来判断请求是否来自真实的浏览器。以下是一个建议的、更完整的请求头字典,你可以根据需要调整:
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',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Referer': 'https://search.bilibili.com/', # 模拟从搜索页跳转而来
'Origin': 'https://www.bilibili.com',
'Connection': 'keep-alive',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
}
使用fake_useragent库动态生成User-Agent是个好习惯,但要注意其更新频率。有时,一个看起来“太新”或“太旧”的UA也可能被标记。我的经验是,准备一个包含几十个常见UA的列表,轮流使用,比完全随机生成更稳定。
其次,请求频率控制是生存法则。 time.sleep(random.uniform(1, 3)) 是基础操作,但我们可以做得更智能。一个更好的模式是“随机延迟 + 请求间隔日志 + 异常退避”。
import time
import random
def request_with_backoff(url, headers, max_retries=3):
retries = 0
while retries < max_retries:
try:
# 在每次重试前也增加一点延迟
if retries > 0:
time.sleep(2 ** retries) # 指数退避
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status() # 检查HTTP状态码是否为200
return response
except requests.exceptions.RequestException as e:
print(f"请求失败 ({retries+1}/{max_retries}): {e}")
retries += 1
# 正常请求后的随机延迟
time.sleep(random.uniform(2, 5)) # 间隔设置在2-5秒比较安全
print(f"重试{max_retries}次后仍失败: {url}")
return None
此外,考虑使用IP代理池是应对IP封锁的终极方案,但对于个人或小规模爬取,通过控制频率和模拟浏览器行为通常已足够。如果你发现即使设置了延迟和完整头部仍频繁被阻,可以尝试在请求中加入cookies(通过Selenium等工具先获取一次有效的登录cookie)。
提示:务必尊重网站的
robots.txt规则。虽然技术上有能力抓取,但过度频繁的请求会对对方服务器造成压力,也可能违反服务条款。将你的爬虫用于个人学习、数据分析,并严格控制请求量,是负责任的做法。
3. 数据解析与清洗:从原始JSON到结构化信息
拿到API的JSON响应后,我们面临两个任务:一是准确提取出目标字段,二是对数据进行初步清洗,使其适合存储和分析。原始数据往往包含我们不需要的字段,或者格式不统一(比如时长可能是“15:30”字符串,也可能是秒数)。
假设我们已经通过上一节的稳健请求获取到了response对象,并确认状态码为200。第一步是解析JSON并检查整体结构。
import json
def parse_search_response(response):
try:
data = response.json()
except json.JSONDecodeError:
print("响应不是有效的JSON格式")
return []
# 检查API返回码,B站通常0表示成功
if data.get('code') != 0:
print(f"API返回错误码: {data.get('code')}, 消息: {data.get('message')}")
return []
# 安全地获取结果列表
result_list = data.get('data', {}).get('result', [])
if not isinstance(result_list, list):
print("结果格式异常,不是列表")
return []
parsed_videos = []
for item in result_list:
# 确保item是字典且包含必要字段
if not isinstance(item, dict):
continue
video_info = {
'bvid': item.get('bvid'), # 新的BV号
'aid': item.get('id'), # 旧的AV号
'title': item.get('title', '').strip(),
'arcurl': item.get('arcurl', ''), # 视频页面链接
'duration': item.get('duration', 0), # 可能是秒数
'pubdate': item.get('pubdate', 0), # 时间戳
'owner': item.get('author', ''), # UP主名
'view': item.get('play', 0), # 播放量
'danmaku': item.get('video_review', 0), # 弹幕数
}
# 对duration进行格式化:如果它是“mm:ss”字符串,转换为秒数
if isinstance(video_info['duration'], str) and ':' in video_info['duration']:
parts = video_info['duration'].split(':')
if len(parts) == 2:
try:
video_info['duration_sec'] = int(parts[0]) * 60 + int(parts[1])
except ValueError:
video_info['duration_sec'] = 0
else:
video_info['duration_sec'] = 0
else:
# 如果已经是数字,直接使用
video_info['duration_sec'] = int(video_info['duration'])
parsed_videos.append(video_info)
return parsed_videos
这个解析函数做了几件重要的事:
- 异常处理:捕获JSON解析错误,防止无效响应导致程序崩溃。
- 状态检查:验证API的
code字段,处理请求失败的情况。 - 安全访问:使用
.get()方法获取字典值,避免键不存在时抛出KeyError。 - 数据清洗:对
title去除首尾空格,将字符串格式的duration统一转换为秒数,便于后续的数值分析。 - 字段映射:注意B站API中,播放量字段可能是
play,弹幕数可能是video_review,不同接口可能有不同命名,需要根据实际响应调整。
清洗后的数据已经变成了一个结构清晰的字典列表,每个字典代表一个视频的标准化信息。这为下一步的存储和入库做好了准备。
4. 高效存储策略:CSV、数据库与增量更新
数据抓取下来后,如何存储是一个关键决策。CSV文件简单易用,适合小规模数据和快速验证。但对于大规模、长期的项目,或者需要复杂查询的情况,数据库是更优的选择。我们分别讨论这两种方案。
方案一:使用CSV文件存储
CSV的优势是无需额外环境,用Excel或文本编辑器就能查看。Python的csv模块也非常易用。但要注意几个细节:
- 编码问题:务必使用
utf-8-sig编码,这样在Excel中打开时不会出现乱码。utf-8-sig会在文件开头写入BOM(字节顺序标记),帮助Excel正确识别编码。 - 追加模式:使用
'a'模式打开文件以追加数据,但要注意,如果程序中断后重新运行,可能会写入重复的表头。一个常见的做法是,在首次写入时写表头,后续追加时只写数据。 - 文件锁:在多线程/进程爬虫中,同时写入一个CSV文件会导致数据错乱。需要引入锁机制,或者让每个线程写入独立的文件,最后再合并。
下面是一个改进的CSV写入函数,它解决了表头重复和线程安全(通过检查文件是否存在)的问题:
import csv
import os
from threading import Lock
csv_lock = Lock() # 用于多线程环境
def save_to_csv(video_list, filename='bilibili_videos.csv'):
if not video_list:
return
# 定义CSV的列名(字段顺序)
fieldnames = ['bvid', 'aid', 'title', 'arcurl', 'duration_sec', 'pubdate', 'owner', 'view', 'danmaku']
file_exists = os.path.isfile(filename)
with csv_lock: # 如果是单线程,可以去掉这行
with open(filename, 'a', newline='', encoding='utf-8-sig') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
# 如果文件不存在(首次运行)或为空,写入表头
if not file_exists or csvfile.tell() == 0:
writer.writeheader()
for video in video_list:
writer.writerow(video)
print(f"已写入 {len(video_list)} 条记录到 {filename}")
方案二:使用SQLite数据库存储 当数据量达到几千条以上,或者你需要进行复杂查询(如“查找播放量大于10万且时长小于5分钟的视频”)时,SQLite是一个轻量级且强大的选择。它无需安装独立的数据库服务器,单个文件即可管理。
使用SQLite存储,我们可以更好地结构化数据,并利用SQL的查询能力。首先,我们需要创建一张表来存储视频信息。
import sqlite3
def init_sqlite_db(db_path='bilibili_data.db'):
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
create_table_sql = '''
CREATE TABLE IF NOT EXISTS videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bvid TEXT UNIQUE, -- 设置唯一约束,避免重复插入
aid INTEGER,
title TEXT NOT NULL,
arcurl TEXT NOT NULL,
duration_sec INTEGER,
pubdate INTEGER,
owner TEXT,
view INTEGER,
danmaku INTEGER,
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
'''
cursor.execute(create_table_sql)
# 可以为常用查询字段创建索引,加快搜索速度
cursor.execute('CREATE INDEX IF NOT EXISTS idx_bvid ON videos (bvid);')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_pubdate ON videos (pubdate);')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_view ON videos (view);')
conn.commit()
conn.close()
print(f"数据库初始化完成: {db_path}")
def save_to_sqlite(video_list, db_path='bilibili_data.db'):
if not video_list:
return
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
insert_sql = '''
INSERT OR IGNORE INTO videos
(bvid, aid, title, arcurl, duration_sec, pubdate, owner, view, danmaku)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
'''
data_to_insert = []
for video in video_list:
# 准备插入的数据元组,顺序必须与SQL语句中的列顺序一致
data_tuple = (
video.get('bvid'),
video.get('aid'),
video.get('title'),
video.get('arcurl'),
video.get('duration_sec'),
video.get('pubdate'),
video.get('owner'),
video.get('view'),
video.get('danmaku'),
)
data_to_insert.append(data_tuple)
try:
cursor.executemany(insert_sql, data_to_insert)
conn.commit()
print(f"成功插入/忽略 {cursor.rowcount} 条记录到数据库。")
except sqlite3.Error as e:
print(f"数据库插入错误: {e}")
conn.rollback()
finally:
conn.close()
使用INSERT OR IGNORE语句,配合bvid字段的UNIQUE约束,可以非常方便地实现增量更新。当爬虫再次运行时,只有新的(bvid不重复)视频记录会被插入,已有的记录则被自动忽略,避免了数据重复。created_time字段记录了每条数据首次入库的时间,便于追踪。
选择CSV还是数据库,取决于你的项目阶段和需求。在开发调试初期,CSV的简单直观很有帮助。当项目进入正轨,需要持久化、查询和管理数据时,切换到SQLite几乎是无痛的,并且能带来巨大的长期收益。
5. 实战流程整合与高级技巧
现在,让我们把前面所有模块整合成一个完整、健壮的爬虫工作流。这个工作流将包含配置管理、错误日志记录、进度显示等生产级爬虫应考虑的要素。
首先,我们用一个配置文件或字典来管理爬虫参数,这样调整关键词、页码范围时不需要去代码里四处寻找。
# config.py (或直接写在主脚本中)
CONFIG = {
'keyword': '编程教学', # 搜索关键词
'total_pages': 10, # 计划爬取的总页数
'start_page': 1, # 起始页码
'base_url': 'https://api.bilibili.com/x/web-interface/search/type',
'output_csv': 'output/videos.csv',
'output_db': 'output/bilibili.db',
'request_timeout': 15,
'max_retries': 3,
'delay_range': (2, 5), # 请求延迟范围(秒)
}
接着,我们构建主循环。它负责协调请求、解析、存储和错误处理。
# main.py
import time
import random
import logging
from urllib.parse import quote
# 配置日志,方便调试和监控
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('crawler.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def build_search_url(page, keyword, base_url):
"""构建搜索API的URL"""
encoded_keyword = quote(keyword)
url = f"{base_url}?page={page}&page_size=42&keyword={encoded_keyword}&search_type=video"
# 这里可以添加其他必要的固定参数
return url
def main():
config = CONFIG
logger.info(f"开始爬取关键词: {config['keyword']}")
# 初始化数据库(如果选择数据库存储)
init_sqlite_db(config['output_db'])
for page in range(config['start_page'], config['start_page'] + config['total_pages']):
logger.info(f"正在处理第 {page} 页...")
# 1. 构建URL
url = build_search_url(page, config['keyword'], config['base_url'])
# 2. 发送请求(使用之前定义的稳健请求函数)
response = request_with_backoff(
url,
headers=headers, # 假设headers已定义
max_retries=config['max_retries']
)
if response is None:
logger.error(f"第 {page} 页请求失败,跳过。")
continue
# 3. 解析响应
video_list = parse_search_response(response)
if not video_list:
logger.warning(f"第 {page} 页解析结果为空。")
# 可能已到最后一页,可以考虑提前终止循环
# if page > config['start_page'] + 5: # 连续多页为空
# break
else:
logger.info(f"第 {page} 页解析到 {len(video_list)} 个视频。")
# 4. 存储数据(这里以数据库为例)
save_to_sqlite(video_list, config['output_db'])
# 如果想同时存CSV备份: save_to_csv(video_list, config['output_csv'])
# 5. 请求间隔延迟
delay = random.uniform(*config['delay_range'])
logger.debug(f"等待 {delay:.2f} 秒后继续。")
time.sleep(delay)
logger.info("爬取任务全部完成。")
if __name__ == '__main__':
main()
这个主流程已经相当完整,但它还可以在以下几个方面进行增强,以适应更复杂的需求:
- 断点续爬:将当前成功爬取到的最大页码持久化到文件或数据库。当程序因网络或错误中断后重新启动时,可以从上次的页码继续,而不是从头开始。
- 分布式爬取:如果需要爬取海量数据(数十万视频),单机单线程太慢。可以考虑使用
Scrapy框架,或者用concurrent.futures实现简单的多线程,但务必注意共享资源(如文件、数据库连接)的线程安全,以及总的请求频率不要超标。 - 数据去重与更新:我们通过
bvid唯一约束实现了插入时的去重。但如果想更新已有视频的播放量、弹幕数等动态信息,就需要更复杂的逻辑,比如使用INSERT OR REPLACE,或者先查询再判断是否需要更新。 - 遵守Robots协议:虽然技术上行得通,但最好检查一下目标网站的
robots.txt(例如https://www.bilibili.com/robots.txt),了解哪些路径是允许爬取的,并设置合理的爬取延迟,体现技术人的操守。
最后,分享一个我踩过的坑:有一次爬取时,我设置的延迟是1到3秒,但爬了几百条后还是被限制了。后来发现,问题出在我的IP是数据中心IP(云服务器),这类IP的请求本身就更受监控。解决方案是换用家庭宽带IP,或者将延迟提高到3到8秒,并加入了更随机的“模拟浏览行为”,比如在每10次请求后,随机休眠一个较长的时间(30-60秒),模拟用户停下来浏览页面的行为。这些小技巧往往比单纯提高延迟更有效。
1686



被折叠的 条评论
为什么被折叠?



