
简介FLAnalyzer 是一款基于 Node.js 的竞彩足球赛事数据采集与分析程序面向足球竞彩数据分析师、Node.js 开发者以及体育数据爱好者用于解决赛事基础信息、多家亚盘/欧盘赔率及必发盈亏数据的自动获取、入库与后续分析问题。项目依赖 MongoDB 存储可通过配置文件切换连接地址执行 node spider 后首次全量采集约需 1218 小时后续仅增量更新效率较高。爬取范围涵盖比赛时间、联赛名称、双方队名以及 BET365、澳门、立博、伟德、SNAI 等主流机构的亚盘/欧盘赔率和彩客网必发盈亏模拟数据。压缩包共 27 个文件、28KB以 21 个 JavaScript 源码文件为主体配合 2 个 JSON 配置、Markdown 说明及若干工程配置文件覆盖爬虫、模型、分析器、页面查看等模块结构小巧清晰。目前已有 1136 人学习/下载适合想快速上手 Node.js 爬虫、了解体育数据采集与分析流程的读者参考其目录组织与增量抓取思路可省去从零搭建数据管道的大量时间。1. 做这个项目的起因手动看盘真的太浪费时间了大概从2023年下半年开始我一直在做竞彩足球赛事的赛前数据研究。说实话这个过程一开始特别崩溃每天要看的比赛有几十场每场比赛要查的东西又多又碎——主客队近期战绩、积分榜排名、主力伤停情况、历史交锋记录、欧赔亚盘的变化趋势……光是整理一场比赛的数据熟练的话也要20分钟遇到数据源不稳定的情况半小时都打不住。我试过一些现成的数据平台有的要付费有的接口返回的字段非常零散还有的干脆就是网页表格复制粘贴下来格式全乱。真正让我决定自己动手的是那次——我手动统计了某联赛连续7轮的比赛发现自己在Excel里折腾了整整一个下午最后还因为一个单元格的公式引用错了导致整列数据全部作废。那时候我就想能不能用Node.js写一个程序把这些采集、清洗、分析的工作全部自动化掉。这就是FLAnalyzer的由来一个跑在本地或者服务器上的Node.js程序定时去抓取竞彩足球赛事的公开数据清洗之后落库再通过一套内置的分析模型输出每场比赛的参考指标最后生成一份结构化的分析报告。全程不需要手动干预每天定时跑一遍就行了。如果你跟我一样平时会花不少时间研究足球赛事数据或者你在做类似的数据采集和分析项目想看一个完整的、从采集到分析再到落地的Node.js实现方案那这篇文章应该对你有用。我会把整个项目的设计思路、关键模块的代码逻辑、踩过的坑以及最终效果都摊开来讲。2. 整体架构设计模块化拆分让程序能跑起来也看得懂FLAnalyzer不是一上来就写代码的。我先把整个流程在纸上画了一遍最后拆成了五个模块采集模块、清洗模块、存储模块、分析模块、报告输出模块。每个模块只干一件事模块之间通过明确的数据结构通信这样后期改某一个模块的时候不会牵连到其他部分。2.1 数据流向从抓取到报告的完整链路整个程序的数据流是这样的定时任务触发采集器根据预设的比赛日期范围去抓取赛事列表拿到赛事列表之后逐场比赛抓取详细信息比分、红黄牌、射门数、控球率等原始数据进入清洗层做字段映射、缺失值处理、格式统一清洗后的数据写入SQLite本地数据库分析模块读取数据库中的历史数据和当前数据跑模型计算生成报告推送到本地文件也可以扩展成推送到钉钉/飞书/企业微信机器人。为什么用SQLite而不是MySQL或者PostgreSQL因为这个项目主要跑在我自己的电脑或者一台小服务器上没有高并发的写入需求数据量也就是几个赛季的比赛记录SQLite零配置文件、单文件存储、随开随用完全够用。如果你要部署到服务器上多人使用到时候再迁移到PostgreSQL也不难因为我在数据访问层做了隔离换数据库只需要改连接配置。2.2 目录结构代码组织方式项目目录结构大致是这样的flanalyzer/ ├── src/ │ ├── collector/ # 采集器 │ │ ├── match-list.js │ │ ├── match-detail.js │ │ └── scheduler.js │ ├── cleaner/ # 清洗 │ │ └──>async function fetchWithRetry(url, options {}, retries 3) { for (let i 0; i retries; i) { try { const response await axios.get(url, { headers: { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: application/json, text/html, */* }, timeout: 10000, ...options }); return response.data; } catch (err) { if (err.response err.response.status 400 err.response.status 500) { // 4xx错误不需要重试直接抛出 throw err; } const waitTime Math.pow(2, i) * 1000 Math.random() * 1000; console.log(请求失败${waitTime}ms后重试...); await sleep(waitTime); } } throw new Error(请求失败已重试${retries}次); }3.3 定时调度用node-cron实现每天自动跑任务采集不能只跑一次而是要每天自动执行。我用了node-cron这个库来管理定时任务。它在Node.js生态里非常成熟语法跟Linux的crontab规则一致学习成本几乎为零。我的调度逻辑是这样设计的const cron require(node-cron); // 每天早上8点采集前一天所有比赛结果 cron.schedule(0 8 * * *, async () { await collectMatches({ days: 1 }); await analyzeAndReport(); }); // 每天下午3点采集当天和未来两天的赛程 cron.schedule(0 15 * * *, async () { await collectUpcomingMatches(); });为什么要设置两个不同的时间点因为前一天的所有比赛通常要到第二天早上才能拿到完整的赛后统计而当天和未来几天的赛程下午采集会比较全因为各个平台的数据更新需要时间。分开调度保证拿到的数据是最完整的。4. 清洗与存储数据质量决定分析结果的可靠度如果说采集是把菜买回来那清洗就是把菜择好洗好。很多做数据分析的人容易忽略这个环节觉得数据能从网页里抓下来就不错了。但实际上脏数据对分析结果的影响是致命的——一个字段映射错了后面所有计算结果全偏。4.1 字段标准化不同数据源的数据如何对齐不同数据源对同一个概念的描述是不一样的。比如控球率有的平台存的是字符串57.4%有的平台存的是数字57.4还有的存的是整数57再比如球队名称同一个球队在不同平台可能有不同写法有的叫曼城有的写全称Manchester City。所以我在清洗层做了一件事定义一套内部标准的数据结构然后把所有数据源的原始数据都映射到这套结构上。例如控球率统一转成浮点数保留一位小数球队名称统一转成中文简称比赛时间统一转成ISO格式的字符串。这一步做起来很枯燥但必须做。我用了一个简单的映射函数来做这件事function cleanMatchData(rawData) { return { matchId: rawData.match_id || rawData.id, league: cleanLeagueName(rawData.tournament || rawData.competition), homeTeam: cleanTeamName(rawData.home_team || rawData.home), awayTeam: cleanTeamName(rawData.away_team || rawData.away), matchTime: new Date(rawData.match_time || rawData.time).toISOString(), homeScore: parseInt(rawData.home_score, 10) || 0, awayScore: parseInt(rawData.away_score, 10) || 0, possession: parseFloat(rawData.possession_home || 0), shots: parseInt(rawData.shots_home, 10) || 0, shotsOnTarget: parseInt(rawData.shots_target_home, 10) || 0, corners: parseInt(rawData.corners_home, 10) || 0, fouls: parseInt(rawData.fouls_home, 10) || 0 }; }4.2 SQLite建表设计与批量写入数据库表的设计上我没有搞太复杂的范式就是按比赛维度建了一张主表然后每个球队的赛季数据单独建了几张辅助表。主表的核心字段包括比赛ID、联赛、主队、客队、比赛时间、比分、半场比分、控球率、射门数、射正数、角球数、犯规数。写入策略上小批量的数据单条插入没问题但如果一次性要写入整个赛季几百场比赛的记录就必须用批量插入。我用了better-sqlite3这个库它支持同步操作性能比异步的sqlite3好不少而且API更简洁。批量插入的代码大概是这样的const db require(better-sqlite3)(data/flanalyzer.db); const insertStmt db.prepare( INSERT OR REPLACE INTO matches (match_id, league, home_team, away_team, match_time, home_score, away_score, possession, shots, corners) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ); const insertMany db.transaction((matches) { for (const m of matches) { insertStmt.run(m.matchId, m.league, m.homeTeam, m.awayTeam, m.matchTime, m.homeScore, m.awayScore, m.possession, m.shots, m.corners); } }); insertMany(cleanedMatches);这里有一个小细节INSERT OR REPLACE的作用是如果同一场比赛ID已经存在就直接覆盖不做重复插入。这样就避免了重复跑采集任务时产生脏数据。5. 分析引擎从积分、近期状态到胜率加权评估分析模块是整个程序的大脑。我要先说明一点足球比赛的影响因素极其复杂没有任何模型能做到精准预测。我能做的是把结构性数据球队实力、近期状态、主客场差异、历史交锋量化成可比较的指标给每一场比赛输出一个参考倾向而不是所谓的稳胆推荐。我用这个程序实际参考了一段时间整体胜率比瞎猜高不少但距离稳赚还差得远——可能也永远到不了。这就是现实。5.1 球队状态评分模型状态评分模型的目标是量化一支球队最近的表现。我的做法是取球队最近10场比赛按比赛时间做加权平均——越近的比赛权重越高因为近期状态比远期状态对下一场表现的影响更大。function calculateForm(teamMatches, recentGames 10) { const window teamMatches.slice(0, recentGames); let totalWeight 0; let weightedScore 0; window.forEach((match, index) { const weight recentGames - index; // 越近权重越大 const score match.result win ? 3 : (match.result draw ? 1 : 0); weightedScore score * weight; totalWeight weight; }); return weightedScore / totalWeight; }这个函数输出的是一个0~3之间的数值3分制的加权平均分。实际跑下来之后这个分数能比较稳定地区分强队、中游队和弱队。不过如果你是刚接触这类项目想先跑通流程可以先用简单一点的等权平均后面再慢慢优化成加权版本。5.2 主客场因素修正足球比赛的主场优势是客观存在的。同一支球队主场和客场的表现差异非常大。所以我的分析模型不能只看球队总体的状态分还要拆开看主客场分开的状态分。具体做法是从历史数据里分别提取这支球队最近10个主场比赛的得分率和最近10个客场比赛的得分率然后跟联赛平均水平做对比计算出一个主场系数和客场系数。function calculateHomeAdvantage(teamHomeMatches, leagueAvgPoints) { const homePointsPerGame teamHomeMatches.reduce((sum, m) sum m.points, 0) / teamHomeMatches.length; return homePointsPerGame / leagueAvgPoints; }这个系数的含义很直观如果大于1说明该队主场表现高于联赛平均水平如果小于1说明主场表现低于平均。然后用这个系数去修正状态评分就得到了考虑主客场因素后的综合评分。5.3 让球盘口的概念与处理方式这里我要单独拿出来讲一下因为很多想研究竞彩的人对这个概念比较模糊。竞彩足球的胜负玩法里有一种叫让球的机制。举个例子如果A队和B队实力差距比较大那么系统会给强队一个让球数比如A队让一球显示为-1。这时候你买A队赢A队必须净胜2球或以上你才算赢如果A队只赢1球那算走盘退款/或按具体规则走盘如果A队平或负买A队就输了。我的程序把让球当作一种实力差的量化表达来处理。具体逻辑是从数据源读取每场比赛的让球数根据让球数反推市场对两队实力差的判断把让球数作为输入特征之一传入评估模型模型综合考虑两队的状态评分、主客场修正、历史交锋之后输出一个净胜球概率分布。举例来说A队对B队让球数是-1。程序先计算A队的主场状态修正分为2.1B队的客场状态修正分为1.3再加上历史交锋里A队场均净胜0.5球那么模型预测A队本场比赛的净胜球期望值大约是0.8球左右——注意这个低于让球数1所以从让球盘口的角度看主队让一球并不算稳。5.4 胜平负概率输出综合分析之后程序会输出这样一个表格球队状态分主客场修正综合评分预测胜率主队 (A队)2.11.152.4252%客队 (B队)1.30.921.2027%平局---21%这个概率分布是我最终用来做决策判断的核心依据只在我认为综合评分差距足够大比如主队综合评分比客队高0.8以上的时候才值得进一步往下研究否则就跳过。这里必须强调模型的输出只能作为参考维度之一。伤病、战意、赛程密集度、天气、裁判尺度这些因素公开数据里不一定能拿到所以程序给出的概率只是基于可获取数据的一个估计。我在实际使用中通常还会结合球队新闻做二次人工筛选。6. 生成分析报告每天早晨推送一份昨日复盘 今日赛程分析如果你跑一个分析程序每次都要打开命令行看输出那用不了几天就懒得用了。我做了报告生成模块把每天的采集和分析结果汇总成一份Markdown格式的报告保存到output/目录下标题格式是report-2024-XX-XX.md。6.1 报告的核心板块设计报告分成三个板块昨日复盘板块列出前一天所有已结束比赛的实际比分、程序预测概率与比赛结果的关系。这部分的用途是持续验证模型的准确率。我统计了一段时间的整体命中率大概在55%~65%之间波动——注意这个数值仅限于赛果倾向判断胜平负不含让球盘口。如果连续一周模型整体命中率低于50%我就会检查是不是数据源的质量出了问题或者联赛进入赛季末段主力轮换导致历史数据失真。今日赛程板块列出当天所有场次的基本信息、让球数、模型预测的胜平负概率以及一个推荐关注度的星标字段从0到5。推荐关注度不是预测结果而是综合了数据完整度、双方状态差、盘口合理性之后的研究优先级信号。异常数据提示板块如果某场比赛的采集数据缺失或者明显不合理比如客队近期成绩全部缺失程序会在报告里标记出来提醒你这场比赛的数据质量不足以支持结论。6.2 用Node.js模板字符串生成Markdown报告生成没有用复杂的模板引擎就是Node.js自带的模板字符串拼接。因为Markdown本身就是纯文本用模板字符串完全够用还少了一个依赖。代码如下function generateReport(summary, matches) { let report # 竞彩足球赛事分析报告\n\n; report 生成时间${new Date().toLocaleString()}\n\n; report ## 昨日战绩概览\n\n; report - 总场次${summary.totalMatches}\n; report - 预测命中场次${summary.hitMatches}\n; report - 命中率${(summary.hitRate * 100).toFixed(1)}%\n\n; report ## 今日赛事分析\n\n; matches.forEach(m { report ### ${m.homeTeam} vs ${m.awayTeam}\n\n; report - 综合评分${m.homeScore} - ${m.awayScore}\n; report - 预测概率胜 ${m.homeWinPct}% / 平 ${m.drawPct}% / 负 ${m.awayWinPct}%\n; report - 让球数${m.handicap}\n; report - 推荐关注度${★.repeat(m.attention)}${☆.repeat(5 - m.attention)}\n\n; }); return report; }这个报告我一般放在一个同步盘目录里手机上也能直接看。你如果用的是服务器也可以扩展一个推送逻辑把报告内容POST到企业微信机器人群或者钉钉群每天早上自动推送到群里省去开电脑的步骤。7. 部署与工程化从本地跑通到服务器定时运行程序在本机跑通之后我把它部署到了一台云服务器上。这一步虽然不难但有几个坑值得记录一下。7.1 Node.js环境版本选择与切换FLAnalyzer的依赖项axios、cheerio、better-sqlite3、node-cron对Node.js的版本没有特别苛刻的要求我建议直接使用当前LTS版本即可。网上很多教程一上来就让你装最新版其实对于这种小项目LTS版本足够稳定而且遇到问题的时候社区资料最全。我遇到过的一个典型坑是服务器上默认的Node.js版本太旧14.x装better-sqlite3会直接编译报错。这种情况不用卸载重装Node直接用nvmNode Version Manager切换版本就行。切版本的方式非常简单# 安装指定版本 nvm install 20.11.0 # 使用指定版本 nvm use 20.11.0 # 设为默认版本 nvm alias default 20.11.0切换完成之后记得重新跑一下npm install因为better-sqlite3这种原生模块需要针对当前的Node ABI重新编译如果直接沿用旧版本的node_modules启动时会报was compiled against a different Node.js version之类的错误。7.2 用PM2守护进程服务器上跑Node.js程序最忌讳前台运行——SSH一断开程序就没了。我用的是pm2这个进程管理工具它能做到程序崩溃自动重启开机自动启动日志集中管理用pm2 logs查看内存占用超限自动重启。启动命令很简单pm2 start app.js --name flanalyzer pm2 save pm2 startup # 按照提示执行输出命令即可如果程序对内存比较敏感还可以在启动时加--max-memory-restart 300M这样内存超过300MB会自动重启避免长时间跑下来内存泄漏导致服务器卡死。7.3 日志规范console.log不是随便用的项目跑起来之后日志是排查问题的主要手段。我的日志规范是采集任务开始时打一条[collector] 开始采集...结束后打一条[collector] 采集完成共获取N场比赛请求失败时打[collector] 请求失败URL, 错误信息分析模块每次运行输出一个简短的摘要。配合PM2的日志文件出问题的时候基本一眼就能定位。如果后续程序规模变大可以引入winston或pino做分级日志但对于目前这个体量的工具来说合理使用console.log已经足够了。8. 实测效果与后续迭代方向经过一段时间的运行FLAnalyzer的表现是让我满意的。它在数据完整时能输出比较稳定的分析结果减少了大量机械性的数据整理时间。最终我做出来的参考关注度指标和实际比赛结果的契合度明显高于我手动筛选的时机这是我坚持使用下来的核心动力。8.1 这个项目还能往哪里扩展如果你也想做一个类似的工具我建议在FLAnalyzer基础上做这几个方向的扩展加入赔率变化追踪。目前程序采集的是赛前让球数和胜平负概率但不追踪赔率随时间的变化。事实上赔率的走势比如某队赔率持续走低反映的是市场资金流向对比赛判断有很强的参考价值。这个功能只需要加一个定时任务每2小时抓一次赔率快照存到数据库里然后写一个分析脚本算变化曲线。接入更多维度的数据源。伤病信息、裁判数据、天气数据、球员个人状态数据这些都能让分析模型更接近真实比赛情况。目前我受限于数据源还没有把这些维度接进去。如果你有稳定的数据源加进去的逻辑并不复杂。可视化展示。目前报告是Markdown文本。如果你有更多产品化的想法可以做一个简单的Web前端比如用ECharts画状态走势图、对比雷达图把SQLite的数据通过一个Express接口暴露出去这样就从命令行工具变成了可视化分析平台。8.2 个人心得数据分析工具的定位要摆正最后说一点我自己的体会这种工具的价值在于把不确定性可视化而不是真的去预测比赛。它帮你节省时间、帮你建立一套相对稳定的信息处理框架但足球比赛终究有太多变量。使用任何类似工具时都应该把它当作信息整理的辅助手段而不是盲目跟随它做投注决策。我的原则是只把模型输出当成参考指标之一最终的判断还要结合最新的球队新闻、阵容信息和理性分析来做。如果你也在做类似的数据采集和分析项目或者觉得FLAnalyzer的思路对你有启发完全可以按着这篇文章里的架构和代码逻辑自己搭一套。从采集、清洗、存储到分析、报告整个链路并不复杂真正花时间的地方在于数据源的选择和清洗规则的打磨——这一块跑通了后面的分析模块就是水到渠成的事。本文还有配套的精品资源点击获取