@claudinho/core 0.8.18 → 0.9.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/dist/index.d.ts CHANGED
@@ -183,6 +183,54 @@ declare function isValidTimeZone(tz: string | undefined | null): boolean;
183
183
  */
184
184
  declare function isValidDate(s: string | undefined | null): boolean;
185
185
 
186
+ /**
187
+ * Feed-string sanitizer — the chokepoint between untrusted provider data and
188
+ * every output surface (terminal, statusline, share cards, and the Claude Code
189
+ * hook, whose stdout lands in the model's context). Strips control characters
190
+ * (C0 incl. ESC, DEL, C1) so a compromised feed can't inject ANSI escapes or
191
+ * multi-line text, and caps length so one field can't flood a surface.
192
+ *
193
+ * Applied at the ESPN adapter boundary (toTeam / mapEspnEvent) and mirrored on
194
+ * the statusline's cache reads (defense against a poisoned cache file).
195
+ */
196
+
197
+ /** Default per-field cap — generous for any real team/venue name. */
198
+ declare const FEED_TEXT_MAX = 100;
199
+ /**
200
+ * Strip C0/C1 control characters (including ESC) and cap at `max` code points.
201
+ * Whitespace controls (tab/newline/CR) become a single space so words a hostile
202
+ * feed split across lines don't fuse together. Total: never throws.
203
+ */
204
+ declare function sanitizeFeedText(value: string, max?: number): string;
205
+ /**
206
+ * Sanitized, display-safe copy of a Match. Used on cache reads (the
207
+ * statusline/hook render straight from the cache file), so it must be total:
208
+ * a malformed entry yields empty strings, never a throw. Beyond the string
209
+ * fields, the RENDERED numeric fields (score, shootout, minute) are dropped
210
+ * unless they are real finite numbers — poisoned values degrade to "vs" /
211
+ * "LIVE", never to injected text. Shootout never survives without its score
212
+ * (the adapter-level invariant, re-enforced here).
213
+ */
214
+ declare function sanitizeMatchStrings(m: Match): Match;
215
+
216
+ /**
217
+ * Display-width helpers for monospace/plain-text alignment.
218
+ *
219
+ * `String.prototype.padEnd` counts UTF-16 units, but emoji flags occupy 2
220
+ * terminal columns regardless of how many units encode them: a regional-
221
+ * indicator flag (🇲🇽) is 4 units, while a tag-sequence flag (England 🏴󠁧󠁢󠁥󠁮󠁧󠁿) is
222
+ * 14 — so unit-counted padding pushed England/Scotland rows ~10 columns out of
223
+ * line. These helpers count grapheme clusters, with pictographic clusters
224
+ * (flags included) as 2 columns, matching how terminals render them.
225
+ */
226
+ /** Terminal display width of a string (grapheme clusters; emoji count as 2). */
227
+ declare function displayWidth(s: string): number;
228
+ /**
229
+ * Pad with trailing spaces to `width` DISPLAY columns (never truncates — a
230
+ * too-long value overflows its column rather than being cut mid-name).
231
+ */
232
+ declare function padVisible(s: string, width: number): string;
233
+
186
234
  /** Outcome (home perspective) from a final/looking scoreline. */
187
235
  declare function outcomeFromScore(home: number, away: number): Outcome;
188
236
  /** Is the match currently in play (including halftime)? */
@@ -437,6 +485,18 @@ interface ProviderAdapter {
437
485
  fetchStandings?(): Promise<GroupStandings[]>;
438
486
  /** Optional push subscription (websocket/SSE providers). Returns an unsubscribe fn. */
439
487
  subscribe?(onBatch: (matches: Match[]) => void): () => void;
488
+ /**
489
+ * Optional: the most recent request failure, cleared when a new request
490
+ * starts (best-effort under concurrency). Lets a caller whose result path
491
+ * fails closed to `degraded` booleans still distinguish a throttle/block
492
+ * (`throttled` — persist a backoff, hammering makes it worse) from an
493
+ * ordinary blip (retry on the normal cadence).
494
+ */
495
+ readonly lastError?: {
496
+ readonly kind: string;
497
+ readonly status?: number;
498
+ readonly throttled?: boolean;
499
+ };
440
500
  }
441
501
 
442
502
  /**
@@ -455,6 +515,19 @@ interface ProviderAdapter {
455
515
  declare const DEFAULT_COMPETITION = "fifa.world";
456
516
  /** Build an ESPN soccer base URL for a competition slug (e.g. "fifa.friendly"). */
457
517
  declare function competitionBase(slug: string): string;
518
+ type ProviderErrorKind = 'http' | 'timeout' | 'parse';
519
+ /**
520
+ * Classified provider failure, so a caller that got a degraded result can tell
521
+ * a throttle/block (back off — hammering makes it worse) from a timeout or a
522
+ * shape change (plain degraded, retry on the normal cadence).
523
+ */
524
+ declare class ProviderError extends Error {
525
+ readonly kind: ProviderErrorKind;
526
+ readonly status?: number;
527
+ constructor(message: string, kind: ProviderErrorKind, status?: number);
528
+ /** 429/403 — the upstream is refusing us; retrying at the live cadence makes it worse. */
529
+ get throttled(): boolean;
530
+ }
458
531
  interface EspnStatusType {
459
532
  name?: string;
460
533
  state?: string;
@@ -560,6 +633,20 @@ declare class EspnAdapter implements ProviderAdapter {
560
633
  readonly capabilities: ProviderCapabilities;
561
634
  /** Cached team-code -> group-letter map (built lazily from standings). */
562
635
  private groupMap?;
636
+ /**
637
+ * One in-flight/recent standings fetch shared by fetchStandings and
638
+ * fetchGroupMap, so a command like `bracket` hits the endpoint once instead
639
+ * of twice, and a long-lived MCP server stays fresh (short TTL). A rejected
640
+ * fetch clears the slot — a transient failure is never cached.
641
+ */
642
+ private standingsShared?;
643
+ /**
644
+ * The most recent request failure (best-effort under concurrency; cleared
645
+ * when a new request starts). A post-hoc hint for callers whose result path
646
+ * fails closed to `degraded` booleans but who still need to distinguish a
647
+ * throttle (persist a backoff) from an ordinary blip.
648
+ */
649
+ lastError?: ProviderError;
563
650
  constructor(opts?: EspnAdapterOptions);
564
651
  fetchByDate(dateISO: string): Promise<Match[]>;
565
652
  fetchWindow(startDate: string, endDate: string): Promise<Match[]>;
@@ -572,6 +659,8 @@ declare class EspnAdapter implements ProviderAdapter {
572
659
  fetchLive(): Promise<Match[]>;
573
660
  /** Standings endpoint URL (lives under apis/v2, not site/v2; derived from base). */
574
661
  private standingsUrl;
662
+ /** The shared standings fetch (see {@link standingsShared}). */
663
+ private sharedStandings;
575
664
  /**
576
665
  * Authoritative, cumulative group tables from the standings endpoint. Throws
577
666
  * on fetch/parse failure (the caller decides the fallback). Group-stage only:
@@ -580,8 +669,11 @@ declare class EspnAdapter implements ProviderAdapter {
580
669
  fetchStandings(): Promise<GroupStandings[]>;
581
670
  /**
582
671
  * Build (and cache) a team-code -> group-letter map from the standings
583
- * endpoint. Best-effort: returns {} if standings are unavailable. Reuses the
584
- * same parse as {@link fetchStandings}, so the two never drift.
672
+ * endpoint. Best-effort: returns {} if standings are unavailable but a
673
+ * transient failure is NOT cached (only a successful parse pins the map), so
674
+ * one blip can't silently drop group letters for the adapter's lifetime.
675
+ * Reuses the same parse/fetch as {@link fetchStandings}, so the two never
676
+ * drift and one command never fetches standings twice.
585
677
  */
586
678
  fetchGroupMap(force?: boolean): Promise<Record<string, string>>;
587
679
  private fetchScoreboard;
@@ -596,7 +688,14 @@ declare class EspnAdapter implements ProviderAdapter {
596
688
  * always the World Cup.
597
689
  */
598
690
  declare function resolveCompetition(explicit?: string): string;
599
- /** Construct a provider adapter for a `--source` name (default: espn). */
691
+ /** Provider names {@link makeAdapter} can construct (the CLI validates against this). */
692
+ declare const KNOWN_SOURCES: readonly ["espn"];
693
+ /**
694
+ * Construct a provider adapter for a `--source` name (default: espn). An
695
+ * unknown source FAILS LOUD instead of silently running ESPN — `--source foo`
696
+ * previously no-op'd, which lied about what the flag did (attribution stayed
697
+ * honest, but the advertised knob did nothing).
698
+ */
600
699
  declare function makeAdapter(source?: string): ProviderAdapter;
601
700
  /**
602
701
  * Merge live matches over a base set by id. Live entries replace base entries
@@ -642,6 +741,10 @@ interface StandingsResult {
642
741
  * asked-for group isn't in it (caller renders "no such group").
643
742
  */
644
743
  declare function getStandings(adapter: ProviderAdapter, group?: string): Promise<StandingsResult>;
744
+ declare function knockoutWindow(): {
745
+ start: string;
746
+ end: string;
747
+ } | null;
645
748
  /**
646
749
  * Knockout bracket with hybrid slot resolution: confirmed FT winners/losers from
647
750
  * the live overlay; group slots project from live standings once a group has started.
@@ -1176,4 +1279,4 @@ declare function formatShareBracket(input: ShareBracketInput, options?: ShareBra
1176
1279
  /** Compact one-line-per-match bracket for narrow share contexts. */
1177
1280
  declare function formatBracketCompactLine(mv: BracketMatchView, opts?: BracketFormatOpts): string;
1178
1281
 
1179
- export { BRACKET_STAGE_ORDER, type BracketFormatOpts, type BracketMatchNode, type BracketMatchView, type BracketResult, type BracketTopology, type BracketView, 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, type KnockoutFixturesResult, LIVE_WINDOW_MS, type Lang, 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 NextFixtureResult, type Outcome, PolymarketProvider, type PolymarketProviderOptions, type ProviderAdapter, type ProviderCapabilities, type PunditPick, type ResolvedParticipant, SHARE_DISCLAIMER, SHARE_HASHTAG, type ShareBracketInput, type ShareBracketOptions, type ShareSnippetInput, type ShareSnippetOptions, type ShareStyle, type ShareTableInput, type SlotRef, type Stage, type StandingRow, type StandingsResult, type Status, type Team, type TeamInfo, type TeamLookup, allFixtures, allTeams, asFlavorLevel, buildBracketTopology, buildBracketView, buildMarketSignal, byKickoff, competitionBase, computeStandings, countdown, currentOrNextFixtureForTeam, deriveFavorite, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, fixturesInLiveWindow, flagEmoji, formatBracketCompactLine, formatBracketList, formatBracketMatchLine, formatBracketTree, formatDate, formatKickoff, formatShareBracket, formatShareSnippet, formatShareTable, formatTime, getBracket, getKnockoutFixtures, getLiveMatches, getMarketSignal, getMarketSignals, getMatchById, getMatchesForDate, getNextFixtureForTeam, getStandings, groups, hasSaneDistribution, isFinished, isFlavorLevel, isLive, isReliableMarketSignal, isResolvedNation, isStaleSignal, isValidDate, isValidTimeZone, liveSourceLabel, loadBracketTopology, localDate, lookupTeam, makeAdapter, makeMarketProvider, mapEspnEvent, mapsCleanly, marketAttributionText, marketBlock, marketFavoriteText, marketFixtureForTeam, marketLine, marketProbabilityText, marketRelevant, marketSignalRendersFor, marketSourceLabel, matchFlavor, matchKey, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, normalizeLang, normalizeOutcomes, outcomeFromScore, parseStandings, parseTeamSlot, resolveCompetition, resolveMarketSource, resolveTz, sanitizeBundledFixture, scoreline, stageLabel, stageLabelI18n, t };
1282
+ export { BRACKET_STAGE_ORDER, type BracketFormatOpts, type BracketMatchNode, type BracketMatchView, type BracketResult, type BracketTopology, type BracketView, type BuildSignalInput, DEFAULT_COMPETITION, DEFAULT_FLAVOR, DEFAULT_MAX_AGE_MS, EspnAdapter, type EspnAdapterOptions, FEED_TEXT_MAX, FLAVOR_LEVELS, FakeMarketProvider, type FakeMarketProviderOptions, type FavoriteStrength, type FlavorLevel, type FormatOpts, type GroupStandings, KNOWN_SOURCES, type KnockoutFixturesResult, LIVE_WINDOW_MS, type Lang, 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 NextFixtureResult, type Outcome, PolymarketProvider, type PolymarketProviderOptions, type ProviderAdapter, type ProviderCapabilities, ProviderError, type ProviderErrorKind, type PunditPick, type ResolvedParticipant, SHARE_DISCLAIMER, SHARE_HASHTAG, type ShareBracketInput, type ShareBracketOptions, type ShareSnippetInput, type ShareSnippetOptions, type ShareStyle, type ShareTableInput, type SlotRef, type Stage, type StandingRow, type StandingsResult, type Status, type Team, type TeamInfo, type TeamLookup, allFixtures, allTeams, asFlavorLevel, buildBracketTopology, buildBracketView, buildMarketSignal, byKickoff, competitionBase, computeStandings, countdown, currentOrNextFixtureForTeam, deriveFavorite, displayWidth, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, fixturesInLiveWindow, flagEmoji, formatBracketCompactLine, formatBracketList, formatBracketMatchLine, formatBracketTree, formatDate, formatKickoff, formatShareBracket, formatShareSnippet, formatShareTable, formatTime, getBracket, getKnockoutFixtures, getLiveMatches, getMarketSignal, getMarketSignals, getMatchById, getMatchesForDate, getNextFixtureForTeam, getStandings, groups, hasSaneDistribution, isFinished, isFlavorLevel, isLive, isReliableMarketSignal, isResolvedNation, isStaleSignal, isValidDate, isValidTimeZone, knockoutWindow, liveSourceLabel, loadBracketTopology, localDate, lookupTeam, makeAdapter, makeMarketProvider, mapEspnEvent, mapsCleanly, marketAttributionText, marketBlock, marketFavoriteText, marketFixtureForTeam, marketLine, marketProbabilityText, marketRelevant, marketSignalRendersFor, marketSourceLabel, matchFlavor, matchKey, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, normalizeLang, normalizeOutcomes, outcomeFromScore, padVisible, parseStandings, parseTeamSlot, resolveCompetition, resolveMarketSource, resolveTz, sanitizeBundledFixture, sanitizeFeedText, sanitizeMatchStrings, scoreline, stageLabel, stageLabelI18n, t };
package/dist/index.js CHANGED
@@ -378,7 +378,8 @@ function normalizeLang(lang) {
378
378
  }
379
379
  function t(lang, key, vars) {
380
380
  const dict = CATALOGS[normalizeLang(lang)];
381
- let s = dict[key] ?? EN[key] ?? key;
381
+ const en = EN;
382
+ let s = dict[key] ?? en[key] ?? key;
382
383
  if (vars) {
383
384
  for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, v);
384
385
  }
@@ -502,6 +503,67 @@ function shiftUtcDate(dateISO, days) {
502
503
  return new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, (d ?? 1) + days)).toISOString().slice(0, 10);
503
504
  }
504
505
 
506
+ // src/sanitize.ts
507
+ var FEED_TEXT_MAX = 100;
508
+ function sanitizeFeedText(value, max = FEED_TEXT_MAX) {
509
+ let out = "";
510
+ let count = 0;
511
+ for (const ch of String(value)) {
512
+ const cp = ch.codePointAt(0) ?? 0;
513
+ const isWhitespaceControl = cp === 9 || cp === 10 || cp === 13;
514
+ if ((cp <= 31 || cp >= 127 && cp <= 159) && !isWhitespaceControl) continue;
515
+ if (count >= max) break;
516
+ out += isWhitespaceControl ? " " : ch;
517
+ count++;
518
+ }
519
+ return out;
520
+ }
521
+ function sanitizeTeam(t2) {
522
+ return {
523
+ ...t2 ?? {},
524
+ code: sanitizeFeedText(t2?.code ?? ""),
525
+ name: sanitizeFeedText(t2?.name ?? ""),
526
+ flag: sanitizeFeedText(t2?.flag ?? "")
527
+ };
528
+ }
529
+ function finiteOrUndefined(v) {
530
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
531
+ }
532
+ function sanitizeScorePair(v) {
533
+ const home = finiteOrUndefined(v?.home);
534
+ const away = finiteOrUndefined(v?.away);
535
+ return home !== void 0 && away !== void 0 ? { home, away } : void 0;
536
+ }
537
+ function sanitizeMatchStrings(m) {
538
+ const score = sanitizeScorePair(m.score);
539
+ return {
540
+ ...m,
541
+ venue: sanitizeFeedText(m.venue ?? ""),
542
+ city: m.city == null ? m.city : sanitizeFeedText(m.city),
543
+ country: m.country == null ? m.country : sanitizeFeedText(m.country),
544
+ home: sanitizeTeam(m.home),
545
+ away: sanitizeTeam(m.away),
546
+ score,
547
+ shootout: score ? sanitizeScorePair(m.shootout) : void 0,
548
+ minute: finiteOrUndefined(m.minute)
549
+ };
550
+ }
551
+
552
+ // src/text.ts
553
+ var segmenter = new Intl.Segmenter();
554
+ var WIDE_CLUSTER = new RegExp("^(?:\\p{Regional_Indicator}|\\p{Extended_Pictographic})", "u");
555
+ function displayWidth(s) {
556
+ let w = 0;
557
+ for (const { segment } of segmenter.segment(s)) {
558
+ w += WIDE_CLUSTER.test(segment) ? 2 : 1;
559
+ }
560
+ return w;
561
+ }
562
+ function padVisible(s, width) {
563
+ const w = displayWidth(s);
564
+ return w >= width ? s : s + " ".repeat(width - w);
565
+ }
566
+
505
567
  // src/normalize.ts
506
568
  function outcomeFromScore(home, away) {
507
569
  if (home > away) return "H";
@@ -3024,10 +3086,27 @@ function rosterAtZero(matches) {
3024
3086
  var ESPN_SOCCER = "https://site.api.espn.com/apis/site/v2/sports/soccer";
3025
3087
  var DEFAULT_COMPETITION = "fifa.world";
3026
3088
  var DEFAULT_BASE = `${ESPN_SOCCER}/${DEFAULT_COMPETITION}`;
3027
- var USER_AGENT = "claudinho/0.0 (+https://github.com/arturogarrido/claudinho)";
3089
+ var USER_AGENT = `claudinho/${"0.9.0"} (+https://github.com/arturogarrido/claudinho)`;
3090
+ var MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
3028
3091
  function competitionBase(slug) {
3029
3092
  return `${ESPN_SOCCER}/${slug}`;
3030
3093
  }
3094
+ var DEFAULT_TIMEOUT_MS = 6e3;
3095
+ var STANDINGS_SHARE_MS = 3e4;
3096
+ var ProviderError = class extends Error {
3097
+ kind;
3098
+ status;
3099
+ constructor(message, kind, status) {
3100
+ super(message);
3101
+ this.name = "ProviderError";
3102
+ this.kind = kind;
3103
+ this.status = status;
3104
+ }
3105
+ /** 429/403 — the upstream is refusing us; retrying at the live cadence makes it worse. */
3106
+ get throttled() {
3107
+ return this.kind === "http" && (this.status === 429 || this.status === 403);
3108
+ }
3109
+ };
3031
3110
  function mapStatus(st) {
3032
3111
  const name = (st?.type?.name ?? "").toUpperCase();
3033
3112
  const state = st?.type?.state ?? "";
@@ -3068,9 +3147,15 @@ function toInt(s) {
3068
3147
  return Number.isFinite(n) ? n : void 0;
3069
3148
  }
3070
3149
  function toTeam(t2) {
3071
- const name = t2?.displayName ?? t2?.name ?? t2?.location ?? t2?.shortDisplayName ?? "TBD";
3072
- const code = (t2?.abbreviation ?? name.slice(0, 3)).toUpperCase();
3073
- return { code, name, flag: nationToFlag(t2?.displayName ?? t2?.abbreviation ?? name) };
3150
+ const name = sanitizeFeedText(
3151
+ t2?.displayName ?? t2?.name ?? t2?.location ?? t2?.shortDisplayName ?? "TBD"
3152
+ );
3153
+ const code = sanitizeFeedText(t2?.abbreviation ?? name.slice(0, 3)).toUpperCase();
3154
+ return {
3155
+ code,
3156
+ name,
3157
+ flag: nationToFlag(sanitizeFeedText(t2?.displayName ?? t2?.abbreviation ?? name))
3158
+ };
3074
3159
  }
3075
3160
  function mapEspnEvent(ev, ctx = {}) {
3076
3161
  const comp = ev.competitions?.[0];
@@ -3101,9 +3186,9 @@ function mapEspnEvent(ev, ctx = {}) {
3101
3186
  stage,
3102
3187
  group,
3103
3188
  kickoff: ev.date,
3104
- venue: comp?.venue?.fullName ?? "",
3105
- city: comp?.venue?.address?.city || void 0,
3106
- country: comp?.venue?.address?.country || void 0,
3189
+ venue: sanitizeFeedText(comp?.venue?.fullName ?? ""),
3190
+ city: sanitizeFeedText(comp?.venue?.address?.city ?? "") || void 0,
3191
+ country: sanitizeFeedText(comp?.venue?.address?.country ?? "") || void 0,
3107
3192
  home,
3108
3193
  away,
3109
3194
  score: hasScore ? { home: hs, away: as } : void 0,
@@ -3163,6 +3248,20 @@ var EspnAdapter = class {
3163
3248
  capabilities = { push: false, latencyHintSec: 45 };
3164
3249
  /** Cached team-code -> group-letter map (built lazily from standings). */
3165
3250
  groupMap;
3251
+ /**
3252
+ * One in-flight/recent standings fetch shared by fetchStandings and
3253
+ * fetchGroupMap, so a command like `bracket` hits the endpoint once instead
3254
+ * of twice, and a long-lived MCP server stays fresh (short TTL). A rejected
3255
+ * fetch clears the slot — a transient failure is never cached.
3256
+ */
3257
+ standingsShared;
3258
+ /**
3259
+ * The most recent request failure (best-effort under concurrency; cleared
3260
+ * when a new request starts). A post-hoc hint for callers whose result path
3261
+ * fails closed to `degraded` booleans but who still need to distinguish a
3262
+ * throttle (persist a backoff) from an ordinary blip.
3263
+ */
3264
+ lastError;
3166
3265
  async fetchByDate(dateISO) {
3167
3266
  return this.fetchScoreboard(toEspnDate(dateISO));
3168
3267
  }
@@ -3184,37 +3283,58 @@ var EspnAdapter = class {
3184
3283
  const base = this.opts.baseUrl ?? DEFAULT_BASE;
3185
3284
  return `${base.replace("/apis/site/v2/", "/apis/v2/")}/standings`;
3186
3285
  }
3286
+ /** The shared standings fetch (see {@link standingsShared}). */
3287
+ sharedStandings() {
3288
+ const now = Date.now();
3289
+ if (this.standingsShared && now - this.standingsShared.at < STANDINGS_SHARE_MS) {
3290
+ return this.standingsShared.promise;
3291
+ }
3292
+ const promise = this.get(this.standingsUrl()).then(
3293
+ (d) => parseStandings(d)
3294
+ );
3295
+ this.standingsShared = { at: now, promise };
3296
+ promise.catch(() => {
3297
+ if (this.standingsShared?.promise === promise) this.standingsShared = void 0;
3298
+ });
3299
+ return promise;
3300
+ }
3187
3301
  /**
3188
3302
  * Authoritative, cumulative group tables from the standings endpoint. Throws
3189
3303
  * on fetch/parse failure (the caller decides the fallback). Group-stage only:
3190
3304
  * non-group `children` are filtered out by {@link parseStandings}.
3191
3305
  */
3192
3306
  async fetchStandings() {
3193
- return parseStandings(await this.get(this.standingsUrl()));
3307
+ return this.sharedStandings();
3194
3308
  }
3195
3309
  /**
3196
3310
  * Build (and cache) a team-code -> group-letter map from the standings
3197
- * endpoint. Best-effort: returns {} if standings are unavailable. Reuses the
3198
- * same parse as {@link fetchStandings}, so the two never drift.
3311
+ * endpoint. Best-effort: returns {} if standings are unavailable but a
3312
+ * transient failure is NOT cached (only a successful parse pins the map), so
3313
+ * one blip can't silently drop group letters for the adapter's lifetime.
3314
+ * Reuses the same parse/fetch as {@link fetchStandings}, so the two never
3315
+ * drift and one command never fetches standings twice.
3199
3316
  */
3200
3317
  async fetchGroupMap(force = false) {
3201
3318
  if (this.groupMap && !force) return this.groupMap;
3202
- const map = {};
3203
3319
  try {
3204
- const tables = parseStandings(await this.get(this.standingsUrl()));
3320
+ const tables = await this.sharedStandings();
3321
+ const map = {};
3205
3322
  for (const t2 of tables) for (const r of t2.rows) map[r.team.code] = t2.group;
3323
+ this.groupMap = map;
3324
+ return map;
3206
3325
  } catch {
3326
+ return {};
3207
3327
  }
3208
- this.groupMap = map;
3209
- return map;
3210
3328
  }
3211
3329
  async fetchScoreboard(dates) {
3212
3330
  const base = this.opts.baseUrl ?? DEFAULT_BASE;
3213
3331
  const url = new URL(`${base}/scoreboard`);
3214
3332
  url.searchParams.set("limit", "300");
3215
3333
  if (dates) url.searchParams.set("dates", dates);
3216
- const groupByTeam = this.opts.enrichGroups === false ? {} : await this.fetchGroupMap();
3217
- const data = await this.get(url.toString());
3334
+ const [groupByTeam, data] = await Promise.all([
3335
+ this.opts.enrichGroups === false ? Promise.resolve({}) : this.fetchGroupMap(),
3336
+ this.get(url.toString())
3337
+ ]);
3218
3338
  return (data.events ?? []).map((ev) => mapEspnEvent(ev, { groupByTeam }));
3219
3339
  }
3220
3340
  async get(url) {
@@ -3222,17 +3342,51 @@ var EspnAdapter = class {
3222
3342
  const controller = new AbortController();
3223
3343
  const timer = setTimeout(
3224
3344
  () => controller.abort(),
3225
- this.opts.timeoutMs ?? 15e3
3345
+ this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
3226
3346
  );
3347
+ this.lastError = void 0;
3227
3348
  try {
3228
- const res = await doFetch(url, {
3229
- signal: controller.signal,
3230
- headers: { Accept: "application/json", "User-Agent": USER_AGENT }
3231
- });
3349
+ let res;
3350
+ try {
3351
+ res = await doFetch(url, {
3352
+ signal: controller.signal,
3353
+ // ESPN never legitimately redirects; following one would sidestep the
3354
+ // fixed-host guarantee, so treat any redirect as a failure (degraded).
3355
+ redirect: "error",
3356
+ headers: { Accept: "application/json", "User-Agent": USER_AGENT }
3357
+ });
3358
+ } catch (e) {
3359
+ throw e?.name === "AbortError" ? new ProviderError(`ESPN request timed out: ${url}`, "timeout") : new ProviderError(`ESPN request failed: ${e?.message ?? e}`, "http");
3360
+ }
3232
3361
  if (!res.ok) {
3233
- throw new Error(`ESPN request failed: ${res.status} ${res.statusText}`);
3362
+ throw new ProviderError(
3363
+ `ESPN request failed: ${res.status} ${res.statusText}`,
3364
+ "http",
3365
+ res.status
3366
+ );
3367
+ }
3368
+ const length = Number(res.headers?.get?.("content-length"));
3369
+ if (Number.isFinite(length) && length > MAX_RESPONSE_BYTES) {
3370
+ throw new ProviderError(`ESPN response too large: ${length} bytes`, "parse");
3371
+ }
3372
+ try {
3373
+ return await res.json();
3374
+ } catch (e) {
3375
+ throw new ProviderError(
3376
+ `ESPN response unparseable: ${e?.message ?? e}`,
3377
+ "parse"
3378
+ );
3379
+ }
3380
+ } catch (e) {
3381
+ const pe = e instanceof ProviderError ? e : new ProviderError(String(e?.message ?? e), "http");
3382
+ this.lastError = pe;
3383
+ if (typeof process !== "undefined" && process.env?.CLAUDINHO_DEBUG) {
3384
+ process.stderr.write(
3385
+ `claudinho: espn ${pe.kind}${pe.status ? ` ${pe.status}` : ""}: ${url}
3386
+ `
3387
+ );
3234
3388
  }
3235
- return await res.json();
3389
+ throw pe;
3236
3390
  } finally {
3237
3391
  clearTimeout(timer);
3238
3392
  }
@@ -4126,13 +4280,18 @@ function resolveCompetition(explicit) {
4126
4280
  }
4127
4281
  return DEFAULT_COMPETITION;
4128
4282
  }
4283
+ var KNOWN_SOURCES = ["espn"];
4129
4284
  function makeAdapter(source = "espn") {
4130
4285
  switch (source) {
4131
- default: {
4286
+ case "espn": {
4132
4287
  const competition = resolveCompetition();
4133
4288
  const baseUrl = competition === DEFAULT_COMPETITION ? void 0 : competitionBase(competition);
4134
4289
  return new EspnAdapter({ baseUrl });
4135
4290
  }
4291
+ default:
4292
+ throw new Error(
4293
+ `Unknown data source "${source}" (available: ${KNOWN_SOURCES.join(", ")})`
4294
+ );
4136
4295
  }
4137
4296
  }
4138
4297
  function mergeLive(base, live) {
@@ -4170,8 +4329,16 @@ async function getStandings(adapter, group) {
4170
4329
  const tables = letters.map((g) => ({ group: g, rows: rosterAtZero(fixturesByGroup(g)) })).filter((t2) => t2.rows.length > 0);
4171
4330
  return { tables, degraded: true };
4172
4331
  }
4173
- var KNOCKOUT_WINDOW_START = "20260628";
4174
- var KNOCKOUT_WINDOW_END = "20260719";
4332
+ var knockoutWindowMemo;
4333
+ function knockoutWindow() {
4334
+ if (knockoutWindowMemo !== void 0) return knockoutWindowMemo;
4335
+ const days = allFixtures().filter((m) => m.stage !== "GROUP" && m.stage !== "FRIENDLY").map((m) => m.kickoff.slice(0, 10).replace(/-/g, "")).filter((d) => d.length === 8);
4336
+ knockoutWindowMemo = days.length ? {
4337
+ start: days.reduce((a, b) => a < b ? a : b),
4338
+ end: days.reduce((a, b) => a > b ? a : b)
4339
+ } : null;
4340
+ return knockoutWindowMemo;
4341
+ }
4175
4342
  async function getBracket(adapter, opts = {}) {
4176
4343
  const topology = loadBracketTopology();
4177
4344
  const base = allFixtures().filter((m) => m.stage !== "GROUP" && m.stage !== "FRIENDLY");
@@ -4179,7 +4346,8 @@ async function getBracket(adapter, opts = {}) {
4179
4346
  let liveDegraded = true;
4180
4347
  let source;
4181
4348
  try {
4182
- const live = adapter.fetchWindow ? await adapter.fetchWindow(KNOCKOUT_WINDOW_START, KNOCKOUT_WINDOW_END) : [];
4349
+ const win = knockoutWindow();
4350
+ const live = adapter.fetchWindow && win ? await adapter.fetchWindow(win.start, win.end) : [];
4183
4351
  matches = mergeLive(base, live);
4184
4352
  liveDegraded = false;
4185
4353
  source = adapter.name;
@@ -4212,8 +4380,9 @@ async function marketFixtureForTeam(adapter, code, now = /* @__PURE__ */ new Dat
4212
4380
  let fixtures = allFixtures();
4213
4381
  let overlayFailed = false;
4214
4382
  try {
4215
- if (adapter.fetchWindow) {
4216
- fixtures = mergeLive(fixtures, await adapter.fetchWindow(KNOCKOUT_WINDOW_START, KNOCKOUT_WINDOW_END));
4383
+ const win = knockoutWindow();
4384
+ if (adapter.fetchWindow && win) {
4385
+ fixtures = mergeLive(fixtures, await adapter.fetchWindow(win.start, win.end));
4217
4386
  }
4218
4387
  } catch {
4219
4388
  overlayFailed = true;
@@ -4236,7 +4405,8 @@ async function getNextFixtureForTeam(adapter, code, now = /* @__PURE__ */ new Da
4236
4405
  let degraded = true;
4237
4406
  let liveById;
4238
4407
  try {
4239
- const live = adapter.fetchWindow ? await adapter.fetchWindow(KNOCKOUT_WINDOW_START, KNOCKOUT_WINDOW_END) : [];
4408
+ const win = knockoutWindow();
4409
+ const live = adapter.fetchWindow && win ? await adapter.fetchWindow(win.start, win.end) : [];
4240
4410
  matches = mergeLive(base, live);
4241
4411
  degraded = false;
4242
4412
  liveById = new Set(live.map((m) => m.id));
@@ -4247,10 +4417,11 @@ async function getNextFixtureForTeam(adapter, code, now = /* @__PURE__ */ new Da
4247
4417
  return { fixture, degraded, source };
4248
4418
  }
4249
4419
  async function getKnockoutFixtures(adapter, now = /* @__PURE__ */ new Date()) {
4250
- if (!adapter.fetchWindow) return { fixtures: [], degraded: true };
4420
+ const win = knockoutWindow();
4421
+ if (!adapter.fetchWindow || !win) return { fixtures: [], degraded: true };
4251
4422
  let live;
4252
4423
  try {
4253
- live = await adapter.fetchWindow(KNOCKOUT_WINDOW_START, KNOCKOUT_WINDOW_END);
4424
+ live = await adapter.fetchWindow(win.start, win.end);
4254
4425
  } catch {
4255
4426
  return { fixtures: [], degraded: true };
4256
4427
  }
@@ -4508,7 +4679,7 @@ var mapping_2026_default = {
4508
4679
  var DEFAULT_BASE2 = "https://gamma-api.polymarket.com";
4509
4680
  var ALLOWED_HOSTS = /* @__PURE__ */ new Set(["gamma-api.polymarket.com"]);
4510
4681
  var USER_AGENT2 = "claudinho/0.0 (+https://github.com/arturogarrido/claudinho)";
4511
- var DEFAULT_TIMEOUT_MS = 8e3;
4682
+ var DEFAULT_TIMEOUT_MS2 = 8e3;
4512
4683
  var WC_SERIES_SLUG = "soccer-fifwc";
4513
4684
  var WC_SPORT = "fifwc";
4514
4685
  var KICKOFF_TOLERANCE_MS = 6 * 60 * 6e4;
@@ -4546,7 +4717,7 @@ var PolymarketProvider = class {
4546
4717
  const entry = (this.opts.mapping ?? BUNDLED_MAPPING)[match.id];
4547
4718
  const slugs = entry?.eventSlug ? [entry.eventSlug] : deriveEventSlugs(match);
4548
4719
  if (slugs.length === 0) return { checked: true };
4549
- const configured = options?.timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
4720
+ const configured = options?.timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
4550
4721
  try {
4551
4722
  for (const slug of slugs) {
4552
4723
  const remaining = deadline - Date.now();
@@ -4566,13 +4737,20 @@ var PolymarketProvider = class {
4566
4737
  const url = `${base}/events?slug=${encodeURIComponent(slug)}`;
4567
4738
  const doFetch = this.opts.fetchImpl ?? fetch;
4568
4739
  const res = await doFetch(url, {
4569
- signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
4740
+ signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2),
4741
+ // Gamma never legitimately redirects; following one would sidestep the
4742
+ // host allow-list (it only validates the base URL), so reject redirects.
4743
+ redirect: "error",
4570
4744
  headers: { Accept: "application/json", "User-Agent": USER_AGENT2 }
4571
4745
  });
4572
4746
  if (res.status === 404) return void 0;
4573
4747
  if (!res.ok) {
4574
4748
  throw new Error(`Polymarket request failed: ${res.status} ${res.statusText}`);
4575
4749
  }
4750
+ const length = Number(res.headers?.get?.("content-length"));
4751
+ if (Number.isFinite(length) && length > MAX_RESPONSE_BYTES) {
4752
+ throw new Error(`Polymarket response too large: ${length} bytes`);
4753
+ }
4576
4754
  const data = await res.json();
4577
4755
  const event = Array.isArray(data) ? data[0] : data;
4578
4756
  return event && typeof event === "object" ? event : void 0;
@@ -5025,10 +5203,13 @@ export {
5025
5203
  DEFAULT_FLAVOR,
5026
5204
  DEFAULT_MAX_AGE_MS,
5027
5205
  EspnAdapter,
5206
+ FEED_TEXT_MAX,
5028
5207
  FLAVOR_LEVELS,
5029
5208
  FakeMarketProvider,
5209
+ KNOWN_SOURCES,
5030
5210
  LIVE_WINDOW_MS,
5031
5211
  PolymarketProvider,
5212
+ ProviderError,
5032
5213
  SHARE_DISCLAIMER,
5033
5214
  SHARE_HASHTAG,
5034
5215
  allFixtures,
@@ -5043,6 +5224,7 @@ export {
5043
5224
  countdown,
5044
5225
  currentOrNextFixtureForTeam,
5045
5226
  deriveFavorite,
5227
+ displayWidth,
5046
5228
  favoriteStrength,
5047
5229
  fixturesByDate,
5048
5230
  fixturesByGroup,
@@ -5078,6 +5260,7 @@ export {
5078
5260
  isStaleSignal,
5079
5261
  isValidDate,
5080
5262
  isValidTimeZone,
5263
+ knockoutWindow,
5081
5264
  liveSourceLabel,
5082
5265
  loadBracketTopology,
5083
5266
  localDate,
@@ -5105,12 +5288,15 @@ export {
5105
5288
  normalizeLang,
5106
5289
  normalizeOutcomes,
5107
5290
  outcomeFromScore,
5291
+ padVisible,
5108
5292
  parseStandings,
5109
5293
  parseTeamSlot,
5110
5294
  resolveCompetition,
5111
5295
  resolveMarketSource,
5112
5296
  resolveTz,
5113
5297
  sanitizeBundledFixture,
5298
+ sanitizeFeedText,
5299
+ sanitizeMatchStrings,
5114
5300
  scoreline,
5115
5301
  stageLabel,
5116
5302
  stageLabelI18n,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudinho/core",
3
- "version": "0.8.18",
3
+ "version": "0.9.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",
@@ -54,6 +54,7 @@
54
54
  ],
55
55
  "devDependencies": {
56
56
  "@types/node": "^22.20.0",
57
+ "@vitest/coverage-v8": "^4.1.9",
57
58
  "tsup": "^8.0.0",
58
59
  "tsx": "^4.19.0",
59
60
  "typescript": "^5.7.0",