1. 项目缘起与核心价值最近在社区里看到不少朋友在讨论游戏数据尤其是《英雄联盟》的战绩分析。作为一个老玩家兼技术爱好者我经常在想如果能把自己或者关注的高手的对局数据系统地爬取下来做点个性化的分析比如看看自己哪个时间段胜率高、用哪个英雄打特定位置更顺手那该多有意思。市面上虽然有一些现成的助手App但数据要么不全要么就是展示方式固定很难满足深度定制的需求。比如你想分析自己近100场排位赛中前15分钟补刀数与最终胜利的相关性或者想批量查看某个英雄在不同版本下的胜率变化这些需求通用工具很难满足。这就是“爬虫”技术大显身手的地方了。通过编写爬虫程序我们可以直接从游戏官方的数据接口或相关数据网站自动化地获取原始、详细的战绩数据然后存入自己的数据库接下来想怎么分析、怎么可视化就完全由自己掌控了。这不仅仅是获取数据更是一个理解网络请求、处理反爬机制、清洗和结构化数据的完整实战过程。对于想学习Python爬虫或者对数据分析感兴趣的朋友来说这是一个绝佳的练手项目。它涵盖了从网络请求、数据解析到数据存储的全流程而且结果直观有趣动力十足。2. 整体技术方案与核心思路拆解2.1 目标数据源分析与选择爬取英雄联盟数据第一步也是最重要的一步就是确定数据从哪里来。我们不能直接去“攻击”游戏服务器而是寻找官方或第三方提供的数据接口。目前主流的数据源有几个方向官方开发者API如 Riot API这是最权威、最稳定的数据来源。拳头公司为开发者提供了完善的API可以获取到极其详细的比赛数据包括每个玩家的装备购买时间、技能加点顺序、每分钟金钱经验等。但使用它需要注册开发者账号申请API Key并且有严格的调用频率限制。对于个人学习和非商业用途免费 tier 的额度基本够用但需要一定的申请流程。第三方数据聚合网站如 OP.GG、U.GG、LeagueOfGraphs 等。这些网站本身的数据就是通过API或其它方式聚合而来并做了很好的可视化。爬取这些网站相当于“爬取爬虫的结果”。优点是无需API Key直接可见缺点是网站会有反爬措施如验证码、请求频率限制、动态加载且数据结构是为前端展示设计的需要自己解析HTML稳定性不如官方API。游戏客户端本地日志极不推荐。这涉及对客户端文件的深度解析复杂度高且可能违反用户协议。我们的方案选择为了项目的完整性和学习价值我们将采取一种“混合策略”进行讲解。核心以模拟浏览器访问第三方网站如 OP.GG为主线因为这种方式更贴近大多数爬虫初学者的实战场景面对的是有反爬的网页。同时我们会对比介绍官方API的申请与调用方式让你了解更规范的途径。本教程将重点放在前者因为它涉及的反爬技巧、数据解析更具通用性。2.2 技术栈与工具选型工欲善其事必先利其器。以下是完成这个项目需要的主要工具库及其作用Python 3.7: 我们的主力编程语言社区活跃库丰富。Requests: 用于发送HTTP请求获取网页源代码。简单易用是爬虫的基石。BeautifulSoup4 (bs4): 当数据直接存在于网页HTML中时用它来解析和提取数据。它像一把手术刀可以精准地找到HTML标签里的内容。Selenium: 当目标网站的数据是通过JavaScript动态加载即右键“查看网页源代码”看不到数据时就需要它。它可以控制一个真实的浏览器如Chrome去访问页面等待JS执行完毕后再获取完整的页面内容。这是应对现代前端框架如React, Vue的利器。Pandas: 数据清洗、处理和分析的瑞士军刀。爬下来的原始数据往往是杂乱无章的列表或字典用Pandas可以方便地转换成结构清晰的表格DataFrame进行过滤、排序、统计等操作。SQLite / MySQL / PostgreSQL: 用于持久化存储数据。SQLite轻量适合本地单机项目MySQL/PostgreSQL功能更强大适合数据量较大或需要远程访问的场景。我们将使用SQLite作为示例。Fiddler / Charles / 浏览器开发者工具: 这些不是Python库而是至关重要的辅助工具。用于“抓包”即监听浏览器和服务器之间的网络请求帮助我们找到真正传输数据的那个请求API接口以及分析请求头、参数、Cookie等关键信息。注意选择Selenium意味着你需要下载对应的浏览器驱动如ChromeDriver并匹配你的浏览器版本。这比纯RequestsBS4的方案稍重但为了应对更普遍的情况我们以Selenium为主进行讲解。如果目标网站没有JS加载可以降级使用RequestsBS4效率更高。2.3 核心爬取流程设计整个爬虫的运作流程可以概括为以下几步这是一个通用的爬虫思维框架目标确定明确要爬取哪个大区如艾欧尼亚、哪个玩家的数据。请求模拟使用Selenium打开目标玩家的战绩页面如https://www.op.gg/summoners/region/summonerName。页面等待与渲染确保页面上的比赛列表、KDA等数据已经完全加载出来。这里需要设置合理的等待时间或等待特定元素出现。元素定位与数据解析使用Selenium或结合BeautifulSoup来定位到包含战绩的HTML元素并从中提取出文本信息如英雄名称、K/D/A、胜负、比赛时长等。数据存储将提取出的结构化数据通常是一个字典或列表保存到文件CSV, JSON或数据库中。翻页与循环如果有多页战绩模拟点击“下一页”按钮或分析翻页的URL规律循环执行步骤2-5。反爬应对在过程中加入随机延迟、使用代理IP池如果需要大量爬取、处理可能的验证码等。3. 实战环境搭建与核心代码解析3.1 环境准备与依赖安装首先确保你的电脑上安装了Python。打开命令行CMD或Terminal创建一个新的项目目录并安装必要的库。# 创建项目目录并进入 mkdir lol-match-spider cd lol-match-spider # 创建虚拟环境推荐避免包冲突 python -m venv venv # 激活虚拟环境 # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate # 安装核心库 pip install requests beautifulsoup4 selenium pandas # 安装用于连接SQLite的库Python标准库自带sqlite3一般无需安装 # 如果需要其他数据库如pymysql for MySQL # pip install pymysql接下来你需要下载与你的Chrome浏览器版本匹配的ChromeDriver。访问 ChromeDriver官网 或使用国内镜像站下载将下载的chromedriver.exe(Windows) 或chromedriver(macOS/Linux) 文件放在项目目录下或者将其所在路径添加到系统的环境变量中。3.2 爬虫核心代码实现我们计划爬取OP.GG上某个玩家的近期战绩列表。以下是一个分步实现的示例代码框架包含了关键步骤和注释。import time import pandas as pd from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException, NoSuchElementException class LOLMatchSpider: def __init__(self, summoner_name, regionkr): 初始化爬虫 :param summoner_name: 召唤师名称 :param region: 服务器地区op.gg上kr代表韩服www代表美服等 self.summoner_name summoner_name self.region region # 构建目标URL注意op.gg的URL格式 self.base_url fhttps://www.op.gg/summoners/{region}/{summoner_name} # 初始化Selenium WebDriver这里以Chrome为例 options webdriver.ChromeOptions() # 添加一些选项以更好地模拟真人浏览器并可能规避一些检测 options.add_argument(--disable-blink-featuresAutomationControlled) options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) # 无头模式不显示浏览器界面调试时可注释掉 # options.add_argument(--headless) self.driver webdriver.Chrome(optionsoptions) # 隐藏WebDriver特征 self.driver.execute_script(Object.defineProperty(navigator, webdriver, {get: () undefined})) self.wait WebDriverWait(self.driver, 10) # 显式等待最多等10秒 # 用于存储所有比赛数据的列表 self.all_matches [] def fetch_match_list(self): 访问玩家主页并获取战绩列表 print(f正在访问 {self.base_url} ...) self.driver.get(self.base_url) # 关键等待战绩列表区域加载完成。通过CSS选择器定位。 # OP.GG的战绩列表通常在一个class包含game-list的div里 try: match_list_container self.wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, div[class*game-list])) ) print(战绩列表加载成功。) except TimeoutException: print(页面加载超时可能元素选择器已更新或网络问题。) self.driver.save_screenshot(timeout.png) # 保存截图便于调试 return False # 等待片刻确保动态内容如KDA加载完全 time.sleep(2) # 开始解析当前页的战绩 self._parse_current_page_matches() return True def _parse_current_page_matches(self): 解析当前页面显示的所有战绩卡片 # 找到所有单场战绩的卡片元素。OP.GG的每场战绩通常在一个div里class名可能类似result-WIN或result-LOSE # 这个选择器需要根据实际网页结构调整这里是一个示例 match_cards self.driver.find_elements(By.CSS_SELECTOR, div[class*result-]) print(f本页找到 {len(match_cards)} 场对局。) for card in match_cards: match_data {} try: # 1. 解析胜负 (WIN/LOSE) # 胜负信息通常直接体现在容器的class名中或者内部有一个特定元素 class_name card.get_attribute(class) if WIN in class_name: match_data[result] 胜利 elif LOSE in class_name: match_data[result] 失败 else: match_data[result] 未知 # 2. 解析游戏模式如单双排、灵活组排、匹配 # 查找包含游戏模式文本的元素 game_type_elem card.find_element(By.CSS_SELECTOR, .game-type) # 示例选择器 match_data[game_type] game_type_elem.text.strip() if game_type_elem else N/A # 3. 解析英雄名称 champion_elem card.find_element(By.CSS_SELECTOR, .champion-name) # 示例选择器 match_data[champion] champion_elem.text.strip() if champion_elem else N/A # 4. 解析K/D/A kda_elem card.find_element(By.CSS_SELECTOR, .kda) # 示例选择器 kda_text kda_elem.text.strip() if kda_elem else N/A # 假设格式为 12 / 5 / 8 match_data[kda] kda_text # 5. 解析比赛时长 time_elem card.find_element(By.CSS_SELECTOR, .time) # 示例选择器 match_data[duration] time_elem.text.strip() if time_elem else N/A # 6. 解析比赛日期 date_elem card.find_element(By.CSS_SELECTOR, .date) # 示例选择器 match_data[date] date_elem.text.strip() if date_elem else N/A # 将本场数据添加到总列表 self.all_matches.append(match_data) print(f已解析: {match_data[date]} | {match_data[result]} | {match_data[champion]} | {match_data[kda]}) except NoSuchElementException as e: # 如果某个元素找不到跳过这场对局记录日志 print(f解析单场对局时遇到元素缺失跳过。错误: {e}) continue def run(self): 运行爬虫的主流程 if self.fetch_match_list(): print(f共爬取到 {len(self.all_matches)} 场对局数据。) # 将数据转换为Pandas DataFrame方便查看和后续处理 df pd.DataFrame(self.all_matches) print(df.head()) # 打印前几行看看 # 保存到CSV文件 df.to_csv(f{self.summoner_name}_matches.csv, indexFalse, encodingutf-8-sig) print(f数据已保存至 {self.summoner_name}_matches.csv) else: print(爬取过程失败。) # 关闭浏览器 self.driver.quit() # 使用示例 if __name__ __main__: # 替换成你想爬取的召唤师名字和区服 spider LOLMatchSpider(summoner_nameHide on bush, regionkr) spider.run()代码关键点解析初始化与反爬在__init__中我们通过ChromeOptions添加了一些参数来隐藏自动化特征这是绕过一些简单反爬检测的常用手段。execute_script那行代码直接修改了浏览器的navigator.webdriver属性使其看起来更像普通浏览器。显式等待 (WebDriverWait)fetch_match_list方法中的WebDriverWait是Selenium最佳实践之一。它不会盲目地time.sleep一个固定时间而是等待页面特定元素出现这样既高效又稳定。这里我们等待一个包含game-list的div元素出现。数据解析 (_parse_current_page_matches)这是最核心也最脆弱的部分。我们通过find_element和CSS选择器来定位元素。请注意.game-type,.champion-name等选择器是我根据常见结构假设的示例实际OP.GG的网页结构可能不同需要你用浏览器开发者工具实时查看并调整。错误处理在解析每张卡片时使用了try...except捕获NoSuchElementException。网页结构可能微调某场比赛可能缺少某些元素比如极地大乱斗没有补刀数良好的错误处理能保证程序不会因为一场比赛解析失败而崩溃。数据存储最后用Pandas将列表转换成DataFrame并保存为CSV文件。CSV格式通用方便用Excel打开或导入数据库。3.3 动态数据与API接口抓取上面我们爬取的是直接渲染在HTML里的数据。但更多详细数据如具体的装备列表、伤害图表、时间线数据往往是页面加载后通过JavaScript调用后台API接口异步获取的。这时直接解析HTML就抓不到了。解决方案抓包分析API。打开Chrome开发者工具F12切换到Network网络标签页。刷新OP.GG的战绩页面或者点击“加载更多比赛”。在Network面板里你会看到很多请求。筛选XHR/Fetch类型的请求这些通常是数据传输的API。仔细查看这些请求的URL、请求方法GET/POST、请求头特别是Authorization,Cookie,X-Riot-Token等和响应内容Preview或Response标签。找到那个返回JSON格式比赛数据的请求。它的响应体可能是一个包含比赛ID列表的JSON或者是详细的比赛数据JSON。模拟这个请求一旦找到真正的数据接口我们的爬虫就可以从“模拟浏览器”升级为更高效的“直接请求API”。我们可以用requests库带上必要的请求头尤其是Cookie它代表了你的登录状态或会话直接向这个API地址发送请求拿到结构化的JSON数据这比解析HTML要稳定和高效得多。import requests import json # 假设通过抓包找到了获取比赛详情的API match_api_url https://op.gg/api/v1.0/internal/bypass/summoners/region/summonerName/matches # 示例URL非真实 # 关键构造请求头通常需要Cookie和User-Agent headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., Accept: application/json, # Cookie: 你的Cookie字符串从浏览器开发者工具里复制, # 重要 # Authorization: Bearer ..., # 如果有的话 Referer: fhttps://www.op.gg/summoners/region/summonerName # 有时需要 } # 可能还需要查询参数 params { queue: 420, # 排位赛 limit: 20, hl: zh_CN } response requests.get(match_api_url, headersheaders, paramsparams) if response.status_code 200: match_data response.json() # 直接得到Python字典/列表 # 处理 match_data... print(json.dumps(match_data, indent2, ensure_asciiFalse)) else: print(f请求失败状态码{response.status_code})重要提醒直接调用第三方网站的API可能违反其服务条款且接口地址和参数可能随时变更。此方法仅用于学习网络请求原理。对于持续、大量的数据需求强烈建议使用官方Riot API它稳定、合法且有完善的文档。4. 数据清洗、存储与简单分析4.1 使用Pandas进行数据清洗爬取下来的原始数据往往存在缺失值、格式不一致等问题。Pandas可以轻松处理这些。import pandas as pd # 读取刚才保存的CSV df pd.read_csv(Hide on bush_matches.csv) # 1. 查看数据概览和信息 print(df.info()) # 查看列名、非空数量、数据类型 print(df.head()) # 2. 处理KDA列拆分成Kill, Death, Assist三列 # 假设kda列格式为 12 / 5 / 8 def split_kda(kda_str): try: k, d, a map(int, kda_str.split(/)) return pd.Series([k, d, a]) except: return pd.Series([0, 0, 0]) # 对于异常数据返回0 df[[Kills, Deaths, Assists]] df[kda].apply(split_kda) # 3. 计算KDA比率(KA)/D防止除零 df[KDA_Ratio] df.apply(lambda row: (row[Kills] row[Assists]) / max(row[Deaths], 1), axis1) # 4. 将比赛时长从字符串如32:15转换为分钟数浮点数 def convert_duration(dur_str): if : in dur_str: parts dur_str.split(:) if len(parts) 2: return int(parts[0]) int(parts[1])/60 return 0 df[duration_minutes] df[duration].apply(convert_duration) # 5. 处理日期列转换为datetime类型如果原始是1天前需要更复杂的处理这里假设是具体日期 # df[date] pd.to_datetime(df[date], errorscoerce) # 如果日期格式规整 # 6. 删除不必要的列或处理缺失值 # df df.dropna(subset[champion]) # 删除英雄名为空的行 # df[game_type].fillna(未知, inplaceTrue) # 填充空值 print(df[[result, champion, Kills, Deaths, Assists, KDA_Ratio, duration_minutes]].head())4.2 数据存储到数据库将清洗后的数据存入SQLite数据库便于长期管理和复杂查询。import sqlite3 # 连接数据库如果不存在则创建 conn sqlite3.connect(lol_matches.db) cursor conn.cursor() # 创建表 create_table_sql CREATE TABLE IF NOT EXISTS match_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, summoner_name TEXT, region TEXT, game_date TEXT, game_type TEXT, champion TEXT, result TEXT, kills INTEGER, deaths INTEGER, assists INTEGER, kda_ratio REAL, duration_min REAL, raw_data TEXT ); cursor.execute(create_table_sql) # 假设我们有一个清洗后的DataFrame df_clean # 将DataFrame的数据插入数据库 for index, row in df.iterrows(): insert_sql INSERT INTO match_history (summoner_name, region, game_date, game_type, champion, result, kills, deaths, assists, kda_ratio, duration_min) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) cursor.execute(insert_sql, ( Hide on bush, # 召唤师名 kr, # 区服 row.get(date, N/A), row.get(game_type, N/A), row.get(champion, N/A), row.get(result, N/A), row[Kills], row[Deaths], row[Assists], row[KDA_Ratio], row[duration_minutes] )) conn.commit() print(数据已存入数据库。) # 查询示例计算某个英雄的平均KDA query_sql SELECT champion, COUNT(*) as games, AVG(kills) as avg_kills, AVG(deaths) as avg_deaths, AVG(assists) as avg_assists, AVG(kda_ratio) as avg_kda_ratio, SUM(CASE WHEN result 胜利 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as win_rate FROM match_history WHERE summoner_name Hide on bush GROUP BY champion ORDER BY games DESC; df_stats pd.read_sql_query(query_sql, conn) print(df_stats.head()) conn.close()4.3 简单的数据分析与可视化有了干净的数据我们就可以进行一些有趣的分析了。这里用Pandas和Matplotlib做一个简单的示例。import matplotlib.pyplot as plt # 设置中文字体如果需要显示中文 plt.rcParams[font.sans-serif] [SimHei] # 用来正常显示中文标签 plt.rcParams[axes.unicode_minus] False # 用来正常显示负号 # 1. 英雄使用频率排行榜 champion_counts df[champion].value_counts().head(10) plt.figure(figsize(10,6)) champion_counts.plot(kindbarh) plt.title(最常使用的英雄 Top 10) plt.xlabel(出场次数) plt.tight_layout() plt.show() # 2. 不同游戏模式的胜率 if game_type in df.columns: win_rate_by_mode df.groupby(game_type)[result].apply(lambda x: (x 胜利).sum() / len(x) * 100) print(各模式胜率) print(win_rate_by_mode) # 3. KDA分布直方图 plt.figure(figsize(10,6)) plt.hist(df[KDA_Ratio], bins20, edgecolorblack, alpha0.7) plt.axvline(df[KDA_Ratio].mean(), colorred, linestyle--, labelf平均KDA: {df[KDA_Ratio].mean():.2f}) plt.xlabel(KDA比率) plt.ylabel(场次) plt.title(KDA比率分布) plt.legend() plt.tight_layout() plt.show()5. 常见问题、反爬策略与优化建议5.1 高频问题与解决方案元素定位失败报错NoSuchElementException原因网页结构发生变化或者页面未完全加载。解决更新选择器使用浏览器开发者工具的“检查”功能重新确认目标元素的CSS选择器或XPath。优先使用相对稳定、语义化的属性如>