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/dist/discover.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
import { fetchText, urlExists } from "./http.js";
|
|
12
|
+
/**
|
|
13
|
+
* Probe a base URL for llms.txt variants.
|
|
14
|
+
* Returns the URL and content of the best llms.txt found, or null.
|
|
15
|
+
*/
|
|
16
|
+
async function probeLlmsTxt(baseUrl) {
|
|
17
|
+
const root = baseUrl.replace(/\/$/, "");
|
|
18
|
+
for (const filename of ["llms-full.txt", "llms.txt"]) {
|
|
19
|
+
const url = `${root}/${filename}`;
|
|
20
|
+
const content = await fetchText(url);
|
|
21
|
+
if (content && content.length > 100) {
|
|
22
|
+
return { url, content };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check if a URL looks like a ReadTheDocs or Sphinx site.
|
|
29
|
+
* Uses a single GET request (no redundant HEAD).
|
|
30
|
+
*/
|
|
31
|
+
async function probeReadTheDocs(url) {
|
|
32
|
+
const html = await fetchText(url, 10000);
|
|
33
|
+
if (!html)
|
|
34
|
+
return false;
|
|
35
|
+
return (html.includes("readthedocs") ||
|
|
36
|
+
html.includes("sphinx") ||
|
|
37
|
+
html.includes("Read the Docs") ||
|
|
38
|
+
/\/en\/(latest|stable)\//.test(url));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Choose the best source to crawl from probe results.
|
|
42
|
+
*
|
|
43
|
+
* Scoring: llms-full.txt (>1000 chars) = 100, llms.txt (>1000 chars) = 90,
|
|
44
|
+
* ReadTheDocs/Sphinx = 70, short llms.txt = 50, reachable = 40, unreachable = 0.
|
|
45
|
+
*
|
|
46
|
+
* @param probeResults - What was actually found at each URL
|
|
47
|
+
* @returns The best source to crawl, or null if only curated content is available
|
|
48
|
+
*/
|
|
49
|
+
export function chooseBestSource(probeResults) {
|
|
50
|
+
let bestScore = 0;
|
|
51
|
+
let bestSource = null;
|
|
52
|
+
for (const probe of probeResults) {
|
|
53
|
+
let score = 0;
|
|
54
|
+
if (probe.llmsTxt) {
|
|
55
|
+
const isFullTxt = probe.llmsTxt.url.includes("llms-full.txt");
|
|
56
|
+
const isSubstantial = probe.llmsTxt.content.length > 1000;
|
|
57
|
+
score = isFullTxt && isSubstantial ? 100
|
|
58
|
+
: isSubstantial ? 90
|
|
59
|
+
: 50;
|
|
60
|
+
}
|
|
61
|
+
else if (probe.isReadTheDocs) {
|
|
62
|
+
score = 70;
|
|
63
|
+
}
|
|
64
|
+
else if (probe.isReachable) {
|
|
65
|
+
score = 40;
|
|
66
|
+
}
|
|
67
|
+
if (score > bestScore) {
|
|
68
|
+
bestScore = score;
|
|
69
|
+
bestSource = probe.llmsTxt
|
|
70
|
+
? { url: probe.llmsTxt.url, type: "llms_txt" }
|
|
71
|
+
: { url: probe.url, type: probe.sourceType };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return bestSource;
|
|
75
|
+
}
|
|
76
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
77
|
+
/**
|
|
78
|
+
* Discover the best documentation source for a provider.
|
|
79
|
+
*
|
|
80
|
+
* Probes all configured URLs for llms.txt, ReadTheDocs markers, and
|
|
81
|
+
* basic reachability, then uses chooseBestSource() to pick the winner.
|
|
82
|
+
*/
|
|
83
|
+
export async function discoverBestSource(provider, sources) {
|
|
84
|
+
const checked = [];
|
|
85
|
+
const probeResults = [];
|
|
86
|
+
for (const source of sources) {
|
|
87
|
+
if (source.type === "curated" || !source.url)
|
|
88
|
+
continue;
|
|
89
|
+
try {
|
|
90
|
+
const [llmsTxt, isReadTheDocs, reachability] = await Promise.all([
|
|
91
|
+
probeLlmsTxt(source.url),
|
|
92
|
+
probeReadTheDocs(source.url),
|
|
93
|
+
urlExists(source.url),
|
|
94
|
+
]);
|
|
95
|
+
const found = llmsTxt
|
|
96
|
+
? `llms.txt (${llmsTxt.content.length} chars)`
|
|
97
|
+
: isReadTheDocs
|
|
98
|
+
? "readthedocs"
|
|
99
|
+
: reachability.ok
|
|
100
|
+
? "reachable"
|
|
101
|
+
: null;
|
|
102
|
+
checked.push({ url: source.url, found });
|
|
103
|
+
probeResults.push({
|
|
104
|
+
url: source.url,
|
|
105
|
+
sourceType: source.type,
|
|
106
|
+
llmsTxt,
|
|
107
|
+
isReadTheDocs,
|
|
108
|
+
isReachable: reachability.ok,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
checked.push({
|
|
113
|
+
url: source.url,
|
|
114
|
+
found: null,
|
|
115
|
+
error: err instanceof Error ? err.message : String(err),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const bestSource = chooseBestSource(probeResults);
|
|
120
|
+
return { provider, source: bestSource, checked };
|
|
121
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Shared HTTP utilities for crawling and discovery. */
|
|
2
|
+
/** Check if a URL returns a successful response. */
|
|
3
|
+
export declare function urlExists(url: string): Promise<{
|
|
4
|
+
ok: boolean;
|
|
5
|
+
contentType?: string;
|
|
6
|
+
}>;
|
|
7
|
+
/** Fetch text content from a URL, returning null on failure. Rejects responses over 10MB. */
|
|
8
|
+
export declare function fetchText(url: string, timeoutMs?: number): Promise<string | null>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Shared HTTP utilities for crawling and discovery. */
|
|
2
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
3
|
+
/** Check if a URL returns a successful response. */
|
|
4
|
+
export async function urlExists(url) {
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetch(url, {
|
|
7
|
+
method: "HEAD",
|
|
8
|
+
signal: AbortSignal.timeout(5000),
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
ok: response.ok,
|
|
12
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { ok: false };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Fetch text content from a URL, returning null on failure. Rejects responses over 10MB. */
|
|
20
|
+
export async function fetchText(url, timeoutMs = 15000) {
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(url, {
|
|
23
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok)
|
|
26
|
+
return null;
|
|
27
|
+
// Reject obviously oversized responses early via Content-Length
|
|
28
|
+
const contentLength = response.headers.get("content-length");
|
|
29
|
+
if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES)
|
|
30
|
+
return null;
|
|
31
|
+
return await response.text();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* Nutmeg Football Docs MCP Server
|
|
3
3
|
*
|
|
4
4
|
* A Context7-style searchable index of football data provider documentation.
|
|
5
|
-
* Exposes
|
|
6
|
-
*
|
|
7
|
-
* - list_providers: List all indexed providers and their coverage
|
|
5
|
+
* Exposes tools for searching docs, listing providers, comparing providers,
|
|
6
|
+
* and requesting documentation updates.
|
|
8
7
|
*
|
|
9
8
|
* Data is stored in a SQLite FTS5 index for fast offline search.
|
|
9
|
+
* Update requests are stored in a separate writable SQLite DB in a
|
|
10
|
+
* user-writable location (XDG_DATA_HOME or ~/.local/share).
|
|
10
11
|
*/
|
|
11
|
-
|
|
12
|
+
/** Sanitise a query string for FTS5 MATCH by wrapping in double quotes (phrase search). */
|
|
13
|
+
export declare function sanitiseFtsQuery(query: string): string;
|
package/dist/index.js
CHANGED
|
@@ -2,32 +2,70 @@
|
|
|
2
2
|
* Nutmeg Football Docs MCP Server
|
|
3
3
|
*
|
|
4
4
|
* A Context7-style searchable index of football data provider documentation.
|
|
5
|
-
* Exposes
|
|
6
|
-
*
|
|
7
|
-
* - list_providers: List all indexed providers and their coverage
|
|
5
|
+
* Exposes tools for searching docs, listing providers, comparing providers,
|
|
6
|
+
* and requesting documentation updates.
|
|
8
7
|
*
|
|
9
8
|
* Data is stored in a SQLite FTS5 index for fast offline search.
|
|
9
|
+
* Update requests are stored in a separate writable SQLite DB in a
|
|
10
|
+
* user-writable location (XDG_DATA_HOME or ~/.local/share).
|
|
10
11
|
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
11
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
-
import { z } from "zod";
|
|
14
18
|
import Database from "better-sqlite3";
|
|
15
|
-
import {
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { existsSync } from "node:fs";
|
|
19
|
+
import { z } from "zod";
|
|
18
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
21
|
const DB_PATH = resolve(__dirname, "..", "data", "docs.db");
|
|
22
|
+
const PKG_VERSION = JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf-8")).version;
|
|
23
|
+
// Request queue DB goes in a user-writable location, not inside the npm package
|
|
24
|
+
const QUEUE_DB_DIR = resolve(process.env.XDG_DATA_HOME ?? resolve(homedir(), ".local", "share"), "football-docs");
|
|
25
|
+
const QUEUE_DB_PATH = resolve(QUEUE_DB_DIR, "requests.db");
|
|
20
26
|
// ── Database ────────────────────────────────────────────────────────────
|
|
21
27
|
function openDb() {
|
|
22
28
|
if (!existsSync(DB_PATH)) {
|
|
23
29
|
throw new Error(`Docs database not found at ${DB_PATH}. Run 'npm run ingest' first to build the index.`);
|
|
24
30
|
}
|
|
25
|
-
|
|
31
|
+
const db = new Database(DB_PATH, { readonly: true });
|
|
32
|
+
// Check schema version — source_type column was added in v0.2.0
|
|
33
|
+
const columns = db.pragma("table_info(docs)");
|
|
34
|
+
const hasProvenance = columns.some((c) => c.name === "source_type");
|
|
35
|
+
if (!hasProvenance) {
|
|
36
|
+
db.close();
|
|
37
|
+
throw new Error(`Docs database is outdated (missing provenance columns). Run 'npm run ingest' to rebuild.`);
|
|
38
|
+
}
|
|
39
|
+
return db;
|
|
40
|
+
}
|
|
41
|
+
function openQueueDb() {
|
|
42
|
+
mkdirSync(QUEUE_DB_DIR, { recursive: true });
|
|
43
|
+
const db = new Database(QUEUE_DB_PATH);
|
|
44
|
+
db.pragma("journal_mode = WAL");
|
|
45
|
+
db.exec(`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS requests (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
type TEXT NOT NULL,
|
|
49
|
+
provider TEXT NOT NULL,
|
|
50
|
+
reason TEXT NOT NULL,
|
|
51
|
+
suggested_urls TEXT,
|
|
52
|
+
requested_at TEXT NOT NULL,
|
|
53
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
54
|
+
)
|
|
55
|
+
`);
|
|
56
|
+
return db;
|
|
57
|
+
}
|
|
58
|
+
/** Sanitise a query string for FTS5 MATCH by wrapping in double quotes (phrase search). */
|
|
59
|
+
export function sanitiseFtsQuery(query) {
|
|
60
|
+
// FTS5 special syntax (AND, OR, NOT, NEAR, column:, *, ^) can cause errors.
|
|
61
|
+
// Wrap the user query in double quotes to treat it as a phrase search,
|
|
62
|
+
// escaping any embedded double quotes first.
|
|
63
|
+
return `"${query.replace(/"/g, '""')}"`;
|
|
26
64
|
}
|
|
27
65
|
// ── Server ──────────────────────────────────────────────────────────────
|
|
28
66
|
const server = new McpServer({
|
|
29
67
|
name: "nutmeg-football-docs",
|
|
30
|
-
version:
|
|
68
|
+
version: PKG_VERSION,
|
|
31
69
|
});
|
|
32
70
|
// Tool: search_docs
|
|
33
71
|
server.tool("search_docs", "Search football data provider documentation. Use for finding event types, qualifier IDs, API endpoints, coordinate systems, data models, and cross-provider mappings. Returns the most relevant documentation chunks.", {
|
|
@@ -44,19 +82,22 @@ server.tool("search_docs", "Search football data provider documentation. Use for
|
|
|
44
82
|
}, async ({ query, provider, max_results }) => {
|
|
45
83
|
const db = openDb();
|
|
46
84
|
try {
|
|
85
|
+
const safeQuery = sanitiseFtsQuery(query);
|
|
47
86
|
let sql = `
|
|
48
|
-
SELECT provider, category, title, content,
|
|
87
|
+
SELECT d.provider, d.category, d.title, d.content,
|
|
88
|
+
d.source_type, d.source_url, d.upstream_version,
|
|
49
89
|
rank * -1 as relevance
|
|
50
90
|
FROM docs_fts
|
|
91
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
51
92
|
WHERE docs_fts MATCH ?
|
|
52
93
|
`;
|
|
53
|
-
const params = [
|
|
94
|
+
const params = [safeQuery];
|
|
54
95
|
if (provider) {
|
|
55
|
-
sql += ` AND provider = ?`;
|
|
96
|
+
sql += ` AND d.provider = ?`;
|
|
56
97
|
params.push(provider.toLowerCase());
|
|
57
98
|
}
|
|
58
99
|
sql += ` ORDER BY rank LIMIT ?`;
|
|
59
|
-
params.push(max_results
|
|
100
|
+
params.push(max_results);
|
|
60
101
|
const rows = db.prepare(sql).all(...params);
|
|
61
102
|
if (rows.length === 0) {
|
|
62
103
|
return {
|
|
@@ -69,7 +110,12 @@ server.tool("search_docs", "Search football data provider documentation. Use for
|
|
|
69
110
|
};
|
|
70
111
|
}
|
|
71
112
|
const results = rows
|
|
72
|
-
.map((r, i) =>
|
|
113
|
+
.map((r, i) => {
|
|
114
|
+
const source = r.source_type === "curated"
|
|
115
|
+
? "**Source:** curated by football-docs contributors"
|
|
116
|
+
: `**Source:** ${r.source_type}${r.source_url ? ` (${r.source_url})` : ""}${r.upstream_version ? ` | v${r.upstream_version}` : ""}`;
|
|
117
|
+
return `## [${i + 1}] ${r.title}\n**Provider:** ${r.provider} | **Category:** ${r.category} | ${source}\n\n${r.content}`;
|
|
118
|
+
})
|
|
73
119
|
.join("\n\n---\n\n");
|
|
74
120
|
return {
|
|
75
121
|
content: [
|
|
@@ -124,19 +170,21 @@ server.tool("compare_providers", "Compare what two or more providers offer for a
|
|
|
124
170
|
}, async ({ topic, providers }) => {
|
|
125
171
|
const db = openDb();
|
|
126
172
|
try {
|
|
173
|
+
const safeTopic = sanitiseFtsQuery(topic);
|
|
127
174
|
let sql = `
|
|
128
|
-
SELECT provider, category, title, content,
|
|
175
|
+
SELECT d.provider, d.category, d.title, d.content,
|
|
129
176
|
rank * -1 as relevance
|
|
130
177
|
FROM docs_fts
|
|
178
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
131
179
|
WHERE docs_fts MATCH ?
|
|
132
180
|
`;
|
|
133
|
-
const params = [
|
|
181
|
+
const params = [safeTopic];
|
|
134
182
|
if (providers && providers.length > 0) {
|
|
135
183
|
const placeholders = providers.map(() => "?").join(", ");
|
|
136
|
-
sql += ` AND provider IN (${placeholders})`;
|
|
184
|
+
sql += ` AND d.provider IN (${placeholders})`;
|
|
137
185
|
params.push(...providers.map((p) => p.toLowerCase()));
|
|
138
186
|
}
|
|
139
|
-
sql += ` ORDER BY provider, rank LIMIT 30`;
|
|
187
|
+
sql += ` ORDER BY d.provider, rank LIMIT 30`;
|
|
140
188
|
const rows = db.prepare(sql).all(...params);
|
|
141
189
|
if (rows.length === 0) {
|
|
142
190
|
return {
|
|
@@ -171,6 +219,66 @@ server.tool("compare_providers", "Compare what two or more providers offer for a
|
|
|
171
219
|
db.close();
|
|
172
220
|
}
|
|
173
221
|
});
|
|
222
|
+
// Tool: request_update
|
|
223
|
+
server.tool("request_update", "Request that a provider's documentation be added, updated, or recrawled. Use when you notice docs are outdated, a provider is missing, or you know of a better documentation source. Requests are queued for review.", {
|
|
224
|
+
type: z.enum(["new_provider", "recrawl", "flag_outdated", "suggest_source"]).describe("Type of request: new_provider (add a new tool/library), recrawl (refresh existing docs), flag_outdated (mark docs as stale), suggest_source (recommend a better doc source like llms.txt)"),
|
|
225
|
+
provider: z.string().max(100).describe("Provider name (existing or proposed). Examples: 'statsbomb', 'mplsoccer', 'floodlight'"),
|
|
226
|
+
reason: z.string().max(2000).describe("Why this update is needed. Be specific: version bump, missing event types, new API endpoints, etc."),
|
|
227
|
+
suggested_urls: z.array(z.string().url().max(500)).max(10).optional().describe("URLs for documentation sources (readthedocs, GitHub, llms.txt, etc.)"),
|
|
228
|
+
}, async ({ type, provider, reason, suggested_urls }) => {
|
|
229
|
+
const qdb = openQueueDb();
|
|
230
|
+
try {
|
|
231
|
+
// Hard cap on queue size
|
|
232
|
+
const countRow = qdb.prepare("SELECT COUNT(*) as cnt FROM requests").get();
|
|
233
|
+
if (countRow.cnt >= 500) {
|
|
234
|
+
return {
|
|
235
|
+
isError: true,
|
|
236
|
+
content: [
|
|
237
|
+
{
|
|
238
|
+
type: "text",
|
|
239
|
+
text: `Request queue is full (${countRow.cnt} entries). Please file an issue at https://github.com/withqwerty/football-docs/issues instead.`,
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Cooldown: reject if same provider was requested in last 7 days
|
|
245
|
+
const recentRequest = qdb.prepare(`SELECT id, requested_at FROM requests
|
|
246
|
+
WHERE lower(provider) = lower(?) AND status = 'pending'
|
|
247
|
+
AND datetime(requested_at) > datetime('now', '-7 days')
|
|
248
|
+
LIMIT 1`).get(provider);
|
|
249
|
+
if (recentRequest) {
|
|
250
|
+
return {
|
|
251
|
+
isError: true,
|
|
252
|
+
content: [
|
|
253
|
+
{
|
|
254
|
+
type: "text",
|
|
255
|
+
text: `A pending request for "${provider}" already exists (from ${recentRequest.requested_at}). Requests have a 7-day cooldown to prevent duplicate work. Request ID: ${recentRequest.id}`,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const id = `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
261
|
+
qdb.prepare(`INSERT INTO requests (id, type, provider, reason, suggested_urls, requested_at, status)
|
|
262
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending')`).run(id, type, provider.toLowerCase(), reason, suggested_urls ? JSON.stringify(suggested_urls) : null, new Date().toISOString());
|
|
263
|
+
const typeLabel = {
|
|
264
|
+
new_provider: "New provider request",
|
|
265
|
+
recrawl: "Recrawl request",
|
|
266
|
+
flag_outdated: "Outdated docs flag",
|
|
267
|
+
suggest_source: "Source suggestion",
|
|
268
|
+
}[type];
|
|
269
|
+
return {
|
|
270
|
+
content: [
|
|
271
|
+
{
|
|
272
|
+
type: "text",
|
|
273
|
+
text: `${typeLabel} queued for "${provider}" (ID: ${id}).\n\nReason: ${reason}${suggested_urls?.length ? `\nSuggested URLs:\n${suggested_urls.map((u) => ` - ${u}`).join("\n")}` : ""}\n\nThis request will be reviewed by maintainers. You can also file an issue at https://github.com/withqwerty/football-docs/issues for community visibility.`,
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
qdb.close();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
174
282
|
// ── Start ───────────────────────────────────────────────────────────────
|
|
175
283
|
async function main() {
|
|
176
284
|
const transport = new StdioServerTransport();
|
package/dist/ingest.d.ts
CHANGED
|
@@ -1,21 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ingest provider documentation into the SQLite FTS5 index.
|
|
3
3
|
*
|
|
4
|
-
* Reads markdown files from docs/
|
|
5
|
-
* by heading (## or ###), storing each chunk with metadata.
|
|
4
|
+
* Reads markdown files from docs/{provider}/*.md and chunks them
|
|
5
|
+
* by heading (## or ###), storing each chunk with provenance metadata.
|
|
6
|
+
*
|
|
7
|
+
* Each markdown file can include a YAML frontmatter block with provenance:
|
|
8
|
+
* ---
|
|
9
|
+
* source_url: https://example.com/docs
|
|
10
|
+
* source_type: readthedocs
|
|
11
|
+
* upstream_version: 1.8.8
|
|
12
|
+
* crawled_at: 2026-03-26T00:00:00Z
|
|
13
|
+
* ---
|
|
14
|
+
*
|
|
15
|
+
* Files without frontmatter default to source_type: "curated".
|
|
6
16
|
*
|
|
7
17
|
* Usage:
|
|
8
18
|
* npm run ingest # ingest all providers
|
|
9
19
|
* npm run ingest -- --provider opta # ingest one provider
|
|
10
|
-
*
|
|
11
|
-
* Doc file naming convention:
|
|
12
|
-
* docs/providers/{provider}/{category}.md
|
|
13
|
-
*
|
|
14
|
-
* Example:
|
|
15
|
-
* docs/providers/opta/event-types.md
|
|
16
|
-
* docs/providers/opta/qualifiers.md
|
|
17
|
-
* docs/providers/opta/coordinate-system.md
|
|
18
|
-
* docs/providers/statsbomb/events.md
|
|
19
|
-
* docs/providers/kloppy/data-model.md
|
|
20
20
|
*/
|
|
21
|
+
interface Frontmatter {
|
|
22
|
+
source_url: string | null;
|
|
23
|
+
source_type: string;
|
|
24
|
+
upstream_version: string | null;
|
|
25
|
+
crawled_at: string | null;
|
|
26
|
+
}
|
|
27
|
+
interface DocChunk {
|
|
28
|
+
provider: string;
|
|
29
|
+
category: string;
|
|
30
|
+
title: string;
|
|
31
|
+
content: string;
|
|
32
|
+
source_url: string | null;
|
|
33
|
+
source_type: string;
|
|
34
|
+
upstream_version: string | null;
|
|
35
|
+
crawled_at: string | null;
|
|
36
|
+
}
|
|
37
|
+
/** Parse optional YAML frontmatter from a markdown file. */
|
|
38
|
+
export declare function parseFrontmatter(text: string): {
|
|
39
|
+
frontmatter: Frontmatter;
|
|
40
|
+
body: string;
|
|
41
|
+
};
|
|
42
|
+
/** Split a markdown file into chunks by ## or ### headings. */
|
|
43
|
+
export declare function chunkMarkdown(text: string, provider: string, category: string): DocChunk[];
|
|
44
|
+
export declare const SCHEMA_SQL = "\n CREATE TABLE IF NOT EXISTS docs (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n provider TEXT NOT NULL,\n category TEXT NOT NULL,\n title TEXT NOT NULL,\n content TEXT NOT NULL,\n source_url TEXT,\n source_type TEXT NOT NULL DEFAULT 'curated',\n upstream_version TEXT,\n crawled_at TEXT\n );\n\n CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(\n provider,\n category,\n title,\n content,\n content='docs',\n content_rowid='id',\n tokenize='porter unicode61'\n );\n\n CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN\n INSERT INTO docs_fts(rowid, provider, category, title, content)\n VALUES (new.id, new.provider, new.category, new.title, new.content);\n END;\n";
|
|
21
45
|
export {};
|