@claudinho/core 0.4.2 → 0.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.
- package/README.md +3 -3
- package/dist/index.d.ts +150 -8
- package/dist/index.js +171 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,13 +18,13 @@ npm i @claudinho/core
|
|
|
18
18
|
## What's inside
|
|
19
19
|
|
|
20
20
|
- **Domain model** — `Match` (incl. `venue`/`city`/`country`), `Team`, `Stage`, `Status`, `PunditPick`, `LedgerRow`
|
|
21
|
-
- **`ProviderAdapter`** — the swappable data-vendor interface; `EspnAdapter` included
|
|
21
|
+
- **`ProviderAdapter`** — the swappable data-vendor interface (`fetchByDate`, `fetchLive`, optional `fetchWindow`/`fetchStandings`); `EspnAdapter` included
|
|
22
22
|
- **Static schedule** — all 104 fixtures (groups, venues, host cities, kickoffs) bundled; query with `allFixtures`, `fixturesByDate` (groups by your timezone), `fixturesByTeam`, `fixturesByGroup`, `nextFixtureForTeam`, `groups`
|
|
23
23
|
- **Live overlay** — `makeAdapter`, `getMatchesForDate`, `getLiveMatches`, `mergeLive` (static base + live state, with graceful degradation)
|
|
24
|
-
- **Standings** — `computeStandings` (points / GD / GF tiebreak)
|
|
24
|
+
- **Standings** — `getStandings` fetches authoritative, cumulative group tables from the provider (`GroupStandings`), failing closed to a roster-at-zero when none is available; `computeStandings` derives a table from a set of matches (points / GD / GF tiebreak)
|
|
25
25
|
- **Helpers** — emoji flags (`nationToFlag`), TZ-aware time (`formatKickoff`, `formatDate`, `formatTime`, `countdown`, `localDate`), location strings (`matchLocation`), localized commentary flair (`matchFlavor` / `FlavorLevel`), validators (`isValidDate`, `isValidTimeZone`)
|
|
26
26
|
- **Prediction-market signals (sidecar)** — read-only market signals kept *separate* from `Match`: the `MarketSignal` / `MarketProvider` model, the `PolymarketProvider` (public Gamma data only — no auth/trading/links; event slugs auto-derived per fixture, validation fails closed), a `FakeMarketProvider`, `makeMarketProvider`, `getMarketSignal` / `getMarketSignals`, the `isReliableMarketSignal` gate, and approved-copy formatters (`marketFavoriteText`, `marketProbabilityText`, `marketBlock`). Informational only — never betting advice.
|
|
27
|
-
- **Shareable snippets** — `formatShareSnippet` builds pure, deterministic, plain-text match cards (composing `Match` + the market copy bank) for the CLI's `share` command and
|
|
27
|
+
- **Shareable snippets** — `formatShareSnippet` builds pure, deterministic, plain-text match cards (composing `Match` + the market copy bank); `formatShareTable` does the same for a group's standings (facts + emoji flags only, no market line). For the CLI's `share` command and MCP/site reuse. The non-affiliation disclaimer is non-optional; market lines come from the approved bank.
|
|
28
28
|
|
|
29
29
|
## Example
|
|
30
30
|
|
package/dist/index.d.ts
CHANGED
|
@@ -224,6 +224,21 @@ declare function nextFixtureForTeam(code: string, opts?: {
|
|
|
224
224
|
from?: Date;
|
|
225
225
|
fixtures?: Match[];
|
|
226
226
|
}): Match | undefined;
|
|
227
|
+
/** A fixture is potentially live from kickoff until ~kickoff + 140 min. */
|
|
228
|
+
declare const LIVE_WINDOW_MS: number;
|
|
229
|
+
/** Fixtures whose live window contains `now` (cheap, static — no network). */
|
|
230
|
+
declare function fixturesInLiveWindow(now?: number, fixtures?: Match[]): Match[];
|
|
231
|
+
/**
|
|
232
|
+
* The team's in-play fixture when one is inside its live window, else the next
|
|
233
|
+
* upcoming fixture. This is "the match that matters for <team> right now":
|
|
234
|
+
* mid-match, a user (or an agent) asking about a team almost always means the
|
|
235
|
+
* one being played — `nextFixtureForTeam` alone would skip it the moment the
|
|
236
|
+
* kickoff is in the past and silently answer about next week.
|
|
237
|
+
*/
|
|
238
|
+
declare function currentOrNextFixtureForTeam(code: string, opts?: {
|
|
239
|
+
from?: Date;
|
|
240
|
+
fixtures?: Match[];
|
|
241
|
+
}): Match | undefined;
|
|
227
242
|
/** Sorted list of distinct group letters present in the schedule. */
|
|
228
243
|
declare function groups(fixtures?: Match[]): string[];
|
|
229
244
|
|
|
@@ -239,6 +254,11 @@ interface StandingRow {
|
|
|
239
254
|
goalDiff: number;
|
|
240
255
|
points: number;
|
|
241
256
|
}
|
|
257
|
+
/** A group's table: the group letter ("A".."L") and its rows in standings order. */
|
|
258
|
+
interface GroupStandings {
|
|
259
|
+
group: string;
|
|
260
|
+
rows: StandingRow[];
|
|
261
|
+
}
|
|
242
262
|
/**
|
|
243
263
|
* Compute a group table from a set of matches. Teams are seeded from every
|
|
244
264
|
* match (so a pre-tournament table still lists all four teams at 0), and only
|
|
@@ -271,6 +291,13 @@ interface ProviderAdapter {
|
|
|
271
291
|
fetchLive(): Promise<Match[]>;
|
|
272
292
|
/** Optional inclusive date-range fetch (used for schedule generation). */
|
|
273
293
|
fetchWindow?(startDate: string, endDate: string): Promise<Match[]>;
|
|
294
|
+
/**
|
|
295
|
+
* Optional authoritative group tables (cumulative across the group stage).
|
|
296
|
+
* Returned in standings order per group. Providers that can't supply a real
|
|
297
|
+
* table omit this; callers then fall back (degraded) to a roster at zero —
|
|
298
|
+
* never a wrong, partial table computed from a narrow live window.
|
|
299
|
+
*/
|
|
300
|
+
fetchStandings?(): Promise<GroupStandings[]>;
|
|
274
301
|
/** Optional push subscription (websocket/SSE providers). Returns an unsubscribe fn. */
|
|
275
302
|
subscribe?(onBatch: (matches: Match[]) => void): () => void;
|
|
276
303
|
}
|
|
@@ -360,6 +387,34 @@ interface EspnAdapterOptions {
|
|
|
360
387
|
*/
|
|
361
388
|
enrichGroups?: boolean;
|
|
362
389
|
}
|
|
390
|
+
interface EspnStandingsStat {
|
|
391
|
+
name?: string;
|
|
392
|
+
value?: number;
|
|
393
|
+
}
|
|
394
|
+
interface EspnStandingsEntry {
|
|
395
|
+
team?: EspnTeam;
|
|
396
|
+
stats?: EspnStandingsStat[];
|
|
397
|
+
}
|
|
398
|
+
interface EspnStandingsChild {
|
|
399
|
+
name?: string;
|
|
400
|
+
abbreviation?: string;
|
|
401
|
+
standings?: {
|
|
402
|
+
entries?: EspnStandingsEntry[];
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
interface EspnStandings {
|
|
406
|
+
children?: EspnStandingsChild[];
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Parse the standings payload into group tables. Pure (exported for tests).
|
|
410
|
+
*
|
|
411
|
+
* Two non-obvious robustness points, both verified against the live response:
|
|
412
|
+
* - ESPN's `entries` array is NOT in rank order, so we sort by the `rank` stat
|
|
413
|
+
* (falling back to points → GD → GF → code when rank is absent).
|
|
414
|
+
* - Non-group `children` (knockout brackets) are skipped by the "Group X" name
|
|
415
|
+
* test, so this stays correct once the bracket phase begins.
|
|
416
|
+
*/
|
|
417
|
+
declare function parseStandings(data: EspnStandings): GroupStandings[];
|
|
363
418
|
declare class EspnAdapter implements ProviderAdapter {
|
|
364
419
|
private readonly opts;
|
|
365
420
|
readonly name = "espn";
|
|
@@ -370,9 +425,18 @@ declare class EspnAdapter implements ProviderAdapter {
|
|
|
370
425
|
fetchByDate(dateISO: string): Promise<Match[]>;
|
|
371
426
|
fetchWindow(startDate: string, endDate: string): Promise<Match[]>;
|
|
372
427
|
fetchLive(): Promise<Match[]>;
|
|
428
|
+
/** Standings endpoint URL (lives under apis/v2, not site/v2; derived from base). */
|
|
429
|
+
private standingsUrl;
|
|
430
|
+
/**
|
|
431
|
+
* Authoritative, cumulative group tables from the standings endpoint. Throws
|
|
432
|
+
* on fetch/parse failure (the caller decides the fallback). Group-stage only:
|
|
433
|
+
* non-group `children` are filtered out by {@link parseStandings}.
|
|
434
|
+
*/
|
|
435
|
+
fetchStandings(): Promise<GroupStandings[]>;
|
|
373
436
|
/**
|
|
374
437
|
* Build (and cache) a team-code -> group-letter map from the standings
|
|
375
|
-
* endpoint. Best-effort: returns {} if standings are unavailable.
|
|
438
|
+
* endpoint. Best-effort: returns {} if standings are unavailable. Reuses the
|
|
439
|
+
* same parse as {@link fetchStandings}, so the two never drift.
|
|
376
440
|
*/
|
|
377
441
|
fetchGroupMap(force?: boolean): Promise<Record<string, string>>;
|
|
378
442
|
private fetchScoreboard;
|
|
@@ -412,6 +476,55 @@ declare function liveSourceLabel(source: string): string;
|
|
|
412
476
|
* schedule on any provider/network error (graceful degradation).
|
|
413
477
|
*/
|
|
414
478
|
declare function getMatchesForDate(adapter: ProviderAdapter, dateISO: string): Promise<LiveResult>;
|
|
479
|
+
interface StandingsResult {
|
|
480
|
+
/** Group tables in group-letter order; each table's rows in standings order. */
|
|
481
|
+
tables: GroupStandings[];
|
|
482
|
+
/** True when no authoritative table was available and rows are a static roster. */
|
|
483
|
+
degraded: boolean;
|
|
484
|
+
/** The provider that served a real table (absent when degraded). */
|
|
485
|
+
source?: string;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Authoritative group tables, preferring the provider's cumulative standings and
|
|
489
|
+
* FAILING CLOSED to a roster-at-zero (degraded) when none is available.
|
|
490
|
+
*
|
|
491
|
+
* Deliberately does NOT compute a table from a live-match window: that silently
|
|
492
|
+
* drops earlier matchdays and reports a wrong, partial table (e.g. all-zeros for
|
|
493
|
+
* a group not playing today) — the bug this replaced. A degraded roster is
|
|
494
|
+
* honestly empty; a confidently-wrong table is the failure mode we refuse.
|
|
495
|
+
*
|
|
496
|
+
* An empty `tables` with `degraded: false` means the fetch succeeded but the
|
|
497
|
+
* asked-for group isn't in it (caller renders "no such group").
|
|
498
|
+
*/
|
|
499
|
+
declare function getStandings(adapter: ProviderAdapter, group?: string): Promise<StandingsResult>;
|
|
500
|
+
interface MatchByIdResult {
|
|
501
|
+
match?: Match;
|
|
502
|
+
degraded: boolean;
|
|
503
|
+
source?: string;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* The fixture a team-scoped MARKET query should be about, live-confirmed.
|
|
507
|
+
* Static window math alone fails twice at the edges: a match in extra time
|
|
508
|
+
* (now > kickoff + 140min, still LIVE) would be skipped for next week's
|
|
509
|
+
* fixture, and a just-finished match (inside the window, already FT) would be
|
|
510
|
+
* selected over the next one. So: pick the in-window candidate using a widened
|
|
511
|
+
* window, overlay live state, and fall through to the next fixture when the
|
|
512
|
+
* overlay says the candidate is finished. Degraded fetches keep the static
|
|
513
|
+
* candidate (fail-closed: the market-relevance gate then errs toward showing
|
|
514
|
+
* nothing rather than something wrong).
|
|
515
|
+
*/
|
|
516
|
+
declare function marketFixtureForTeam(adapter: ProviderAdapter, code: string, now?: Date): Promise<MatchByIdResult>;
|
|
517
|
+
/**
|
|
518
|
+
* A single match by id, with live overlay. The provider's scoreboard buckets
|
|
519
|
+
* days in its own zone (ESPN: US/Eastern), so a fixture's UTC date can differ
|
|
520
|
+
* from the scoreboard day it's filed under — a 02:00Z kickoff belongs to the
|
|
521
|
+
* previous ET evening, and fetching only the UTC date silently misses its live
|
|
522
|
+
* state (the match then renders from the static schedule as if scheduled).
|
|
523
|
+
* Fetch the ±1-day window around the fixture's UTC date instead — the same
|
|
524
|
+
* trick `getMatchesForDate` uses — and fall back to the static fixture on any
|
|
525
|
+
* provider error.
|
|
526
|
+
*/
|
|
527
|
+
declare function getMatchById(adapter: ProviderAdapter, id: string): Promise<MatchByIdResult>;
|
|
415
528
|
/** Currently-live matches; empty + degraded on error. */
|
|
416
529
|
declare function getLiveMatches(adapter: ProviderAdapter): Promise<LiveResult>;
|
|
417
530
|
|
|
@@ -520,14 +633,16 @@ interface MarketProvider {
|
|
|
520
633
|
findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
|
|
521
634
|
}
|
|
522
635
|
|
|
523
|
-
/**
|
|
524
|
-
* Probability normalization + the reliability gate. `isReliableMarketSignal` is
|
|
525
|
-
* the single primitive every default-on surface keys off: "show only when
|
|
526
|
-
* reliable, otherwise omit silently."
|
|
527
|
-
*/
|
|
528
|
-
|
|
529
636
|
/** Default freshness window: a signal older than this is stale (15 minutes). */
|
|
530
637
|
declare const DEFAULT_MAX_AGE_MS: number;
|
|
638
|
+
/**
|
|
639
|
+
* Market signals are pre-match and in-play reads. Once a match has finished —
|
|
640
|
+
* status `FT`, or (for static fixtures that never get a live overlay) once its
|
|
641
|
+
* live window has long passed — a "favorite" is resolved history: showing
|
|
642
|
+
* "markets favor X 100%" after full time reads as a bug, not information.
|
|
643
|
+
* An unparseable kickoff fails open (the reliability gate still applies).
|
|
644
|
+
*/
|
|
645
|
+
declare function marketRelevant(match: Match, now?: Date): boolean;
|
|
531
646
|
/**
|
|
532
647
|
* Re-scale outcome probabilities so the positive ones sum to 1. This removes
|
|
533
648
|
* market "vig" (raw prices sum to >1) and is scale-agnostic: inputs may be
|
|
@@ -762,5 +877,32 @@ interface ShareSnippetInput {
|
|
|
762
877
|
* line; lines within a block by a single newline.
|
|
763
878
|
*/
|
|
764
879
|
declare function formatShareSnippet(input: ShareSnippetInput, options?: ShareSnippetOptions): string;
|
|
880
|
+
interface ShareTableInput {
|
|
881
|
+
/** Group tables to render (1..n); each in standings order. */
|
|
882
|
+
tables: {
|
|
883
|
+
group: string;
|
|
884
|
+
rows: StandingRow[];
|
|
885
|
+
}[];
|
|
886
|
+
/** Live-data provider name for attribution; omit when degraded/static. */
|
|
887
|
+
source?: string;
|
|
888
|
+
/** Exact run cue, e.g. "npx @claudinho/cli table A". */
|
|
889
|
+
installLine?: string;
|
|
890
|
+
/** Body line when there are no tables (e.g. "No group Z."). */
|
|
891
|
+
emptyNote?: string;
|
|
892
|
+
/**
|
|
893
|
+
* True when the rows are a static roster (no live results), not an
|
|
894
|
+
* authoritative table. A shared card is pasted into public/social, so this
|
|
895
|
+
* MUST be surfaced — otherwise a roster-at-zero reads as a real "nobody has
|
|
896
|
+
* played yet" table. The card then carries an explicit not-live notice.
|
|
897
|
+
*/
|
|
898
|
+
degraded?: boolean;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Render a shareable group-standings card. Pure, plain-text, deterministic. Like
|
|
902
|
+
* {@link formatShareSnippet} but for tables: facts + emoji flags only, no market
|
|
903
|
+
* lines (standings carry no market read), disclaimer non-optional, hashtag and
|
|
904
|
+
* run cue toggleable via {@link ShareSnippetOptions}.
|
|
905
|
+
*/
|
|
906
|
+
declare function formatShareTable(input: ShareTableInput, options?: ShareSnippetOptions): string;
|
|
765
907
|
|
|
766
|
-
export { type BuildSignalInput, DEFAULT_COMPETITION, DEFAULT_FLAVOR, DEFAULT_MAX_AGE_MS, EspnAdapter, type EspnAdapterOptions, FLAVOR_LEVELS, FakeMarketProvider, type FakeMarketProviderOptions, type FavoriteStrength, type FlavorLevel, type FormatOpts, type LedgerRow, type LiveResult, type MapContext, type MarketFavorite, type MarketMapping, type MarketMappingTable, type MarketOutcome, type MarketOutcomeKind, type MarketProvider, type MarketSignal, type MarketSignalOptions, type MarketSignalsResult, type Match, type MatchEvent, type Outcome, PolymarketProvider, type PolymarketProviderOptions, type ProviderAdapter, type ProviderCapabilities, type PunditPick, SHARE_DISCLAIMER, SHARE_HASHTAG, type ShareSnippetInput, type ShareSnippetOptions, type ShareStyle, type Stage, type StandingRow, type Status, type Team, allFixtures, asFlavorLevel, buildMarketSignal, byKickoff, competitionBase, computeStandings, countdown, deriveFavorite, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, flagEmoji, formatDate, formatKickoff, formatShareSnippet, formatTime, getLiveMatches, getMarketSignal, getMarketSignals, getMatchesForDate, groups, hasSaneDistribution, isFinished, isFlavorLevel, isLive, isReliableMarketSignal, isStaleSignal, isValidDate, isValidTimeZone, liveSourceLabel, localDate, makeAdapter, makeMarketProvider, mapEspnEvent, mapsCleanly, marketAttributionText, marketBlock, marketFavoriteText, marketLine, marketProbabilityText, marketSourceLabel, matchFlavor, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, normalizeOutcomes, outcomeFromScore, resolveCompetition, resolveMarketSource, resolveTz, scoreline };
|
|
908
|
+
export { type BuildSignalInput, DEFAULT_COMPETITION, DEFAULT_FLAVOR, DEFAULT_MAX_AGE_MS, EspnAdapter, type EspnAdapterOptions, FLAVOR_LEVELS, FakeMarketProvider, type FakeMarketProviderOptions, type FavoriteStrength, type FlavorLevel, type FormatOpts, type GroupStandings, LIVE_WINDOW_MS, type LedgerRow, type LiveResult, type MapContext, type MarketFavorite, type MarketMapping, type MarketMappingTable, type MarketOutcome, type MarketOutcomeKind, type MarketProvider, type MarketSignal, type MarketSignalOptions, type MarketSignalsResult, type Match, type MatchByIdResult, type MatchEvent, type Outcome, PolymarketProvider, type PolymarketProviderOptions, type ProviderAdapter, type ProviderCapabilities, type PunditPick, SHARE_DISCLAIMER, SHARE_HASHTAG, type ShareSnippetInput, type ShareSnippetOptions, type ShareStyle, type ShareTableInput, type Stage, type StandingRow, type StandingsResult, type Status, type Team, allFixtures, asFlavorLevel, buildMarketSignal, byKickoff, competitionBase, computeStandings, countdown, currentOrNextFixtureForTeam, deriveFavorite, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, fixturesInLiveWindow, flagEmoji, formatDate, formatKickoff, formatShareSnippet, formatShareTable, formatTime, getLiveMatches, getMarketSignal, getMarketSignals, getMatchById, getMatchesForDate, getStandings, groups, hasSaneDistribution, isFinished, isFlavorLevel, isLive, isReliableMarketSignal, isStaleSignal, isValidDate, isValidTimeZone, liveSourceLabel, localDate, makeAdapter, makeMarketProvider, mapEspnEvent, mapsCleanly, marketAttributionText, marketBlock, marketFavoriteText, marketFixtureForTeam, marketLine, marketProbabilityText, marketRelevant, marketSourceLabel, matchFlavor, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, normalizeOutcomes, outcomeFromScore, parseStandings, resolveCompetition, resolveMarketSource, resolveTz, scoreline };
|
package/dist/index.js
CHANGED
|
@@ -2622,6 +2622,22 @@ function nextFixtureForTeam(code, opts = {}) {
|
|
|
2622
2622
|
(m) => new Date(m.kickoff).getTime() >= from.getTime()
|
|
2623
2623
|
);
|
|
2624
2624
|
}
|
|
2625
|
+
var LIVE_WINDOW_MS = 140 * 6e4;
|
|
2626
|
+
function fixturesInLiveWindow(now = Date.now(), fixtures = SCHEDULE) {
|
|
2627
|
+
return fixtures.filter((m) => {
|
|
2628
|
+
const k = Date.parse(m.kickoff);
|
|
2629
|
+
return now >= k && now <= k + LIVE_WINDOW_MS;
|
|
2630
|
+
}).sort(byKickoff);
|
|
2631
|
+
}
|
|
2632
|
+
function currentOrNextFixtureForTeam(code, opts = {}) {
|
|
2633
|
+
const from = opts.from ?? /* @__PURE__ */ new Date();
|
|
2634
|
+
const nowMs = from.getTime();
|
|
2635
|
+
const inPlay = fixturesByTeam(code, opts.fixtures ?? SCHEDULE).find((m) => {
|
|
2636
|
+
const k = Date.parse(m.kickoff);
|
|
2637
|
+
return nowMs >= k && nowMs <= k + LIVE_WINDOW_MS;
|
|
2638
|
+
});
|
|
2639
|
+
return inPlay ?? nextFixtureForTeam(code, opts);
|
|
2640
|
+
}
|
|
2625
2641
|
function groups(fixtures = SCHEDULE) {
|
|
2626
2642
|
const set = /* @__PURE__ */ new Set();
|
|
2627
2643
|
for (const m of fixtures) if (m.group) set.add(m.group);
|
|
@@ -2771,6 +2787,43 @@ function mapEspnEvent(ev, ctx = {}) {
|
|
|
2771
2787
|
function toEspnDate(d) {
|
|
2772
2788
|
return d.replace(/\D/g, "").slice(0, 8);
|
|
2773
2789
|
}
|
|
2790
|
+
function statVal(stats, name) {
|
|
2791
|
+
const v = stats?.find((s) => s.name === name)?.value;
|
|
2792
|
+
return typeof v === "number" && Number.isFinite(v) ? Math.round(v) : 0;
|
|
2793
|
+
}
|
|
2794
|
+
function entryToRow(e) {
|
|
2795
|
+
return {
|
|
2796
|
+
team: toTeam(e.team),
|
|
2797
|
+
played: statVal(e.stats, "gamesPlayed"),
|
|
2798
|
+
won: statVal(e.stats, "wins"),
|
|
2799
|
+
drawn: statVal(e.stats, "ties"),
|
|
2800
|
+
lost: statVal(e.stats, "losses"),
|
|
2801
|
+
goalsFor: statVal(e.stats, "pointsFor"),
|
|
2802
|
+
goalsAgainst: statVal(e.stats, "pointsAgainst"),
|
|
2803
|
+
goalDiff: statVal(e.stats, "pointDifferential"),
|
|
2804
|
+
points: statVal(e.stats, "points")
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
function parseStandings(data) {
|
|
2808
|
+
const out = [];
|
|
2809
|
+
for (const child of data.children ?? []) {
|
|
2810
|
+
const letter = (child.name ?? child.abbreviation ?? "").match(/Group\s+([A-L])/i)?.[1]?.toUpperCase();
|
|
2811
|
+
if (!letter) continue;
|
|
2812
|
+
const ranked = (child.standings?.entries ?? []).map((e) => ({
|
|
2813
|
+
row: entryToRow(e),
|
|
2814
|
+
rank: statVal(e.stats, "rank")
|
|
2815
|
+
}));
|
|
2816
|
+
ranked.sort((a, b) => {
|
|
2817
|
+
if (a.rank && b.rank && a.rank !== b.rank) return a.rank - b.rank;
|
|
2818
|
+
const r = a.row;
|
|
2819
|
+
const s = b.row;
|
|
2820
|
+
return s.points - r.points || s.goalDiff - r.goalDiff || s.goalsFor - r.goalsFor || r.team.code.localeCompare(s.team.code);
|
|
2821
|
+
});
|
|
2822
|
+
out.push({ group: letter, rows: ranked.map((x) => x.row) });
|
|
2823
|
+
}
|
|
2824
|
+
out.sort((a, b) => a.group.localeCompare(b.group));
|
|
2825
|
+
return out;
|
|
2826
|
+
}
|
|
2774
2827
|
var EspnAdapter = class {
|
|
2775
2828
|
constructor(opts = {}) {
|
|
2776
2829
|
this.opts = opts;
|
|
@@ -2790,25 +2843,30 @@ var EspnAdapter = class {
|
|
|
2790
2843
|
const today = await this.fetchScoreboard();
|
|
2791
2844
|
return today.filter((m) => m.status === "LIVE" || m.status === "HT");
|
|
2792
2845
|
}
|
|
2846
|
+
/** Standings endpoint URL (lives under apis/v2, not site/v2; derived from base). */
|
|
2847
|
+
standingsUrl() {
|
|
2848
|
+
const base = this.opts.baseUrl ?? DEFAULT_BASE;
|
|
2849
|
+
return `${base.replace("/apis/site/v2/", "/apis/v2/")}/standings`;
|
|
2850
|
+
}
|
|
2851
|
+
/**
|
|
2852
|
+
* Authoritative, cumulative group tables from the standings endpoint. Throws
|
|
2853
|
+
* on fetch/parse failure (the caller decides the fallback). Group-stage only:
|
|
2854
|
+
* non-group `children` are filtered out by {@link parseStandings}.
|
|
2855
|
+
*/
|
|
2856
|
+
async fetchStandings() {
|
|
2857
|
+
return parseStandings(await this.get(this.standingsUrl()));
|
|
2858
|
+
}
|
|
2793
2859
|
/**
|
|
2794
2860
|
* Build (and cache) a team-code -> group-letter map from the standings
|
|
2795
|
-
* endpoint. Best-effort: returns {} if standings are unavailable.
|
|
2861
|
+
* endpoint. Best-effort: returns {} if standings are unavailable. Reuses the
|
|
2862
|
+
* same parse as {@link fetchStandings}, so the two never drift.
|
|
2796
2863
|
*/
|
|
2797
2864
|
async fetchGroupMap(force = false) {
|
|
2798
2865
|
if (this.groupMap && !force) return this.groupMap;
|
|
2799
|
-
const base = this.opts.baseUrl ?? DEFAULT_BASE;
|
|
2800
|
-
const standingsUrl = `${base.replace("/apis/site/v2/", "/apis/v2/")}/standings`;
|
|
2801
2866
|
const map = {};
|
|
2802
2867
|
try {
|
|
2803
|
-
const
|
|
2804
|
-
for (const
|
|
2805
|
-
const letter = (child.name ?? child.abbreviation ?? "").match(/Group\s+([A-L])/i)?.[1]?.toUpperCase();
|
|
2806
|
-
if (!letter) continue;
|
|
2807
|
-
for (const e of child.standings?.entries ?? []) {
|
|
2808
|
-
const code = e.team?.abbreviation?.toUpperCase();
|
|
2809
|
-
if (code) map[code] = letter;
|
|
2810
|
-
}
|
|
2811
|
-
}
|
|
2868
|
+
const tables = parseStandings(await this.get(this.standingsUrl()));
|
|
2869
|
+
for (const t of tables) for (const r of t.rows) map[r.team.code] = t.group;
|
|
2812
2870
|
} catch {
|
|
2813
2871
|
}
|
|
2814
2872
|
this.groupMap = map;
|
|
@@ -2881,10 +2939,53 @@ async function getMatchesForDate(adapter, dateISO) {
|
|
|
2881
2939
|
return { matches: base, degraded: true };
|
|
2882
2940
|
}
|
|
2883
2941
|
}
|
|
2942
|
+
async function getStandings(adapter, group) {
|
|
2943
|
+
const want = group?.toUpperCase();
|
|
2944
|
+
if (adapter.fetchStandings) {
|
|
2945
|
+
try {
|
|
2946
|
+
const all = await adapter.fetchStandings();
|
|
2947
|
+
const tables2 = (want ? all.filter((t) => t.group === want) : all).sort(
|
|
2948
|
+
(a, b) => a.group.localeCompare(b.group)
|
|
2949
|
+
);
|
|
2950
|
+
return { tables: tables2, degraded: false, source: adapter.name };
|
|
2951
|
+
} catch {
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
const letters = want ? [want] : groups();
|
|
2955
|
+
const tables = letters.map((g) => ({ group: g, rows: computeStandings(fixturesByGroup(g)) })).filter((t) => t.rows.length > 0);
|
|
2956
|
+
return { tables, degraded: true };
|
|
2957
|
+
}
|
|
2884
2958
|
function shiftUtcDate(dateISO, days) {
|
|
2885
2959
|
const [y, m, d] = dateISO.slice(0, 10).split("-").map(Number);
|
|
2886
2960
|
return new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, (d ?? 1) + days)).toISOString().slice(0, 10);
|
|
2887
2961
|
}
|
|
2962
|
+
var EXTRA_TIME_SLACK_MS = 60 * 6e4;
|
|
2963
|
+
async function marketFixtureForTeam(adapter, code, now = /* @__PURE__ */ new Date()) {
|
|
2964
|
+
const nowMs = now.getTime();
|
|
2965
|
+
const candidate = fixturesByTeam(code).find((m) => {
|
|
2966
|
+
const k = Date.parse(m.kickoff);
|
|
2967
|
+
return nowMs >= k && nowMs <= k + LIVE_WINDOW_MS + EXTRA_TIME_SLACK_MS;
|
|
2968
|
+
});
|
|
2969
|
+
if (candidate) {
|
|
2970
|
+
const r = await getMatchById(adapter, candidate.id);
|
|
2971
|
+
const m = r.match ?? candidate;
|
|
2972
|
+
if (!isFinished(m.status)) return { ...r, match: m };
|
|
2973
|
+
}
|
|
2974
|
+
const next = nextFixtureForTeam(code, { from: now });
|
|
2975
|
+
return { match: next, degraded: false };
|
|
2976
|
+
}
|
|
2977
|
+
async function getMatchById(adapter, id) {
|
|
2978
|
+
const base = allFixtures().find((m) => m.id === id);
|
|
2979
|
+
if (!base) return { match: void 0, degraded: false };
|
|
2980
|
+
const day = base.kickoff.slice(0, 10);
|
|
2981
|
+
try {
|
|
2982
|
+
const live = adapter.fetchWindow ? await adapter.fetchWindow(shiftUtcDate(day, -1), shiftUtcDate(day, 1)) : await adapter.fetchByDate(day);
|
|
2983
|
+
const hit = live.find((m) => m.id === id);
|
|
2984
|
+
return { match: hit ?? base, degraded: false, source: hit ? adapter.name : void 0 };
|
|
2985
|
+
} catch {
|
|
2986
|
+
return { match: base, degraded: true };
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2888
2989
|
async function getLiveMatches(adapter) {
|
|
2889
2990
|
try {
|
|
2890
2991
|
return { matches: await adapter.fetchLive(), degraded: false, source: adapter.name };
|
|
@@ -2895,6 +2996,12 @@ async function getLiveMatches(adapter) {
|
|
|
2895
2996
|
|
|
2896
2997
|
// src/markets/normalize.ts
|
|
2897
2998
|
var DEFAULT_MAX_AGE_MS = 15 * 6e4;
|
|
2999
|
+
function marketRelevant(match, now = /* @__PURE__ */ new Date()) {
|
|
3000
|
+
if (isLive(match.status)) return true;
|
|
3001
|
+
if (isFinished(match.status)) return false;
|
|
3002
|
+
const k = Date.parse(match.kickoff);
|
|
3003
|
+
return !Number.isFinite(k) || now.getTime() <= k + LIVE_WINDOW_MS;
|
|
3004
|
+
}
|
|
2898
3005
|
function normalizeOutcomes(outcomes) {
|
|
2899
3006
|
const sum = outcomes.reduce(
|
|
2900
3007
|
(s, o) => s + (Number.isFinite(o.probability) && o.probability > 0 ? o.probability : 0),
|
|
@@ -3401,11 +3508,50 @@ function formatShareSnippet(input, options = {}) {
|
|
|
3401
3508
|
blocks.push(card.join("\n"));
|
|
3402
3509
|
}
|
|
3403
3510
|
}
|
|
3511
|
+
blocks.push(
|
|
3512
|
+
shareFooter({
|
|
3513
|
+
source: input.source,
|
|
3514
|
+
installLine: input.installLine,
|
|
3515
|
+
includeHashtag,
|
|
3516
|
+
includeInstall
|
|
3517
|
+
})
|
|
3518
|
+
);
|
|
3519
|
+
return blocks.join("\n\n");
|
|
3520
|
+
}
|
|
3521
|
+
function shareFooter(opts) {
|
|
3404
3522
|
const footer = [];
|
|
3405
|
-
if (
|
|
3406
|
-
footer.push(
|
|
3407
|
-
|
|
3408
|
-
|
|
3523
|
+
if (opts.source) footer.push(`Live data: ${liveSourceLabel(opts.source)}`);
|
|
3524
|
+
footer.push(
|
|
3525
|
+
[opts.includeHashtag ? SHARE_HASHTAG : "", SHARE_DISCLAIMER].filter(Boolean).join(" \xB7 ")
|
|
3526
|
+
);
|
|
3527
|
+
if (opts.includeInstall && opts.installLine) footer.push(`Try it: ${opts.installLine}`);
|
|
3528
|
+
return footer.join("\n");
|
|
3529
|
+
}
|
|
3530
|
+
function gd(n) {
|
|
3531
|
+
return n > 0 ? `+${n}` : `${n}`;
|
|
3532
|
+
}
|
|
3533
|
+
function tableRow(r, rank) {
|
|
3534
|
+
return `${rank}. ${r.team.flag} ${r.team.code} ${r.points} pts \xB7 ${r.won}-${r.drawn}-${r.lost} \xB7 ${gd(r.goalDiff)}`;
|
|
3535
|
+
}
|
|
3536
|
+
function formatShareTable(input, options = {}) {
|
|
3537
|
+
const includeHashtag = options.includeHashtag !== false;
|
|
3538
|
+
const includeInstall = options.includeInstallLine !== false;
|
|
3539
|
+
const blocks = [];
|
|
3540
|
+
if (input.tables.length === 0) {
|
|
3541
|
+
blocks.push(input.emptyNote ?? "No standings available.");
|
|
3542
|
+
} else {
|
|
3543
|
+
for (const { group, rows } of input.tables) {
|
|
3544
|
+
blocks.push(
|
|
3545
|
+
[`Group ${group} \xB7 standings`, "", ...rows.map((r, i) => tableRow(r, i + 1))].join("\n")
|
|
3546
|
+
);
|
|
3547
|
+
}
|
|
3548
|
+
if (input.degraded) {
|
|
3549
|
+
blocks.push("(Live standings unavailable \u2014 group roster, not live results.)");
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
blocks.push(
|
|
3553
|
+
shareFooter({ source: input.source, installLine: input.installLine, includeHashtag, includeInstall })
|
|
3554
|
+
);
|
|
3409
3555
|
return blocks.join("\n\n");
|
|
3410
3556
|
}
|
|
3411
3557
|
export {
|
|
@@ -3415,6 +3561,7 @@ export {
|
|
|
3415
3561
|
EspnAdapter,
|
|
3416
3562
|
FLAVOR_LEVELS,
|
|
3417
3563
|
FakeMarketProvider,
|
|
3564
|
+
LIVE_WINDOW_MS,
|
|
3418
3565
|
PolymarketProvider,
|
|
3419
3566
|
SHARE_DISCLAIMER,
|
|
3420
3567
|
SHARE_HASHTAG,
|
|
@@ -3425,20 +3572,25 @@ export {
|
|
|
3425
3572
|
competitionBase,
|
|
3426
3573
|
computeStandings,
|
|
3427
3574
|
countdown,
|
|
3575
|
+
currentOrNextFixtureForTeam,
|
|
3428
3576
|
deriveFavorite,
|
|
3429
3577
|
favoriteStrength,
|
|
3430
3578
|
fixturesByDate,
|
|
3431
3579
|
fixturesByGroup,
|
|
3432
3580
|
fixturesByTeam,
|
|
3581
|
+
fixturesInLiveWindow,
|
|
3433
3582
|
flagEmoji,
|
|
3434
3583
|
formatDate,
|
|
3435
3584
|
formatKickoff,
|
|
3436
3585
|
formatShareSnippet,
|
|
3586
|
+
formatShareTable,
|
|
3437
3587
|
formatTime,
|
|
3438
3588
|
getLiveMatches,
|
|
3439
3589
|
getMarketSignal,
|
|
3440
3590
|
getMarketSignals,
|
|
3591
|
+
getMatchById,
|
|
3441
3592
|
getMatchesForDate,
|
|
3593
|
+
getStandings,
|
|
3442
3594
|
groups,
|
|
3443
3595
|
hasSaneDistribution,
|
|
3444
3596
|
isFinished,
|
|
@@ -3457,8 +3609,10 @@ export {
|
|
|
3457
3609
|
marketAttributionText,
|
|
3458
3610
|
marketBlock,
|
|
3459
3611
|
marketFavoriteText,
|
|
3612
|
+
marketFixtureForTeam,
|
|
3460
3613
|
marketLine,
|
|
3461
3614
|
marketProbabilityText,
|
|
3615
|
+
marketRelevant,
|
|
3462
3616
|
marketSourceLabel,
|
|
3463
3617
|
matchFlavor,
|
|
3464
3618
|
matchLocation,
|
|
@@ -3468,6 +3622,7 @@ export {
|
|
|
3468
3622
|
nextFixtureForTeam,
|
|
3469
3623
|
normalizeOutcomes,
|
|
3470
3624
|
outcomeFromScore,
|
|
3625
|
+
parseStandings,
|
|
3471
3626
|
resolveCompetition,
|
|
3472
3627
|
resolveMarketSource,
|
|
3473
3628
|
resolveTz,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claudinho/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Domain model, provider adapters (ESPN), standings, bundled schedule, and the read-only Polymarket market-signal sidecar powering Claudinho. Not affiliated with FIFA or Anthropic.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|