466 lines · 16.4 KB
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
?>