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 请求获取页面 HTMLbeautifulsoup4解析 HTML提取评分字段lxmlBeautifulSoup 的解析引擎速度更快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;q0.9, } def fetch_html(url: str, timeout: int 10, retries: int 3): for attempt in range(retries): try: resp requests.get(url, headersHEADERS, timeouttimeout) 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(stripTrue), score: score_el.get_text(stripTrue), rating_count: count_el.get_text(stripTrue) if count_el else }) return items这里必须说明.rating-card、.player-name都是占位选择器。跑脚本前要用浏览器开发者工具确认页面真实类名否则解析结果会是空列表。4.4 如果数据来自 JSON 接口在 Network 面板里找到评分接口后优先直接请求 JSONimport requests url https://example.com/api/game/ratings # 需要替换为真实接口地址 params {game_id: nip-vs-wbg} resp requests.get(url, headersHEADERS, paramsparams, timeout10) 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(stripTrue) score_text score_el.get_text(stripTrue) 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], errorscoerce) # 去掉评分为空的记录 df df.dropna(subset[score]) # 去掉重复选手 df df.drop_duplicates(subset[player], keeplast) # 按评分降序排列 df df.sort_values(score, ascendingFalse).reset_index(dropTrue) return df清洗后输出统计信息df clean_ratings(items) print(df.describe())describe()会给出评分均值、标准差、最小值和最大值这些是写赛后总结时最常用的指标。6.2 存储为 CSV / Exceldf.to_csv(nip_wbg_ratings.csv, indexFalse, encodingutf-8-sig) df.to_excel(nip_wbg_ratings.xlsx, indexFalse, 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_existsappend, indexFalse) conn.close()每条记录可以追加比赛 ID 和时间字段方便后续按场次筛选。7. 分析与可视化7.1 分析维度针对 NIP 2:1 WBG 这场比赛的评分数据可以分析两队选手平均分对比。评分人数分布评分人数越多参考价值越高。最高分和最低分选手结合比赛过程做原因分析。队内离散程度标准差大说明观众意见分化明显。具体实现team_avg df.groupby(team)[score].mean().sort_values(ascendingFalse) 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(rotation45) plt.tight_layout() plt.savefig(score_bar.png, dpi150)7.3 箱线图两队评分分布df.boxplot(columnscore, byteam, figsize(8, 6)) plt.title(NIP vs WBG 评分分布) plt.suptitle() plt.tight_layout() plt.savefig(score_boxplot.png, dpi150)箱线图能直观体现两队的评分中位数、四分位数和离群点。如果某位选手的评分明显低于全队区间这个点值得在复盘里单独讨论。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 fhttps://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 21日志文件必须保留定时任务出问题后能快速定位是哪一场比赛导致的异常。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做实时看板也可以把评分变化历史存进时序数据库做选手状态的趋势分析。技术本身不复杂关键是把数据源、字段逻辑和更新策略先定义清楚后续扩展会顺很多。