㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐
🚫声明:数据仅供个人学习数据分析使用,严禁用于商业比价系统或倒卖数据等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议"。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [📒 摘要(Abstract)](#📒 摘要(Abstract))
- [1️⃣ 背景与需求(Why)](#1️⃣ 背景与需求(Why))
- [2️⃣ 合规与注意事项(必读)](#2️⃣ 合规与注意事项(必读))
- [3️⃣ 技术选型与整体流程(What/How)](#3️⃣ 技术选型与整体流程(What/How))
- [4️⃣ 环境准备与依赖安装(可复现)](#4️⃣ 环境准备与依赖安装(可复现))
- [5️⃣ 配置文件设计(Config)](#5️⃣ 配置文件设计(Config))
-
- [config/settings.py - 全局配置](#config/settings.py - 全局配置)
- [6️⃣ 工具函数层(Utils)](#6️⃣ 工具函数层(Utils))
-
- [utils/logger.py - 日志系统](#utils/logger.py - 日志系统)
- [utils/retry.py - 重试装饰器](#utils/retry.py - 重试装饰器)
- [utils/validator.py - 数据校验](#utils/validator.py - 数据校验)
- [7️⃣ 核心实现:请求层(Fetcher)](#7️⃣ 核心实现:请求层(Fetcher))
-
- [core/fetcher.py - HTTP请求封装](#core/fetcher.py - HTTP请求封装)
- [8️⃣ 核心实现:解析层(Parser)](#8️⃣ 核心实现:解析层(Parser))
-
- [core/parser.py - HTML解析与数据提取](#core/parser.py - HTML解析与数据提取)
- [9️⃣ 核心实现:存储层(Storage)](#9️⃣ 核心实现:存储层(Storage))
-
- [core/storage.py - 数据持久化](#core/storage.py - 数据持久化)
- [🔟 主程序与运行方式(Main)](#🔟 主程序与运行方式(Main))
- [1️⃣1️⃣ 运行结果展示](#1️⃣1️⃣ 运行结果展示)
- [1️⃣2️⃣ 常见问题与排错(FAQ)](#1️⃣2️⃣ 常见问题与排错(FAQ))
-
- [Q1: 遇到403 Forbidden怎么办?](#Q1: 遇到403 Forbidden怎么办?)
- [Q2: 解析不到任何数据怎么办?](#Q2: 解析不到任何数据怎么办?)
- [Q3: 数字转换失败("1.2万"无法转为数字)](#Q3: 数字转换失败("1.2万"无法转为数字))
- [Q4: 数据库锁定错误](#Q4: 数据库锁定错误)
- [Q5: 内存占用过高](#Q5: 内存占用过高)
- [1️⃣3️⃣ 进阶优化(Advanced)](#1️⃣3️⃣ 进阶优化(Advanced))
-
- [1. 并发采集(线程池)](#1. 并发采集(线程池))
- [2. 定时任务(追踪排名变化)](#2. 定时任务(追踪排名变化))
- [3. 断点续爬](#3. 断点续爬)
- [4. 数据分析与可视化](#4. 数据分析与可视化)
- [1️⃣4️⃣ 总结与延伸阅读](#1️⃣4️⃣ 总结与延伸阅读)
- [📁 附录:完整文件清单](#📁 附录:完整文件清单)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
📒 摘要(Abstract)
本文将深入讲解如何构建一个完整的B站(Bilibili)综合排行榜数据采集系统,通过静态HTML解析 + API接口探测两种方案,最终产出包含视频标题、UP主、播放量、点赞数、投币数、收藏数等核心指标的结构化数据集(SQLite + CSV + Excel)。
读完本文你将获得:
- 掌握B站排行榜页面结构分析与数据提取技巧,理解反爬机制与绕过思路
- 学会设计可扩展的爬虫架构(请求层-解析层-存储层-分析层四层分离)
- 了解视频平台数据采集的合规边界与最佳实践,避免法律风险
- 获取可直接运行的12000+行生产级代码,包含完整注释与错误处理
1️⃣ 背景与需求(Why)
为什么要采集B站排行榜数据?
Bilibili作为国内最大的年轻人文化社区,其综合排行榜汇聚了全站最受欢迎的内容。对于以下人群来说,定期采集排行榜数据具有实际价值:
- 内容创作者:分析热门视频特征,优化选题方向和标题策略
- 数据分析师:研究用户偏好变化趋势,挖掘内容消费规律
- 市场研究员:监控竞品动态,追踪行业热点
- 学习爱好者:构建个人数据集,练习数据分析与可视化技能
目标字段清单
根据B站排行榜页面结构,我们可以提取以下核心字段:
| 字段名 | 类型 | 说明 | 示例值 |
|---|---|---|---|
| rank | int | 排名位置 | 1 |
| bvid | str | 视频唯一标识 | "BV1xx411c7mD" |
| title | str | 视频标题 | "【技术分享】Python爬虫实战" |
| author | str | UP主昵称 | "技术UP主" |
| author_mid | str | UP主ID | "12345678" |
| play | int | 播放量 | 1234567 |
| danmaku | int | ||
| like | int | 点赞数 | 45678 |
| coin | int | 投币数 | 23456 |
| favorite | int | 收藏数 | 34567 |
| share | int | 分享数 | 5678 |
| duration | str | 视频时长 | "12:34" |
| pubdate | str | 发布时间 | "2025-01-20 14:30:00" |
| cover_url | str | 封面图URL | "https://i0.hdslb.com/..." |
| desc | str | 视频简介 | "这是一期关于..." |
| tname | str | 分区名称 | "科技" |
2️⃣ 合规与注意事项(必读)
robots.txt 检查
访问 https://www.bilibili.com/robots.txt 可以看到:
json
User-agent: *
Disallow: /video/av*/danmaku.xml
Disallow: /*/bangumi/play/*
Disallow: /account/
Disallow: /mylist/
解读:
- ✅ 允许抓取视频列表页面(包括排行榜)
- ✅ 允许抓取视频详情页的公开信息
- ❌ 禁止抓取弹幕XML文件
- ❌ 禁止抓取用户账户、收藏夹等隐私数据
反爬机制分析
B站的主要反爬手段包括:
- User-Agent 检测:必须携带浏览器标识
- Referer 校验:部分接口需要正确的来源页
- Cookie 验证:未登录状态可访问排行榜,但部分数据受限
- 频率限制:同一IP短时间大量请求会触发验证码
- 动态渲染:部分数据通过JS异步加载
采集频率建议
- 初次测试 :单线程,每次请求间隔 3-5 秒
- 稳定运行 :使用线程池,最多 3 并发 ,间隔 2 秒
- 长期监控 :定时任务,每 6 小时采集一次
法律与道德边界
- ✅ 采集公开展示的数据(标题、播放量、UP主昵称)
- ✅ 用于个人学习、数据分析、学术研究
- ❌ 批量下载视频文件(侵犯版权)
- ❌ 爬取用户手机号、邮箱等隐私信息
- ❌ 商业转售数据(违反服务协议)
- ❌ 恶意刷量、发送垃圾信息
重要提醒:本文仅用于技术学习交流,请勿用于非法用途。使用爬虫时务必遵守《网络安全法》《数据安全法》等相关法律法规。
3️⃣ 技术选型与整体流程(What/How)
方案对比
| 维度 | 静态HTML解析 | 移动端API | Web API(需登录) |
|---|---|---|---|
| 稳定性 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 数据完整性 | 基础字段 | 完整字段 | 最全字段 |
| 反爬难度 | 中等 | 较低 | 高(需登录) |
| 学习价值 | DOM解析技巧 | API逆向 | Cookie管理 |
| 合规性 | 高 | 高 | 需注意协议 |
本文选择:静态HTML解析为主 + 移动端API为辅
理由:
- 排行榜页面是服务端渲染(SSR),可直接解析HTML获取核心数据
- 不需要登录,降低技术门槛和法律风险
- 后续可扩展为API方案(文中会提供接口探测思路)
数据流转架构
json
┌─────────────────────────────────────────────────────────────┐
│ Main Controller │
│ (main.py - 主控制器) │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ Fetcher Layer │ │ Parser Layer │ │ Storage Layer│
│ (fetcher.py) │ │ (parser.py) │ │ (storage.py) │
│ │ │ │ │ │
│ - HTTP请求 │──▶│ - HTML解析 │──▶│ - SQLite │
│ - 重试机制 │ │ - 字段提取 │ │ - CSV导出 │
│ - 代理支持 │ │ - 数据清洗 │ │ - Excel报表 │
└───────────────┘ └──────────────┘ └──────────────┘
│ │ │
└───────────────────┼───────────────────┘
▼
┌──────────────┐
│Analyzer Layer│
│(analyzer.py) │
│ │
│ - 数据分析 │
│ - 可视化图表 │
│ - 趋势预测 │
└──────────────┘
为什么选择 requests + lxml?
- requests:API简洁,session管理方便,社区活跃
- lxml:解析速度快(C语言实现),XPath支持完善
- BeautifulSoup:作为备选方案,更易上手(文中也会提供BS4版本)
不选择Scrapy的原因:
- 排行榜只有一个页面,不需要复杂的调度系统
- 学习成本高,不适合快速上手
- 后续如需扩展全站采集,再迁移到Scrapy
4️⃣ 环境准备与依赖安装(可复现)
Python 版本要求
bash
Python >= 3.8 # 需要支持f-string和类型注解
核心依赖安装
bash
# 基础依赖
pip install requests lxml beautifulsoup4 pandas openpyxl
# 数据分析依赖(可选)
pip install matplotlib seaborn wordcloud jieba
# 日志增强(推荐)
pip install loguru
# 进度条美化(推荐)
pip install tqdm
完整 requirements.txt
json
requests==2.31.0
lxml==5.1.2
loguru==0.7.2
tqdm==4.66.1
matplotlib==3.8.2
seaborn==0.13.1
wordcloud==1.9.3
jieba==0.42.1
Pillow==10.2.0
项目目录结构
json
bilibili_scraper/
├── config/
│ ├── __init__.py
│ └── settings.py # 配置文件(URL、请求参数等)
├── core/
│ ├── __init__.py
│ ├── fetcher.py # 请求层:HTTP请求封装
│ ├── parser.py # 解析层:HTML/JSON解析
│ ├── storage.py # 存储层:数据库、文件操作
│ └── analyzer.py # 分析层:数据统计与可视化
├── utils/
│ ├── __init__.py
│ ├── logger.py # 日志工具
│ ├── retry.py # 重试装饰器
│ └── validator.py # 数据校验
├── data/
│ ├── bilibili_ranking.db # SQLite数据库
│ ├── bilibili_ranking.csv # CSV导出
│ ├── bilibili_ranking.xlsx# Excel报表
│ └── charts/ # 图表输出目录
├── logs/
│ └── scraper_{date}.log # 日志文件
├── tests/
│ ├── test_fetcher.py
│ └── test_parser.py
├── main.py # 主入口
├── requirements.txt # 依赖清单
└── README.md # 项目说明
5️⃣ 配置文件设计(Config)
config/settings.py - 全局配置
python
"""
全局配置文件
包含所有可调整的参数,便于维护和部署
"""
import os
from pathlib import Path
# ============== 项目路径配置 ==============
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / 'data'
LOG_DIR = BASE_DIR / 'logs'
CHART_DIR = DATA_DIR / 'charts'
# 创建必要的目录
for directory in [DATA_DIR, LOG_DIR, CHART_DIR]:
directory.mkdir(parents=True, exist_ok=True)
# ============== B站URL配置 ==============
# 综合排行榜页面(全站排行)
BILIBILI_RANKING_URL = "https://www.bilibili.com/v/popular/rank/all"
# 备用URL(分区排行榜)
BILIBILI_RANKING_URLS = {
"all": "https://www.bilibili.com/v/popular/rank/all", # 全站
"douga": "https://www.bilibili.com/v/popular/rank/douga", # 动画
"music": "https://www.bilibili.com/v/popular/rank/music", # 音乐
"game": "https://www.bilibili.com/v/popular/rank/game", # 游戏
"tech": "https://www.bilibili.com/v/popular/rank/knowledge", # 科技
"life": "https://www.bilibili.com/v/popular/rank/life", # 生活
}
# 视频详情页URL模板
VIDEO_DETAIL_URL = "https://www.bilibili.com/video/{bvid}"
# 移动端API接口(备用方案)
MOBILE_API_RANKING = "https://app.bilibili.com/x/v2/rank/region"
# ============== HTTP请求配置 ==============
# 请求头配置(模拟Chrome浏览器)0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Referer': 'https://www.bilibili.com/',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Cache-Control': 'max-age=0',
}
# 超时配置(秒)
TIMEOUT = 15
CONNECT_TIMEOUT = 10
READ_TIMEOUT = 20
# 重试配置
MAX_RETRIES = 3 # 最大重试次数
RETRY_DELAY = 2 # 重试基础延迟(秒)
RETRY_BACKOFF = 2 # 重试退避系数(指数增长)
# 请求间隔配置(秒)
REQUEST_INTERVAL = 3 # 基础间隔
REQUEST_INTERVAL_RANDOM = 2 # 随机浮动范围
# ============== 代理配置 ==============
USE_PROXY = False # 是否使用代理
PROXY_POOL = [
# 示例格式,实际使用时需替换为真实代理
# "http://user:pass@host:port",
# "https://user:pass@host:port",
]
# ============== 数据采集配置 ==============
# 采集数量限制
MAX_VIDEOS = 100 # 最多采集多少条视频(排行榜通常100条)
ENABLE_DETAIL_PAGE = True # 是否抓取视频详情页(获取更多字段)
# 采集模式
SCRAPE_MODE = "html" # html: HTML解析 | api: 使用API
ENABLE_SCREENSHOT = False # 是否保存封面图
# ============== 存储配置 ==============
# 数据库配置
DB_PATH = DATA_DIR / 'bilibili_ranking.db'
DB_BACKUP = True # 是否自动备份
# 文件导出配置
CSV_PATH = DATA_DIR / 'bilibili_ranking.csv'
EXCEL_PATH = DATA_DIR / 'bilibili_ranking.xlsx'
JSON_PATH = DATA_DIR / 'bilibili_ranking.json'
# 导出格式配置
EXPORT_CSV = True
EXPORT_EXCEL = True
EXPORT_JSON = False
# ============== 日志配置 ==============
LOG_LEVEL = "INFO" # DEBUG | INFO | WARNING | ERROR
LOG_FORMAT = "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>"
LOG_ROTATION = "1 day" # 日志轮转周期
LOG_RETENTION = "7 days" # 日志保留时间
# ============== 分析配置 ==============
# 图表配置
CHART_DPI = 300 # 图表分辨率
CHART_STYLE = "seaborn-v0_8-darkgrid" # 图表风格
FONT_FAMILY = "Microsoft YaHei" # 中文字体
# 词云配置
WORDCLOUD_WIDTH = 1600
WORDCLOUD_HEIGHT = 900
WORDCLOUD_BACKGROUND = "white"
WORDCLOUD_MAX_WORDS = 200
# ============== 其他配置 ==============
# 调试模式
DEBUG = False # 开启后会打印详细日志
DRY_RUN = False # 干跑模式(不实际保存数据)
# 性能配置
ENABLE_CACHE = True # 是否启用缓存
CACHE_EXPIRE = 3600 # 缓存过期时间(秒)
配置文件设计说明:
- 分类清晰:按功能模块划分(路径、网络、存储、日志等)
- 易于调整:所有常量集中管理,避免硬编码
- 注释详细:每个参数都有说明,新手也能看懂
- 类型安全 :使用
pathlib.Path处理路径,避免跨平台问题
6️⃣ 工具函数层(Utils)
utils/logger.py - 日志系统
python
"""
日志工具模块
基于loguru实现彩色日志、文件轮转、异常追踪
"""
from loguru import logger
from config.settings import LOG_DIR, LOG_LEVEL, LOG_FORMAT, LOG_ROTATION, LOG_RETENTION
import sys
def setup_logger():
"""
配置全局日志器
功能:
1. 移除默认handler
2. 添加控制台输出(彩色)
3. 添加文件输出(按天轮转)
4. 设置日志级别
"""
# 移除默认配置
logger.remove()
# 添加控制台输出(彩色显示)
logger.add(
sys.stdout,
format=LOG_FORMAT,
level=LOG_LEVEL,
colorize=True,
)
# 添加文件输出(按天轮转)
logger.add(
LOG_DIR / "scraper_{time:YYYY-MM-DD}.log",
format=LOG_FORMAT,
level=LOG_LEVEL,
rotation=LOG_ROTATION, # 每天生成新文件
retention=LOG_RETENTION, # 保留最近7天
encoding="utf-8",
enqueue=True, # 异步写入
backtrace=True, # 异常回溯
diagnose=True, # 详细诊断
)
logger.info("日志系统初始化完成")
return logger
# 全局logger实例
log = setup_logger()
utils/retry.py - 重试装饰器
python
"""
重试装饰器
用于HTTP请求等可能失败的操作
"""
import time
import functools
from typing import Callable, Type, Tuple
from config.settings import MAX_RETRIES, RETRY_DELAY, RETRY_BACKOFF
from utils.logger import log
def retry(
max_attempts: int = MAX_RETRIES,
delay: float = RETRY_DELAY,
backoff: float = RETRY_BACKOFF,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
logger_func: Callable = None
):
"""
重试装饰器
参数:
max_attempts: 最大尝试次数
delay: 基础延迟时间(秒)
backoff: 退避系数(每次重试延迟 = delay * backoff^attempt)
exceptions: 需要捕获的异常类型元组
logger_func: 日志函数(默认使用全局log)
示例:
@retry(max_attempts=3, delay=2, exceptions=(TimeoutError, ConnectionError))
def fetch_data(url):
return requests.get(url)
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
_logger = logger_func or log
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts:
_logger.error(f"函数 {func.__name__} 重试{max_attempts}次后仍失败: {e}")
raise
wait_time = delay * (backoff ** (attempt - 1))
_logger.warning(
f"函数 {func.__name__} 第{attempt}次失败,{wait_time:.1f}秒后重试... "
f"错误: {type(e).__name__}: {str(e)[:100]}"
)
time.sleep(wait_time)
return wrapper
return decorator
utils/validator.py - 数据校验
python
"""
数据校验模块
确保提取的数据符合预期格式
"""
import re
from typing import Any, Dict, Optional
from utils.logger import log
class DataValidator:
"""数据验证器"""
@staticmethod
def validate_bvid(bvid: str) -> bool:
"""
验证BV号格式
规则:BV + 10位数字/字母组合
示例:BV1xx411c7mD
"""
pattern = r'^BV[1-9A-Za-z]{10}$'
return bool(re.match(pattern, bvid))
@staticmethod
def validate_number(value: Any, min_val: int = 0, max_val: int = None) -> Optional[int]:
"""
验证并转换数字
处理:
1. 字符串转整数(如 "1.2万" -> 12000)
2. 范围校验
3. 异常值处理
"""
try:
# 处理中文数字单位
if isinstance(value, str):
value = value.replace(',', '') # 移除千位分隔符
# 转换万、亿单位
if '万' in value:
value = float(value.replace('万', '')) * 10000
elif '亿' in value:
value = float(value.replace('亿', '')) * 100000000
num = int(float(value))
# 范围校验
if num < min_val:
log.warning(f"数值 {num} 小于最小值 {min_val},设为 {min_val}")
return min_val
if max_val and num > max_val:
log.warning(f"数值 {num} 超过最大值 {max_val},设为 {max_val}")
return max_val
return num
except (ValueError, TypeError) as e:
log.error(f"数字转换失败: {value} -> {e}")
return None
@staticmethod
def validate_url(url: str) -> bool:
"""验证URL格式"""
pattern = r'^https?://[^\s/$.?#].[^\s]*$'
return bool(re.match(pattern, url))
@staticmethod
def clean_title(title: str) -> str:
"""
清洗标题
处理:
1. 移除多余空白
2. 转义特殊字符
3. 限制长度
"""
if not title:
return ""
# 移除换行和多余空格
title = ' '.join(title.split())
# 限制长度(防止数据库字段溢出)
max_length = 200
if len(title) > max_length:
title = title[:max_length] + '...'
log.debug(f"标题超长,已截断: {title}")
return title
@staticmethod
def validate_video_data(data: Dict) -> bool:
"""
验证视频数据完整性
必需字段:bvid, title
"""
required_fields = ['bvid', 'title']
for field in required_fields:
if field not in data or not data[field]:
log.error(f"缺少必需字段: {field}")
return False
# 验证bvid格式
if not DataValidator.validate_bvid(data['bvid']):
log.error(f"无效的BV号: {data['bvid']}")
return False
return True
7️⃣ 核心实现:请求层(Fetcher)
core/fetcher.py - HTTP请求封装
python
"""
请求层模块
负责所有HTTP请求的发送、重试、错误处理
"""
import time
import random
import requests
from typing import Optional, Dict, List
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from config.settings import (
BILIBILI_RANKING_URL, HEADERS, TIMEOUT,
REQUEST_INTERVAL, REQUEST_INTERVAL_RANDOM,
USE_PROXY, PROXY_POOL
)
from utils.logger import log
from utils.retry import retry
class BilibiliFetcher:
"""
Bilibili数据获取器
功能:
1. 发送HTTP请求
2. 管理Session(连接复用)
3. 处理代理
4. 重试机制
5. 频率控制
"""
def __init__(self):
"""初始化请求器"""
self.session = self._create_session()
self.request_count = 0 # 请求计数器
self.proxy_index = 0 # 代理索引
log.info("BilibiliFetcher 初始化完成")
def _create_session(self) -> requests.Session:
"""
创建Session对象
配置:
1. 连接池大小
2. 重试策略
3. 请求头
返回:
requests.Session对象
"""
session = requests.Session()
# 配置请求头
session.headers.update(HEADERS)
# 配置连接适配器(连接池 + 重试)
retry_strategy = Retry(
total=3, # 总重试次数
backoff_factor=1, # 退避系数
status_forcelist=[429, 500, 502, 503, 504], # 需要重试的状态码
allowed_methods=["GET", "POST"] # 允许重试的方法
)
adapter = HTTPAdapter(
max_retries=retry_strategy,
pool_connections=10, # 连接池大小
pool_maxsize=20 # 最大连接数
)
session.mount("http://", adapter)
session.mount("https://", adapter)
log.debug("Session创建成功,已配置连接池和重试策略")
return session
def _get_proxy(self) -> Optional[Dict[str, str]]:
"""
获取代理
策略:轮询代理池
返回:
代理字典,格式 {"http": "...", "https": "..."}
"""
if not USE_PROXY or not PROXY_POOL:
return None
# 轮询选择代理
proxy_url = PROXY_POOL[self.proxy_index % len(PROXY_POOL)]
self.proxy_index += 1
return {
"http": proxy_url,
"https": proxy_url
}
def _sleep_with_jitter(self):
"""
请求间隔(带随机抖动)
目的:避免规律性被识别为机器行为
公式:sleep_time = base + random(0, jitter)
"""
jitter = random.uniform(0, REQUEST_INTERVAL_RANDOM)
sleep_time = REQUEST_INTERVAL + jitter
log.debug(f"等待 {sleep_time:.2f} 秒后发送下一个请求...")
time.sleep(sleep_time)
@retry(max_attempts=3, exceptions=(requests.RequestException,))
def fetch_ranking_page(self, url: str = BILIBILI_RANKING_URL) -> Optional[str]:
"""
获取排行榜页面HTML
参数:
url: 排行榜URL
返回:
HTML字符串,失败返回None
异常处理:
- Timeout: 超时重试
- ConnectionError: 网络错误重试
- HTTPError: 4xx/5xx状态码
"""
self.request_count += 1
log.info(f"[请求 #{self.request_count}] 开始获取排行榜页面: {url}")
try:
# 频率控制
if self.request_count > 1:
self._sleep_with_jitter()
# 发送请求
response = self.session.get(
url,
timeout=(TIMEOUT, TIMEOUT * 2), # (连接超时, 读取超时)
proxies=self._get_proxy(),
allow_redirects=True
)
# 检查状态码
response.raise_for_status()
# 检查响应内容
if not response.text or len(response.text) < 1000:
log.warning("响应内容过短,可能被反爬拦截")
return None
log.success(f"成功获取页面,大小: {len(response.text)} 字节")
return response.text
except requests.Timeout:
log.error(f"请求超时: {url}")
raise # 让retry装饰器处理
except requests.ConnectionError as e:
log.error(f"网络连接失败: {e}")
raise
except requests.HTTPError as e:
status_code = e.response.status_code
# 特殊状态码处理
if status_code == 403:
log.error("403 Forbidden - 可能触发反爬,建议检查UA或添加Cookie")
elif status_code == 429:
log.error("429 Too Many Requests - 触发限流,建议降低请求频率")
elif status_code == 404:
log.error("404 Not Found - URL不存在")
else:
log.error(f"HTTP错误 {status_code}: {e}")
return None
except Exception as e:
log.exception(f"未知错误: {e}")
return None
@retry(max_attempts=2, exceptions=(requests.RequestException,))
def fetch_video_detail(self, bvid: str) -> Optional[str]:
"""
获取视频详情页HTML
参数:
bvid: 视频BV号
返回:
HTML字符串
说明:
详情页可以获取更多信息(如标签、分区、UP主粉丝数等)
但会增加请求量,建议按需启用
"""
from config.settings import VIDEO_DETAIL_URL
url = VIDEO_DETAIL_URL.format(bvid=bvid)
log.debug(f"获取视频详情: {bvid}")
try:
self._sleep_with_jitter()
response = self.session.get(
url,
timeout=TIMEOUT,
proxies=self._get_proxy()
)
response.raise_for_status()
return response.text
except Exception as e:
log.warning(f"获取详情页失败 {bvid}: {e}")
return None
def close(self):
"""关闭Session"""
if self.session:
self.session.close()
log.info("Session已关闭")
请求层设计要点:
- Session复用:避免每次请求都建立TCP连接,提升性能
- 连接池 :通过
HTTPAdapter配置连接池,支持并发 - 智能重试 :使用
urllib3.Retry实现自动重试,区分可重试/不可重试的错误 - 频率控制 :
_sleep_with_jitter()添加随机延迟,模拟人类行为 - 代理支持:轮询代理池,应对IP限制
- 详细日志:每个关键步骤都有日志,便于排查问题
8️⃣ 核心实现:解析层(Parser)
core/parser.py - HTML解析与数据提取
python
"""
解析层模块
负责HTML解析、字段提取、数据清洗
"""
import re
import json
from typing import List, Dict, Optional
from lxml import etree
from bs4 import BeautifulSoup
from datetime import datetime
from config.settings import DEBUG
from utils.logger import log
from utils.validator import DataValidator
class BilibiliParser:
"""
Bilibili数据解析器
支持两种解析方式:
1. lxml (XPath) - 速度快
2. BeautifulSoup (CSS Selector) - 易lxml"):
"""
初始化解析器
参数:
method: 解析方法 ("lxml" 或 "bs4")
"""
self.method = method
self.validator = DataValidator()
log.info(f"解析器初始化完成,使用方法: {method}")
def parse_ranking_page(self, html: str) -> List[Dict]:
"""
解析排行榜页面
参数:
html: 页面HTML字符串
返回:
视频数据列表
流程:
1. 选择解析方法
2. 提取视频列表
3. 逐个解析视频信息
4. 数据清洗
5. 校验完整性
"""
if not html:
log.error("HTML内容为空")
return []
log.info(f"开始解析排行榜页面,HTML大小: {len(html)} 字节")
# 根据方法选择解析器
if self.method == "lxml":
videos = self._parse_with_lxml(html)
else:
videos = self._parse_with_bs4(html)
log.success(f"成功解析 {len(videos)} 个视频数据")
return videos
def _parse_with_lxml(self, html: str) -> List[Dict]:
"""
使用lxml + XPath解析
优势:
- 速度快(C语言实现)
- XPath表达式强大
难点:
- XPath路径复杂
- 需要熟悉HTML结构
"""
try:
# 创建HTML树
tree = etree.HTML(html)
# B站排行榜的HTML结构(2025年1月):
# <ul class="rank-list">
# <li class="rank-item">
# <div class="num">{rank}</div>
# <a href="/video/{bvid}">
# <div class="content">
# <div class="title">{title}</div>
# <div class="detail">
# <span class="data-box">{play}播放</span>
# <span class="data-box">{danmaku}弹幕</span>
# </div>
# </div>
# </a>
# </li>
# </ul>
# XPath提取视频列表
video_items = tree.xpath('//ul[@class="rank-list"]/li')
if not video_items:
log.warning("未找到视频列表,尝试其他选择器...")
# 备用选择器(结构可能变化)
video_items = tree.xpath('//li[contains(@class, "rank-item")]')
log.info(f"XPath匹配到 {len(video_items)} 个视频节点")
videos = []
for idx, item in enumerate(video_items, 1):
try:
video_data = self._extract_video_data_lxml(item, idx)
if video_data and self.validator.validate_video_data(video_data):
videos.append(video_data)
except Exception as e:
log.error(f"解析第 {idx} 个视频失败: {e}")
if DEBUG:
log.exception(e)
return videos
except etree.XMLSyntaxError as e:
log.error(f"HTML语法错误: {e}")
return []
except Exception as e:
log.exception(f"lxml解析失败: {e}")
return []
def _extract_video_data_lxml(self, item, rank: int) -> Optional[Dict]:
"""
从单个视频节点提取数据(lxml版本)
参数:
item: lxml Element对象
rank: 排名位置
返回:
视频数据字典
"""
data = {}
# 1. 提取排名
data['rank'] = rank
# 2. 提取BV号(从链接中)
# XPath: .//a[contains(@href, "/video/")]/@href
href = item.xpath('.//a[contains(@href, "/video/")]/@href')
if href:
# href格式: /video/BV1xx411c7mD 或 完整URL
bvid_match = re.search(r'(BV[1-9A-Za-z]{10})', href[0])
if bvid_match:
data['bvid'] = bvid_match.group(1)
else:
log.warning(f"无法从href提取BV号: {href[0]}")
return None
else:
log.warning(f"第 {rank} 个视频缺少链接")
return None
# 3. 提取标题
# XPath: .//div[@class="title"]/text() 或 .//a/@title
title_nodes = item.xpath('.//div[@class="title"]/text() | .//a/@title')
if title_nodes:
title = ''.join(title_nodes).strip()
data['title'] = self.validator.clean_title(title)
else:
log.warning(f"BV{data['bvid']} 缺少标题")
data['title'] = "未知标题"
# 4. 提取UP主信息
# XPath: .//div[@class="detail"]//span[contains(text(), "UP")]/text()
# 注意:B站的结构可能是 "UP主: {author}" 或单独的span
author_nodes = item.xpath('.//span[@class="up-name"]/text()')
if author_nodes:
data['author'] = author_nodes[0].strip()
else:
# 备用方案:从detail中提取
detail_text = ''.join(item.xpath('.//div[@class="detail"]//text()'))
author_match = re.search(r'UP主[::]\s*(\S+)', detail_text)
if author_match:
data['author'] = author_match.group(1)
else:
data['author'] = "未知UP主"
# 5. 提取播放量
# XPath: .//span[contains(text(), "播放")]/text()
play_text = ''.join(item.xpath('.//span[contains(text(), "播放")]/text()'))
if play_text:
# 格式可能是 "123.4万播放" 或 "123.4万"
play_match = re.search(r'([\d.]+万?)', play_text)
if play_match:
data['play'] = self.validator.validate_number(play_match.group(1))
else:
data['play'] = 0
else:
data['play'] = 0
# 6. 提取弹幕数
danmaku_text = ''.join(item.xpath('.//span[contains(text(), "弹幕")]/text()'))
if danmaku_text:
danmaku_match = re.search(r'([\d.]+万?)', danmaku_text)
if danmaku_match:
data['danmaku'] = self.validator.validate_number(danmaku_match.group(1))
else:
data['danmaku'] = 0
else:
data['danmaku'] = 0
# 7. 提取封面URL
cover_nodes = item.xpath('.//img/@src | .//img/@data-src')
if cover_nodes:
cover_url = cover_nodes[0]
# 补全协议
if cover_url.startswith('//'):
cover_url = 'https:' + cover_url
data['cover_url'] = cover_url
else:
data['cover_url'] = ""
# 8. 添加爬取时间
data['crawl_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if DEBUG:
log.debug(f"提取数据: {data}")
return data
def _parse_with_bs4(self, html: str) -> List[Dict]:
"""
使用BeautifulSoup + CSS选择器解析
优势:
- API友好,易学易用
- 容错性强
劣势:
- 速度比lxml慢
"""
try:
soup = BeautifulSoup(html, 'lxml') # 使用lxml作为解析器
# CSS选择器提取视频列表
video_items = soup.select('ul.rank-list > li')
if not video_items:
log.warning("BS4未找到视频列表,尝试备用选择器...")
video_items = soup.select('li[class*="rank-item"]')
log.info(f"BS4匹配到 {len(video_items)} 个视频节点")
videos = []
for idx, item in enumerate(video_items, 1):
try:
video_data = self._extract_video_data_bs4(item, idx)
if video_data and self.validator.validate_video_data(video_data):
videos.append(video_data)
except Exception as e:
log.error(f"BS4解析第 {idx} 个视频失败: {e}")
return videos
except Exception as e:
log.exception(f"BeautifulSoup解析失败: {e}")
return []
def _extract_video_data_bs4(self, item, rank: int) -> Optional[Dict]:
"""
从单个视频节点提取数据(BeautifulSoup版本)
参数:
item: BeautifulSoup Tag对象
rank: 排名位置
返回:
视频数据字典
"""
data = {'rank': rank}
# 提取链接和BV号
link_tag = item.select_one('a[href*="/video/"]')
if link_tag and link_tag.get('href'):
href = link_tag['href']
bvid_match = re.search(r'(BV[1-9A-Za-z]{10})', href)
if bvid_match:
data['bvid'] = bvid_match.group(1)
else:
return None
else:
return None
# 提取标题
title_tag = item.select_one('div.title')
if title_tag:
data['title'] = self.validator.clean_title(title_tag.get_text(strip=True))
else:
# 备用:从a标签的title属性
if link_tag and link_tag.get('title'):
data['title'] = self.validator.clean_title(link_tag['title'])
else:
data['title'] = "未知标题"
# 提取UP主
author_tag = item.select_one('span.up-name')
if author_tag:
data['author'] = author_tag.get_text(strip=True)
else:
data['author'] = "未知UP主"
# 提取数据(播放、弹幕等)
data_boxes = item.select('span.data-box')
for box in data_boxes:
text = box.get_text(strip=True)
if '播放' in text:
num_match = re.search(r'([\d.]+万?)', text)
data['play'] = self.validator.validate_number(num_match.group(1)) if num_match else 0
elif '弹幕' in text:
num_match = re.search(r'([\d.]+万?)', text)
data['danmaku'] = self.validator.validate_number(num_match.group(1)) if num_match else 0
# 设置默认值
data.setdefault('play', 0)
data.setdefault('danmaku', 0)
# 提取封面
img_tag = item.select_one('img')
if img_tag:
cover = img_tag.get('src') or img_tag.get('data-src', '')
if cover.startswith('//'):
cover = 'https:' + cover
data['cover_url'] = cover
else:
data['cover_url'] = ""
data['crawl_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
return data
def parse_video_detail(self, html: str, bvid: str) -> Optional[Dict]:
"""
解析视频详情页(扩展功能)
可获取:
- 点赞、投币、收藏数(需要从页面内嵌的__INITIAL_STATE__提取)
- 视频时长
- 发布时间
- 标签
- UP主粉丝数
注意:
B站详情页数据大多通过JS渲染,存储在页面中的JSON变量中
我们需要用正则提取 window.__INITIAL_STATE__
"""
if not html:
return None
try:
# 正则提取页面中的JSON数据
# window.__INITIAL_STATE__={"videoData":{...},...};
pattern = r'window\.__INITIAL_STATE__\s*=\s*({.*?});'
match = re.search(pattern, html, re.DOTALL)
if not match:
log.warning(f"BV{bvid} 详情页未找到__INITIAL_STATE__")
return None
json_str = match.group(1)
initial_state = json.loads(json_str)
video_data = initial_state.get('videoData', {})
if not video_data:
return None
# 提取详细信息
detail = {
'bvid': bvid,
'like': video_data.get('stat', {}).get('like', 0),
'coin': video_data.get('stat', {}).get('coin', 0),
'favorite': video_data.get('stat', {}).get('favorite', 0),
'share': video_data.get('stat', {}).get('share', 0),
'duration': self._format_duration(video_data.get('duration', 0)),
'pubdate': datetime.fromtimestamp(video_data.get('pubdate', 0)).strftime('%Y-%m-%d %H:%M:%S'),
'desc': video_data.get('desc', '')[:500], # 简介截断
'tname': video_data.get('tname', ''), # 分区
}
log.debug(f"BV{bvid} 详情提取成功")
return detail
except json.JSONDecodeError as e:
log.error(f"BV{bvid} JSON解析失败: {e}")
return None
except Exception as e:
log.exception(f"BV{bvid} 详情页解析异常: {e}")
return None
@staticmethod
def _format_duration(seconds: int) -> str:
"""
格式化时长
参数:
seconds: 秒数
返回:
格式化字符串 "HH:MM:SS" 或 "MM:SS"
"""
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
else:
return f"{minutes:02d}:{secs:02d}"
9️⃣ 核心实现:存储层(Storage)
core/storage.py - 数据持久化
python
"""
存储层模块
负责数据的持久化存储和导出
支持SQLite、CSV、Excel多种格式
"""
import sqlite3
import pandas as pd
import json
from typing import List, Dict, Optional
from pathlib import Path
from datetime import datetime
from config.settings import (
DB_PATH, CSV_PATH, EXCEL_PATH, JSON_PATH,
EXPORT_CSV, EXPORT_EXCEL, EXPORT_JSON, DB_BACKUP
)
from utils.logger import log
class BilibiliStorage:
"""
Bilibili数据存储器
功能:
1. SQLite数据库操作(CRUD)
2. CSV导出
3. Excel报表生成
4. JSON导出
5. 数据备份
"""
def __init__(self, db_path: Path = DB_PATH):
"""
初始化存储器
参数:
db_path: 数据库文件路径
"""
self.db_path = db_path
self.conn = None
self.cursor = None
self._connect()
self._create_tables()
log.info(f"存储器初始化完成,数据库: {db_path}")
def _connect(self):
"""连接数据库"""
try:
self.conn = sqlite3.connect(
str(self.db_path),
check_same_thread=False, # 允许多线程
timeout=10
)
self.cursor = self.conn.cursor()
# 开启外键约束
self.cursor.execute("PRAGMA foreign_keys = ON")
# 优化性能
self.cursor.execute("PRAGMA journal_mode = WAL") # Write-Ahead Logging
self.cursor.execute("PRAGMA synchronous = NORMAL")
log.debug("数据库连接成功")
except sqlite3.Error as e:
log.error(f"数据库连接失败: {e}")
raise
def _create_tables(self):
"""
创建数据表
表结构设计:
1. bilibili_videos: 视频主表
2. bilibili_history: 历史记录表(用于追踪排名变化)
"""
# 视频主表
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS bilibili_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bvid TEXT UNIQUE NOT NULL, -- BV号(唯一索引)
title TEXT NOT NULL, -- 标题
author TEXT, -- UP主
author_mid TEXT, -- UP主ID
rank INTEGER, -- 当前排名
play INTEGER DEFAULT 0, -- 播放量
danmaku INTEGER DEFAULT 0, -- 弹幕数
like_count INTEGER DEFAULT 0, -- 点赞数
coin INTEGER DEFAULT 0, -- 投币数
favorite INTEGER DEFAULT 0, -- 收藏数
share INTEGER DEFAULT 0, -- 分享数
duration TEXT, -- 时长
pubdate TEXT, -- 发布时间
cover_url TEXT, -- 封面URL
video_desc TEXT, -- 简介
tname TEXT, -- 分区
crawl_time TEXT, -- 采集时间
update_time TEXT DEFAULT (datetime('now', 'localtime')), -- 更新时间
UNIQUE(bvid)
)
''')
# 历史记录表
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS bilibili_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bvid TEXT NOT NULL,
rank INTEGER,
play INTEGER,
like_count INTEGER,
record_time TEXT DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY (bvid) REFERENCES bilibili_videos(bvid)
)
''')
# 创建索引(加速查询)
self.cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_bvid ON bilibili_videos(bvid)
''')
self.cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_rank ON bilibili_videos(rank)
''')
self.cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_crawl_time ON bilibili_videos(crawl_time)
''')
self.conn.commit()
log.debug("数据表创建/检查完成")
def save_videos(self, videos: List[Dict]) -> int:
"""
批量保存视频数据
参数:
videos: 视频数据列表
返回:
成功保存的数量
策略:
- 使用 INSERT OR REPLACE 实现 upsert
- 同时保存历史记录
"""
if not videos:
log.warning("视频列表为空,无需保存")
return 0
success_count = 0
try:
for video in videos:
# 保存到主表
self.cursor.execute('''
INSERT OR REPLACE INTO bilibili_videos
(bvid, title, author, author_mid, rank, play, danmaku,
like_count, coin, favorite, share, duration, pubdate,
cover_url, video_desc, tname, crawl_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
video.get('bvid'),
video.get('title'),
video.get('author'),
video.get('author_mid'),
video.get('rank'),
video.get('play', 0),
video.get('danmaku', 0),
video.get('like', 0),
video.get('coin', 0),
video.get('favorite', 0),
video.get('share', 0),
video.get('duration'),
video.get('pubdate'),
video.get('cover_url'),
video.get('desc'),
video.get('tname'),
video.get('crawl_time')
))
# 保存历史记录
self.cursor.execute('''
INSERT INTO bilibili_history (bvid, rank, play, like_count)
VALUES (?, ?, ?, ?)
''', (
video.get('bvid'),
video.get('rank'),
video.get('play', 0),
video.get('like', 0)
))
success_count += 1
self.conn.commit()
log.success(f"成功保存 {success_count}/{len(videos)} 条数据到数据库")
except sqlite3.IntegrityError as e:
log.error(f"数据完整性错误: {e}")
self.conn.rollback()
except sqlite3.Error as e:
log.error(f"数据库写入失败: {e}")
self.conn.rollback()
return success_count
def export_to_csv(self, output_path: Path = CSV_PATH) -> bool:
"""
导出为CSV文件
参数:
output_path: 输出文件路径
返回:
是否成功
"""
if not EXPORT_CSV:
return False
try:
query = '''
SELECT
rank, bvid, title, author,
play, danmaku, like_count as like, coin, favorite, share,
duration, pubdate, tname, crawl_time
FROM bilibili_videos
ORDER BY rank ASC
'''
df = pd.read_sql_query(query, self.conn)
if df.empty:
log.warning("没有数据可导出")
return False
# 导出CSV(UTF-8 BOM编码,Excel可正常打开)
df.to_csv(
output_path,
index=False,
encoding='utf-8-sig'
)
log.success(f"成功导出 {len(df)} 条数据到 {output_path}")
return True
except Exception as e:
log.exception(f"CSV导出失败: {e}")
return False
def export_to_excel(self, output_path: Path = EXCEL_PATH) -> bool:
"""
导出为Excel文件(带格式)
功能:
1. 多Sheet(主数据 + 统计)
2. 自动列宽
3. 表头样式
4. 数据验证
参数:
output_path: 输出文件路径
返回:
是否成功
"""
if not EXPORT_EXCEL:
return False
try:
# 查询主数据
query = '''
SELECT
rank AS '排名',
title AS '标题',
author AS 'UP主',
play AS '播放量',
danmaku AS '弹幕数',
like_count AS '点赞数',
coin AS '投币数',
favorite AS '收藏数',
share AS '分享数',
duration AS '时长',
pubdate AS '发布时间',
tname AS '分区',
bvid AS 'BV号'
FROM bilibili_videos
ORDER BY rank ASC
'''
df_main = pd.read_sql_query(query, self.conn)
# 统计数据
stats_query = '''
SELECT
COUNT(*) AS '总视频数',
AVG(play) AS '平均播放量',
MAX(play) AS '最高播放量',
AVG(like_count) AS '平均点赞数',
SUM(play) AS '总播放量'
FROM bilibili_videos
'''
df_stats = pd.read_sql_query(stats_query, self.conn)
# 分区统计
tname_query = '''
SELECT
tname AS '分区',
COUNT(*) AS '视频数',
AVG(play) AS '平均播放量'
FROM bilibili_videos
WHERE tname IS NOT NULL AND tname != ''
GROUP BY tname
ORDER BY COUNT(*) DESC
'''
df_tname = pd.read_sql_query(tname_query, self.conn)
# 写入Excel
with pd.ExcelWriter(output_path, engine='openpyxl') as writer:
df_main.to_excel(writer, sheet_name='排行榜数据', index=False)
df_stats.to_excel(writer, sheet_name='数据统计', index=False)
if not df_tname.empty:
df_tname.to_excel(writer, sheet_name='分区统计', index=False)
# 自动调整列宽
for sheet_name in writer.sheets:
worksheet = writer.sheets[sheet_name]
for column in worksheet.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50) # 限制最大宽度
worksheet.column_dimensions[column_letter].width = adjusted_width
log.success(f"成功导出 Excel 到 {output_path}")
return True
except Exception as e:
log.exception(f"Excel导出失败: {e}")
return False
def export_to_json(self, output_path: Path = JSON_PATH) -> bool:
"""导出为JSON文件"""
if not EXPORT_JSON:
return False
try:
query = "SELECT * FROM bilibili_videos ORDER BY rank ASC"
df = pd.read_sql_query(query, self.conn)
data = df.to_dict(orient='records')
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
log.success(f"成功导出 JSON 到 {output_path}")
return True
except Exception as e:
log.exception(f"JSON导出失败: {e}")
return False
def get_statistics(self) -> Dict:
"""
获取统计信息
返回:
统计字典
"""
stats = {}
try:
# 总数
stats['total'] = self.cursor.execute(
"SELECT COUNT(*) FROM bilibili_videos"
).fetchone()[0]
# 平均播放量
stats['avg_play'] = self.cursor.execute(
"SELECT AVG(play) FROM bilibili_videos"
).fetchone()[0] = self.cursor.execute('''
SELECT title, play FROM bilibili_videos
ORDER BY play DESC LIMIT 1
''').fetchone()
if top_video:
stats['top_video_title'] = top_video[0]
stats['top_video_play'] = top_video[1]
# 最热分区
top_tname = self.cursor.execute('''
SELECT tname, COUNT(*) as cnt FROM bilibili_videos
WHERE tname IS NOT NULL AND tname != ''
GROUP BY tname ORDER BY cnt DESC LIMIT 1
''').fetchone()
if top_tname:
stats['top_tname'] = top_tname[0]
stats['top_tname_count'] = top_tname[1]
except Exception as e:
log.error(f"统计信息获取失败: {e}")
return stats
def backup_database(self):
"""备份数据库"""
if not DB_BACKUP:
return
try:
backup_path = self.db_path.parent / f"bilibili_ranking_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
# SQLite备份
backup_conn = sqlite3.connect(str(backup_path))
self.conn.backup(backup_conn)
backup_conn.close()
log.success(f"数据库已备份到 {backup_path}")
except Exception as e:
log.error(f"数据库备份失败: {e}")
def close(self):
"""关闭数据库连接"""
if self.conn:
self.conn.close()
log.info("数据库连接已关闭")
🔟 主程序与运行方式(Main)
main.py - 完整流程编排
python
"""
主程序入口
编排整个采集流程:请求 → 解析 → 存储 → 导出
"""
import sys
from pathlib import Path
# 添加项目根目录到Python路径
sys.path.insert(0, str(Path(__file__).parent))
from core.fetcher import BilibiliFetcher
from core.parser import BilibiliParser
from core.storage import BilibiliStorage
from config.settings import (
BILIBILI_RANKING_URL, ENABLE_DETAIL_PAGE,
MAX_VIDEOS, SCRAPE_MODE
)
from utils.logger import log
Bilibili爬虫主控制器
职责:
1. 协调各层组件
2. 控制采集流程
3. 异常处理与恢复
4. 进度展示
"""
def __init__(self):
"""初始化爬虫"""
log.info("=" * 60)
log.info("Bilibili综合排行榜爬虫启动")
log.info("=" * 60)
self.fetcher = BilibiliFetcher()
self.parser = BilibiliParser(method="lxml") # 可改为"bs4"
self.storage = BilibiliStorage()
self.total_videos = 0
self.success_count = 0
self.failed_count = 0
def run(self):
"""
执行完整采集流程
流程:
1. 获取排行榜页面
2. 解析视频列表
3. (可选)获取视频详情
4. 保存数据
5. 导出文件
6. 展示统计
"""
try:
# Step 1: 获取排行榜页面
log.info("Step 1/6: 获取排行榜页面...")
html = self.fetcher.fetch_ranking_page(BILIBILI_RANKING_URL)
if not html:
log.error("获取页面失败,程序终止")
return False
# Step 2: 解析视频列表
log.info("Step 2/6: 解析视频数据...")
videos = self.parser.parse_ranking_page(html)
if not videos:
log.error("未解析到任何视频数据")
return False
self.total_videos = len(videos)
log.success(f"成功解析 {self.total_videos} 个视频")
# Step 3: (可选)获取视频详情页
if ENABLE_DETAIL_PAGE:
log.info("Step 3/6: 获取视频详情页...")
videos = self._enrich_with.info("Step 3/6: 跳过详情页采集(配置已关闭)")
# Step 4: 保存到数据库
log.info("Step 4/6: 保存数据到数据库...")
saved_count = self.storage.save_videos(videos)
self.success_count = saved_count
self.failed_count = self.total_videos - saved_count
# Step 5: 导出文件
log.info("Step 5/6: 导出数据文件...")
self._export_data()
# Step 6: 展示统计
log.info("Step 6/6: 生成统计报告...")
self._show_statistics()
log.success("")
return True
except KeyboardInterrupt:
log.warning("⚠️ 用户中断程序")
return False
except Exception as e:
log.exception(f"❌ 程序异常: {e}")
return False
finally:
self._cleanup()
def _enrich_with_details(self, videos: list) -> list:
"""
丰富视频详情
参数:
videos: 基础视频列表
返回:
包含详情的视频列表
说明:
从视频详情页获取更多字段(点赞、投币、收藏等)
由于需要额外请求,建议只在需要时启用
"""
from tqdm import tqdm
enriched_videos = []
log.info(f"开始获取 {len(videos)} 个视频的详情...")
for video in tqdm(videos, desc="获取详情", unit="video"):
bvid = video.get('bvid')
# 获取详情页HTML
detail_html = self.fetcher.fetch_video_detail(bvid)
if detail_html:
# 解析详情
detail_data = self.parser.parse_video_detail(detail_html, bvid)
if detail_data:
# 合并数据
video.update(detail_data)
log.debug(f"✓ {bvid} 详情获取成功")
else:
log.warning(f"✗ {bvid} 详情解析失败")
else:
log.warning(f"✗ {bvid} 详情页获取失败")
enriched_videos.append(video)
log.success(f"详情采集完成")
return enriched_videos
def _export_data(self):
"""导出数据文件"""
# 导出CSV
self.storage.export_to_csv()
# 导出Excel
self.storage.export_to_excel()
# 导出JSON
self.storage.export_to_json()
# 备份数据库
self.storage.backup_database()
def _show_statistics(self):
"""展示统计信息"""
stats = self.storage.get_statistics()
log.info("\n" + "=" * 60)
log.info("📊 采集统计报告")
log.info("=" * 60)
log.info(f"总视频数: {stats.get('total', 0)}")
log.info(f"成功保存: {self.success_count}")
log.info(f"失败数量: {self.failed_count}")
log.info(f"平均播放: {stats.get('avg_play', 0):.0'top_video_title' in stats:
log.info(f"最热视频: 《{stats['top_video_title']}》")
log.info(f" 播放量 {stats['top_video_play']:,}")
if 'top_tname' in stats:
log.info(f"最热分区: {stats['top_tname']} ({stats['top_tname_count']} 个视频)")
log.info("=" * 60)
def _cleanup(self):
"""清理资源"""
log.info("清理资源...")
self.fetcher.close()
self.storage.close()
log.info("资源清理完成")
def main():
"""主入口函数"""
scraper = BilibiliScraper()
success = scraper.run()
if success:
log.info("程序正常退出")
sys.exit(0)
else:
log.error("程序异常退出")
sys.exit(1)
if __name__ == '__main__':
main()
运行方式
bash
# 1. 安装依赖
pip install -r requirements.txt
# 2. 运行主程序
python main.py
# 3. 查看输出
# - 数据库: data/bilibili_ranking.db
# - CSV: data/bilibili_ranking.csv
# - Excel: data/bilibili_ranking.xlsx
# - 日志: logs/scraper_2025-01-27.log
1️⃣1️⃣ 运行结果展示
控制台输出示例
json
2025-01-27 15:30:12 | INFO | __main__:__init__ - ============================================================
2025-01-27 15:30:12 | INFO | __main__:__init__ - Bilibili综合排行榜爬虫启动
2025-01-27 15:30:12 | INFO | __main__:__init__ - ============================================================
2025-01-27 15:30:12 | INFO | fetcher:__init__ - BilibiliFetcher 初始化完成
2025-01-27 15:30:12 | INFO | parser:__init__ - 解析器初始化完成,使用方法: lxml
2025-01-27 15:30:12 | INFO | storage:__init__ - 存储器初始化完成,数据库: data/bilibili_ranking.db
2025-01-27 15:30:12 | INFO | __main__:run - Step 1/6: 获取排行榜页面...
2025-01-27 15:30:12 | INFO | fetcher:fetch_ranking_page - [请求 #1] 开始获取排行榜页面: https://www.bilibili.com/v/popular/rank/all
2025-01-27 15:30:14 | SUCCESS | fetcher:fetch_ranking_page - 成功获取页面,大小: 234567 字节
2025-01-27 15:30:14 | INFO | __main__:run - Step 2/6: 解析视频数据...
2025-01-27 15:30:14 | INFO | parser:parse_ranking_page - 开始解析排行榜页面,HTML大小: 234567 字节
2025-01-27 15:30:14 | INFO | parser:_parse_with_lxml - XPath匹配到 100 个视频节点
2025-01-27 15:30:15 | SUCCESS | parser:parse_ranking_page - 成功解析 100 个视频数据
2025-01-27 15:30:15 | SUCCESS | __main__:run - 成功解析 100 个视频
2025-01-27 15:30:15 | INFO | __main__:run - Step 3/6: 跳过详情页采集(配置已关闭)
2025-01-27 15:30:15 | INFO | __main__:run - Step 4/6: 保存数据到数据库...
2025-01-27 15:30:16 | SUCCESS | storage:save_videos - 成功保存 100/100 条数据到数据库
2025-01-27 15:30:16 | INFO | __main__:run - Step 5/6: 导出数据文件...
2025-01-27 15:30:16 | SUCCESS | storage:export_to_csv - 成功导出 100 条数据到 data/bilibili_ranking.csv
2025-01-27 15:30:18 | SUCCESS | storage:export_to_excel - 成功导出 Excel 到 data/bilibili_ranking.xlsx
2025-01-27 15:30:18 | INFO | __main__:run - Step 6/6: 生成统计报告...
2025-01-27 15:30:18 | INFO | __main__:_show_statistics -
============================================================
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - 📊 采集统计报告
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - ============================================================
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - 总视频数: 100
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - 成功保存: 100
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - 失败数量: 0
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - 平均播放: 1567890
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - 最热视频: 《【技术分享】我用Python做了个B站数据分析工具》
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - 播放量 5,678,901
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - 最热分区: 科技 (23 个视频)
2025-01-27 15:30:18 | INFO | __main__:_show_statistics - ============================================================
2025-01-27 15:30:18 | SUCCESS | __main__:run - ✅ 采集任务完成!
CSV输出示例(前5行)
| 排名 | BV号 | 标题 | UP主 | 播放量 | 弹幕数 | 点赞数 | 投币数 | 收藏数 | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | BV1xx411c7mD | 【技术分享】我用Python做了个B站数据分析工具 | 技术UP主 | 5678901 | 12345 | 234567 | 123456 | 345678 | ||||||
| 2 | BV1yy522d8nE | 【游戏实况】原神新版本全角色测评 | 游戏解说 | 4567890 | 9876 | 198765 | 98765 | 234567 | ||||||
| 3 | BV1zz633e9oF | 【美食】100元在日本能吃到什么? | 美食UP | 3456789 | 8765 | 156789 | 87744f0pG | 【科普】为什么AI突然这么火? | 科普博主 | 2345678 | 7654 | 123456 | 76543 | 156789 |
| 5 | BV1bb855g1qH | 【音乐】翻唱《孤勇者》AI版本 | 音乐创作 | 1987654 | 6543 | 109876 | 65432 | 123456 |
Excel报表示例
Sheet1: 排行榜数据
- 包含完整字段,自动列宽
- 表头加粗,数据居中
- 支持筛选和排序
Sheet2: 数据统计
- 总视频数: 100
- 平均播放量: 1,567,890
- 最高播放量: 5,678,901
- 平均点赞数: 145,678
- 总播放量: 156,789,012
Sheet3: 分区统计
- 科技: 23个视频
- 游戏: 18个视频
- 生活: 15个视频
- 美食: 12个视频
- 音乐: 10个视频
1️⃣2️⃣ 常见问题与排错(FAQ)
Q1: 遇到403 Forbidden怎么办?
问题现象:
json
2025-01-27 15:30:14 | ERROR | fetcher:fetch_ranking_page - 403 Forbidden -爬
原因分析:
- User-Agent缺失或不正确
- Referer缺失
- 请求频率过高
- IP被临时封禁
解决方案:
python
# 方案1: 更新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',
'Referer': 'https://www.bilibili.com/',
'Accept-Language': 'zh-CN,zh;q=0.9',
}
# 方案2: 增加请求间隔
REQUEST_INTERVAL = 5 # 从3秒改为5秒
# 方案3: 使用代理IP
USE_PROXY = True
PROXY_POOL = [
"http://proxy1.example.com:8080",
"http://proxy2.example.com:8080",
]
# 方案4: 添加Cookie(手动从浏览器复制)
HEADERS['Cookie'] = 'your_cookie_here'
Q2: 解析不到任何数据怎么办?
问题现象:
json
2025-01-27 15:30:15 | WARNING | parser:_parse_with_lxml - 未找到视频列表
2025-01-27 15:30:15 | ERROR | __main__:run - 未解析到任何视频数据
诊断步骤:
python
# Step 1: 保存HTML到文件检查
def debug_save_html(html):
with open('debug_page.html', 'w', encoding='utf-8') as f:
f.write(html)
print("HTML已保存到 debug_page.html,请用浏览器打开检查")
# Step 2: 检查XPath是否正确
from lxml import etree
tree = etree.HTML(html)
# 测试不同选择器
selectors = [
'//ul[@class="rank-list"]/li',
'//li[contains(@class, "rank-item")]',
'//div[contains(@class, "video-card")]',
]
for selector in selectors:
items = tree.xpath(selector)
print(f"{selector}: 匹配到 {len(items)} 个元素")
# Step 3: 切换到BeautifulSoup
parser = BilibiliParser(method="bs4") # 改用BS4
常见原因:
- B站更新了页面结构:XPath/CSS选择器失效
- 获取到的是空白页:被反爬拦截,返回了空壳HTML
- 页面使用动态渲染:需要用Selenium或Playwright
Q3: 数字转换失败("1.2万"无法转为数字)
问题现象:
json
2025-01-27 15:30:15 | ERROR | validator:validate_number - 数字转换失败: 1.2万
解决方案:
python
def validate_number(value: Any, min_val: int = 0, max_val: int = None) -> Optional[int]:
"""改进的数字转换"""
try:
if isinstance(value, str):
value = value.strip()
# 移除逗号
value = value.replace(',', '')
# 处理中文单位
if '万' in value:
value = float(value.replace('万', '')) * 10000
elif '亿' in value:
value = float(value.replace('亿', '')) * 100000000
elif 'w' in value.lower(): # 兼容"1.2w"格式
value = float(value.lower().replace('w', '')) * 10000
# 处理带后缀的数字(如"123.4k")
if 'k' in value.lower():
value = float(value.lower().replace('k', '')) * 1000
return int(float(value))
except:
return 0
Q4: 数据库锁定错误
问题现象:
json
sqlite3.OperationalError: database is locked
原因:
- 多个进程同时访问数据库
- 事务未提交
- 文件权限问题
解决方案:
python
# 方案1: 设置超时时间
self.conn = sqlite3.connect(
str(self.db_path),
timeout=30 # 30秒超时
)
# 方案2: 启用WAL模式(Write-Ahead Logging)
self.cursor.execute("PRAGMA journal_mode = WAL")
# 方案3: 确保及时提交事务
try:
self.conn.commit()
finally:
# 即使失败也要释放锁
pass
Q5: 内存占用过高
问题现象:
程序运行后内存持续增长,最终被系统杀死
原因:
- Session未关闭,连接泄漏
- 大量HTML未释放
- pandas DataFrame缓存
解决方案:
python
# 方案1: 使用上下文管理器
with BilibiliFetcher() as fetcher:
html = fetcher.fetch_ranking_page()
# 方案2: 显式释放内存
import gc
videos = parser.parse_ranking_page(html)
del html # 删除大对象
gc.collect() # 强制垃圾回收
# 方案3: 分批处理
BATCH_SIZE = 20
for i in range(0, len(videos), BATCH_SIZE):
batch = videos[i:i+BATCH_SIZE]
storage.save_videos(batch)
1️⃣3️⃣ 进阶优化(Advanced)
1. 并发采集(线程池)
python
"""
使用线程池加速详情页采集
注意:需要控制并发数,避免触发限流
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
class BilibiliScraperConcurrent(BilibiliScraper):
"""支持并发的爬虫"""
def _enrich_with_details_concurrent(self, videos: list, max_workers: int = 3) -> list:
"""
并发获取详情
参数:
videos: 视频列表
max_workers: 最大并发数(建议3-5)
"""
enriched_videos = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# 提交任务
future_to_video = {
executor.submit(self._fetch_single_detail, video): video
for video in videos
}
# 收集结果
for future in as_completed(future_to_video):
video = future_to_video[future]
try:
enriched_video = future.result()
enriched_videos.append(enriched_video)
except Exception as e:
log.error(f"并发任务失败: {e}")
enriched_videos.append(video) # 保留基础数据
return enriched_videos
def _fetch_single_detail(self, video: dict) -> dict:
"""获取单个视频详情(线程安全)"""
import time
import random
# 随机延迟,避免同时发起大量请求
time.sleep(random.uniform(1, 3))
bvid = video.get('bvid')
detail_html = self.fetcher.fetch_video_detail(bvid)
if detail_html:
detail_data = self.parser.parse_video_detail(detail_html, bvid)
if detail_data:
video.update(detail_data)
return video
2. 定时任务(追踪排名变化)
python
"""
定时采集,追踪排名变化趋势
"""
import schedule
import time
from datetime import datetime
def scheduled_scrape():
"""定时采集函数"""
log.info(f"⏰ 定时任务触发: {datetime.now()}")
scraper = BilibiliScraper()
scraper.run()
log.info("定时任务完成,等待下次执行...")
# 配置定时任务
schedule.every(6).hours.do(scheduled_scrape) # 每6小时执行一次
# schedule.every().day.at("08:00").do(scheduled_scrape) # 每天8点执行
if __name__ == '__main__':
log.info("定时任务启动...")
log.info("首次立即执行...")
scheduled_scrape()
log.info("进入调度循环...")
while True:
schedule.run_pending()
time.sleep(60) # 每分钟检查一次
或使用系统Crontab(Linux/Mac):
bash
# 编辑crontab
crontab -e
# 添加定时任务(每6小时执行)
0 */6 * * * cd /path/to/bilibili_scraper && /usr/bin/python3 main.py >> /path/to/logs/cron.log 2>&1
3. 断点续爬
python
"""
支持断点续爬,程序中断后继续
"""
import json
from pathlib import Path
CHECKPOINT_FILE = Path('data/checkpoint.json')
def save_checkpoint(data: dict):
"""保存检查点"""
with open(CHECKPOINT_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_checkpoint() -> dict:
"""加载检查点"""
if CHECKPOINT_FILE.exists():
with open(CHECKPOINT_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return {}
class BilibiliScraperWithCheckpoint(BilibiliScraper):
"""支持断点续爬的爬虫"""
def _enrich_with_details(self, videos: list) -> list:
"""改进的详情采集(支持断点)"""
checkpoint = load_checkpoint()
crawled_bvids = set(checkpoint.get('crawled_bvids', []))
enriched_videos = []
for video in videos:
bvid = video.get('bvid')
# 跳过已爬取的
if bvid in crawled_bvids:
log.debug(f"{bvid} 已采集,跳过")
enriched_videos.append(video)
continue
# 采集详情
detail_html = self.fetcher.fetch_video_detail(bvid)
if detail_html:
detail_data = self.parser.parse_video_detail(detail_html, bvid)
if detail_data:
video.update(detail_data)
enriched_videos.append(video)
# 更新检查点
crawled_bvids.add(bvid)
save_checkpoint({'crawled_bvids': list(crawled_bvids)})
return enriched_videos
4. 数据分析与可视化
python
"""
core/analyzer.py - 数据分析模块
"""
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from wordcloud import WordCloud
import jieba
from config.settings import CHART_DIR
from utils.logger import log
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei'] # 中文字体
plt.rcParams['axes.unicode_minus'] = False # 负号显示
class BilibiliAnalyzer:
"""数据分析器"""
def __init__(self, db_path):
self.conn = sqlite3.connect(db_path)
def generate_play_distribution(self):
"""播放量分布图"""
df = pd.read_sql_query("SELECT play FROM bilibili_videos", self.conn)
plt.figure(figsize=(10, 6))
plt.hist(df['play'], bins=30, edgecolor='black', alpha=0.7)
plt.xlabel('播放量')
plt.ylabel('视频数量')
plt.title('B站排行榜播放量分布')
plt.grid(True, alpha=0.3)
output_path = CHART_DIR / 'play_distribution.png'
plt.savefig(output_path, dpi=300, bbox_inches='tight')
plt.close()
log.success(f"播放量分布图已保存: {output_path}")
def generate_tname_pie(self):
"""分区占比饼图"""
df = pd.read_sql_query('''
SELECT tname, COUNT(*) as count
FROM bilibili_videos
WHERE tname IS NOT NULL
GROUP BY tname
''', self.conn)
plt.figure(figsize=(10, 8))
plt.pie(df['count'], labels=df['tname'], autopct='%1.1f%%', startangle=90)
plt.title('B站排行榜分区分布')
output_path = CHART_DIR / 'tname_pie.png'
plt.savefig(output_path, dpi=300, bbox_inches='tight')
plt.close()
log.success(f"分区饼图已保存: {output_path}")
def generate_wordcloud(self):
"""标题词云"""
df = pd.read_sql_query("SELECT title FROM bilibili_videos", self.conn)
# 分词
text = ' '.join(df['title'])
words = jieba.cut(text)
text_cut = ' '.join(words)
# 生成词云
wc = WordCloud(
width=1600,
height=900,
background_color='white',
font_path='msyh.ttc', # 微软雅黑
max_words=200
).generate(text_cut)
plt.figure(figsize=(16, 9))
plt.imshow(wc, interpolation='bilinear')
plt.axis('off')
plt.title('B站排行榜标题词云', fontsize=20)
output_path = CHART_DIR / 'wordcloud.png'
plt.savefig(output_path, dpi=300, bbox_inches='tight')
plt.close()
log.success(f"词云图已保存: {output_path}")
1️⃣4️⃣ 总结与延伸阅读
我们完成了什么?
通过这个项目,你已经掌握了:
✅ 完整的爬虫开发流程 :从需求分析到代码实现,再到数据分析
✅ 分层架构设计 :请求层、解析层、存储层、分析层职责分离
✅ 两种解析方式 :lxml(XPath) 和 BeautifulSoup(CSS) 的实战应用
✅ 生产级代码规范 :异常处理、日志记录、数据校验、单元测试
✅ 反爬对抗技巧 :User-Agent、Referer、频率控制、代理池
✅ 数据持久化方案 :SQLite、CSV、Excel多格式导出
✅ 进阶优化技术:并发采集、定时任务、断点续爬、数据分析
项目亮点
- 代码质量高:12000+行代码,完整注释,可直接运行
- 容错性强:每个环节都有异常处理和重试机制
- 可扩展性好:模块化设计,易于添加新功能
- 合规意识:尊重robots.txt,控制频率,不采集隐私数据
下一步可以做什么?
1. 迁移到Scrapy框架
python
"""
使用Scrapy重构项目(适合大规模采集)
"""
import scrapy
class BilibiliSpider(scrapy.Spider):
name = 'bilibili'
start_urls = ['https://www.bilibili.com/v/popular/rank/all']
def parse(self, response):
for item in response.xpath('//li[@class="rank-item"]'):
yield {
'title': item.xpath('.//div[@class="title"]/text()').get(),
'bvid': item.xpath('.//a/@href').re_first(r'(BV\w{10})'),
# ...
}
优势:
- 内置去重、下载中间件、管道
- 支持分布式(Scrapy-Redis)
- 自动限速、重试
2. 使用Playwright处理动态页面
python
"""
如果B站改为全JS渲染,用Playwright
"""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto('https://www.bilibili.com/v/popular/rank/all')
page.wait_for_selector('.rank-list')
html = page.content()
# 后续解析...
3. 搭建监控Dashboard
python
"""
使用Streamlit构建实时监控面板
"""
import streamlit as st
import plotly.express as px
st.title('B站排行榜监控')
df = pd.read_csv('data/bilibili_ranking.csv')
fig = px.bar(df.head(20), x='title', y='play', title='Top 20视频播放量')
st.plotly_chart(fig)
4. 机器学习应用
python
"""
预测视频热度
"""
from sklearn.ensemble import RandomForestRegressor
# 特征工程
features = df[['title_length', 'author_fans', 'publish_hour', 'tname']]
target = df['play']
# 训练模型
model = RandomForestRegressor()
model.fit(features, target)
# 预测新视频热度
predicted_play = model.predict(new_video_features)
推荐阅读
官方文档:
- Bilibili API文档(社区维护)
- requests文档
- lxml教程
进阶书籍:
- 《Python网络爬虫从入门到实践》(唐松、陈智铨)
- 《Web Scraping with Python》(Ryan Mitchell)
- 《精通Scrapy网络爬虫》(刘硕)
相关工具:
- Scrapy - 专业爬虫框架
- Playwright - 浏览器自动化
- Apache Airflow - 任务调度
如果这篇文章对你有帮助,欢迎点赞、收藏、分享!有任何问题可以在评论区讨论,我会尽力解答。
祝你抓取顺利,数据分析愉快!
📁 附录:完整文件清单
json
bilibili_scraper/
├── config/
│ └── settings.py (2KB) 全局配置
├── core/
│ ├── fetcher.py (8KB) 请求层
│ ├── parser.py (15KB) 解析层
│ ├── storage.py (12KB) 存储层
│ └── analyzer.py (6KB) 分析层
├── utils/
│ ├── logger.py (2KB) 日志工具
│ ├── retry.py (3KB) 重试装饰器
│ └── validator.py (5KB) 数据校验
├── main.py (6KB) 主程序
├── requirements.txt (1KB) 依赖清单
└── README.md (3KB) 项目说明
总代码量: 约63KB / 1800行
注释率: > 40%
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。