yahoo-fantasy-api 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PeregrineCode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # yahoo-fantasy-api
2
+
3
+ Yahoo Fantasy Sports API client for Node.js with OAuth 2.0 authentication, automatic token refresh, rate limiting, and CLI tools.
4
+
5
+ Works with any Yahoo Fantasy sport (MLB, NHL, NFL, NBA).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install yahoo-fantasy-api
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ ### 1. Create a Yahoo App
16
+
17
+ 1. Go to [Yahoo Developer](https://developer.yahoo.com/apps/)
18
+ 2. Create a new app with **Fantasy Sports** read permissions
19
+ 3. Set the redirect URI to `https://localhost:3000/auth/callback`
20
+ 4. Note your Client ID and Client Secret
21
+
22
+ ### 2. Configure Environment
23
+
24
+ Create a `.env` file:
25
+
26
+ ```env
27
+ YAHOO_CLIENT_ID=your_client_id
28
+ YAHOO_CLIENT_SECRET=your_client_secret
29
+ YAHOO_REDIRECT_URI=https://localhost:3000/auth/callback
30
+ ```
31
+
32
+ ### 3. Generate SSL Certificates
33
+
34
+ Yahoo requires HTTPS for OAuth callbacks. Generate self-signed certs:
35
+
36
+ ```bash
37
+ mkdir certs
38
+ openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj '/CN=localhost'
39
+ ```
40
+
41
+ ### 4. Authenticate
42
+
43
+ ```bash
44
+ npx yahoo-fantasy-api authenticate
45
+ ```
46
+
47
+ This opens an HTTPS server on localhost:3000, prints an authorization URL, and waits for the OAuth callback. The token is saved to `.yahoo-token.json` and auto-refreshes.
48
+
49
+ ## CLI Usage
50
+
51
+ ```bash
52
+ npx yahoo-fantasy-api authenticate # Interactive OAuth flow
53
+ npx yahoo-fantasy-api token-status # Check token expiry
54
+ npx yahoo-fantasy-api list-leagues # Discover all your leagues
55
+ npx yahoo-fantasy-api league-settings # Pull settings for all leagues
56
+ npx yahoo-fantasy-api league-settings 469.l.75479 # Specific league
57
+ ```
58
+
59
+ ## Programmatic Usage
60
+
61
+ ```javascript
62
+ require('dotenv').config();
63
+ const { createClient } = require('yahoo-fantasy-api');
64
+
65
+ const { auth, client } = createClient({
66
+ tokenFile: '.yahoo-token.json',
67
+ certsDir: 'certs',
68
+ });
69
+
70
+ // Discover leagues
71
+ const leagues = await client.getUserLeagues(['mlb', 'nhl']);
72
+
73
+ // Get league settings
74
+ const settings = await client.getLeagueSettings('469.l.75479');
75
+
76
+ // Get teams in a league
77
+ const teams = await client.getLeagueTeams('469.l.75479');
78
+
79
+ // Get draft results
80
+ const picks = await client.getDraftResults('469.l.75479');
81
+
82
+ // Raw API call to any endpoint
83
+ const data = await client.get('/league/469.l.75479/scoreboard');
84
+ ```
85
+
86
+ ### createClient(options)
87
+
88
+ Returns `{ auth, client }` — a `YahooAuth` instance and a `YahooClient` instance.
89
+
90
+ | Option | Default | Description |
91
+ |--------|---------|-------------|
92
+ | `clientId` | `YAHOO_CLIENT_ID` env | OAuth client ID |
93
+ | `clientSecret` | `YAHOO_CLIENT_SECRET` env | OAuth client secret |
94
+ | `redirectUri` | `https://localhost:3000/auth/callback` | OAuth redirect URI |
95
+ | `tokenFile` | `.yahoo-token.json` | Path to token storage |
96
+ | `certsDir` | `certs/` | Path to SSL certificates |
97
+ | `minInterval` | `2000` | Minimum ms between API calls |
98
+ | `log` | `console.log` | Custom logging function |
99
+
100
+ ## Client Methods
101
+
102
+ ### `client.get(endpoint)`
103
+ Rate-limited GET request. Handles token refresh on 401 and retries on Yahoo's 999 rate limit. All other methods are built on this.
104
+
105
+ ### `client.getUserLeagues(gameCodes)`
106
+ Discover all leagues for the authenticated user. `gameCodes` defaults to `['mlb', 'nhl']`.
107
+
108
+ ### `client.resolveGameKey(gameCode)`
109
+ Map a game code (`'mlb'`, `'nhl'`) to Yahoo's numeric game key. Cached after first call.
110
+
111
+ ### `client.leagueKey(gameKey, leagueId)`
112
+ Helper to build a league key string (e.g., `'469.l.75479'`).
113
+
114
+ ### `client.getLeagueSettings(leagueKey)`
115
+ Fetch roster positions, stat categories, and league configuration.
116
+
117
+ ### `client.getLeagueTeams(leagueKey)`
118
+ Fetch all teams with names and manager info.
119
+
120
+ ### `client.getDraftResults(leagueKey)`
121
+ Fetch draft picks with costs (for auction leagues).
122
+
123
+ ## Yahoo API Notes
124
+
125
+ - Base URL: `https://fantasysports.yahooapis.com/fantasy/v2`
126
+ - All endpoints work identically across sports — only the game key differs
127
+ - Rate limit: client enforces 2s minimum between calls (configurable)
128
+ - Yahoo 999 errors: automatic 5s backoff + retry
129
+ - Token auto-refreshes 5 minutes before expiry
130
+ - **Settings quirk:** `/league/{key}/settings` returns stale predraft data. Use `/league/{key};out=settings` instead (this client handles it correctly).
131
+ - **Weekly stats quirk:** `;out=stats;type=week;week=N` returns wrong data. Use the subresource syntax `/players/stats;type=week;week=N` (slash before `stats`).
132
+ - **Positions are always current:** `selected_position` always reflects the current roster, never historical.
133
+
134
+ ## License
135
+
136
+ MIT
package/auth.js ADDED
@@ -0,0 +1,307 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const https = require('https');
4
+ const crypto = require('crypto');
5
+
6
+ class YahooAuth {
7
+ constructor(options = {}) {
8
+ this.clientId = options.clientId || process.env.YAHOO_CLIENT_ID;
9
+ this.clientSecret = options.clientSecret || process.env.YAHOO_CLIENT_SECRET;
10
+ this.redirectUri = options.redirectUri || process.env.YAHOO_REDIRECT_URI || 'https://localhost:3000/auth/callback';
11
+ this.tokenFile = options.tokenFile || path.join(process.cwd(), '.yahoo-token.json');
12
+ this.certsDir = options.certsDir || path.join(process.cwd(), 'certs');
13
+ this.token = null;
14
+ this._refreshTimer = null;
15
+ this._refreshPromise = null; // mutex for concurrent refresh calls
16
+ this._log = options.log || ((type, msg) => console.log(`[YahooAuth] ${msg}`));
17
+
18
+ // Load token from disk on construction
19
+ this.loadToken();
20
+ }
21
+
22
+ loadToken() {
23
+ if (fs.existsSync(this.tokenFile)) {
24
+ try {
25
+ this.token = JSON.parse(fs.readFileSync(this.tokenFile, 'utf-8'));
26
+ if (!this.token.access_token) {
27
+ this._log('error', `Token file ${this.tokenFile} exists but is missing access_token — re-authenticate`);
28
+ this.token = null;
29
+ } else {
30
+ this._log('info', `Loaded token (expires ${new Date(this.token.expires_at).toISOString()})`);
31
+ }
32
+ } catch (e) {
33
+ this._log('error', `Token file ${this.tokenFile} exists but could not be read: ${e.message}`);
34
+ }
35
+ }
36
+ return this.token;
37
+ }
38
+
39
+ saveToken(token) {
40
+ if (!token.access_token || !token.expires_at) {
41
+ throw new Error('Invalid token: missing access_token or expires_at');
42
+ }
43
+ this.token = token;
44
+ fs.writeFileSync(this.tokenFile, JSON.stringify(token, null, 2), { mode: 0o600 });
45
+ this._log('info', `Token saved (expires ${new Date(token.expires_at).toISOString()})`);
46
+ }
47
+
48
+ isAuthenticated() {
49
+ return !!this.token;
50
+ }
51
+
52
+ /**
53
+ * Returns a valid access token, refreshing proactively if within 5 minutes of expiry.
54
+ */
55
+ async getAccessToken() {
56
+ if (!this.token) throw new Error('Not authenticated with Yahoo');
57
+ const REFRESH_BUFFER = 5 * 60 * 1000;
58
+ if (Date.now() >= this.token.expires_at - REFRESH_BUFFER) {
59
+ this._log('info', 'Proactive token refresh (expires soon)');
60
+ await this.refreshToken();
61
+ }
62
+ return this.token.access_token;
63
+ }
64
+
65
+ /**
66
+ * Build the Yahoo OAuth authorization URL.
67
+ */
68
+ getAuthUrl(state) {
69
+ const params = new URLSearchParams({
70
+ client_id: this.clientId,
71
+ redirect_uri: this.redirectUri,
72
+ response_type: 'code',
73
+ state: state || crypto.randomBytes(16).toString('hex'),
74
+ });
75
+ return `https://api.login.yahoo.com/oauth2/request_auth?${params}`;
76
+ }
77
+
78
+ /**
79
+ * Exchange an authorization code for tokens.
80
+ */
81
+ async handleCallback(code) {
82
+ const params = new URLSearchParams({
83
+ grant_type: 'authorization_code',
84
+ code,
85
+ redirect_uri: this.redirectUri,
86
+ });
87
+
88
+ const basicAuth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
89
+ const controller = new AbortController();
90
+ const timeout = setTimeout(() => controller.abort(), 30000);
91
+ let res;
92
+ try {
93
+ res = await fetch('https://api.login.yahoo.com/oauth2/get_token', {
94
+ method: 'POST',
95
+ headers: {
96
+ 'Content-Type': 'application/x-www-form-urlencoded',
97
+ Authorization: `Basic ${basicAuth}`,
98
+ },
99
+ body: params.toString(),
100
+ signal: controller.signal,
101
+ });
102
+ } finally {
103
+ clearTimeout(timeout);
104
+ }
105
+
106
+ if (!res.ok) {
107
+ const text = await res.text();
108
+ throw new Error(`Token exchange failed (${res.status})`);
109
+ }
110
+
111
+ const data = await res.json();
112
+ this.saveToken({
113
+ access_token: data.access_token,
114
+ refresh_token: data.refresh_token,
115
+ expires_at: Date.now() + data.expires_in * 1000,
116
+ });
117
+ return this.token;
118
+ }
119
+
120
+ /**
121
+ * Refresh the access token using the refresh token.
122
+ */
123
+ async refreshToken() {
124
+ // Mutex: if a refresh is already in progress, piggyback on it
125
+ // instead of firing a second token exchange (which could invalidate
126
+ // the refresh token).
127
+ if (this._refreshPromise) {
128
+ return this._refreshPromise;
129
+ }
130
+
131
+ this._refreshPromise = this._doRefresh();
132
+ try {
133
+ return await this._refreshPromise;
134
+ } finally {
135
+ this._refreshPromise = null;
136
+ }
137
+ }
138
+
139
+ async _doRefresh() {
140
+ if (!this.token || !this.token.refresh_token) {
141
+ throw new Error('No refresh token available');
142
+ }
143
+ this._log('info', 'Refreshing token...');
144
+
145
+ const params = new URLSearchParams({
146
+ grant_type: 'refresh_token',
147
+ refresh_token: this.token.refresh_token,
148
+ });
149
+
150
+ const basicAuth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
151
+ const controller = new AbortController();
152
+ const timeout = setTimeout(() => controller.abort(), 30000);
153
+ let res;
154
+ try {
155
+ res = await fetch('https://api.login.yahoo.com/oauth2/get_token', {
156
+ method: 'POST',
157
+ headers: {
158
+ 'Content-Type': 'application/x-www-form-urlencoded',
159
+ Authorization: `Basic ${basicAuth}`,
160
+ },
161
+ body: params.toString(),
162
+ signal: controller.signal,
163
+ });
164
+ } finally {
165
+ clearTimeout(timeout);
166
+ }
167
+
168
+ if (!res.ok) {
169
+ const text = await res.text();
170
+ this._log('error', `Token refresh failed: ${text.substring(0, 300)}`);
171
+ throw new Error(`Token refresh failed (${res.status})`);
172
+ }
173
+
174
+ const data = await res.json();
175
+ if (!data.access_token || !data.expires_in) {
176
+ throw new Error('Token refresh returned invalid response');
177
+ }
178
+ this.saveToken({
179
+ access_token: data.access_token,
180
+ refresh_token: data.refresh_token || this.token.refresh_token,
181
+ expires_at: Date.now() + data.expires_in * 1000,
182
+ });
183
+ this._log('info', 'Token refreshed successfully');
184
+ return this.token;
185
+ }
186
+
187
+ /**
188
+ * Start a background timer that checks token expiry every intervalMs.
189
+ */
190
+ startBackgroundRefresh(intervalMs = 2 * 60 * 1000) {
191
+ this.stopBackgroundRefresh();
192
+ this._refreshTimer = setInterval(async () => {
193
+ if (!this.token) return;
194
+ const REFRESH_BUFFER = 5 * 60 * 1000;
195
+ if (Date.now() >= this.token.expires_at - REFRESH_BUFFER) {
196
+ this._log('info', 'Background token refresh (timer)');
197
+ try {
198
+ await this.refreshToken();
199
+ } catch (e) {
200
+ this._log('error', `Background refresh failed — will retry: ${e.message}`);
201
+ }
202
+ }
203
+ }, intervalMs);
204
+ // Don't prevent process exit if nothing else is keeping the event loop alive
205
+ if (this._refreshTimer.unref) this._refreshTimer.unref();
206
+ }
207
+
208
+ stopBackgroundRefresh() {
209
+ if (this._refreshTimer) {
210
+ clearInterval(this._refreshTimer);
211
+ this._refreshTimer = null;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Interactive OAuth: spin up temp HTTPS server, print auth URL, wait for callback.
217
+ */
218
+ async authenticateInteractive(port = 3000) {
219
+ return new Promise((resolve, reject) => {
220
+ const certPath = path.join(this.certsDir, 'cert.pem');
221
+ const keyPath = path.join(this.certsDir, 'key.pem');
222
+
223
+ if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
224
+ reject(new Error(`Certs not found in ${this.certsDir}. Run: openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes`));
225
+ return;
226
+ }
227
+
228
+ const sslOptions = {
229
+ key: fs.readFileSync(keyPath),
230
+ cert: fs.readFileSync(certPath),
231
+ };
232
+
233
+ const expectedState = crypto.randomBytes(16).toString('hex');
234
+
235
+ const handler = async (req, res) => {
236
+ const url = new URL(req.url, `https://localhost:${port}`);
237
+
238
+ if (url.pathname === '/auth/callback') {
239
+ const code = url.searchParams.get('code');
240
+ const error = url.searchParams.get('error');
241
+ const returnedState = url.searchParams.get('state');
242
+
243
+ if (returnedState !== expectedState) {
244
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
245
+ res.end('Invalid state parameter');
246
+ return; // don't close server — could be a stale/malicious callback
247
+ }
248
+
249
+ if (error) {
250
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
251
+ res.end(`OAuth error: ${error}`);
252
+ server.close();
253
+ reject(new Error(`OAuth error: ${error}`));
254
+ return;
255
+ }
256
+
257
+ if (!code) {
258
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
259
+ res.end('No authorization code received');
260
+ server.close();
261
+ reject(new Error('No authorization code received'));
262
+ return;
263
+ }
264
+
265
+ try {
266
+ await this.handleCallback(code);
267
+ res.writeHead(200, { 'Content-Type': 'text/html' });
268
+ res.end('<h2>Authenticated! You can close this tab.</h2>');
269
+ server.close();
270
+ resolve(this.token);
271
+ } catch (e) {
272
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
273
+ res.end(`Auth error: ${e.message}`);
274
+ server.close();
275
+ reject(e);
276
+ }
277
+ } else {
278
+ res.writeHead(404);
279
+ res.end('Not found');
280
+ }
281
+ };
282
+
283
+ const server = https.createServer(sslOptions, handler);
284
+
285
+ // Timeout after 5 minutes if user never completes the flow
286
+ const authTimeout = setTimeout(() => {
287
+ server.close();
288
+ reject(new Error('Authentication timed out after 5 minutes'));
289
+ }, 5 * 60 * 1000);
290
+
291
+ server.listen(port, () => {
292
+ const authUrl = this.getAuthUrl(expectedState);
293
+ console.log(`\nOpen this URL to authenticate:\n\n ${authUrl}\n`);
294
+ console.log(`Waiting for callback on https://localhost:${port}/auth/callback ...\n`);
295
+ });
296
+
297
+ server.on('close', () => clearTimeout(authTimeout));
298
+
299
+ server.on('error', (e) => {
300
+ clearTimeout(authTimeout);
301
+ reject(new Error(`Server error: ${e.message}`));
302
+ });
303
+ });
304
+ }
305
+ }
306
+
307
+ module.exports = { YahooAuth };
package/cli.js ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ const path = require('path');
3
+
4
+ // Load .env from current working directory (if dotenv is available)
5
+ try { require('dotenv').config(); } catch (e) { /* dotenv is optional */ }
6
+
7
+ const { createClient } = require('./index');
8
+
9
+ const tokenFile = path.join(process.cwd(), '.yahoo-token.json');
10
+ const certsDir = path.join(process.cwd(), 'certs');
11
+
12
+ const { auth, client } = createClient({ tokenFile, certsDir });
13
+
14
+ const command = process.argv[2];
15
+
16
+ async function main() {
17
+ switch (command) {
18
+ case 'authenticate':
19
+ return authenticate();
20
+ case 'token-status':
21
+ return tokenStatus();
22
+ case 'list-leagues':
23
+ return listLeagues();
24
+ case 'league-settings':
25
+ return leagueSettings();
26
+ default:
27
+ usage();
28
+ }
29
+ }
30
+
31
+ function usage() {
32
+ console.log(`
33
+ Usage: yahoo-fantasy-api <command>
34
+
35
+ Commands:
36
+ authenticate Interactive OAuth flow (opens browser)
37
+ token-status Show current token info
38
+ list-leagues Discover all your fantasy leagues
39
+ league-settings Pull settings for all leagues (or specify league key)
40
+
41
+ Examples:
42
+ npx yahoo-fantasy-api authenticate
43
+ npx yahoo-fantasy-api list-leagues
44
+ npx yahoo-fantasy-api league-settings 469.l.75479
45
+ `);
46
+ }
47
+
48
+ async function authenticate() {
49
+ console.log('Starting interactive Yahoo OAuth...');
50
+ try {
51
+ const token = await auth.authenticateInteractive(3000);
52
+ console.log('\nAuthentication successful!');
53
+ console.log(`Token expires: ${new Date(token.expires_at).toISOString()}`);
54
+ } catch (e) {
55
+ console.error(`Authentication failed: ${e.message}`);
56
+ process.exit(1);
57
+ }
58
+ }
59
+
60
+ function tokenStatus() {
61
+ if (!auth.isAuthenticated()) {
62
+ console.log('Not authenticated. Run: npx yahoo-fantasy-api authenticate');
63
+ return;
64
+ }
65
+ const t = auth.token;
66
+ const expiresAt = new Date(t.expires_at);
67
+ const expiresIn = Math.round((t.expires_at - Date.now()) / 1000);
68
+ const expired = expiresIn <= 0;
69
+
70
+ console.log(`Token file: ${tokenFile}`);
71
+ console.log(`Expires at: ${expiresAt.toISOString()}`);
72
+ console.log(`Expires in: ${expired ? 'EXPIRED' : `${expiresIn}s (${Math.round(expiresIn / 60)}min)`}`);
73
+ console.log(`Has refresh: ${t.refresh_token ? 'yes' : 'no'}`);
74
+ }
75
+
76
+ async function listLeagues() {
77
+ if (!auth.isAuthenticated()) {
78
+ console.error('Not authenticated. Run: npx yahoo-fantasy-api authenticate');
79
+ process.exit(1);
80
+ }
81
+
82
+ console.log('Discovering leagues...\n');
83
+ const leagues = await client.getUserLeagues(['mlb', 'nhl', 'nfl', 'nba']);
84
+
85
+ if (leagues.length === 0) {
86
+ console.log('No leagues found.');
87
+ return;
88
+ }
89
+
90
+ // Group by sport
91
+ const bySport = {};
92
+ for (const lg of leagues) {
93
+ if (!bySport[lg.sportName]) bySport[lg.sportName] = [];
94
+ bySport[lg.sportName].push(lg);
95
+ }
96
+
97
+ for (const [sport, sLeagues] of Object.entries(bySport)) {
98
+ console.log(`=== ${sport} ===`);
99
+ for (const lg of sLeagues) {
100
+ console.log(` ${lg.name}`);
101
+ console.log(` League Key: ${lg.leagueKey}`);
102
+ console.log(` League ID: ${lg.leagueId}`);
103
+ console.log(` Season: ${lg.season}`);
104
+ console.log(` Teams: ${lg.numTeams}`);
105
+ console.log(` Scoring: ${lg.scoringType}`);
106
+ console.log(` Draft: ${lg.draftStatus}`);
107
+ console.log();
108
+ }
109
+ }
110
+ }
111
+
112
+ async function leagueSettings() {
113
+ if (!auth.isAuthenticated()) {
114
+ console.error('Not authenticated. Run: npx yahoo-fantasy-api authenticate');
115
+ process.exit(1);
116
+ }
117
+
118
+ const specificKey = process.argv[3];
119
+
120
+ if (specificKey) {
121
+ // Fetch settings for a specific league
122
+ console.log(`Fetching settings for ${specificKey}...\n`);
123
+ const settings = await client.getLeagueSettings(specificKey);
124
+ console.log(JSON.stringify(settings, null, 2));
125
+ return;
126
+ }
127
+
128
+ // Fetch settings for all leagues
129
+ console.log('Discovering leagues...\n');
130
+ const leagues = await client.getUserLeagues(['mlb', 'nhl', 'nfl', 'nba']);
131
+
132
+ for (const lg of leagues) {
133
+ console.log(`\n${'='.repeat(60)}`);
134
+ console.log(`${lg.sportName} — ${lg.name} (${lg.leagueKey})`);
135
+ console.log('='.repeat(60));
136
+ try {
137
+ const settings = await client.getLeagueSettings(lg.leagueKey);
138
+ console.log(JSON.stringify(settings, null, 2));
139
+ } catch (e) {
140
+ console.error(` Error: ${e.message}`);
141
+ }
142
+ }
143
+ }
144
+
145
+ main().catch(e => {
146
+ console.error(e.message);
147
+ process.exit(1);
148
+ });
package/client.js ADDED
@@ -0,0 +1,255 @@
1
+ const BASE_URL = 'https://fantasysports.yahooapis.com/fantasy/v2';
2
+
3
+ class YahooClient {
4
+ constructor(auth, options = {}) {
5
+ this.auth = auth;
6
+ this.minInterval = options.minInterval || 2000;
7
+ this.userAgent = options.userAgent || 'yahoo-fantasy-api/1.0';
8
+ this._lastApiCall = 0;
9
+ this._queue = Promise.resolve(); // serialize calls for rate limiting
10
+ this._gameKeyCache = new Map(); // gameCode -> gameKey
11
+ this._log = options.log || auth._log || ((type, msg) => console.log(`[YahooClient] ${msg}`));
12
+ }
13
+
14
+ /**
15
+ * Rate-limited GET request to Yahoo Fantasy API.
16
+ * Handles 401 (token expired) and 999 (rate limited) with retry.
17
+ */
18
+ async get(endpoint) {
19
+ // Serialize all API calls through a queue to enforce rate limiting
20
+ // even under concurrent usage.
21
+ return this._queue = this._queue.then(
22
+ () => this._doGet(endpoint),
23
+ () => this._doGet(endpoint) // continue queue even if previous call failed
24
+ );
25
+ }
26
+
27
+ async _doGet(endpoint) {
28
+ await this.auth.getAccessToken();
29
+
30
+ // Rate limit our own calls
31
+ const now = Date.now();
32
+ const timeSinceLast = now - this._lastApiCall;
33
+ if (timeSinceLast < this.minInterval) {
34
+ await new Promise(r => setTimeout(r, this.minInterval - timeSinceLast));
35
+ }
36
+ this._lastApiCall = Date.now();
37
+
38
+ const url = `${BASE_URL}${endpoint}${endpoint.includes('?') ? '&' : '?'}format=json`;
39
+ this._log('api', `GET ${endpoint}`);
40
+
41
+ const makeRequest = async () => {
42
+ const controller = new AbortController();
43
+ const timeout = setTimeout(() => controller.abort(), 30000);
44
+ try {
45
+ return await fetch(url, {
46
+ headers: {
47
+ Authorization: `Bearer ${this.auth.token.access_token}`,
48
+ 'User-Agent': this.userAgent,
49
+ },
50
+ signal: controller.signal,
51
+ });
52
+ } finally {
53
+ clearTimeout(timeout);
54
+ }
55
+ };
56
+
57
+ let res = await makeRequest();
58
+
59
+ // Handle 401 - token expired
60
+ if (res.status === 401) {
61
+ this._log('warn', `401 on ${endpoint} — refreshing token`);
62
+ await this.auth.refreshToken();
63
+ res = await makeRequest();
64
+ if (res.status === 401) {
65
+ throw new Error('Yahoo API returned 401 after token refresh — re-authenticate');
66
+ }
67
+ }
68
+
69
+ // Handle 999 - Yahoo rate limiting
70
+ if (res.status === 999) {
71
+ this._log('warn', `999 rate limited on ${endpoint} — waiting 5s`);
72
+ await new Promise(r => setTimeout(r, 5000));
73
+ res = await makeRequest();
74
+ if (res.status === 999) {
75
+ this._log('error', `999 persists on ${endpoint} after retry`);
76
+ throw new Error('Yahoo rate limited (999). Try again in a moment.');
77
+ }
78
+ }
79
+
80
+ if (!res.ok) {
81
+ const text = await res.text();
82
+ this._log('error', `${res.status} on ${endpoint}: ${text.substring(0, 300)}`);
83
+ throw new Error(`Yahoo API error: ${res.status} on ${endpoint}`);
84
+ }
85
+
86
+ const data = await res.json();
87
+ this._log('api', `OK ${endpoint}`);
88
+ return data;
89
+ }
90
+
91
+ /**
92
+ * Resolve a game code ('mlb', 'nhl', etc.) to its numeric game key.
93
+ * Caches the result.
94
+ */
95
+ async resolveGameKey(gameCode) {
96
+ if (!gameCode) throw new Error('gameCode is required');
97
+ if (this._gameKeyCache.has(gameCode)) {
98
+ return this._gameKeyCache.get(gameCode);
99
+ }
100
+ const data = await this.get(`/game/${gameCode}`);
101
+ const gameKey = data.fantasy_content?.game?.[0]?.game_key;
102
+ if (!gameKey) throw new Error(`Could not resolve game key for '${gameCode}'`);
103
+ this._gameKeyCache.set(gameCode, gameKey);
104
+ this._log('info', `Resolved ${gameCode} game key: ${gameKey}`);
105
+ return gameKey;
106
+ }
107
+
108
+ /**
109
+ * Build a league key from game key + league ID.
110
+ */
111
+ leagueKey(gameKey, leagueId) {
112
+ return `${gameKey}.l.${leagueId}`;
113
+ }
114
+
115
+ /**
116
+ * Discover all leagues the authenticated user belongs to for the given game codes.
117
+ * Returns a flat array of league objects.
118
+ */
119
+ async getUserLeagues(gameCodes = ['mlb', 'nhl']) {
120
+ // Resolve all game keys first
121
+ const gameKeys = [];
122
+ for (const code of gameCodes) {
123
+ const key = await this.resolveGameKey(code);
124
+ gameKeys.push({ code, key });
125
+ }
126
+
127
+ const keyList = gameKeys.map(g => g.key).join(',');
128
+ const data = await this.get(`/users;use_login=1/games;game_keys=${keyList}/leagues`);
129
+
130
+ const leagues = [];
131
+ try {
132
+ const games = data.fantasy_content.users[0].user[1].games;
133
+ const gameCount = games.count;
134
+ for (let i = 0; i < gameCount; i++) {
135
+ const game = games[i].game;
136
+ const gameInfo = game[0];
137
+ const gameCode = gameInfo.code;
138
+ const gameKey = gameInfo.game_key;
139
+ const gameName = gameInfo.name;
140
+
141
+ if (game[1] && game[1].leagues) {
142
+ const leagueData = game[1].leagues;
143
+ const leagueCount = leagueData.count;
144
+ for (let j = 0; j < leagueCount; j++) {
145
+ const lg = leagueData[j].league[0];
146
+ leagues.push({
147
+ name: lg.name,
148
+ sport: gameCode,
149
+ sportName: gameName,
150
+ gameKey: gameKey,
151
+ leagueId: lg.league_id,
152
+ leagueKey: lg.league_key,
153
+ season: lg.season,
154
+ numTeams: lg.num_teams,
155
+ draftStatus: lg.draft_status,
156
+ scoringType: lg.scoring_type,
157
+ url: lg.url,
158
+ });
159
+ }
160
+ }
161
+ }
162
+ } catch (e) {
163
+ this._log('error', `Error parsing leagues response: ${e.message}`);
164
+ throw new Error(`Failed to parse leagues: ${e.message}`);
165
+ }
166
+
167
+ return leagues;
168
+ }
169
+
170
+ /**
171
+ * Fetch league settings (roster positions, stat categories, etc.)
172
+ *
173
+ * NOTE: Uses the subresource syntax `/league/{key};out=settings` rather
174
+ * than `/league/{key}/settings`. Yahoo's `/settings` sub-path returns a
175
+ * STALE predraft snapshot (frozen at draft time) and never refreshes —
176
+ * `is_auction_draft`, `uses_faab`, `draft_status`, etc. all come back wrong.
177
+ * The `;out=settings` subresource syntax returns fresh post-draft values.
178
+ */
179
+ async getLeagueSettings(leagueKey) {
180
+ if (!leagueKey) throw new Error('leagueKey is required');
181
+ const data = await this.get(`/league/${leagueKey};out=settings`);
182
+ try {
183
+ const league = data.fantasy_content.league;
184
+ const meta = league[0];
185
+ const settings = league[1].settings[0];
186
+ return { meta, settings };
187
+ } catch (e) {
188
+ throw new Error(`Failed to parse league settings: ${e.message}`);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Fetch draft results for a league. For auction drafts, each pick includes
194
+ * the auction `cost` (dollars paid). Returns an array of:
195
+ * { pick, round, cost, teamKey, playerKey }
196
+ *
197
+ * Draft results don't change after the draft completes, so this is safe
198
+ * to cache for the entire season.
199
+ */
200
+ async getDraftResults(leagueKey) {
201
+ if (!leagueKey) throw new Error('leagueKey is required');
202
+ const data = await this.get(`/league/${leagueKey}/draftresults`);
203
+ try {
204
+ const dr = data.fantasy_content.league[1].draft_results;
205
+ const count = dr.count;
206
+ const picks = [];
207
+ for (let i = 0; i < count; i++) {
208
+ const result = dr[i].draft_result;
209
+ picks.push({
210
+ pick: result.pick,
211
+ round: result.round,
212
+ cost: result.cost != null ? Number(result.cost) : null,
213
+ teamKey: result.team_key,
214
+ playerKey: result.player_key,
215
+ });
216
+ }
217
+ return picks;
218
+ } catch (e) {
219
+ throw new Error(`Failed to parse draft results: ${e.message}`);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Fetch all teams in a league.
225
+ */
226
+ async getLeagueTeams(leagueKey) {
227
+ if (!leagueKey) throw new Error('leagueKey is required');
228
+ const data = await this.get(`/league/${leagueKey}/teams`);
229
+ try {
230
+ const teams = [];
231
+ const teamsData = data.fantasy_content.league[1].teams;
232
+ const count = teamsData.count;
233
+ for (let i = 0; i < count; i++) {
234
+ const team = teamsData[i].team[0];
235
+ const info = {};
236
+ for (const item of team) {
237
+ if (typeof item === 'object' && !Array.isArray(item)) {
238
+ Object.assign(info, item);
239
+ }
240
+ }
241
+ teams.push({
242
+ teamKey: info.team_key,
243
+ teamId: info.team_id,
244
+ name: info.name,
245
+ managerName: info.managers?.[0]?.manager?.nickname,
246
+ });
247
+ }
248
+ return teams;
249
+ } catch (e) {
250
+ throw new Error(`Failed to parse league teams: ${e.message}`);
251
+ }
252
+ }
253
+ }
254
+
255
+ module.exports = { YahooClient };
package/index.js ADDED
@@ -0,0 +1,16 @@
1
+ const { YahooAuth } = require('./auth');
2
+ const { YahooClient } = require('./client');
3
+
4
+ /**
5
+ * Convenience factory: creates a YahooAuth + YahooClient pair.
6
+ *
7
+ * @param {Object} options - { clientId, clientSecret, redirectUri, tokenFile, certsDir, minInterval, userAgent, log }
8
+ * @returns {{ auth: YahooAuth, client: YahooClient }}
9
+ */
10
+ function createClient(options = {}) {
11
+ const auth = new YahooAuth(options);
12
+ const client = new YahooClient(auth, options);
13
+ return { auth, client };
14
+ }
15
+
16
+ module.exports = { YahooAuth, YahooClient, createClient };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "yahoo-fantasy-api",
3
+ "version": "1.0.0",
4
+ "description": "Yahoo Fantasy Sports API client for Node.js — OAuth 2.0, rate limiting, and CLI tools",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "yahoo-fantasy-api": "cli.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "auth.js",
12
+ "client.js",
13
+ "cli.js",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "keywords": [
18
+ "yahoo",
19
+ "fantasy",
20
+ "sports",
21
+ "api",
22
+ "oauth",
23
+ "fantasy-sports",
24
+ "fantasy-baseball",
25
+ "fantasy-hockey",
26
+ "fantasy-football"
27
+ ],
28
+ "author": "PeregrineCode",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/PeregrineCode/yahoo-fantasy-api.git"
33
+ },
34
+ "homepage": "https://github.com/PeregrineCode/yahoo-fantasy-api#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/PeregrineCode/yahoo-fantasy-api/issues"
37
+ },
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "dependencies": {},
42
+ "optionalDependencies": {
43
+ "dotenv": "^17.3.1"
44
+ }
45
+ }