endfield-mcp
Version:
MCP Server for Arknights: Endfield (TypeScript / stdio + Streamable HTTP)
301 lines • 11.6 kB
JavaScript
/**
* Endfield Talos Wiki (endfield.wiki.gg) API client.
*
* Ported from PRTS-MCP's `ts/src/api/prtsWiki.ts`. Two substantive
* differences from the PRTS client:
*
* 1. WAF handling — wiki.gg blocks bare/curl-like User-Agents. Every
* request carries a browser UA + Referer + Accept header. These are
* pulled from the loaded Config so they can be overridden per-deployment
* (EF_WIKI_UA / EF_WIKI_REFERER / EF_WIKI_ENDPOINT).
*
* 2. Content language — endfield.wiki.gg is primarily English. Snippets
* and page text come back in English by default; the calling LLM is
* responsible for bridging into the user's language. Tool descriptions
* stay Chinese (the project's user-facing language), but the strings
* returned by this client are whatever language the wiki page is in.
*
* All MediaWiki action=parse / action=query shapes are identical to PRTS,
* so the response parsing ports verbatim.
*/
import { RATE_LIMIT_INTERVAL, } from "../config.js";
import { stripWikitext } from "../utils/sanitizer.js";
import { parseParsetreeXml } from "./parsetreeParser.js";
// ---------------------------------------------------------------------------
// Module-local config binding
// ---------------------------------------------------------------------------
/**
* The wiki client is bound to a Config snapshot at first use rather than
* re-reading process.env on every call. This keeps request headers stable
* for the process lifetime and makes the client trivially mockable in tests
* (swap `bindWikiConfig` with a stub before importing the test subject).
*/
let bound = null;
export function bindWikiConfig(cfg) {
bound = cfg;
}
function cfg() {
if (bound === null) {
throw new Error("Wiki client used before bindWikiConfig() — call it once at startup.");
}
return bound;
}
// ---------------------------------------------------------------------------
// Rate limiter
// ---------------------------------------------------------------------------
// Tracks the earliest time the next request is allowed to fire.
// Updated immediately (before any await) so concurrent callers each
// reserve a distinct slot — avoiding the check-then-act race.
let nextAllowedTime = 0;
async function rateLimit() {
const now = Date.now();
const intervalMs = RATE_LIMIT_INTERVAL * 1000;
const slot = Math.max(now, nextAllowedTime);
nextAllowedTime = slot + intervalMs;
const waitMs = slot - now;
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
// ---------------------------------------------------------------------------
// Request helper (WAF headers live here)
// ---------------------------------------------------------------------------
function defaultHeaders() {
const c = cfg();
return {
"User-Agent": c.wikiUserAgent,
"Referer": c.wikiReferer,
"Accept": "application/json",
};
}
async function wikiGet(params) {
await rateLimit();
const c = cfg();
const url = new URL(c.wikiEndpoint);
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, String(v));
}
const res = await fetch(url.toString(), {
headers: defaultHeaders(),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok)
throw new Error(`Endfield wiki API error: HTTP ${res.status}`);
return res.json();
}
// ---------------------------------------------------------------------------
// Text cleanup helpers (ported verbatim from prtsWiki.ts)
// ---------------------------------------------------------------------------
const CSS_JS_RE = /@(font-face|keyframes|media|import|charset|namespace|supports|page)[^{]*\{[^}]*\}|\(window\.RLQ\s*\|\|\s*\[\]\)\.push\([^)]*\)|<style[^>]*>.*?<\/style>|<script[^>]*>.*?<\/script>/gis;
const HTML_TAG_RE = /<[^>]+>/g;
const NAMED_ENTITIES = {
quot: '"', amp: "&", lt: "<", gt: ">", apos: "'", nbsp: " ",
mdash: "—", ndash: "–", hellip: "…",
lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”",
copy: "©", reg: "®", trade: "™",
times: "×", divide: "÷", plusmn: "±",
bull: "•", middot: "·", shy: "",
};
function unescapeHTMLEntities(text) {
return text
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&([a-zA-Z]+);/g, (_, name) => NAMED_ENTITIES[name] ?? `&${name};`);
}
function cleanSnippet(snippet) {
// Remove JSON key-value fragments from technical data pages
snippet = snippet.replace(/\s*"[^"]*"\s*:\s*"[^"]*"\s*,?\s*/g, " ");
// Remove isolated pipe-value artifacts with non-ASCII keys
snippet = snippet.replace(/\|[\u4e00-\u9fff\w]+\s*=[^\n]*/g, "");
snippet = snippet.replace(/#REDIRECT|#重定向/g, "");
// Collapse whitespace
snippet = snippet.replace(/[ \t]+/g, " ");
snippet = snippet.replace(/,{2,}/g, "");
snippet = snippet.replace(/\n{2,}/g, "\n");
return snippet.replace(/^[ ,\n]+|[ ,\n]+$/g, "");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* endfield.wiki.gg doesn't host the same /spine, /data technical subpages
* that PRTS does, but MediaWiki namespace prefixes (Template:, Widget:,
* Module:, File:, Category: as standalone pages) are still worth filtering
* from search results — they're editing infrastructure, not lore.
*/
const TECHNICAL_PAGE_PATTERNS = [
"Widget:",
"Template:",
"Module:",
"File:",
"MediaWiki:",
];
function isTechnicalPage(title) {
return TECHNICAL_PAGE_PATTERNS.some((p) => title.startsWith(p));
}
function stripHtml(text) {
let out = text.replace(CSS_JS_RE, "");
out = out.replace(HTML_TAG_RE, "");
out = unescapeHTMLEntities(out);
out = out.replace(/[ \t]+/g, " ");
out = out.replace(/\n{3,}/g, "\n\n");
return out.trim();
}
/** Search endfield.wiki.gg. */
export async function searchWiki(query, limit = 5, searchMode = "text", filterTechnical = true) {
const fetchLimit = filterTechnical ? limit * 2 : limit;
const params = {
action: "query",
list: "search",
srsearch: query,
srlimit: fetchLimit,
srnamespace: 0,
srinfo: "totalhits",
format: "json",
};
if (searchMode === "title") {
params.srwhat = "title";
}
const data = (await wikiGet(params));
// Handle API-level errors. endfield.wiki.gg disables title search
// (`wgDisableSearchTitle` — returns `search-title-disabled`). Other
// errors (rate limit, etc.) surface here too. We throw a Chinese
// message so the tool layer can catch and return it as text content.
if (data.error) {
if (data.error.code === "search-title-disabled") {
throw new Error("endfield.wiki.gg 禁用了标题搜索(srwhat=title)。请改用默认的全文搜索模式(search_mode=\"text\" 或不传该参数)。");
}
throw new Error(`Wiki 搜索 API 返回错误:${data.error.info ?? data.error.code ?? "未知错误"}`);
}
const totalHits = data.query?.searchinfo?.totalhits ?? 0;
const results = [];
for (const item of data.query?.search ?? []) {
if (filterTechnical && isTechnicalPage(item.title))
continue;
if (results.length >= limit)
break;
let snippet = stripWikitext(item.snippet ?? "");
snippet = unescapeHTMLEntities(snippet);
snippet = cleanSnippet(snippet);
results.push({ title: item.title, snippet });
}
return { totalHits, results };
}
/** Fetch rendered plain-text content for a wiki page. */
export async function readPage(title, sectionIndex) {
const params = {
action: "parse",
page: title,
prop: "text",
format: "json",
};
if (sectionIndex !== undefined) {
params.section = sectionIndex;
}
const data = (await wikiGet(params));
if (data.error?.info) {
return `Page '${title}' not found or empty.`;
}
const htmlText = data.parse?.text?.["*"] ?? "";
if (!htmlText) {
return `Page '${title}' not found or empty.`;
}
return stripHtml(htmlText);
}
/** List section table of contents for a page. */
export async function listSections(title) {
const data = (await wikiGet({
action: "parse",
page: title,
prop: "sections",
format: "json",
}));
if (data.error?.info) {
throw new Error(`Page '${title}' not found.`);
}
return (data.parse?.sections ?? []).map((s) => ({
index: s.index ?? "",
level: s.level ?? "",
line: s.line ?? "",
fromTitle: s.fromtitle ?? "",
}));
}
// ---------------------------------------------------------------------------
// Public API (continued)
// ---------------------------------------------------------------------------
//
// Template extraction (`getTemplateData` below) delegates parsetree XML
// parsing to `./parsetreeParser.ts` — see that module for the rationale
// (file-size guard + independently testable pure function).
/** Get category tags for a page. */
export async function getCategories(title) {
const data = (await wikiGet({
action: "parse",
page: title,
prop: "categories",
format: "json",
}));
if (data.error?.info) {
throw new Error(`Page '${title}' not found.`);
}
return (data.parse?.categories ?? []).map((c) => c["*"]);
}
/** Get outbound links (parse prop=links) or inbound backlinks (list=backlinks). */
export async function getLinks(title, direction = "outbound", limit = 30) {
if (direction === "outbound") {
const data = (await wikiGet({
action: "parse",
page: title,
prop: "links",
format: "json",
}));
if (data.error?.info) {
throw new Error(`Page '${title}' not found.`);
}
const allLinks = (data.parse?.links ?? []).map((l) => l["*"]);
return {
title,
links: allLinks.slice(0, limit),
total: allLinks.length,
hasMore: allLinks.length > limit,
};
}
if (direction !== "inbound") {
throw new Error(`Invalid direction: ${JSON.stringify(direction)}. Use "outbound" or "inbound".`);
}
// inbound: use list=backlinks
const data = (await wikiGet({
action: "query",
list: "backlinks",
bltitle: title,
bllimit: Math.min(limit, 500),
blnamespace: 0,
format: "json",
}));
const backlinks = data.query?.backlinks ?? [];
const links = backlinks.map((bl) => bl.title);
return {
title,
links: links.slice(0, limit),
total: links.length,
hasMore: "continue" in data,
};
}
/** Extract structured template data (parsetree) from a page. */
export async function getTemplateData(title) {
const data = (await wikiGet({
action: "parse",
page: title,
prop: "parsetree",
format: "json",
}));
if (data.error?.info) {
throw new Error(`Page '${title}' not found.`);
}
const xml = data.parse?.parsetree?.["*"] ?? "";
if (!xml) {
throw new Error(`Page '${title}' has no parsetree data.`);
}
return parseParsetreeXml(xml);
}
//# sourceMappingURL=endfieldWiki.js.map