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 +21 -0
- package/README.md +136 -0
- package/auth.js +307 -0
- package/cli.js +148 -0
- package/client.js +255 -0
- package/index.js +16 -0
- package/package.json +45 -0
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
|
+
}
|