@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 +107 -4
- package/dist/index.js +222 -36
- package/package.json +2 -1
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
|
|
584
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 = "
|
|
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 =
|
|
3072
|
-
|
|
3073
|
-
|
|
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
|
|
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
|
|
3198
|
-
*
|
|
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 =
|
|
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 =
|
|
3217
|
-
|
|
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 ??
|
|
3345
|
+
this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
3226
3346
|
);
|
|
3347
|
+
this.lastError = void 0;
|
|
3227
3348
|
try {
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4174
|
-
|
|
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
|
|
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
|
-
|
|
4216
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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.
|
|
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",
|