@claudinho/core 0.4.3 → 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 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 future MCP/site reuse. The non-affiliation disclaimer is non-optional; market lines come from the approved bank.
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
@@ -254,6 +254,11 @@ interface StandingRow {
254
254
  goalDiff: number;
255
255
  points: number;
256
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
+ }
257
262
  /**
258
263
  * Compute a group table from a set of matches. Teams are seeded from every
259
264
  * match (so a pre-tournament table still lists all four teams at 0), and only
@@ -286,6 +291,13 @@ interface ProviderAdapter {
286
291
  fetchLive(): Promise<Match[]>;
287
292
  /** Optional inclusive date-range fetch (used for schedule generation). */
288
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[]>;
289
301
  /** Optional push subscription (websocket/SSE providers). Returns an unsubscribe fn. */
290
302
  subscribe?(onBatch: (matches: Match[]) => void): () => void;
291
303
  }
@@ -375,6 +387,34 @@ interface EspnAdapterOptions {
375
387
  */
376
388
  enrichGroups?: boolean;
377
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[];
378
418
  declare class EspnAdapter implements ProviderAdapter {
379
419
  private readonly opts;
380
420
  readonly name = "espn";
@@ -385,9 +425,18 @@ declare class EspnAdapter implements ProviderAdapter {
385
425
  fetchByDate(dateISO: string): Promise<Match[]>;
386
426
  fetchWindow(startDate: string, endDate: string): Promise<Match[]>;
387
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[]>;
388
436
  /**
389
437
  * Build (and cache) a team-code -> group-letter map from the standings
390
- * 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.
391
440
  */
392
441
  fetchGroupMap(force?: boolean): Promise<Record<string, string>>;
393
442
  private fetchScoreboard;
@@ -427,6 +476,27 @@ declare function liveSourceLabel(source: string): string;
427
476
  * schedule on any provider/network error (graceful degradation).
428
477
  */
429
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>;
430
500
  interface MatchByIdResult {
431
501
  match?: Match;
432
502
  degraded: boolean;
@@ -807,5 +877,32 @@ interface ShareSnippetInput {
807
877
  * line; lines within a block by a single newline.
808
878
  */
809
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;
810
907
 
811
- 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, 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 Stage, type StandingRow, type Status, type Team, allFixtures, asFlavorLevel, buildMarketSignal, byKickoff, competitionBase, computeStandings, countdown, currentOrNextFixtureForTeam, deriveFavorite, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, fixturesInLiveWindow, flagEmoji, formatDate, formatKickoff, formatShareSnippet, formatTime, getLiveMatches, getMarketSignal, getMarketSignals, getMatchById, getMatchesForDate, 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, 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
@@ -2787,6 +2787,43 @@ function mapEspnEvent(ev, ctx = {}) {
2787
2787
  function toEspnDate(d) {
2788
2788
  return d.replace(/\D/g, "").slice(0, 8);
2789
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
+ }
2790
2827
  var EspnAdapter = class {
2791
2828
  constructor(opts = {}) {
2792
2829
  this.opts = opts;
@@ -2806,25 +2843,30 @@ var EspnAdapter = class {
2806
2843
  const today = await this.fetchScoreboard();
2807
2844
  return today.filter((m) => m.status === "LIVE" || m.status === "HT");
2808
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
+ }
2809
2859
  /**
2810
2860
  * Build (and cache) a team-code -> group-letter map from the standings
2811
- * 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.
2812
2863
  */
2813
2864
  async fetchGroupMap(force = false) {
2814
2865
  if (this.groupMap && !force) return this.groupMap;
2815
- const base = this.opts.baseUrl ?? DEFAULT_BASE;
2816
- const standingsUrl = `${base.replace("/apis/site/v2/", "/apis/v2/")}/standings`;
2817
2866
  const map = {};
2818
2867
  try {
2819
- const data = await this.get(standingsUrl);
2820
- for (const child of data.children ?? []) {
2821
- const letter = (child.name ?? child.abbreviation ?? "").match(/Group\s+([A-L])/i)?.[1]?.toUpperCase();
2822
- if (!letter) continue;
2823
- for (const e of child.standings?.entries ?? []) {
2824
- const code = e.team?.abbreviation?.toUpperCase();
2825
- if (code) map[code] = letter;
2826
- }
2827
- }
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;
2828
2870
  } catch {
2829
2871
  }
2830
2872
  this.groupMap = map;
@@ -2897,6 +2939,22 @@ async function getMatchesForDate(adapter, dateISO) {
2897
2939
  return { matches: base, degraded: true };
2898
2940
  }
2899
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
+ }
2900
2958
  function shiftUtcDate(dateISO, days) {
2901
2959
  const [y, m, d] = dateISO.slice(0, 10).split("-").map(Number);
2902
2960
  return new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, (d ?? 1) + days)).toISOString().slice(0, 10);
@@ -3450,11 +3508,50 @@ function formatShareSnippet(input, options = {}) {
3450
3508
  blocks.push(card.join("\n"));
3451
3509
  }
3452
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) {
3453
3522
  const footer = [];
3454
- if (input.source) footer.push(`Live data: ${liveSourceLabel(input.source)}`);
3455
- footer.push([includeHashtag ? SHARE_HASHTAG : "", SHARE_DISCLAIMER].filter(Boolean).join(" \xB7 "));
3456
- if (includeInstall && input.installLine) footer.push(`Try it: ${input.installLine}`);
3457
- blocks.push(footer.join("\n"));
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
+ );
3458
3555
  return blocks.join("\n\n");
3459
3556
  }
3460
3557
  export {
@@ -3486,12 +3583,14 @@ export {
3486
3583
  formatDate,
3487
3584
  formatKickoff,
3488
3585
  formatShareSnippet,
3586
+ formatShareTable,
3489
3587
  formatTime,
3490
3588
  getLiveMatches,
3491
3589
  getMarketSignal,
3492
3590
  getMarketSignals,
3493
3591
  getMatchById,
3494
3592
  getMatchesForDate,
3593
+ getStandings,
3495
3594
  groups,
3496
3595
  hasSaneDistribution,
3497
3596
  isFinished,
@@ -3523,6 +3622,7 @@ export {
3523
3622
  nextFixtureForTeam,
3524
3623
  normalizeOutcomes,
3525
3624
  outcomeFromScore,
3625
+ parseStandings,
3526
3626
  resolveCompetition,
3527
3627
  resolveMarketSource,
3528
3628
  resolveTz,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudinho/core",
3
- "version": "0.4.3",
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",