| 1 |
<?php |
| 2 |
/** |
| 3 |
* api.php |
| 4 |
* |
| 5 |
* Talks to ESPN's public site API using only PHP's built-in cURL |
| 6 |
* extension (no external libraries). |
| 7 |
*/ |
| 8 |
|
| 9 |
define('ESPN_BASE', 'http://site.api.espn.com/apis/site/v2/sports'); |
| 10 |
|
| 11 |
/** |
| 12 |
* Fetch several JSON API URLs in parallel using curl_multi so a page |
| 13 |
* with several teams doesn't wait on each request one at a time. |
| 14 |
*/ |
| 15 |
function fetch_json_multi(array $urls, ?callable $log = null): array |
| 16 |
{ |
| 17 |
if (empty($urls)) { |
| 18 |
return []; |
| 19 |
} |
| 20 |
|
| 21 |
$mh = curl_multi_init(); |
| 22 |
$handles = []; |
| 23 |
|
| 24 |
foreach ($urls as $key => $url) { |
| 25 |
$ch = curl_init($url); |
| 26 |
curl_setopt_array($ch, [ |
| 27 |
CURLOPT_RETURNTRANSFER => true, |
| 28 |
CURLOPT_TIMEOUT => 8, |
| 29 |
CURLOPT_CONNECTTIMEOUT => 5, |
| 30 |
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; LocalSportsSite/1.0)', |
| 31 |
CURLOPT_HTTPHEADER => ['Accept: application/json'], |
| 32 |
// Follow http->https (and any other) redirects. ESPN's endpoints |
| 33 |
// are requested over http:// in a few places; if the server ever |
| 34 |
// 301/302s to https, without this the response would be a redirect |
| 35 |
// body with a non-200 code and get discarded as a failure. |
| 36 |
CURLOPT_FOLLOWLOCATION => true, |
| 37 |
CURLOPT_MAXREDIRS => 3, |
| 38 |
]); |
| 39 |
curl_multi_add_handle($mh, $ch); |
| 40 |
$handles[$key] = $ch; |
| 41 |
} |
| 42 |
|
| 43 |
$running = null; |
| 44 |
do { |
| 45 |
$status = curl_multi_exec($mh, $running); |
| 46 |
if ($running) { |
| 47 |
curl_multi_select($mh); |
| 48 |
} |
| 49 |
} while ($running > 0 && $status === CURLM_OK); |
| 50 |
|
| 51 |
$results = []; |
| 52 |
foreach ($handles as $key => $ch) { |
| 53 |
$body = curl_multi_getcontent($ch); |
| 54 |
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); |
| 55 |
$errNo = curl_errno($ch); |
| 56 |
$errMsg = curl_error($ch); |
| 57 |
$decoded = ($body && $httpCode === 200) ? json_decode($body, true) : null; |
| 58 |
$results[$key] = $decoded; |
| 59 |
|
| 60 |
if ($log) { |
| 61 |
$log(sprintf( |
| 62 |
'fetch key=%s http=%d bodyBytes=%d curlErr=%d%s decodedOk=%s', |
| 63 |
$key, |
| 64 |
$httpCode, |
| 65 |
is_string($body) ? strlen($body) : 0, |
| 66 |
$errNo, |
| 67 |
$errMsg ? " (\"$errMsg\")" : '', |
| 68 |
$decoded === null ? 'NO' : 'yes' |
| 69 |
)); |
| 70 |
} |
| 71 |
|
| 72 |
curl_multi_remove_handle($mh, $ch); |
| 73 |
curl_close($ch); |
| 74 |
} |
| 75 |
curl_multi_close($mh); |
| 76 |
|
| 77 |
return $results; |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Build the ESPN schedule URL for a team. |
| 82 |
*/ |
| 83 |
/** |
| 84 |
* Turn a $MAJOR_EVENTS entry's 'window_months' into an actual |
| 85 |
* YYYYMMDD-YYYYMMDD range for the current year (ESPN's scoreboard |
| 86 |
* endpoint only returns "today" without a dates range, so without |
| 87 |
* this, postseason games sitting outside the current window would |
| 88 |
* never show up at all). |
| 89 |
* |
| 90 |
* Handles wraparound for championships whose window crosses into the |
| 91 |
* following January relative to when their season started (e.g. the |
| 92 |
* Super Bowl, played in Feb of the year *after* the season's fall |
| 93 |
* start) via 'spans_new_year'. |
| 94 |
*/ |
| 95 |
function major_event_dates_range(array $months, bool $spansNewYear): string |
| 96 |
{ |
| 97 |
sort($months); |
| 98 |
$firstMonth = $months[0]; |
| 99 |
$lastMonth = end($months); |
| 100 |
|
| 101 |
$now = (int) date('Y'); |
| 102 |
$prev = $now - 1; |
| 103 |
|
| 104 |
if ($spansNewYear) { |
| 105 |
// e.g. months = [1, 2] means "Jan-Feb of this year" for a |
| 106 |
// season that started last fall; also include next Jan-Feb in |
| 107 |
// case we're currently in the fall portion of the same season. |
| 108 |
$startYear = $now; |
| 109 |
$endYear = $now; |
| 110 |
} else { |
| 111 |
$startYear = $now; |
| 112 |
$endYear = $now; |
| 113 |
} |
| 114 |
|
| 115 |
$start = sprintf('%04d%02d01', $startYear, $firstMonth); |
| 116 |
$lastDay = (int) date('t', mktime(0, 0, 0, $lastMonth, 1, $endYear)); |
| 117 |
$end = sprintf('%04d%02d%02d', $endYear, $lastMonth, $lastDay); |
| 118 |
|
| 119 |
return "{$start}-{$end}"; |
| 120 |
} |
| 121 |
|
| 122 |
function schedule_url(string $sport, string $league, string $abbr): string |
| 123 |
{ |
| 124 |
return ESPN_BASE . "/{$sport}/{$league}/teams/{$abbr}/schedule"; |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* Build an ESPN scoreboard URL for a whole league, optionally scoped to |
| 129 |
* a postseason window via a date range (YYYYMMDD-YYYYMMDD) so we don't |
| 130 |
* have to guess week numbers. A wide range is fine — the scoreboard |
| 131 |
* endpoint just returns whatever games fall inside it. |
| 132 |
* |
| 133 |
* $extraParams lets a caller add query args. This matters for college |
| 134 |
* basketball: its scoreboard endpoint returns HTTP 404 for a dates |
| 135 |
* range unless 'groups=50' (all Division I) is also present, so those |
| 136 |
* major-event entries pass ['groups' => '50']. |
| 137 |
*/ |
| 138 |
function scoreboard_url(string $sport, string $league, ?string $datesRange = null, array $extraParams = []): string |
| 139 |
{ |
| 140 |
$url = ESPN_BASE . "/{$sport}/{$league}/scoreboard?limit=100"; |
| 141 |
if ($datesRange) { |
| 142 |
$url .= "&dates={$datesRange}"; |
| 143 |
} |
| 144 |
foreach ($extraParams as $k => $v) { |
| 145 |
$url .= '&' . urlencode((string) $k) . '=' . urlencode((string) $v); |
| 146 |
} |
| 147 |
return $url; |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* From a decoded scoreboard payload, find the single game that looks |
| 152 |
* like "the championship" for that league. The checks that must pass: |
| 153 |
* |
| 154 |
* 1. The game must be completed. |
| 155 |
* 2. If $requiresPostseason is true, the event's season type must be |
| 156 |
* postseason (ESPN's season.type === 3). This is the important |
| 157 |
* guard for US leagues — without it, a plain "any completed game |
| 158 |
* on today's scoreboard" search will happily grab a random |
| 159 |
* regular-season game (e.g. an MLB game completed today in July) |
| 160 |
* and mislabel it as "the World Series." Single-elimination |
| 161 |
* international tournaments (World Cup, Champions League) don't |
| 162 |
* reliably expose the same season-type field in ESPN's soccer |
| 163 |
* payloads, so callers pass false for those and rely on keyword |
| 164 |
* matching alone — appropriate since essentially every match in |
| 165 |
* those competitions is part of a knockout/group stage anyway, |
| 166 |
* and "final" in the title is the meaningful signal. |
| 167 |
* 3. The event's name/shortName/notes headline should match one of |
| 168 |
* the given title keywords (e.g. "World Series", "Super Bowl"), |
| 169 |
* to distinguish the actual final from an earlier round that's |
| 170 |
* also completed (and, for US leagues, also postseason). |
| 171 |
* |
| 172 |
* If nothing qualifies yet (e.g. mid regular season), this correctly |
| 173 |
* returns null rather than guessing. |
| 174 |
* |
| 175 |
* Falls back to the most recent completed game that passed the |
| 176 |
* postseason gate (when applicable) if no keyword match is found, so |
| 177 |
* a naming convention change doesn't silently produce nothing. |
| 178 |
*/ |
| 179 |
function find_championship_game(?array $scoreboardData, array $titleKeywords, bool $requiresPostseason = true): ?array |
| 180 |
{ |
| 181 |
if (!$scoreboardData || empty($scoreboardData['events'])) { |
| 182 |
return null; |
| 183 |
} |
| 184 |
|
| 185 |
$candidates = []; |
| 186 |
foreach ($scoreboardData['events'] as $event) { |
| 187 |
$competition = $event['competitions'][0] ?? null; |
| 188 |
$completedFlag = $competition['status']['type']['completed'] ?? false; |
| 189 |
if (!$competition || !$completedFlag) { |
| 190 |
continue; |
| 191 |
} |
| 192 |
|
| 193 |
if ($requiresPostseason) { |
| 194 |
// Require postseason. ESPN represents this a couple of |
| 195 |
// different ways depending on sport/endpoint, so check |
| 196 |
// what's available rather than assuming one exact shape. |
| 197 |
$seasonType = $event['season']['type'] |
| 198 |
?? $scoreboardData['season']['type'] |
| 199 |
?? null; |
| 200 |
$seasonSlug = strtolower($event['season']['slug'] ?? $scoreboardData['season']['slug'] ?? ''); |
| 201 |
|
| 202 |
$isPostseason = ($seasonType === 3 || $seasonType === '3' || $seasonSlug === 'post-season' || $seasonSlug === 'postseason'); |
| 203 |
|
| 204 |
if (!$isPostseason) { |
| 205 |
continue; |
| 206 |
} |
| 207 |
} |
| 208 |
|
| 209 |
$candidates[] = $event; |
| 210 |
} |
| 211 |
|
| 212 |
if (empty($candidates)) { |
| 213 |
return null; |
| 214 |
} |
| 215 |
|
| 216 |
usort($candidates, function ($a, $b) { |
| 217 |
return strtotime($b['date'] ?? 'now') <=> strtotime($a['date'] ?? 'now'); |
| 218 |
}); |
| 219 |
|
| 220 |
// Prefer a game whose name/shortName/notes headline matches a known |
| 221 |
// championship title (handles leagues/tournaments that keep other |
| 222 |
// rounds in the same scoreboard window). |
| 223 |
foreach ($candidates as $event) { |
| 224 |
$haystack = strtolower( |
| 225 |
($event['name'] ?? '') . ' ' . |
| 226 |
($event['shortName'] ?? '') . ' ' . |
| 227 |
(($event['competitions'][0]['notes'][0]['headline'] ?? '') ?? '') |
| 228 |
); |
| 229 |
foreach ($titleKeywords as $keyword) { |
| 230 |
if (str_contains($haystack, strtolower($keyword))) { |
| 231 |
return $event; |
| 232 |
} |
| 233 |
} |
| 234 |
} |
| 235 |
|
| 236 |
// No keyword requires an exact match to be meaningful when we |
| 237 |
// couldn't gate on postseason at all — in that case, don't guess. |
| 238 |
if (!$requiresPostseason) { |
| 239 |
return null; |
| 240 |
} |
| 241 |
|
| 242 |
// Fallback: most recent completed postseason game overall (still |
| 243 |
// gated by the postseason check above — never a regular-season game). |
| 244 |
return $candidates[0]; |
| 245 |
} |
| 246 |
|
| 247 |
/** |
| 248 |
* Turn a championship scoreboard event into the same simple shape the |
| 249 |
* regional game cards use, but from a neutral (no home team) point of |
| 250 |
* view since there's no "our team" for a nationally-watched final. |
| 251 |
*/ |
| 252 |
function summarize_championship_game(array $event, string $eventLabel): ?array |
| 253 |
{ |
| 254 |
$competition = $event['competitions'][0] ?? null; |
| 255 |
if (!$competition || empty($competition['competitors'])) { |
| 256 |
return null; |
| 257 |
} |
| 258 |
|
| 259 |
$competitors = $competition['competitors']; |
| 260 |
if (count($competitors) < 2) { |
| 261 |
return null; |
| 262 |
} |
| 263 |
|
| 264 |
// ESPN puts the winner first in some sports and marks homeAway |
| 265 |
// otherwise; sort so the winner (if decided) displays first. |
| 266 |
usort($competitors, function ($a, $b) { |
| 267 |
$aWin = !empty($a['winner']) ? 1 : 0; |
| 268 |
$bWin = !empty($b['winner']) ? 1 : 0; |
| 269 |
return $bWin <=> $aWin; |
| 270 |
}); |
| 271 |
|
| 272 |
$teamA = $competitors[0]; |
| 273 |
$teamB = $competitors[1]; |
| 274 |
|
| 275 |
$scoreA = extract_score($teamA['score'] ?? null); |
| 276 |
$scoreB = extract_score($teamB['score'] ?? null); |
| 277 |
|
| 278 |
$statusName = strtoupper($competition['status']['type']['name'] ?? ''); |
| 279 |
$isFinal = !in_array($statusName, NON_FINAL_STATUS_NAMES, true); |
| 280 |
|
| 281 |
$headline = $competition['notes'][0]['headline'] ?? ($event['name'] ?? $eventLabel); |
| 282 |
|
| 283 |
return [ |
| 284 |
'event_label' => $eventLabel, |
| 285 |
'headline' => $headline, |
| 286 |
'is_final' => $isFinal, |
| 287 |
'team_a' => $teamA['team']['displayName'] ?? ($teamA['team']['name'] ?? 'Team A'), |
| 288 |
'team_a_score' => $scoreA ?? '?', |
| 289 |
'team_a_winner' => !empty($teamA['winner']), |
| 290 |
'team_b' => $teamB['team']['displayName'] ?? ($teamB['team']['name'] ?? 'Team B'), |
| 291 |
'team_b_score' => $scoreB ?? '?', |
| 292 |
'team_b_winner' => !empty($teamB['winner']), |
| 293 |
'date_str' => isset($event['date']) ? date('M j, Y', strtotime($event['date'])) : '', |
| 294 |
'date_raw' => $event['date'] ?? '', |
| 295 |
]; |
| 296 |
} |
| 297 |
|
| 298 |
/** |
| 299 |
* From a decoded schedule payload, pull the most recent $count |
| 300 |
* completed games, newest first. |
| 301 |
*/ |
| 302 |
function last_completed_games(?array $scheduleData, int $count = 2): array |
| 303 |
{ |
| 304 |
if (!$scheduleData || empty($scheduleData['events'])) { |
| 305 |
return []; |
| 306 |
} |
| 307 |
|
| 308 |
$completed = []; |
| 309 |
foreach ($scheduleData['events'] as $event) { |
| 310 |
$competition = $event['competitions'][0] ?? null; |
| 311 |
$completedFlag = $competition['status']['type']['completed'] ?? false; |
| 312 |
if ($competition && $completedFlag) { |
| 313 |
$completed[] = $event; |
| 314 |
} |
| 315 |
} |
| 316 |
|
| 317 |
usort($completed, function ($a, $b) { |
| 318 |
return strtotime($b['date'] ?? 'now') <=> strtotime($a['date'] ?? 'now'); |
| 319 |
}); |
| 320 |
|
| 321 |
return array_slice($completed, 0, $count); |
| 322 |
} |
| 323 |
|
| 324 |
/** |
| 325 |
* Find the next upcoming game within the next 2 weeks. |
| 326 |
*/ |
| 327 |
function get_upcoming_game(?array $scheduleData, string $teamAbbr): ?array |
| 328 |
{ |
| 329 |
if (!$scheduleData || empty($scheduleData['events'])) { |
| 330 |
return null; |
| 331 |
} |
| 332 |
|
| 333 |
$upcoming = []; |
| 334 |
$now = time(); |
| 335 |
$twoWeeksFromNow = strtotime('+2 weeks'); |
| 336 |
|
| 337 |
foreach ($scheduleData['events'] as $event) { |
| 338 |
$competition = $event['competitions'][0] ?? null; |
| 339 |
$completedFlag = $competition['status']['type']['completed'] ?? false; |
| 340 |
|
| 341 |
// If not completed, it's a future or live game |
| 342 |
if ($competition && !$completedFlag) { |
| 343 |
$gameTime = strtotime($event['date'] ?? '+1 year'); |
| 344 |
if ($gameTime > $now && $gameTime <= $twoWeeksFromNow) { |
| 345 |
$upcoming[] = $event; |
| 346 |
} |
| 347 |
} |
| 348 |
} |
| 349 |
|
| 350 |
if (empty($upcoming)) { |
| 351 |
return null; |
| 352 |
} |
| 353 |
|
| 354 |
// Sort to find the soonest game |
| 355 |
usort($upcoming, function ($a, $b) { |
| 356 |
return strtotime($a['date'] ?? 'now') <=> strtotime($b['date'] ?? 'now'); |
| 357 |
}); |
| 358 |
|
| 359 |
$event = $upcoming[0]; |
| 360 |
$competition = $event['competitions'][0]; |
| 361 |
|
| 362 |
$team = null; |
| 363 |
$opponent = null; |
| 364 |
foreach ($competition['competitors'] as $competitor) { |
| 365 |
$competitorAbbr = strtolower($competitor['team']['abbreviation'] ?? ''); |
| 366 |
if ($competitorAbbr === strtolower($teamAbbr)) { |
| 367 |
$team = $competitor; |
| 368 |
} else { |
| 369 |
$opponent = $competitor; |
| 370 |
} |
| 371 |
} |
| 372 |
|
| 373 |
if (!$team || !$opponent) { |
| 374 |
return null; |
| 375 |
} |
| 376 |
|
| 377 |
return [ |
| 378 |
'opponent' => $opponent['team']['displayName'] ?? ($opponent['team']['name'] ?? 'Opponent'), |
| 379 |
'is_home' => ($team['homeAway'] ?? '') === 'home', |
| 380 |
'date_str' => isset($event['date']) ? date('M j, g:i A', strtotime($event['date'])) : '', |
| 381 |
'date_raw' => $event['date'] ?? '', // Passed through for debugging |
| 382 |
'game_id' => $event['id'] ?? null, // stable ESPN id, shared by both teams' feeds (for dedup) |
| 383 |
]; |
| 384 |
} |
| 385 |
|
| 386 |
/** |
| 387 |
* Statuses ESPN uses for games that finished without a normal win/loss |
| 388 |
* (postponed/suspended/canceled). last_completed_games() already filters |
| 389 |
* to status.type.completed === true, but a suspended-and-later-resumed |
| 390 |
* game can still show one of these names on a "completed" event, so we |
| 391 |
* check explicitly rather than assuming completed always means a clean |
| 392 |
* final score. |
| 393 |
*/ |
| 394 |
const NON_FINAL_STATUS_NAMES = ['STATUS_POSTPONED', 'STATUS_CANCELED', 'STATUS_SUSPENDED', 'STATUS_DELAYED']; |
| 395 |
|
| 396 |
/** |
| 397 |
* Turn a single ESPN "event" into a simple result array from the given |
| 398 |
* team's point of view. Returns null if the shape isn't what we expect. |
| 399 |
* |
| 400 |
* 'status' is one of: 'win', 'loss', 'tie', 'postponed'. |
| 401 |
*/ |
| 402 |
function summarize_game(array $event, string $teamAbbr): ?array |
| 403 |
{ |
| 404 |
$competition = $event['competitions'][0] ?? null; |
| 405 |
if (!$competition || empty($competition['competitors'])) { |
| 406 |
return null; |
| 407 |
} |
| 408 |
|
| 409 |
$team = null; |
| 410 |
$opponent = null; |
| 411 |
foreach ($competition['competitors'] as $competitor) { |
| 412 |
$competitorAbbr = strtolower($competitor['team']['abbreviation'] ?? ''); |
| 413 |
if ($competitorAbbr === strtolower($teamAbbr)) { |
| 414 |
$team = $competitor; |
| 415 |
} else { |
| 416 |
$opponent = $competitor; |
| 417 |
} |
| 418 |
} |
| 419 |
|
| 420 |
if (!$team || !$opponent) { |
| 421 |
return null; |
| 422 |
} |
| 423 |
|
| 424 |
$teamScore = extract_score($team['score'] ?? null); |
| 425 |
$oppScore = extract_score($opponent['score'] ?? null); |
| 426 |
|
| 427 |
$statusName = strtoupper($competition['status']['type']['name'] ?? ''); |
| 428 |
|
| 429 |
if (in_array($statusName, NON_FINAL_STATUS_NAMES, true)) { |
| 430 |
$status = 'postponed'; |
| 431 |
} elseif (isset($team['winner']) && isset($opponent['winner']) && !$team['winner'] && !$opponent['winner']) { |
| 432 |
// ESPN marks both sides winner:false for a tie (e.g. NFL/NHL ties). |
| 433 |
$status = 'tie'; |
| 434 |
} elseif (isset($team['winner'])) { |
| 435 |
$status = $team['winner'] ? 'win' : 'loss'; |
| 436 |
} elseif ($teamScore !== null && $oppScore !== null && (float) $teamScore === (float) $oppScore) { |
| 437 |
$status = 'tie'; |
| 438 |
} else { |
| 439 |
$status = ((float) $teamScore > (float) $oppScore) ? 'win' : 'loss'; |
| 440 |
} |
| 441 |
|
| 442 |
return [ |
| 443 |
'status' => $status, |
| 444 |
'won' => $status === 'win', // kept for backward compatibility |
| 445 |
'team_score' => $teamScore ?? '?', |
| 446 |
'opp_score' => $oppScore ?? '?', |
| 447 |
'opponent' => $opponent['team']['displayName'] ?? ($opponent['team']['name'] ?? 'Opponent'), |
| 448 |
'is_home' => ($team['homeAway'] ?? '') === 'home', |
| 449 |
'date' => isset($event['date']) ? date('M j', strtotime($event['date'])) : '', |
| 450 |
'date_raw' => $event['date'] ?? '', // Passed through for debugging |
| 451 |
'game_id' => $event['id'] ?? null, // stable ESPN id, shared by both teams' feeds (for dedup) |
| 452 |
]; |
| 453 |
} |
| 454 |
|
| 455 |
/** |
| 456 |
* ESPN sometimes returns score as a plain string ("24") and sometimes |
| 457 |
* as an object ({"value": 24, "displayValue": "24"}). Normalize both. |
| 458 |
*/ |
| 459 |
function extract_score($score) |
| 460 |
{ |
| 461 |
if (is_array($score)) { |
| 462 |
return $score['displayValue'] ?? $score['value'] ?? null; |
| 463 |
} |
| 464 |
return $score; |
| 465 |
} |
| 466 |
?> |