sleeper_api 1.3.1 → 1.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/CLAUDE.md +26 -0
- data/lib/sleeper_api/client.rb +116 -8
- data/lib/sleeper_api/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0fdd2ef6194210448caf049cdd89a37182bcd13205e45ea9543945c172d36fd4
|
|
4
|
+
data.tar.gz: 811a0991f5c863956c62c637971571aef98ccd379f9a3bff4b33ad1d28405290
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16f575a6d822157d44b7d5aef135626371e0fb03ed8359c70abf82f49bf6d9ae1373ff7a5ee49cbf4c32986593eec23e838b08e70ce5306e29d5fb3df5229be4
|
|
7
|
+
data.tar.gz: b7b325b17bfd0c9d921ec16dbaa07fdcf6f277d242732db1f414d11f0c5846664ac300c2fc64f2401a3ef54402c112b8a9e89651f986ba3628a5fdcabc38f394
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
+
## [1.5.0] - 2026-09-25
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **`Client#scores(season, week = nil)` — games with their kickoff times.** Each
|
|
6
|
+
game carries `start_time`, epoch milliseconds, which is **the only kickoff
|
|
7
|
+
time Sleeper publishes**: `#schedule` has a `date` and nothing finer. Measured
|
|
8
|
+
2026-09-25 against ATL @ GB, the previous night's Thursday game — its
|
|
9
|
+
`start_time` is 2026-09-25T00:15Z and agrees with `metadata.date_time` on
|
|
10
|
+
every game of the week.
|
|
11
|
+
|
|
12
|
+
⚠️ **Also on `api.sleeper.com`**, like `#stats_with_context`, and with a
|
|
13
|
+
different shape: the season type is a **path segment**
|
|
14
|
+
(`/scores/nfl/regular/2026/3`), and the query-parameter form answers 200 with
|
|
15
|
+
nothing.
|
|
16
|
+
|
|
17
|
+
**Leave the week off for the whole season** — all 272 games of 2026 in one
|
|
18
|
+
~610 KB call (~93 KB gzipped), against ~57 KB a week. `metadata` is the live
|
|
19
|
+
game (`quarter`, `time_remaining`, quarter scores, `is_in_progress`) plus the
|
|
20
|
+
TV channel, spread and forecast. Its `status` vocabulary (`scheduled`,
|
|
21
|
+
`closed`) differs from the top level's (`pre_game`, `complete`), and neither
|
|
22
|
+
value a live game carries has been read yet. Nothing 404s: week 19 and a
|
|
23
|
+
garbage season type answer `[]`.
|
|
24
|
+
|
|
25
|
+
## [1.4.0] - 2026-09-14
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`Client#stats_with_context(season, week)` — one week's stat lines carrying
|
|
30
|
+
the week they were played in.** Each row has the player's team *that week*,
|
|
31
|
+
their `opponent`, the `game_id` and the `date`, which the existing `#stats`
|
|
32
|
+
endpoint does not return at all.
|
|
33
|
+
|
|
34
|
+
⚠️ **It is served from a different host: `api.sleeper.com`, not
|
|
35
|
+
`api.sleeper.app`.** Same sport and season in the path, no `/v1`, and the
|
|
36
|
+
season type is a query parameter rather than a segment. The `.app` endpoint
|
|
37
|
+
answers the *same stat lines with none of this metadata*, which is how it sat
|
|
38
|
+
unfound through four epics of stats work in the consuming app.
|
|
39
|
+
|
|
40
|
+
**What is genuinely per-week, measured** over the 2,275 players present in
|
|
41
|
+
both week 1 and week 16 of 2021: `team` differs on **164** of them and
|
|
42
|
+
`opponent` on **2,262**. ⚠️ **The nested `player` object is not history** — it
|
|
43
|
+
is the current catalog record stapled on, with `team` null inside it and
|
|
44
|
+
`injury_status` differing on 1 of the 204 rows carrying one across those
|
|
45
|
+
fifteen weeks. A 2021 row reporting "Questionable" is reporting that he is
|
|
46
|
+
questionable *now*.
|
|
47
|
+
|
|
48
|
+
⚠️ **Pass a week.** Dropping it answers season totals — 8,251 rows for 2021 —
|
|
49
|
+
with `week` and `opponent` null on every one, so the season form carries none
|
|
50
|
+
of the context the endpoint exists for. Shares `#stats`' traps otherwise:
|
|
51
|
+
nothing 404s, and `pre`/`post` restart week numbering. An array of rows, not
|
|
52
|
+
a hash keyed by player id.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **`make_request` takes an optional `host:`**, which is how the second host is
|
|
57
|
+
reached. ⚠️ **Passing an absolute URL instead does not work**: HTTParty 0.24
|
|
58
|
+
raises `UnsafeURIError` for any URL whose host differs from the configured
|
|
59
|
+
`base_uri` — "this request could send credentials to an unintended server" —
|
|
60
|
+
so a second host has to arrive as its own per-request `base_uri`.
|
|
61
|
+
|
|
62
|
+
**Errors and logs now name the host whenever it is not the default.**
|
|
63
|
+
`/stats/nfl/2021/16` is a real path on both hosts and they return different
|
|
64
|
+
things, so a message quoting the path alone cannot say which one failed.
|
|
65
|
+
Messages for paths on `api.sleeper.app` are byte-for-byte what they were.
|
|
66
|
+
|
|
1
67
|
## [1.3.1] - 2026-09-10
|
|
2
68
|
|
|
3
69
|
### Fixed
|
data/CLAUDE.md
CHANGED
|
@@ -29,6 +29,32 @@ Four layers, with a deliberate split between HTTP and modeling:
|
|
|
29
29
|
- **`Client`** — the only thing that talks HTTP. `include HTTParty` with `base_uri "https://api.sleeper.app"` — **the bare host; the `/v1` lives in each path**. That is deliberate and load-bearing: not every Sleeper endpoint is versioned. `/schedule/{sport}/{season_type}/{season}` is served from the host root, and while `base_uri` carried the version that endpoint was unreachable at any path a caller could pass in. A new endpoint spells out where it lives; do not move the version back into `base_uri` to shorten the paths.
|
|
30
30
|
|
|
31
31
|
Every call funnels through the private `make_request`, which handles retry-on-timeout, logging, and converts non-2xx into `SleeperApi::Error`. **That error quotes the path**, so the path prefix is part of a public string — v1.2.0 changed it from `"Failed to fetch /user/x: 404"` to `"Failed to fetch /v1/user/x: 404"`. `Client` also owns the 24-hour in-memory player cache.
|
|
32
|
+
|
|
33
|
+
⚠️ **There are two Sleeper hosts and v1.4.0 added the second.** `WEB_HOST`
|
|
34
|
+
(`https://api.sleeper.com`) is the one the web app uses, and it serves
|
|
35
|
+
**richer rows from same-looking paths**: `#stats_with_context` returns the
|
|
36
|
+
player's team *that week* and their opponent, while `#stats` on `.app`
|
|
37
|
+
returns the same stat lines with neither. Reached with `make_request(path,
|
|
38
|
+
host: WEB_HOST)` rather than by moving `base_uri`, because every other
|
|
39
|
+
endpoint lives on `.app`.
|
|
40
|
+
|
|
41
|
+
**Passing an absolute URL instead does not work**, and the failure is not
|
|
42
|
+
subtle: HTTParty 0.24 raises `UnsafeURIError` for any URL whose host differs
|
|
43
|
+
from the configured `base_uri` — *"this request could send credentials to an
|
|
44
|
+
unintended server"* — so a second host must arrive as its own per-request
|
|
45
|
+
`base_uri`. **The error and the log name the host whenever it is not the
|
|
46
|
+
default**, because `/stats/nfl/2021/16` is a real path on both and they
|
|
47
|
+
return different things; paths on `.app` quote exactly as they did before.
|
|
48
|
+
|
|
49
|
+
v1.5.0 added a second `WEB_HOST` endpoint, `#scores`, the only source of a
|
|
50
|
+
**kickoff time** — and its season type is a path segment where
|
|
51
|
+
`#stats_with_context` takes a query parameter. The host does not have one
|
|
52
|
+
convention; each path is measured on its own.
|
|
53
|
+
|
|
54
|
+
**When an endpoint almost has what you need, try the other host before
|
|
55
|
+
concluding the data does not exist.** Four epics of stats work in the
|
|
56
|
+
consuming app never surfaced the per-week team, because the documented-looking
|
|
57
|
+
path on `.app` answers 200 with a payload that simply omits it.
|
|
32
58
|
- **`League` / `User` / `Draft`** — resource objects. Each takes `(id, client)`, fetches eagerly in the constructor, memoizes into ivars, and exposes formatted hashes.
|
|
33
59
|
- **`Helpers`** — mixed into all four. `deep_symbolize_keys` plus `player_details`, which reaches through `@client` — so any class including it must define `@client`.
|
|
34
60
|
|
data/lib/sleeper_api/client.rb
CHANGED
|
@@ -22,6 +22,12 @@ module SleeperApi
|
|
|
22
22
|
# pass to make_request.
|
|
23
23
|
base_uri "https://api.sleeper.app"
|
|
24
24
|
|
|
25
|
+
# ⚠️ **The other Sleeper host.** The web app talks to `api.sleeper.com`,
|
|
26
|
+
# which serves *richer rows from same-looking paths* — see
|
|
27
|
+
# #stats_with_context. Reached per-request rather than by moving base_uri,
|
|
28
|
+
# because every other endpoint here lives on `.app`.
|
|
29
|
+
WEB_HOST = "https://api.sleeper.com".freeze
|
|
30
|
+
|
|
25
31
|
# Not HTTParty's own. Its JSON branch passes `quirks_mode`, which json 3.0
|
|
26
32
|
# removed, so without this every response raises ArgumentError the moment a
|
|
27
33
|
# consumer resolves json 3. See JsonParser.
|
|
@@ -285,6 +291,92 @@ module SleeperApi
|
|
|
285
291
|
make_request(weekly_path("projections", sport, season_type, season, week))
|
|
286
292
|
end
|
|
287
293
|
|
|
294
|
+
# Get one week's stat lines **with the context of the week they were played
|
|
295
|
+
# in** — the player's team *that week*, their opponent, the game and its
|
|
296
|
+
# date. Probed 2026-09-08, re-probed 2026-09-14.
|
|
297
|
+
#
|
|
298
|
+
# ⚠️ **A different host: `api.sleeper.com`, not `api.sleeper.app`.** The
|
|
299
|
+
# `.app` endpoint #stats calls returns the *same stat lines with none of
|
|
300
|
+
# this metadata*, which is why it can be there for years without anyone
|
|
301
|
+
# finding it. Same sport and season in the path, but no `/v1` and the
|
|
302
|
+
# season type is a query parameter rather than a segment.
|
|
303
|
+
#
|
|
304
|
+
# **What is genuinely per-week, measured** over the 2,275 players present in
|
|
305
|
+
# both week 1 and week 16 of 2021: `team` differs on **164** of them (a
|
|
306
|
+
# midseason trade is a real change of team) and `opponent` on **2,262**. So
|
|
307
|
+
# both are history rather than today's catalog.
|
|
308
|
+
#
|
|
309
|
+
# ⚠️ **The nested `player` object is NOT history.** It is the current
|
|
310
|
+
# catalog record stapled on — `player.team` is null inside it, and
|
|
311
|
+
# `injury_status` differed on 1 of the 204 rows carrying one across fifteen
|
|
312
|
+
# weeks, which is the signature of a field that does not vary by week at
|
|
313
|
+
# all. A 2021 row reporting "Questionable" is reporting that he is
|
|
314
|
+
# questionable *now*. The top-level `status` is null on every row seen.
|
|
315
|
+
#
|
|
316
|
+
# ⚠️ **Pass a week.** Dropping it answers season totals — 8,251 rows for
|
|
317
|
+
# 2021 — with `week` and `opponent` null on every one, so the season form
|
|
318
|
+
# carries none of the context this endpoint exists for.
|
|
319
|
+
#
|
|
320
|
+
# Shares #stats' traps: nothing 404s (an unplayed 2026 week, a week out of
|
|
321
|
+
# range, a season before Sleeper's history and a garbage season type all
|
|
322
|
+
# answer `200` with `[]`), and `pre`/`post` restart week numbering — 2021
|
|
323
|
+
# `post` week 1 is the wildcard round, played 2022-01-15.
|
|
324
|
+
#
|
|
325
|
+
# **An array, not a hash.** #stats keys by player id; this returns a list of
|
|
326
|
+
# rows each carrying `player_id`, so a caller indexing it must build its own
|
|
327
|
+
# map. `/projections/nfl/{season}/{week}` on the same host answers the same
|
|
328
|
+
# shape (9,420 rows for 2021 week 16) and has no wrapper here yet.
|
|
329
|
+
#
|
|
330
|
+
# @param season [Integer, String] Season year, e.g. 2021
|
|
331
|
+
# @param week [Integer, String] Week number — required, see above
|
|
332
|
+
# @param season_type [String] "regular" (default), "pre", or "post"
|
|
333
|
+
# @param sport [String] Sport code (default: "nfl")
|
|
334
|
+
# @return [HTTParty::Response] Array of stat lines, each with `team`,
|
|
335
|
+
# `opponent`, `game_id`, `date`, `week`, `season` and a nested `player`
|
|
336
|
+
def stats_with_context(season, week, season_type: "regular", sport: "nfl")
|
|
337
|
+
segments = [sport, season, week].map { |segment| ERB::Util.url_encode(segment.to_s) }
|
|
338
|
+
query = ERB::Util.url_encode(season_type.to_s)
|
|
339
|
+
|
|
340
|
+
make_request("/stats/#{segments.join("/")}?season_type=#{query}", host: WEB_HOST)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Get games with their kickoff times — undocumented, on the web host.
|
|
344
|
+
#
|
|
345
|
+
# **The only kickoff time Sleeper publishes.** #schedule carries a `date`
|
|
346
|
+
# and nothing finer; each game here has `start_time`, epoch milliseconds —
|
|
347
|
+
# 2026's Thursday opener of week 3, ATL @ GB, is 1790295300000, which is
|
|
348
|
+
# 2026-09-25T00:15Z and agrees with `metadata.date_time` on every game.
|
|
349
|
+
# Measured 2026-09-25, the morning after that game.
|
|
350
|
+
#
|
|
351
|
+
# `metadata` is the live game: `quarter`, `time_remaining`, the score by
|
|
352
|
+
# quarter, `is_in_progress`, `is_over`, plus the TV `channel`, the spread and
|
|
353
|
+
# a forecast. Its `status` is a different vocabulary from the top level's —
|
|
354
|
+
# `scheduled`/`closed` against `pre_game`/`complete` — and neither value a
|
|
355
|
+
# live game carries has been read here yet. Match with a fallback.
|
|
356
|
+
#
|
|
357
|
+
# **Leave the week off for the whole season** — all 272 games of 2026 in one
|
|
358
|
+
# call, ~610 KB (~93 KB gzipped), against ~57 KB a week. Unlike
|
|
359
|
+
# #stats_with_context, the season form is useful.
|
|
360
|
+
#
|
|
361
|
+
# The season type is a **path segment**, not a query parameter —
|
|
362
|
+
# `/scores/nfl/2026/3?season_type=regular` answers 200 with nothing. Shares
|
|
363
|
+
# the host's traps: nothing 404s (week 19, a garbage season type all answer
|
|
364
|
+
# `200` with `[]`), and `pre`/`post` restart week numbering.
|
|
365
|
+
#
|
|
366
|
+
# @param season [Integer, String] Season year, e.g. 2026
|
|
367
|
+
# @param week [Integer, String, nil] Week number; nil for the whole season
|
|
368
|
+
# @param season_type [String] "regular" (default), "pre", or "post"
|
|
369
|
+
# @param sport [String] Sport code (default: "nfl")
|
|
370
|
+
# @return [HTTParty::Response] Array of games, each with `game_id`, `week`,
|
|
371
|
+
# `status`, `start_time`, `date` and a nested `metadata`
|
|
372
|
+
def scores(season, week = nil, season_type: "regular", sport: "nfl")
|
|
373
|
+
segments = [sport, season_type, season, week].compact.map do |segment|
|
|
374
|
+
ERB::Util.url_encode(segment.to_s)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
make_request("/scores/#{segments.join("/")}", host: WEB_HOST)
|
|
378
|
+
end
|
|
379
|
+
|
|
288
380
|
# Get a season's game schedule.
|
|
289
381
|
#
|
|
290
382
|
# Undocumented, and served from the host root rather than /v1 — hence the
|
|
@@ -383,29 +475,45 @@ module SleeperApi
|
|
|
383
475
|
|
|
384
476
|
# Make an HTTP request with retry logic and logging.
|
|
385
477
|
#
|
|
478
|
+
# ⚠️ **`host:` cannot be done by passing an absolute URL instead.** HTTParty
|
|
479
|
+
# 0.24 raises `UnsafeURIError` for any URL whose host differs from the
|
|
480
|
+
# configured `base_uri` — "this request could send credentials to an
|
|
481
|
+
# unintended server" — so a second host has to arrive as its own
|
|
482
|
+
# `base_uri`, which is a per-request option it supports.
|
|
483
|
+
#
|
|
484
|
+
# **The error and the log name the host whenever it is not the default.**
|
|
485
|
+
# `/stats/nfl/2021/16` is a real path on both hosts and they return
|
|
486
|
+
# different things, so a message quoting the path alone cannot say which
|
|
487
|
+
# one failed. Paths on the default host quote exactly as they always have.
|
|
488
|
+
#
|
|
386
489
|
# @param path [String] API endpoint path
|
|
490
|
+
# @param host [String, nil] a different host, e.g. WEB_HOST
|
|
387
491
|
# @return [HTTParty::Response]
|
|
388
492
|
# @raise [SleeperApi::Error] On HTTP errors or timeouts
|
|
389
|
-
def make_request(path)
|
|
390
|
-
|
|
493
|
+
def make_request(path, host: nil)
|
|
494
|
+
named = host ? "#{host}#{path}" : path
|
|
495
|
+
options = { timeout: @config.timeout }
|
|
496
|
+
options[:base_uri] = host if host
|
|
497
|
+
|
|
498
|
+
@config.logger&.info("Making request to #{host || self.class.base_uri}#{path}")
|
|
391
499
|
retries = 0
|
|
392
500
|
begin
|
|
393
|
-
response = self.class.get(path,
|
|
501
|
+
response = self.class.get(path, **options)
|
|
394
502
|
if response.success?
|
|
395
|
-
@config.logger&.info("Successful response for #{
|
|
503
|
+
@config.logger&.info("Successful response for #{named}")
|
|
396
504
|
response
|
|
397
505
|
else
|
|
398
|
-
@config.logger&.error("Failed to fetch #{
|
|
399
|
-
raise SleeperApi::Error, "Failed to fetch #{
|
|
506
|
+
@config.logger&.error("Failed to fetch #{named}: #{response.code}")
|
|
507
|
+
raise SleeperApi::Error, "Failed to fetch #{named}: #{response.code}"
|
|
400
508
|
end
|
|
401
509
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
402
510
|
retries += 1
|
|
403
511
|
if retries <= @config.retries
|
|
404
|
-
@config.logger&.warn("Retrying #{
|
|
512
|
+
@config.logger&.warn("Retrying #{named} (attempt #{retries}/#{@config.retries}) due to #{e}")
|
|
405
513
|
sleep(1)
|
|
406
514
|
retry
|
|
407
515
|
else
|
|
408
|
-
@config.logger&.error("Request timed out for #{
|
|
516
|
+
@config.logger&.error("Request timed out for #{named} after #{retries} retries")
|
|
409
517
|
raise SleeperApi::Error, "Request timed out after #{retries} retries"
|
|
410
518
|
end
|
|
411
519
|
end
|
data/lib/sleeper_api/version.rb
CHANGED