Files
Krunker-Civilian-Client-Test/src/preload/translator.ts
T
bigjakk aeabddcf3a
Build and Release / build-and-release (push) Has been cancelled
initial commit
2026-04-03 15:33:20 -07:00

362 lines
12 KiB
TypeScript

import type { SavedConsole } from './utils';
// ── Config ──
interface TranslatorConfig {
enabled: boolean;
targetLanguage: string;
showLanguageTag: boolean;
}
const DEFAULTS: TranslatorConfig = {
enabled: true,
targetLanguage: 'en',
showLanguageTag: true,
};
// ── Module state ──
let _con: SavedConsole;
let cfg: TranslatorConfig = { ...DEFAULTS };
let chatObserver: MutationObserver | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
// ── Translation cache (sessionStorage, 10-min expiry) ──
const CACHE_KEY_PREFIX = 'kccTL_';
const CACHE_EXPIRY_MS = 10 * 60 * 1000;
interface CacheEntry {
t: string; // translation
l: string; // source language
ts: number; // timestamp
}
function cacheGet(text: string): CacheEntry | null {
try {
const raw = sessionStorage.getItem(CACHE_KEY_PREFIX + text.toLowerCase().trim());
if (!raw) return null;
const entry: CacheEntry = JSON.parse(raw);
if (Date.now() - entry.ts > CACHE_EXPIRY_MS) return null;
return entry;
} catch { return null; }
}
function cacheSet(text: string, translation: string, srcLang: string): void {
try {
const entry: CacheEntry = { t: translation, l: srcLang, ts: Date.now() };
sessionStorage.setItem(CACHE_KEY_PREFIX + text.toLowerCase().trim(), JSON.stringify(entry));
} catch { /* sessionStorage full */ }
}
// ── Skip terms (gaming/chat slang — never sent for translation) ──
const SKIP_TERMS = new Set([
// Greetings & basics
'hi', 'hey', 'hello', 'yo', 'sup', 'bye', 'cya', 'gn', 'gm',
'yes', 'no', 'yep', 'yea', 'yeah', 'nah', 'nope', 'ok', 'okay', 'kk',
// Chat abbreviations
'lol', 'lmao', 'lmfao', 'rofl', 'omg', 'omfg', 'wtf', 'wth',
'bruh', 'bro', 'dude', 'man', 'brb', 'afk', 'gtg', 'g2g',
'smh', 'tbh', 'imo', 'imho', 'ngl', 'fr', 'frfr', 'fax',
'idk', 'idc', 'idgaf', 'nvm', 'stfu', 'pls', 'plz',
'thx', 'ty', 'tysm', 'np', 'yw', 'mb', 'sry', 'sorry',
'bet', 'cap', 'nocap', 'sus', 'mid', 'based', 'cringe', 'ratio',
'rip', 'oof', 'uwu', 'owo', 'xd', 'xdd', 'xddd', 'lel', 'kek',
'damn', 'dang', 'boi', 'fam', 'goat', 'goated',
'lit', 'vibe', 'vibes', 'lowkey', 'highkey', 'deadass',
'nice', 'cool', 'sick', 'fire', 'trash', 'ass', 'toxic',
'wow', 'whoa', 'wha', 'huh', 'wat', 'wut', 'hmm',
// Gaming general
'gg', 'ggwp', 'ggez', 'wp', 'ez', 'gl', 'hf', 'glhf',
'nt', 'ns', 'gj', 'mvp', 'clutch', 'ace', 'carry',
'noob', 'newb', 'n00b', 'bot', 'tryhard', 'sweat', 'sweaty',
'hack', 'hacks', 'hacker', 'hax', 'cheater', 'cheats',
'lag', 'laggy', 'ping', 'fps', 'dc', 'disconnect',
'nerf', 'buff', 'op', 'broken', 'meta', 'spam', 'camp', 'camper',
'aim', 'aimbot', 'wh', 'wallhack', 'esp',
'rush', 'push', 'rotate', 'flank', 'peek', 'hold',
'one', 'low', 'dead', 'down', 'res', 'revive',
'w', 'l', 'dub', 'win', 'loss', 'f', 'ggs',
// Krunker-specific
'kr', 'ak', 'smg', 'sniper', 'shotty', 'rev', 'semi',
'crossy', 'famas', 'rpg', 'lmg', 'deagle', 'comp',
'pub', 'pubs', 'ranked', 'nuke', 'nuked', 'nuking',
'kpd', 'bhop', 'bhopping', 'slidehopping', 'slidehop',
'krunker', 'krunky', 'yendis', 'krunkitis',
'contra', 'relic', 'unob', 'unobtainable', 'spin',
'market', 'trade', 'gift', 'drop', 'drops', 'skin', 'skins',
'clan', 'verified', 'lvl', 'level',
'trig', 'trigger', 'runner', 'det', 'detective',
'vince', 'bowman', 'spray', 'agent', 'rocketeer',
'streamer', 'ttv',
// Emoticons
':)', ':(', ':d', ':p', ':o', '<3',
]);
// ── False-positive source languages ──
const FALSE_POSITIVE_LANGS = new Set([
'so', 'cy', 'ht', 'hmn', 'ceb', 'haw', 'la', 'mg', 'mi',
'ny', 'sm', 'st', 'su', 'sw', 'tl', 'yo', 'zu', 'sn',
'ig', 'rw', 'co', 'fy', 'gd', 'lb', 'mt', 'eo',
]);
// ── Auto-suppression (repeated short phrases) ──
const suppressionCounts = new Map<string, number>();
const SUPPRESS_THRESHOLD = 3;
const MIN_LATIN_WORDS = 3;
const SHORT_TEXT_THRESHOLD = 15;
// ── Concurrency control ──
let activeRequests = 0;
const MAX_CONCURRENT = 3;
const pendingQueue: Array<() => void> = [];
function enqueue(fn: () => Promise<void>): void {
if (activeRequests < MAX_CONCURRENT) {
activeRequests++;
fn().finally(() => {
activeRequests--;
if (pendingQueue.length > 0) pendingQueue.shift()!();
});
} else {
pendingQueue.push(() => enqueue(fn));
}
}
// ── System message patterns to skip ──
const SYSTEM_PATTERNS = [
'joined the game', 'left the game', 'has been kicked', 'has been banned',
'vote to kick', 'press f1', 'connecting', 'connected', 'was arrested',
'started a vote', 'was kicked', 'was banned',
];
// ── Pre-translation filtering ──
function isLatinOnly(text: string): boolean {
// eslint-disable-next-line no-control-regex
return /^[\x00-\x7F\u00C0-\u024F\u1E00-\u1EFF\s\d.,!?;:'"()\-/@#$%^&*+=~`[\]{}|\\<>]+$/u.test(text);
}
function shouldTranslate(text: string): boolean {
const cleaned = text.trim();
if (cleaned.length < 2) return false;
// Tokenize for skip-term checking
const words = cleaned.replace(/[^a-zA-Z0-9\s]/g, '').toLowerCase().split(/\s+/).filter(w => w.length > 0);
if (words.length === 0) return false;
if (words.every(w => SKIP_TERMS.has(w))) return false;
// Auto-suppressed phrases
const key = cleaned.toLowerCase();
if ((suppressionCounts.get(key) ?? 0) >= SUPPRESS_THRESHOLD) return false;
// Non-Latin characters = almost certainly needs translation
if (!isLatinOnly(cleaned)) return true;
// Latin-only: require minimum word count (short English slang triggers false positives)
if (words.length < MIN_LATIN_WORDS) {
// Allow if accented characters suggest non-English
if (!/[À-ÿ]/.test(cleaned)) return false;
}
return true;
}
// ── Chat text extraction ──
interface ChatExtraction {
message: string;
username: string; // "Username:" prefix or empty
}
function extractChatText(node: HTMLElement): ChatExtraction | null {
const text = node.textContent?.trim();
if (!text || text.length < 2) return null;
// Skip nodes with images (kill feed has weapon/skull icons)
if (node.querySelector('img')) return null;
// Skip commands
if (text.startsWith('/')) return null;
// Skip system messages
const lower = text.toLowerCase();
if (SYSTEM_PATTERNS.some(p => lower.includes(p))) return null;
// Extract message content after "Username: " prefix
const colonIdx = text.indexOf(':');
if (colonIdx > 0 && colonIdx < 25) {
const username = text.substring(0, colonIdx + 1);
const msg = text.substring(colonIdx + 1).trim();
return msg.length >= 2 ? { message: msg, username } : null;
}
return { message: text, username: '' };
}
// ── Google Translate API ──
async function translateText(text: string): Promise<{ translation: string; srcLang: string } | null> {
// Check cache
const cached = cacheGet(text);
if (cached) return { translation: cached.t, srcLang: cached.l };
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl='
+ cfg.targetLanguage + '&dt=t&q=' + encodeURIComponent(text);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
_con.warn('[KCC-TL] HTTP', response.status);
return null;
}
const data = await response.json();
if (!data?.[0]?.[0]) return null;
const translation = (data[0] as any[]).map((item: any) => item[0]).join('');
const srcLang: string = data[2] || 'unknown';
// Already in target language
if (srcLang === cfg.targetLanguage) return null;
// Identical translation (strip punctuation/whitespace for robust comparison)
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
if (norm(translation) === norm(text)) return null;
// Post-filter: false-positive languages on short text
if (text.length < SHORT_TEXT_THRESHOLD && FALSE_POSITIVE_LANGS.has(srcLang)) {
const key = text.toLowerCase().trim();
suppressionCounts.set(key, (suppressionCounts.get(key) ?? 0) + 1);
return null;
}
// Track short phrases for auto-suppression learning
const wordCount = text.trim().split(/\s+/).length;
if (wordCount <= 2) {
const key = text.toLowerCase().trim();
const count = (suppressionCounts.get(key) ?? 0) + 1;
suppressionCounts.set(key, count);
if (count >= SUPPRESS_THRESHOLD) return null;
}
cacheSet(text, translation, srcLang);
return { translation, srcLang };
} catch (err: any) {
if (err.name !== 'AbortError') _con.warn('[KCC-TL] Error:', err.message);
return null;
}
}
// ── DOM manipulation ──
function appendTranslation(chatNode: HTMLElement, username: string, translation: string, srcLang: string): void {
const div = document.createElement('div');
div.className = 'kcc-translation';
const langTag = (cfg.showLanguageTag && srcLang !== 'unknown') ? ' [' + srcLang.toUpperCase() + ']' : '';
div.textContent = '\u{1F310} ' + (username ? username + ' ' : '') + translation + langTag;
chatNode.appendChild(div);
}
// ── Message processing ──
function processMessage(node: HTMLElement): void {
if (node.hasAttribute('data-kpc-translated')) return;
node.setAttribute('data-kpc-translated', '1');
const extracted = extractChatText(node);
if (!extracted) return;
if (!shouldTranslate(extracted.message)) return;
const { message, username } = extracted;
enqueue(async () => {
const result = await translateText(message);
if (result) appendTranslation(node, username, result.translation, result.srcLang);
});
}
// ── Observer lifecycle ──
function startObserver(): void {
if (chatObserver) return;
let attempts = 0;
pollTimer = setInterval(() => {
attempts++;
const chatList = document.getElementById('chatList');
if (!chatList) {
if (attempts > 60) {
clearInterval(pollTimer!);
pollTimer = null;
_con.warn('[KCC-TL] #chatList not found after 30s, giving up');
}
return;
}
clearInterval(pollTimer!);
pollTimer = null;
chatObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) processMessage(node as HTMLElement);
}
}
});
chatObserver.observe(chatList, { childList: true });
_con.log('[KCC-TL] Chat observer active');
}, 500);
}
function stopObserver(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (chatObserver) {
chatObserver.disconnect();
chatObserver = null;
}
}
// ── Public API ──
export function initTranslator(savedConsole: SavedConsole, initCfg: TranslatorConfig): void {
_con = savedConsole;
cfg = {
enabled: initCfg.enabled ?? DEFAULTS.enabled,
targetLanguage: initCfg.targetLanguage ?? DEFAULTS.targetLanguage,
showLanguageTag: initCfg.showLanguageTag ?? DEFAULTS.showLanguageTag,
};
if (!cfg.enabled) {
_con.log('[KCC-TL] Translator disabled');
return;
}
_con.log('[KCC-TL] Initializing (target: ' + cfg.targetLanguage + ')');
startObserver();
}
export function updateTranslatorConfig(update: Partial<TranslatorConfig>): void {
if (update.enabled !== undefined) {
cfg.enabled = update.enabled;
if (update.enabled && !chatObserver) startObserver();
if (!update.enabled) stopObserver();
}
if (update.targetLanguage !== undefined) cfg.targetLanguage = update.targetLanguage;
if (update.showLanguageTag !== undefined) cfg.showLanguageTag = update.showLanguageTag;
}