football-docs 0.1.2 → 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 +94 -30
- package/data/docs.db +0 -0
- package/dist/crawl.d.ts +34 -0
- package/dist/crawl.js +419 -0
- package/dist/discover.d.ts +53 -0
- package/dist/discover.js +121 -0
- package/dist/http.d.ts +8 -0
- package/dist/http.js +36 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +126 -18
- package/dist/ingest.d.ts +36 -12
- package/dist/ingest.js +120 -52
- package/docs/databallpy/data-model.md +146 -0
- package/docs/databallpy/overview.md +71 -0
- package/docs/databallpy/usage.md +280 -0
- package/docs/mplsoccer/overview.md +25 -0
- package/docs/mplsoccer/pitch-types.md +88 -0
- package/docs/mplsoccer/visualizations.md +280 -0
- package/docs/soccerdata/data-sources.md +131 -0
- package/docs/soccerdata/overview.md +54 -0
- package/docs/soccerdata/usage.md +157 -0
- package/package.json +24 -5
- package/providers.json +170 -0
package/README.md
CHANGED
|
@@ -2,13 +2,21 @@
|
|
|
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
|
-
###
|
|
11
|
+
### Claude Code
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
claude mcp add football-docs -- npx -y football-docs
|
|
15
|
+
```
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
### Cursor
|
|
18
|
+
|
|
19
|
+
Settings → MCP → Add server. Use this config:
|
|
12
20
|
|
|
13
21
|
```json
|
|
14
22
|
{
|
|
@@ -21,19 +29,59 @@ Add to your MCP config:
|
|
|
21
29
|
}
|
|
22
30
|
```
|
|
23
31
|
|
|
24
|
-
###
|
|
32
|
+
### VS Code / Copilot
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
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 football-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
|
-
|
|
|
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
|
-
**
|
|
110
|
+
**640 searchable chunks** across 10 providers.
|
|
58
111
|
|
|
59
|
-
## Contributing
|
|
112
|
+
## Contributing
|
|
60
113
|
|
|
61
|
-
|
|
114
|
+
Contributions are welcome from everyone. There are three ways to help:
|
|
62
115
|
|
|
63
|
-
|
|
64
|
-
docs
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
package/dist/crawl.d.ts
ADDED
|
@@ -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>;
|