football-docs 0.6.0 → 0.6.2
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 +34 -21
- package/bin/serve.js +6 -1
- package/data/docs.db +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25 -5
- package/dist/tools.d.ts +12 -1
- package/dist/tools.js +309 -18
- package/docs/fotmob/identity-surfaces.md +3 -4
- package/docs/free-sources/contextual-story-joins.md +110 -0
- package/docs/free-sources/overview.md +60 -13
- package/docs/free-sources/understat.md +15 -6
- package/docs/free-sources/xg-timelines.md +123 -0
- package/docs/impect/identity-surfaces.md +3 -4
- package/docs/kloppy/event-derived-metrics.md +337 -0
- package/docs/kloppy/tracking-rendering.md +420 -0
- package/docs/mplsoccer/visualizations.md +363 -6
- package/docs/opta/charting-game-state.md +216 -0
- package/docs/opta/charting-lineups.md +90 -0
- package/docs/opta/charting-passmaps.md +130 -0
- package/docs/opta/charting-set-pieces.md +106 -0
- package/docs/opta/charting-shot-placement.md +233 -0
- package/docs/opta/event-types.md +44 -0
- package/docs/opta/identity-surfaces.md +4 -4
- package/docs/opta/qualifiers.md +16 -7
- package/docs/skillcorner/identity-surfaces.md +4 -4
- package/docs/skillcorner/physical-data.md +35 -0
- package/docs/socceraction/spadl.md +21 -0
- package/docs/soccerdata/data-sources.md +37 -0
- package/docs/soccerdonna/identity-surfaces.md +6 -6
- package/docs/sportmonks/charting-season-stories.md +125 -0
- package/docs/sportmonks/data-model.md +35 -12
- package/docs/sportmonks/event-types.md +1 -1
- package/docs/sportmonks/identity-surfaces.md +4 -4
- package/docs/sportradar/api-access.md +75 -0
- package/docs/sportradar/api-endpoints.md +92 -0
- package/docs/sportradar/charting-and-stories.md +57 -0
- package/docs/sportradar/data-model.md +84 -0
- package/docs/sportradar/integration-notes.md +58 -0
- package/docs/statsbomb/charting-lineups.md +79 -0
- package/docs/statsbomb/identity-surfaces.md +5 -4
- package/docs/statsbomb/player-mapping.md +6 -7
- package/docs/thesportsdb/api-access.md +68 -0
- package/docs/thesportsdb/api-endpoints.md +55 -0
- package/docs/thesportsdb/identity-surfaces.md +49 -0
- package/docs/thesportsdb/livescore.md +70 -0
- package/docs/transfermarkt/identity-surfaces.md +51 -6
- package/docs/transferroom/charting-availability.md +74 -0
- package/docs/wyscout/charting-analysis-metrics.md +124 -0
- package/docs/wyscout/identity-surfaces.md +4 -4
- package/package.json +3 -2
- package/providers.json +127 -2
package/README.md
CHANGED
|
@@ -4,19 +4,26 @@ Searchable football data provider and tooling documentation for AI coding agents
|
|
|
4
4
|
|
|
5
5
|
**Who it's for:** Developers and analysts who use AI coding tools (Claude Code, Cursor, VS Code Copilot) to work with football data. Works with any tool that supports MCP.
|
|
6
6
|
|
|
7
|
-
**What it does:** Gives your AI agent a searchable index of documentation for
|
|
7
|
+
**What it does:** Gives your AI agent a searchable index of documentation for 19 football data providers and tools — event types, qualifier IDs, coordinate systems, API endpoints, data models, identity surfaces, and cross-provider comparisons for the data providers (StatsBomb, Opta, Wyscout, Impect, SkillCorner, Sportradar, TheSportsDB, FMDB Pro, TransferRoom, and more), plus the open-source libraries people build with (kloppy, mplsoccer, socceraction, soccerdata, and more). Your agent looks up the real docs instead of guessing from training data.
|
|
8
8
|
|
|
9
9
|
**Why not just let the AI figure it out?** LLMs get football data specifics wrong constantly — Opta qualifier IDs, StatsBomb coordinate ranges, API endpoint URLs, library method signatures. These are mutable facts that change across versions. football-docs gives the agent verified, sourced documentation with provenance tracking so you know where every answer came from.
|
|
10
10
|
|
|
11
|
+
## Strategy
|
|
12
|
+
|
|
13
|
+
football-docs is intended to be a community-owned, source-transparent Context7
|
|
14
|
+
for football data. The public operating contract is in
|
|
15
|
+
[STRATEGY.md](STRATEGY.md): what belongs here, what must stay out, how we handle
|
|
16
|
+
public-safe provider facts, and how contributors should prove retrieval quality.
|
|
17
|
+
|
|
11
18
|
## Provider identity facts
|
|
12
19
|
|
|
13
20
|
football-docs is the public source for provider identity-surface facts: access
|
|
14
21
|
shape, ID schemes, matching fields, provider quirks, and provenance rules.
|
|
15
|
-
Curated
|
|
16
|
-
register state. They should say whether a fact comes from public docs,
|
|
17
|
-
page evidence, licensed feed shape, or a reviewed public-safe
|
|
18
|
-
must not include private paths, credentials, scraper state,
|
|
19
|
-
salts, or raw restricted payloads.
|
|
22
|
+
Curated provider identity notes belong here when they can be stated without
|
|
23
|
+
private register state. They should say whether a fact comes from public docs,
|
|
24
|
+
public page evidence, licensed feed shape, or a reviewed public-safe
|
|
25
|
+
observation, and must not include private paths, credentials, scraper state,
|
|
26
|
+
action ledgers, mint salts, or raw restricted payloads.
|
|
20
27
|
|
|
21
28
|
MCP ([Model Context Protocol](https://modelcontextprotocol.io)) is a standard for connecting AI coding tools to external data sources.
|
|
22
29
|
|
|
@@ -78,11 +85,15 @@ Add to `claude_desktop_config.json`:
|
|
|
78
85
|
| Tool | Description |
|
|
79
86
|
|------|-------------|
|
|
80
87
|
| `search_docs` | Full-text search across all provider docs. Filter by provider. Results include provenance (source URL, version). |
|
|
88
|
+
| `resolve_provider_id` | Resolve provider names and aliases to canonical indexed provider keys before searching. |
|
|
89
|
+
| `get_provider_docs` | Retrieve docs for a resolved provider, optionally filtered by topic or category. |
|
|
81
90
|
| `list_providers` | List all indexed providers and their doc coverage. |
|
|
82
91
|
| `compare_providers` | Compare how different providers handle the same concept. |
|
|
83
|
-
| `request_update` | Request a new provider, flag outdated docs, or suggest a better doc source.
|
|
92
|
+
| `request_update` | Request a new provider, flag outdated docs, or suggest a better doc source. Queues locally and points to the matching public GitHub issue template. |
|
|
84
93
|
| `resolve_entity` | Resolve players, teams, or coaches to cross-provider IDs via the Reep API. |
|
|
85
94
|
|
|
95
|
+
Provider filters use the indexed provider keys shown by `list_providers`, but common aliases are accepted. Examples: `fbref`, `understat`, `ClubElo`, `football-data.co.uk`, and `engsoccerdata` search `free-sources`; `Sofascore` and `ESPN` search `soccerdata`; `FMDB` searches `fmdb-pro`; `Transfer Room` searches `transferroom`; `Hudl Wyscout` searches `wyscout`; `Stats Perform` / `Opta F24` / `WhoScored` search `opta`; `Metrica`, `Sportec` / `DFL`, and `TRACAB` search `databallpy`; `Second Spectrum` searches `kloppy`; `SportRadar API` / `Soccer Extended` search `sportradar`; `The Sports DB` / `TSDB` search `thesportsdb`; `StatsBomb Open Data` searches `statsbomb`.
|
|
96
|
+
|
|
86
97
|
## Example queries
|
|
87
98
|
|
|
88
99
|
- "What is Opta qualifier 76?" (big chance)
|
|
@@ -97,32 +108,34 @@ Add to `claude_desktop_config.json`:
|
|
|
97
108
|
|
|
98
109
|
| Provider | Chunks | Categories |
|
|
99
110
|
|----------|--------|------------|
|
|
100
|
-
| StatsBomb |
|
|
101
|
-
| Wyscout |
|
|
102
|
-
| kloppy |
|
|
103
|
-
| SportMonks |
|
|
111
|
+
| StatsBomb | 237 | event-types, data-model, coordinate-system, api-access, api-endpoints, charting-lineups, xg-model, iq-metrics, player/team stats, player-mapping, identity-surfaces |
|
|
112
|
+
| Wyscout | 164 | event-types, data-model, coordinate-system, api-access, api-endpoints, charting-analysis-metrics, glossary, identity-surfaces |
|
|
113
|
+
| kloppy | 126 | data-model, usage, provider-mapping, tracking-rendering, event-derived-metrics |
|
|
114
|
+
| SportMonks | 85 | event-types, data-model, api-access, charting-season-stories, identity-surfaces |
|
|
104
115
|
| databallpy | 63 | data-model, overview, usage |
|
|
105
|
-
| mplsoccer |
|
|
116
|
+
| mplsoccer | 64 | overview, pitch-types, visualizations |
|
|
106
117
|
| Impect | 58 | api-access, api-endpoints, data-model, event-types, coordinate-system, concepts, identity-surfaces |
|
|
107
|
-
| SkillCorner |
|
|
108
|
-
| Free sources |
|
|
109
|
-
| soccerdata |
|
|
110
|
-
| TransferRoom |
|
|
111
|
-
| Opta |
|
|
118
|
+
| SkillCorner | 52 | api-access, api-endpoints, data-model, physical-data, coordinate-system, concepts, identity-surfaces |
|
|
119
|
+
| Free sources | 62 | overview, fbref, understat, contextual-story-joins, xg-timelines |
|
|
120
|
+
| soccerdata | 41 | overview, data-sources, usage |
|
|
121
|
+
| TransferRoom | 43 | api-access, api-endpoints, charting-availability, data-model, identity-surfaces |
|
|
122
|
+
| Opta | 74 | event-types, qualifiers, coordinate-system, api-access, charting-game-state, charting-lineups, charting-passmaps, charting-set-pieces, charting-shot-placement, identity-surfaces |
|
|
112
123
|
| FMDB Pro | 36 | api-access, api-endpoints, data-model, identity-surfaces |
|
|
113
|
-
|
|
|
124
|
+
| Sportradar | 29 | api-access, api-endpoints, data-model, charting-and-stories, integration-notes |
|
|
125
|
+
| socceraction | 27 | SPADL format, VAEP, Expected Threat |
|
|
126
|
+
| TheSportsDB | 18 | api-access, api-endpoints, livescore, identity-surfaces |
|
|
114
127
|
| FotMob | 7 | identity-surfaces |
|
|
115
128
|
| Soccerdonna | 7 | identity-surfaces |
|
|
116
|
-
| Transfermarkt |
|
|
129
|
+
| Transfermarkt | 8 | identity-surfaces |
|
|
117
130
|
|
|
118
|
-
**1,
|
|
131
|
+
**1,201 searchable chunks** across 19 providers and tools.
|
|
119
132
|
|
|
120
133
|
## Contributing
|
|
121
134
|
|
|
122
135
|
Contributions are welcome from everyone. There are three ways to help:
|
|
123
136
|
|
|
124
137
|
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)
|
|
125
|
-
2. **Use the `request_update` tool** — AI agents can flag outdated or missing docs directly via the MCP server, which queues requests
|
|
138
|
+
2. **Use the `request_update` tool** — AI agents can flag outdated or missing docs directly via the MCP server, which queues requests locally and points to the matching public GitHub issue template
|
|
126
139
|
3. **Open a PR** — fix errors, add new providers, or improve existing docs
|
|
127
140
|
|
|
128
141
|
**You don't need to be an expert.** See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full guide.
|
package/bin/serve.js
CHANGED
|
@@ -3,4 +3,9 @@ import { resolve, dirname } from "node:path";
|
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
|
|
5
5
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
await import(pathToFileURL(resolve(__dirname, "..", "dist", "index.js")).href);
|
|
6
|
+
const { main } = await import(pathToFileURL(resolve(__dirname, "..", "dist", "index.js")).href);
|
|
7
|
+
|
|
8
|
+
main().catch((error) => {
|
|
9
|
+
console.error("Failed to start football-docs MCP server:", error);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
package/data/docs.db
CHANGED
|
Binary file
|
package/dist/index.d.ts
CHANGED
|
@@ -15,3 +15,4 @@ export { sanitiseFtsQuery } from "./tools.js";
|
|
|
15
15
|
export declare function openDb(): Database.Database;
|
|
16
16
|
export declare function openQueueDb(): Database.Database;
|
|
17
17
|
export declare function createFootballDocsServer(): McpServer;
|
|
18
|
+
export declare function main(): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
17
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
18
|
import Database from "better-sqlite3";
|
|
19
19
|
import { z } from "zod";
|
|
20
|
-
import { compareProviders, listProviders, requestUpdate, resolveEntity, searchDocs, } from "./tools.js";
|
|
20
|
+
import { compareProviders, getProviderDocs, listProviders, requestUpdate, resolveEntity, resolveProviderId, searchDocs, } from "./tools.js";
|
|
21
21
|
export { sanitiseFtsQuery } from "./tools.js";
|
|
22
22
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
23
|
const DB_PATH = resolve(__dirname, "..", "data", "docs.db");
|
|
@@ -78,24 +78,44 @@ export function createFootballDocsServer() {
|
|
|
78
78
|
version: PKG_VERSION,
|
|
79
79
|
});
|
|
80
80
|
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.", {
|
|
81
|
-
query: z.string().describe("Search query. Examples: 'Opta goal qualifier', 'StatsBomb shot event type', 'coordinate system differences', 'xG qualifier ID', 'SportMonks fixture endpoint'"),
|
|
81
|
+
query: z.string().describe("Search query. Examples: 'Opta goal qualifier', 'StatsBomb shot event type', 'coordinate system differences', 'xG qualifier ID', 'SportMonks fixture endpoint', 'FMDB Pro players endpoint'"),
|
|
82
82
|
provider: z
|
|
83
83
|
.string()
|
|
84
84
|
.optional()
|
|
85
|
-
.describe("
|
|
85
|
+
.describe("Optional provider filter. Use list_providers for indexed provider keys. Common aliases such as fbref, understat, ClubElo, football-data.co.uk, engsoccerdata, Sofascore, ESPN, FMDB, TransferRoom, Hudl Wyscout, Stats Perform, Opta F24, WhoScored, Metrica, Sportec/DFL, TRACAB, Second Spectrum, SportRadar API, Soccer Extended, TheSportsDB, and TSDB are accepted."),
|
|
86
86
|
max_results: z
|
|
87
87
|
.number()
|
|
88
88
|
.optional()
|
|
89
89
|
.default(10)
|
|
90
90
|
.describe("Maximum number of results to return (default 10)"),
|
|
91
91
|
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => searchDocs(db, args)));
|
|
92
|
+
server.tool("resolve_provider_id", "Resolve a football data provider name or alias to the canonical football-docs provider key before searching. Use when users mention brands, vendors, products, or aliases such as Stats Perform, Opta F24, Hudl Wyscout, Second Spectrum, FMDB, Transfer Room, FBref, Sofascore, or TheSportsDB.", {
|
|
93
|
+
query: z
|
|
94
|
+
.string()
|
|
95
|
+
.describe("Provider name, brand, product, or alias to resolve to a canonical provider key."),
|
|
96
|
+
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => resolveProviderId(db, args)));
|
|
97
|
+
server.tool("get_provider_docs", "Retrieve documentation for a resolved provider, optionally filtered by topic or indexed category. Use after resolve_provider_id when you know which provider to inspect and want provenance-bearing docs.", {
|
|
98
|
+
provider: z
|
|
99
|
+
.string()
|
|
100
|
+
.describe("Provider key or alias. Use resolve_provider_id first when the provider name is ambiguous."),
|
|
101
|
+
topic: z.string().optional().describe("Optional topic to search within this provider's docs."),
|
|
102
|
+
category: z
|
|
103
|
+
.string()
|
|
104
|
+
.optional()
|
|
105
|
+
.describe("Optional indexed category to restrict results, such as api-endpoints, qualifiers, identity-surfaces, or tracking-rendering."),
|
|
106
|
+
max_results: z
|
|
107
|
+
.number()
|
|
108
|
+
.optional()
|
|
109
|
+
.default(10)
|
|
110
|
+
.describe("Maximum number of provider docs to return (default 10)."),
|
|
111
|
+
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => getProviderDocs(db, args)));
|
|
92
112
|
server.tool("list_providers", "List all indexed football data providers, their document count, and coverage categories. Use to understand what documentation is available. Call this first to see what providers are indexed before searching.", {}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => withDocsDb((db) => listProviders(db)));
|
|
93
113
|
server.tool("compare_providers", "Compare what two or more providers offer for a specific data type or concept. For example: 'How do Opta and StatsBomb represent shot events differently?'", {
|
|
94
114
|
topic: z.string().describe("The concept to compare across providers. Examples: 'shot events', 'coordinate systems', 'xG', 'pass types'"),
|
|
95
115
|
providers: z
|
|
96
116
|
.array(z.string())
|
|
97
117
|
.optional()
|
|
98
|
-
.describe("Providers to compare. If omitted, compares all indexed providers."),
|
|
118
|
+
.describe("Providers to compare. If omitted, compares all indexed providers. Use list_providers for indexed keys; common aliases such as ClubElo, football-data.co.uk, engsoccerdata, Sofascore, ESPN, StatsBomb Open Data, Opta F24, WhoScored, SkillCorner, Metrica, Sportec/DFL, TRACAB, Second Spectrum, SportRadar API, Soccer Extended, TheSportsDB, and TSDB are accepted."),
|
|
99
119
|
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => compareProviders(db, args)));
|
|
100
120
|
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.", {
|
|
101
121
|
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)"),
|
|
@@ -128,7 +148,7 @@ export function createFootballDocsServer() {
|
|
|
128
148
|
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async (args) => resolveEntity(args));
|
|
129
149
|
return server;
|
|
130
150
|
}
|
|
131
|
-
async function main() {
|
|
151
|
+
export async function main() {
|
|
132
152
|
const transport = new StdioServerTransport();
|
|
133
153
|
await createFootballDocsServer().connect(transport);
|
|
134
154
|
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type Database from "better-sqlite3";
|
|
2
|
-
export declare const TOOL_NAMES: readonly ["search_docs", "list_providers", "compare_providers", "request_update", "resolve_entity"];
|
|
2
|
+
export declare const TOOL_NAMES: readonly ["search_docs", "resolve_provider_id", "get_provider_docs", "list_providers", "compare_providers", "request_update", "resolve_entity"];
|
|
3
3
|
type TextContent = {
|
|
4
4
|
type: "text";
|
|
5
5
|
text: string;
|
|
@@ -13,6 +13,15 @@ export type SearchDocsArgs = {
|
|
|
13
13
|
provider?: string;
|
|
14
14
|
max_results?: number;
|
|
15
15
|
};
|
|
16
|
+
export type ResolveProviderIdArgs = {
|
|
17
|
+
query: string;
|
|
18
|
+
};
|
|
19
|
+
export type GetProviderDocsArgs = {
|
|
20
|
+
provider: string;
|
|
21
|
+
topic?: string;
|
|
22
|
+
category?: string;
|
|
23
|
+
max_results?: number;
|
|
24
|
+
};
|
|
16
25
|
export type CompareProvidersArgs = {
|
|
17
26
|
topic: string;
|
|
18
27
|
providers?: string[];
|
|
@@ -38,6 +47,8 @@ export type ResolveEntityArgs = {
|
|
|
38
47
|
* like "Opta qualifier 76" to match docs that contain those tokens separately.
|
|
39
48
|
*/
|
|
40
49
|
export declare function sanitiseFtsQuery(query: string): string;
|
|
50
|
+
export declare function resolveProviderId(db: Database.Database, args: ResolveProviderIdArgs): ToolResponse;
|
|
51
|
+
export declare function getProviderDocs(db: Database.Database, args: GetProviderDocsArgs): ToolResponse;
|
|
41
52
|
export declare function searchDocs(db: Database.Database, args: SearchDocsArgs): ToolResponse;
|
|
42
53
|
export declare function listProviders(db: Database.Database): ToolResponse;
|
|
43
54
|
export declare function compareProviders(db: Database.Database, args: CompareProvidersArgs): ToolResponse;
|
package/dist/tools.js
CHANGED
|
@@ -1,11 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
1
4
|
export const TOOL_NAMES = [
|
|
2
5
|
"search_docs",
|
|
6
|
+
"resolve_provider_id",
|
|
7
|
+
"get_provider_docs",
|
|
3
8
|
"list_providers",
|
|
4
9
|
"compare_providers",
|
|
5
10
|
"request_update",
|
|
6
11
|
"resolve_entity",
|
|
7
12
|
];
|
|
8
13
|
const QUERY_STOP_WORDS = new Set(["and", "or", "not", "near"]);
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const PROVIDERS_PATH = resolve(__dirname, "..", "providers.json");
|
|
16
|
+
const PROVIDER_REGISTRY = JSON.parse(readFileSync(PROVIDERS_PATH, "utf-8"));
|
|
17
|
+
function slugProvider(provider) {
|
|
18
|
+
return provider
|
|
19
|
+
.trim()
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/&/g, " and ")
|
|
22
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
23
|
+
.replace(/^-|-$/g, "");
|
|
24
|
+
}
|
|
25
|
+
function buildProviderAliases() {
|
|
26
|
+
const aliases = {};
|
|
27
|
+
for (const [provider, metadata] of Object.entries(PROVIDER_REGISTRY.providers)) {
|
|
28
|
+
aliases[slugProvider(provider)] = provider;
|
|
29
|
+
aliases[slugProvider(metadata.display_name)] = provider;
|
|
30
|
+
for (const alias of metadata.aliases) {
|
|
31
|
+
aliases[slugProvider(alias)] = provider;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return aliases;
|
|
35
|
+
}
|
|
36
|
+
const PROVIDER_ALIASES = buildProviderAliases();
|
|
37
|
+
const DISPLAY_PROVIDER_ALIASES = Object.entries(PROVIDER_ALIASES).reduce((aliasesByProvider, [alias, provider]) => {
|
|
38
|
+
if (alias === provider)
|
|
39
|
+
return aliasesByProvider;
|
|
40
|
+
const aliases = aliasesByProvider[provider] ?? [];
|
|
41
|
+
aliases.push(alias);
|
|
42
|
+
aliasesByProvider[provider] = aliases;
|
|
43
|
+
return aliasesByProvider;
|
|
44
|
+
}, {});
|
|
9
45
|
function textResult(text, isError = false) {
|
|
10
46
|
return {
|
|
11
47
|
isError: isError || undefined,
|
|
@@ -42,7 +78,83 @@ function relaxedFtsQuery(query) {
|
|
|
42
78
|
return tokens.map(quoteFtsToken).join(" OR ");
|
|
43
79
|
}
|
|
44
80
|
function normaliseProvider(provider) {
|
|
45
|
-
|
|
81
|
+
const slug = slugProvider(provider);
|
|
82
|
+
return PROVIDER_ALIASES[slug] ?? slug;
|
|
83
|
+
}
|
|
84
|
+
function providerFilterLabel(original, normalised) {
|
|
85
|
+
if (!original || !normalised)
|
|
86
|
+
return "";
|
|
87
|
+
const trimmed = original.trim();
|
|
88
|
+
return trimmed === normalised ? normalised : `${trimmed} (${normalised})`;
|
|
89
|
+
}
|
|
90
|
+
function indexedProviders(db) {
|
|
91
|
+
const rows = db.prepare("SELECT DISTINCT provider FROM docs ORDER BY provider").all();
|
|
92
|
+
return new Set(rows.map((row) => row.provider));
|
|
93
|
+
}
|
|
94
|
+
function editDistance(a, b) {
|
|
95
|
+
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
96
|
+
const current = Array.from({ length: b.length + 1 }, () => 0);
|
|
97
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
98
|
+
current[0] = i;
|
|
99
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
100
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
101
|
+
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
|
|
102
|
+
}
|
|
103
|
+
previous.splice(0, previous.length, ...current);
|
|
104
|
+
}
|
|
105
|
+
return previous[b.length];
|
|
106
|
+
}
|
|
107
|
+
function providerSuggestions(provider, providerSet) {
|
|
108
|
+
const candidates = new Map();
|
|
109
|
+
for (const indexedProvider of providerSet) {
|
|
110
|
+
candidates.set(indexedProvider, indexedProvider);
|
|
111
|
+
}
|
|
112
|
+
for (const [alias, indexedProvider] of Object.entries(PROVIDER_ALIASES)) {
|
|
113
|
+
if (providerSet.has(indexedProvider)) {
|
|
114
|
+
candidates.set(alias, indexedProvider);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const threshold = Math.max(2, Math.ceil(provider.length * 0.3));
|
|
118
|
+
return [...candidates.entries()]
|
|
119
|
+
.map(([candidate, indexedProvider]) => ({
|
|
120
|
+
candidate,
|
|
121
|
+
indexedProvider,
|
|
122
|
+
distance: editDistance(provider, candidate),
|
|
123
|
+
}))
|
|
124
|
+
.filter((suggestion) => suggestion.distance <= threshold)
|
|
125
|
+
.sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate))
|
|
126
|
+
.map((suggestion) => suggestion.indexedProvider)
|
|
127
|
+
.filter((providerName, index, suggestions) => suggestions.indexOf(providerName) === index)
|
|
128
|
+
.slice(0, 3);
|
|
129
|
+
}
|
|
130
|
+
function unknownProviderMessage(original, provider, providerSet) {
|
|
131
|
+
const suggestions = providerSuggestions(provider, providerSet);
|
|
132
|
+
const suggestionText = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : "";
|
|
133
|
+
return `Provider "${providerFilterLabel(original, provider)}" is not indexed.${suggestionText} Call list_providers for available provider keys, or use request_update to suggest adding it.`;
|
|
134
|
+
}
|
|
135
|
+
function requestProviderKey(provider) {
|
|
136
|
+
return slugProvider(provider).replace(/-/g, "");
|
|
137
|
+
}
|
|
138
|
+
function requestIssueTemplate(type) {
|
|
139
|
+
return {
|
|
140
|
+
new_provider: "new-provider.md",
|
|
141
|
+
recrawl: "flag-outdated.md",
|
|
142
|
+
flag_outdated: "flag-outdated.md",
|
|
143
|
+
suggest_source: "suggest-source.md",
|
|
144
|
+
}[type];
|
|
145
|
+
}
|
|
146
|
+
function requestIssueUrl(args) {
|
|
147
|
+
const titlePrefix = {
|
|
148
|
+
new_provider: "New provider",
|
|
149
|
+
recrawl: "Recrawl",
|
|
150
|
+
flag_outdated: "Outdated",
|
|
151
|
+
suggest_source: "Better source",
|
|
152
|
+
}[args.type];
|
|
153
|
+
const params = new URLSearchParams({
|
|
154
|
+
template: requestIssueTemplate(args.type),
|
|
155
|
+
title: `${titlePrefix}: ${args.provider}`,
|
|
156
|
+
});
|
|
157
|
+
return `https://github.com/withqwerty/football-docs/issues/new?${params.toString()}`;
|
|
46
158
|
}
|
|
47
159
|
function normaliseLimit(value, defaultValue, maxValue) {
|
|
48
160
|
if (value === undefined || !Number.isFinite(value))
|
|
@@ -53,6 +165,7 @@ function searchRows(db, matchQuery, provider, limit) {
|
|
|
53
165
|
let sql = `
|
|
54
166
|
SELECT d.provider, d.category, d.title, d.content,
|
|
55
167
|
d.source_type, d.source_url, d.upstream_version,
|
|
168
|
+
d.crawled_at,
|
|
56
169
|
rank * -1 as relevance
|
|
57
170
|
FROM docs_fts
|
|
58
171
|
JOIN docs d ON d.id = docs_fts.rowid
|
|
@@ -84,29 +197,162 @@ function compareRows(db, matchQuery, providers) {
|
|
|
84
197
|
sql += " ORDER BY d.provider, rank LIMIT 30";
|
|
85
198
|
return db.prepare(sql).all(...params);
|
|
86
199
|
}
|
|
200
|
+
function compareRowsForProvider(db, matchQuery, provider, limit) {
|
|
201
|
+
return db
|
|
202
|
+
.prepare(`SELECT d.provider, d.category, d.title, d.content,
|
|
203
|
+
rank * -1 as relevance
|
|
204
|
+
FROM docs_fts
|
|
205
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
206
|
+
WHERE docs_fts MATCH ? AND d.provider = ?
|
|
207
|
+
ORDER BY rank
|
|
208
|
+
LIMIT ?`)
|
|
209
|
+
.all(matchQuery, normaliseProvider(provider), limit);
|
|
210
|
+
}
|
|
87
211
|
function sourceLabel(row) {
|
|
212
|
+
const crawledAt = row.crawled_at ? ` | crawled ${row.crawled_at}` : "";
|
|
88
213
|
if (row.source_type === "curated") {
|
|
89
|
-
return
|
|
214
|
+
return `**Source:** curated by football-docs contributors${crawledAt}`;
|
|
90
215
|
}
|
|
91
|
-
return `**Source:** ${row.source_type}${row.source_url ? ` (${row.source_url})` : ""}${row.upstream_version ? ` | v${row.upstream_version}` : ""}`;
|
|
216
|
+
return `**Source:** ${row.source_type}${row.source_url ? ` (${row.source_url})` : ""}${row.upstream_version ? ` | v${row.upstream_version}` : ""}${crawledAt}`;
|
|
217
|
+
}
|
|
218
|
+
function providerMetadata(provider) {
|
|
219
|
+
return PROVIDER_REGISTRY.providers[provider];
|
|
220
|
+
}
|
|
221
|
+
function providerCoverage(db, provider) {
|
|
222
|
+
const rows = db
|
|
223
|
+
.prepare(`SELECT category, COUNT(*) as chunks
|
|
224
|
+
FROM docs
|
|
225
|
+
WHERE provider = ?
|
|
226
|
+
GROUP BY category
|
|
227
|
+
ORDER BY category`)
|
|
228
|
+
.all(provider);
|
|
229
|
+
return {
|
|
230
|
+
indexed: rows.length > 0,
|
|
231
|
+
total: rows.reduce((sum, row) => sum + row.chunks, 0),
|
|
232
|
+
categories: rows.map((row) => `${row.category} (${row.chunks})`),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function formatProviderResolution(db, provider, originalQuery) {
|
|
236
|
+
const metadata = providerMetadata(provider);
|
|
237
|
+
const coverage = providerCoverage(db, provider);
|
|
238
|
+
const aliasText = metadata?.aliases.length ? metadata.aliases.join(", ") : "none";
|
|
239
|
+
const sourceText = metadata?.sources
|
|
240
|
+
.map((source) => `${source.type}${source.url ? `: ${source.url}` : ""}`)
|
|
241
|
+
.join("; ") || "none registered";
|
|
242
|
+
return [
|
|
243
|
+
`Resolved "${originalQuery}" to provider ID: **${provider}**`,
|
|
244
|
+
`**Display name:** ${metadata?.display_name ?? provider}`,
|
|
245
|
+
`**Indexed:** ${coverage.indexed ? `yes (${coverage.total} chunks)` : "no"}`,
|
|
246
|
+
`**Categories:** ${coverage.categories.length ? coverage.categories.join(", ") : "none indexed"}`,
|
|
247
|
+
`**Aliases:** ${aliasText}`,
|
|
248
|
+
`**Access level:** ${metadata?.access_level ?? "unknown"}`,
|
|
249
|
+
`**Licence/status:** ${metadata?.licence_status ?? "unknown"}`,
|
|
250
|
+
`**Public-safety notes:** ${metadata?.public_safety_notes ?? "none registered"}`,
|
|
251
|
+
`**Version:** ${metadata?.version ?? "unversioned"}`,
|
|
252
|
+
`**Last crawled:** ${metadata?.last_crawled ?? "not crawled"}`,
|
|
253
|
+
`**Registered sources:** ${sourceText}`,
|
|
254
|
+
].join("\n");
|
|
255
|
+
}
|
|
256
|
+
function providerDocsRows(db, provider, matchQuery, category, limit) {
|
|
257
|
+
const params = [];
|
|
258
|
+
let sql;
|
|
259
|
+
if (matchQuery) {
|
|
260
|
+
sql = `
|
|
261
|
+
SELECT d.provider, d.category, d.title, d.content,
|
|
262
|
+
d.source_type, d.source_url, d.upstream_version,
|
|
263
|
+
d.crawled_at,
|
|
264
|
+
rank * -1 as relevance
|
|
265
|
+
FROM docs_fts
|
|
266
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
267
|
+
WHERE docs_fts MATCH ? AND d.provider = ?
|
|
268
|
+
`;
|
|
269
|
+
params.push(matchQuery, provider);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
sql = `
|
|
273
|
+
SELECT d.provider, d.category, d.title, d.content,
|
|
274
|
+
d.source_type, d.source_url, d.upstream_version,
|
|
275
|
+
d.crawled_at,
|
|
276
|
+
0 as relevance
|
|
277
|
+
FROM docs d
|
|
278
|
+
WHERE d.provider = ?
|
|
279
|
+
`;
|
|
280
|
+
params.push(provider);
|
|
281
|
+
}
|
|
282
|
+
if (category) {
|
|
283
|
+
sql += " AND d.category = ?";
|
|
284
|
+
params.push(category);
|
|
285
|
+
}
|
|
286
|
+
sql += matchQuery ? " ORDER BY rank LIMIT ?" : " ORDER BY d.category, d.id LIMIT ?";
|
|
287
|
+
params.push(limit);
|
|
288
|
+
return db.prepare(sql).all(...params);
|
|
289
|
+
}
|
|
290
|
+
export function resolveProviderId(db, args) {
|
|
291
|
+
const provider = normaliseProvider(args.query);
|
|
292
|
+
const registryProviders = new Set(Object.keys(PROVIDER_REGISTRY.providers));
|
|
293
|
+
const indexedProviderSet = indexedProviders(db);
|
|
294
|
+
if (registryProviders.has(provider)) {
|
|
295
|
+
return textResult(formatProviderResolution(db, provider, args.query));
|
|
296
|
+
}
|
|
297
|
+
const suggestions = providerSuggestions(provider, new Set([...registryProviders, ...indexedProviderSet]));
|
|
298
|
+
const suggestionText = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : "";
|
|
299
|
+
return textResult(`Provider "${args.query}" is not registered.${suggestionText} Use request_update to suggest adding it, or open a GitHub issue with the new-provider template.`, true);
|
|
300
|
+
}
|
|
301
|
+
export function getProviderDocs(db, args) {
|
|
302
|
+
const provider = normaliseProvider(args.provider);
|
|
303
|
+
const providerSet = indexedProviders(db);
|
|
304
|
+
if (!providerSet.has(provider)) {
|
|
305
|
+
const registryProviders = new Set(Object.keys(PROVIDER_REGISTRY.providers));
|
|
306
|
+
if (registryProviders.has(provider)) {
|
|
307
|
+
return textResult(`Provider "${providerFilterLabel(args.provider, provider)}" is registered but has no indexed docs yet. Use request_update to ask maintainers to crawl or curate it.`, true);
|
|
308
|
+
}
|
|
309
|
+
return textResult(unknownProviderMessage(args.provider, provider, providerSet), true);
|
|
310
|
+
}
|
|
311
|
+
const limit = normaliseLimit(args.max_results, 10, 50);
|
|
312
|
+
const category = args.category ? slugProvider(args.category) : undefined;
|
|
313
|
+
const strictQuery = args.topic ? sanitiseFtsQuery(args.topic) : undefined;
|
|
314
|
+
const fallbackQuery = args.topic ? relaxedFtsQuery(args.topic) : undefined;
|
|
315
|
+
let rows = providerDocsRows(db, provider, strictQuery, category, limit);
|
|
316
|
+
if (rows.length === 0 && fallbackQuery && fallbackQuery !== strictQuery) {
|
|
317
|
+
rows = providerDocsRows(db, provider, fallbackQuery, category, limit);
|
|
318
|
+
}
|
|
319
|
+
if (rows.length === 0) {
|
|
320
|
+
return textResult(`No provider docs found for "${providerFilterLabel(args.provider, provider)}"${category ? ` in category "${category}"` : ""}${args.topic ? ` matching "${args.topic}"` : ""}. Call resolve_provider_id or list_providers to inspect available coverage.`);
|
|
321
|
+
}
|
|
322
|
+
const metadata = providerMetadata(provider);
|
|
323
|
+
const docs = rows
|
|
324
|
+
.map((row, index) => {
|
|
325
|
+
return `## [${index + 1}] ${row.title}\n**Provider:** ${row.provider} | **Category:** ${row.category} | ${sourceLabel(row)}\n\n${row.content}`;
|
|
326
|
+
})
|
|
327
|
+
.join("\n\n---\n\n");
|
|
328
|
+
return textResult(`Provider docs for **${provider}** (${metadata?.display_name ?? provider})${args.topic ? ` matching "${args.topic}"` : ""}${category ? ` in category "${category}"` : ""}:\n\n${docs}`);
|
|
92
329
|
}
|
|
93
330
|
export function searchDocs(db, args) {
|
|
94
331
|
const limit = normaliseLimit(args.max_results, 10, 50);
|
|
95
332
|
const strictQuery = sanitiseFtsQuery(args.query);
|
|
96
333
|
const fallbackQuery = relaxedFtsQuery(args.query);
|
|
97
|
-
|
|
334
|
+
const provider = args.provider ? normaliseProvider(args.provider) : undefined;
|
|
335
|
+
if (args.provider && provider) {
|
|
336
|
+
const providerSet = indexedProviders(db);
|
|
337
|
+
if (!providerSet.has(provider)) {
|
|
338
|
+
return textResult(unknownProviderMessage(args.provider, provider, providerSet), true);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
let rows = searchRows(db, strictQuery, provider, limit);
|
|
98
342
|
if (rows.length === 0 && fallbackQuery !== strictQuery) {
|
|
99
|
-
rows = searchRows(db, fallbackQuery,
|
|
343
|
+
rows = searchRows(db, fallbackQuery, provider, limit);
|
|
100
344
|
}
|
|
101
345
|
if (rows.length === 0) {
|
|
102
|
-
|
|
346
|
+
const providerLabel = providerFilterLabel(args.provider, provider);
|
|
347
|
+
return textResult(`No results found for "${args.query}"${providerLabel ? ` in ${providerLabel}` : ""}. Try broader football-data terms, remove the provider filter, or call list_providers to inspect coverage.`);
|
|
103
348
|
}
|
|
104
349
|
const results = rows
|
|
105
350
|
.map((row, index) => {
|
|
106
351
|
return `## [${index + 1}] ${row.title}\n**Provider:** ${row.provider} | **Category:** ${row.category} | ${sourceLabel(row)}\n\n${row.content}`;
|
|
107
352
|
})
|
|
108
353
|
.join("\n\n---\n\n");
|
|
109
|
-
|
|
354
|
+
const providerLabel = providerFilterLabel(args.provider, provider);
|
|
355
|
+
return textResult(`Found ${rows.length} result(s) for "${args.query}"${providerLabel ? ` in ${providerLabel}` : ""}:\n\n${results}`);
|
|
110
356
|
}
|
|
111
357
|
export function listProviders(db) {
|
|
112
358
|
const rows = db
|
|
@@ -124,7 +370,9 @@ export function listProviders(db) {
|
|
|
124
370
|
}
|
|
125
371
|
const lines = [...byProvider.entries()]
|
|
126
372
|
.map(([provider, info]) => {
|
|
127
|
-
|
|
373
|
+
const aliases = DISPLAY_PROVIDER_ALIASES[provider];
|
|
374
|
+
const aliasText = aliases?.length ? ` | aliases: ${aliases.join(", ")}` : "";
|
|
375
|
+
return `**${provider}** (${info.total} chunks): ${info.categories.join(", ")}${aliasText}`;
|
|
128
376
|
})
|
|
129
377
|
.join("\n");
|
|
130
378
|
return textResult(`Indexed providers:\n\n${lines}`);
|
|
@@ -132,11 +380,39 @@ export function listProviders(db) {
|
|
|
132
380
|
export function compareProviders(db, args) {
|
|
133
381
|
const strictQuery = sanitiseFtsQuery(args.topic);
|
|
134
382
|
const fallbackQuery = relaxedFtsQuery(args.topic);
|
|
135
|
-
let rows
|
|
136
|
-
|
|
137
|
-
|
|
383
|
+
let rows;
|
|
384
|
+
let requestedProviders = [];
|
|
385
|
+
const providerSet = indexedProviders(db);
|
|
386
|
+
if (args.providers?.length) {
|
|
387
|
+
requestedProviders = [...new Set(args.providers.map(normaliseProvider))];
|
|
388
|
+
rows = requestedProviders.flatMap((provider) => {
|
|
389
|
+
if (!providerSet.has(provider))
|
|
390
|
+
return [];
|
|
391
|
+
const strictRows = compareRowsForProvider(db, strictQuery, provider, 3);
|
|
392
|
+
if (strictRows.length > 0 || fallbackQuery === strictQuery)
|
|
393
|
+
return strictRows;
|
|
394
|
+
return compareRowsForProvider(db, fallbackQuery, provider, 3);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
rows = compareRows(db, strictQuery, args.providers);
|
|
399
|
+
if (rows.length === 0 && fallbackQuery !== strictQuery) {
|
|
400
|
+
rows = compareRows(db, fallbackQuery, args.providers);
|
|
401
|
+
}
|
|
138
402
|
}
|
|
139
403
|
if (rows.length === 0) {
|
|
404
|
+
if (requestedProviders.length > 0) {
|
|
405
|
+
const missingProviders = requestedProviders.filter((provider) => !providerSet.has(provider));
|
|
406
|
+
const providerNote = missingProviders.length
|
|
407
|
+
? ` Requested provider(s) not indexed: ${missingProviders.join(", ")}.${missingProviders
|
|
408
|
+
.map((provider) => {
|
|
409
|
+
const suggestions = providerSuggestions(provider, providerSet);
|
|
410
|
+
return suggestions.length ? ` ${provider}: did you mean ${suggestions.join(", ")}?` : "";
|
|
411
|
+
})
|
|
412
|
+
.join("")}`
|
|
413
|
+
: "";
|
|
414
|
+
return textResult(`No matching docs found for "${args.topic}" across requested provider(s): ${requestedProviders.join(", ")}.${providerNote} Call list_providers to inspect coverage, or use request_update to suggest missing provider docs.`);
|
|
415
|
+
}
|
|
140
416
|
return textResult(`No documentation found for "${args.topic}". Try different terms.`);
|
|
141
417
|
}
|
|
142
418
|
const grouped = new Map();
|
|
@@ -148,27 +424,39 @@ export function compareProviders(db, args) {
|
|
|
148
424
|
const sections = [...grouped.entries()]
|
|
149
425
|
.map(([provider, chunks]) => `## ${provider}\n\n${chunks.slice(0, 3).join("\n\n")}`)
|
|
150
426
|
.join("\n\n---\n\n");
|
|
151
|
-
|
|
427
|
+
const missingProviders = requestedProviders.filter((provider) => !grouped.has(provider));
|
|
428
|
+
const missingNote = missingProviders.length
|
|
429
|
+
? `\n\nNo matching docs found for requested provider(s): ${missingProviders.join(", ")}.${missingProviders
|
|
430
|
+
.map((provider) => {
|
|
431
|
+
if (providerSet.has(provider))
|
|
432
|
+
return "";
|
|
433
|
+
const suggestions = providerSuggestions(provider, providerSet);
|
|
434
|
+
return suggestions.length ? ` ${provider}: did you mean ${suggestions.join(", ")}?` : "";
|
|
435
|
+
})
|
|
436
|
+
.join("")}`
|
|
437
|
+
: "";
|
|
438
|
+
return textResult(`Comparison for "${args.topic}" across ${grouped.size} provider(s):\n\n${sections}${missingNote}`);
|
|
152
439
|
}
|
|
153
440
|
export function requestUpdate(db, args, options = {}) {
|
|
441
|
+
const requestedAt = (options.now ?? new Date()).toISOString();
|
|
442
|
+
const providerKey = requestProviderKey(args.provider);
|
|
154
443
|
const countRow = db.prepare("SELECT COUNT(*) as cnt FROM requests").get();
|
|
155
444
|
if (countRow.cnt >= 500) {
|
|
156
445
|
return textResult(`Request queue is full (${countRow.cnt} entries). Please file an issue at https://github.com/withqwerty/football-docs/issues instead.`, true);
|
|
157
446
|
}
|
|
158
447
|
const recentRequest = db
|
|
159
448
|
.prepare(`SELECT id, requested_at FROM requests
|
|
160
|
-
WHERE
|
|
161
|
-
AND datetime(requested_at) > datetime(
|
|
449
|
+
WHERE provider = ? AND status = 'pending'
|
|
450
|
+
AND datetime(requested_at) > datetime(?, '-7 days')
|
|
162
451
|
LIMIT 1`)
|
|
163
|
-
.get(
|
|
452
|
+
.get(providerKey, requestedAt);
|
|
164
453
|
if (recentRequest) {
|
|
165
454
|
return textResult(`A pending request for "${args.provider}" already exists (from ${recentRequest.requested_at}). Requests have a 7-day cooldown to prevent duplicate work. Request ID: ${recentRequest.id}`, true);
|
|
166
455
|
}
|
|
167
456
|
const id = options.requestId ??
|
|
168
457
|
`req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
169
|
-
const requestedAt = (options.now ?? new Date()).toISOString();
|
|
170
458
|
db.prepare(`INSERT INTO requests (id, type, provider, reason, suggested_urls, requested_at, status)
|
|
171
|
-
VALUES (?, ?, ?, ?, ?, ?, 'pending')`).run(id, args.type,
|
|
459
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending')`).run(id, args.type, providerKey, args.reason, args.suggested_urls ? JSON.stringify(args.suggested_urls) : null, requestedAt);
|
|
172
460
|
const typeLabel = {
|
|
173
461
|
new_provider: "New provider request",
|
|
174
462
|
recrawl: "Recrawl request",
|
|
@@ -177,7 +465,7 @@ export function requestUpdate(db, args, options = {}) {
|
|
|
177
465
|
}[args.type];
|
|
178
466
|
return textResult(`${typeLabel} queued for "${args.provider}" (ID: ${id}).\n\nReason: ${args.reason}${args.suggested_urls?.length
|
|
179
467
|
? `\nSuggested URLs:\n${args.suggested_urls.map((url) => ` - ${url}`).join("\n")}`
|
|
180
|
-
: ""}\n\nThis request will be reviewed by maintainers.
|
|
468
|
+
: ""}\n\nThis local request will be reviewed by maintainers. For community visibility, also open the matching GitHub issue: ${requestIssueUrl(args)}`);
|
|
181
469
|
}
|
|
182
470
|
export async function resolveEntity(args, options = {}) {
|
|
183
471
|
const baseUrl = options.baseUrl ?? "https://reep-api.rahulkeerthi2-95d.workers.dev";
|
|
@@ -200,6 +488,9 @@ export async function resolveEntity(args, options = {}) {
|
|
|
200
488
|
try {
|
|
201
489
|
const response = await fetchImpl(url);
|
|
202
490
|
if (!response.ok) {
|
|
491
|
+
if (response.status === 401 || response.status === 403) {
|
|
492
|
+
return textResult(`Reep API authentication failed (${response.status} ${response.statusText}). Entity resolution is optional and needs a Reep API credential/configured endpoint before provider ID lookup can work. Docs search tools still work without Reep access.`, true);
|
|
493
|
+
}
|
|
203
494
|
return textResult(`Reep API error: ${response.status} ${response.statusText}`, true);
|
|
204
495
|
}
|
|
205
496
|
const data = (await response.json());
|