十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

历年足球数据下载全流程:数据源、抓取、清洗与存储实战

历年足球数据下载全流程:数据源、抓取、清洗与存储实战 做足球数据分析这些年被问得最多的一句就是哪里能一次性把历年足球数据下载下来。这个问题背后其实藏着一堆细分需求有人要复盘某支球队十年的主客场战绩有人想拉一条联赛进失球的时间序列还有人只是想在本地跑个模型练手不想每次分析都被网络卡住。我自己从最早手工复制网页表格到后来写脚本批量抓取归档前后折腾了七八年踩过的坑能写一本小册子。这篇文章就把历年足球数据下载这件事从头到尾拆一遍——数据源怎么挑、脚本怎么写、抓下来怎么清洗、存成什么格式最省事、出了问题怎么排查。不管你是刚入门的数据分析新手还是已经能写爬虫但总在数据质量上栽跟头的老手应该都能从里面捞到点能直接抄的东西。1. 需求先拆开历年足球数据到底要解决什么问题历年足球数据这五个字看着简单真落到地上会分裂成好几种完全不同的东西。你得先搞清楚自己要的是哪一种否则后面选数据源和设计表结构全会走弯路。1.1 三种最常见的用法第一种是赛果回溯。也就是你只关心谁在什么时候赢了谁比分多少。这类需求的核心是赛程表和比分表字段无非是日期、主队、客队、主客队进球、赛事名称、赛季。数据量不大一个主流联赛上百年的历史也就几万行一个CSV文件就装下了。做这类项目的人多半想验证一些朴素规律比如主场优势到底有多明显、某支球队的最长连胜纪录有多长。第二种是逐事件数据。这类数据精细到每一次传球、射门、抢断发生在第几分钟、在球场的哪个坐标。一场比赛能拆出上千条记录一个赛季下来就是几十万上百万行。做这类分析的人通常关心战术层面的东西比如控球区域分布、前场压迫强度、期望进球模型。数据量大、清洗成本高对存储和计算的要求完全是另一个量级。第三种是球员与阵容维度。你要的是球员名单、出场时间、位置、转会记录、身价变化。这类数据的特点是维度多、更新频繁而且不同年代的数据完整度差异巨大——上世纪七八十年代的出场数据往往残缺不全甚至同一名球员在不同来源里的名字拼写都不一样。我个人的建议是先从第一种做法入手把整条链路跑通再考虑往细粒度升级。很多人一上来就冲着逐事件数据去结果卡在数据清洗上项目直接死在半路。先把最简单的赛果数据从下载到入库走一遍你会发现后面所有复杂需求都只是在这个骨架上加字段、加表而已。1.2 数据必须满足的四个硬指标不管你做哪种分析一份合格的历年数据集至少得满足四件事。时间连续。断档是历史数据最致命的问题。你可能拿到了2010到2023年的完整数据但2009年缺了一半那所有跨年份的对比都失去了基准。所以在下载之前一定要先做一次覆盖度检查把每一年的比赛场次列出来跟理论场次对一下差得多的年份单独标记出来。主键唯一。同一场比赛可能在赛程表、比分表、积分榜里各出现一次如果没有一个全局唯一的比赛ID你在做关联查询的时候就会遇到重复计数最后算出来的总场次比实际多出一大截。字段命名一致。历史数据源的字段命名经常变今天叫home_team明天改叫homeTeam。做长期维护的项目一定要在入库前加一层字段映射把外部命名统一成自己的标准不然每次数据源一升级你就得改一次下游代码。单位一致。时间要统一时区比分要统一记法有些来源把点球大战单独记有些合进常规比分球员身价要统一币种和单位。这些细节看着琐碎但一旦混淆后面算出来的所有指标都是错的。提示在正式大规模下载前先手动下载某一年的一个小样本人工核对十几条记录。这一步花二十分钟能帮你省掉后面几天的返工。2. 数据源怎么挑五类公开来源的取舍选源是整件事里最需要花心思的一步。源选错了后面写再漂亮的代码也救不回来。2.1 从官方归档到社区数据集现在能拿到历年足球数据的公开渠道大致分成五类各有各的脾气。第一类是赛事组织方和联赛官方的历史归档页。这类来源权威性最高比分和赛程基本不会有错缺点是结构不统一有的只提供网页展示有的提供的是按赛季分开的文档文件想批量拿下来得做不少解析工作。而且这类页面通常有访问频率限制你得慢慢来。第二类是体育数据平台开放的公开查询能力。很多平台会开放一部分面向普通访问的查询入口返回结构化数据。用这类来源的好处是省去了HTML解析的麻烦坏处是可用范围有限、字段有限而且接口结构可能随时调整。用之前一定要把使用条款读清楚明确能用来做什么、频率上限是多少。第三类是开源社区维护的历史数据集。这类数据集通常以CSV或JSON的形式直接放在代码托管平台上有的甚至几十年数据一次打包。优点是拿来就能用缺点是更新滞后往往是有人想起来才更新一次最新赛季的数据可能缺失。第四类是面向研究和学习的公开数据集合。一些数据机构和高校会把脱敏后的比赛事件数据开放出来专门供教学和算法研究使用。这类数据质量高、标注规范但覆盖的联赛和赛季往往是挑选过的不适合做全量历史分析。第五类是公开的表格类归档文件。一些爱好者把散落的数据整理成统一的CSV放出来字段整齐、直接可读适合快速起项目。用之前最好交叉验证几个赛季的比分确认没有转录错误。2.2 选源时的四个判断标准我挑源的时候一般看四件事按优先级排下来是这样判断维度具体看什么不达标的表现覆盖度年份跨度、赛事数量、是否含杯赛只有近五年数据或只覆盖顶级联赛结构化程度是否有稳定接口、字段是否规范全靠网页表格字段名随页面改版漂移更新机制是否有明确更新周期、能否增量获取靠人工维护最新赛季长期缺失授权与条款使用范围、频率限制、署名要求条款模糊或明确限制批量获取覆盖度和结构化程度是硬门槛更新机制和授权条款是长期项目必须过的关。一个只做一次性分析的小项目可以用手工整理的数据集凑合但你要是打算搭一个每月自动更新、持续几年的数据仓库那更新机制和授权条款就不能将就了。我自己的习惯是同时准备主源和备用源。主源负责日常增量备用源在每年赛季结束后做一次全量校验两边对不上的比赛单独拎出来人工核对。这个双源校验的做法救过我好几次尤其是那种赛季中期改期的比赛单一来源经常记错日期。注意无论用哪一类来源都要遵守对方的使用条款和标明出处的要求控制请求频率不要做任何绕过访问限制的操作。用于个人学习和非商业研究是比较稳妥的使用方式。3. 从零搭下载脚本环境、骨架与增量更新选好源之后就该动手了。这一节我把一个能长期跑的下载脚本拆开讲。3.1 环境与依赖准备先说技术栈。Python 3.10 以上配合requests做请求、pandas做清洗、BeautifulSoup或lxml做HTML解析、DuckDB或SQLite做本地存储。这套组合的好处是全在本地不依赖任何外部服务装完就能跑。python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install requests pandas lxml beautifulsoup4 duckdb tenacitytenacity是个专门做重试的库后面讲失败恢复时会用到。数据量大的话我会把解析部分换成polars速度能快好几倍但API跟 pandas 不太一样新手先用 pandas 就好。目录结构我习惯这样搭football-data/ ├── raw/ # 原始下载文件按赛季分目录 ├── parsed/ # 解析后的结构化文件 ├── db/ # 数据库文件 ├── logs/ # 运行日志 └── scripts/ # 脚本原始文件一定要留着别下完就删。这是我用血换来的教训。有次解析逻辑写错了把主客队搞反结果原始数据当初是边下边删的只能全部重新下白等了一整天。留着 raw 目录相当于给自己留了一份后悔药。3.2 单页抓取的基本骨架先写一个最小可用的抓取函数核心就三件事发请求、判断状态、返回内容。import time import requests from tenacity import retry, stop_after_attempt, wait_exponential HEADERS { User-Agent: Mozilla/5.0 (compatible; research-bot/1.0), Accept-Language: zh-CN,zh;q0.9,en;q0.8, } retry(stopstop_after_attempt(3), waitwait_exponential(multiplier2, min2, max30)) def fetch(url, session, timeout15): resp session.get(url, headersHEADERS, timeouttimeout) resp.raise_for_status() return resp.text def download_pages(urls, out_dir, interval1.5): session requests.Session() for i, url in enumerate(urls): try: html fetch(url, session) except Exception as e: print(f[FAIL] {url} - {e}) continue name url.rstrip(/).split(/)[-1] or fpage_{i} with open(f{out_dir}/{name}.html, w, encodingutf-8) as f: f.write(html) time.sleep(interval)几个参数值得说清楚。timeout15是单次请求的等待上限设太短会在网络波动时大量失败设太长会让整个任务卡住。interval1.5是请求之间的间隔这个值要根据目标站点的实际响应速度调普通站点控制在1到2秒比较合适既能拿到数据也别给对方服务器造成不必要的压力。wait_exponential(multiplier2, min2, max30)这个重试策略的意思是第一次失败等2秒第二次等4秒第三次等8秒最长不超过30秒。这种退避策略比固定间隔重试友好得多遇到临时性的服务不可用等一会儿再试往往就通了。3.3 分页遍历与增量更新历史数据下载最耗时的部分不是单个页面而是几百上千个页面的遍历。这里有两个关键设计一是怎么遍历二是怎么避免重复下载。遍历本身不复杂按赛季、按月份、按页码生成URL列表就行。真正值得花心思的是增量更新——第二次运行时只处理那些还没有或者可能变了的页面。import os import hashlib import json def load_state(pathstate.json): if os.path.exists(path): with open(path, encodingutf-8) as f: return json.load(f) return {} def save_state(state, pathstate.json): with open(path, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) def run_incremental(urls, out_dir, state_pathstate.json): state load_state(state_path) session requests.Session() for url in urls: key hashlib.md5(url.encode()).hexdigest() if state.get(key) done: continue try: html fetch(url, session) except Exception as e: print(f[FAIL] {url} - {e}) continue with open(f{out_dir}/{key}.html, w, encodingutf-8) as f: f.write(html) state[key] done save_state(state, state_path) time.sleep(1.5)用URL的哈希做文件名和状态键能绕开中文、特殊字符带来的文件名问题。状态文件每下完一页就落一次盘这样脚本中途断了重跑的时候能直接从断点继续不会浪费时间重下已经拿到的数据。这个设计看起来简单但它是我所有长任务脚本的地基。4. 抓取节奏的控制与失败恢复抓取是一件慢工出细活的事急不来。这一节的思路都是围绕让任务能安全地跑完展开的。4.1 请求节奏怎么量化我见过太多人一开始把并发开到几十几分钟内请求上千次结果被目标站点拒绝脚本直接废掉只能换个时间重来。合理的做法是把慢当成设计的一部分。具体怎么定节奏我一般参考三个指标目标站点的平均响应时间、单次任务的总页面数、可接受的总耗时。举个实际例子。假设你要下载某联赛近20年的赛程共约12000场比赛按每页20条算需要600个页面。如果每次请求间隔1.5秒加上平均0.4秒的响应时间单页大约2秒600页就是20分钟。这个耗时完全可接受没必要为了快这几分钟去冒被拦的风险。import time import random def polite_sleep(base1.5, jitter0.6): # 在基准间隔上叠加一点随机抖动避免请求节奏过于机械 time.sleep(base random.uniform(0, jitter))加随机抖动是有讲究的。固定的间隔会让请求呈现出非常规律的节拍而真实用户的访问节奏是散乱的。加一点随机性能让访问行为显得更自然一些。当然这只是次要因素真正重要的还是整体频率要低、总请求量要有节制。另外要记得遵守目标站点在根目录下声明的抓取规则明确标注了禁止访问的路径就别去碰这不是能不能做到的问题是应不应该做的问题。4.2 失败恢复的三种处理方式长任务一定会遇到失败关键是怎么设计恢复机制。我一般分三层。第一层是单次请求重试。前面用的tenacity就干这个针对超时、连接重置这类临时故障退避重试两三次基本能解决。第二层是任务级断点续传。状态文件记录每个URL的处理结果重跑时跳过已完成的。这一层的意义在于哪怕任务跑了三天突然断电重启后也不用从头再来。第三层是结果校验与补漏。下载完成后对照预期清单检查哪些页面缺失把缺的URL单独列出来重新跑一遍。def verify_and_retry(all_urls, state, out_dir, max_rounds3): for round_no in range(max_rounds): missing [u for u in all_urls if state.get(hashlib.md5(u.encode()).hexdigest()) ! done] if not missing: print(全部完成) return print(f第 {round_no 1} 轮补漏剩余 {len(missing)} 条) run_incremental(missing, out_dir)这套重试—续传—校验的三层结构我用在几乎所有批量下载任务上省心得很。尤其是跨天运行的长时间任务没有断点续传基本没法用。提示状态文件建议定期备份或者在原文件旁边留一份带时间戳的副本。有次状态文件被意外覆盖整个任务白跑了六个小时。5. 数据清洗把脏数据变成能用的表原始数据拿到手第一件事不是急着分析而是清洗。这一节讲字段怎么统一、脏数据怎么处理。5.1 字段标准化与主键设计我给自己定了一套标准命名所有外部数据进来都先映射成这套标准字段含义常见的外部叫法match_date比赛日期Date, date, matchDate, 比赛时间home_team主队HomeTeam, home, 主队away_team客队AwayTeam, away, 客队home_goals主队进球FTHG, home_score, 主队进球数away_goals客队进球FTAG, away_score, 客队进球数competition赛事League, Div, comp, 联赛season赛季Season, year, 赛季映射关系我写在配置文件里而不是硬编码在脚本里。这样将来加新数据源只要改配置不用动主逻辑。主键我前面提过用赛季赛事日期主客队拼哈希。这里有个细节要处理队名要先归一化。同一个队在A源叫Manchester United在B源叫Man United不统一的话同一场比赛在两个源里会算成两场。我的做法是维护一张别名表把常见缩写、全称、历史名称都映射到一个规范名。import hashlib ALIAS { Man United: Manchester United, Man Utd: Manchester United, 曼联: Manchester United, } def norm_team(name): name name.strip() return ALIAS.get(name, name) def make_match_id(row): raw |.join([ str(row[season]), str(row[competition]), str(row[match_date]), norm_team(row[home_team]), norm_team(row[away_team]), ]) return hashlib.md5(raw.encode()).hexdigest()[:16]主键长度取16位十六进制就够用了完整32位太长存库和建索引都浪费空间。碰撞概率在几百万行的量级下可以忽略不计。5.2 几类常见脏数据与处理办法历史数据的脏主要脏在四个地方。日期格式混乱。有2023-05-12的有12/05/2023的还有May 12, 2023的。最坑的是日月颠倒欧洲习惯日在前美国习惯月在前一旦混用05/12到底是5月12号还是12月5号很难判断。我的处理是用pandas.to_datetime配合明确的格式列表逐个尝试同时对那些无法解析的行单独输出到一份清单里人工确认。import pandas as pd def parse_date(s): for fmt in (%Y-%m-%d, %d/%m/%Y, %Y/%m/%d, %d.%m.%Y): try: return pd.to_datetime(s, formatfmt) except (ValueError, TypeError): continue return pd.NaT比分缺项。有些早期数据只记了胜负没记具体比分有些把加时和点球混进了常规比分。处理方式是在表里加一列result_type标记这场比赛的比分是常规时间、加时后还是点球决胜。不标记的话后面算场均进球会偏高。队名不一致。前面说的别名表能解决大部分剩下顽固的就得靠人工。有个小技巧是先把所有队名按出现频次排序频次低的那些往往就是拼写错误的异类优先检查它们。赛季划分不统一。跨年联赛的2023赛季可能指2023年8月到2024年5月也可能指2023年1月到12月。这个必须在入库时统一成一种约定我习惯用起始年份表示即2023赛季 2023年8月到2024年5月。注意清洗阶段务必保留一份原始行到清洗后行的对照关系出了问题能追溯。我一般会在清洗后的表里加一列source_file和source_row定位问题的时候非常好用。6. 存储与组织让几百万行数据随手可查数据存得好不好直接决定你后面分析时是顺畅还是抓狂。6.1 文件格式的选择小数据量几十万行以内用 CSV 完全够用好处是人人都能打开看坏处是体积大、没有类型信息、读取慢。数据量上到百万行我建议换 Parquet。它是列式存储同样的数据体积通常只有CSV的三分之一到五分之一读取速度快好几倍而且自带字段类型。做历史数据分析经常要按列聚合列式存储在这个场景下的优势非常明显。import pandas as pd df pd.read_csv(parsed/matches_all.csv) df.to_parquet(parsed/matches_all.parquet, indexFalse)真要频繁做关联查询那就上 DuckDB。它能直接读取 Parquet 文件用标准 SQL 查询单机跑几千万行毫无压力而且不需要安装任何服务端。import duckdb con duckdb.connect(db/football.duckdb) con.execute( CREATE TABLE IF NOT EXISTS matches AS SELECT * FROM read_parquet(parsed/matches_all.parquet) ) result con.execute( SELECT season, COUNT(*) AS cnt FROM matches GROUP BY season ORDER BY season ).fetchdf()一个简单的赛季场次统计几秒钟就出结果。这套组合我用下来单机处理几千万行数据完全够用没必要上分布式。6.2 目录结构与命名规范数据一多命名混乱比技术问题更让人头疼。我踩过的坑包括赛季写成2023232022-23三种形式导致同一个赛季的数据散在三个目录日期不补零2023-5-1和2023-05-01排序结果完全不同。我的命名约定是这样的raw/ ├── 2023/ │ ├── epl_2023_p01.html │ └── epl_2023_p02.html └── 2024/ └── epl_2024_p01.html parsed/ ├── matches_2018_2023.parquet └── matches_2024.parquet规则就三条赛季统一用四位起始年份、文件名全小写下划线分隔、日期一律补零成两位。看着简单但坚持下来能省下大量找文件的时间。另外每次全量更新前先做一次备份。Parquet 文件不像数据库有事务保护写入过程中断了有可能把文件写坏。我现在习惯先写到临时文件成功后再替换原文件这样哪怕出错也不会污染已有数据。import os def safe_write(df, target): tmp target .tmp df.to_parquet(tmp, indexFalse) os.replace(tmp, target) # 原子替换操作os.replace在同一个文件系统内是原子操作这一步能保证任何时刻磁盘上的目标文件要么是完整的旧版本要么是完整的新版本不会出现半截文件。7. 常见问题排查速查表做了这么多年出问题基本都逃不出下面这些类型。我按阶段整理成两张表遇到故障直接对照着查。7.1 抓取阶段的典型故障现象可能原因处理办法大量请求返回 403请求头不完整或频率过高补全 User-Agent拉长间隔降低并发返回内容为空页面依赖前端脚本渲染检查渲染方式优先改用结构化数据来源突然全部超时目标站点限流或临时不可用暂停任务等一段时间后从断点续传下载的文件全是乱码编码识别错误按响应头的 charset 解码或显式指定编码部分页面内容错位页面结构改版对比新旧结构更新解析规则并重跑受影响范围这里面最常见的是403。多数情况不是被彻底拒绝了而是请求频率超过了对方面向普通访问设定的阈值。降低频率、补全请求头、间隔几小时后重试三板斧下去能解决九成问题。还有一种容易忽略的情况返回状态码是200但内容其实是一个提示页或者验证页。这种情况光看状态码发现不了得加一层内容校验——比如检查返回内容里是否包含预期的关键元素没有就当成失败处理。def is_valid(html, must_contain比赛): return must_contain in html7.2 数据阶段的典型故障现象可能原因处理办法同一年场次数明显偏少分页没遍历完检查总页数计算逻辑补下缺失页出现重复比赛多源合并时主键不一致统一队名别名重建主键场均进球异常偏高点球大战比分混入增加 result_type 字段区分日期集中在某几天解析格式错配逐格式尝试人工核对异常行关联查询结果膨胀一对多关系未去重建表时明确粒度必要时加唯一索引重复比赛这个问题特别隐蔽。文件层面看不出来只有当你按赛季汇总场次发现某个赛季的比赛数比理论值高了十几场才会意识到出问题了。养成习惯每次数据入库后先跑一遍场次数校验和重复主键检查这两个检查加起来不到十行代码却能挡住大部分数据质量问题。def sanity_check(df): dup df[match_id].duplicated().sum() print(f重复主键{dup} 条) by_season df.groupby(season).size() print(by_season)场次校验的原则是跟理论值比。一个20队双循环的联赛单赛季理论场次是380场如果你的数据里某赛季只有250场那基本可以确定缺页或者漏解析了。8. 长期维护与效率提升的实战经验前面讲的是一次完整的下载流程。但历年足球数据这件事的特点在于它不是做一次就完而是要长期维护。这一节分享几个让维护变轻松的做法。8.1 把重复劳动脚本化历史数据下载这个事第一年是新鲜第二年是习惯第三年就变成负担了。能自动化的千万别手动。我现在整套流程做成了一个脚本加一个配置# config.yaml sources: - name: source_a base_url: https://example.com/matches seasons: [2015, 2024] page_size: 20 interval: 1.5 out_dir: raw新增一个数据源只需要在配置里加几行。脚本读取配置生成URL列表跑增量下载跑校验跑清洗最后输出Parquet。整个过程一条命令搞定。python pipeline.py --config config.yaml --mode incremental--mode支持incremental只补新的和full全量重跑平时用前者赛季结束用后者做一次全量校验。这个设计让日常维护从每次都要想一遍步骤变成了敲一条命令去喝杯咖啡。8.2 数据质量监控的几条底线长期维护最怕的是数据悄悄坏掉而你几个月后才发现。我给自己定了三条监控底线每次更新后自动检查。第一场次不得低于历史均值的八成。某个赛季突然少了三成比赛大概率是下载或解析出了问题。第二日期区间要连续。把每个赛季的最小日期和最大日期列出来如果出现断档超过两个月的情况标记出来人工看。很多抓取漏页的问题通过这个检查就能暴露出来。第三关键字段的空值率不能突变。比如过去比分字段的空值率一直是0.5%某次更新后变成15%那就是解析规则出问题了。这三点检查写成脚本也就几十行可以设成定时任务每周跑一次有异常发个提醒。数据项目做久了你会发现维护成本的大头不在抓取在于及时发现数据什么时候变得不可信了。8.3 关于合规使用的一点个人体会最后说一个容易被忽略但很重要的事。抓数据之前先把目标站点的使用条款和抓取规则读一遍搞清楚哪些能做、哪些不能做。用于个人学习、教学演示和非商业研究是相对稳妥的用法如果打算公开发布或者用于商业用途务必先确认授权情况必要时直接联系数据方获取正规的数据许可。我自己现在维护的历史数据集全部来自明确允许公开获取和使用的来源并且在使用时标注了原始出处。这么做多花一点时间但省心也让整个项目能长期做下去。另外把每个数据源的授权说明和抓取日期记在一个文档里也很有必要时间久了你自己都会忘记哪份数据是从哪来的、当时是什么条款。提示如果你打算把整理好的数据分享出去务必先确认原始来源的授权是否允许再分发并把出处标注清楚避免给自己和别人带来麻烦。这套流程我跑了几年从最早的几百行脚本到现在一个配置驱动的流水线中间改过很多次。真正让我少走弯路的不是某个高深的技术点而是那些笨功夫留原始文件、记状态、做校验、按季节检查覆盖度。把这些做到位历年足球数据下载这件事就从每次都头疼变成了设好任务不用管。
返回列表