Scout Bowie是一个面向 Sleeper 平台的客户端选秀室与阵容优化器。它的核心价值在于,把 fantasy football 中最容易出错的选秀决策和每周首发决策,变成可以本地计算、可复现、可校验的数据问题。整个项目不依赖自建后端,数据来自 Sleeper 公开接口,计算全部在浏览器端完成,选秀过程、推荐依据、阵容结果都能导出检查。
这篇文章会围绕这个主题,从问题拆解开始,逐步完成项目结构、数据层、选秀室算法、阵容优化器、本地验证和静态部署。实践中会给出 TypeScript 代码、状态结构、评分参数和排错清单。读者如果有 Vue 或 React 基础,可以直接按这里的思路在自己的项目里复现一套类似的纯客户端 fantasy 工具。即使不玩 Sleeper,这套“外部数据源 + 客户端状态机 + 约束求解 + 本地存储”的结构,也适合迁移到预测竞猜、排班推荐、资源分配等常见场景。
1. 先理解选秀室和阵容优化器到底解决什么问题
1.1 从 mock draft 到赛季阵容,问题链很长
fantasy football 用户通常要经历两个最重要的决策阶段。
第一个阶段是选秀。联盟成员按蛇形顺序轮流挑选球员,选秀结果决定整个赛季的阵容基础。选秀过程中最常出现的问题是:轮到自己选秀时,不知道当前还有哪些球员可以选;不知道自己的阵容还缺哪个位置;不停刷新外部 adp 榜单又容易漏掉更好的选择。Scout Bowie 要处理的就是这个场景:给定当前联盟、当前选秀轮次、已经完成的 pick 列表,推荐一支最优球员。
第二个阶段是每周阵容。正式赛季开始后,每周需要从自己阵容里选出固定数量的首发球员,剩下进入替补。阵容槽位通常有 QB、RB、WR、TE、FLEX、K、DST 等限制。问题是:球员 A 的赛季总分比球员 B 高,但本周对手更强;球员 C 位置更稳,但天花板低。阵容优化器要解决的是如何根据可用数据和约束条件,给出“谁首发、谁替补”的推荐。
这两件看起来不同的事,底层其实是同一个问题:在有限资源下,按约束条件选出得分期望最高的组合。
1.2 为什么强调 client-side,而不是做一个后端服务
传统做法是把选秀状态、球员数据、推荐结果都放在服务器端维护。服务器可以做更复杂的调度、使用更完整的数据库、给所有用户统一推送选秀结果。但它的成本也明显:需要维护服务、需要处理并发、需要存储用户数据,还要面对资源限制和计费问题。
Scout Bowie 选择“client-side”,实际是一种架构取舍:
- 使用 Sleeper 公开 JSON 接口只读拉取球员和联盟数据,计算在浏览器本地完成。
- 选秀进度、阵容优化结果保存在浏览器本地存储中,用户可以导出为文件。
- 不采集用户账户密码,不存储其他成员敏感数据,没有后端日志。
- 部署产物是纯静态文件,可以放到任何静态托管平台。
这样做牺牲了一部分实时多人协作能力,但换来了更简单的部署和更透明的逻辑。对于学习项目、内部联盟工具、个人选秀辅助工具来说,这个取舍通常值得。
1.3 客户端选秀工具的整体数据链路
无论界面如何设计,核心链路是一致的:
Sleeper API | v 数据适配层(fetch / 静态快照 → 统一 Player 类型) | v 前端状态(当前 pick、轮次、阵容槽位、已选球员) | v 价值模型(ADP 折算、位置需求、近期表现) | v 推荐引擎 / 优化器(筛选、排序、约束填充) | v UI 展示 + 本地存储 + JSON 导出这条链路中,Sleeper API 是外部数据源,但用户并不需要自己搭建数据中台。客户端应用通过 fetch 直接请求公开端点和浏览器本地计算,就能完成从数据到决策的闭环。后面几章会逐个环节落地。
2. 数据准备与项目结构:先用 Sleeper API 拿到可计算的数据
2.1 先确认 Sleeper 公开接口能提供哪些字段
Sleeper 提供公开只读的 JSON 接口,不需要 API key。在 Scout Bowie 中主要使用这几个端点:
| 用途 | 接口路径 | 返回内容 |
|---|---|---|
| 获取全量 NFL 球员 | /v1/players/nfl | 以 player_id 为 key 的球员字典 |
| 获取联盟信息 | /v1/league/{league_id} | 联盟名称、人数、设置 |
| 获取联盟选秀 | /v1/league/{league_id}/drafts | 联盟下所有选秀 ID |
| 获取已发生的选秀结果 | /v1/draft/{draft_id}/picks | 每个 pick 的轮次、选秀人、球员 |
| 获取联盟阵容 | /v1/league/{league_id}/rosters | 各玩家的球队 roster |
在players/nfl返回中,一个球员对象通常包含以下字段:player_id、full_name、team、position、fantasy_positions、adp、years_exp、active、status、search_full_name。这里position指球员实际打的位置,fantasy_positions指在 fantasy 平台允许放入的槽位,二者需要区分。
需要注意,公开接口在正式项目里可能面临请求频率限制。如果开发环境中大规模连续请求,可能收到 HTTP 420。因此数据层不要每次都直接拉全量球员接口,而是考虑引入本地快照。
2.2 用 Vite + Vue 3 + TypeScript 搭出客户端骨架
下面步骤假定使用 Node.js 18 或更高版本。创建一个 Vue 3 + TypeScript 项目,并加入 Pinia 做状态管理。
npm create vite@latest scout-bowie -- --template vue-ts cd scout-bowie npm install npm install pinia创建后的目录结构按功能划分如下:
scout-bowie/ ├── index.html ├── package.json ├── tsconfig.json ├── vite.config.ts ├── public/ │ └── players.nfl.json └── src/ ├── main.ts ├── App.vue ├── types/ │ └── sleeper.ts ├── services/ │ ├── sleeperApi.ts │ └── storage.ts ├── store/ │ ├── draftStore.ts │ └── lineupStore.ts ├── algorithms/ │ ├── value.ts │ ├── mockDraft.ts │ └── lineupOptimizer.ts └── views/ ├── DraftRoomView.vue └── LineupView.vuetypes目录放数据结构,services放数据请求和本地存储,algorithms放推荐和优化逻辑,views放两个主要页面。这样的分层可以保证算法和 UI 解耦,后续替换数据源或换用别的前端框架,核心逻辑可以保留。
2.3 定义球员、选秀、阵容的统一类型
要处理 fantasy 数据,第一步是建立足够的类型约束。下面是一个最小集合:
// src/types/sleeper.ts export interface SleeperPlayer { player_id: string; full_name?: string; team?: string; position?: string; fantasy_positions?: string[]; adp?: number; years_exp?: number; active?: boolean; status?: string; } export interface DraftPick { pick: number; round: number; player_id: string; member_id: string; timestamp?: string; } export interface RosterSlot { position: 'QB' | 'RB' | 'WR' | 'TE' | 'FLEX' | 'K' | 'DST' | 'BN'; qty: number; } export interface PlayerWithProjection extends SleeperPlayer { projectedPoints: number; }使用SleeperPlayer作为数据入口,使用RosterSlot表达阵容槽位,使用PlayerWithProjection给优化器增加预测分。注意:projectedPoints可以来自用户手动维护的 CSV,也可以后续接入每周比赛数据,初始阶段不应该硬编码成某个平台的真实预测数据。
2.4 数据请求层:带退避重试的 fetch 封装
Sleeper 公开接口返回 JSON,但浏览器端请求可能因为网络波动、请求过快等原因失败。一个带退避重试的封装可以提前解决大部分偶发问题:
// src/services/sleeperApi.ts const BASE = 'https://api.sleeper.app/v1'; function delay(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } async function sleeperFetch<T>(path: string, retries = 3): Promise<T> { let lastError: unknown; for (let i = 0; i < retries; i++) { try { const res = await fetch(`${BASE}${path}`); if (res.status === 420) { await delay((i + 1) * 1000); continue; } if (!res.ok) { throw new Error(`Sleeper API ${res.status} for ${path}`); } return await res.json() as T; } catch (e) { lastError = e; } } throw lastError; } export async function fetchNflPlayers() { return sleeperFetch<Record<string, SleeperPlayer>>('/players/nfl'); } export async function fetchLeagueDrafts(leagueId: string) { return sleeperFetch<Array<{ draft_id: string; settings: Record<string, unknown> }>>( `/league/${leagueId}/drafts` ); }这里的退避策略比较简单:遇到 420 后等待(i + 1) * 1000毫秒。真实项目可以改成指数退避并加入随机抖动,避免所有客户端同时重试。对学习项目来说,这个封装已经足够避免“点一次页面就大量失败”的问题。
注意:浏览器端跨域访问是否被允许,要以实际运行环境为准。如果遇到 CORS 报错,不要急着换后端,可以先从
/v1/players/nfl拉一次数据保存为public/players.nfl.json静态快照,再从同源路径加载。
3. 选秀室实现:从状态机到推荐评分
选秀室是整个工具中最复杂的部分,因为它既要展示多轮选秀进度,又要根据当前阵容推荐最佳球员。可以把问题拆成状态管理、推荐模型、自动选秀模拟三块。
3.1 用状态机表达选秀进度
选秀过程通常按轮次进行,每轮按固定顺序选人,一轮结束后下一轮顺序反转。客户端只需要维护一个当前 pick 指针和已选球员集合,就能推导出此刻可用的球员池。
export type DraftPhase = 'idle' | 'drafting' | 'complete'; export interface DraftState { draftId: string; leagueId: string; teamCount: number; rounds: number; currentPick: number; phase: DraftPhase; picks: DraftPick[]; } export function getAvailablePlayers( players: Record<string, SleeperPlayer>, picks: DraftPick[] ): SleeperPlayer[] { const pickedIds = new Set(picks.map(p => p.player_id)); return Object.values(players).filter( p => p.active !== false && !pickedIds.has(p.player_id) ); }这里用pickedIds排除已选球员,用active !== false过滤掉退役和不可用球员。状态机的好处是,任何时刻只要看currentPick、picks、teamCount就能还原整个流程,不需要额外存大量视图状态。
3.2 推荐模型:不能只看 ADP,还要看位置需求
选秀推荐最容易犯的错误是排名榜单从上往下选。在 fantasy 规则中,每个位置上限不同,真正缺的位置可能比高分位置更重要。因此推荐模型要把两个信号合成一个最终分数:
- 球员实力。用 ADP(平均选秀位置)作为参考,ADP 越小说明行业越认可。
- 位置需求。如果当前阵容已经选了 3 个 QB,却只有 1 个 RB,那么普遍抢跑的 RB 和 WR 优先度会上升。
// src/algorithms/value.ts export interface ValueWeights { adpWeight: number; needWeight: number; flexMultiplier: number; rosterSize: number; } const DEFAULT_WEIGHTS: ValueWeights = { adpWeight: 0.6, needWeight: 0.4, flexMultiplier: 0.8, rosterSize: 16, }; function clamp01(value: number): number { return Math.max(0, Math.min(1, value)); } export function computePickScore( player: SleeperPlayer, positionNeeds: Record<string, number>, weights: ValueWeights = DEFAULT_WEIGHTS ): number { const position = player.position ?? 'unknown'; const adp = player.adp ?? 999; const adpScore = adp >= 999 ? 0 : clamp01(1 - (adp - 1) / (weights.rosterSize * 2)); const need = positionNeeds[position] ?? 0; const needScore = need > 0 ? Math.min(need, 2) * 0.4 : 0; return adpScore * weights.adpWeight + needScore * weights.needWeight; }这个模型刻意简洁。核心是两个分数加权,权重通过ValueWeights暴露给界面调整。实际项目里,可以把adpScore换成 tier 分数,也可以把needScore替换成更细的盈亏模型。但先跑通简单的加权版本,再逐渐升级,比一开始堆复杂模型更容易落地。
3.3 根据当前阵容计算位置缺口
要算positionNeeds,需要知道“某个位置还能选几个人”。典型流程是:
- 查看当前球队已经选了哪些位置。
- 用槽位上限减去已选数量。
- FLEX 槽位可以吸收 RB、WR、TE,单独考虑。
export function computePositionNeeds( selected: SleeperPlayer[], slots: RosterSlot[] ): Record<string, number> { const needs: Record<string, number> = {}; const selectedCount: Record<string, number> = {}; for (const p of selected) { for (const pos of p.fantasy_positions ?? []) { selectedCount[pos] = (selectedCount[pos] ?? 0) + 1; } } for (const slot of slots) { const existing = selectedCount[slot.position] ?? 0; const remaining = Math.max(0, slot.qty - existing); if (remaining > 0) { needs[slot.position] = (needs[slot.position] ?? 0) + remaining; } } return needs; }注意这里用fantasy_positions而不是position。比如有些球员在现实中是 WR,在 fantasy platform 中可能同时允许放进 WR 和 FLEX。如果只用position,会出现“明明能放进 FLEX 却显示无处可放”的偏差。
3.4 找到当前轮次最推荐的球员
有了球员池、位置需求和评分函数,推荐逻辑就很直接:遍历所有仍然可用的球员,按分数降序,取第一个。如果不想每次全量排序,可以用最小值堆优化,但对普通 league 数据量来说,全量排序足够快。
export function bestAvailable( available: SleeperPlayer[], positionNeeds: Record<string, number>, weights: ValueWeights ): SleeperPlayer | undefined { return available .filter(p => p.position && p.adp !== undefined) .map(p => ({ player: p, score: computePickScore(p, positionNeeds, weights) })) .sort((a, b) => b.score - a.score)[0]?.player; }抽取这一层的好处是,UI 组件只负责调用bestAvailable,不必理解 ADP 和位置缺口之间如何平衡。后续如果想加入基于机器学习的推荐,只要替换这个函数内部实现。
3.5 模拟选秀:用延迟和策略模拟对手行为
选秀室在测试时经常需要模拟其他成员。模拟逻辑不需要太复杂,关键是要有可控的延迟和策略变量:
// src/algorithms/mockDraft.ts export interface MockPickStrategy { useRecommendationRate: number; minDelayMs: number; maxDelayMs: number; } export function createMockPickDelay(strategy: MockPickStrategy): number { const range = strategy.maxDelayMs - strategy.minDelayMs; return strategy.minDelayMs + Math.floor(Math.random() * range); } export function shouldUseRecommendation(rate: number): boolean { return Math.random() < rate; }useRecommendationRate控制机器人多少概率采用推荐结果,多少概率乱选。测试时把这个值调成 1,就是“全员合理选秀”;调成 0,就是“全员随机”,可以观察工具在极端场景下的表现。
这里用setTimeout做模拟足够。如果希望在整个选秀过程中不阻塞 UI,后续可以迁移到 Web Worker 中执行连续模拟。
4. 阵容优化器:把“谁首发”变成一个约束求解问题
赛季开始后,每周获得一个球员集合,其中一部分要放入首发槽位,其余进入替补。这个问题的难点不是“谁分高”,而是“谁能放进哪个槽位”。阵容优化器的任务就是在这个约束下最大化首发分数总和。
4.1 把阵容结构建模成槽位数组
在 Scout Bowie 中,阵容槽位不应该硬编码,而应该来自配置。常见 12 人联盟的配置如下:
export const DEFAULT_LINEUP_SLOTS: RosterSlot[] = [ { position: 'QB', qty: 1 }, { position: 'RB', qty: 2 }, { position: 'WR', qty: 2 }, { position: 'TE', qty: 1 }, { position: 'FLEX', qty: 2 }, { position: 'K', qty: 1 }, { position: 'DST', qty: 1 }, { position: 'BN', qty: 6 }, ];每个对象的含义是:这个位置需要qty个球员。FLEX是一个特殊槽位,可以放入 RB、WR、TE 中的任意一个。不同联盟的 FLEX 数量和服务端限制不同,所以优化器必须接受外部输入的 slots。
4.2 判定球员是否满足槽位要求
需要一个isEligible函数判断球员能否放入槽位。最简单的方式是看fantasy_positions是否包含该槽位名,或者当槽位是FLEX时,球迷位置包含 RB、WR、TE 三者之一。
const FLEX_POSITIONS = new Set(['RB', 'WR', 'TE']); export function isEligible( player: PlayerWithProjection, slot: RosterSlot ): boolean { if (slot.position === 'FLEX') { return player.fantasy_positions?.some(pos => FLEX_POSITIONS.has(pos)) ?? false; } return player.fantasy_positions?.includes(slot.position) ?? false; }isEligible应该是纯函数,不访问任何外部状态。这样可以非常方便地做单元测试,例如构造一个 WR 球员验证它能放进 WR 和 FLEX,但不能放进 RB。
4.3 用贪心策略填充槽位
贪心策略很简单:把所有球员按预测分从高到低排序,依次尝试放入一个未填满并且匹配的槽位。优先放 QB、RB、WR、TE 这类硬位置,最后再填 FLEX 和 BN。
// src/algorithms/lineupOptimizer.ts export interface LineupResult { assignments: Record<string, PlayerWithProjection[]>; projectedTotal: number; } const SLOT_PRIORITY: Record<string, number> = { QB: 0, RB: 1, WR: 2, TE: 3, FLEX: 4, K: 5, DST: 6, BN: 7, }; export function optimizeLineup( players: PlayerWithProjection[], slots: RosterSlot[] ): LineupResult { const sorted = [...players].sort((a, b) => b.projectedPoints - a.projectedPoints); const assignments: Record<string, PlayerWithProjection[]> = {}; const counts: Record<string, number> = {}; const assigned = new Set<string>(); for (const player of sorted) { if (assigned.has(player.player_id)) continue; const eligibleSlots = slots .filter(slot => isEligible(player, slot)) .filter(slot => (counts[slot.position] ?? 0) < slot.qty) .sort((a, b) => SLOT_PRIORITY[a.position] - SLOT_PRIORITY[b.position]); const targetSlot = eligibleSlots[0]; if (targetSlot) { assigned.add(player.player_id); counts[targetSlot.position] = (counts[targetSlot.position] ?? 0) + 1; (assignments[targetSlot.position] ??= []).push(player); } } const projectedTotal = Object.values(assignments) .flat() .reduce((sum, p) => sum + p.projectedPoints, 0); return { assignments, projectedTotal }; }这段代码首先按预测分降序排列,然后逐个尝试放置球员。它的优点是实现快、结果容易解释,适合作为 MVP。缺点是贪心策略不一定全局最优:某个球员分高,但它占用了一个硬位置,可能导致另一个能拉高 FLEX 总分的组合被破坏。如果分数差距不大,实际影响通常可以接受。
4.4 预测分从哪里来
真正的生产系统会使用专业的 weekly projection 数据源。Scout Bowie 的初始版本不需要接复杂数据源,可以先让用户手工录入或从 CSV 导入本周预测分。一个最小结构是这样:
player_id,projectedPoints XXXXX,18.5 YYYYY,15.2前端读取后映射到PlayerWithProjection。这样可以先把优化器跑通,后续再替换成自动预测接口。脏活和核心逻辑分离,是这类工具最值得坚持的架构习惯。
5. 运行验证:先从 mock 数据确认算法正确,再部署静态站点
5.1 本地启动与基础验证
启动项目:
npm run dev浏览器打开 Vite 输出的地址,通常是http://localhost:5173。首次进入选秀室页面时,页面应该拉取球员数据和联盟选秀信息。如果使用本地快照,则不需要请求外部接口。
在验证阶段,可以在控制台手动调用核心函数,确认输入输出符合预期。下面是一段最小验证用例:
const players = [ { player_id: 'P1', full_name: 'QB A', position: 'QB', fantasy_positions: ['QB'], adp: 20, projectedPoints: 18 }, { player_id: 'P2', full_name: 'RB B', position: 'RB', fantasy_positions: ['RB'], adp: 10, projectedPoints: 15 }, ]; const slots = [ { position: 'QB', qty: 1 }, { position: 'RB', qty: 2 }, ]; const result = optimizeLineup(players.map(p => ({ ...p, projectedPoints: p.projectedPoints })), slots); console.log(result.assignments);预期结果:QB A 放入 QB 槽位,RB B 放入 RB 槽位。如果输出与预期不符,说明isEligible或槽位计数逻辑有问题,应该优先检查这两个函数。
5.2 用固定数据和断言做简单的算法回归
随着算法不断调整,很容易出现“推荐结果变了但不知道为什么变”的问题。建议在package.json里加一个算法测试脚本:
{ "scripts": { "test:algo": "tsx src/algorithms/__tests__/basic.test.ts" } }在测试文件里,只验证固定输入下的输出。比如:
import { computePickScore } from '../value'; const player = { player_id: 'X', position: 'WR', fantasy_positions: ['WR'], adp: 5, }; const needs = { QB: 0, RB: 2, WR: 1, TE: 0 }; const score = computePickScore(player, needs, { adpWeight: 0.6, needWeight: 0.4, flexMultiplier: 0.8, rosterSize: 16 }); console.log(score); if (score <= 0 || score > 1) { throw new Error('score out of range'); }这类回归测试不需要覆盖复杂逻辑,只需要保证核心函数的边界关系稳定。后续每改一次权重算法,就跑一遍测试,能及时发现“分数超过 1”或“需要为空时 still 推 QB”之类的低级错误。
5.3 生产构建与静态部署
客户端项目构建产物是纯静态文件,可以直接部署到对象存储、CDN 或静态托管平台。构建命令:
npm run build构建完成后,dist/目录就是可发布站点。把整个目录上传到静态托管平台即可。正式使用前,建议把league_id、draft_id放到部署环境变量或页面配置中,而不是硬编码进源码。
开发和生产的差异可以这样整理:
| 环节 | 开发环境 | 生产环境 |
|---|---|---|
| 数据来源 | 实时请求 Sleeper API 或本地快照 | 优先使用固定时间点的静态快照 |
| 请求频控 | 手动测试,容易触发 420 | 加缓存,减少重复请求 |
| 本地存储 | 可以频繁清理 | 提供导出和导入 JSON 备份 |
| 推荐参数 | 默认权重即可 | 建议暴露配置面板 |
还需要一份发布前检查清单:
- 能确认
league_id和draft_id有效,浏览器单独打开对应 API URL 返回 JSON。 - 能确认球员数据是全量更新,至少包含所有仍在活跃状态的球员。
- 能确认本地存储中有选秀状态备份,不会因为误操作丢失数据。
- 能确认当前环境不会重复请求导致 420。
- 能确认构建产物中不包含敏感 token、私密联盟信息或本地绝对路径。
- 能确认移动端布局可用,选秀页面在手机屏幕上的操作路径顺畅。
6. 常见问题与排查路径
客户端数据工具的报错并不复杂,但问题往往出在数据来源和浏览器环境之间。下面按问题现象、可能原因、检查方式、处理方案整理一份排查清单。
| 问题现象 | 可能原因 | 检查方式 | 处理方案 |
|---|---|---|---|
| 选秀页面一直显示兵力不足 | league_id 或 draft_id 错误 | 浏览器单独访问 API URL | 确认 ID 正确,检查私有联盟限制 |
| 请求返回 HTTP 420 | 请求过于频繁,触发限制了 | Network 面板观察请求频率 | 增加退避重试,使用静态快照 |
| 浏览器报 CORS 跨域错误 | 当前环境不允许直接 fetch | 用命令行curl对比 | 将数据保存到public/同源提供 |
| 某球员位置显示为空 | 接口数据里该球员字段缺失 | 打印该 player 的完整 JSON | 推荐时过滤掉 position 缺失项 |
| 推荐结果明显偏向某个位置 | 权重设置不合理 | 查看计算后的 score 与 needs | 调整adpWeight和needWeight |
| 选了球员后本地状态丢失 | localStorage 被清空或隐私模式 | 查看 Application 面板 | 增加 JSON 导出备份 |
| 阵容结果里有重复球员 | 球员集合去重逻辑遗漏 | 查看 assigned Set 使用位置 | 确保optimizeLineup中assigned生效 |
排查顺序建议按这条链路来:
- 先检查输入。
league_id和draft_id是否真的对应一个公开联盟。 - 再检查网络。浏览器 Network 面板里是否出现 4xx 或 5xx。
- 再检查数据字段。进入页面的球员列表里,是否有 position 或 adp 缺失。
- 再检查算法。用最小 mock 数据调用核心函数,看输出是否符合直觉。
- 再检查状态。刷新页面后,
picks和本地存储是否保持一致。 - 最后检查部署。如果是生产环境,确认使用的数据快照版本和时间。
排错时建议先打开浏览器 Network 面板,再操作选秀页面。很多“算法不准”的问题,实际是某些请求失败了,前端拿到了旧的或残缺的数据,而不是推荐逻辑本身出错。
7. 最佳实践与后续扩展方向
7.1 这些实践能让 Scout Bowie 更像生产工具
先把 MVP 跑通,再逐步提升健壮性。在实现过程中,几个实践值得保留:
- 不要在高频操作里重复请求外部接口。球员数据一次加载后放入内存或 IndexedDB,设置一个刷新按钮而不是每次页面变化都重新拉取。
- 不要盲目按 ADP 排序。必须同时考虑位置缺口和阵容槽位,否则推荐结果看起来合理却常常不可用。
- 不要用裸 try catch 吞掉请求异常。至少记录状态码、失败路径和重试次数,否则排错时没有线索。
- 不要把推荐权重写死。把
adpWeight、needWeight、flexMultiplier放到配置面板或配置文件中,方便根据不同联盟规则调整。 - 不要只验证“推荐出球员了”。要验证推荐出的球员是否满足当前槽位数量、是否已经被人选走、放入阵容是否超出位置上限。
7.2 从 MVP 走向更强版本的几个方向
MVP 版本可以正常运行后,扩展方向很明确:
- 使用 Web Worker 并行计算多轮选秀模拟。当用户想让工具连续模拟未来 5 轮选秀时,实时计算可能阻塞 UI。把纯计算函数迁移到 Worker,交互会流畅很多。
- 引入 WebSocket 做多人选秀同步。纯客户端版本是单机工具,但如果需要同一个联盟多个成员实时看到选秀进度,可以用一个轻量后端或 WebSocket 服务广播选秀状态。
- 引入本地加密存储。虽然当前版本没有敏感信息,但如果未来要缓存联盟数据,用加密手段保护本地缓存是加分项。
- 把阵容优化器升级为动态规划或整数规划。贪心算法适合 MVP,面对更严格的约束时,可以用背包模型或线性规划求解器找到全局最优。
- 给选秀结果增加可视化报告。每个 pick 的推荐理由、被跳过的高分球员、当前阵容覆盖度等,都是用户最想看到的解释。
7.3 对新手最重要的练习建议
如果想真正掌握这套工具,不要急着接真实联盟数据。先准备一个只有 20 个球员的 mock 数据文件,固定一轮选秀,把流程跑通。然后逐步增加位置、FLEX、多轮次、阵容优化。每加一个功能,就用固定断言验证一次输出。这样做的价值是,在真实数据量增大后,你依然能判断“推荐变了,是因为数据变了,还是因为算法写错了”。
Scout Bowie 这类工具最容易打动人心的点,不是有一个复杂的机器学习模型,而是能把“为什么推荐他”讲清楚。保持算法可解释、数据可导出、参数可调节,用户在实战中才会越来越信任它的建议。