
简介FLAnalyzer 是一款基于 Node.js 的竞彩足球赛事数据采集与分析工具面向具备一定 JavaScript 基础、希望自行搭建数据管道进行赛事研究的开发者与数据分析爱好者。它可自动抓取比赛时间、联赛名称、双方队名等基本信息并采集 BET365、澳门等亚盘赔率平均欧赔、立博、伟德、SNAI 等欧盘赔率以及彩客网必发盈亏模拟数据解决手动整理多源赔率效率低、数据分散的问题。资源包共 27 个文件以 21 个 js 脚本为主体涵盖爬虫、模型、分析器、打印器与配置等模块另含 2 个 json 配置、md 说明及 jshintrc、bowerrc 等工程文件压缩包约 28KB结构清晰便于二次开发。项目通过 npm install 安装依赖默认连接本地 MongoDB执行 node spider 即可启动采集首次全量约需 12 至 18 小时后续仅增量更新。目前已有 1141 人学习适合作为 Node.js 爬虫与体育数据建模的实战参考。1. 从一堆散乱赔率到可回测数据FLAnalyzer 到底解决什么问题如果你做过竞彩足球相关的数据分析大概率经历过这个场景打开几个数据页面手动复制赔率、亚盘、大小球粘进 Excel公式拉到一半发现某场比赛的盘口时间戳对不上再回头核对半小时没了。更麻烦的是你想验证一个策略——比如“主队近六场场均进球大于 1.8 且初盘让半球”——却发现自己连一份干净的历史数据都凑不出来。FLAnalyzer 就是冲着这个痛点来的一个基于 Node.js 的竞彩足球赛事数据采集与分析程序把抓取、清洗、存储、指标计算串成一条可重复执行的流水线。它适合两类人一是想用 JavaScript 做数据侧工程、不想在 Python 和 Node 之间来回切栈的开发者二是手里有分析思路、但缺一套稳定数据底座的竞彩研究者。你不需要它来“预测比赛”你需要它来把数据这件事做扎实。2. 环境搭建与数据采集从零把第一条赛事记录拉进本地库2.1 为什么选 Node.js 而不是 Python 脚本竞彩数据采集有个特点请求密集、页面结构经常微调、需要定时任务。Node.js 的事件循环模型在处理大量并发 HTTP 请求时内存占用比同步阻塞式脚本更可控而且 npm 生态里有 cheerio 这类服务端 DOM 解析库写法和前端 jQuery 接近改选择器时心智负担小。另一个现实原因是很多做竞彩数据的人本身有前端背景用 JavaScript 能直接复用已有的工具链不用为了一个采集脚本再装一套 Python 虚拟环境。FLAnalyzer 的定位不是“算法研究平台”而是“数据管道”Node.js 在这个场景下够用且顺手。常见做法是用 axios 发请求cheerio 解析 HTMLbetter-sqlite3 做本地存储。better-sqlite3 是同步 API在采集脚本里反而比异步数据库驱动更好控制节奏不会因为并发写入把库锁死。2.2 初始化项目与依赖安装先建目录初始化 package.json装核心依赖。不要全局装任何东西全部走本地依赖方便后面打包迁移。mkdir flanalyzer cd flanalyzer npm init -y npm install axios cheerio better-sqlite3 dayjs npm install -D nodemon这里四个运行时依赖各有分工axios 负责 HTTP 请求cheerio 负责把返回的 HTML 转成可查询的 DOMbetter-sqlite3 负责本地持久化dayjs 处理比赛时间的时区和格式转换。nodemon 只在开发时用改完脚本自动重启省去手动 CtrlC 再跑一遍的重复动作。装完后检查一下 better-sqlite3 是否编译成功这个包带原生模块在某些 Node 版本上需要本地编译工具链。如果报错先确认 node-gyp 的依赖是否齐全这是第一个容易翻车的地方。2.3 采集脚本的骨架请求、解析、入库三段式采集脚本不要写成一个巨大的 main 函数按“请求层 → 解析层 → 存储层”拆开。下面是一个最小可运行的采集模块以抓取某赛事列表页为例。// collector.js const axios require(axios); const cheerio require(cheerio); const Database require(better-sqlite3); const dayjs require(dayjs); const db new Database(./matches.db); // 建表只建一次用 IF NOT EXISTS 保证幂等 db.exec( CREATE TABLE IF NOT EXISTS matches ( match_id TEXT PRIMARY KEY, league TEXT, home_team TEXT, away_team TEXT, match_time TEXT, home_odds REAL, draw_odds REAL, away_odds REAL, created_at TEXT DEFAULT (datetime(now, localtime)) ) ); // 请求层统一 UA 和超时避免被当成异常流量 async function fetchPage(url) { const res await axios.get(url, { timeout: 10000, headers: { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } }); return res.data; } // 解析层选择器集中管理页面改版只改这里 function parseMatchList(html) { const $ cheerio.load(html); const rows []; $(table.match-list tbody tr).each((i, el) { const tds $(el).find(td); rows.push({ match_id: $(el).attr(data-matchid) || auto_${i}, league: $(tds[0]).text().trim(), home_team: $(tds[1]).text().trim(), away_team: $(tds[2]).text().trim(), match_time: dayjs($(tds[3]).text().trim()).format(YYYY-MM-DD HH:mm:ss), home_odds: parseFloat($(tds[4]).text()) || null, draw_odds: parseFloat($(tds[5]).text()) || null, away_odds: parseFloat($(tds[6]).text()) || null }); }); return rows; } // 存储层用事务批量写入比逐条 insert 快一个数量级 const insertStmt db.prepare( INSERT OR REPLACE INTO matches (match_id, league, home_team, away_team, match_time, home_odds, draw_odds, away_odds) VALUES (match_id, league, home_team, away_team, match_time, home_odds, draw_odds, away_odds) ); const insertMany db.transaction((rows) { for (const row of rows) insertStmt.run(row); }); async function run(url) { const html await fetchPage(url); const rows parseMatchList(html); insertMany(rows); console.log(写入 ${rows.length} 条赛事记录); } run(https://example.com/matches?date2025-01-01).catch(console.error);这段代码的逻辑说明fetchPage把请求参数收拢在一处超时设 10 秒是经验值太短容易误杀慢响应太长会拖住整个采集队列。parseMatchList里所有选择器写在一起页面结构一变你只需要改这个函数不用满文件找$(td)。INSERT OR REPLACE配合match_id主键保证同一条比赛重复采集时是覆盖而不是产生脏数据。db.transaction把批量插入包成事务实测一千条记录从逐条写入的几秒降到几十毫秒。参数方面match_id优先用页面上的真实 ID取不到才用索引兜底否则重复采集会失控。赔率字段用parseFloat加|| null遇到空字符串或“-”时存 null 而不是 NaN后面做统计时少一类脏数据。2.4 定时采集与增量策略一次性采集谁都会写难的是每天自动跑且不重复拉全量。常见做法是用 node-cron 挂定时任务每次只拉当天和未来两天的赛程历史数据靠首次全量补齐。const cron require(node-cron); // 每天 08:00 和 20:00 各跑一次覆盖赛程更新 cron.schedule(0 8,20 * * *, async () { const today dayjs().format(YYYY-MM-DD); await run(https://example.com/matches?date${today}); console.log([${dayjs().format(HH:mm:ss)}] 增量采集完成); });增量策略的核心是“按日期切片”不要每次拉全量。全量只在初始化时跑一次之后每天补当天数据。如果你发现某天数据缺失手动补跑那一天的 URL 即可不需要动整个库。3. 数据清洗与指标计算把原始赔率变成能回测的字段3.1 清洗规则哪些记录必须丢掉采集回来的数据不能直接用。我一般会过四道清洗规则比赛时间早于当前时间且没有比分的一律标记为过期主客队名称为空或长度小于 2 的丢弃三项赔率全部为 null 的丢弃赔率小于 1.01 的视为异常值置 null。这些规则写在独立的 clean.js 里和采集脚本解耦方便单独重跑。// clean.js const Database require(better-sqlite3); const db new Database(./matches.db); // 标记过期比赛 db.prepare( UPDATE matches SET league league || _expired WHERE match_time datetime(now, localtime) AND home_odds IS NULL ).run(); // 删除无效记录 db.prepare( DELETE FROM matches WHERE home_team OR away_team OR (home_odds IS NULL AND draw_odds IS NULL AND away_odds IS NULL) ).run(); // 异常赔率置空 db.prepare( UPDATE matches SET home_odds NULL WHERE home_odds 1.01 ).run();清洗顺序有讲究先标记过期再删无效最后处理异常值。反过来做的话过期标记可能把刚删掉的记录又算进去。清洗脚本可以重复执行每条语句都是幂等的。3.2 衍生指标隐含概率与凯利值原始赔率本身信息量有限真正有用的是从赔率反推的隐含概率。公式是隐含概率 1 / 赔率三项归一化后得到市场对胜平负的定价。再进一步可以算凯利值用来衡量赔率中是否包含正期望。// metrics.js const Database require(better-sqlite3); const db new Database(./matches.db); db.exec( CREATE TABLE IF NOT EXISTS match_metrics ( match_id TEXT PRIMARY KEY, home_prob REAL, draw_prob REAL, away_prob REAL, overround REAL, kelly_home REAL ) ); const rows db.prepare( SELECT match_id, home_odds, draw_odds, away_odds FROM matches WHERE home_odds IS NOT NULL AND draw_odds IS NOT NULL AND away_odds IS NOT NULL ).all(); const insert db.prepare( INSERT OR REPLACE INTO match_metrics (match_id, home_prob, draw_prob, away_prob, overround, kelly_home) VALUES (?, ?, ?, ?, ?, ?) ); const tx db.transaction((list) { for (const r of list) { const hp 1 / r.home_odds; const dp 1 / r.draw_odds; const ap 1 / r.away_odds; const sum hp dp ap; // 归一化隐含概率 const nh hp / sum, nd dp / sum, na ap / sum; // 返还率 1 / sumoverround 是庄家水位 const overround sum - 1; // 凯利值假设真实概率用归一化值近似 const kelly (nh * r.home_odds - 1) / (r.home_odds - 1); insert.run(r.match_id, nh, nd, na, overround, kelly); } }); tx(rows); console.log(计算 ${rows.length} 条指标);参数说明overround反映的是三项赔率隐含概率之和减一数值越大说明庄家抽水越重一般联赛在 0.05 到 0.12 之间。凯利值为正表示按归一化概率估算存在理论正期望但注意这只是基于市场赔率的反推不是真实概率别把它当成下注信号。这一步的产出是match_metrics表后面回测直接查这张表。3.3 用 SQL 做快速筛选与分组统计数据落到 SQLite 之后很多分析不需要写 JavaScript直接 SQL 就能出结果。比如按联赛统计平均返还率或者筛出凯利值大于 0.05 的比赛。-- 各联赛平均返还率与场次 SELECT league, COUNT(*) AS cnt, ROUND(AVG(overround), 4) AS avg_overround FROM matches m JOIN match_metrics mm ON m.match_id mm.match_id GROUP BY league HAVING cnt 20 ORDER BY avg_overround ASC; -- 凯利值偏高的比赛 SELECT m.match_id, m.home_team, m.away_team, mm.kelly_home FROM matches m JOIN match_metrics mm ON m.match_id mm.match_id WHERE mm.kelly_home 0.05 ORDER BY mm.kelly_home DESC LIMIT 50;第一条查询帮你判断哪些联赛的定价效率更高返还率越低说明抽水越少理论上更适合做数据分析。第二条是常见的筛选模板把阈值 0.05 改成你想要的数就行。SQL 的好处是改条件不用动代码直接在数据库客户端里跑。4. 避坑与排查采集分析里最容易翻车的五个地方4.1 页面结构一变选择器全部失效现象脚本昨天还能跑今天返回 0 条记录控制台不报错。原因目标页面调整了表格的 class 名或 DOM 层级cheerio 选择器匹配不到。解决把选择器集中在一个配置文件里每次采集后打印rows.length如果为 0 立即告警。更稳的做法是加一层“结构校验”比如检查页面里是否存在预期的表头文字不存在就跳过解析并记录日志。4.2 时区没统一比赛时间对不上现象采集到的比赛时间和实际开赛时间差 8 小时回测时把已结束的比赛当成未来场次。原因页面返回的是 UTC 时间或带时区偏移的字符串dayjs 默认按本地时区解析。解决所有时间入库前统一转成YYYY-MM-DD HH:mm:ss格式的本地时间解析时显式指定时区。如果页面给的是时间戳直接用dayjs(ts)并确认单位是秒还是毫秒。4.3 重复采集导致数据膨胀现象同一场比赛在库里出现多条记录统计时场次翻倍。原因match_id用了自增或索引兜底每次采集生成新 ID。解决优先用页面上的稳定标识作为主键实在没有就用“联赛主队客队比赛时间”拼一个哈希值。入库语句用INSERT OR REPLACE保证幂等。4.4 赔率字段混入非数值字符现象parseFloat返回 NaN写入数据库后统计结果全是 NaN。原因页面赔率列可能包含“-”“封”“未开售”等文字。解决解析时先做正则提取数字提取不到就置 null。入库前加一道校验isNaN的字段统一转 null不要让它进库。4.5 并发请求过高被限流现象采集几十条后请求全部超时或返回 403。原因短时间内并发请求过多触发目标站点的频率限制。解决用 p-limit 这类库控制并发数一般设 3 到 5 比较稳妥每个请求之间加 200 到 500 毫秒延迟。不要为了快把并发拉到几十采集是长跑不是冲刺。5. 进阶技巧把分析结果导出成可复用的 JSON 与定时校验5.1 导出结构化 JSON 供前端或其他工具消费SQLite 适合存储和查询但如果你想把分析结果喂给前端页面或者别的脚本导出 JSON 更通用。下面这个脚本把指定日期的比赛和指标合并导出。// export.js const Database require(better-sqlite3); const fs require(fs); const db new Database(./matches.db); const date process.argv[2] || new Date().toISOString().slice(0, 10); const rows db.prepare( SELECT m.match_id, m.league, m.home_team, m.away_team, m.match_time, m.home_odds, m.draw_odds, m.away_odds, mm.home_prob, mm.draw_prob, mm.away_prob, mm.overround, mm.kelly_home FROM matches m LEFT JOIN match_metrics mm ON m.match_id mm.match_id WHERE date(m.match_time) ? ORDER BY m.match_time ).all(date); fs.writeFileSync(./export_${date}.json, JSON.stringify(rows, null, 2)); console.log(导出 ${rows.length} 条到 export_${date}.json);用法是node export.js 2025-01-01不传日期就导出当天。导出后用任何 JSON 工具都能查看也方便丢给前端做可视化。注意LEFT JOIN保证没有指标的记录也会导出不会丢数据。5.2 加一道定时校验防止数据静默缺失采集脚本最怕的不是报错而是不报错但没数据。我习惯在每天采集完成后跑一个校验脚本检查当天记录数是否在合理区间。// validate.js const Database require(better-sqlite3); const db new Database(./matches.db); const dayjs require(dayjs); const today dayjs().format(YYYY-MM-DD); const { cnt } db.prepare( SELECT COUNT(*) AS cnt FROM matches WHERE date(match_time) ? ).get(today); // 正常比赛日一般有几十到上百场低于 10 场就要查 if (cnt 10) { console.error([告警] ${today} 仅采集到 ${cnt} 条记录请检查采集脚本); process.exit(1); } console.log([校验通过] ${today} 共 ${cnt} 条记录);这个校验脚本可以挂在 cron 里采集完紧接着跑。process.exit(1)让它在 CI 或任务调度里能被识别为失败触发告警。阈值 10 是经验值你可以按自己关注的联赛范围调整。5.3 一个参数表常用配置项与建议值配置项建议值说明请求超时10000ms低于 5s 易误杀高于 15s 拖慢队列并发数3~5再高容易触发限流请求间隔200~500ms配合并发数控制总频率赔率下限1.01低于此值视为异常凯利阈值0.05筛选正期望的常用起点校验最低场次10低于此值触发告警这张表是我自己跑下来比较稳的区间不是绝对标准。比如你采集的联赛场次本来就少校验阈值要相应调低否则天天误报。从那以后我每次改完采集脚本都会先跑一天的数据做对比确认记录数和字段完整度没有异常再挂到定时任务上。数据管道这东西出问题往往不是轰轰烈烈地报错而是悄无声息地少了几百条。希望帮到你。本文还有配套的精品资源点击获取