mcp-unknowncheatz 0.3.3 → 0.3.5
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 +2 -2
- package/package.json +1 -1
- package/src/browser.ts +3 -0
- package/src/crawl.ts +2 -0
- package/src/forum-index.ts +3 -2
- package/src/forum-search.ts +67 -0
- package/src/forum-url.ts +15 -0
- package/src/offset-discovery.ts +13 -0
- package/src/parsers/thread-list.ts +2 -9
- package/src/parsers/thread.ts +5 -4
- package/src/search-fallback.ts +1 -3
- package/src/tools/bulk-get-threads.ts +3 -1
- package/src/tools/extract-code.ts +2 -0
- package/src/tools/find-latest-offsets.ts +54 -29
- package/src/tools/get-thread.ts +3 -0
- package/src/tools/search-forum.ts +23 -63
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ For other clients, configure a local stdio MCP server with command `bunx` and ar
|
|
|
107
107
|
| `crawl_subforum` | Collect threads from subforum pages |
|
|
108
108
|
| `bulk_get_threads` | Read several threads |
|
|
109
109
|
| `get_user_reputation` | Read reputation details |
|
|
110
|
-
| `find_latest_offsets` | Find a game's offsets thread in
|
|
110
|
+
| `find_latest_offsets` | Find a game's offsets thread in a dedicated or shared forum and scan recent pages backward |
|
|
111
111
|
| `debug_page` | Inspect page structure |
|
|
112
112
|
| `crawl_cache` | Inspect or clear the HTML cache |
|
|
113
113
|
| `index_subforum` | Refresh a bounded local thread and post index for one subforum |
|
|
@@ -126,7 +126,7 @@ search_index({ query: "offsets", subforum: "apex-legends" })
|
|
|
126
126
|
|
|
127
127
|
The server advertises forum research tools for game cheating scenes, cheat techniques and tooling, anti-cheat, reversing, and offsets questions. The connected AI client decides whether to invoke them; tool descriptions and server instructions guide selection but do not force a call. For a specific claim, read its source thread and report the source URL and date.
|
|
128
128
|
|
|
129
|
-
The first directory lookup saves forum URLs in `forum-index.json`. Offsets lookups read the selected game's live thread listing, choose a linked candidate, and scan its recent pages from newest to oldest. Results
|
|
129
|
+
The first directory lookup saves forum URLs in `forum-index.json`. Offsets lookups read the selected game's live thread listing, choose a linked candidate, and scan its recent pages from newest to oldest. For games in a shared forum, they try indexed thread titles and then native forum search. Results identify whether a candidate came from a listing, search, or the local index; opening the thread verifies it live. A matching post does not prove the offsets work with the current game build. If the game name is ambiguous, use a slug returned by `list_subforums` or pass an exact `thread_url`.
|
|
130
130
|
|
|
131
131
|
Browsing or crawling a subforum records its visible listing in the local index. Reading a thread records the pages visited. `index_subforum` additionally samples the first and recent post pages of changed threads, up to five by default, and rechecks unchanged threads after 24 hours. The index remains partial: `search_index` includes listing and post page counts, timestamps, and a partial coverage marker. Use live search when freshness or missing coverage matters. The SQLite database is stored under the user's application data directory (`mcp-unknowncheat/forum-index.sqlite`); set `UC_INDEX_PATH` to change it. Browser-backed tools share one page and run one at a time; a queued call returns a busy error after 10 seconds. `get_thread`, `bulk_get_threads`, `crawl_subforum`, `index_subforum`, and `find_latest_offsets` stop starting new page requests after a 45-second fetch budget. `crawl_cache` reports cache hits, queued requests, failures, and total fetch time.
|
|
132
132
|
|
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync } from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
|
+
import { isApacheNotFoundPage, normalizeThreadUrl } from "./forum-url.js";
|
|
6
7
|
|
|
7
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const COOKIES_PATH = path.join(__dirname, "..", "cookies.json");
|
|
@@ -187,6 +188,7 @@ function isNavigationAbortError(err: unknown): boolean {
|
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
export async function navigateWithRetry(url: string, deadlineAt?: number): Promise<{ page: BrowserInstance["page"]; html: string }> {
|
|
191
|
+
url = normalizeThreadUrl(url);
|
|
190
192
|
validateUrl(url);
|
|
191
193
|
let page = await getPage();
|
|
192
194
|
let navRetried = false;
|
|
@@ -203,6 +205,7 @@ export async function navigateWithRetry(url: string, deadlineAt?: number): Promi
|
|
|
203
205
|
await page.goto(url, { waitUntil, timeout: remaining(timeout) });
|
|
204
206
|
|
|
205
207
|
const html = await waitForChallenge(page, await page.content(), deadlineAt);
|
|
208
|
+
if (isApacheNotFoundPage(html)) throw new Error(`Forum page not found (HTTP 404): ${url}`);
|
|
206
209
|
remaining(1);
|
|
207
210
|
|
|
208
211
|
await saveCookies(page);
|
package/src/crawl.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { navigateWithRetry, validateUrl } from "./browser.js";
|
|
2
|
+
import { normalizeThreadUrl } from "./forum-url.js";
|
|
2
3
|
|
|
3
4
|
const CACHE_TTL_MS = Number(process.env.UC_CACHE_TTL_MS ?? 5 * 60_000);
|
|
4
5
|
const MIN_REQUEST_INTERVAL_MS = Number(process.env.UC_MIN_REQUEST_INTERVAL_MS ?? 900);
|
|
@@ -37,6 +38,7 @@ export interface FetchOptions {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export async function fetchHtml(url: string, opts: FetchOptions = {}): Promise<string> {
|
|
41
|
+
url = normalizeThreadUrl(url);
|
|
40
42
|
validateUrl(url);
|
|
41
43
|
const ttl = opts.cacheOverrideTtlMs ?? CACHE_TTL_MS;
|
|
42
44
|
|
package/src/forum-index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import type { ThreadListEntry } from "./parsers/thread-list.js";
|
|
6
6
|
import type { ThreadPost } from "./types.js";
|
|
7
7
|
import type { ThreadData } from "./types.js";
|
|
8
|
+
import { normalizeThreadUrl } from "./forum-url.js";
|
|
8
9
|
|
|
9
10
|
const DATA_DIR = process.platform === "win32"
|
|
10
11
|
? path.join(process.env.LOCALAPPDATA ?? os.homedir(), "mcp-unknowncheat")
|
|
@@ -218,9 +219,9 @@ export class ForumIndex {
|
|
|
218
219
|
for (let i = 0; hits.length < safeLimit && (i < threads.length || i < posts.length); i++) {
|
|
219
220
|
const thread = threads[i];
|
|
220
221
|
const post = posts[i];
|
|
221
|
-
if (thread) hits.push({ ...thread, kind: "thread" });
|
|
222
|
+
if (thread) hits.push({ ...thread, kind: "thread", url: normalizeThreadUrl(thread.url) });
|
|
222
223
|
if (post && hits.length < safeLimit) {
|
|
223
|
-
const url = new URL(post.url);
|
|
224
|
+
const url = new URL(normalizeThreadUrl(post.url));
|
|
224
225
|
if (post.page && post.page > 1) url.searchParams.set("page", String(post.page));
|
|
225
226
|
url.hash = `post${post.postId}`;
|
|
226
227
|
hits.push({ ...post, kind: "post", url: url.toString() });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { load } from "cheerio";
|
|
2
|
+
import { navigateWithRetry } from "./browser.js";
|
|
3
|
+
import { getForumIndex } from "./forum-index.js";
|
|
4
|
+
import { parseThreadList } from "./parsers/thread-list.js";
|
|
5
|
+
import { normalizeName } from "./offset-discovery.js";
|
|
6
|
+
|
|
7
|
+
const SEARCH_URL = "https://www.unknowncheats.me/forum/search.php";
|
|
8
|
+
|
|
9
|
+
export async function searchNativeThreads(query: string, titleOnly = true, sortBy = "relevancy", searchUser = "", deadlineAt?: number) {
|
|
10
|
+
const { page } = await navigateWithRetry(SEARCH_URL, deadlineAt);
|
|
11
|
+
const submitted = await page.evaluate((opts) => {
|
|
12
|
+
const form = [...document.forms].find((candidate) =>
|
|
13
|
+
candidate.querySelector('input[name="query"]') &&
|
|
14
|
+
(candidate.id === "searchform" || candidate.name === "searchform" || candidate.action.includes("search.php")));
|
|
15
|
+
if (!form) return { ok: false, loginRequired: !!document.querySelector('input[name="securitytoken"][value="guest"]') };
|
|
16
|
+
const input = form.querySelector('input[name="query"][size="35"]') as HTMLInputElement | null
|
|
17
|
+
?? form.querySelector('input[name="query"]') as HTMLInputElement | null;
|
|
18
|
+
if (!input) return { ok: false, loginRequired: false };
|
|
19
|
+
input.value = opts.query;
|
|
20
|
+
const titleSelect = form.querySelector('select[name="titleonly"]') as HTMLSelectElement | null;
|
|
21
|
+
if (titleSelect?.querySelector(`option[value="${opts.titleOnly ? "1" : "0"}"]`)) titleSelect.value = opts.titleOnly ? "1" : "0";
|
|
22
|
+
const showThreads = form.querySelector('input[name="showposts"][value="0"]') as HTMLInputElement | null;
|
|
23
|
+
if (showThreads) showThreads.checked = true;
|
|
24
|
+
const sortSelect = form.querySelector('select[name="sortby"]') as HTMLSelectElement | null;
|
|
25
|
+
if (sortSelect?.querySelector(`option[value="${opts.sortBy}"]`)) sortSelect.value = opts.sortBy;
|
|
26
|
+
if (opts.searchUser) {
|
|
27
|
+
const userInput = form.querySelector('input[name="searchuser"]') as HTMLInputElement | null;
|
|
28
|
+
if (userInput) userInput.value = opts.searchUser;
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, loginRequired: false };
|
|
31
|
+
}, { query, titleOnly, sortBy, searchUser });
|
|
32
|
+
if (!submitted.ok) throw new Error(submitted.loginRequired
|
|
33
|
+
? "Forum advanced search requires a logged-in session"
|
|
34
|
+
: `Forum search form is unavailable (page: ${await page.title()})`);
|
|
35
|
+
|
|
36
|
+
await Promise.all([
|
|
37
|
+
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: Math.max(1, Math.min(30_000, (deadlineAt ?? Infinity) - Date.now())) }),
|
|
38
|
+
page.evaluate(() => {
|
|
39
|
+
const form = [...document.forms].find((candidate) =>
|
|
40
|
+
candidate.querySelector('input[name="query"]') &&
|
|
41
|
+
(candidate.id === "searchform" || candidate.name === "searchform" || candidate.action.includes("search.php")));
|
|
42
|
+
if (!form) throw new Error("Forum search form disappeared before submission");
|
|
43
|
+
const button = form.querySelector('input[type="submit"], button[type="submit"]') as HTMLElement | null;
|
|
44
|
+
if (button) button.click();
|
|
45
|
+
else form.requestSubmit();
|
|
46
|
+
}),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const html = await page.content();
|
|
50
|
+
const parsed = parseThreadList(html);
|
|
51
|
+
const $ = load(html);
|
|
52
|
+
const pageTitle = $("title").text().trim();
|
|
53
|
+
const errorText = $(".standard_error, .errorwrap, .blockbody .error").first().text().trim();
|
|
54
|
+
if (errorText) throw new Error(`Forum search failed: ${errorText}`);
|
|
55
|
+
if (!/Search Results/i.test(pageTitle)) {
|
|
56
|
+
throw new Error(`Forum did not return search results (page: ${pageTitle || "untitled"})`);
|
|
57
|
+
}
|
|
58
|
+
const terms = normalizeName(query).split(" ").filter(Boolean);
|
|
59
|
+
const results = titleOnly
|
|
60
|
+
? parsed.filter((thread) => terms.every((term) => normalizeName(thread.title).split(" ").includes(term)))
|
|
61
|
+
: parsed;
|
|
62
|
+
if (results.length > 0) getForumIndex().recordSearchResults(results);
|
|
63
|
+
const pageNav = $(".pagenav td.vbmenu_control").first().text().trim();
|
|
64
|
+
const pageMatch = pageNav.match(/Page (\d+) of (\d+)/);
|
|
65
|
+
const pagination = pageMatch ? { currentPage: Number(pageMatch[1]), totalPages: Number(pageMatch[2]) } : undefined;
|
|
66
|
+
return { results, pageTitle, pagination, resultsUrl: page.url() };
|
|
67
|
+
}
|
package/src/forum-url.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const FORUM_BASE = "https://www.unknowncheats.me/forum/";
|
|
2
|
+
|
|
3
|
+
export function normalizeThreadUrl(input: string): string {
|
|
4
|
+
const url = new URL(input, FORUM_BASE);
|
|
5
|
+
if ((url.hostname === "www.unknowncheats.me" || url.hostname === "unknowncheats.me") &&
|
|
6
|
+
(/^\/[a-z0-9-]+\/\d+-[^/]+\.html$/i.test(url.pathname) || url.pathname === "/showthread.php")) {
|
|
7
|
+
url.pathname = `/forum${url.pathname}`;
|
|
8
|
+
}
|
|
9
|
+
return url.toString();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isApacheNotFoundPage(html: string): boolean {
|
|
13
|
+
return /<title>\s*404 Not Found\s*<\/title>/i.test(html) &&
|
|
14
|
+
/Apache Server at (?:www\.)?unknowncheats\.me/i.test(html);
|
|
15
|
+
}
|
package/src/offset-discovery.ts
CHANGED
|
@@ -66,6 +66,19 @@ export function rankOffsetThreads(threads: ThreadListEntry[], listingPage: strin
|
|
|
66
66
|
.sort((a, b) => b.score - a.score || b.replies - a.replies);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export function rankSharedForumOffsetThreads(game: string, threads: ThreadListEntry[], listingPage: string): OffsetThread[] {
|
|
70
|
+
const normalizedGame = normalizeName(game);
|
|
71
|
+
const wantsCn = /\b(?:cn|chinese|wegame)\b/.test(normalizedGame);
|
|
72
|
+
const gameTerms = normalizedGame.split(" ").filter((term) => !["cn", "chinese", "wegame"].includes(term));
|
|
73
|
+
return rankOffsetThreads(threads, listingPage)
|
|
74
|
+
.filter((thread) => {
|
|
75
|
+
const title = normalizeName(thread.title);
|
|
76
|
+
return gameTerms.every((term) => title.split(" ").includes(term)) &&
|
|
77
|
+
(!wantsCn || /\b(?:cn|chinese|wegame)\b/.test(title));
|
|
78
|
+
})
|
|
79
|
+
.sort((a, b) => b.score - a.score || b.replies - a.replies);
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
export function containsOffsetUpdate(post: ThreadPost): boolean {
|
|
70
83
|
const firstValue = post.content.search(/\b0x[0-9a-f]{3,}\b/i);
|
|
71
84
|
const firstLink = post.content.search(/https:\/\/(?:www\.)?(?:pastebin\.com|pastes\.dev)\//i);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { load } from "cheerio";
|
|
2
|
+
import { normalizeThreadUrl } from "../forum-url.js";
|
|
2
3
|
|
|
3
4
|
export interface ThreadListEntry {
|
|
4
5
|
title: string;
|
|
@@ -14,14 +15,6 @@ export interface ThreadListEntry {
|
|
|
14
15
|
prefix?: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
const UC_BASE = "https://www.unknowncheats.me";
|
|
18
|
-
|
|
19
|
-
function absoluteUrl(href: string): string {
|
|
20
|
-
if (href.startsWith("http")) return href;
|
|
21
|
-
if (href.startsWith("//")) return `https:${href}`;
|
|
22
|
-
return `${UC_BASE}${href.startsWith("/") ? "" : "/"}${href}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
18
|
export function parseThreadList(html: string): ThreadListEntry[] {
|
|
26
19
|
const $ = load(html);
|
|
27
20
|
const results: ThreadListEntry[] = [];
|
|
@@ -95,7 +88,7 @@ export function parseThreadList(html: string): ThreadListEntry[] {
|
|
|
95
88
|
|
|
96
89
|
results.push({
|
|
97
90
|
title,
|
|
98
|
-
url:
|
|
91
|
+
url: normalizeThreadUrl(href),
|
|
99
92
|
threadId: id,
|
|
100
93
|
author,
|
|
101
94
|
date,
|
package/src/parsers/thread.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { load } from "cheerio";
|
|
2
2
|
import type { ThreadData, ThreadPost } from "../types.js";
|
|
3
3
|
import { parseReputationInPost } from "./reputation.js";
|
|
4
|
+
import { normalizeThreadUrl } from "../forum-url.js";
|
|
4
5
|
|
|
5
6
|
function parseTotalPages($: ReturnType<typeof load>): number {
|
|
6
7
|
// .pagenav contains "Page X of Y"
|
|
@@ -60,8 +61,8 @@ export function parseThread(html: string, url: string, pageNum = 1): ThreadData
|
|
|
60
61
|
const href = $(a).attr("href") ?? "";
|
|
61
62
|
const text = $(a).text().trim();
|
|
62
63
|
if (href && !href.startsWith("#")) {
|
|
63
|
-
const
|
|
64
|
-
links.push({ text: text ||
|
|
64
|
+
const resolved = normalizeThreadUrl(href);
|
|
65
|
+
if (/^https?:\/\//i.test(resolved)) links.push({ text: text || resolved, url: resolved });
|
|
65
66
|
}
|
|
66
67
|
});
|
|
67
68
|
|
|
@@ -70,8 +71,8 @@ export function parseThread(html: string, url: string, pageNum = 1): ThreadData
|
|
|
70
71
|
contentEl.find("img[src]").each((_, img) => {
|
|
71
72
|
const src = $(img).attr("src") ?? "";
|
|
72
73
|
if (src && !src.includes("clear.gif") && !src.includes("spacer") && !src.includes("wol_error") && !src.includes("statusicon")) {
|
|
73
|
-
const
|
|
74
|
-
images.push(
|
|
74
|
+
const resolved = normalizeThreadUrl(src);
|
|
75
|
+
if (/^https?:\/\//i.test(resolved)) images.push(resolved);
|
|
75
76
|
}
|
|
76
77
|
});
|
|
77
78
|
|
package/src/search-fallback.ts
CHANGED
|
@@ -49,9 +49,7 @@ export async function searchViaSubforums(
|
|
|
49
49
|
const subforums = knownSubforums ?? discoverSubforumSlugs(await fetchHtml("https://www.unknowncheats.me/forum/index.php"));
|
|
50
50
|
const ranked = rankSubforums(subforums, query);
|
|
51
51
|
|
|
52
|
-
const candidates = ranked.
|
|
53
|
-
? ranked.slice(0, 3)
|
|
54
|
-
: [{ slug: query.trim().toLowerCase().replace(/\s+/g, "-"), label: query, score: 1 }];
|
|
52
|
+
const candidates = ranked.slice(0, 3);
|
|
55
53
|
|
|
56
54
|
const seen = new Set<string>();
|
|
57
55
|
const results: ThreadListEntry[] = [];
|
|
@@ -5,6 +5,7 @@ import { fetchHtml } from "../crawl.js";
|
|
|
5
5
|
import { parseThread } from "../parsers/thread.js";
|
|
6
6
|
import { parseCodeBlocks } from "../parsers/code-blocks.js";
|
|
7
7
|
import { validateUrl } from "../browser.js";
|
|
8
|
+
import { normalizeThreadUrl } from "../forum-url.js";
|
|
8
9
|
import { getForumIndex } from "../forum-index.js";
|
|
9
10
|
import type { ThreadPost } from "../types.js";
|
|
10
11
|
import type { AuthorReputation } from "../parsers/reputation.js";
|
|
@@ -141,7 +142,8 @@ export function registerBulkGetThreads(server: McpServer): void {
|
|
|
141
142
|
let timeBudgetReached = false;
|
|
142
143
|
const deadlineAt = Date.now() + 45_000;
|
|
143
144
|
|
|
144
|
-
for (const
|
|
145
|
+
for (const rawUrl of urls) {
|
|
146
|
+
const url = normalizeThreadUrl(rawUrl);
|
|
145
147
|
if (Date.now() >= deadlineAt) {
|
|
146
148
|
timeBudgetReached = true;
|
|
147
149
|
break;
|
|
@@ -6,6 +6,7 @@ import { parseCodeBlocks } from "../parsers/code-blocks.js";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { mkdir } from "node:fs/promises";
|
|
9
|
+
import { normalizeThreadUrl } from "../forum-url.js";
|
|
9
10
|
|
|
10
11
|
const MAX_CODE_LENGTH = 3_000;
|
|
11
12
|
const EXPORT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "exports");
|
|
@@ -32,6 +33,7 @@ export function registerExtractCode(server: McpServer): void {
|
|
|
32
33
|
},
|
|
33
34
|
async ({ url, limit, export_to_file }) => withBrowserSession(async () => {
|
|
34
35
|
try {
|
|
36
|
+
url = normalizeThreadUrl(url);
|
|
35
37
|
const html = await fetchHtml(url);
|
|
36
38
|
const all = parseCodeBlocks(html);
|
|
37
39
|
|
|
@@ -6,8 +6,10 @@ import { FORUM_INDEX, readForumCatalog, saveForumCatalog, type ForumCatalog } fr
|
|
|
6
6
|
import type { Subforum } from "../parsers/subforums.js";
|
|
7
7
|
import { parseThreadList, parsePaginationInfo } from "../parsers/thread-list.js";
|
|
8
8
|
import { parseThread } from "../parsers/thread.js";
|
|
9
|
-
import { containsOffsetUpdate, normalizeName, rankGameForums, rankOffsetThreads, type OffsetThread } from "../offset-discovery.js";
|
|
9
|
+
import { containsOffsetUpdate, normalizeName, rankGameForums, rankOffsetThreads, rankSharedForumOffsetThreads, type OffsetThread } from "../offset-discovery.js";
|
|
10
10
|
import { getForumIndex } from "../forum-index.js";
|
|
11
|
+
import { normalizeThreadUrl } from "../forum-url.js";
|
|
12
|
+
import { searchNativeThreads } from "../forum-search.js";
|
|
11
13
|
|
|
12
14
|
const MAX_LISTING_PAGES = 10;
|
|
13
15
|
const MAX_THREAD_PAGES = 50;
|
|
@@ -52,6 +54,7 @@ export function registerFindLatestOffsets(server: McpServer): void {
|
|
|
52
54
|
async ({ game, subforum_slug, thread_url, max_listing_pages, max_thread_pages }) => withBrowserSession(async () => {
|
|
53
55
|
const deadlineAt = Date.now() + 45_000;
|
|
54
56
|
try {
|
|
57
|
+
if (thread_url) thread_url = normalizeThreadUrl(thread_url);
|
|
55
58
|
if (thread_url) validateUrl(thread_url);
|
|
56
59
|
const entry = thread_url ? await navigateWithRetry(thread_url, deadlineAt) : null;
|
|
57
60
|
let browserPage: ForumPage | null = entry?.page ?? null;
|
|
@@ -69,48 +72,70 @@ export function registerFindLatestOffsets(server: McpServer): void {
|
|
|
69
72
|
catalog = await readForumCatalog();
|
|
70
73
|
fromCache = catalog !== null;
|
|
71
74
|
if (!catalog) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
try {
|
|
76
|
+
const index = await navigateWithRetry(FORUM_INDEX, deadlineAt);
|
|
77
|
+
browserPage = index.page;
|
|
78
|
+
catalog = await saveForumCatalog(index.html);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error("[offsets] Forum directory unavailable; checking indexed threads:", error);
|
|
81
|
+
}
|
|
75
82
|
}
|
|
76
|
-
let forums = catalog
|
|
83
|
+
let forums = catalog?.subforums ?? [];
|
|
77
84
|
forumChoices = rankGameForums(game, forums);
|
|
78
85
|
forum = subforum_slug ? forums.find((item) => item.slug === subforum_slug) ?? null : forumChoices[0] ?? null;
|
|
79
86
|
if (!forum && fromCache) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
try {
|
|
88
|
+
const index = await navigateWithRetry(FORUM_INDEX, deadlineAt);
|
|
89
|
+
browserPage = index.page;
|
|
90
|
+
catalog = await saveForumCatalog(index.html);
|
|
91
|
+
fromCache = false;
|
|
92
|
+
forums = catalog.subforums;
|
|
93
|
+
forumChoices = rankGameForums(game, forums);
|
|
94
|
+
forum = subforum_slug ? forums.find((item) => item.slug === subforum_slug) ?? null : forumChoices[0] ?? null;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error("[offsets] Forum directory refresh failed; checking indexed threads:", error);
|
|
97
|
+
}
|
|
87
98
|
}
|
|
88
|
-
if (!forum) {
|
|
99
|
+
if (!forum && subforum_slug) {
|
|
89
100
|
return { content: [{ type: "text", text: JSON.stringify({
|
|
90
101
|
found: false, reason: "game_forum_not_found", game,
|
|
91
|
-
forumIndex: { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache }, candidateForums: forumChoices.slice(0, 10),
|
|
102
|
+
forumIndex: catalog ? { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache } : undefined, candidateForums: forumChoices.slice(0, 10),
|
|
92
103
|
}) }] };
|
|
93
104
|
}
|
|
94
|
-
if (!subforum_slug &&
|
|
105
|
+
if (forum && !subforum_slug &&
|
|
95
106
|
normalizeName(forum.label) !== normalizeName(game) &&
|
|
96
107
|
normalizeName(forum.slug) !== normalizeName(game)) {
|
|
97
108
|
return { content: [{ type: "text", text: JSON.stringify({
|
|
98
109
|
found: false, reason: "ambiguous_game_forum", game,
|
|
99
|
-
forumIndex: { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache }, candidateForums: forumChoices.slice(0, 10),
|
|
110
|
+
forumIndex: catalog ? { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache } : undefined, candidateForums: forumChoices.slice(0, 10),
|
|
100
111
|
}) }] };
|
|
101
112
|
}
|
|
102
113
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
if (forum) {
|
|
115
|
+
for (let number = 1; number <= max_listing_pages; number++) {
|
|
116
|
+
if (Date.now() >= deadlineAt) break;
|
|
117
|
+
const url = number === 1 ? forum.url : `${forum.url}index${number}.html`;
|
|
118
|
+
const response = browserPage ? await readPage(browserPage, url, deadlineAt) : await navigateWithRetry(url, deadlineAt);
|
|
119
|
+
browserPage = response.page;
|
|
120
|
+
listingPagesScanned.push(url);
|
|
121
|
+
const threads = parseThreadList(response.html);
|
|
122
|
+
if (threads.length === 0) throw new Error(`No thread list parsed from ${url}`);
|
|
123
|
+
getForumIndex().recordListing(forum.slug, number, threads);
|
|
124
|
+
candidates.push(...rankOffsetThreads(threads, url));
|
|
125
|
+
if (candidates.length > 0 || number >= parsePaginationInfo(response.html).totalPages) break;
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
const baseGame = normalizeName(game).replace(/\b(?:cn|chinese|wegame)\b/g, "").trim();
|
|
129
|
+
const indexed = getForumIndex().search(baseGame, undefined, 100)
|
|
130
|
+
.filter((hit) => hit.kind === "thread")
|
|
131
|
+
.map((hit) => ({ ...hit, replies: 0, views: 0, isSticky: false }));
|
|
132
|
+
candidates = rankSharedForumOffsetThreads(game, indexed, "local_index");
|
|
133
|
+
if (candidates.length === 0) {
|
|
134
|
+
const query = `${baseGame} offsets`;
|
|
135
|
+
const search = await searchNativeThreads(query, true, "relevancy", "", deadlineAt);
|
|
136
|
+
listingPagesScanned.push(search.resultsUrl);
|
|
137
|
+
candidates = rankSharedForumOffsetThreads(game, search.results, search.resultsUrl);
|
|
138
|
+
}
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
candidates = [...new Map(candidates.map((item) => [item.url, item])).values()]
|
|
@@ -119,8 +144,8 @@ export function registerFindLatestOffsets(server: McpServer): void {
|
|
|
119
144
|
const selected = candidates[0];
|
|
120
145
|
if (!selected) {
|
|
121
146
|
return { content: [{ type: "text", text: JSON.stringify({
|
|
122
|
-
found: false, reason: Date.now() >= deadlineAt ? "time_budget_reached" : "offset_thread_not_found_in_scanned_listings", game, forum,
|
|
123
|
-
forumIndex: { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache }, listingPagesScanned,
|
|
147
|
+
found: false, reason: Date.now() >= deadlineAt ? "time_budget_reached" : forum ? "offset_thread_not_found_in_scanned_listings" : "offset_thread_not_found_in_search", game, forum,
|
|
148
|
+
forumIndex: catalog ? { url: FORUM_INDEX, indexedAt: catalog.indexedAt, fromCache } : undefined, listingPagesScanned,
|
|
124
149
|
}) }] };
|
|
125
150
|
}
|
|
126
151
|
selectedUrl = selected.url;
|
package/src/tools/get-thread.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { fetchHtml } from "../crawl.js";
|
|
|
5
5
|
import { parseThread } from "../parsers/thread.js";
|
|
6
6
|
import { getForumIndex } from "../forum-index.js";
|
|
7
7
|
import type { ThreadPost } from "../types.js";
|
|
8
|
+
import { normalizeThreadUrl } from "../forum-url.js";
|
|
8
9
|
|
|
9
10
|
const MAX_PAGES = 50;
|
|
10
11
|
const MAX_IMAGES = 10; // max images to fetch and embed per call
|
|
@@ -62,11 +63,13 @@ export function registerGetThread(server: McpServer): void {
|
|
|
62
63
|
{ readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
63
64
|
async ({ url, fetch_all_pages, latest_pages, include_images }) => withBrowserSession(async () => {
|
|
64
65
|
try {
|
|
66
|
+
url = normalizeThreadUrl(url);
|
|
65
67
|
const deadlineAt = Date.now() + 45_000;
|
|
66
68
|
const firstHtml = await fetchHtml(url, { deadlineAt });
|
|
67
69
|
const pageParam = Number(new URL(url).searchParams.get("page"));
|
|
68
70
|
const requestedPage = Number.isInteger(pageParam) && pageParam > 0 ? pageParam : 1;
|
|
69
71
|
const firstPage = parseThread(firstHtml, url, requestedPage);
|
|
72
|
+
if (firstPage.posts.length === 0) throw new Error(`No thread posts found at ${url} (page title: ${firstPage.title})`);
|
|
70
73
|
const totalPages = firstPage.totalPages;
|
|
71
74
|
const pagesToFetch = latest_pages
|
|
72
75
|
? Array.from({ length: Math.min(latest_pages, totalPages) }, (_, index) =>
|
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
import { withBrowserSession } from "../browser.js";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { load } from "cheerio";
|
|
5
|
-
import { navigateWithRetry } from "../browser.js";
|
|
6
4
|
import { fetchHtml } from "../crawl.js";
|
|
7
5
|
import { isLoggedIn } from "../auth.js";
|
|
8
6
|
import { searchViaSubforums } from "../search-fallback.js";
|
|
9
7
|
import { filterThreads, parseThreadList } from "../parsers/thread-list.js";
|
|
10
8
|
import { FORUM_INDEX, readForumCatalog, saveForumCatalog } from "../forum-catalog.js";
|
|
11
9
|
import { getForumIndex } from "../forum-index.js";
|
|
10
|
+
import { searchNativeThreads } from "../forum-search.js";
|
|
12
11
|
|
|
13
12
|
const UC_HOME = "https://www.unknowncheats.me/forum/";
|
|
14
|
-
const UC_SEARCH = "https://www.unknowncheats.me/forum/search.php";
|
|
15
13
|
|
|
16
14
|
async function runFallbackSearch(query: string) {
|
|
17
|
-
console.error(`[search]
|
|
15
|
+
console.error(`[search] Native search unavailable — checking the local index and subforums for "${query}"`);
|
|
16
|
+
const index = getForumIndex();
|
|
17
|
+
const indexedQuery = /\b(?:cn|chinese)\b/i.test(query)
|
|
18
|
+
? query.replace(/\b(?:cn|chinese)\b/gi, "WeGame")
|
|
19
|
+
: query;
|
|
20
|
+
const hits = index.search(indexedQuery, undefined, 20).filter((hit) => hit.kind === "thread");
|
|
21
|
+
if (hits.length > 0) {
|
|
22
|
+
return {
|
|
23
|
+
count: hits.length,
|
|
24
|
+
source: "local_index" as const,
|
|
25
|
+
incomplete: true,
|
|
26
|
+
coverage: index.status(),
|
|
27
|
+
requiresLoginForNativeSearch: true,
|
|
28
|
+
hint: "These indexed listings may be stale. Open a thread to verify it live.",
|
|
29
|
+
results: hits,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
18
32
|
const catalog = await readForumCatalog() ?? await saveForumCatalog(await fetchHtml(FORUM_INDEX));
|
|
19
33
|
const { results, scannedSubforums } = await searchViaSubforums(query, async (url) => {
|
|
20
34
|
const html = await fetchHtml(url);
|
|
@@ -29,10 +43,13 @@ async function runFallbackSearch(query: string) {
|
|
|
29
43
|
return {
|
|
30
44
|
count: results.length,
|
|
31
45
|
source: "subforum_scan" as const,
|
|
46
|
+
incomplete: true,
|
|
32
47
|
scannedSubforums,
|
|
33
48
|
requiresLoginForNativeSearch: true,
|
|
34
49
|
hint: results.length === 0
|
|
35
|
-
?
|
|
50
|
+
? scannedSubforums.length === 0
|
|
51
|
+
? "No matching subforum was found. Log in for full UC search or pass an exact subforum slug."
|
|
52
|
+
: "No matches in scanned subforums. Pass subforum (e.g. apex-legends) or use the login tool for full UC search."
|
|
36
53
|
: "Use the login tool for full UC advanced search (filters, sort, author).",
|
|
37
54
|
results,
|
|
38
55
|
};
|
|
@@ -78,64 +95,7 @@ export function registerSearchForum(server: McpServer): void {
|
|
|
78
95
|
};
|
|
79
96
|
}
|
|
80
97
|
|
|
81
|
-
const {
|
|
82
|
-
|
|
83
|
-
const submitted = await page.evaluate((opts) => {
|
|
84
|
-
const searchForm = document.getElementById("searchform") as HTMLFormElement | null;
|
|
85
|
-
if (!searchForm) return { ok: false, error: "Advanced search form (#searchform) not found" };
|
|
86
|
-
|
|
87
|
-
const queryInput = searchForm.querySelector('input[name="query"][size="35"]') as HTMLInputElement
|
|
88
|
-
?? searchForm.querySelector('input[name="query"]') as HTMLInputElement;
|
|
89
|
-
if (!queryInput) return { ok: false, error: "Query input not found in form" };
|
|
90
|
-
queryInput.value = opts.query;
|
|
91
|
-
|
|
92
|
-
const titleOnlySelect = searchForm.querySelector('select[name="titleonly"]') as HTMLSelectElement;
|
|
93
|
-
if (titleOnlySelect) {
|
|
94
|
-
titleOnlySelect.value = opts.titleOnly ? "1" : "0";
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const showThreads = searchForm.querySelector('input[name="showposts"][value="0"]') as HTMLInputElement;
|
|
98
|
-
if (showThreads) showThreads.checked = true;
|
|
99
|
-
|
|
100
|
-
const sortSelect = searchForm.querySelector('select[name="sortby"]') as HTMLSelectElement;
|
|
101
|
-
if (sortSelect) sortSelect.value = opts.sortBy;
|
|
102
|
-
|
|
103
|
-
if (opts.searchUser) {
|
|
104
|
-
const userInput = searchForm.querySelector('input[name="searchuser"]') as HTMLInputElement;
|
|
105
|
-
if (userInput) userInput.value = opts.searchUser;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return { ok: true };
|
|
109
|
-
}, { query, titleOnly: title_only, sortBy: sort_by, searchUser: search_user ?? "" });
|
|
110
|
-
|
|
111
|
-
if (!submitted.ok) {
|
|
112
|
-
const payload = await runFallbackSearch(query);
|
|
113
|
-
return {
|
|
114
|
-
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
await Promise.all([
|
|
119
|
-
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30_000 }),
|
|
120
|
-
page.evaluate(() => (document.getElementById("searchform") as HTMLFormElement).submit()),
|
|
121
|
-
]);
|
|
122
|
-
|
|
123
|
-
const html = await page.content();
|
|
124
|
-
const results = parseThreadList(html);
|
|
125
|
-
if (results.length > 0) getForumIndex().recordSearchResults(results);
|
|
126
|
-
const $ = load(html);
|
|
127
|
-
const pageTitle = $("title").text().trim();
|
|
128
|
-
|
|
129
|
-
const errorText = $(".standard_error, .errorwrap, .blockbody .error").first().text().trim();
|
|
130
|
-
if (errorText) {
|
|
131
|
-
return {
|
|
132
|
-
content: [{ type: "text", text: JSON.stringify({ count: 0, error: errorText, pageTitle }) }],
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const pageNav = $(".pagenav td.vbmenu_control").first().text().trim();
|
|
137
|
-
const pageMatch = pageNav.match(/Page (\d+) of (\d+)/);
|
|
138
|
-
const pagination = pageMatch ? { currentPage: parseInt(pageMatch[1]), totalPages: parseInt(pageMatch[2]) } : undefined;
|
|
98
|
+
const { results, pageTitle, pagination } = await searchNativeThreads(query, title_only, sort_by, search_user ?? "");
|
|
139
99
|
|
|
140
100
|
console.error(`[search] "${query}" → ${results.length} results, page: ${pageTitle}`);
|
|
141
101
|
|