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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 amaralkaff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # mcp-unknowncheat
2
+
3
+ A TypeScript MCP server for reading and searching the [UnknownCheats](https://www.unknowncheats.me) forum. It uses Bun, a local Chrome window, and Cheerio to parse forum pages.
4
+
5
+ ## Install
6
+
7
+ Install [Bun](https://bun.sh) and Chrome or Chromium. Run the npm package with:
8
+
9
+ ```sh
10
+ bunx mcp-unknowncheatz
11
+ ```
12
+
13
+ To run the source:
14
+
15
+ ```sh
16
+ git clone https://github.com/amangly/mcp-unknowncheat.git
17
+ cd mcp-unknowncheat
18
+ bun install --frozen-lockfile
19
+ bun run start
20
+ ```
21
+
22
+ The server uses MCP over standard input and output. Chrome opens when a tool first needs a page. You can log in with the `login` tool; session cookies are saved locally in `cookies.json`.
23
+
24
+ ## Tools
25
+
26
+ | Tool | Purpose |
27
+ |---|---|
28
+ | `check_login` | Check session status |
29
+ | `login` | Log in with a username and password |
30
+ | `search_forum` | Search threads or browse a subforum |
31
+ | `get_thread` | Read posts and pages in a thread |
32
+ | `extract_code` | Extract code blocks from a thread |
33
+ | `download_file` | Download and inspect an attachment |
34
+ | `list_subforums` | List forum sections from a 24-hour local directory; use `refresh: true` to rebuild it |
35
+ | `crawl_subforum` | Collect threads from subforum pages |
36
+ | `bulk_get_threads` | Read several threads |
37
+ | `get_user_reputation` | Read reputation details |
38
+ | `find_latest_offsets` | Find a game's offsets thread in the forum and scan recent pages backward |
39
+ | `debug_page` | Inspect page structure |
40
+ | `crawl_cache` | Inspect or clear the HTML cache |
41
+
42
+ The MCP tool schemas provide the available arguments. For example:
43
+
44
+ ```text
45
+ search_forum({ query: "example" })
46
+ get_thread({ url: "https://www.unknowncheats.me/forum/showthread.php?t=123" })
47
+ find_latest_offsets({ game: "Apex Legends" })
48
+ ```
49
+
50
+ 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 include the listing URL, scanned pages, and source post. 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`.
51
+
52
+ ## Development
53
+
54
+ ```sh
55
+ bun run typecheck
56
+ bun run test
57
+ bun run build
58
+ ```
59
+
60
+ The browser code and tools are in `src/`; HTML parsers are in `src/parsers/`. `downloads/`, `exports/`, `cookies.json`, and `forum-index.json` are local output ignored by Git.
61
+
62
+ ## Configuration
63
+
64
+ | Variable | Default | Purpose |
65
+ |---|---:|---|
66
+ | `UC_CF_WAIT_MS` | `15000` | Time to wait for a Cloudflare challenge, in milliseconds |
67
+ | `UC_CACHE_TTL_MS` | `300000` | HTML cache lifetime, in milliseconds |
68
+ | `UC_MIN_REQUEST_INTERVAL_MS` | `900` | Minimum interval between crawl requests, in milliseconds |
69
+
70
+ The npm package is [mcp-unknowncheatz](https://www.npmjs.com/package/mcp-unknowncheatz). Report bugs in [GitHub Issues](https://github.com/amangly/mcp-unknowncheat/issues).
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "mcp-unknowncheatz",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "description": "MCP server for searching and reading the UnknownCheats forum",
6
+ "bin": {
7
+ "mcp-unknowncheatz": "src/index.ts"
8
+ },
9
+ "keywords": [
10
+ "mcp",
11
+ "unknowncheats",
12
+ "puppeteer",
13
+ "cloudflare",
14
+ "scraper",
15
+ "ai"
16
+ ],
17
+ "author": "amangly",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/amangly/mcp-unknowncheat.git"
22
+ },
23
+ "homepage": "https://github.com/amangly/mcp-unknowncheat",
24
+ "bugs": {
25
+ "url": "https://github.com/amangly/mcp-unknowncheat/issues"
26
+ },
27
+ "engines": {
28
+ "bun": ">=1.0.0"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "start": "bun run src/index.ts",
37
+ "dev": "bun --watch src/index.ts",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "bun test",
40
+ "build": "bun build src/index.ts --target=bun --outdir=dist"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.30.1",
44
+ "cheerio": "^1.2.0",
45
+ "puppeteer-real-browser": "^1.4.4",
46
+ "zod": "^4.6.5"
47
+ },
48
+ "devDependencies": {
49
+ "@types/bun": "^1.4.2",
50
+ "@types/node": "^26.6.2",
51
+ "typescript": "^7.0.2"
52
+ }
53
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { load } from "cheerio";
2
+
3
+ export function isLoggedIn(html: string): boolean {
4
+ const $ = load(html);
5
+ return $('a[href*="login.php?do=logout"]').length > 0;
6
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { connect } from "puppeteer-real-browser";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const COOKIES_PATH = path.join(__dirname, "..", "cookies.json");
7
+ const CLOUDFLARE_INDICATORS = ["Just a moment", "cf-browser-verification", "Checking your browser"];
8
+ const NAV_TIMEOUT = 30_000;
9
+ const NAV_TIMEOUT_RETRY = 60_000;
10
+ const CF_WAIT_MS = Number(process.env.UC_CF_WAIT_MS ?? 15_000);
11
+
12
+ function useRealDisplay(): boolean {
13
+ return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
14
+ }
15
+
16
+ const ALLOWED_HOSTS = new Set(["www.unknowncheats.me", "unknowncheats.me"]);
17
+
18
+ export function validateUrl(url: string): void {
19
+ let parsed: URL;
20
+ try {
21
+ parsed = new URL(url);
22
+ } catch {
23
+ throw new Error(`Invalid URL: ${url}`);
24
+ }
25
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
26
+ throw new Error(`Blocked URL scheme: ${parsed.protocol} — only http/https allowed`);
27
+ }
28
+ if (!ALLOWED_HOSTS.has(parsed.hostname)) {
29
+ throw new Error(`Blocked URL host: ${parsed.hostname} — only unknowncheats.me is allowed`);
30
+ }
31
+ }
32
+
33
+ type BrowserInstance = {
34
+ browser: Awaited<ReturnType<typeof connect>>["browser"];
35
+ page: Awaited<ReturnType<typeof connect>>["page"];
36
+ };
37
+
38
+ let instance: BrowserInstance | null = null;
39
+
40
+ async function loadCookies(page: BrowserInstance["page"]): Promise<void> {
41
+ try {
42
+ const file = Bun.file(COOKIES_PATH);
43
+ if (await file.exists()) {
44
+ const cookies = await file.json();
45
+ if (Array.isArray(cookies) && cookies.length > 0) {
46
+ await page.setCookie(...cookies);
47
+ console.error("[browser] Loaded cookies from", COOKIES_PATH);
48
+ }
49
+ }
50
+ } catch (err) {
51
+ console.error("[browser] Cookie load failed (starting fresh):", err);
52
+ }
53
+ }
54
+
55
+ async function saveCookies(page: BrowserInstance["page"]): Promise<void> {
56
+ try {
57
+ const cookies = await page.cookies();
58
+ await Bun.write(COOKIES_PATH, JSON.stringify(cookies, null, 2));
59
+ } catch (err) {
60
+ console.error("[browser] Cookie save failed:", err);
61
+ }
62
+ }
63
+
64
+ async function launchBrowser(): Promise<BrowserInstance> {
65
+ console.error("[browser] Launching Chrome...");
66
+ const onWayland = process.env.XDG_SESSION_TYPE === "wayland" || !!process.env.WAYLAND_DISPLAY;
67
+ const { browser, page } = await connect({
68
+ headless: false,
69
+ turnstile: true,
70
+ args: onWayland ? ["--ozone-platform=wayland", "--start-maximized"] : ["--start-maximized"],
71
+ customConfig: {},
72
+ connectOption: {
73
+ defaultViewport: null,
74
+ },
75
+ disableXvfb: useRealDisplay(),
76
+ });
77
+
78
+ browser.on("disconnected", () => {
79
+ console.error("[browser] Browser disconnected");
80
+ instance = null;
81
+ });
82
+
83
+ await loadCookies(page);
84
+ return { browser, page };
85
+ }
86
+
87
+ export async function getPage(): Promise<BrowserInstance["page"]> {
88
+ if (!instance) {
89
+ instance = await launchBrowser();
90
+ }
91
+ return instance.page;
92
+ }
93
+
94
+ export async function ensureFreshBrowser(): Promise<BrowserInstance["page"]> {
95
+ if (instance) {
96
+ try {
97
+ await instance.browser.close();
98
+ } catch {
99
+ // ignore — may already be dead
100
+ }
101
+ instance = null;
102
+ }
103
+ instance = await launchBrowser();
104
+ return instance.page;
105
+ }
106
+
107
+ function hasCloudflareChallenge(html: string): boolean {
108
+ return CLOUDFLARE_INDICATORS.some((indicator) => html.includes(indicator));
109
+ }
110
+
111
+ function isDetachedError(err: unknown): boolean {
112
+ if (!(err instanceof Error)) return false;
113
+ const msg = err.message;
114
+ return (
115
+ msg.includes("Detached Frame") ||
116
+ msg.includes("Execution context was destroyed") ||
117
+ msg.includes("Target closed") ||
118
+ msg.includes("Session closed")
119
+ );
120
+ }
121
+
122
+ function isNavigationAbortError(err: unknown): boolean {
123
+ if (!(err instanceof Error)) return false;
124
+ return err.message.includes("ERR_ABORTED") || err.message.includes("Navigation failed");
125
+ }
126
+
127
+ export async function navigateWithRetry(url: string): Promise<{ page: BrowserInstance["page"]; html: string }> {
128
+ validateUrl(url);
129
+ let page = await getPage();
130
+ let navRetried = false;
131
+ let detachedRetried = false;
132
+
133
+ const attempt = async (timeout: number, waitUntil: "networkidle2" | "domcontentloaded" = "networkidle2"): Promise<string> => {
134
+ await page.goto(url, { waitUntil, timeout });
135
+
136
+ let html = await page.content();
137
+
138
+ if (hasCloudflareChallenge(html)) {
139
+ console.error("[browser] Cloudflare challenge detected, waiting", CF_WAIT_MS, "ms...");
140
+ await new Promise((res) => setTimeout(res, CF_WAIT_MS));
141
+ html = await page.content();
142
+
143
+ if (hasCloudflareChallenge(html)) {
144
+ throw new Error("CloudflareBlockError: Challenge did not resolve after waiting");
145
+ }
146
+ }
147
+
148
+ await saveCookies(page);
149
+ return html;
150
+ };
151
+
152
+ try {
153
+ const html = await attempt(NAV_TIMEOUT);
154
+ return { page, html };
155
+ } catch (err) {
156
+ if (isNavigationAbortError(err) && !navRetried) {
157
+ console.error("[browser] Navigation aborted (often ads), retrying with domcontentloaded...");
158
+ navRetried = true;
159
+ const html = await attempt(NAV_TIMEOUT_RETRY, "domcontentloaded");
160
+ return { page, html };
161
+ }
162
+ if (isDetachedError(err) && !detachedRetried) {
163
+ console.error("[browser] Detached frame error, relaunching browser and retrying...");
164
+ detachedRetried = true;
165
+ page = await ensureFreshBrowser();
166
+ const html = await attempt(NAV_TIMEOUT_RETRY);
167
+ return { page, html };
168
+ }
169
+ throw err;
170
+ }
171
+ }
172
+
173
+ export async function closeBrowser(): Promise<void> {
174
+ if (instance) {
175
+ try {
176
+ await instance.browser.close();
177
+ } catch {
178
+ // ignore
179
+ }
180
+ instance = null;
181
+ }
182
+ }
package/src/crawl.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { navigateWithRetry, validateUrl } from "./browser.js";
2
+
3
+ const CACHE_TTL_MS = Number(process.env.UC_CACHE_TTL_MS ?? 5 * 60_000);
4
+ const MIN_REQUEST_INTERVAL_MS = Number(process.env.UC_MIN_REQUEST_INTERVAL_MS ?? 900);
5
+ const CACHE_MAX_ENTRIES = 128;
6
+
7
+ interface CacheEntry {
8
+ html: string;
9
+ timestamp: number;
10
+ }
11
+
12
+ const cache = new Map<string, CacheEntry>();
13
+ let lastRequestAt = 0;
14
+
15
+ function evictIfNeeded(): void {
16
+ if (cache.size <= CACHE_MAX_ENTRIES) return;
17
+ const oldestKey = cache.keys().next().value;
18
+ if (oldestKey !== undefined) cache.delete(oldestKey);
19
+ }
20
+
21
+ async function throttle(): Promise<void> {
22
+ const now = Date.now();
23
+ const elapsed = now - lastRequestAt;
24
+ if (elapsed < MIN_REQUEST_INTERVAL_MS) {
25
+ await new Promise((r) => setTimeout(r, MIN_REQUEST_INTERVAL_MS - elapsed));
26
+ }
27
+ lastRequestAt = Date.now();
28
+ }
29
+
30
+ export interface FetchOptions {
31
+ bypassCache?: boolean;
32
+ cacheOverrideTtlMs?: number;
33
+ }
34
+
35
+ export async function fetchHtml(url: string, opts: FetchOptions = {}): Promise<string> {
36
+ validateUrl(url);
37
+ const ttl = opts.cacheOverrideTtlMs ?? CACHE_TTL_MS;
38
+
39
+ if (!opts.bypassCache) {
40
+ const cached = cache.get(url);
41
+ if (cached && Date.now() - cached.timestamp < ttl) {
42
+ console.error(`[crawl] Cache hit: ${url}`);
43
+ return cached.html;
44
+ }
45
+ }
46
+
47
+ await throttle();
48
+ const { html } = await navigateWithRetry(url);
49
+ cache.set(url, { html, timestamp: Date.now() });
50
+ evictIfNeeded();
51
+ return html;
52
+ }
53
+
54
+ export function clearCache(): number {
55
+ const size = cache.size;
56
+ cache.clear();
57
+ return size;
58
+ }
59
+
60
+ export function getCacheStats(): { entries: number; ttlMs: number; minIntervalMs: number } {
61
+ return { entries: cache.size, ttlMs: CACHE_TTL_MS, minIntervalMs: MIN_REQUEST_INTERVAL_MS };
62
+ }
@@ -0,0 +1,36 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { parseSubforums, type Subforum } from "./parsers/subforums.js";
4
+
5
+ export const FORUM_INDEX = "https://www.unknowncheats.me/forum/index.php";
6
+ const CATALOG_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "forum-index.json");
7
+ const CATALOG_TTL_MS = 24 * 60 * 60_000;
8
+
9
+ export interface ForumCatalog {
10
+ source: string;
11
+ indexedAt: string;
12
+ subforums: Subforum[];
13
+ }
14
+
15
+ export async function readForumCatalog(): Promise<ForumCatalog | null> {
16
+ try {
17
+ const catalog: ForumCatalog = await Bun.file(CATALOG_PATH).json();
18
+ const age = Date.now() - Date.parse(catalog.indexedAt);
19
+ if (catalog.source !== FORUM_INDEX || !Number.isFinite(age) || age < 0 || age > CATALOG_TTL_MS ||
20
+ !Array.isArray(catalog.subforums) || !catalog.subforums.every((item) =>
21
+ typeof item.slug === "string" && typeof item.label === "string" && typeof item.url === "string")) {
22
+ return null;
23
+ }
24
+ return catalog;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export async function saveForumCatalog(html: string): Promise<ForumCatalog> {
31
+ const subforums = parseSubforums(html);
32
+ if (subforums.length === 0) throw new Error("Forum index contained no subforums");
33
+ const catalog = { source: FORUM_INDEX, indexedAt: new Date().toISOString(), subforums };
34
+ await Bun.write(CATALOG_PATH, JSON.stringify(catalog, null, 2));
35
+ return catalog;
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bun
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { closeBrowser } from "./browser.js";
5
+ import packageJson from "../package.json" with { type: "json" };
6
+ import { registerCheckLogin } from "./tools/check-login.js";
7
+ import { registerLogin } from "./tools/login.js";
8
+ import { registerSearchForum } from "./tools/search-forum.js";
9
+ import { registerGetThread } from "./tools/get-thread.js";
10
+ import { registerExtractCode } from "./tools/extract-code.js";
11
+ import { registerDebugPage } from "./tools/debug-page.js";
12
+ import { registerDownloadFile } from "./tools/download-file.js";
13
+ import { registerListSubforums } from "./tools/list-subforums.js";
14
+ import { registerCrawlSubforum } from "./tools/crawl-subforum.js";
15
+ import { registerBulkGetThreads } from "./tools/bulk-get-threads.js";
16
+ import { registerCacheControl } from "./tools/cache-control.js";
17
+ import { registerGetUserReputation } from "./tools/get-user-reputation.js";
18
+ import { registerFindLatestOffsets } from "./tools/find-latest-offsets.js";
19
+
20
+ const server = new McpServer(
21
+ { name: "mcp-unknowncheat", version: packageJson.version },
22
+ { instructions: "For the newest offsets for any game, call find_latest_offsets with the game name. It discovers the game forum and offset thread from live listings, then scans recent posts backward. Report the source post and scan coverage. Do not present a found post as a verified current game offset." }
23
+ );
24
+
25
+ // Register all tools
26
+ registerCheckLogin(server);
27
+ registerLogin(server);
28
+ registerSearchForum(server);
29
+ registerGetThread(server);
30
+ registerExtractCode(server);
31
+ registerDebugPage(server);
32
+ registerDownloadFile(server);
33
+ registerListSubforums(server);
34
+ registerCrawlSubforum(server);
35
+ registerBulkGetThreads(server);
36
+ registerCacheControl(server);
37
+ registerGetUserReputation(server);
38
+ registerFindLatestOffsets(server);
39
+
40
+ // Graceful shutdown
41
+ async function shutdown() {
42
+ console.error("[server] Shutting down...");
43
+ await closeBrowser();
44
+ process.exit(0);
45
+ }
46
+
47
+ process.on("SIGINT", shutdown);
48
+ process.on("SIGTERM", shutdown);
49
+
50
+ // Connect stdio transport (IMPORTANT: never write to stdout except via MCP)
51
+ const transport = new StdioServerTransport();
52
+ await server.connect(transport);
53
+
54
+ console.error("[server] mcp-unknowncheat started");
@@ -0,0 +1,60 @@
1
+ import type { Subforum } from "./parsers/subforums.js";
2
+ import type { ThreadListEntry } from "./parsers/thread-list.js";
3
+ import type { ThreadPost } from "./types.js";
4
+
5
+ export function normalizeName(value: string): string {
6
+ return value.toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, " ").trim();
7
+ }
8
+
9
+ export function rankGameForums(game: string, forums: Subforum[]): Subforum[] {
10
+ const query = normalizeName(game);
11
+ if (!query) return [];
12
+ const terms = query.split(" ");
13
+ return forums
14
+ .map((forum) => {
15
+ const label = normalizeName(forum.label);
16
+ const slug = normalizeName(forum.slug);
17
+ const score = label === query || slug === query
18
+ ? 100
19
+ : label.startsWith(`${query} `) || slug.startsWith(`${query} `)
20
+ ? 50
21
+ : terms.every((term) => label.split(" ").includes(term) || slug.split(" ").includes(term))
22
+ ? 10
23
+ : 0;
24
+ return { forum, score };
25
+ })
26
+ .filter(({ score }) => score > 0)
27
+ .sort((a, b) => b.score - a.score || a.forum.label.length - b.forum.label.length)
28
+ .map(({ forum }) => forum);
29
+ }
30
+
31
+ export type OffsetThread = ThreadListEntry & { listingPage: string; score: number };
32
+
33
+ export function rankOffsetThreads(threads: ThreadListEntry[], listingPage: string): OffsetThread[] {
34
+ return threads
35
+ .map((thread) => {
36
+ const title = thread.title.toLowerCase();
37
+ const score = (/\boffsets?\b/.test(title) ? 5 : 0) +
38
+ (/\breversal\b/.test(title) ? 4 : 0) +
39
+ (/\bstructs?\b/.test(title) ? 2 : 0) +
40
+ (/\b(?:sigs?|signatures?)\b/.test(title) ? 2 : 0);
41
+ return { ...thread, listingPage, score };
42
+ })
43
+ .filter(({ score }) => score >= 4)
44
+ .sort((a, b) => b.score - a.score || b.replies - a.replies);
45
+ }
46
+
47
+ export function containsOffsetUpdate(post: ThreadPost): boolean {
48
+ const firstValue = post.content.search(/\b0x[0-9a-f]{3,}\b/i);
49
+ const firstLink = post.content.search(/https:\/\/(?:www\.)?(?:pastebin\.com|pastes\.dev)\//i);
50
+ const evidenceAt = [firstValue, firstLink].filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? 200;
51
+ const introduction = post.content.slice(0, Math.min(evidenceAt, 200));
52
+ if (/\?|\b(?:anyone|looking for|need|requesting)\b/i.test(introduction)) {
53
+ return false;
54
+ }
55
+ const terms = /\b(offsets?|signatures?|sigs?|dump|patch)\b/i;
56
+ const pasteLink = post.links.some(({ url }) => /^https:\/\/(?:www\.)?(?:pastebin\.com|pastes\.dev)\//i.test(url));
57
+ const hexAssignments = post.content.match(/=\s*0x[0-9a-f]{3,}\b/gi)?.length ?? 0;
58
+ return (pasteLink && terms.test(post.content)) ||
59
+ (hexAssignments >= 3 && /\boffsets?\s*[:{]|\bconstexpr\b|\b(?:OFF_|dw[A-Z]|m_)/i.test(post.content));
60
+ }
@@ -0,0 +1,59 @@
1
+ import { load } from "cheerio";
2
+ import type { CodeBlock } from "../types.js";
3
+
4
+ const CPP_PATTERNS = [/#include\s*[<"]/, /\bstd::/, /\bnullptr\b/, /\bcout\b/, /\bcin\b/, /\bvoid\s+\w+\s*\(/, /\bconstexpr\b/, /\bDWORD\b/, /\buint64_t\b/, /\buint32_t\b/, /#define\s+\w+/, /\bULONG\b/, /\bINT64\b/];
5
+ const CSHARP_PATTERNS = [/\busing\s+System\b/, /\bnamespace\s+\w+/, /\bpublic\s+class\b/, /Console\.Write/];
6
+ const PYTHON_PATTERNS = [/\bdef\s+\w+\s*\(/, /\bimport\s+\w+/, /\bprint\s*\(/, /\bself\./, /\b__init__\b/];
7
+ const LUA_PATTERNS = [/\bfunction\s+\w+\s*\(/, /\blocal\s+\w+/, /\brequire\s*\(/, /\bend\b/];
8
+
9
+ function detectLanguage(code: string, cssClass?: string): string {
10
+ if (cssClass) {
11
+ const cls = cssClass.toLowerCase();
12
+ if (cls.includes("cpp") || cls.includes("c++")) return "cpp";
13
+ if (cls.includes("csharp") || cls.includes("c#")) return "csharp";
14
+ if (cls.includes("python") || cls.includes("py")) return "python";
15
+ if (cls.includes("lua")) return "lua";
16
+ }
17
+
18
+ const score = (patterns: RegExp[]) => patterns.filter((p) => p.test(code)).length;
19
+
20
+ const scores: [string, number][] = [
21
+ ["cpp", score(CPP_PATTERNS)],
22
+ ["csharp", score(CSHARP_PATTERNS)],
23
+ ["python", score(PYTHON_PATTERNS)],
24
+ ["lua", score(LUA_PATTERNS)],
25
+ ];
26
+
27
+ const best = scores.reduce((a, b) => (b[1] > a[1] ? b : a));
28
+ return best[1] > 0 ? best[0] : "unknown";
29
+ }
30
+
31
+ export function parseCodeBlocks(html: string): CodeBlock[] {
32
+ const $ = load(html);
33
+ const blocks: CodeBlock[] = [];
34
+ const seen = new Set<string>();
35
+
36
+ // vBulletin highlight blocks, pre, and code tags
37
+ $(".highlight, pre, code").each((_, el) => {
38
+ const element = $(el);
39
+ const code = element.text().trim();
40
+
41
+ if (!code || code.length < 10 || seen.has(code)) return;
42
+ seen.add(code);
43
+
44
+ const cssClass = element.attr("class") ?? "";
45
+ const language = detectLanguage(code, cssClass);
46
+
47
+ // Grab surrounding context (previous sibling text, up to 100 chars)
48
+ const contextEl = element.prev();
49
+ const context = contextEl.length ? contextEl.text().trim().slice(0, 100) : undefined;
50
+
51
+ // Find ancestor post ID (vBulletin: table[id^='post'])
52
+ const postAncestor = element.closest("table[id]").filter((_, el) => /^post\d+$/.test($(el).attr("id") ?? ""));
53
+ const postId = postAncestor.length ? postAncestor.attr("id") : undefined;
54
+
55
+ blocks.push({ code, language, context, postId });
56
+ });
57
+
58
+ return blocks;
59
+ }