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,130 @@
1
+ import type { Cheerio, CheerioAPI } from "cheerio";
2
+ import type { Element } from "domhandler";
3
+
4
+ export type RepSign = "positive" | "negative" | "neutral" | "unknown";
5
+ export type RepTier =
6
+ | "legend"
7
+ | "excellent"
8
+ | "great"
9
+ | "good"
10
+ | "average"
11
+ | "positive"
12
+ | "novice"
13
+ | "neutral"
14
+ | "negative"
15
+ | "unknown";
16
+
17
+ export interface AuthorReputation {
18
+ score: number | null;
19
+ power: number | null;
20
+ dots: number;
21
+ positiveDots: number;
22
+ highPositiveDots: number;
23
+ negativeDots: number;
24
+ neutralDots: number;
25
+ sign: RepSign;
26
+ tier: RepTier;
27
+ description?: string;
28
+ trustScore: number;
29
+ }
30
+
31
+ function classifyBySrc(src: string): "highpos" | "pos" | "neg" | "neutral" | "unknown" {
32
+ const s = src.toLowerCase();
33
+ if (s.includes("reputation_highpos") || s.includes("rep_highpos")) return "highpos";
34
+ if (s.includes("reputation_pos") || s.includes("rep_pos")) return "pos";
35
+ if (s.includes("reputation_neg") || s.includes("rep_neg")) return "neg";
36
+ if (s.includes("reputation_bar") || s.includes("rep_bar")) return "neutral";
37
+ return "unknown";
38
+ }
39
+
40
+ function computeTier(score: number | null, sign: RepSign, negativeDots: number): RepTier {
41
+ if (sign === "negative" || negativeDots > 0) return "negative";
42
+ if (score === null) return sign === "neutral" ? "neutral" : "unknown";
43
+
44
+ if (score >= 5_000) return "legend";
45
+ if (score >= 2_000) return "excellent";
46
+ if (score >= 1_000) return "great";
47
+ if (score >= 500) return "good";
48
+ if (score >= 100) return "average";
49
+ if (score >= 10) return "positive";
50
+ if (score > 0) return "novice";
51
+ if (score === 0) return "neutral";
52
+ return "negative";
53
+ }
54
+
55
+ function computeTrustScore(score: number | null, negativeDots: number, dots: number): number {
56
+ if (negativeDots > 0) return 5;
57
+
58
+ const base = score ?? 0;
59
+ if (base <= 0) return dots > 0 ? 25 : 15;
60
+
61
+ const log = Math.log10(base + 1) * 20;
62
+ const clamped = Math.min(100, Math.round(log));
63
+ return Math.max(15, clamped);
64
+ }
65
+
66
+ export function parseReputationInPost($: CheerioAPI, postCell: Cheerio<Element>): AuthorReputation {
67
+ const infoText = postCell.find(".info").text();
68
+
69
+ const scoreMatch = infoText.match(/Reputation:\s*([-\d,]+)/i);
70
+ const score = scoreMatch ? parseInt(scoreMatch[1].replace(/,/g, ""), 10) : null;
71
+
72
+ const powerMatch = infoText.match(/Rep\s*Power:\s*([-\d,]+)/i);
73
+ const power = powerMatch ? parseInt(powerMatch[1].replace(/,/g, ""), 10) : null;
74
+
75
+ let highPositiveDots = 0;
76
+ let positiveDots = 0;
77
+ let negativeDots = 0;
78
+ let neutralDots = 0;
79
+ let description: string | undefined;
80
+
81
+ const dotsContainer = postCell.find("[id^='repinfoDots_']").first();
82
+ const dotsScope = dotsContainer.length > 0 ? dotsContainer : postCell;
83
+
84
+ dotsScope.find("img").each((_, el) => {
85
+ const src = $(el).attr("src") ?? "";
86
+ const kind = classifyBySrc(src);
87
+ if (kind === "highpos") highPositiveDots++;
88
+ else if (kind === "pos") positiveDots++;
89
+ else if (kind === "neg") negativeDots++;
90
+ else if (kind === "neutral") neutralDots++;
91
+
92
+ if (!description && kind !== "unknown") {
93
+ const alt = ($(el).attr("alt") ?? "").trim();
94
+ if (alt && !/^add to|^take from/i.test(alt)) {
95
+ description = alt;
96
+ }
97
+ }
98
+ });
99
+
100
+ const dots = highPositiveDots + positiveDots + negativeDots + neutralDots;
101
+
102
+ let sign: RepSign;
103
+ if (negativeDots > 0) sign = "negative";
104
+ else if (highPositiveDots > 0 || positiveDots > 0) sign = "positive";
105
+ else if (neutralDots > 0) sign = "neutral";
106
+ else if (score !== null) {
107
+ if (score > 0) sign = "positive";
108
+ else if (score < 0) sign = "negative";
109
+ else sign = "neutral";
110
+ } else {
111
+ sign = "unknown";
112
+ }
113
+
114
+ const tier = computeTier(score, sign, negativeDots);
115
+ const trustScore = computeTrustScore(score, negativeDots, dots);
116
+
117
+ return {
118
+ score,
119
+ power,
120
+ dots,
121
+ positiveDots,
122
+ highPositiveDots,
123
+ negativeDots,
124
+ neutralDots,
125
+ sign,
126
+ tier,
127
+ description,
128
+ trustScore,
129
+ };
130
+ }
@@ -0,0 +1,45 @@
1
+ import { load } from "cheerio";
2
+
3
+ export interface Subforum {
4
+ slug: string;
5
+ label: string;
6
+ url: string;
7
+ description?: string;
8
+ threadCount?: number;
9
+ postCount?: number;
10
+ }
11
+
12
+ export function parseSubforums(html: string): Subforum[] {
13
+ const $ = load(html);
14
+ const map = new Map<string, Subforum>();
15
+
16
+ $("a[href*='/forum/']").each((_, el) => {
17
+ const link = $(el);
18
+ const href = (link.attr("href") ?? "").trim().replace(/^\/\//, "https://");
19
+ const match = href.match(/\/forum\/([a-z0-9][a-z0-9-]{1,})\/?(?:$|[?#])/i);
20
+ if (!match) return;
21
+
22
+ const slug = match[1].toLowerCase();
23
+ if (slug.endsWith(".php") || ["forum", "index", "portal", "downloads", "search", "misc", "usercp"].includes(slug)) return;
24
+
25
+ const label = link.text().trim();
26
+ if (!label || label.length > 80 || map.has(slug)) return;
27
+
28
+ const row = link.closest("tr, li.forumbit_post, .forumbit_nopost");
29
+ const description = row.find(".forumdescription, .smallfont.forumdescription").first().text().trim() || undefined;
30
+ const statText = row.find(".forumstats, td.alt2").text();
31
+ const threadMatch = statText.match(/Threads[:\s]+([\d,]+)/i);
32
+ const postMatch = statText.match(/Posts[:\s]+([\d,]+)/i);
33
+
34
+ map.set(slug, {
35
+ slug,
36
+ label,
37
+ url: `https://www.unknowncheats.me/forum/${slug}/`,
38
+ description,
39
+ threadCount: threadMatch ? parseInt(threadMatch[1].replace(/,/g, ""), 10) : undefined,
40
+ postCount: postMatch ? parseInt(postMatch[1].replace(/,/g, ""), 10) : undefined,
41
+ });
42
+ });
43
+
44
+ return [...map.values()];
45
+ }
@@ -0,0 +1,155 @@
1
+ import { load } from "cheerio";
2
+
3
+ export interface ThreadListEntry {
4
+ title: string;
5
+ url: string;
6
+ threadId: string;
7
+ author?: string;
8
+ date?: string;
9
+ replies: number;
10
+ views: number;
11
+ subforum?: string;
12
+ snippet?: string;
13
+ isSticky: boolean;
14
+ prefix?: string;
15
+ }
16
+
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
+ export function parseThreadList(html: string): ThreadListEntry[] {
26
+ const $ = load(html);
27
+ const results: ThreadListEntry[] = [];
28
+
29
+ $("a[id^='thread_title_']").each((_, el) => {
30
+ const link = $(el);
31
+ const title = link.text().trim();
32
+ const href = link.attr("href") ?? "";
33
+ const id = (link.attr("id") ?? "").replace("thread_title_", "");
34
+ if (!title || !href) return;
35
+
36
+ const row = link.closest("tr, div[id^='threadbit'], li[id^='thread_']");
37
+
38
+ const author =
39
+ row.find(".threadstarterinfo a, a.username, .username").first().text().trim() ||
40
+ row.find("div.smallfont").eq(0).text().trim() ||
41
+ undefined;
42
+
43
+ const date =
44
+ row.find(".threadlastpost .date, .time, .date").first().text().trim() || undefined;
45
+
46
+ const subforum =
47
+ row.find("a[href*='forumdisplay'], .forumtitle").first().text().trim() || undefined;
48
+
49
+ const cells = row.find("td");
50
+ let replies = 0;
51
+ let views = 0;
52
+ cells.each((_, td) => {
53
+ const text = $(td).text().trim();
54
+ const replyMatch = text.match(/(\d[\d,]*)\s*(?:Repl|repl)/);
55
+ const viewMatch = text.match(/(\d[\d,]*)\s*(?:View|view)/);
56
+ if (replyMatch) replies = parseInt(replyMatch[1].replace(/,/g, ""), 10);
57
+ if (viewMatch) views = parseInt(viewMatch[1].replace(/,/g, ""), 10);
58
+ });
59
+ if (replies === 0 && views === 0) {
60
+ const nums: number[] = [];
61
+ cells.each((_, td) => {
62
+ const text = $(td).text().trim().replace(/,/g, "");
63
+ if (/^\d+$/.test(text)) nums.push(parseInt(text, 10));
64
+ });
65
+ if (nums.length >= 2) {
66
+ replies = nums[nums.length - 2];
67
+ views = nums[nums.length - 1];
68
+ }
69
+ }
70
+
71
+ const snippetRaw = row
72
+ .find(".threadpreview, .searchresult_text, .smallfont:not(:has(a))")
73
+ .first()
74
+ .text()
75
+ .trim();
76
+ const snippet = snippetRaw ? snippetRaw.slice(0, 200) : undefined;
77
+
78
+ const prefixRaw = row.find(".prefix, .threadprefix").first().text().trim();
79
+ const prefix = prefixRaw || undefined;
80
+
81
+ const parent =
82
+ row.parent().attr("id") ??
83
+ row.attr("id") ??
84
+ row.closest("[id]").attr("id") ??
85
+ "";
86
+ const rowClass = row.attr("class") ?? "";
87
+ const rowText = row.text();
88
+ const stickyIconPresent = row.find("img[src*='sticky'], img[alt*='Sticky' i]").length > 0;
89
+ const isSticky =
90
+ /sticky/i.test(parent) ||
91
+ /sticky/i.test(rowClass) ||
92
+ stickyIconPresent ||
93
+ /\bSticky:/i.test(rowText) ||
94
+ snippet?.toLowerCase() === "sticky";
95
+
96
+ results.push({
97
+ title,
98
+ url: absoluteUrl(href),
99
+ threadId: id,
100
+ author,
101
+ date,
102
+ replies,
103
+ views,
104
+ subforum,
105
+ snippet,
106
+ isSticky,
107
+ prefix,
108
+ });
109
+ });
110
+
111
+ return results;
112
+ }
113
+
114
+ export interface PaginationInfo {
115
+ currentPage: number;
116
+ totalPages: number;
117
+ }
118
+
119
+ export function parsePaginationInfo(html: string): PaginationInfo {
120
+ const $ = load(html);
121
+ const navText = $(".pagenav").first().text().trim();
122
+ const match = navText.match(/Page\s+(\d+)\s+of\s+(\d+)/i);
123
+ if (match) {
124
+ return { currentPage: parseInt(match[1], 10), totalPages: parseInt(match[2], 10) };
125
+ }
126
+ return { currentPage: 1, totalPages: 1 };
127
+ }
128
+
129
+ export interface ThreadFilter {
130
+ query?: string;
131
+ minReplies?: number;
132
+ minViews?: number;
133
+ prefix?: string;
134
+ author?: string;
135
+ includeSticky?: boolean;
136
+ }
137
+
138
+ export function filterThreads(threads: ThreadListEntry[], filter: ThreadFilter): ThreadListEntry[] {
139
+ const terms = filter.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
140
+ const authorLc = filter.author?.toLowerCase();
141
+ const prefixLc = filter.prefix?.toLowerCase();
142
+
143
+ return threads.filter((thread) => {
144
+ if (!filter.includeSticky && thread.isSticky) return false;
145
+ if (filter.minReplies !== undefined && thread.replies < filter.minReplies) return false;
146
+ if (filter.minViews !== undefined && thread.views < filter.minViews) return false;
147
+ if (authorLc && !(thread.author ?? "").toLowerCase().includes(authorLc)) return false;
148
+ if (prefixLc && !(thread.prefix ?? "").toLowerCase().includes(prefixLc)) return false;
149
+ if (terms.length > 0) {
150
+ const haystack = `${thread.title} ${thread.snippet ?? ""}`.toLowerCase();
151
+ if (!terms.every((term) => haystack.includes(term))) return false;
152
+ }
153
+ return true;
154
+ });
155
+ }
@@ -0,0 +1,84 @@
1
+ import { load } from "cheerio";
2
+ import type { ThreadData, ThreadPost } from "../types.js";
3
+ import { parseReputationInPost } from "./reputation.js";
4
+
5
+ function parseTotalPages($: ReturnType<typeof load>): number {
6
+ // .pagenav contains "Page X of Y"
7
+ const navText = $(".pagenav").text();
8
+ const match = navText.match(/Page\s+\d+\s+of\s+(\d+)/i);
9
+ if (match) return parseInt(match[1], 10);
10
+ return 1;
11
+ }
12
+
13
+ function parseTitle($: ReturnType<typeof load>): string {
14
+ const raw = $("title").first().text().trim();
15
+ // Strip trailing " - Page N" and site suffix " - unknowncheats.me"
16
+ return raw
17
+ .replace(/\s*-\s*unknowncheats\.me\s*$/i, "")
18
+ .replace(/\s*-\s*Page\s+\d+\s*$/i, "")
19
+ .trim() || "Unknown Thread";
20
+ }
21
+
22
+ export function parseThread(html: string, url: string, pageNum = 1): ThreadData {
23
+ const $ = load(html);
24
+
25
+ const title = parseTitle($);
26
+ const totalPages = parseTotalPages($);
27
+ const posts: ThreadPost[] = [];
28
+
29
+ // Each post is wrapped in table[id^='post'] where id is purely numeric (e.g. post4638271)
30
+ $("table[id]").each((_, el) => {
31
+ const tableEl = $(el);
32
+ const idAttr = tableEl.attr("id") ?? "";
33
+
34
+ // Only match post tables: id starts with 'post' followed by digits only (not 'post_message_')
35
+ if (!/^post\d+$/.test(idAttr)) return;
36
+
37
+ const postId = parseInt(idAttr.replace("post", ""), 10);
38
+
39
+ // Author: a.bigusername inside this post table
40
+ const author = tableEl.find("a.bigusername").first().text().trim();
41
+
42
+ // Reputation lives inside the left postbit cell (td.alt2)
43
+ const postbitCell = tableEl.find("td.alt2").first();
44
+ const reputation = postbitCell.length > 0 ? parseReputationInPost($, postbitCell) : undefined;
45
+
46
+ // Date: first td.thead text (strip whitespace and img alt text)
47
+ const dateRaw = tableEl.find("td.thead").first().clone();
48
+ dateRaw.find("img, a").remove();
49
+ const date = dateRaw.text().trim().replace(/\s+/g, " ");
50
+
51
+ // Content: div#post_message_NNNNN
52
+ const contentEl = tableEl.find(`div[id='post_message_${postId}']`).clone();
53
+ // Remove quoted blocks
54
+ contentEl.find("div[style*='margin']").has("div.smallfont").remove();
55
+ const content = contentEl.text().trim().replace(/\s+/g, " ");
56
+
57
+ // Extract links
58
+ const links: { text: string; url: string }[] = [];
59
+ contentEl.find("a[href]").each((_, a) => {
60
+ const href = $(a).attr("href") ?? "";
61
+ const text = $(a).text().trim();
62
+ if (href && !href.startsWith("#")) {
63
+ const url = href.startsWith("http") ? href : `https://www.unknowncheats.me${href}`;
64
+ links.push({ text: text || url, url });
65
+ }
66
+ });
67
+
68
+ // Extract images
69
+ const images: string[] = [];
70
+ contentEl.find("img[src]").each((_, img) => {
71
+ const src = $(img).attr("src") ?? "";
72
+ if (src && !src.includes("clear.gif") && !src.includes("spacer") && !src.includes("wol_error") && !src.includes("statusicon")) {
73
+ const url = src.startsWith("http") ? src : `https://www.unknowncheats.me${src}`;
74
+ images.push(url);
75
+ }
76
+ });
77
+
78
+ if (author || content) {
79
+ posts.push({ author, date, content, postNumber: postId, links, images, reputation });
80
+ }
81
+ });
82
+
83
+ return { title, posts, currentPage: pageNum, totalPages, url };
84
+ }
@@ -0,0 +1,81 @@
1
+ import { load } from "cheerio";
2
+ import { filterThreads, parseThreadList, type ThreadListEntry } from "./parsers/thread-list.js";
3
+
4
+ function discoverSubforumSlugs(html: string): Array<{ slug: string; label: string }> {
5
+ const $ = load(html);
6
+ const slugs = new Map<string, string>();
7
+
8
+ $("a[href*='/forum/']").each((_, el) => {
9
+ const href = ($(el).attr("href") ?? "").replace(/^\/\//, "https://");
10
+ const match = href.match(/\/forum\/([a-z0-9][a-z0-9-]{1,})\/?(?:$|[?#])/i);
11
+ if (!match) return;
12
+
13
+ const slug = match[1].toLowerCase();
14
+ if (slug.endsWith(".php") || slug === "forum" || slug === "index") return;
15
+
16
+ const label = $(el).text().trim() || slug;
17
+ if (!slugs.has(slug)) slugs.set(slug, label);
18
+ });
19
+
20
+ return [...slugs.entries()].map(([slug, label]) => ({ slug, label }));
21
+ }
22
+
23
+ function rankSubforums(
24
+ subforums: Array<{ slug: string; label: string }>,
25
+ query: string
26
+ ): Array<{ slug: string; label: string; score: number }> {
27
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
28
+ const slugGuess = query.trim().toLowerCase().replace(/\s+/g, "-");
29
+
30
+ const ranked = subforums.map((entry) => {
31
+ const haystack = `${entry.slug} ${entry.label}`.toLowerCase();
32
+ let score = terms.filter((term) => haystack.includes(term)).length;
33
+ if (entry.slug === slugGuess) score += 10;
34
+ if (entry.slug.includes(slugGuess) || slugGuess.includes(entry.slug)) score += 3;
35
+ return { ...entry, score };
36
+ });
37
+
38
+ return ranked.filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score);
39
+ }
40
+
41
+ export async function searchViaSubforums(
42
+ query: string,
43
+ fetchHtml: (url: string) => Promise<string>
44
+ ): Promise<{ results: ThreadListEntry[]; scannedSubforums: string[] }> {
45
+ const indexHtml = await fetchHtml("https://www.unknowncheats.me/forum/index.php");
46
+ const subforums = discoverSubforumSlugs(indexHtml);
47
+ const ranked = rankSubforums(subforums, query);
48
+
49
+ const candidates = ranked.length > 0
50
+ ? ranked.slice(0, 3)
51
+ : [{ slug: query.trim().toLowerCase().replace(/\s+/g, "-"), label: query, score: 1 }];
52
+
53
+ const seen = new Set<string>();
54
+ const results: ThreadListEntry[] = [];
55
+ const scannedSubforums: string[] = [];
56
+
57
+ for (const candidate of candidates) {
58
+ const url = `https://www.unknowncheats.me/forum/${candidate.slug}/`;
59
+ let html: string;
60
+
61
+ try {
62
+ html = await fetchHtml(url);
63
+ } catch {
64
+ continue;
65
+ }
66
+
67
+ scannedSubforums.push(candidate.slug);
68
+ const threads = filterThreads(parseThreadList(html), { query, includeSticky: true });
69
+
70
+ for (const thread of threads) {
71
+ if (seen.has(thread.url)) continue;
72
+ seen.add(thread.url);
73
+ results.push({
74
+ ...thread,
75
+ subforum: thread.subforum ?? candidate.label,
76
+ });
77
+ }
78
+ }
79
+
80
+ return { results, scannedSubforums };
81
+ }