mcp-unknowncheatz 0.3.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/LICENSE +21 -0
- package/README.md +70 -0
- package/package.json +53 -0
- package/src/auth.ts +6 -0
- package/src/browser.ts +182 -0
- package/src/crawl.ts +62 -0
- package/src/forum-catalog.ts +36 -0
- package/src/index.ts +54 -0
- package/src/offset-discovery.ts +60 -0
- package/src/parsers/code-blocks.ts +59 -0
- package/src/parsers/reputation.ts +130 -0
- package/src/parsers/subforums.ts +45 -0
- package/src/parsers/thread-list.ts +155 -0
- package/src/parsers/thread.ts +84 -0
- package/src/search-fallback.ts +81 -0
- package/src/tools/bulk-get-threads.ts +288 -0
- package/src/tools/cache-control.ts +24 -0
- package/src/tools/check-login.ts +41 -0
- package/src/tools/crawl-subforum.ts +128 -0
- package/src/tools/debug-page.ts +102 -0
- package/src/tools/download-file.ts +406 -0
- package/src/tools/extract-code.ts +108 -0
- package/src/tools/find-latest-offsets.ts +167 -0
- package/src/tools/get-thread.ts +137 -0
- package/src/tools/get-user-reputation.ts +125 -0
- package/src/tools/list-subforums.ts +59 -0
- package/src/tools/login.ts +71 -0
- package/src/tools/search-forum.ts +146 -0
- package/src/types.ts +31 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { fetchHtml } from "../crawl.js";
|
|
4
|
+
import { parseThread } from "../parsers/thread.js";
|
|
5
|
+
import type { ThreadPost } from "../types.js";
|
|
6
|
+
|
|
7
|
+
const MAX_PAGES = 50;
|
|
8
|
+
const MAX_IMAGES = 10; // max images to fetch and embed per call
|
|
9
|
+
|
|
10
|
+
function buildPageUrl(baseUrl: string, page: number): string {
|
|
11
|
+
const url = new URL(baseUrl);
|
|
12
|
+
url.searchParams.set("page", String(page));
|
|
13
|
+
return url.toString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function fetchImageAsBase64(url: string): Promise<{ data: string; mimeType: string } | null> {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(url, {
|
|
19
|
+
headers: { "User-Agent": "Mozilla/5.0" },
|
|
20
|
+
signal: AbortSignal.timeout(8_000),
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) return null;
|
|
23
|
+
|
|
24
|
+
const contentType = res.headers.get("content-type") ?? "image/png";
|
|
25
|
+
const mimeType = contentType.split(";")[0].trim();
|
|
26
|
+
if (!mimeType.startsWith("image/")) return null;
|
|
27
|
+
|
|
28
|
+
const buffer = await res.arrayBuffer();
|
|
29
|
+
const data = Buffer.from(buffer).toString("base64");
|
|
30
|
+
return { data, mimeType };
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerGetThread(server: McpServer): void {
|
|
37
|
+
server.tool(
|
|
38
|
+
"get_thread",
|
|
39
|
+
"Get a forum thread. Use latest_pages for recent posts in long-running threads; the default reads only the linked page.",
|
|
40
|
+
{
|
|
41
|
+
url: z.string().url().describe("Thread URL"),
|
|
42
|
+
fetch_all_pages: z
|
|
43
|
+
.boolean()
|
|
44
|
+
.optional()
|
|
45
|
+
.default(false)
|
|
46
|
+
.describe("If true, fetches all pages of the thread (max 50 pages)"),
|
|
47
|
+
latest_pages: z
|
|
48
|
+
.number()
|
|
49
|
+
.int()
|
|
50
|
+
.min(1)
|
|
51
|
+
.max(5)
|
|
52
|
+
.optional()
|
|
53
|
+
.describe("Read the last 1-5 pages. Takes precedence over fetch_all_pages."),
|
|
54
|
+
include_images: z
|
|
55
|
+
.boolean()
|
|
56
|
+
.optional()
|
|
57
|
+
.default(false)
|
|
58
|
+
.describe("If true, fetches post images and returns them as viewable image content (max 10 images)"),
|
|
59
|
+
},
|
|
60
|
+
async ({ url, fetch_all_pages, latest_pages, include_images }) => {
|
|
61
|
+
try {
|
|
62
|
+
const firstHtml = await fetchHtml(url);
|
|
63
|
+
const pageParam = Number(new URL(url).searchParams.get("page"));
|
|
64
|
+
const requestedPage = Number.isInteger(pageParam) && pageParam > 0 ? pageParam : 1;
|
|
65
|
+
const firstPage = parseThread(firstHtml, url, requestedPage);
|
|
66
|
+
const totalPages = firstPage.totalPages;
|
|
67
|
+
const pagesToFetch = latest_pages
|
|
68
|
+
? Array.from({ length: Math.min(latest_pages, totalPages) }, (_, index) =>
|
|
69
|
+
totalPages - Math.min(latest_pages, totalPages) + index + 1)
|
|
70
|
+
: fetch_all_pages
|
|
71
|
+
? Array.from({ length: Math.min(totalPages, MAX_PAGES) }, (_, index) => index + 1)
|
|
72
|
+
: [Math.min(requestedPage, totalPages)];
|
|
73
|
+
|
|
74
|
+
const allPosts: ThreadPost[] = [];
|
|
75
|
+
for (const pageNum of pagesToFetch) {
|
|
76
|
+
const pageUrl = buildPageUrl(url, pageNum);
|
|
77
|
+
const html = pageNum === requestedPage ? firstHtml : await fetchHtml(pageUrl);
|
|
78
|
+
allPosts.push(...parseThread(html, pageUrl, pageNum).posts);
|
|
79
|
+
console.error(`[get-thread] Fetched page ${pageNum}/${totalPages}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = {
|
|
83
|
+
title: firstPage.title,
|
|
84
|
+
posts: allPosts,
|
|
85
|
+
currentPage: pagesToFetch.at(-1),
|
|
86
|
+
pagesFetched: pagesToFetch,
|
|
87
|
+
totalPages,
|
|
88
|
+
url,
|
|
89
|
+
...(fetch_all_pages && !latest_pages && totalPages > MAX_PAGES
|
|
90
|
+
? { note: `Capped at ${MAX_PAGES} pages (thread has ${totalPages} total)` }
|
|
91
|
+
: {}),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Build content array
|
|
95
|
+
const content: Array<
|
|
96
|
+
| { type: "text"; text: string }
|
|
97
|
+
| { type: "image"; data: string; mimeType: string }
|
|
98
|
+
> = [{ type: "text", text: JSON.stringify(result) }];
|
|
99
|
+
|
|
100
|
+
if (include_images) {
|
|
101
|
+
// Collect all unique image URLs from all posts
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
const imageUrls: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (const post of allPosts) {
|
|
106
|
+
for (const imgUrl of post.images) {
|
|
107
|
+
if (!seen.has(imgUrl)) {
|
|
108
|
+
seen.add(imgUrl);
|
|
109
|
+
imageUrls.push(imgUrl);
|
|
110
|
+
}
|
|
111
|
+
if (imageUrls.length >= MAX_IMAGES) break;
|
|
112
|
+
}
|
|
113
|
+
if (imageUrls.length >= MAX_IMAGES) break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.error(`[get-thread] Fetching ${imageUrls.length} images...`);
|
|
117
|
+
|
|
118
|
+
for (const imgUrl of imageUrls) {
|
|
119
|
+
const img = await fetchImageAsBase64(imgUrl);
|
|
120
|
+
if (img) {
|
|
121
|
+
content.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
122
|
+
console.error(`[get-thread] Fetched image: ${imgUrl}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { content };
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
130
|
+
return {
|
|
131
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
132
|
+
isError: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { load } from "cheerio";
|
|
4
|
+
import { fetchHtml } from "../crawl.js";
|
|
5
|
+
import { validateUrl } from "../browser.js";
|
|
6
|
+
|
|
7
|
+
function extractStat(text: string, pattern: RegExp): string | undefined {
|
|
8
|
+
const match = text.match(pattern);
|
|
9
|
+
return match ? match[1].trim() : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseNumber(text?: string): number | null {
|
|
13
|
+
if (!text) return null;
|
|
14
|
+
const n = parseInt(text.replace(/,/g, ""), 10);
|
|
15
|
+
return Number.isFinite(n) ? n : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerGetUserReputation(server: McpServer): void {
|
|
19
|
+
server.tool(
|
|
20
|
+
"get_user_reputation",
|
|
21
|
+
"Get an UnknownCheats user's reputation, rep power, join date, post count, and tier from their profile page.",
|
|
22
|
+
{
|
|
23
|
+
profile_url: z
|
|
24
|
+
.string()
|
|
25
|
+
.url()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Full user profile URL, e.g. https://www.unknowncheats.me/forum/members/6719713.html"),
|
|
28
|
+
user_id: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.positive()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("Numeric UC user ID (alternative to profile_url)"),
|
|
34
|
+
},
|
|
35
|
+
async ({ profile_url, user_id }) => {
|
|
36
|
+
try {
|
|
37
|
+
const url = profile_url ?? (user_id
|
|
38
|
+
? `https://www.unknowncheats.me/forum/members/${user_id}.html`
|
|
39
|
+
: undefined);
|
|
40
|
+
if (!url) {
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: "text", text: "Error: provide either profile_url or user_id." }],
|
|
43
|
+
isError: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
validateUrl(url);
|
|
47
|
+
|
|
48
|
+
const html = await fetchHtml(url);
|
|
49
|
+
const $ = load(html);
|
|
50
|
+
|
|
51
|
+
const rawTitle = $("title").text().trim();
|
|
52
|
+
const username = $("h1").first().text().trim().split(/\s+/)[0] ||
|
|
53
|
+
rawTitle.replace(/^View Profile:\s*/i, "").split("-")[0].trim();
|
|
54
|
+
|
|
55
|
+
const bodyText = $("body").text();
|
|
56
|
+
|
|
57
|
+
const reputationText = extractStat(bodyText, /Reputation[:\s]+([-\d,]+)/i);
|
|
58
|
+
const repPowerText = extractStat(bodyText, /Rep\s*Power[:\s]+([-\d,]+)/i);
|
|
59
|
+
const postsText = extractStat(bodyText, /(?:Total Posts|Posts)[:\s]+([\d,]+)/i);
|
|
60
|
+
const joinText = extractStat(bodyText, /(?:Join Date|Joined)[:\s]+([\w\s,-]{4,25}?)(?=\s{2,}|\n|$)/i);
|
|
61
|
+
const location = extractStat(bodyText, /Location[:\s]+([^\n]{1,60})/i);
|
|
62
|
+
|
|
63
|
+
const repDot = $("img[src*='reputation_']").first();
|
|
64
|
+
const repDescription = repDot.attr("alt")?.trim();
|
|
65
|
+
const repSrc = repDot.attr("src") ?? "";
|
|
66
|
+
const sign =
|
|
67
|
+
repSrc.includes("_neg") ? "negative" :
|
|
68
|
+
repSrc.includes("_bar") ? "neutral" :
|
|
69
|
+
repSrc.includes("_pos") || repSrc.includes("_highpos") ? "positive" :
|
|
70
|
+
"unknown";
|
|
71
|
+
|
|
72
|
+
const reputation = parseNumber(reputationText);
|
|
73
|
+
const repPower = parseNumber(repPowerText);
|
|
74
|
+
const posts = parseNumber(postsText);
|
|
75
|
+
|
|
76
|
+
let tier: string;
|
|
77
|
+
if (sign === "negative") tier = "negative";
|
|
78
|
+
else if (reputation === null) tier = "unknown";
|
|
79
|
+
else if (reputation >= 5_000) tier = "legend";
|
|
80
|
+
else if (reputation >= 2_000) tier = "excellent";
|
|
81
|
+
else if (reputation >= 1_000) tier = "great";
|
|
82
|
+
else if (reputation >= 500) tier = "good";
|
|
83
|
+
else if (reputation >= 100) tier = "average";
|
|
84
|
+
else if (reputation >= 10) tier = "positive";
|
|
85
|
+
else if (reputation > 0) tier = "novice";
|
|
86
|
+
else tier = "neutral";
|
|
87
|
+
|
|
88
|
+
const trustScore = sign === "negative"
|
|
89
|
+
? 5
|
|
90
|
+
: reputation === null
|
|
91
|
+
? 15
|
|
92
|
+
: reputation <= 0
|
|
93
|
+
? 20
|
|
94
|
+
: Math.max(15, Math.min(100, Math.round(Math.log10(reputation + 1) * 20)));
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
type: "text",
|
|
100
|
+
text: JSON.stringify({
|
|
101
|
+
url,
|
|
102
|
+
username,
|
|
103
|
+
reputation,
|
|
104
|
+
repPower,
|
|
105
|
+
posts,
|
|
106
|
+
joinDate: joinText,
|
|
107
|
+
location,
|
|
108
|
+
tier,
|
|
109
|
+
sign,
|
|
110
|
+
description: repDescription,
|
|
111
|
+
trustScore,
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
120
|
+
isError: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { fetchHtml } from "../crawl.js";
|
|
4
|
+
import { FORUM_INDEX, readForumCatalog, saveForumCatalog } from "../forum-catalog.js";
|
|
5
|
+
|
|
6
|
+
export function registerListSubforums(server: McpServer): void {
|
|
7
|
+
server.tool(
|
|
8
|
+
"list_subforums",
|
|
9
|
+
"List game subforum URLs discovered from the live UnknownCheats index. The local directory expires after 24 hours.",
|
|
10
|
+
{
|
|
11
|
+
query: z.string().optional().describe("Optional keyword to filter subforum slugs/labels/descriptions"),
|
|
12
|
+
limit: z.number().int().min(1).max(500).optional().default(100).describe("Max subforums returned (default 100)"),
|
|
13
|
+
refresh: z.boolean().optional().default(false).describe("Fetch the forum index again instead of using the local directory"),
|
|
14
|
+
},
|
|
15
|
+
async ({ query, limit, refresh }) => {
|
|
16
|
+
try {
|
|
17
|
+
const cached = refresh ? null : await readForumCatalog();
|
|
18
|
+
const catalog = cached ?? await saveForumCatalog(await fetchHtml(FORUM_INDEX, { bypassCache: refresh }));
|
|
19
|
+
const all = catalog.subforums;
|
|
20
|
+
|
|
21
|
+
const filtered = query
|
|
22
|
+
? all.filter((sf) => {
|
|
23
|
+
const haystack = `${sf.slug} ${sf.label} ${sf.description ?? ""}`.toLowerCase();
|
|
24
|
+
return query
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.split(/\s+/)
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.every((term) => haystack.includes(term));
|
|
29
|
+
})
|
|
30
|
+
: all;
|
|
31
|
+
|
|
32
|
+
const capped = filtered.slice(0, limit);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
content: [
|
|
36
|
+
{
|
|
37
|
+
type: "text",
|
|
38
|
+
text: JSON.stringify({
|
|
39
|
+
total: all.length,
|
|
40
|
+
matched: filtered.length,
|
|
41
|
+
returned: capped.length,
|
|
42
|
+
source: catalog.source,
|
|
43
|
+
indexedAt: catalog.indexedAt,
|
|
44
|
+
fromCache: Boolean(cached),
|
|
45
|
+
subforums: capped,
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
54
|
+
isError: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { load } from "cheerio";
|
|
4
|
+
import { navigateWithRetry } from "../browser.js";
|
|
5
|
+
import { clearCache } from "../crawl.js";
|
|
6
|
+
import { isLoggedIn } from "../auth.js";
|
|
7
|
+
|
|
8
|
+
const UC_LOGIN = "https://www.unknowncheats.me/forum/login.php";
|
|
9
|
+
|
|
10
|
+
export function registerLogin(server: McpServer): void {
|
|
11
|
+
server.tool(
|
|
12
|
+
"login",
|
|
13
|
+
"Log into UnknownCheats with username and password",
|
|
14
|
+
{
|
|
15
|
+
username: z.string().describe("UnknownCheats username"),
|
|
16
|
+
password: z.string().describe("UnknownCheats password"),
|
|
17
|
+
},
|
|
18
|
+
async ({ username, password }) => {
|
|
19
|
+
try {
|
|
20
|
+
const { page } = await navigateWithRetry(UC_LOGIN);
|
|
21
|
+
|
|
22
|
+
// Fill login form — vBulletin field names
|
|
23
|
+
await page.type('input[name="vb_login_username"]', username, { delay: 60 });
|
|
24
|
+
await page.type('input[name="vb_login_password"], input[type="password"]', password, { delay: 60 });
|
|
25
|
+
|
|
26
|
+
// Submit
|
|
27
|
+
await Promise.all([
|
|
28
|
+
page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30_000 }),
|
|
29
|
+
page.click('input[type="submit"], button[type="submit"]'),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const html = await page.content();
|
|
33
|
+
const $ = load(html);
|
|
34
|
+
|
|
35
|
+
const loggedIn = isLoggedIn(html);
|
|
36
|
+
|
|
37
|
+
if (!loggedIn) {
|
|
38
|
+
// Check for error message
|
|
39
|
+
const error = $(".error, .panel .error, #navbar_notice").first().text().trim();
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: "text",
|
|
44
|
+
text: JSON.stringify({ success: false, error: error || "Login failed — check credentials" }),
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
isError: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
clearCache();
|
|
52
|
+
|
|
53
|
+
// Extract username from page to confirm
|
|
54
|
+
const welcomeEl = $("#welcomelink, .welcomelink").first();
|
|
55
|
+
const welcomeText = welcomeEl.text().trim();
|
|
56
|
+
const match = welcomeText.match(/Welcome,?\s+(.+)/i);
|
|
57
|
+
const confirmedUsername = match ? match[1].replace(/[!.]+$/, "").trim() : username;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: "text", text: JSON.stringify({ success: true, username: confirmedUsername }) }],
|
|
61
|
+
};
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { load } from "cheerio";
|
|
4
|
+
import { navigateWithRetry } from "../browser.js";
|
|
5
|
+
import { fetchHtml } from "../crawl.js";
|
|
6
|
+
import { isLoggedIn } from "../auth.js";
|
|
7
|
+
import { searchViaSubforums } from "../search-fallback.js";
|
|
8
|
+
import { filterThreads, parseThreadList } from "../parsers/thread-list.js";
|
|
9
|
+
|
|
10
|
+
const UC_HOME = "https://www.unknowncheats.me/forum/";
|
|
11
|
+
const UC_SEARCH = "https://www.unknowncheats.me/forum/search.php";
|
|
12
|
+
|
|
13
|
+
async function runFallbackSearch(query: string) {
|
|
14
|
+
console.error(`[search] Not logged in — scanning UC subforums for "${query}"`);
|
|
15
|
+
const { results, scannedSubforums } = await searchViaSubforums(query, (url) => fetchHtml(url));
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
count: results.length,
|
|
19
|
+
source: "subforum_scan" as const,
|
|
20
|
+
scannedSubforums,
|
|
21
|
+
requiresLoginForNativeSearch: true,
|
|
22
|
+
hint: results.length === 0
|
|
23
|
+
? "No matches in scanned subforums. Pass subforum (e.g. apex-legends) or use the login tool for full UC search."
|
|
24
|
+
: "Use the login tool for full UC advanced search (filters, sort, author).",
|
|
25
|
+
results,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerSearchForum(server: McpServer): void {
|
|
30
|
+
server.tool(
|
|
31
|
+
"search_forum",
|
|
32
|
+
"Search the UnknownCheats forum. Uses advanced search when logged in, scans relevant subforums as a guest, or browses a named subforum.",
|
|
33
|
+
{
|
|
34
|
+
query: z.string().optional().default("").describe("Search query string; optional when browsing a subforum"),
|
|
35
|
+
subforum: z.string().optional().describe("Subforum slug to browse directly (e.g. 'apex-legends')"),
|
|
36
|
+
title_only: z.boolean().optional().default(true).describe("Search only in thread titles (default true, more accurate)"),
|
|
37
|
+
sort_by: z.enum(["relevancy", "lastpost", "replycount", "views", "threadstart"]).optional().default("relevancy").describe("Sort results by"),
|
|
38
|
+
search_user: z.string().optional().describe("Filter by thread author username"),
|
|
39
|
+
},
|
|
40
|
+
async ({ query, subforum, title_only, sort_by, search_user }) => {
|
|
41
|
+
try {
|
|
42
|
+
if (!subforum && !query.trim()) {
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: "Provide query or subforum" }],
|
|
45
|
+
isError: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (subforum) {
|
|
50
|
+
const url = `https://www.unknowncheats.me/forum/${subforum}/`;
|
|
51
|
+
const html = await fetchHtml(url);
|
|
52
|
+
const results = filterThreads(parseThreadList(html), { query, includeSticky: true });
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: JSON.stringify({ count: results.length, subforum, results }) }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const homeHtml = await fetchHtml(UC_HOME);
|
|
59
|
+
if (!isLoggedIn(homeHtml)) {
|
|
60
|
+
const payload = await runFallbackSearch(query);
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { page } = await navigateWithRetry(UC_SEARCH);
|
|
67
|
+
|
|
68
|
+
const submitted = await page.evaluate((opts) => {
|
|
69
|
+
const searchForm = document.getElementById("searchform") as HTMLFormElement | null;
|
|
70
|
+
if (!searchForm) return { ok: false, error: "Advanced search form (#searchform) not found" };
|
|
71
|
+
|
|
72
|
+
const queryInput = searchForm.querySelector('input[name="query"][size="35"]') as HTMLInputElement
|
|
73
|
+
?? searchForm.querySelector('input[name="query"]') as HTMLInputElement;
|
|
74
|
+
if (!queryInput) return { ok: false, error: "Query input not found in form" };
|
|
75
|
+
queryInput.value = opts.query;
|
|
76
|
+
|
|
77
|
+
const titleOnlySelect = searchForm.querySelector('select[name="titleonly"]') as HTMLSelectElement;
|
|
78
|
+
if (titleOnlySelect) {
|
|
79
|
+
titleOnlySelect.value = opts.titleOnly ? "1" : "0";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const showThreads = searchForm.querySelector('input[name="showposts"][value="0"]') as HTMLInputElement;
|
|
83
|
+
if (showThreads) showThreads.checked = true;
|
|
84
|
+
|
|
85
|
+
const sortSelect = searchForm.querySelector('select[name="sortby"]') as HTMLSelectElement;
|
|
86
|
+
if (sortSelect) sortSelect.value = opts.sortBy;
|
|
87
|
+
|
|
88
|
+
if (opts.searchUser) {
|
|
89
|
+
const userInput = searchForm.querySelector('input[name="searchuser"]') as HTMLInputElement;
|
|
90
|
+
if (userInput) userInput.value = opts.searchUser;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { ok: true };
|
|
94
|
+
}, { query, titleOnly: title_only, sortBy: sort_by, searchUser: search_user ?? "" });
|
|
95
|
+
|
|
96
|
+
if (!submitted.ok) {
|
|
97
|
+
const payload = await runFallbackSearch(query);
|
|
98
|
+
return {
|
|
99
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await Promise.all([
|
|
104
|
+
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30_000 }),
|
|
105
|
+
page.evaluate(() => (document.getElementById("searchform") as HTMLFormElement).submit()),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const html = await page.content();
|
|
109
|
+
const results = parseThreadList(html);
|
|
110
|
+
const $ = load(html);
|
|
111
|
+
const pageTitle = $("title").text().trim();
|
|
112
|
+
|
|
113
|
+
const errorText = $(".standard_error, .errorwrap, .blockbody .error").first().text().trim();
|
|
114
|
+
if (errorText) {
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: JSON.stringify({ count: 0, error: errorText, pageTitle }) }],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const pageNav = $(".pagenav td.vbmenu_control").first().text().trim();
|
|
121
|
+
const pageMatch = pageNav.match(/Page (\d+) of (\d+)/);
|
|
122
|
+
const pagination = pageMatch ? { currentPage: parseInt(pageMatch[1]), totalPages: parseInt(pageMatch[2]) } : undefined;
|
|
123
|
+
|
|
124
|
+
console.error(`[search] "${query}" → ${results.length} results, page: ${pageTitle}`);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: "text", text: JSON.stringify({ count: results.length, source: "unknowncheats", pageTitle, pagination, results }) }],
|
|
128
|
+
};
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
131
|
+
try {
|
|
132
|
+
const payload = await runFallbackSearch(query);
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: "text", text: JSON.stringify({ ...payload, nativeSearchError: message }) }],
|
|
135
|
+
};
|
|
136
|
+
} catch (fallbackErr) {
|
|
137
|
+
const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
138
|
+
return {
|
|
139
|
+
content: [{ type: "text", text: `Error: ${message} (fallback also failed: ${fallbackMessage})` }],
|
|
140
|
+
isError: true,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface PostLink {
|
|
2
|
+
text: string;
|
|
3
|
+
url: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
import type { AuthorReputation } from "./parsers/reputation.js";
|
|
7
|
+
|
|
8
|
+
export interface ThreadPost {
|
|
9
|
+
author: string;
|
|
10
|
+
date: string;
|
|
11
|
+
content: string;
|
|
12
|
+
postNumber: number;
|
|
13
|
+
links: PostLink[];
|
|
14
|
+
images: string[];
|
|
15
|
+
reputation?: AuthorReputation;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ThreadData {
|
|
19
|
+
title: string;
|
|
20
|
+
posts: ThreadPost[];
|
|
21
|
+
currentPage: number;
|
|
22
|
+
totalPages: number;
|
|
23
|
+
url: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CodeBlock {
|
|
27
|
+
code: string;
|
|
28
|
+
language: string;
|
|
29
|
+
context?: string;
|
|
30
|
+
postId?: string;
|
|
31
|
+
}
|