football-docs 0.1.1 → 0.2.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
@@ -1,39 +1,87 @@
1
- # @nutmeg/docs
1
+ # football-docs
2
2
 
3
3
  Searchable football data provider documentation for AI coding agents. Like [Context7](https://context7.com) for football data.
4
4
 
5
- An MCP server that gives your AI agent instant access to documentation for Opta, StatsBomb, Wyscout, SportMonks, kloppy, and free sources (FBref, Understat, ClubElo). Search event types, qualifier IDs, coordinate systems, API endpoints, and cross-provider mappings.
5
+ An MCP server that gives your AI agent instant access to documentation for Opta, StatsBomb, Wyscout, SportMonks, socceraction, kloppy, soccerdata, mplsoccer, databallpy, and free sources (FBref, Understat, ClubElo). Search event types, qualifier IDs, coordinate systems, API endpoints, and cross-provider mappings.
6
+
7
+ MCP ([Model Context Protocol](https://modelcontextprotocol.io)) is a standard for connecting AI coding tools to external data sources.
6
8
 
7
9
  ## Quick start
8
10
 
9
- ### With Claude Code
11
+ ### Claude Code
12
+
13
+ ```bash
14
+ claude mcp add football-docs -- npx -y football-docs
15
+ ```
10
16
 
11
- Add to your MCP config:
17
+ ### Cursor
18
+
19
+ Settings → MCP → Add server. Use this config:
12
20
 
13
21
  ```json
14
22
  {
15
23
  "mcpServers": {
16
24
  "football-docs": {
17
25
  "command": "npx",
18
- "args": ["-y", "@nutmeg/docs"]
26
+ "args": ["-y", "football-docs"]
19
27
  }
20
28
  }
21
29
  }
22
30
  ```
23
31
 
24
- ### With any MCP client
32
+ ### VS Code / Copilot
25
33
 
26
- ```bash
27
- npx @nutmeg/docs
34
+ Add to `.vscode/mcp.json`:
35
+
36
+ ```json
37
+ {
38
+ "servers": {
39
+ "football-docs": {
40
+ "command": "npx",
41
+ "args": ["-y", "football-docs"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ### Windsurf
48
+
49
+ Add to `~/.codeium/windsurf/mcp_config.json`:
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "football-docs": {
55
+ "command": "npx",
56
+ "args": ["-y", "football-docs"]
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ### Claude Desktop
63
+
64
+ Add to `claude_desktop_config.json`:
65
+
66
+ ```json
67
+ {
68
+ "mcpServers": {
69
+ "football-docs": {
70
+ "command": "npx",
71
+ "args": ["-y", "football-docs"]
72
+ }
73
+ }
74
+ }
28
75
  ```
29
76
 
30
77
  ## Tools
31
78
 
32
79
  | Tool | Description |
33
80
  |------|-------------|
34
- | `search_docs` | Full-text search across all provider docs. Filter by provider. |
81
+ | `search_docs` | Full-text search across all provider docs. Filter by provider. Results include provenance (source URL, version). |
35
82
  | `list_providers` | List all indexed providers and their doc coverage. |
36
83
  | `compare_providers` | Compare how different providers handle the same concept. |
84
+ | `request_update` | Request a new provider, flag outdated docs, or suggest a better doc source. Queued for maintainer review. |
37
85
 
38
86
  ## Example queries
39
87
 
@@ -42,45 +90,61 @@ npx @nutmeg/docs
42
90
  - "Compare Opta and Wyscout coordinate systems"
43
91
  - "Does SportMonks have xG data?"
44
92
  - "What event types does kloppy map to GenericEvent?"
93
+ - "How does SPADL represent a tackle?"
45
94
 
46
95
  ## Indexed providers
47
96
 
48
97
  | Provider | Chunks | Categories |
49
98
  |----------|--------|------------|
50
- | Opta | 29 | event-types, qualifiers, coordinate-system, api-access |
51
99
  | StatsBomb | 143 | event-types, data-model, coordinate-system, api-access, xg-model |
52
- | Wyscout | 61 | event-types, data-model, coordinate-system, api-access |
53
- | SportMonks | 71 | event-types, data-model, api-access |
54
100
  | kloppy | 100 | data-model, usage, provider-mapping |
55
- | Free sources | 28 | overview, fbref, understat |
101
+ | SportMonks | 71 | event-types, data-model, api-access |
102
+ | databallpy | 63 | data-model, overview, usage |
103
+ | mplsoccer | 62 | overview, pitch-types, visualizations |
104
+ | Wyscout | 61 | event-types, data-model, coordinate-system, api-access |
105
+ | Free sources | 45 | overview, fbref, understat |
106
+ | soccerdata | 40 | overview, data-sources, usage |
107
+ | Opta | 29 | event-types, qualifiers, coordinate-system, api-access |
108
+ | socceraction | 26 | SPADL format, VAEP, Expected Threat |
56
109
 
57
- **432 searchable chunks** across 6 providers.
110
+ **640 searchable chunks** across 10 providers.
58
111
 
59
- ## Contributing docs
112
+ ## Contributing
60
113
 
61
- Add or improve provider documentation by creating markdown files in `docs/{provider}/`:
114
+ Contributions are welcome from everyone. There are three ways to help:
62
115
 
63
- ```
64
- docs/
65
- opta/
66
- event-types.md
67
- qualifiers.md
68
- coordinate-system.md
69
- api-access.md
70
- statsbomb/
71
- ...
72
- your-new-provider/
73
- events.md
74
- api.md
75
- ```
116
+ 1. **Open an issue** — [request a new provider](https://github.com/withqwerty/football-docs/issues/new/choose), [flag outdated docs](https://github.com/withqwerty/football-docs/issues/new/choose), or [suggest a better doc source](https://github.com/withqwerty/football-docs/issues/new/choose)
117
+ 2. **Use the `request_update` tool** — AI agents can flag outdated or missing docs directly via the MCP server, which queues requests for maintainer review
118
+ 3. **Open a PR** — fix errors, add new providers, or improve existing docs
119
+
120
+ **You don't need to be an expert.** See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full guide.
121
+
122
+ ### What we especially need
123
+
124
+ | Provider | Priority | Source material to start from |
125
+ |----------|----------|-------------------------------|
126
+ | FPL (Fantasy Premier League) | High | [FPL APIs Explained](https://www.oliverlooney.com/blogs/FPL-APIs-Explained) |
127
+ | API-Football (RapidAPI) | High | [API-Football docs](https://www.api-football.com/documentation-v3) |
128
+ | Football-data.org | Medium | [Football-data.org docs](https://www.football-data.org/documentation/api) |
129
+ | TheSportsDB | Medium | [TheSportsDB API](https://www.thesportsdb.com/api.php) |
130
+ | WhoScored | Medium | Based on Opta F24, community-documented |
131
+ | Sofascore | Medium | Unofficial API, community-documented |
132
+
133
+ ## For maintainers
134
+
135
+ ### Crawl pipeline
76
136
 
77
- Then rebuild the index:
137
+ Provider doc sources are tracked in `providers.json`. The crawl pipeline discovers the best doc source (llms.txt > ReadTheDocs > GitHub README) and writes markdown with provenance frontmatter.
78
138
 
79
139
  ```bash
80
- npm run ingest
140
+ npm run discover # probe sources without crawling
141
+ npm run crawl # crawl all providers with sources
142
+ npm run crawl -- --provider kloppy # crawl one provider
143
+ npm run ingest # rebuild search index from docs/
144
+ npm run ingest -- --provider kloppy # re-ingest one provider (incremental)
81
145
  ```
82
146
 
83
- Each markdown file is chunked by heading (## or ###) and indexed with FTS5. PRs welcome.
147
+ Each crawled doc carries provenance metadata (source URL, source type, upstream version, crawl timestamp) that is surfaced in search results, so agents can distinguish between curated content and upstream documentation.
84
148
 
85
149
  ## License
86
150
 
package/data/docs.db CHANGED
Binary file
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Crawl upstream documentation for football data providers.
3
+ *
4
+ * Fetches documentation from the best available source (llms.txt, ReadTheDocs,
5
+ * GitHub, OpenAPI specs) and writes markdown files with provenance frontmatter.
6
+ *
7
+ * IMPORTANT: This crawler stores upstream content faithfully. It does NOT
8
+ * paraphrase, summarise, or interpret. The content in the output files should
9
+ * be directly traceable to the source URL.
10
+ *
11
+ * Usage:
12
+ * npm run crawl # crawl all providers with sources
13
+ * npm run crawl -- --provider kloppy # crawl one provider
14
+ * npm run crawl -- --discover # probe sources without crawling
15
+ */
16
+ interface CrawledDoc {
17
+ category: string;
18
+ content: string;
19
+ source_url: string;
20
+ source_type: string;
21
+ }
22
+ /**
23
+ * Extract main content from an HTML page and convert to markdown.
24
+ * Uses Mozilla's Readability (same algorithm as Firefox Reader View)
25
+ * to isolate the main content, then Turndown to convert to markdown.
26
+ */
27
+ export declare function htmlToMarkdown(html: string, url: string): string | null;
28
+ /**
29
+ * Crawl an llms.txt or llms-full.txt file.
30
+ * Already structured for LLM consumption — chunk by top-level heading.
31
+ */
32
+ export declare function crawlLlmsTxt(content: string, sourceUrl: string): CrawledDoc[];
33
+ export declare function slugify(text: string): string;
34
+ export {};
package/dist/crawl.js ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Crawl upstream documentation for football data providers.
3
+ *
4
+ * Fetches documentation from the best available source (llms.txt, ReadTheDocs,
5
+ * GitHub, OpenAPI specs) and writes markdown files with provenance frontmatter.
6
+ *
7
+ * IMPORTANT: This crawler stores upstream content faithfully. It does NOT
8
+ * paraphrase, summarise, or interpret. The content in the output files should
9
+ * be directly traceable to the source URL.
10
+ *
11
+ * Usage:
12
+ * npm run crawl # crawl all providers with sources
13
+ * npm run crawl -- --provider kloppy # crawl one provider
14
+ * npm run crawl -- --discover # probe sources without crawling
15
+ */
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
17
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import SwaggerParser from "@apidevtools/swagger-parser";
20
+ import { Readability } from "@mozilla/readability";
21
+ import { parseHTML } from "linkedom";
22
+ import TurndownService from "turndown";
23
+ import { discoverBestSource } from "./discover.js";
24
+ import { fetchText } from "./http.js";
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const DOCS_DIR = resolve(__dirname, "..", "docs");
27
+ const PROVIDERS_PATH = resolve(__dirname, "..", "providers.json");
28
+ const turndown = new TurndownService({
29
+ headingStyle: "atx",
30
+ codeBlockStyle: "fenced",
31
+ bulletListMarker: "-",
32
+ });
33
+ // ── HTML → Markdown ──────────────────────────────────────────────────
34
+ /**
35
+ * Extract main content from an HTML page and convert to markdown.
36
+ * Uses Mozilla's Readability (same algorithm as Firefox Reader View)
37
+ * to isolate the main content, then Turndown to convert to markdown.
38
+ */
39
+ export function htmlToMarkdown(html, url) {
40
+ // linkedom's parseHTML doesn't support a url option, so we inject a <base> tag
41
+ // for Readability to resolve relative links against the source URL
42
+ const htmlWithBase = html.includes("<base ")
43
+ ? html
44
+ : html.replace(/(<head[^>]*>)/i, `$1<base href="${url}">`);
45
+ const { document } = parseHTML(htmlWithBase);
46
+ const reader = new Readability(document, { charThreshold: 100 });
47
+ const article = reader.parse();
48
+ if (!article?.content)
49
+ return null;
50
+ return turndown.turndown(article.content);
51
+ }
52
+ // ── Crawl strategies ─────────────────────────────────────────────────
53
+ /**
54
+ * Crawl an llms.txt or llms-full.txt file.
55
+ * Already structured for LLM consumption — chunk by top-level heading.
56
+ */
57
+ export function crawlLlmsTxt(content, sourceUrl) {
58
+ const sections = [];
59
+ const lines = content.split("\n");
60
+ let currentTitle = "overview";
61
+ let currentLines = [];
62
+ for (const line of lines) {
63
+ const headingMatch = line.match(/^(#{1,2})\s+(.+)/);
64
+ if (headingMatch && currentLines.length > 0) {
65
+ const body = currentLines.join("\n").trim();
66
+ if (body.length > 50) {
67
+ sections.push({
68
+ category: slugify(currentTitle),
69
+ content: body,
70
+ source_url: sourceUrl,
71
+ source_type: "llms_txt",
72
+ });
73
+ }
74
+ currentTitle = headingMatch[2].trim();
75
+ currentLines = [line];
76
+ }
77
+ else {
78
+ currentLines.push(line);
79
+ }
80
+ }
81
+ const body = currentLines.join("\n").trim();
82
+ if (body.length > 50) {
83
+ sections.push({
84
+ category: slugify(currentTitle),
85
+ content: body,
86
+ source_url: sourceUrl,
87
+ source_type: "llms_txt",
88
+ });
89
+ }
90
+ if (sections.length <= 1) {
91
+ return [{
92
+ category: "reference",
93
+ content,
94
+ source_url: sourceUrl,
95
+ source_type: "llms_txt",
96
+ }];
97
+ }
98
+ return sections;
99
+ }
100
+ /**
101
+ * Crawl a documentation website by fetching linked pages from the index.
102
+ * Works with Sphinx/RTD, MkDocs, GitBook HTML, Docusaurus, and most
103
+ * static doc sites — Readability handles content extraction regardless
104
+ * of the specific platform.
105
+ */
106
+ async function crawlDocsSite(baseUrl) {
107
+ const docs = [];
108
+ const root = baseUrl.replace(/\/$/, "");
109
+ const rootHost = new URL(root).hostname;
110
+ const usedCategories = new Set();
111
+ const indexHtml = await fetchText(root);
112
+ if (!indexHtml)
113
+ return docs;
114
+ // Extract the root/index page itself
115
+ const indexContent = htmlToMarkdown(indexHtml, root);
116
+ if (indexContent && indexContent.length >= 100) {
117
+ usedCategories.add("index");
118
+ docs.push({
119
+ category: "index",
120
+ content: indexContent,
121
+ source_url: root,
122
+ source_type: "crawled",
123
+ });
124
+ }
125
+ // Extract links from the index page
126
+ const linkPattern = /href="([^"]*\.html?)"/g;
127
+ const links = new Set();
128
+ for (const match of indexHtml.matchAll(linkPattern)) {
129
+ const href = match[1];
130
+ if (href.startsWith("#") ||
131
+ href.includes("genindex") ||
132
+ href.includes("search.html") ||
133
+ href.includes("_static") ||
134
+ href.includes("_sources"))
135
+ continue;
136
+ let fullUrl;
137
+ try {
138
+ fullUrl = new URL(href, `${root}/`).href;
139
+ }
140
+ catch {
141
+ continue;
142
+ }
143
+ if (new URL(fullUrl).hostname !== rootHost)
144
+ continue;
145
+ links.add(fullUrl);
146
+ }
147
+ // Cap at 50 pages to avoid hammering upstream
148
+ const pageUrls = [...links].slice(0, 50);
149
+ for (const pageUrl of pageUrls) {
150
+ const html = await fetchText(pageUrl);
151
+ if (!html)
152
+ continue;
153
+ const content = htmlToMarkdown(html, pageUrl);
154
+ if (!content || content.length < 100)
155
+ continue;
156
+ // Use last 2 path segments to avoid duplicate category names
157
+ const pathSegments = new URL(pageUrl).pathname
158
+ .replace(/\.(html?|htm)$/, "")
159
+ .split("/")
160
+ .filter(Boolean);
161
+ let category = slugify(pathSegments.slice(-2).join("-") || "page");
162
+ // Deduplicate: append a suffix if the category is already used
163
+ if (usedCategories.has(category)) {
164
+ let suffix = 2;
165
+ while (usedCategories.has(`${category}-${suffix}`))
166
+ suffix++;
167
+ category = `${category}-${suffix}`;
168
+ }
169
+ usedCategories.add(category);
170
+ docs.push({
171
+ category,
172
+ content,
173
+ source_url: pageUrl,
174
+ source_type: "crawled",
175
+ });
176
+ }
177
+ return docs;
178
+ }
179
+ /**
180
+ * Crawl a GitHub repository's README.
181
+ */
182
+ async function crawlGitHubReadme(repoUrl) {
183
+ const docs = [];
184
+ const repoMatch = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
185
+ if (!repoMatch)
186
+ return docs;
187
+ const [, owner, repo] = repoMatch;
188
+ for (const branch of ["main", "master", "develop"]) {
189
+ const readmeUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/README.md`;
190
+ const content = await fetchText(readmeUrl);
191
+ if (content) {
192
+ docs.push({
193
+ category: "readme",
194
+ content,
195
+ source_url: readmeUrl,
196
+ source_type: "crawled",
197
+ });
198
+ break;
199
+ }
200
+ }
201
+ return docs;
202
+ }
203
+ /**
204
+ * Crawl an OpenAPI/Swagger spec and convert endpoints to searchable markdown.
205
+ * Handles both YAML and JSON specs, with $ref resolution.
206
+ */
207
+ async function crawlOpenApiSpec(specUrl) {
208
+ const docs = [];
209
+ // Fetch the spec ourselves first, then dereference the parsed object
210
+ // with external $ref resolution disabled to prevent SSRF via malicious specs
211
+ const specContent = await fetchText(specUrl);
212
+ if (!specContent)
213
+ return docs;
214
+ let api;
215
+ try {
216
+ const parsed = JSON.parse(specContent);
217
+ api = await SwaggerParser.dereference(parsed, {
218
+ resolve: { external: false },
219
+ });
220
+ }
221
+ catch {
222
+ // If JSON parse fails, try as YAML via URL (for YAML specs)
223
+ try {
224
+ api = await SwaggerParser.dereference(specUrl, {
225
+ resolve: { external: false },
226
+ });
227
+ }
228
+ catch (err) {
229
+ console.error(` Failed to parse OpenAPI spec: ${err}`);
230
+ return docs;
231
+ }
232
+ }
233
+ const info = api.info;
234
+ const paths = api.paths;
235
+ // Overview doc
236
+ const overviewLines = [
237
+ `# ${info?.title ?? "API"}`,
238
+ "",
239
+ ];
240
+ if (info?.description)
241
+ overviewLines.push(info.description, "");
242
+ if (info?.version)
243
+ overviewLines.push(`**Version:** ${info.version}`, "");
244
+ // Collect servers/base URLs if present
245
+ const servers = api.servers;
246
+ if (servers?.length) {
247
+ overviewLines.push("## Base URLs", "");
248
+ for (const s of servers) {
249
+ overviewLines.push(`- \`${s.url}\`${s.description ? ` — ${s.description}` : ""}`);
250
+ }
251
+ overviewLines.push("");
252
+ }
253
+ docs.push({
254
+ category: "api-overview",
255
+ content: overviewLines.join("\n").trim(),
256
+ source_url: specUrl,
257
+ source_type: "openapi",
258
+ });
259
+ if (!paths)
260
+ return docs;
261
+ // Group endpoints by tag (or by first path segment if untagged)
262
+ const byGroup = new Map();
263
+ for (const [path, methods] of Object.entries(paths)) {
264
+ if (!methods || typeof methods !== "object")
265
+ continue;
266
+ for (const [method, details] of Object.entries(methods)) {
267
+ if (method.startsWith("x-") || typeof details !== "object" || !details)
268
+ continue;
269
+ const op = details;
270
+ const tags = op.tags ?? [path.split("/").filter(Boolean)[0] ?? "general"];
271
+ const group = tags[0] ?? "general";
272
+ const lines = [];
273
+ lines.push(`### ${method.toUpperCase()} ${path}`);
274
+ if (op.summary)
275
+ lines.push("", String(op.summary));
276
+ if (op.description)
277
+ lines.push("", String(op.description));
278
+ // Parameters
279
+ const params = op.parameters;
280
+ if (params?.length) {
281
+ lines.push("", "**Parameters:**", "");
282
+ lines.push("| Name | In | Type | Required | Description |");
283
+ lines.push("|---|---|---|---|---|");
284
+ for (const p of params) {
285
+ const schema = p.schema;
286
+ lines.push(`| ${p.name} | ${p.in} | ${schema?.type ?? "—"} | ${p.required ? "yes" : "no"} | ${p.description ?? "—"} |`);
287
+ }
288
+ }
289
+ // Response codes
290
+ const responses = op.responses;
291
+ if (responses) {
292
+ lines.push("", "**Responses:**", "");
293
+ for (const [code, resp] of Object.entries(responses)) {
294
+ lines.push(`- **${code}**: ${resp.description ?? "—"}`);
295
+ }
296
+ }
297
+ lines.push("");
298
+ const groupLines = byGroup.get(group) ?? [];
299
+ groupLines.push(lines.join("\n"));
300
+ byGroup.set(group, groupLines);
301
+ }
302
+ }
303
+ // Write one doc per tag/group
304
+ for (const [group, endpoints] of byGroup) {
305
+ docs.push({
306
+ category: slugify(group),
307
+ content: `## ${group}\n\n${endpoints.join("\n---\n\n")}`,
308
+ source_url: specUrl,
309
+ source_type: "openapi",
310
+ });
311
+ }
312
+ return docs;
313
+ }
314
+ // ── Utilities ────────────────────────────────────────────────────────
315
+ export function slugify(text) {
316
+ return text
317
+ .toLowerCase()
318
+ .replace(/[^a-z0-9]+/g, "-")
319
+ .replace(/^-|-$/g, "")
320
+ .slice(0, 50);
321
+ }
322
+ /** Write a crawled doc to disk with provenance frontmatter. */
323
+ function writeCrawledDoc(provider, doc, upstreamVersion) {
324
+ const dir = resolve(DOCS_DIR, provider);
325
+ mkdirSync(dir, { recursive: true });
326
+ const frontmatter = [
327
+ "---",
328
+ `source_url: ${doc.source_url}`,
329
+ `source_type: ${doc.source_type}`,
330
+ upstreamVersion ? `upstream_version: ${upstreamVersion}` : `upstream_version:`,
331
+ `crawled_at: ${new Date().toISOString()}`,
332
+ "---",
333
+ "",
334
+ ].join("\n");
335
+ const filePath = resolve(dir, `${doc.category}.md`);
336
+ const rel = relative(dir, filePath);
337
+ if (rel.startsWith("..") || isAbsolute(rel)) {
338
+ throw new Error(`Path traversal detected: ${doc.category}`);
339
+ }
340
+ writeFileSync(filePath, frontmatter + doc.content, "utf-8");
341
+ }
342
+ // ── Main ─────────────────────────────────────────────────────────────
343
+ async function main() {
344
+ const args = process.argv.slice(2);
345
+ const providerArg = args.indexOf("--provider");
346
+ const singleProvider = providerArg >= 0 ? args[providerArg + 1] : undefined;
347
+ const discoverOnly = args.includes("--discover");
348
+ if (!existsSync(PROVIDERS_PATH)) {
349
+ console.error("providers.json not found. Create it first.");
350
+ process.exit(1);
351
+ }
352
+ const providersFile = JSON.parse(readFileSync(PROVIDERS_PATH, "utf-8"));
353
+ const providers = providersFile.providers;
354
+ const targets = singleProvider
355
+ ? { [singleProvider]: providers[singleProvider] }
356
+ : providers;
357
+ if (singleProvider && !providers[singleProvider]) {
358
+ console.error(`Provider "${singleProvider}" not found in providers.json`);
359
+ process.exit(1);
360
+ }
361
+ for (const [name, config] of Object.entries(targets)) {
362
+ if (!config)
363
+ continue;
364
+ const sources = config.sources
365
+ .filter((s) => !!s.url)
366
+ .map((s) => ({
367
+ url: s.url,
368
+ type: s.type,
369
+ note: s.note,
370
+ }));
371
+ if (sources.length === 0) {
372
+ console.log(`${name}: curated-only (no URLs to crawl)`);
373
+ continue;
374
+ }
375
+ console.log(`\n${name}: discovering sources...`);
376
+ const discovery = await discoverBestSource(name, sources);
377
+ for (const check of discovery.checked) {
378
+ const status = check.found ?? check.error ?? "not found";
379
+ console.log(` ${check.url} → ${status}`);
380
+ }
381
+ if (!discovery.source) {
382
+ console.log(` → no crawlable source found (chooseBestSource returned null)`);
383
+ continue;
384
+ }
385
+ console.log(` → best source: ${discovery.source.type} (${discovery.source.url})`);
386
+ if (discoverOnly)
387
+ continue;
388
+ // Crawl based on source type
389
+ let docs = [];
390
+ if (discovery.source.type === "llms_txt") {
391
+ const content = await fetchText(discovery.source.url);
392
+ if (content) {
393
+ docs = crawlLlmsTxt(content, discovery.source.url);
394
+ }
395
+ }
396
+ else if (discovery.source.type === "readthedocs" || discovery.source.type === "api_docs") {
397
+ docs = await crawlDocsSite(discovery.source.url);
398
+ }
399
+ else if (discovery.source.type === "github_docs") {
400
+ docs = await crawlGitHubReadme(discovery.source.url);
401
+ }
402
+ else if (discovery.source.type === "openapi") {
403
+ docs = await crawlOpenApiSpec(discovery.source.url);
404
+ }
405
+ if (docs.length === 0) {
406
+ console.log(` → no content extracted`);
407
+ continue;
408
+ }
409
+ for (const doc of docs) {
410
+ writeCrawledDoc(name, doc, config.version);
411
+ console.log(` wrote ${name}/${doc.category}.md (${doc.content.length} chars) [${doc.source_type}]`);
412
+ }
413
+ console.log(` → ${docs.length} doc(s) written`);
414
+ }
415
+ }
416
+ main().catch((err) => {
417
+ console.error("Crawl failed:", err);
418
+ process.exit(1);
419
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Source discovery for football data providers.
3
+ *
4
+ * Given a provider's known URLs, discovers the best documentation source
5
+ * to crawl. Checks for llms.txt, ReadTheDocs structure, GitHub docs, etc.
6
+ *
7
+ * Usage:
8
+ * import { discoverBestSource } from "./discover.js";
9
+ * const result = await discoverBestSource(provider);
10
+ */
11
+ export interface ProviderSource {
12
+ url: string;
13
+ type: "llms_txt" | "readthedocs" | "github_docs" | "api_docs" | "openapi" | "curated";
14
+ note?: string;
15
+ }
16
+ export interface DiscoveryResult {
17
+ provider: string;
18
+ /** The best source found, or null if only curated content is available */
19
+ source: ProviderSource | null;
20
+ /** All sources that were checked and what was found */
21
+ checked: Array<{
22
+ url: string;
23
+ found: string | null;
24
+ error?: string;
25
+ }>;
26
+ }
27
+ export interface ProbeResult {
28
+ url: string;
29
+ sourceType: ProviderSource["type"];
30
+ llmsTxt: {
31
+ url: string;
32
+ content: string;
33
+ } | null;
34
+ isReadTheDocs: boolean;
35
+ isReachable: boolean;
36
+ }
37
+ /**
38
+ * Choose the best source to crawl from probe results.
39
+ *
40
+ * Scoring: llms-full.txt (>1000 chars) = 100, llms.txt (>1000 chars) = 90,
41
+ * ReadTheDocs/Sphinx = 70, short llms.txt = 50, reachable = 40, unreachable = 0.
42
+ *
43
+ * @param probeResults - What was actually found at each URL
44
+ * @returns The best source to crawl, or null if only curated content is available
45
+ */
46
+ export declare function chooseBestSource(probeResults: ProbeResult[]): ProviderSource | null;
47
+ /**
48
+ * Discover the best documentation source for a provider.
49
+ *
50
+ * Probes all configured URLs for llms.txt, ReadTheDocs markers, and
51
+ * basic reachability, then uses chooseBestSource() to pick the winner.
52
+ */
53
+ export declare function discoverBestSource(provider: string, sources: ProviderSource[]): Promise<DiscoveryResult>;