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.
@@ -0,0 +1,406 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { getPage, navigateWithRetry, validateUrl } from "../browser.js";
4
+ import path from "path";
5
+ import { mkdir, mkdtemp, readdir, readFile, stat } from "node:fs/promises";
6
+ import { fileURLToPath } from "url";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const DOWNLOADS_DIR = path.join(__dirname, "..", "..", "downloads");
10
+ const MAX_WAIT_MS = 120_000;
11
+ const POLL_MS = 1_000;
12
+ const MAX_FILE_PREVIEW = 50_000; // chars per file for analysis
13
+ const MAX_TOTAL_CONTENT = 300_000; // total chars budget for all analyzed files
14
+ const MAX_FILES_ANALYZE = 50;
15
+
16
+ // Known third-party / vendored directories to skip during analysis
17
+ const SKIP_DIRS = new Set([
18
+ "imgui", "imgui-master", "dear-imgui",
19
+ "stb", "glad", "glfw", "glew", "sdl",
20
+ "json", "nlohmann", "rapidjson",
21
+ "boost", "eigen", "glm",
22
+ "node_modules", ".git", "__pycache__",
23
+ "packages", "vendor", "third_party", "thirdparty", "3rdparty", "external", "deps", "lib",
24
+ ]);
25
+
26
+ interface FileEntry {
27
+ path: string;
28
+ size: number;
29
+ extension: string;
30
+ }
31
+
32
+ interface AnalyzedFile {
33
+ path: string;
34
+ size: number;
35
+ extension: string;
36
+ content?: string;
37
+ binary?: boolean;
38
+ }
39
+
40
+ const TEXT_EXTENSIONS = new Set([
41
+ ".txt", ".md", ".json", ".xml", ".yml", ".yaml", ".toml", ".ini", ".cfg", ".conf",
42
+ ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx",
43
+ ".cs", ".java", ".kt", ".scala",
44
+ ".py", ".rb", ".lua", ".pl", ".php",
45
+ ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs",
46
+ ".html", ".htm", ".css", ".scss", ".less",
47
+ ".rs", ".go", ".swift", ".m", ".mm",
48
+ ".sh", ".bash", ".bat", ".cmd", ".ps1",
49
+ ".asm", ".s", ".inc",
50
+ ".sln", ".csproj", ".vcxproj", ".props", ".targets",
51
+ ".cmake", ".makefile", ".mk",
52
+ ".gitignore", ".editorconfig", ".env.example",
53
+ ".log", ".csv", ".sql",
54
+ ]);
55
+
56
+ function isTextFile(filePath: string): boolean {
57
+ const ext = path.extname(filePath).toLowerCase();
58
+ const base = path.basename(filePath).toLowerCase();
59
+ if (TEXT_EXTENSIONS.has(ext)) return true;
60
+ if (["makefile", "cmakelists.txt", "dockerfile", "readme", "license", "changelog"].includes(base)) return true;
61
+ return false;
62
+ }
63
+
64
+ function isThirdParty(filePath: string): boolean {
65
+ const parts = filePath.toLowerCase().split(/[\\/]/);
66
+ return parts.some(p => SKIP_DIRS.has(p));
67
+ }
68
+
69
+ function formatSize(bytes: number): string {
70
+ if (bytes < 1024) return `${bytes} B`;
71
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
72
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
73
+ }
74
+
75
+ async function listFilesRecursive(dir: string, base: string = ""): Promise<FileEntry[]> {
76
+ const entries: FileEntry[] = [];
77
+ const items = await readdir(dir, { withFileTypes: true });
78
+ for (const item of items) {
79
+ const rel = base ? `${base}/${item.name}` : item.name;
80
+ const full = path.join(dir, item.name);
81
+ if (item.isDirectory()) {
82
+ entries.push(...await listFilesRecursive(full, rel));
83
+ } else {
84
+ const s = await stat(full);
85
+ entries.push({ path: rel, size: s.size, extension: path.extname(item.name).toLowerCase() });
86
+ }
87
+ }
88
+ return entries;
89
+ }
90
+
91
+ async function extractZip(zipPath: string, destDir: string): Promise<void> {
92
+ // Use PowerShell Expand-Archive (safe: args are passed as separate parameters, not interpolated)
93
+ const ps = Bun.spawn(
94
+ ["powershell", "-NoProfile", "-Command",
95
+ "Expand-Archive", "-Path", zipPath, "-DestinationPath", destDir, "-Force"],
96
+ { stdout: "pipe", stderr: "pipe" }
97
+ );
98
+ const psExit = await ps.exited;
99
+ if (psExit !== 0) {
100
+ // Fallback: try tar
101
+ const proc = Bun.spawn(["tar", "-xf", zipPath, "-C", destDir], {
102
+ stdout: "pipe",
103
+ stderr: "pipe",
104
+ });
105
+ const exitCode = await proc.exited;
106
+ if (exitCode !== 0) {
107
+ throw new Error("Failed to extract zip");
108
+ }
109
+ }
110
+ }
111
+
112
+ async function extractRar(filePath: string, destDir: string): Promise<void> {
113
+ // Try unrar or 7z
114
+ let proc = Bun.spawn(["unrar", "x", "-o+", filePath, destDir], {
115
+ stdout: "pipe", stderr: "pipe",
116
+ });
117
+ let exitCode = await proc.exited;
118
+ if (exitCode !== 0) {
119
+ proc = Bun.spawn(["7z", "x", `-o${destDir}`, "-y", filePath], {
120
+ stdout: "pipe", stderr: "pipe",
121
+ });
122
+ exitCode = await proc.exited;
123
+ if (exitCode !== 0) {
124
+ throw new Error("Failed to extract .rar — install unrar or 7-Zip and ensure it's in PATH");
125
+ }
126
+ }
127
+ }
128
+
129
+ async function extract7z(filePath: string, destDir: string): Promise<void> {
130
+ const proc = Bun.spawn(["7z", "x", `-o${destDir}`, "-y", filePath], {
131
+ stdout: "pipe", stderr: "pipe",
132
+ });
133
+ const exitCode = await proc.exited;
134
+ if (exitCode !== 0) {
135
+ throw new Error("Failed to extract .7z — install 7-Zip and ensure it's in PATH");
136
+ }
137
+ }
138
+
139
+ export function registerDownloadFile(server: McpServer): void {
140
+ server.tool(
141
+ "download_file",
142
+ "Download a file attachment from UnknownCheats, extract archives (zip/rar/7z), and analyze contents. Returns file tree and text file previews.",
143
+ {
144
+ url: z.string().url().describe("Direct download URL or UC attachment page URL"),
145
+ analyze: z
146
+ .boolean()
147
+ .optional()
148
+ .default(true)
149
+ .describe("If true, reads and returns text file contents for analysis (default true)"),
150
+ },
151
+ async ({ url, analyze }) => {
152
+ try {
153
+ validateUrl(url);
154
+ await mkdir(DOWNLOADS_DIR, { recursive: true });
155
+ const runDir = await mkdtemp(path.join(DOWNLOADS_DIR, "run-"));
156
+
157
+ let page = await getPage();
158
+
159
+ // Set download behavior to our downloads folder
160
+ const client = await page.createCDPSession();
161
+ await client.send("Browser.setDownloadBehavior", {
162
+ behavior: "allow",
163
+ downloadPath: runDir,
164
+ eventsEnabled: true,
165
+ });
166
+
167
+ console.error(`[download] Navigating to: ${url}`);
168
+
169
+ // Detect URL type
170
+ const isDirectAttachment = /attachment\.php|attachmentid=/.test(url);
171
+ const isDownloadsPage = /downloads\.php\?do=file/.test(url);
172
+
173
+ if (isDirectAttachment) {
174
+ // Direct attachment — navigate triggers download
175
+ await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 }).catch(() => {
176
+ // Navigation may "fail" because it's a download, not a page
177
+ });
178
+ } else if (isDownloadsPage) {
179
+ // UC downloads page — navigate, then find the "act=down&actionhash=" link
180
+ const navResult = await navigateWithRetry(url);
181
+ page = navResult.page;
182
+
183
+ // UC download links have pattern: downloads.php?do=file&id=XXXX&act=down&actionhash=YYYY
184
+ const downloadLink = await page.evaluate(() => {
185
+ const selectors = [
186
+ 'a[href*="act=down"]',
187
+ 'a[href*="act=down&actionhash"]',
188
+ 'a[title*="Download"]',
189
+ 'a[href*="attachment.php"]',
190
+ ];
191
+ for (const sel of selectors) {
192
+ const el = document.querySelector(sel) as HTMLAnchorElement | null;
193
+ if (el?.href) return el.href;
194
+ }
195
+ return null;
196
+ });
197
+
198
+ if (downloadLink) {
199
+ console.error(`[download] Found UC download link: ${downloadLink}`);
200
+ // Navigate to the download link — this triggers the actual file download
201
+ await page.goto(downloadLink, { waitUntil: "networkidle2", timeout: 30_000 }).catch(() => {
202
+ // Expected — download navigation doesn't resolve to a page
203
+ });
204
+ } else {
205
+ // Debug: show what links are on the page
206
+ const pageLinks = await page.evaluate(() => {
207
+ return Array.from(document.querySelectorAll("a[href]"))
208
+ .filter(a => (a as HTMLAnchorElement).href.includes("download") || (a as HTMLAnchorElement).href.includes("act="))
209
+ .slice(0, 15)
210
+ .map(a => ({
211
+ text: (a as HTMLAnchorElement).textContent?.trim().slice(0, 80),
212
+ href: (a as HTMLAnchorElement).href,
213
+ }));
214
+ });
215
+ return {
216
+ content: [{ type: "text", text: JSON.stringify({ error: "No download link found on UC downloads page", page_links_sample: pageLinks }, null, 2) }],
217
+ isError: true,
218
+ };
219
+ }
220
+ } else {
221
+ // Generic page — navigate and look for attachment/download links
222
+ const navResult = await navigateWithRetry(url);
223
+ page = navResult.page;
224
+
225
+ const attachmentUrl = await page.evaluate(() => {
226
+ const link = document.querySelector('a[href*="attachment.php"]') as HTMLAnchorElement | null;
227
+ return link?.href ?? null;
228
+ });
229
+
230
+ if (attachmentUrl) {
231
+ console.error(`[download] Found attachment link: ${attachmentUrl}`);
232
+ await page.goto(attachmentUrl, { waitUntil: "networkidle2", timeout: 30_000 }).catch(() => {});
233
+ } else {
234
+ const clicked = await page.evaluate(() => {
235
+ const btn = document.querySelector('a[href*="do=get"], a.download, a[download]') as HTMLAnchorElement | null;
236
+ if (btn) { btn.click(); return true; }
237
+ return false;
238
+ });
239
+
240
+ if (!clicked) {
241
+ return {
242
+ content: [{ type: "text", text: "Error: No downloadable file found on this page. Provide a direct attachment URL (containing attachment.php or attachmentid=)." }],
243
+ isError: true,
244
+ };
245
+ }
246
+ }
247
+ }
248
+
249
+ // Wait for download to complete
250
+ console.error("[download] Waiting for download to complete...");
251
+ let downloadedFile: string | null = null;
252
+ const start = Date.now();
253
+
254
+ while (Date.now() - start < MAX_WAIT_MS) {
255
+ await new Promise(r => setTimeout(r, POLL_MS));
256
+ const files = await readdir(runDir);
257
+ // Filter out .crdownload / .part / .tmp partial files
258
+ const completed = files.filter(f =>
259
+ !f.endsWith(".crdownload") &&
260
+ !f.endsWith(".part") &&
261
+ !f.endsWith(".tmp") &&
262
+ !f.startsWith(".")
263
+ );
264
+ if (completed.length > 0) {
265
+ downloadedFile = completed[0];
266
+ // Wait a bit more to ensure write is complete
267
+ await new Promise(r => setTimeout(r, 1_500));
268
+ break;
269
+ }
270
+ }
271
+
272
+ await client.detach();
273
+
274
+ if (!downloadedFile) {
275
+ return {
276
+ content: [{ type: "text", text: "Error: Download timed out or no file was received." }],
277
+ isError: true,
278
+ };
279
+ }
280
+
281
+ const downloadedPath = path.join(runDir, downloadedFile);
282
+ const fileStat = await stat(downloadedPath);
283
+ const ext = path.extname(downloadedFile).toLowerCase();
284
+
285
+ console.error(`[download] Downloaded: ${downloadedFile} (${formatSize(fileStat.size)})`);
286
+
287
+ const result: Record<string, unknown> = {
288
+ file: downloadedFile,
289
+ size: formatSize(fileStat.size),
290
+ type: ext || "unknown",
291
+ };
292
+
293
+ // Extract if archive
294
+ const isArchive = [".zip", ".rar", ".7z"].includes(ext);
295
+ let extractedDir: string | null = null;
296
+
297
+ if (isArchive) {
298
+ extractedDir = path.join(runDir, "extracted");
299
+ await mkdir(extractedDir, { recursive: true });
300
+
301
+ console.error(`[download] Extracting ${ext} archive...`);
302
+
303
+ if (ext === ".zip") {
304
+ await extractZip(downloadedPath, extractedDir);
305
+ } else if (ext === ".rar") {
306
+ await extractRar(downloadedPath, extractedDir);
307
+ } else if (ext === ".7z") {
308
+ await extract7z(downloadedPath, extractedDir);
309
+ }
310
+
311
+ const fileList = await listFilesRecursive(extractedDir);
312
+ result.extracted = true;
313
+ result.file_count = fileList.length;
314
+ result.total_size = formatSize(fileList.reduce((sum, f) => sum + f.size, 0));
315
+
316
+ // Build file tree
317
+ const tree = fileList.map(f => `${f.path} (${formatSize(f.size)})`);
318
+ result.file_tree = tree;
319
+
320
+ // Extension stats
321
+ const extCounts: Record<string, number> = {};
322
+ for (const f of fileList) {
323
+ const e = f.extension || "(none)";
324
+ extCounts[e] = (extCounts[e] ?? 0) + 1;
325
+ }
326
+ result.extension_stats = extCounts;
327
+
328
+ // Analyze text files (skip third-party/vendored code)
329
+ if (analyze) {
330
+ const analyzed: AnalyzedFile[] = [];
331
+ const allTextFiles = fileList.filter(f => isTextFile(f.path));
332
+ const projectFiles = allTextFiles.filter(f => !isThirdParty(f.path));
333
+ const skippedFiles = allTextFiles.filter(f => isThirdParty(f.path));
334
+ const textFiles = projectFiles.slice(0, MAX_FILES_ANALYZE);
335
+
336
+ let totalContent = 0;
337
+ for (const f of textFiles) {
338
+ if (totalContent >= MAX_TOTAL_CONTENT) {
339
+ analyzed.push({
340
+ path: f.path,
341
+ size: f.size,
342
+ extension: f.extension,
343
+ content: `[skipped — content budget exhausted (${formatSize(MAX_TOTAL_CONTENT)} total)]`,
344
+ });
345
+ continue;
346
+ }
347
+ const fullPath = path.join(extractedDir, f.path);
348
+ try {
349
+ const raw = await readFile(fullPath, "utf-8");
350
+ const preview = raw.length > MAX_FILE_PREVIEW
351
+ ? raw.slice(0, MAX_FILE_PREVIEW) + `\n... [truncated, ${raw.length} chars total]`
352
+ : raw;
353
+ totalContent += preview.length;
354
+ analyzed.push({
355
+ path: f.path,
356
+ size: f.size,
357
+ extension: f.extension,
358
+ content: preview,
359
+ });
360
+ } catch {
361
+ analyzed.push({
362
+ path: f.path,
363
+ size: f.size,
364
+ extension: f.extension,
365
+ binary: true,
366
+ });
367
+ }
368
+ }
369
+
370
+ result.analyzed_files = analyzed;
371
+ result.analyzed_count = analyzed.length;
372
+ result.total_content_chars = totalContent;
373
+
374
+ if (skippedFiles.length > 0) {
375
+ result.skipped_third_party = skippedFiles.map(f => f.path);
376
+ result.skipped_note = `${skippedFiles.length} third-party/library files skipped (imgui, etc.). Only project source files are analyzed.`;
377
+ }
378
+
379
+ if (projectFiles.length > MAX_FILES_ANALYZE) {
380
+ result.analysis_note = `Showing ${textFiles.length} of ${projectFiles.length} project text files (capped at ${MAX_FILES_ANALYZE})`;
381
+ }
382
+ }
383
+ } else if (analyze && isTextFile(downloadedFile)) {
384
+ // Single text file — just read it
385
+ const raw = await readFile(downloadedPath, "utf-8");
386
+ result.content = raw.length > MAX_FILE_PREVIEW
387
+ ? raw.slice(0, MAX_FILE_PREVIEW) + `\n... [truncated, ${raw.length} chars total]`
388
+ : raw;
389
+ } else {
390
+ result.note = "File downloaded but not a recognized archive or text file. Check the downloads folder.";
391
+ result.downloads_path = runDir;
392
+ }
393
+
394
+ return {
395
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
396
+ };
397
+ } catch (err) {
398
+ const message = err instanceof Error ? err.message : String(err);
399
+ return {
400
+ content: [{ type: "text", text: `Error: ${message}` }],
401
+ isError: true,
402
+ };
403
+ }
404
+ }
405
+ );
406
+ }
@@ -0,0 +1,108 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { fetchHtml } from "../crawl.js";
4
+ import { parseCodeBlocks } from "../parsers/code-blocks.js";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { mkdir } from "node:fs/promises";
8
+
9
+ const MAX_CODE_LENGTH = 3_000;
10
+ const EXPORT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "exports");
11
+
12
+ export function registerExtractCode(server: McpServer): void {
13
+ server.tool(
14
+ "extract_code",
15
+ "Extract and identify code blocks from a thread. Detects C++, C#, Python, and Lua. Use export_to_file=true to save all blocks to a JSON file when there are many code blocks.",
16
+ {
17
+ url: z.string().url().describe("Thread URL to extract code from"),
18
+ limit: z
19
+ .number()
20
+ .int()
21
+ .min(1)
22
+ .max(50)
23
+ .optional()
24
+ .default(10)
25
+ .describe("Max number of code blocks to return inline (default 10, max 50). Ignored when export_to_file is true."),
26
+ export_to_file: z
27
+ .boolean()
28
+ .optional()
29
+ .default(false)
30
+ .describe("If true, exports ALL code blocks to a JSON file instead of returning them inline. Recommended when a page has many code blocks."),
31
+ },
32
+ async ({ url, limit, export_to_file }) => {
33
+ try {
34
+ const html = await fetchHtml(url);
35
+ const all = parseCodeBlocks(html);
36
+
37
+ if (all.length === 0) {
38
+ return {
39
+ content: [{ type: "text", text: JSON.stringify({ total: 0, returned: 0, blocks: [] }) }],
40
+ };
41
+ }
42
+
43
+ if (export_to_file) {
44
+ // Export all blocks (no truncation) to a timestamped file
45
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
46
+ const slug = url.split("/").filter(Boolean).pop()?.replace(/\.\w+$/, "") ?? "thread";
47
+ const filePath = `${EXPORT_DIR}/${slug}_${timestamp}.json`;
48
+
49
+ const payload = {
50
+ url,
51
+ exported_at: new Date().toISOString(),
52
+ total: all.length,
53
+ blocks: all,
54
+ };
55
+
56
+ await mkdir(EXPORT_DIR, { recursive: true });
57
+ await Bun.write(filePath, JSON.stringify(payload, null, 2));
58
+
59
+ return {
60
+ content: [{
61
+ type: "text",
62
+ text: JSON.stringify({
63
+ total: all.length,
64
+ exported_to: filePath,
65
+ message: `All ${all.length} code blocks exported to ${filePath}`,
66
+ }),
67
+ }],
68
+ };
69
+ }
70
+
71
+ // Inline mode: apply limit + truncation
72
+ const sliced = all.slice(0, limit);
73
+ const truncated = all.length > limit;
74
+ const lastPostId = truncated ? (sliced[sliced.length - 1].postId ?? null) : null;
75
+
76
+ const blocks = sliced.map((b) => ({
77
+ ...b,
78
+ code:
79
+ b.code.length > MAX_CODE_LENGTH
80
+ ? b.code.slice(0, MAX_CODE_LENGTH) + `\n... [truncated, ${b.code.length} chars total]`
81
+ : b.code,
82
+ }));
83
+
84
+ return {
85
+ content: [{
86
+ type: "text",
87
+ text: JSON.stringify({
88
+ total: all.length,
89
+ returned: blocks.length,
90
+ truncated,
91
+ ...(truncated && {
92
+ hint: `${all.length - limit} blocks not shown. Use export_to_file=true to get all, or increase limit (max 50).`,
93
+ last_post_id: lastPostId,
94
+ }),
95
+ blocks,
96
+ }),
97
+ }],
98
+ };
99
+ } catch (err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ return {
102
+ content: [{ type: "text", text: `Error: ${message}` }],
103
+ isError: true,
104
+ };
105
+ }
106
+ }
107
+ );
108
+ }
@@ -0,0 +1,167 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { navigateWithRetry, validateUrl } from "../browser.js";
4
+ import { FORUM_INDEX, readForumCatalog, saveForumCatalog, type ForumCatalog } from "../forum-catalog.js";
5
+ import type { Subforum } from "../parsers/subforums.js";
6
+ import { parseThreadList, parsePaginationInfo } from "../parsers/thread-list.js";
7
+ import { parseThread } from "../parsers/thread.js";
8
+ import { containsOffsetUpdate, normalizeName, rankGameForums, rankOffsetThreads, type OffsetThread } from "../offset-discovery.js";
9
+
10
+ const MAX_LISTING_PAGES = 10;
11
+ const MAX_THREAD_PAGES = 50;
12
+ type ForumPage = Awaited<ReturnType<typeof navigateWithRetry>>["page"];
13
+
14
+ function withPage(url: string, page: number): string {
15
+ const target = new URL(url);
16
+ target.searchParams.set("page", String(page));
17
+ return target.toString();
18
+ }
19
+
20
+ async function readPage(page: ForumPage, url: string): Promise<{ page: ForumPage; html: string }> {
21
+ validateUrl(url);
22
+ try {
23
+ const response = await page.evaluate(async (target) => {
24
+ const result = await fetch(target, { credentials: "include", signal: AbortSignal.timeout(8_000) });
25
+ return { ok: result.ok, url: result.url, html: await result.text() };
26
+ }, url);
27
+ validateUrl(response.url);
28
+ if (!response.ok || /Just a moment|cf-browser-verification|Checking your browser/i.test(response.html)) {
29
+ throw new Error("Forum returned a challenge or an error");
30
+ }
31
+ return { page, html: response.html };
32
+ } catch {
33
+ return navigateWithRetry(url);
34
+ }
35
+ }
36
+
37
+ export function registerFindLatestOffsets(server: McpServer): void {
38
+ server.tool(
39
+ "find_latest_offsets",
40
+ "Discover a game's offsets thread from live UnknownCheats forum listings, then scan from its last page backward for the newest matching post.",
41
+ {
42
+ game: z.string().min(1).describe("Game name, such as Apex Legends or PUBG"),
43
+ subforum_slug: z.string().optional().describe("Exact subforum slug from list_subforums when needed"),
44
+ thread_url: z.string().url().optional().describe("Exact UnknownCheats thread URL, skipping forum and thread discovery"),
45
+ max_listing_pages: z.number().int().min(1).max(MAX_LISTING_PAGES).optional().default(5).describe("Maximum game-forum listing pages to inspect"),
46
+ max_thread_pages: z.number().int().min(1).max(MAX_THREAD_PAGES).optional().default(20).describe("Maximum recent thread pages to inspect"),
47
+ },
48
+ async ({ game, subforum_slug, thread_url, max_listing_pages, max_thread_pages }) => {
49
+ try {
50
+ if (thread_url) validateUrl(thread_url);
51
+ const entry = thread_url ? await navigateWithRetry(thread_url) : null;
52
+ let browserPage: ForumPage | null = entry?.page ?? null;
53
+ let catalog: ForumCatalog | null = null;
54
+ let fromCache = false;
55
+ let forum: Subforum | null = null;
56
+ let forumChoices: Subforum[] = [];
57
+ const listingPagesScanned: string[] = [];
58
+ let candidates: OffsetThread[] = [];
59
+ let selectedUrl = thread_url;
60
+ let selectedTitle: string | undefined;
61
+ let discoveredAt: string | undefined;
62
+
63
+ if (!selectedUrl) {
64
+ catalog = await readForumCatalog();
65
+ fromCache = catalog !== null;
66
+ if (!catalog) {
67
+ const index = await navigateWithRetry(FORUM_INDEX);
68
+ browserPage = index.page;
69
+ catalog = await saveForumCatalog(index.html);
70
+ }
71
+ let forums = catalog.subforums;
72
+ forumChoices = rankGameForums(game, forums);
73
+ forum = subforum_slug ? forums.find((item) => item.slug === subforum_slug) ?? null : forumChoices[0] ?? null;
74
+ if (!forum && fromCache) {
75
+ const index = await navigateWithRetry(FORUM_INDEX);
76
+ browserPage = index.page;
77
+ catalog = await saveForumCatalog(index.html);
78
+ fromCache = false;
79
+ forums = catalog.subforums;
80
+ forumChoices = rankGameForums(game, forums);
81
+ forum = subforum_slug ? forums.find((item) => item.slug === subforum_slug) ?? null : forumChoices[0] ?? null;
82
+ }
83
+ if (!forum) {
84
+ return { content: [{ type: "text", text: JSON.stringify({
85
+ found: false, reason: "game_forum_not_found", game,
86
+ forumIndex: { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache }, candidateForums: forumChoices.slice(0, 10),
87
+ }) }] };
88
+ }
89
+ if (!subforum_slug &&
90
+ normalizeName(forum.label) !== normalizeName(game) &&
91
+ normalizeName(forum.slug) !== normalizeName(game)) {
92
+ return { content: [{ type: "text", text: JSON.stringify({
93
+ found: false, reason: "ambiguous_game_forum", game,
94
+ forumIndex: { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache }, candidateForums: forumChoices.slice(0, 10),
95
+ }) }] };
96
+ }
97
+
98
+ for (let number = 1; number <= max_listing_pages; number++) {
99
+ const url = number === 1 ? forum.url : `${forum.url}index${number}.html`;
100
+ const response = browserPage ? await readPage(browserPage, url) : await navigateWithRetry(url);
101
+ browserPage = response.page;
102
+ listingPagesScanned.push(url);
103
+ const threads = parseThreadList(response.html);
104
+ if (threads.length === 0) throw new Error(`No thread list parsed from ${url}`);
105
+ candidates.push(...rankOffsetThreads(threads, url));
106
+ if (candidates.length > 0 || number >= parsePaginationInfo(response.html).totalPages) break;
107
+ }
108
+
109
+ candidates = [...new Map(candidates.map((item) => [item.url, item])).values()]
110
+ .sort((a, b) => b.score - a.score || b.replies - a.replies)
111
+ .slice(0, 10);
112
+ const selected = candidates[0];
113
+ if (!selected) {
114
+ return { content: [{ type: "text", text: JSON.stringify({
115
+ found: false, reason: "offset_thread_not_found_in_scanned_listings", game, forum,
116
+ forumIndex: { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache }, listingPagesScanned,
117
+ }) }] };
118
+ }
119
+ selectedUrl = selected.url;
120
+ selectedTitle = selected.title;
121
+ discoveredAt = selected.listingPage;
122
+ }
123
+
124
+ const first = entry ?? await readPage(browserPage!, selectedUrl);
125
+ browserPage = first.page;
126
+ const thread = parseThread(first.html, selectedUrl);
127
+ selectedTitle ??= thread.title;
128
+ if (thread.posts.length === 0) throw new Error(`No posts parsed from ${selectedUrl}`);
129
+ const pagesScanned: number[] = [];
130
+ const oldestPage = Math.max(1, thread.totalPages - max_thread_pages + 1);
131
+
132
+ for (let number = thread.totalPages; number >= oldestPage; number--) {
133
+ const url = withPage(selectedUrl, number);
134
+ const response: { page: ForumPage; html: string } = number === 1 && thread.totalPages === 1 ? first : await readPage(browserPage!, url);
135
+ browserPage = response.page;
136
+ const posts = parseThread(response.html, url, number).posts;
137
+ if (posts.length === 0) throw new Error(`No posts parsed from ${url}`);
138
+ pagesScanned.push(number);
139
+ const match = posts.reverse().find(containsOffsetUpdate);
140
+ if (match) {
141
+ return { content: [{ type: "text", text: JSON.stringify({
142
+ found: true, game, forum, candidateForums: forumChoices.slice(0, 5),
143
+ forumIndex: catalog ? { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache } : undefined,
144
+ thread: { title: selectedTitle, url: selectedUrl, discoveredAt }, candidateThreads: candidates,
145
+ listingPagesScanned, totalThreadPages: thread.totalPages, pagesScanned,
146
+ sourcePage: url, sourcePost: `${url}#post${match.postNumber}`,
147
+ checkedAt: new Date().toISOString(),
148
+ post: { date: match.date, author: match.author, postNumber: match.postNumber, content: match.content.slice(0, 2_000), links: match.links },
149
+ note: "Newest matching post in the scanned pages. Linked data and game-version validity are unverified.",
150
+ }) }] };
151
+ }
152
+ }
153
+
154
+ return { content: [{ type: "text", text: JSON.stringify({
155
+ found: false, reason: "no_offset_update_in_scanned_pages", game, forum,
156
+ forumIndex: catalog ? { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache } : undefined,
157
+ thread: { title: selectedTitle, url: selectedUrl, discoveredAt }, candidateThreads: candidates,
158
+ listingPagesScanned, totalThreadPages: thread.totalPages, pagesScanned,
159
+ checkedAt: new Date().toISOString(),
160
+ }) }] };
161
+ } catch (err) {
162
+ const message = err instanceof Error ? err.message : String(err);
163
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
164
+ }
165
+ }
166
+ );
167
+ }