Python爬虫实战:从NIP vs WBG虎扑评分学数据采集与可视化
最近 LPL 赛场上 NIP 2:1 战胜 WBG,赛后虎扑用户会给选手逐人打分,这些散落的评分数据其实是一份不错的赛事分析素材。本文不聊比赛判罚,也不预测季后赛走势,而是把“NIP 2-1 WBG”当作一个数据分析案例,演示怎么把虎扑评分页面里的公开数据采集下来,清洗成结构化表格,再画成能直接放进复盘文档的图表。
这套流程不绑定特定比赛。换一场比赛、换一个队,只要页面结构相似,脚本微调后就能跑。整个过程不需要 GPU,不需要大模型,一台普通电脑加 Python 环境就够了。重点解决三个问题:数据从哪里拿、字段怎么解析、多场比赛怎么批量处理。
如果你平时做赛事内容、写复盘报告,或者想把社区评分做成自动化报表,这篇文章可以直接收藏。下面从项目能力、环境准备、采集思路、数据清洗、可视化和批量任务几个部分展开。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | 赛事评分数据采集与分析工程示例 |
| 案例背景 | NIP 2:1 WBG 赛后虎扑评分 |
| 数据源 | 虎扑比赛评分页面(公开页面,需自行确认可访问性) |
| 核心功能 | 页面请求、评分解析、字段清洗、CSV/SQLite 存储、可视化 |
| 技术栈 | Python、requests、BeautifulSoup、Pandas、Matplotlib |
| 硬件要求 | 无 GPU 需求,普通 CPU 即可 |
| 显存占用 | 无 |
| 启动方式 | 命令行运行 Python 脚本 |
| 是否支持 API | 可扩展为本地 HTTP 服务 |
| 是否支持批量任务 | 支持,按比赛 ID 循环采集 |
| 适合场景 | 赛果复盘、选手表现分析、舆情观察、自动化报表 |
从材料来看,这个工程示例的重点不是做一个成品软件,而是把“网页评分数据怎么变成分析图表”的完整链路跑通。核心工作量集中在页面结构确认、选择器调整和异常处理上。
2. 适用场景与使用边界
这个数据采集思路适合三类人:
- 赛事内容作者:赛后需要快速整理选手评分,避免手动复制几十条数据。
- 数据分析学习者:用真实网页数据练手,覆盖 requests、BeautifulSoup、Pandas 的完整流程。
- 体育社区产品运营:观察评分人数、平均分变化,辅助内容运营决策。
不适合的场景:
- 不适合作为商业数据源直接出售。虎扑评分数据属于平台公开内容,但商用前必须确认平台条款和授权范围。
- 不适合采集登录后才可见的数据。本文只讨论公开页面的基础采集。
- 不适合高频、大规模抓取。虎扑有反爬策略,单机高频请求容易被限制,也不应该用代理池绕限制。
使用边界必须说清楚:
- 采集前先看目标页面是否允许爬取,确认 robots 协议和用户协议。
- 控制请求频率,建议单次请求间隔 3 秒以上。
- 只采集比赛结果、选手 ID、评分数值这类公开信息,不要去抓用户名、头像、个人主页等个人信息。
- 如果页面明确出现验证码或访问限制,立即停止,不要尝试绕过。
这套流程只用于个人学习、技术演示和合理研究。公开发布分析结果时,不要伪造数据来源,使用真实请求结果并保留截图时间。
3. 环境准备与前置条件
3.1 基础环境
- 操作系统:Windows 10/11、macOS 或 Linux 均可。
- Python:建议 3.9 及以上版本。
- 网络:能正常访问目标页面。如果目标网站在国内有 CDN,普通家庭网络即可。
3.2 安装依赖
推荐使用虚拟环境隔离依赖。
python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate安装依赖库:
pip install requests beautifulsoup4 lxml pandas matplotlib openpyxl各库作用:
| 库 | 用途 |
|---|---|
| requests | 发起 HTTP 请求,获取页面 HTML |
| beautifulsoup4 | 解析 HTML,提取评分字段 |
| lxml | BeautifulSoup 的解析引擎,速度更快 |
| pandas | 数据清洗、去重、统计分析 |
| matplotlib | 绘制评分分布图、对比图 |
| openpyxl | 导出 Excel 文件 |
3.3 开发工具准备
建议使用 VS Code 或 PyCharm。调试爬虫时,浏览器开发者工具是必需工具。打开目标评分页面,按 F12 进入 Elements 和 Network 面板,先人工确认数据是直接渲染在 HTML 里,还是通过异步接口返回。
4. 数据采集思路与页面分析
4.1 先确认数据位置,再写代码
赛事评分页面可能有三种渲染方式:
- 静态 HTML:评分数据直接包含在 HTML 里,BeautifulSoup 直接解析。
- XHR/JSON 接口:页面通过 JavaScript 请求 JSON 数据,需要在 Network 面板里找到接口地址。
- 动态渲染:数据由前端框架渲染,且接口较难定位,可以改用 Playwright 渲染页面后解析。
最稳妥的做法是在 Network 面板刷新页面,看有没有返回 JSON 的请求。如果数据量不大且字段清晰,优先走 JSON 接口;如果找不到接口,再回退到 HTML 解析。
这一步是整个项目里最容易卡住的地方。页面结构会随版本更新变化,没有一劳永逸的选择器。下面给出的是通用模板,不是直接可用的成品。
4.2 通用 HTML 请求模板
import random import time import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_html(url: str, timeout: int = 10, retries: int = 3): for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=timeout) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text except requests.RequestException as e: print(f"请求失败,第 {attempt + 1} 次重试: {e}") time.sleep(2 * (attempt + 1)) return None4.3 通用解析模板
先用选择器定位评分卡片容器,再逐条提取选手名、评分、评分人数。
from bs4 import BeautifulSoup def parse_ratings(html: str): """ 解析评分页面,返回评分记录列表。 注意:CSS 选择器必须根据实际页面的 HTML 结构调整。 """ soup = BeautifulSoup(html, "lxml") items = [] # 示例选择器,实际使用时需要替换成页面真实类名 cards = soup.select(".rating-card") for card in cards: player_el = card.select_one(".player-name") score_el = card.select_one(".score") count_el = card.select_one(".rating-count") if player_el and score_el: items.append({ "player": player_el.get_text(strip=True), "score": score_el.get_text(strip=True), "rating_count": count_el.get_text(strip=True) if count_el else "" }) return items这里必须说明:.rating-card、.player-name都是占位选择器。跑脚本前,要用浏览器开发者工具确认页面真实类名,否则解析结果会是空列表。
4.4 如果数据来自 JSON 接口
在 Network 面板里找到评分接口后,优先直接请求 JSON:
import requests url = "https://example.com/api/game/ratings" # 需要替换为真实接口地址 params = {"game_id": "nip-vs-wbg"} resp = requests.get(url, headers=HEADERS, params=params, timeout=10) data = resp.json() for item in data: print(item.get("player"), item.get("score"))注意接口地址、参数名、返回字段都需要按实际页面调整。返回结构不确定时,先打印resp.text前 500 个字符确认格式。
5. 功能测试与效果验证
5.1 测试一:页面能否正常请求
单独运行请求函数,确认返回状态码和文档开头内容。
python -c "from crawler import fetch_html; html = fetch_html('https://example.com/game/nip-vs-wbg/ratings'); print(html[:200])"判断标准:
- 返回 HTML 包含页面标题或比赛信息。
- 没有出现验证码页面、403 页面、登录跳转。
如果出现 403,大概率是请求头没带全或频率过高。可以先在浏览器里复制完整请求头,再补充到 HEADERS。
5.2 测试二:字段解析是否正常
把解析函数跑一遍,检查返回列表长度和字段完整性。
items = parse_ratings(html) print(len(items)) for item in items[:3]: print(item)判断标准:
items长度大于 0。- 每条记录都包含选手名和评分。
- 评分字段没有混入多余文字。
如果items为空,优先检查选择器。页面结构更新后,之前的选择器会失效。
5.3 测试三:缺失字段和异常值处理
采集过程中可能出现评分人数缺失、选手名重复、评分为空的情况。解析时要做好空值兜底。
def parse_ratings_safe(html: str): soup = BeautifulSoup(html, "lxml") items = [] for card in soup.select(".rating-card"): player_el = card.select_one(".player-name") score_el = card.select_one(".score") if player_el is None: continue player = player_el.get_text(strip=True) score_text = score_el.get_text(strip=True) if score_el else "" try: score = float(score_text) except ValueError: score = None items.append({ "player": player, "score": score, }) return items判断标准:程序不因为某一条数据异常而中断,缺失字段被置为None,后续清洗阶段再统一处理。
5.4 测试四:重试和限速
批量采集前,先模拟连续 5 次请求,观察是否触发验证码或 429 状态码。
for i in range(5): html = fetch_html("https://example.com/game/nip-vs-wbg/ratings") print(i, len(html) if html else "fail") time.sleep(random.uniform(3, 5))如果连续请求出现异常,把间隔拉大到 10 秒以上,并在重试逻辑里使用指数退避。这里不做任何规避验证码的操作,遇到限制就降低频率或停手。
6. 数据清洗与存储
采集到的原始数据含有多余字符、缺失值和重复记录,需要先清洗再分析。
6.1 Pandas 清洗流程
import pandas as pd def clean_ratings(items): df = pd.DataFrame(items) if df.empty: return df # 去除选手名中的空白字符 df["player"] = df["player"].str.strip() # 评分转数值,无法转换的置为 NaN df["score"] = pd.to_numeric(df["score"], errors="coerce") # 去掉评分为空的记录 df = df.dropna(subset=["score"]) # 去掉重复选手 df = df.drop_duplicates(subset=["player"], keep="last") # 按评分降序排列 df = df.sort_values("score", ascending=False).reset_index(drop=True) return df清洗后输出统计信息:
df = clean_ratings(items) print(df.describe())describe()会给出评分均值、标准差、最小值和最大值,这些是写赛后总结时最常用的指标。
6.2 存储为 CSV / Excel
df.to_csv("nip_wbg_ratings.csv", index=False, encoding="utf-8-sig") df.to_excel("nip_wbg_ratings.xlsx", index=False, sheet_name="选手评分")建议使用utf-8-sig编码,避免用 Excel 打开 CSV 时中文乱码。
6.3 存储为 SQLite
需要做历史累积分析时,SQLite 比 CSV 更合适。
import sqlite3 conn = sqlite3.connect("lol_ratings.db") df.to_sql("player_ratings", conn, if_exists="append", index=False) conn.close()每条记录可以追加比赛 ID 和时间字段,方便后续按场次筛选。
7. 分析与可视化
7.1 分析维度
针对 NIP 2:1 WBG 这场比赛的评分数据,可以分析:
- 两队选手平均分对比。
- 评分人数分布:评分人数越多,参考价值越高。
- 最高分和最低分选手,结合比赛过程做原因分析。
- 队内离散程度:标准差大说明观众意见分化明显。
具体实现:
team_avg = df.groupby("team")["score"].mean().sort_values(ascending=False) print(team_avg)7.2 柱状图:选手评分对比
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "PingFang SC"] plt.rcParams["axes.unicode_minus"] = False fig, ax = plt.subplots(figsize=(10, 6)) ax.bar(df["player"], df["score"]) ax.set_xlabel("选手") ax.set_ylabel("虎扑评分") ax.set_title("NIP vs WBG 选手虎扑评分对比") plt.xticks(rotation=45) plt.tight_layout() plt.savefig("score_bar.png", dpi=150)7.3 箱线图:两队评分分布
df.boxplot(column="score", by="team", figsize=(8, 6)) plt.title("NIP vs WBG 评分分布") plt.suptitle("") plt.tight_layout() plt.savefig("score_boxplot.png", dpi=150)箱线图能直观体现两队的评分中位数、四分位数和离群点。如果某位选手的评分明显低于全队区间,这个点值得在复盘里单独讨论。
7.4 散点图:评分人数与评分关系
fig, ax = plt.subplots(figsize=(10, 6)) ax.scatter(df["rating_count"], df["score"]) ax.set_xlabel("评分人数") ax.set_ylabel("虎扑评分") ax.set_title("评分人数与评分关系")这个维度可以判断某位选手的高分是“少数人打出来的”还是“大量观众一致认可”。
8. 批量任务与定时采集
8.1 多场比赛批量采集
维护一个比赛 ID 列表,循环请求、解析、存储。
import random import time games = [ {"game_id": "nip-vs-wbg-20250401", "team_a": "NIP", "team_b": "WBG"}, {"game_id": "blg-vs-jdg-20250402", "team_a": "BLG", "team_b": "JDG"}, ] def run_batch(games): for game in games: url = f"https://example.com/game/{game['game_id']}/ratings" html = fetch_html(url) if html: items = parse_ratings_safe(html) df = clean_ratings(pd.DataFrame(items)) df["game_id"] = game["game_id"] df["team_a"] = game["team_a"] df["team_b"] = game["team_b"] df["collected_at"] = pd.Timestamp.now().isoformat() save_to_database(df) time.sleep(random.uniform(3, 5))批量任务的核心是给每条数据打上比赛标识和时间戳,否则后续无法区分来源。
8.2 增量采集策略
如果比赛评分在结束后一周内还在变化,可以做增量更新:每次采集前先查询库里已有的game_id和更新时间,只采集新增场次,或者直接覆盖当天数据。
def get_existing_game_ids(): conn = sqlite3.connect("lol_ratings.db") ids = pd.read_sql("SELECT DISTINCT game_id FROM player_ratings", conn) conn.close() return set(ids["game_id"].tolist()) existing = get_existing_game_ids() new_games = [g for g in games if g["game_id"] not in existing]8.3 定时调度
Windows 可以用“任务计划程序”,macOS/Linux 用 cron。
# 每天 23:30 执行一次采集脚本 30 23 * * * cd /path/to/project && /usr/bin/python3 batch_crawl.py >> crawl.log 2>&1日志文件必须保留,定时任务出问题后能快速定位是哪一场比赛导致的异常。
9. 资源占用与性能观察
这个项目是轻量级 CPU 任务,不需要 GPU,显存占用为零。内存开销主要集中在 Pandas 处理 DataFrame 时,单场比赛评分数据量一般只有几十到几百条,完全无压力。
观察性能时需要关注的指标:
- 单次请求耗时:正常情况 1 到 3 秒。
- 全场比赛采集耗时:包含解析和间隔,单场约 30 到 60 秒。
- 内存峰值:单场数据清洗时通常低于 300MB。
耗时大头在网络请求,不是解析。想提高效率,最快的办法是确认是否存在批量评分 JSON 接口,一次请求拿到全部数据,而不是拆成多条请求。
反爬限制是最主要的性能瓶颈。连续请求超过一定频率后很可能触发访问限制。更稳妥的做法是:
- 控制采集频率,固定 3 到 5 秒间隔。
- 使用指数退避重试。
- 设置超时上限,避免单个请求卡死整个批量任务。
不要为了提高速度使用大规模并发请求,这样不仅容易触发反爬,也可能给目标站点带来不必要的压力。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 请求返回 403 | 缺少 User-Agent 或请求头不完整 | 打印响应状态码和响应体验证码页面 | 补充浏览器请求头,降低频率 |
| 解析列表为空 | CSS 选择器过时或页面结构变化 | 检查 HTML 中是否包含真实字段类名 | 更新选择器 |
| 评分字段包含文字 | 页面包含“分”等后缀 | 打印原始文本 | 正则提取数字或使用float()前先清洗 |
| 中文乱码 | 编码判断错误 | 查看响应头编码 | 使用resp.encoding = resp.apparent_encoding |
| 访问被限制 | 请求频率过高 | 查看是否出现验证码或 429 | 拉长间隔,暂停一段时间 |
| SQLite 数据重复 | 重复运行采集脚本 | 查询 game_id 重复数量 | 增加主键或先查重再写入 |
| 定时任务未执行 | cron 环境变量或 Python 路径不对 | 查看 cron 日志 | 使用绝对路径,打印日志到文件 |
10.1 关键排查思路
第一,所有排查从日志开始。脚本里至少要在请求前后打印 URL、状态码、解析条数,不要静默失败。
第二,页面结构变化是采集失效的最常见原因。发现解析结果为空,第一件事就是打开浏览器手动访问页面,重新确认 DOM 结构。
第三,请求异常先检查响应文本是什么,不要只看状态码。403 页面和验证码页面的处理逻辑完全不同。
11. 最佳实践与使用建议
11.1 工程化建议
- 把请求、解析、清洗、存储拆成四个独立函数,方便单测和维护。
- 每场比赛的原始 HTML 可以落盘保存一份,解析失败时不用重新请求。
- 批量任务要加分页容错,单场失败不中断整个队列。
- 数据入库前先做查重,避免重复采集污染历史数据。
11.2 合规建议
- 采集前确认目标网站的 robots 协议和用户协议。
- 只采集公开数据,不采集用户名、ID、评论正文等个人信息。
- 采集频率保持在合理范围,不构造高并发请求。
- 涉及选手、战队、赛事名称的内容发布时,注明数据来源和采集时间。
- 本流程仅用于个人技术学习、赛事分析和合理研究,不要用于商业出售。
11.3 场景扩展思路
这套数据采集流程稍微调整后,可以迁移到其他方向:
- 选手历史评分趋势:跨场次累积数据后,画出某一选手近十场评分走势。
- 战队口碑变化:把比赛结果和赛后评分关联,观察队伍成绩与社区口碑的关系。
- 自动化赛报生成:采集评分后,结合比赛结果自动生成一段带图表的赛后总结。
12. 总结与下一步
以 NIP 2:1 WBG 这场比赛作为切入点,这篇文章完整走了一遍“网页评分数据采集 -> 清洗 -> 存储 -> 可视化”的流程。核心收获不是某个固定的爬虫脚本,而是三个可以复用的能力:确认数据位置、解析页面字段、批量任务容错。
最先应该验证的是页面结构。打开目标评分页面,确认数据是静态渲染还是 JSON 接口返回,这一步决定了后面所有代码写法。最容易踩的坑也是这里:CSS 选择器写错,脚本运行不报错,但列表始终是空。
下一步可以考虑把采集结果接到 Superset 或者 Grafana,做实时看板;也可以把评分变化历史存进时序数据库,做选手状态的趋势分析。技术本身不复杂,关键是把数据源、字段逻辑和更新策略先定义清楚,后续扩展会顺很多。
