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,288 @@
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 { parseCodeBlocks } from "../parsers/code-blocks.js";
6
+ import { validateUrl } from "../browser.js";
7
+ import type { ThreadPost } from "../types.js";
8
+ import type { AuthorReputation } from "../parsers/reputation.js";
9
+
10
+ const MAX_URLS = 20;
11
+ const MAX_PAGES_PER_THREAD = 10;
12
+ const MAX_CONTENT_CHARS_PER_POST = 4_000;
13
+ const MAX_CODE_CHARS = 3_000;
14
+
15
+ function truncate(text: string, limit: number): string {
16
+ if (text.length <= limit) return text;
17
+ return `${text.slice(0, limit)}\n... [truncated, ${text.length} chars total]`;
18
+ }
19
+
20
+ function buildPageUrl(baseUrl: string, page: number): string {
21
+ const url = new URL(baseUrl);
22
+ url.searchParams.set("page", String(page));
23
+ return url.toString();
24
+ }
25
+
26
+ interface AuthorAgg {
27
+ author: string;
28
+ bestReputation: AuthorReputation | null;
29
+ postCount: number;
30
+ hasNegative: boolean;
31
+ }
32
+
33
+ function aggregateAuthors(posts: ThreadPost[]): AuthorAgg[] {
34
+ const map = new Map<string, AuthorAgg>();
35
+
36
+ for (const post of posts) {
37
+ if (!post.author) continue;
38
+ const existing = map.get(post.author) ?? {
39
+ author: post.author,
40
+ bestReputation: null,
41
+ postCount: 0,
42
+ hasNegative: false,
43
+ };
44
+ existing.postCount += 1;
45
+
46
+ const rep = post.reputation ?? null;
47
+ if (rep) {
48
+ if (rep.negativeDots > 0 || rep.sign === "negative") existing.hasNegative = true;
49
+ if (!existing.bestReputation || (rep.trustScore > existing.bestReputation.trustScore)) {
50
+ existing.bestReputation = rep;
51
+ }
52
+ }
53
+
54
+ map.set(post.author, existing);
55
+ }
56
+
57
+ return [...map.values()].sort((a, b) => {
58
+ const at = a.bestReputation?.trustScore ?? 0;
59
+ const bt = b.bestReputation?.trustScore ?? 0;
60
+ return bt - at;
61
+ });
62
+ }
63
+
64
+ export function registerBulkGetThreads(server: McpServer): void {
65
+ server.tool(
66
+ "bulk_get_threads",
67
+ "Fetch multiple UC threads (cached + rate-limited). Includes author reputation, trust scores, and OP-rep filters so untrustworthy threads can be skipped.",
68
+ {
69
+ urls: z
70
+ .array(z.string().url())
71
+ .min(1)
72
+ .max(MAX_URLS)
73
+ .describe(`Thread URLs to fetch (max ${MAX_URLS})`),
74
+ include_posts: z
75
+ .boolean()
76
+ .optional()
77
+ .default(true)
78
+ .describe("Include post content (default true). Set false for a summary-only crawl."),
79
+ include_code: z
80
+ .boolean()
81
+ .optional()
82
+ .default(true)
83
+ .describe("Extract code blocks per thread (default true)"),
84
+ code_limit_per_thread: z
85
+ .number()
86
+ .int()
87
+ .min(1)
88
+ .max(20)
89
+ .optional()
90
+ .default(5)
91
+ .describe("Max code blocks returned per thread (default 5)"),
92
+ fetch_all_pages: z
93
+ .boolean()
94
+ .optional()
95
+ .default(false)
96
+ .describe(`If true, fetch every page of each thread (cap ${MAX_PAGES_PER_THREAD})`),
97
+ post_content_chars: z
98
+ .number()
99
+ .int()
100
+ .min(200)
101
+ .max(20_000)
102
+ .optional()
103
+ .default(MAX_CONTENT_CHARS_PER_POST)
104
+ .describe("Max characters of body text kept per post"),
105
+ min_op_rep: z
106
+ .number()
107
+ .int()
108
+ .optional()
109
+ .describe("Skip threads whose original poster has reputation below this value"),
110
+ exclude_negative_op: z
111
+ .boolean()
112
+ .optional()
113
+ .default(false)
114
+ .describe("Skip threads whose original poster has any negative-rep dot"),
115
+ min_op_trust: z
116
+ .number()
117
+ .int()
118
+ .min(0)
119
+ .max(100)
120
+ .optional()
121
+ .describe("Skip threads whose OP trust score (0-100) is below this"),
122
+ },
123
+ async ({
124
+ urls,
125
+ include_posts,
126
+ include_code,
127
+ code_limit_per_thread,
128
+ fetch_all_pages,
129
+ post_content_chars,
130
+ min_op_rep,
131
+ exclude_negative_op,
132
+ min_op_trust,
133
+ }) => {
134
+ const results: unknown[] = [];
135
+ const skipped: Array<{ url: string; reason: string; opAuthor?: string; opReputation?: AuthorReputation }> = [];
136
+ let successCount = 0;
137
+ let errorCount = 0;
138
+
139
+ for (const url of urls) {
140
+ try {
141
+ validateUrl(url);
142
+
143
+ const firstHtml = await fetchHtml(url);
144
+ const first = parseThread(firstHtml, url, 1);
145
+ const opPost = first.posts[0];
146
+ const opRep = opPost?.reputation ?? null;
147
+
148
+ if (opRep) {
149
+ if (exclude_negative_op && (opRep.negativeDots > 0 || opRep.sign === "negative")) {
150
+ skipped.push({
151
+ url,
152
+ reason: "OP has negative reputation",
153
+ opAuthor: opPost?.author,
154
+ opReputation: opRep,
155
+ });
156
+ continue;
157
+ }
158
+ if (min_op_rep !== undefined && (opRep.score ?? 0) < min_op_rep) {
159
+ skipped.push({
160
+ url,
161
+ reason: `OP rep ${opRep.score ?? "unknown"} < min_op_rep ${min_op_rep}`,
162
+ opAuthor: opPost?.author,
163
+ opReputation: opRep,
164
+ });
165
+ continue;
166
+ }
167
+ if (min_op_trust !== undefined && opRep.trustScore < min_op_trust) {
168
+ skipped.push({
169
+ url,
170
+ reason: `OP trustScore ${opRep.trustScore} < min_op_trust ${min_op_trust}`,
171
+ opAuthor: opPost?.author,
172
+ opReputation: opRep,
173
+ });
174
+ continue;
175
+ }
176
+ }
177
+
178
+ let allPosts: ThreadPost[] = [...first.posts];
179
+ const pagesFetched: number[] = [1];
180
+
181
+ if (fetch_all_pages && first.totalPages > 1) {
182
+ const limit = Math.min(first.totalPages, MAX_PAGES_PER_THREAD);
183
+ for (let pageNum = 2; pageNum <= limit; pageNum++) {
184
+ const pageUrl = buildPageUrl(url, pageNum);
185
+ try {
186
+ const pageHtml = await fetchHtml(pageUrl);
187
+ const parsed = parseThread(pageHtml, pageUrl, pageNum);
188
+ allPosts.push(...parsed.posts);
189
+ pagesFetched.push(pageNum);
190
+ } catch (pageErr) {
191
+ console.error(`[bulk] Page ${pageNum} of ${url} failed:`, pageErr);
192
+ break;
193
+ }
194
+ }
195
+ }
196
+
197
+ const authors = aggregateAuthors(allPosts);
198
+
199
+ const threadResult: Record<string, unknown> = {
200
+ url,
201
+ title: first.title,
202
+ currentPagesFetched: pagesFetched,
203
+ totalPages: first.totalPages,
204
+ postCount: allPosts.length,
205
+ op: opPost
206
+ ? {
207
+ author: opPost.author,
208
+ date: opPost.date,
209
+ reputation: opRep,
210
+ }
211
+ : null,
212
+ authorScoring: {
213
+ uniqueAuthors: authors.length,
214
+ topAuthors: authors.slice(0, 5).map((a) => ({
215
+ author: a.author,
216
+ postCount: a.postCount,
217
+ trustScore: a.bestReputation?.trustScore ?? 0,
218
+ tier: a.bestReputation?.tier ?? "unknown",
219
+ sign: a.bestReputation?.sign ?? "unknown",
220
+ score: a.bestReputation?.score ?? null,
221
+ description: a.bestReputation?.description,
222
+ })),
223
+ flaggedAuthors: authors.filter((a) => a.hasNegative).map((a) => a.author),
224
+ },
225
+ };
226
+
227
+ if (include_posts) {
228
+ threadResult.posts = allPosts.map((post) => ({
229
+ author: post.author,
230
+ date: post.date,
231
+ postNumber: post.postNumber,
232
+ content: truncate(post.content, post_content_chars),
233
+ linkCount: post.links.length,
234
+ imageCount: post.images.length,
235
+ reputation: post.reputation
236
+ ? {
237
+ score: post.reputation.score,
238
+ power: post.reputation.power,
239
+ sign: post.reputation.sign,
240
+ tier: post.reputation.tier,
241
+ trustScore: post.reputation.trustScore,
242
+ description: post.reputation.description,
243
+ positiveDots: post.reputation.positiveDots,
244
+ highPositiveDots: post.reputation.highPositiveDots,
245
+ negativeDots: post.reputation.negativeDots,
246
+ }
247
+ : null,
248
+ }));
249
+ }
250
+
251
+ if (include_code) {
252
+ const codeBlocks = parseCodeBlocks(firstHtml)
253
+ .slice(0, code_limit_per_thread)
254
+ .map((block) => ({
255
+ ...block,
256
+ code: truncate(block.code, MAX_CODE_CHARS),
257
+ }));
258
+ threadResult.codeBlocks = codeBlocks;
259
+ threadResult.codeBlockCount = codeBlocks.length;
260
+ }
261
+
262
+ results.push(threadResult);
263
+ successCount++;
264
+ } catch (err) {
265
+ const message = err instanceof Error ? err.message : String(err);
266
+ results.push({ url, error: message });
267
+ errorCount++;
268
+ }
269
+ }
270
+
271
+ return {
272
+ content: [
273
+ {
274
+ type: "text",
275
+ text: JSON.stringify({
276
+ requested: urls.length,
277
+ succeeded: successCount,
278
+ failed: errorCount,
279
+ skipped: skipped.length,
280
+ skippedDetail: skipped,
281
+ threads: results,
282
+ }),
283
+ },
284
+ ],
285
+ };
286
+ }
287
+ );
288
+ }
@@ -0,0 +1,24 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { clearCache, getCacheStats } from "../crawl.js";
4
+
5
+ export function registerCacheControl(server: McpServer): void {
6
+ server.tool(
7
+ "crawl_cache",
8
+ "Inspect or clear the in-memory HTML cache used by crawler tools.",
9
+ {
10
+ action: z.enum(["stats", "clear"]).default("stats").describe("'stats' to inspect, 'clear' to purge"),
11
+ },
12
+ async ({ action }) => {
13
+ if (action === "clear") {
14
+ const removed = clearCache();
15
+ return {
16
+ content: [{ type: "text", text: JSON.stringify({ ok: true, removed }) }],
17
+ };
18
+ }
19
+ return {
20
+ content: [{ type: "text", text: JSON.stringify(getCacheStats()) }],
21
+ };
22
+ }
23
+ );
24
+ }
@@ -0,0 +1,41 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { load } from "cheerio";
3
+ import { navigateWithRetry } from "../browser.js";
4
+ import { isLoggedIn } from "../auth.js";
5
+
6
+ const UC_HOME = "https://www.unknowncheats.me/forum/";
7
+
8
+ export function registerCheckLogin(server: McpServer): void {
9
+ server.tool("check_login", "Check if the browser session is logged into UnknownCheats", {}, async () => {
10
+ try {
11
+ const { html } = await navigateWithRetry(UC_HOME);
12
+ const $ = load(html);
13
+
14
+ const loggedIn = isLoggedIn(html);
15
+
16
+ let username: string | undefined;
17
+ if (loggedIn) {
18
+ // Try common vBulletin welcome selectors
19
+ const welcomeEl = $("#welcomelink, .welcomelink, #userlinks .bolds").first();
20
+ const welcomeText = welcomeEl.text().trim();
21
+ const match = welcomeText.match(/Welcome,?\s+(.+)/i);
22
+ if (match) {
23
+ username = match[1].replace(/[!.]+$/, "").trim();
24
+ } else {
25
+ // Fallback: grab username from user CP link text
26
+ username = $('a[href*="usercp.php"]').first().text().trim() || undefined;
27
+ }
28
+ }
29
+
30
+ return {
31
+ content: [{ type: "text", text: JSON.stringify({ loggedIn, username }) }],
32
+ };
33
+ } catch (err) {
34
+ const message = err instanceof Error ? err.message : String(err);
35
+ return {
36
+ content: [{ type: "text", text: `Error: ${message}` }],
37
+ isError: true,
38
+ };
39
+ }
40
+ });
41
+ }
@@ -0,0 +1,128 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { fetchHtml } from "../crawl.js";
4
+ import {
5
+ filterThreads,
6
+ parsePaginationInfo,
7
+ parseThreadList,
8
+ type ThreadListEntry,
9
+ } from "../parsers/thread-list.js";
10
+
11
+ const HARD_PAGE_CAP = 30;
12
+
13
+ function buildPageUrl(subforum: string, page: number): string {
14
+ const base = `https://www.unknowncheats.me/forum/${subforum}/`;
15
+ if (page <= 1) return base;
16
+ return `${base}index${page}.html`;
17
+ }
18
+
19
+ export function registerCrawlSubforum(server: McpServer): void {
20
+ server.tool(
21
+ "crawl_subforum",
22
+ "Walk multiple pages of a subforum thread listing with filters (query, min_replies, min_views, author, prefix). Great for building a corpus of relevant threads.",
23
+ {
24
+ subforum: z.string().describe("Subforum slug (e.g. 'apex-legends'). Use list_subforums to discover."),
25
+ max_pages: z
26
+ .number()
27
+ .int()
28
+ .min(1)
29
+ .max(HARD_PAGE_CAP)
30
+ .optional()
31
+ .default(3)
32
+ .describe(`How many pages of the subforum to walk (max ${HARD_PAGE_CAP}, default 3)`),
33
+ query: z.string().optional().describe("Optional keyword filter on title/snippet (AND semantics)"),
34
+ min_replies: z.number().int().min(0).optional().describe("Only include threads with at least this many replies"),
35
+ min_views: z.number().int().min(0).optional().describe("Only include threads with at least this many views"),
36
+ author: z.string().optional().describe("Only include threads by this author (case-insensitive substring)"),
37
+ prefix: z.string().optional().describe("Only include threads with this prefix label"),
38
+ include_sticky: z.boolean().optional().default(false).describe("Include sticky/announcement threads (default false)"),
39
+ sort_by: z
40
+ .enum(["default", "replies", "views", "title"])
41
+ .optional()
42
+ .default("default")
43
+ .describe("Sort the collected results locally"),
44
+ limit: z.number().int().min(1).max(500).optional().default(100).describe("Max threads returned after filtering/sorting"),
45
+ },
46
+ async ({ subforum, max_pages, query, min_replies, min_views, author, prefix, include_sticky, sort_by, limit }) => {
47
+ try {
48
+ const pagesWalked: number[] = [];
49
+ const collected: ThreadListEntry[] = [];
50
+ const seen = new Set<string>();
51
+ let totalPagesAvailable = 1;
52
+
53
+ for (let page = 1; page <= max_pages; page++) {
54
+ const url = buildPageUrl(subforum, page);
55
+ let html: string;
56
+ try {
57
+ html = await fetchHtml(url);
58
+ } catch (err) {
59
+ const message = err instanceof Error ? err.message : String(err);
60
+ console.error(`[crawl-subforum] Failed page ${page}: ${message}`);
61
+ break;
62
+ }
63
+
64
+ const pageInfo = parsePaginationInfo(html);
65
+ totalPagesAvailable = pageInfo.totalPages;
66
+ pagesWalked.push(page);
67
+
68
+ const threads = parseThreadList(html);
69
+ for (const thread of threads) {
70
+ if (seen.has(thread.url)) continue;
71
+ seen.add(thread.url);
72
+ collected.push(thread);
73
+ }
74
+
75
+ if (page >= totalPagesAvailable) break;
76
+ }
77
+
78
+ const filtered = filterThreads(collected, {
79
+ query,
80
+ minReplies: min_replies,
81
+ minViews: min_views,
82
+ author,
83
+ prefix,
84
+ includeSticky: include_sticky,
85
+ });
86
+
87
+ const sorted = [...filtered];
88
+ if (sort_by === "replies") sorted.sort((a, b) => b.replies - a.replies);
89
+ else if (sort_by === "views") sorted.sort((a, b) => b.views - a.views);
90
+ else if (sort_by === "title") sorted.sort((a, b) => a.title.localeCompare(b.title));
91
+
92
+ const capped = sorted.slice(0, limit);
93
+
94
+ return {
95
+ content: [
96
+ {
97
+ type: "text",
98
+ text: JSON.stringify({
99
+ subforum,
100
+ pagesWalked,
101
+ totalPagesAvailable,
102
+ collected: collected.length,
103
+ matched: filtered.length,
104
+ returned: capped.length,
105
+ filter: {
106
+ query,
107
+ min_replies,
108
+ min_views,
109
+ author,
110
+ prefix,
111
+ include_sticky,
112
+ sort_by,
113
+ },
114
+ results: capped,
115
+ }),
116
+ },
117
+ ],
118
+ };
119
+ } catch (err) {
120
+ const message = err instanceof Error ? err.message : String(err);
121
+ return {
122
+ content: [{ type: "text", text: `Error: ${message}` }],
123
+ isError: true,
124
+ };
125
+ }
126
+ }
127
+ );
128
+ }
@@ -0,0 +1,102 @@
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
+
6
+ export function registerDebugPage(server: McpServer): void {
7
+ server.tool(
8
+ "debug_page",
9
+ "Fetch a page and return its HTML snippet + key element selectors for debugging",
10
+ {
11
+ url: z.string().url().describe("URL to inspect"),
12
+ selector: z.string().optional().describe("CSS selector to extract (returns matched outerHTML)"),
13
+ },
14
+ async ({ url, selector }) => {
15
+ try {
16
+ const { html } = await navigateWithRetry(url);
17
+ const $ = load(html);
18
+
19
+ if (selector) {
20
+ const matched: string[] = [];
21
+ $(selector)
22
+ .slice(0, 5)
23
+ .each((_, el) => {
24
+ matched.push($.html(el)?.slice(0, 500) ?? "");
25
+ });
26
+ return {
27
+ content: [
28
+ {
29
+ type: "text",
30
+ text: JSON.stringify({ selector, count: $(selector).length, samples: matched }),
31
+ },
32
+ ],
33
+ };
34
+ }
35
+
36
+ // Return structural overview: tag names + classes of top-level body children
37
+ const structure: string[] = [];
38
+ $("body")
39
+ .children()
40
+ .slice(0, 30)
41
+ .each((_, el) => {
42
+ const e = $(el);
43
+ const id = e.attr("id") ? `#${e.attr("id")}` : "";
44
+ const cls = e.attr("class")
45
+ ? "." +
46
+ e
47
+ .attr("class")!
48
+ .trim()
49
+ .split(/\s+/)
50
+ .slice(0, 3)
51
+ .join(".")
52
+ : "";
53
+ structure.push(`<${el.tagName}${id}${cls}>`);
54
+ });
55
+
56
+ // Also grab title and first post candidate
57
+ const titleCandidates = [
58
+ { sel: "h1", text: $("h1").first().text().trim() },
59
+ { sel: ".threadtitle", text: $(".threadtitle").first().text().trim() },
60
+ { sel: "#pagetitle", text: $("#pagetitle").first().text().trim() },
61
+ { sel: "title", text: $("title").first().text().trim() },
62
+ ];
63
+
64
+ const postCandidates = [
65
+ { sel: ".postcontainer", count: $(".postcontainer").length },
66
+ { sel: ".postbitlegacy", count: $(".postbitlegacy").length },
67
+ { sel: "li[id^='post_']", count: $("li[id^='post_']").length },
68
+ { sel: "div[id^='post_']", count: $("div[id^='post_']").length },
69
+ { sel: ".message", count: $(".message").length },
70
+ { sel: "article", count: $("article").length },
71
+ { sel: ".post", count: $(".post").length },
72
+ { sel: "td.alt1", count: $("td.alt1").length },
73
+ ];
74
+
75
+ const paginationCandidates = [
76
+ { sel: ".pagination", html: $(".pagination").first().html()?.slice(0, 300) },
77
+ { sel: ".pagenav", html: $(".pagenav").first().html()?.slice(0, 300) },
78
+ { sel: "nav", html: $("nav").first().html()?.slice(0, 300) },
79
+ ];
80
+
81
+ return {
82
+ content: [
83
+ {
84
+ type: "text",
85
+ text: JSON.stringify(
86
+ { structure, titleCandidates, postCandidates, paginationCandidates },
87
+ null,
88
+ 2
89
+ ),
90
+ },
91
+ ],
92
+ };
93
+ } catch (err) {
94
+ const message = err instanceof Error ? err.message : String(err);
95
+ return {
96
+ content: [{ type: "text", text: `Error: ${message}` }],
97
+ isError: true,
98
+ };
99
+ }
100
+ }
101
+ );
102
+ }