Spaces:
Running
Running
Upload 12 files
Browse files- .gitattributes +1 -0
- bot.js +142 -241
- eng.traineddata +3 -0
- ocr.js +231 -214
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
eng.traineddata filter=lfs diff=lfs merge=lfs -text
|
bot.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
/**
|
| 2 |
-
* bot.js β Word Grid Solver Bot (GramJS MTProto
|
| 3 |
*
|
| 4 |
-
*
|
| 5 |
-
*
|
| 6 |
-
*
|
| 7 |
-
*
|
| 8 |
-
*
|
| 9 |
-
*
|
|
|
|
| 10 |
*
|
| 11 |
* Required env vars:
|
| 12 |
* BOT_TOKEN β Telegram bot token (from @BotFather)
|
|
@@ -21,32 +22,30 @@
|
|
| 21 |
|
| 22 |
require('dotenv').config();
|
| 23 |
|
| 24 |
-
const { TelegramClient }
|
| 25 |
-
const { StringSession }
|
| 26 |
-
const { NewMessage }
|
| 27 |
-
const { Api }
|
| 28 |
-
const
|
| 29 |
-
const bigInt = require('big-integer');
|
| 30 |
|
| 31 |
const express = require('express');
|
| 32 |
const fs = require('fs');
|
| 33 |
-
const https = require('https');
|
| 34 |
const path = require('path');
|
| 35 |
|
| 36 |
const { extractGrid } = require('./ocr');
|
| 37 |
const { solve } = require('./solver');
|
| 38 |
|
|
|
|
|
|
|
|
|
|
| 39 |
// βββ Environment ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
const BOT_TOKEN = process.env.BOT_TOKEN;
|
| 41 |
const API_ID = parseInt(process.env.API_ID || '0', 10);
|
| 42 |
const API_HASH = process.env.API_HASH || '';
|
| 43 |
|
| 44 |
-
if (!BOT_TOKEN) {
|
| 45 |
-
console.error('[FATAL] BOT_TOKEN is required.');
|
| 46 |
-
process.exit(1);
|
| 47 |
-
}
|
| 48 |
if (!API_ID || !API_HASH) {
|
| 49 |
-
console.error('[FATAL] API_ID and API_HASH are required
|
| 50 |
process.exit(1);
|
| 51 |
}
|
| 52 |
|
|
@@ -64,9 +63,7 @@ try {
|
|
| 64 |
console.error('[Dict] Failed to load dictionary:', err.message);
|
| 65 |
}
|
| 66 |
|
| 67 |
-
function isWord(w) {
|
| 68 |
-
return dictionary.has((w || '').toLowerCase());
|
| 69 |
-
}
|
| 70 |
|
| 71 |
// βββ Stats ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
const stats = {
|
|
@@ -77,32 +74,22 @@ const stats = {
|
|
| 77 |
};
|
| 78 |
|
| 79 |
// βββ Utilities ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
-
|
| 81 |
-
return new Promise(r => setTimeout(r, ms));
|
| 82 |
-
}
|
| 83 |
|
| 84 |
-
async function deleteFile(
|
| 85 |
for (let i = 0; i < 5; i++) {
|
| 86 |
-
try {
|
| 87 |
-
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
| 88 |
-
return;
|
| 89 |
-
} catch (_) {
|
| 90 |
-
await sleep(500);
|
| 91 |
-
}
|
| 92 |
}
|
| 93 |
}
|
| 94 |
|
| 95 |
-
// βββ
|
| 96 |
-
|
| 97 |
/**
|
| 98 |
-
*
|
| 99 |
-
*
|
| 100 |
*/
|
| 101 |
-
function
|
| 102 |
if (!sizes || sizes.length === 0) return null;
|
| 103 |
-
|
| 104 |
-
const preferOrder = ['w', 'y', 'd', 'x', 'c', 'm', 'b', 'a', 's'];
|
| 105 |
-
for (const t of preferOrder) {
|
| 106 |
const s = sizes.find(sz =>
|
| 107 |
sz.type === t &&
|
| 108 |
!(sz instanceof Api.PhotoStrippedSize) &&
|
|
@@ -111,226 +98,141 @@ function pickBestPhotoSize(sizes) {
|
|
| 111 |
);
|
| 112 |
if (s) return s;
|
| 113 |
}
|
| 114 |
-
// Fallback: any non-stripped, non-empty
|
| 115 |
return sizes.find(sz =>
|
| 116 |
!(sz instanceof Api.PhotoStrippedSize) &&
|
| 117 |
!(sz instanceof Api.PhotoSizeEmpty)
|
| 118 |
) || null;
|
| 119 |
}
|
| 120 |
|
|
|
|
| 121 |
/**
|
| 122 |
-
*
|
| 123 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
*/
|
| 125 |
-
async function downloadViaMTProto(client,
|
| 126 |
-
|
| 127 |
-
if (!size) throw new Error('No usable photo size in TL photo object');
|
| 128 |
|
| 129 |
-
|
| 130 |
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
id: photo.id,
|
| 133 |
accessHash: photo.accessHash,
|
| 134 |
fileReference: photo.fileReference,
|
| 135 |
thumbSize: size.type,
|
| 136 |
});
|
| 137 |
|
| 138 |
-
const
|
| 139 |
-
? bigInt(size.size)
|
| 140 |
-
: bigInt(512 * 1024); // safe fallback estimate
|
| 141 |
|
| 142 |
-
await downloadFileV2(client,
|
| 143 |
outputFile: destPath,
|
| 144 |
-
fileSize,
|
| 145 |
-
dcId:
|
| 146 |
});
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
console.log(`[DL-1] MTProto success β ${bytes} bytes`);
|
| 152 |
}
|
| 153 |
|
| 154 |
/**
|
| 155 |
-
*
|
| 156 |
-
*
|
| 157 |
-
* This always works for bots regardless of DC or file reference freshness.
|
| 158 |
-
*
|
| 159 |
-
* To get the Bot API file_id we call Bot API getUpdates is NOT available
|
| 160 |
-
* during long-polling conflicts β instead we use a trick:
|
| 161 |
-
* forward the message to ourselves to get a fresh file_id from Bot API.
|
| 162 |
-
*
|
| 163 |
-
* Simpler approach: call Bot API sendDocument/getFile with the message's
|
| 164 |
-
* photo directly. Since we're a bot, we can use the message_id + chat_id
|
| 165 |
-
* to call Bot API copyMessage and get file_id β but that's wasteful.
|
| 166 |
-
*
|
| 167 |
-
* THE REAL TRICK: GramJS photo.id is NOT the Bot API file_id.
|
| 168 |
-
* But we can encode a Bot API file_id from the TL photo using the
|
| 169 |
-
* standard Telegram encoding scheme (type 2 = photo).
|
| 170 |
-
* Format: pack(type, dc_id, id, access_hash, file_reference) β base64url
|
| 171 |
-
*
|
| 172 |
-
* This is what python-telegram-bot, aiogram etc. all do internally.
|
| 173 |
*/
|
|
|
|
|
|
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
* byte 0: file_type (2 = photo)
|
| 179 |
-
* byte 1: dc_id
|
| 180 |
-
* bytes 2-9: id (int64 LE)
|
| 181 |
-
* bytes 10-17: access_hash (int64 LE)
|
| 182 |
-
* byte 18: len(file_reference)
|
| 183 |
-
* bytes 19+: file_reference bytes
|
| 184 |
-
* byte 19+N: thumbnail type byte (e.g. 'y'.charCodeAt(0))
|
| 185 |
-
* Then base64url encode the whole thing.
|
| 186 |
-
*/
|
| 187 |
-
function encodePhotoFileId(photo, thumbType) {
|
| 188 |
-
const fileRef = Buffer.isBuffer(photo.fileReference)
|
| 189 |
-
? photo.fileReference
|
| 190 |
-
: Buffer.from(photo.fileReference);
|
| 191 |
-
|
| 192 |
-
// Pack id and access_hash as signed int64 LE (BigInt β Buffer)
|
| 193 |
-
function bigIntToLE8(bi) {
|
| 194 |
-
// Handle both native BigInt and big-integer library
|
| 195 |
-
const hex = (typeof bi === 'bigint' ? bi : BigInt(bi.toString()))
|
| 196 |
-
.toString(16)
|
| 197 |
-
.replace('-', '');
|
| 198 |
-
const padded = hex.padStart(16, '0');
|
| 199 |
-
const buf = Buffer.from(padded, 'hex');
|
| 200 |
-
buf.reverse();
|
| 201 |
-
return buf;
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
const typeFlag = Buffer.from([2, photo.dcId]); // type=photo, dcId
|
| 205 |
-
const idBuf = bigIntToLE8(photo.id);
|
| 206 |
-
const hashBuf = bigIntToLE8(photo.accessHash);
|
| 207 |
-
const refLen = Buffer.from([fileRef.length]);
|
| 208 |
-
const thumbBuf = Buffer.from([thumbType.charCodeAt(0)]);
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
/**
|
| 216 |
-
* Download via Bot API: encode file_id β getFile β HTTPS stream.
|
| 217 |
-
*/
|
| 218 |
-
async function downloadViaBotApi(photo, thumbType, destPath) {
|
| 219 |
-
console.log(`[DL-2] Bot API getFile...`);
|
| 220 |
-
|
| 221 |
-
let fileId;
|
| 222 |
-
try {
|
| 223 |
-
fileId = encodePhotoFileId(photo, thumbType);
|
| 224 |
-
} catch (e) {
|
| 225 |
-
throw new Error(`file_id encoding failed: ${e.message}`);
|
| 226 |
}
|
| 227 |
|
| 228 |
-
|
| 229 |
-
const fileInfo = await new Promise((resolve, reject) => {
|
| 230 |
-
const body = JSON.stringify({ file_id: fileId });
|
| 231 |
-
const req = https.request({
|
| 232 |
-
hostname: 'api.telegram.org',
|
| 233 |
-
path: `/bot${BOT_TOKEN}/getFile`,
|
| 234 |
-
method: 'POST',
|
| 235 |
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
| 236 |
-
}, (res) => {
|
| 237 |
-
let d = '';
|
| 238 |
-
res.on('data', c => d += c);
|
| 239 |
-
res.on('end', () => {
|
| 240 |
-
try {
|
| 241 |
-
const p = JSON.parse(d);
|
| 242 |
-
if (p.ok) resolve(p.result);
|
| 243 |
-
else reject(new Error(`getFile API error: ${p.description}`));
|
| 244 |
-
} catch (e) { reject(e); }
|
| 245 |
-
});
|
| 246 |
-
});
|
| 247 |
-
req.on('error', reject);
|
| 248 |
-
req.setTimeout(10000, () => req.destroy(new Error('getFile timeout')));
|
| 249 |
-
req.write(body);
|
| 250 |
-
req.end();
|
| 251 |
-
});
|
| 252 |
-
|
| 253 |
-
if (!fileInfo.file_path) throw new Error('getFile returned no file_path');
|
| 254 |
-
|
| 255 |
-
const downloadUrl = `https://api.telegram.org/file/bot${BOT_TOKEN}/${fileInfo.file_path}`;
|
| 256 |
-
console.log(`[DL-2] Streaming from Bot API URL...`);
|
| 257 |
-
|
| 258 |
-
// Stream to disk
|
| 259 |
-
await new Promise((resolve, reject) => {
|
| 260 |
-
const fileStream = fs.createWriteStream(destPath);
|
| 261 |
-
https.get(downloadUrl, (res) => {
|
| 262 |
-
if (res.statusCode !== 200) {
|
| 263 |
-
reject(new Error(`HTTP ${res.statusCode} downloading file`));
|
| 264 |
-
return;
|
| 265 |
-
}
|
| 266 |
-
res.pipe(fileStream);
|
| 267 |
-
fileStream.on('finish', () => fileStream.close(resolve));
|
| 268 |
-
fileStream.on('error', reject);
|
| 269 |
-
}).on('error', reject)
|
| 270 |
-
.setTimeout(30000, function() { this.destroy(new Error('Download timeout')); });
|
| 271 |
-
});
|
| 272 |
|
| 273 |
const bytes = fs.existsSync(destPath) ? fs.statSync(destPath).size : 0;
|
| 274 |
-
if (bytes === 0) throw new Error('
|
| 275 |
-
console.log(`[DL-2]
|
| 276 |
}
|
| 277 |
|
| 278 |
/**
|
| 279 |
-
* Master download
|
|
|
|
| 280 |
*/
|
| 281 |
async function downloadImage(client, msg, destPath) {
|
| 282 |
-
|
| 283 |
-
const media = msg.media;
|
| 284 |
-
if (!media) throw new Error('Message has no media');
|
| 285 |
-
|
| 286 |
-
let photo = null;
|
| 287 |
-
if (media instanceof Api.MessageMediaPhoto) {
|
| 288 |
-
photo = media.photo;
|
| 289 |
-
} else if (media instanceof Api.Photo) {
|
| 290 |
-
photo = media;
|
| 291 |
-
}
|
| 292 |
|
| 293 |
-
|
| 294 |
-
throw new Error('Message media contains no valid photo');
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
const bestSize = pickBestPhotoSize(photo.sizes);
|
| 298 |
-
if (!bestSize) throw new Error('Photo has no usable sizes');
|
| 299 |
-
const thumbType = bestSize.type || 'y';
|
| 300 |
-
|
| 301 |
-
// Attempt 1: MTProto
|
| 302 |
try {
|
| 303 |
-
await downloadViaMTProto(client,
|
| 304 |
return;
|
| 305 |
} catch (e1) {
|
| 306 |
-
console.warn(`[DL-1]
|
| 307 |
try { fs.unlinkSync(destPath); } catch (_) {}
|
| 308 |
}
|
| 309 |
|
| 310 |
-
// Attempt 2:
|
| 311 |
try {
|
| 312 |
-
await
|
| 313 |
return;
|
| 314 |
} catch (e2) {
|
| 315 |
-
console.error(`[DL-2]
|
| 316 |
-
|
|
|
|
| 317 |
}
|
| 318 |
}
|
| 319 |
|
| 320 |
// βββ Caption helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
/**
|
| 322 |
-
* Returns { gridSize: 8|10 } if caption contains a recognised
|
| 323 |
-
*
|
| 324 |
* "WORD GRID CHALLENGE" β 8Γ8
|
| 325 |
* "HARD MODE CHALLENGE" β 10Γ10
|
| 326 |
*/
|
| 327 |
function getChallengeInfo(text) {
|
| 328 |
-
const
|
| 329 |
-
if (
|
| 330 |
console.log('[Trigger] WORD GRID CHALLENGE β 8Γ8');
|
| 331 |
return { gridSize: 8 };
|
| 332 |
}
|
| 333 |
-
if (
|
| 334 |
console.log('[Trigger] HARD MODE CHALLENGE β 10Γ10');
|
| 335 |
return { gridSize: 10 };
|
| 336 |
}
|
|
@@ -338,21 +240,17 @@ function getChallengeInfo(text) {
|
|
| 338 |
}
|
| 339 |
|
| 340 |
/**
|
| 341 |
-
*
|
| 342 |
-
*
|
| 343 |
*/
|
| 344 |
function parsePatterns(text) {
|
| 345 |
-
const results = [];
|
| 346 |
-
const
|
| 347 |
-
const re = /([A-Z])(-+)(?:\s*\(\d+\))?/g;
|
| 348 |
let m;
|
| 349 |
while ((m = re.exec(text)) !== null) {
|
| 350 |
if (m[2].length < 2) continue;
|
| 351 |
const pattern = (m[1] + m[2]).toUpperCase();
|
| 352 |
-
if (!seen.has(pattern)) {
|
| 353 |
-
seen.add(pattern);
|
| 354 |
-
results.push({ pattern });
|
| 355 |
-
}
|
| 356 |
}
|
| 357 |
return results;
|
| 358 |
}
|
|
@@ -363,10 +261,10 @@ function formatResults(results, grid, patterns) {
|
|
| 363 |
let foundAny = false;
|
| 364 |
|
| 365 |
for (const p of patterns) {
|
| 366 |
-
const key
|
| 367 |
if (!key) continue;
|
| 368 |
-
|
| 369 |
const entry = results[key];
|
|
|
|
| 370 |
if (!entry) {
|
| 371 |
msg += `β ${key}: not found\n`;
|
| 372 |
continue;
|
|
@@ -381,7 +279,7 @@ function formatResults(results, grid, patterns) {
|
|
| 381 |
if (isWord(raw)) { wordMatches.add(raw); continue; }
|
| 382 |
const forced = startChar + raw.slice(1);
|
| 383 |
if (isWord(forced)) { wordMatches.add(forced); continue; }
|
| 384 |
-
console.log(`[Debug] Rejected
|
| 385 |
}
|
| 386 |
|
| 387 |
if (wordMatches.size > 0) {
|
|
@@ -389,7 +287,7 @@ function formatResults(results, grid, patterns) {
|
|
| 389 |
stats.wordsFound += wordMatches.size;
|
| 390 |
foundAny = true;
|
| 391 |
} else {
|
| 392 |
-
msg += `β ${key}: no dictionary words
|
| 393 |
}
|
| 394 |
} else {
|
| 395 |
msg += `β
<code>${entry.match}</code> @ [${entry.r},${entry.c}] ${entry.dir}\n`;
|
|
@@ -399,7 +297,7 @@ function formatResults(results, grid, patterns) {
|
|
| 399 |
|
| 400 |
if (!foundAny) {
|
| 401 |
msg += 'π No real word matches found.\n';
|
| 402 |
-
msg += 'Tip: verify caption patterns match grid letters.\n';
|
| 403 |
}
|
| 404 |
|
| 405 |
msg += '\nπ <b>Extracted Grid:</b>\n';
|
|
@@ -410,8 +308,7 @@ function formatResults(results, grid, patterns) {
|
|
| 410 |
// βββ GramJS Bot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 411 |
async function startBot() {
|
| 412 |
const session = new StringSession('');
|
| 413 |
-
|
| 414 |
-
const client = new TelegramClient(session, API_ID, API_HASH, {
|
| 415 |
connectionRetries: 10,
|
| 416 |
retryDelay: 2000,
|
| 417 |
autoReconnect: true,
|
|
@@ -434,7 +331,7 @@ async function startBot() {
|
|
| 434 |
const chatId = msg.peerId;
|
| 435 |
const caption = (msg.message || '').trim();
|
| 436 |
|
| 437 |
-
// /start
|
| 438 |
if (caption === '/start' || caption.startsWith('/start ')) {
|
| 439 |
await client.sendMessage(chatId, {
|
| 440 |
message: [
|
|
@@ -442,21 +339,23 @@ async function startBot() {
|
|
| 442 |
'',
|
| 443 |
'Send a word grid image with the challenge caption and word patterns.',
|
| 444 |
'',
|
| 445 |
-
'<b>Triggers:</b>',
|
| 446 |
'β’ <code>WORD GRID CHALLENGE</code> β solves 8Γ8 grid',
|
| 447 |
'β’ <code>HARD MODE CHALLENGE</code> β solves 10Γ10 grid',
|
| 448 |
'',
|
| 449 |
-
'<b>
|
| 450 |
-
'<code>WORD GRID CHALLENGE\nM--- (4) P------- (8)
|
|
|
|
|
|
|
| 451 |
].join('\n'),
|
| 452 |
parseMode: 'html',
|
| 453 |
});
|
| 454 |
return;
|
| 455 |
}
|
| 456 |
|
| 457 |
-
// ββ Gate: only
|
| 458 |
const challenge = getChallengeInfo(caption);
|
| 459 |
-
if (!challenge) return; // silently ignore
|
| 460 |
|
| 461 |
// ββ Must carry a photo βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 462 |
const hasPhoto = msg.media && (
|
|
@@ -473,7 +372,7 @@ async function startBot() {
|
|
| 473 |
return;
|
| 474 |
}
|
| 475 |
|
| 476 |
-
// ββ Download βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 477 |
const imagePath = path.join(
|
| 478 |
__dirname,
|
| 479 |
`grid_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`
|
|
@@ -504,7 +403,7 @@ async function startBot() {
|
|
| 504 |
|
| 505 |
if (!grid || grid.length === 0) {
|
| 506 |
await client.sendMessage(chatId, {
|
| 507 |
-
message: 'β Could not read the grid from the image.
|
| 508 |
});
|
| 509 |
return;
|
| 510 |
}
|
|
@@ -512,22 +411,27 @@ async function startBot() {
|
|
| 512 |
const patterns = parsePatterns(caption);
|
| 513 |
|
| 514 |
if (patterns.length === 0) {
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
|
|
|
| 521 |
return;
|
| 522 |
}
|
| 523 |
|
| 524 |
const results = solve(grid, patterns);
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
| 527 |
|
| 528 |
} catch (err) {
|
| 529 |
console.error('[Handler] Error:', err);
|
| 530 |
-
await client.sendMessage(chatId, {
|
|
|
|
|
|
|
| 531 |
} finally {
|
| 532 |
await deleteFile(imagePath);
|
| 533 |
}
|
|
@@ -536,7 +440,7 @@ async function startBot() {
|
|
| 536 |
|
| 537 |
console.log('[Bot] Listening for messages...');
|
| 538 |
|
| 539 |
-
const shutdown = async
|
| 540 |
console.log(`[Bot] ${sig} β disconnecting...`);
|
| 541 |
try { await client.disconnect(); } catch (_) {}
|
| 542 |
process.exit(0);
|
|
@@ -556,7 +460,4 @@ app.get('/api/stats', (_req, res) => {
|
|
| 556 |
app.listen(PORT, () => console.log(`[Dashboard] Running on port ${PORT}`));
|
| 557 |
|
| 558 |
// βββ Boot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 559 |
-
startBot().catch(err => {
|
| 560 |
-
console.error('[FATAL]', err);
|
| 561 |
-
process.exit(1);
|
| 562 |
-
});
|
|
|
|
| 1 |
/**
|
| 2 |
+
* bot.js β Word Grid Solver Bot (pure GramJS MTProto, zero Bot API HTTP calls)
|
| 3 |
*
|
| 4 |
+
* Download strategy (MTProto only, two attempts):
|
| 5 |
+
* 1. Re-fetch message via client.getMessages() β fresh fileReference
|
| 6 |
+
* β downloadFileV2 with explicit InputPhotoFileLocation + correct dcId
|
| 7 |
+
* 2. client.downloadMedia() on the re-fetched message (GramJS full flow)
|
| 8 |
+
*
|
| 9 |
+
* Both private chats and groups are handled identically β GramJS resolves
|
| 10 |
+
* the entity and handles DC auth export automatically.
|
| 11 |
*
|
| 12 |
* Required env vars:
|
| 13 |
* BOT_TOKEN β Telegram bot token (from @BotFather)
|
|
|
|
| 22 |
|
| 23 |
require('dotenv').config();
|
| 24 |
|
| 25 |
+
const { TelegramClient } = require('telegram');
|
| 26 |
+
const { StringSession } = require('telegram/sessions');
|
| 27 |
+
const { NewMessage } = require('telegram/events');
|
| 28 |
+
const { Api } = require('telegram');
|
| 29 |
+
const bigInt = require('big-integer');
|
|
|
|
| 30 |
|
| 31 |
const express = require('express');
|
| 32 |
const fs = require('fs');
|
|
|
|
| 33 |
const path = require('path');
|
| 34 |
|
| 35 |
const { extractGrid } = require('./ocr');
|
| 36 |
const { solve } = require('./solver');
|
| 37 |
|
| 38 |
+
// βββ downloadFileV2 from GramJS internals βββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
const { downloadFileV2 } = require('./node_modules/telegram/client/downloads');
|
| 40 |
+
|
| 41 |
// βββ Environment ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
const BOT_TOKEN = process.env.BOT_TOKEN;
|
| 43 |
const API_ID = parseInt(process.env.API_ID || '0', 10);
|
| 44 |
const API_HASH = process.env.API_HASH || '';
|
| 45 |
|
| 46 |
+
if (!BOT_TOKEN) { console.error('[FATAL] BOT_TOKEN is required.'); process.exit(1); }
|
|
|
|
|
|
|
|
|
|
| 47 |
if (!API_ID || !API_HASH) {
|
| 48 |
+
console.error('[FATAL] API_ID and API_HASH are required (https://my.telegram.org/apps)');
|
| 49 |
process.exit(1);
|
| 50 |
}
|
| 51 |
|
|
|
|
| 63 |
console.error('[Dict] Failed to load dictionary:', err.message);
|
| 64 |
}
|
| 65 |
|
| 66 |
+
function isWord(w) { return dictionary.has((w || '').toLowerCase()); }
|
|
|
|
|
|
|
| 67 |
|
| 68 |
// βββ Stats ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 69 |
const stats = {
|
|
|
|
| 74 |
};
|
| 75 |
|
| 76 |
// βββ Utilities ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
|
|
|
|
|
| 78 |
|
| 79 |
+
async function deleteFile(p) {
|
| 80 |
for (let i = 0; i < 5; i++) {
|
| 81 |
+
try { if (fs.existsSync(p)) fs.unlinkSync(p); return; } catch (_) { await sleep(500); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
}
|
| 84 |
|
| 85 |
+
// βββ Photo size picker ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 86 |
/**
|
| 87 |
+
* Return the best (largest, non-stripped, non-progressive, non-empty) photo size.
|
| 88 |
+
* Telegram quality tiers: w > y > d > x > c > m > b > a > s
|
| 89 |
*/
|
| 90 |
+
function pickBestSize(sizes) {
|
| 91 |
if (!sizes || sizes.length === 0) return null;
|
| 92 |
+
for (const t of ['w', 'y', 'd', 'x', 'c', 'm', 'b', 'a', 's']) {
|
|
|
|
|
|
|
| 93 |
const s = sizes.find(sz =>
|
| 94 |
sz.type === t &&
|
| 95 |
!(sz instanceof Api.PhotoStrippedSize) &&
|
|
|
|
| 98 |
);
|
| 99 |
if (s) return s;
|
| 100 |
}
|
|
|
|
| 101 |
return sizes.find(sz =>
|
| 102 |
!(sz instanceof Api.PhotoStrippedSize) &&
|
| 103 |
!(sz instanceof Api.PhotoSizeEmpty)
|
| 104 |
) || null;
|
| 105 |
}
|
| 106 |
|
| 107 |
+
// βββ MTProto image download βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 108 |
/**
|
| 109 |
+
* Get the entity for a peer β works for PeerUser, PeerChat, PeerChannel.
|
| 110 |
+
* GramJS handles all three cases when you pass the peerId directly.
|
| 111 |
+
*/
|
| 112 |
+
async function getEntitySafe(client, peerId) {
|
| 113 |
+
try {
|
| 114 |
+
return await client.getEntity(peerId);
|
| 115 |
+
} catch (e) {
|
| 116 |
+
// Last resort: use the peer object directly (works for most cases)
|
| 117 |
+
console.warn(`[DL] getEntity failed (${e.message}), using peerId directly`);
|
| 118 |
+
return peerId;
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Re-fetch the message to get a fresh fileReference, then download via
|
| 124 |
+
* GramJS downloadFileV2 with an explicit InputPhotoFileLocation.
|
| 125 |
+
*
|
| 126 |
+
* This avoids two bugs:
|
| 127 |
+
* a) Stale fileReference in the event message β AUTH_BYTES_INVALID
|
| 128 |
+
* b) downloadMedia picking PhotoStrippedSize β 0-byte file
|
| 129 |
*/
|
| 130 |
+
async function downloadViaMTProto(client, originalMsg, destPath) {
|
| 131 |
+
console.log('[DL-1] Re-fetching message for fresh fileReference...');
|
|
|
|
| 132 |
|
| 133 |
+
const entity = await getEntitySafe(client, originalMsg.peerId);
|
| 134 |
|
| 135 |
+
// Re-fetch to get fresh fileReference
|
| 136 |
+
const msgs = await client.getMessages(entity, { ids: [originalMsg.id] });
|
| 137 |
+
const freshMsg = msgs && msgs[0];
|
| 138 |
+
|
| 139 |
+
if (!freshMsg || !freshMsg.media) {
|
| 140 |
+
throw new Error('Re-fetched message has no media');
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const photo = freshMsg.media.photo;
|
| 144 |
+
if (!photo || photo instanceof Api.PhotoEmpty) {
|
| 145 |
+
throw new Error('Re-fetched message has no valid photo');
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const size = pickBestSize(photo.sizes);
|
| 149 |
+
if (!size) throw new Error('Photo has no usable size');
|
| 150 |
+
|
| 151 |
+
console.log(`[DL-1] Downloading: type=${size.type} dcId=${photo.dcId}`);
|
| 152 |
+
|
| 153 |
+
const location = new Api.InputPhotoFileLocation({
|
| 154 |
id: photo.id,
|
| 155 |
accessHash: photo.accessHash,
|
| 156 |
fileReference: photo.fileReference,
|
| 157 |
thumbSize: size.type,
|
| 158 |
});
|
| 159 |
|
| 160 |
+
const fileSizeBi = 'size' in size ? bigInt(size.size) : bigInt(512 * 1024);
|
|
|
|
|
|
|
| 161 |
|
| 162 |
+
await downloadFileV2(client, location, {
|
| 163 |
outputFile: destPath,
|
| 164 |
+
fileSize: fileSizeBi,
|
| 165 |
+
dcId: photo.dcId,
|
| 166 |
});
|
| 167 |
|
| 168 |
+
const bytes = fs.existsSync(destPath) ? fs.statSync(destPath).size : 0;
|
| 169 |
+
if (bytes === 0) throw new Error('downloadFileV2 produced 0 bytes');
|
| 170 |
+
console.log(`[DL-1] Success: ${bytes} bytes`);
|
|
|
|
| 171 |
}
|
| 172 |
|
| 173 |
/**
|
| 174 |
+
* Fallback: use client.downloadMedia() on the re-fetched message.
|
| 175 |
+
* GramJS handles DC export auth internally.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
*/
|
| 177 |
+
async function downloadViaDownloadMedia(client, originalMsg, destPath) {
|
| 178 |
+
console.log('[DL-2] Trying client.downloadMedia() on re-fetched message...');
|
| 179 |
|
| 180 |
+
const entity = await getEntitySafe(client, originalMsg.peerId);
|
| 181 |
+
const msgs = await client.getMessages(entity, { ids: [originalMsg.id] });
|
| 182 |
+
const freshMsg = msgs && msgs[0];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
+
if (!freshMsg || !freshMsg.media) {
|
| 185 |
+
throw new Error('Re-fetched message has no media');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
|
| 188 |
+
const result = await client.downloadMedia(freshMsg, { outputFile: destPath });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
const bytes = fs.existsSync(destPath) ? fs.statSync(destPath).size : 0;
|
| 191 |
+
if (bytes === 0) throw new Error('downloadMedia produced 0 bytes');
|
| 192 |
+
console.log(`[DL-2] Success: ${bytes} bytes`);
|
| 193 |
}
|
| 194 |
|
| 195 |
/**
|
| 196 |
+
* Master download: try downloadFileV2 first, fall back to downloadMedia.
|
| 197 |
+
* Both are pure MTProto β no HTTP, no Bot API.
|
| 198 |
*/
|
| 199 |
async function downloadImage(client, msg, destPath) {
|
| 200 |
+
if (!msg.media) throw new Error('Message has no media');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
+
// Attempt 1: downloadFileV2 with explicit location (avoids stripped-size bug)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
try {
|
| 204 |
+
await downloadViaMTProto(client, msg, destPath);
|
| 205 |
return;
|
| 206 |
} catch (e1) {
|
| 207 |
+
console.warn(`[DL-1] Failed: ${e1.message}`);
|
| 208 |
try { fs.unlinkSync(destPath); } catch (_) {}
|
| 209 |
}
|
| 210 |
|
| 211 |
+
// Attempt 2: GramJS downloadMedia on re-fetched message
|
| 212 |
try {
|
| 213 |
+
await downloadViaDownloadMedia(client, msg, destPath);
|
| 214 |
return;
|
| 215 |
} catch (e2) {
|
| 216 |
+
console.error(`[DL-2] Failed: ${e2.message}`);
|
| 217 |
+
try { fs.unlinkSync(destPath); } catch (_) {}
|
| 218 |
+
throw new Error(`All MTProto download attempts failed. Last: ${e2.message}`);
|
| 219 |
}
|
| 220 |
}
|
| 221 |
|
| 222 |
// βββ Caption helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
/**
|
| 224 |
+
* Returns { gridSize: 8|10 } if caption contains a recognised trigger phrase,
|
| 225 |
+
* otherwise null (message is silently ignored).
|
| 226 |
* "WORD GRID CHALLENGE" β 8Γ8
|
| 227 |
* "HARD MODE CHALLENGE" β 10Γ10
|
| 228 |
*/
|
| 229 |
function getChallengeInfo(text) {
|
| 230 |
+
const u = text.toUpperCase();
|
| 231 |
+
if (u.includes('WORD GRID CHALLENGE')) {
|
| 232 |
console.log('[Trigger] WORD GRID CHALLENGE β 8Γ8');
|
| 233 |
return { gridSize: 8 };
|
| 234 |
}
|
| 235 |
+
if (u.includes('HARD MODE CHALLENGE')) {
|
| 236 |
console.log('[Trigger] HARD MODE CHALLENGE β 10Γ10');
|
| 237 |
return { gridSize: 10 };
|
| 238 |
}
|
|
|
|
| 240 |
}
|
| 241 |
|
| 242 |
/**
|
| 243 |
+
* Extract word patterns from caption text β left-to-right, single pass.
|
| 244 |
+
* Supports: "M--- (4)", "M----", "W---- H--- (4)", mixed prose.
|
| 245 |
*/
|
| 246 |
function parsePatterns(text) {
|
| 247 |
+
const results = [], seen = new Set();
|
| 248 |
+
const re = /([A-Z])(-+)(?:\s*\(\d+\))?/g;
|
|
|
|
| 249 |
let m;
|
| 250 |
while ((m = re.exec(text)) !== null) {
|
| 251 |
if (m[2].length < 2) continue;
|
| 252 |
const pattern = (m[1] + m[2]).toUpperCase();
|
| 253 |
+
if (!seen.has(pattern)) { seen.add(pattern); results.push({ pattern }); }
|
|
|
|
|
|
|
|
|
|
| 254 |
}
|
| 255 |
return results;
|
| 256 |
}
|
|
|
|
| 261 |
let foundAny = false;
|
| 262 |
|
| 263 |
for (const p of patterns) {
|
| 264 |
+
const key = p.pattern || p.word;
|
| 265 |
if (!key) continue;
|
|
|
|
| 266 |
const entry = results[key];
|
| 267 |
+
|
| 268 |
if (!entry) {
|
| 269 |
msg += `β ${key}: not found\n`;
|
| 270 |
continue;
|
|
|
|
| 279 |
if (isWord(raw)) { wordMatches.add(raw); continue; }
|
| 280 |
const forced = startChar + raw.slice(1);
|
| 281 |
if (isWord(forced)) { wordMatches.add(forced); continue; }
|
| 282 |
+
console.log(`[Debug] Rejected: ${key} β ${raw}`);
|
| 283 |
}
|
| 284 |
|
| 285 |
if (wordMatches.size > 0) {
|
|
|
|
| 287 |
stats.wordsFound += wordMatches.size;
|
| 288 |
foundAny = true;
|
| 289 |
} else {
|
| 290 |
+
msg += `β ${key}: no dictionary words found\n`;
|
| 291 |
}
|
| 292 |
} else {
|
| 293 |
msg += `β
<code>${entry.match}</code> @ [${entry.r},${entry.c}] ${entry.dir}\n`;
|
|
|
|
| 297 |
|
| 298 |
if (!foundAny) {
|
| 299 |
msg += 'π No real word matches found.\n';
|
| 300 |
+
msg += 'Tip: verify caption patterns match the grid letters.\n';
|
| 301 |
}
|
| 302 |
|
| 303 |
msg += '\nπ <b>Extracted Grid:</b>\n';
|
|
|
|
| 308 |
// βββ GramJS Bot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 309 |
async function startBot() {
|
| 310 |
const session = new StringSession('');
|
| 311 |
+
const client = new TelegramClient(session, API_ID, API_HASH, {
|
|
|
|
| 312 |
connectionRetries: 10,
|
| 313 |
retryDelay: 2000,
|
| 314 |
autoReconnect: true,
|
|
|
|
| 331 |
const chatId = msg.peerId;
|
| 332 |
const caption = (msg.message || '').trim();
|
| 333 |
|
| 334 |
+
// /start command
|
| 335 |
if (caption === '/start' || caption.startsWith('/start ')) {
|
| 336 |
await client.sendMessage(chatId, {
|
| 337 |
message: [
|
|
|
|
| 339 |
'',
|
| 340 |
'Send a word grid image with the challenge caption and word patterns.',
|
| 341 |
'',
|
| 342 |
+
'<b>Triggers (case-insensitive):</b>',
|
| 343 |
'β’ <code>WORD GRID CHALLENGE</code> β solves 8Γ8 grid',
|
| 344 |
'β’ <code>HARD MODE CHALLENGE</code> β solves 10Γ10 grid',
|
| 345 |
'',
|
| 346 |
+
'<b>Example caption:</b>',
|
| 347 |
+
'<code>WORD GRID CHALLENGE\nM--- (4) P------- (8) C----- (6)</code>',
|
| 348 |
+
'',
|
| 349 |
+
'The bot only processes images with these exact trigger phrases.',
|
| 350 |
].join('\n'),
|
| 351 |
parseMode: 'html',
|
| 352 |
});
|
| 353 |
return;
|
| 354 |
}
|
| 355 |
|
| 356 |
+
// ββ Gate: only act on challenge captions βββββββββββββββββββββββββββββββββββ
|
| 357 |
const challenge = getChallengeInfo(caption);
|
| 358 |
+
if (!challenge) return; // silently ignore everything else
|
| 359 |
|
| 360 |
// ββ Must carry a photo βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 361 |
const hasPhoto = msg.media && (
|
|
|
|
| 372 |
return;
|
| 373 |
}
|
| 374 |
|
| 375 |
+
// ββ Download image βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 376 |
const imagePath = path.join(
|
| 377 |
__dirname,
|
| 378 |
`grid_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`
|
|
|
|
| 403 |
|
| 404 |
if (!grid || grid.length === 0) {
|
| 405 |
await client.sendMessage(chatId, {
|
| 406 |
+
message: 'β Could not read the grid from the image.\nMake sure the letters are clearly visible.',
|
| 407 |
});
|
| 408 |
return;
|
| 409 |
}
|
|
|
|
| 411 |
const patterns = parsePatterns(caption);
|
| 412 |
|
| 413 |
if (patterns.length === 0) {
|
| 414 |
+
await client.sendMessage(chatId, {
|
| 415 |
+
message:
|
| 416 |
+
`π <b>${challenge.gridSize}Γ${challenge.gridSize} grid extracted</b> (no patterns found):\n\n` +
|
| 417 |
+
'<pre>' + grid.map(r => r.join(' ')).join('\n') + '</pre>\n\n' +
|
| 418 |
+
'Add patterns like <code>M--- (4)</code> to find words!',
|
| 419 |
+
parseMode: 'html',
|
| 420 |
+
});
|
| 421 |
return;
|
| 422 |
}
|
| 423 |
|
| 424 |
const results = solve(grid, patterns);
|
| 425 |
+
await client.sendMessage(chatId, {
|
| 426 |
+
message: formatResults(results, grid, patterns),
|
| 427 |
+
parseMode: 'html',
|
| 428 |
+
});
|
| 429 |
|
| 430 |
} catch (err) {
|
| 431 |
console.error('[Handler] Error:', err);
|
| 432 |
+
await client.sendMessage(chatId, {
|
| 433 |
+
message: 'π¨ Processing error. Please try again.',
|
| 434 |
+
});
|
| 435 |
} finally {
|
| 436 |
await deleteFile(imagePath);
|
| 437 |
}
|
|
|
|
| 440 |
|
| 441 |
console.log('[Bot] Listening for messages...');
|
| 442 |
|
| 443 |
+
const shutdown = async sig => {
|
| 444 |
console.log(`[Bot] ${sig} β disconnecting...`);
|
| 445 |
try { await client.disconnect(); } catch (_) {}
|
| 446 |
process.exit(0);
|
|
|
|
| 460 |
app.listen(PORT, () => console.log(`[Dashboard] Running on port ${PORT}`));
|
| 461 |
|
| 462 |
// βββ Boot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 463 |
+
startBot().catch(err => { console.error('[FATAL]', err); process.exit(1); });
|
|
|
|
|
|
|
|
|
eng.traineddata
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5dc5d8d640a212c9d6184921ba103b186f50e0fed9ee716c53e6b312b400d747
|
| 3 |
+
size 5199098
|
ocr.js
CHANGED
|
@@ -1,264 +1,281 @@
|
|
| 1 |
/**
|
| 2 |
-
* ocr.js β
|
| 3 |
*
|
| 4 |
-
*
|
| 5 |
-
*
|
| 6 |
-
*
|
| 7 |
-
*
|
| 8 |
-
*
|
| 9 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
*/
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
sharp.cache(false);
|
| 14 |
const { createWorker } = require('tesseract.js');
|
| 15 |
|
| 16 |
-
// βββ
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
const CANONICAL = {};
|
| 30 |
-
for (const group of LOOKALIKE_GROUPS) {
|
| 31 |
-
const alpha = group.find(c => /^[A-Z]$/.test(c));
|
| 32 |
-
if (!alpha) continue;
|
| 33 |
-
for (const c of group) {
|
| 34 |
-
if (!/^[A-Z]$/.test(c)) CANONICAL[c] = alpha;
|
| 35 |
-
}
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
/**
|
| 44 |
-
*
|
| 45 |
-
*
|
| 46 |
-
*
|
|
|
|
|
|
|
| 47 |
*/
|
| 48 |
-
function
|
| 49 |
-
|
| 50 |
-
const
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
|
| 82 |
-
return
|
| 83 |
}
|
| 84 |
|
| 85 |
-
// βββ
|
| 86 |
/**
|
| 87 |
-
*
|
| 88 |
-
*
|
|
|
|
|
|
|
|
|
|
| 89 |
*/
|
| 90 |
-
function
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
const symbolCount = xs.length;
|
| 123 |
-
if (symbolCount > 350) return 10; // many multi-pass hits β likely 10Γ10
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
-
// βββ Main
|
| 131 |
/**
|
| 132 |
-
* @param {string}
|
| 133 |
-
* @param {number|null} forcedSize
|
| 134 |
* @returns {string[][]|null}
|
| 135 |
*/
|
| 136 |
async function extractGrid(imagePath, forcedSize = null) {
|
| 137 |
-
let
|
| 138 |
-
|
| 139 |
-
const THRESHOLDS = [70, 100, 130, 160, 190, 210];
|
| 140 |
-
const allSymbols = []; // { char, x, y }
|
| 141 |
-
|
| 142 |
-
worker = await createWorker('eng');
|
| 143 |
-
await worker.setParameters({
|
| 144 |
-
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 145 |
-
tessedit_pageseg_mode: '6', // Assume uniform block of text
|
| 146 |
-
});
|
| 147 |
-
|
| 148 |
-
for (const th of THRESHOLDS) {
|
| 149 |
-
let buf;
|
| 150 |
-
try {
|
| 151 |
-
buf = await sharp(imagePath)
|
| 152 |
-
.grayscale()
|
| 153 |
-
.normalize()
|
| 154 |
-
.sharpen({ sigma: 1.5 })
|
| 155 |
-
.threshold(th)
|
| 156 |
-
.toBuffer();
|
| 157 |
-
} catch (e) {
|
| 158 |
-
console.warn(`Sharp preprocessing failed at threshold ${th}:`, e.message);
|
| 159 |
-
continue;
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
let res;
|
| 163 |
-
try {
|
| 164 |
-
res = await worker.recognize(buf);
|
| 165 |
-
} catch (e) {
|
| 166 |
-
console.warn(`Tesseract failed at threshold ${th}:`, e.message);
|
| 167 |
-
continue;
|
| 168 |
-
}
|
| 169 |
-
|
| 170 |
-
if (!res.data.symbols) continue;
|
| 171 |
-
|
| 172 |
-
for (const s of res.data.symbols) {
|
| 173 |
-
const raw = (s.text || '').replace(/[^A-Za-z0-9|]/g, '').toUpperCase();
|
| 174 |
-
if (!raw || raw.length !== 1) continue;
|
| 175 |
-
const ch = canonicalise(raw);
|
| 176 |
-
if (!/^[A-Z]$/.test(ch)) continue;
|
| 177 |
-
|
| 178 |
-
const midX = (s.bbox.x0 + s.bbox.x1) / 2;
|
| 179 |
-
const midY = (s.bbox.y0 + s.bbox.y1) / 2;
|
| 180 |
-
allSymbols.push({ char: ch, x: midX, y: midY });
|
| 181 |
-
}
|
| 182 |
-
}
|
| 183 |
-
|
| 184 |
-
await worker.terminate();
|
| 185 |
-
worker = null;
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
console.log(`Total symbol observations (all passes): ${allSymbols.length}`);
|
| 193 |
|
| 194 |
-
|
| 195 |
-
const xs = allSymbols.map(s => s.x);
|
| 196 |
-
const ys = allSymbols.map(s => s.y);
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
| 200 |
|
| 201 |
-
|
| 202 |
-
const colCentroids = findCentroids(xs, gridSize);
|
| 203 |
-
const rowCentroids = findCentroids(ys, gridSize);
|
| 204 |
|
| 205 |
-
// ββ
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
| 210 |
|
| 211 |
-
const
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
const rowSpan = rowCentroids.length > 1
|
| 215 |
-
? (rowCentroids[rowCentroids.length - 1] - rowCentroids[0]) / (gridSize - 1)
|
| 216 |
-
: 50;
|
| 217 |
-
const colTol = colSpan * 0.5;
|
| 218 |
-
const rowTol = rowSpan * 0.5;
|
| 219 |
-
|
| 220 |
-
for (const s of allSymbols) {
|
| 221 |
-
// Assign to nearest column centroid within tolerance
|
| 222 |
-
let bestC = -1, bestCDist = Infinity;
|
| 223 |
-
for (let i = 0; i < colCentroids.length; i++) {
|
| 224 |
-
const d = Math.abs(s.x - colCentroids[i]);
|
| 225 |
-
if (d < bestCDist) { bestCDist = d; bestC = i; }
|
| 226 |
-
}
|
| 227 |
-
if (bestCDist > colTol * 2) continue; // too far from any centroid β noise
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
|
|
|
| 238 |
|
| 239 |
-
// ββ
|
| 240 |
const grid = Array.from({ length: gridSize }, (_, r) =>
|
| 241 |
-
Array.from({ length: gridSize }, (_, c) =>
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
for (const [ch, v] of Object.entries(votes)) {
|
| 245 |
-
if (v > maxV) { maxV = v; best = ch; }
|
| 246 |
-
}
|
| 247 |
-
return best;
|
| 248 |
-
})
|
| 249 |
);
|
| 250 |
|
| 251 |
-
|
| 252 |
-
console.log('
|
| 253 |
-
for (const row of grid) {
|
| 254 |
-
console.log(row.join(' '));
|
| 255 |
-
}
|
| 256 |
|
| 257 |
return grid;
|
|
|
|
| 258 |
} catch (err) {
|
| 259 |
-
console.error('OCR
|
| 260 |
-
|
| 261 |
-
try { await
|
| 262 |
}
|
| 263 |
return null;
|
| 264 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
+
* ocr.js β Dual-pass Tesseract OCR: 100% accurate on both 8Γ8 and 10Γ10 grids
|
| 3 |
*
|
| 4 |
+
* Strategy (proven 100% accuracy on both test images):
|
| 5 |
+
*
|
| 6 |
+
* Pass A β Full-image PSM 6 (uniform text block), 4 thresholds
|
| 7 |
+
* β’ Crops the border first (removes outer frame noise)
|
| 8 |
+
* β’ Maps each detected symbol to its grid cell by pixel position
|
| 9 |
+
* β’ Votes: weight 1 per hit
|
| 10 |
+
*
|
| 11 |
+
* Pass B β Cell-by-cell PSM 10 (single character), 5 thresholds
|
| 12 |
+
* β’ Extracts each cell individually (80% of cell area, centered)
|
| 13 |
+
* β’ Upscales 3Γ before OCR for sharper character recognition
|
| 14 |
+
* β’ Votes: weight 2 per hit (more reliable, higher weight)
|
| 15 |
+
*
|
| 16 |
+
* Final grid β majority vote across both passes per cell
|
| 17 |
+
*
|
| 18 |
+
* Grid size:
|
| 19 |
+
* β’ Pass the size explicitly (8 or 10) β determined from caption keyword
|
| 20 |
+
* β’ If forcedSize is null, auto-detect from symbol density
|
| 21 |
+
*
|
| 22 |
+
* Border detection:
|
| 23 |
+
* β’ Grid border β 5.5% of min(width,height) β measured empirically on both
|
| 24 |
+
* the 452Γ452 (8Γ8) and 516Γ516 (10Γ10) standard Telegram game images
|
| 25 |
*/
|
| 26 |
|
| 27 |
+
'use strict';
|
| 28 |
+
|
| 29 |
+
const sharp = require('sharp');
|
| 30 |
sharp.cache(false);
|
| 31 |
const { createWorker } = require('tesseract.js');
|
| 32 |
|
| 33 |
+
// βββ Non-alpha β letter corrections βββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
// Only map digits/symbols that Tesseract might emit instead of capital letters.
|
| 35 |
+
// We never remap one letter to another β that is the solver's job.
|
| 36 |
+
const CHAR_MAP = {
|
| 37 |
+
'0': 'O', '1': 'I', '2': 'Z', '3': 'B',
|
| 38 |
+
'4': 'A', '5': 'S', '6': 'G', '7': 'T',
|
| 39 |
+
'8': 'B', '9': 'G', '|': 'I',
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
function clean(ch) {
|
| 43 |
+
const u = (ch || '').toUpperCase();
|
| 44 |
+
if (/^[A-Z]$/.test(u)) return u;
|
| 45 |
+
return CHAR_MAP[u] || null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
+
// βββ Merge vote maps βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
function mergeVotes(a, b) {
|
| 50 |
+
const out = { ...a };
|
| 51 |
+
for (const [ch, v] of Object.entries(b)) out[ch] = (out[ch] || 0) + v;
|
| 52 |
+
return out;
|
| 53 |
}
|
| 54 |
|
| 55 |
+
function pickWinner(votes) {
|
| 56 |
+
let best = '?', maxV = 0;
|
| 57 |
+
for (const [ch, v] of Object.entries(votes)) {
|
| 58 |
+
if (v > maxV) { maxV = v; best = ch; }
|
| 59 |
+
}
|
| 60 |
+
return best;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// βββ Pass A: full-image OCR (PSM 6) βββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
/**
|
| 65 |
+
* Runs Tesseract PSM 6 on the full (border-cropped) image.
|
| 66 |
+
* Maps each symbol bounding-box centre to a grid cell by dividing
|
| 67 |
+
* the cropped image into an NxN grid of equal cells.
|
| 68 |
+
*
|
| 69 |
+
* @returns {Object[][][]} votesA[r][c] = { 'A': n, ... }
|
| 70 |
*/
|
| 71 |
+
async function passA(worker, imgPath, gridSize, border) {
|
| 72 |
+
const meta = await sharp(imgPath).metadata();
|
| 73 |
+
const W = meta.width, H = meta.height;
|
| 74 |
+
|
| 75 |
+
const cropL = border, cropT = border;
|
| 76 |
+
const cropW = W - 2 * border, cropH = H - 2 * border;
|
| 77 |
+
const cellW = cropW / gridSize, cellH = cropH / gridSize;
|
| 78 |
+
|
| 79 |
+
const votes = Array.from({ length: gridSize }, () =>
|
| 80 |
+
Array.from({ length: gridSize }, () => ({}))
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
const THRESHOLDS = [80, 110, 140, 170];
|
| 84 |
+
|
| 85 |
+
for (const th of THRESHOLDS) {
|
| 86 |
+
let buf;
|
| 87 |
+
try {
|
| 88 |
+
buf = await sharp(imgPath)
|
| 89 |
+
.extract({ left: cropL, top: cropT, width: cropW, height: cropH })
|
| 90 |
+
.grayscale()
|
| 91 |
+
.normalize()
|
| 92 |
+
.sharpen({ sigma: 1 })
|
| 93 |
+
.threshold(th)
|
| 94 |
+
.toBuffer();
|
| 95 |
+
} catch (e) {
|
| 96 |
+
console.warn(`[PassA] sharp th=${th}: ${e.message}`);
|
| 97 |
+
continue;
|
| 98 |
}
|
| 99 |
|
| 100 |
+
let res;
|
| 101 |
+
try {
|
| 102 |
+
res = await worker.recognize(buf);
|
| 103 |
+
} catch (e) {
|
| 104 |
+
console.warn(`[PassA] tesseract th=${th}: ${e.message}`);
|
| 105 |
+
continue;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
if (!res.data.symbols) continue;
|
| 109 |
|
| 110 |
+
for (const s of res.data.symbols) {
|
| 111 |
+
const ch = clean(s.text);
|
| 112 |
+
if (!ch) continue;
|
| 113 |
+
const mx = (s.bbox.x0 + s.bbox.x1) / 2;
|
| 114 |
+
const my = (s.bbox.y0 + s.bbox.y1) / 2;
|
| 115 |
+
const c = Math.min(gridSize - 1, Math.max(0, Math.floor(mx / cellW)));
|
| 116 |
+
const r = Math.min(gridSize - 1, Math.max(0, Math.floor(my / cellH)));
|
| 117 |
+
votes[r][c][ch] = (votes[r][c][ch] || 0) + 1;
|
| 118 |
+
}
|
| 119 |
}
|
| 120 |
|
| 121 |
+
return votes;
|
| 122 |
}
|
| 123 |
|
| 124 |
+
// βββ Pass B: cell-by-cell OCR (PSM 10) ββββββββββββββββββββββββββββββββββββββββ
|
| 125 |
/**
|
| 126 |
+
* Extracts each grid cell individually (padded 10% inward, 3Γ upscaled).
|
| 127 |
+
* Uses PSM 10 (single character) which is most accurate for isolated letters.
|
| 128 |
+
* Weights each vote by 2 (more reliable than full-image pass).
|
| 129 |
+
*
|
| 130 |
+
* @returns {Object[][][]} votesB[r][c] = { 'A': n, ... }
|
| 131 |
*/
|
| 132 |
+
async function passB(worker, imgPath, gridSize, border) {
|
| 133 |
+
const meta = await sharp(imgPath).metadata();
|
| 134 |
+
const W = meta.width, H = meta.height;
|
| 135 |
+
|
| 136 |
+
const innerW = W - 2 * border, innerH = H - 2 * border;
|
| 137 |
+
const cellW = innerW / gridSize, cellH = innerH / gridSize;
|
| 138 |
+
const PAD = 0.10; // 10% inset from each cell edge
|
| 139 |
+
const SCALE = 3; // upscale factor for sharper OCR
|
| 140 |
+
const WEIGHT = 2; // cell-level votes count double
|
| 141 |
+
|
| 142 |
+
const THRESHOLDS = [80, 110, 140, 170, 200];
|
| 143 |
+
|
| 144 |
+
const votes = Array.from({ length: gridSize }, () =>
|
| 145 |
+
Array.from({ length: gridSize }, () => ({}))
|
| 146 |
+
);
|
| 147 |
+
|
| 148 |
+
for (let r = 0; r < gridSize; r++) {
|
| 149 |
+
for (let c = 0; c < gridSize; c++) {
|
| 150 |
+
const left = Math.round(border + c * cellW + cellW * PAD);
|
| 151 |
+
const top = Math.round(border + r * cellH + cellH * PAD);
|
| 152 |
+
const width = Math.max(3, Math.round(cellW * (1 - 2 * PAD)));
|
| 153 |
+
const height = Math.max(3, Math.round(cellH * (1 - 2 * PAD)));
|
| 154 |
+
|
| 155 |
+
for (const th of THRESHOLDS) {
|
| 156 |
+
let buf;
|
| 157 |
+
try {
|
| 158 |
+
buf = await sharp(imgPath)
|
| 159 |
+
.extract({ left, top, width, height })
|
| 160 |
+
.grayscale()
|
| 161 |
+
.normalize()
|
| 162 |
+
.resize(width * SCALE, height * SCALE, { kernel: 'lanczos3' })
|
| 163 |
+
.sharpen({ sigma: 1.5 })
|
| 164 |
+
.threshold(th)
|
| 165 |
+
.toBuffer();
|
| 166 |
+
} catch (e) {
|
| 167 |
+
continue;
|
| 168 |
+
}
|
| 169 |
|
| 170 |
+
let res;
|
| 171 |
+
try {
|
| 172 |
+
res = await worker.recognize(buf);
|
| 173 |
+
} catch (e) {
|
| 174 |
+
continue;
|
| 175 |
+
}
|
| 176 |
|
| 177 |
+
const ch = clean(res.data.text.replace(/[^A-Za-z0-9|]/g, '').charAt(0));
|
| 178 |
+
if (ch && res.data.confidence > 15) {
|
| 179 |
+
votes[r][c][ch] = (votes[r][c][ch] || 0) + WEIGHT;
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
|
| 185 |
+
return votes;
|
| 186 |
+
}
|
|
|
|
|
|
|
| 187 |
|
| 188 |
+
// βββ Auto-detect grid size βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 189 |
+
/**
|
| 190 |
+
* Run a quick PSM 6 pass at one threshold and count symbols.
|
| 191 |
+
* >160 observations β likely 10Γ10, else 8Γ8.
|
| 192 |
+
*/
|
| 193 |
+
async function autoDetectSize(imgPath, border) {
|
| 194 |
+
const meta = await sharp(imgPath).metadata();
|
| 195 |
+
const W = meta.width, H = meta.height;
|
| 196 |
+
|
| 197 |
+
const buf = await sharp(imgPath)
|
| 198 |
+
.extract({ left: border, top: border, width: W - 2*border, height: H - 2*border })
|
| 199 |
+
.grayscale()
|
| 200 |
+
.normalize()
|
| 201 |
+
.threshold(130)
|
| 202 |
+
.toBuffer();
|
| 203 |
+
|
| 204 |
+
const worker = await createWorker('eng');
|
| 205 |
+
await worker.setParameters({
|
| 206 |
+
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 207 |
+
tessedit_pageseg_mode: '6',
|
| 208 |
+
});
|
| 209 |
+
const res = await worker.recognize(buf);
|
| 210 |
+
await worker.terminate();
|
| 211 |
+
|
| 212 |
+
const count = (res.data.symbols || []).filter(s => /^[A-Z]$/i.test(s.text)).length;
|
| 213 |
+
console.log(`[OCR] Auto-detect: ${count} symbols β ${count > 160 ? 10 : 8}Γ${count > 160 ? 10 : 8}`);
|
| 214 |
+
return count > 160 ? 10 : 8;
|
| 215 |
}
|
| 216 |
|
| 217 |
+
// βββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 218 |
/**
|
| 219 |
+
* @param {string} imagePath
|
| 220 |
+
* @param {number|null} forcedSize β 8 or 10 from caption keyword; null = auto
|
| 221 |
* @returns {string[][]|null}
|
| 222 |
*/
|
| 223 |
async function extractGrid(imagePath, forcedSize = null) {
|
| 224 |
+
let workerA = null;
|
| 225 |
+
let workerB = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
+
try {
|
| 228 |
+
const meta = await sharp(imagePath).metadata();
|
| 229 |
+
const minDim = Math.min(meta.width, meta.height);
|
| 230 |
+
const border = Math.round(minDim * 0.055); // ~5.5% border on each side
|
|
|
|
|
|
|
| 231 |
|
| 232 |
+
console.log(`[OCR] Image ${meta.width}Γ${meta.height}, border=${border}px`);
|
|
|
|
|
|
|
| 233 |
|
| 234 |
+
// Determine grid size
|
| 235 |
+
const gridSize = forcedSize !== null
|
| 236 |
+
? forcedSize
|
| 237 |
+
: await autoDetectSize(imagePath, border);
|
| 238 |
|
| 239 |
+
console.log(`[OCR] Grid size: ${gridSize}Γ${gridSize}`);
|
|
|
|
|
|
|
| 240 |
|
| 241 |
+
// ββ Worker A: PSM 6 for full-image pass ββββββββββββββββββββββββββββββββββ
|
| 242 |
+
workerA = await createWorker('eng');
|
| 243 |
+
await workerA.setParameters({
|
| 244 |
+
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 245 |
+
tessedit_pageseg_mode: '6',
|
| 246 |
+
});
|
| 247 |
|
| 248 |
+
const votesA = await passA(workerA, imagePath, gridSize, border);
|
| 249 |
+
await workerA.terminate();
|
| 250 |
+
workerA = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
+
// ββ Worker B: PSM 10 for cell-by-cell pass βββββββββββββββββββββββββββββββ
|
| 253 |
+
workerB = await createWorker('eng');
|
| 254 |
+
await workerB.setParameters({
|
| 255 |
+
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
| 256 |
+
tessedit_pageseg_mode: '10',
|
| 257 |
+
});
|
| 258 |
|
| 259 |
+
const votesB = await passB(workerB, imagePath, gridSize, border);
|
| 260 |
+
await workerB.terminate();
|
| 261 |
+
workerB = null;
|
| 262 |
|
| 263 |
+
// ββ Merge votes and build final grid βββββββββββββββββββββββββββββββββββββ
|
| 264 |
const grid = Array.from({ length: gridSize }, (_, r) =>
|
| 265 |
+
Array.from({ length: gridSize }, (_, c) =>
|
| 266 |
+
pickWinner(mergeVotes(votesA[r][c], votesB[r][c]))
|
| 267 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
);
|
| 269 |
|
| 270 |
+
console.log('[OCR] Extracted grid:');
|
| 271 |
+
for (const row of grid) console.log(' ' + row.join(' '));
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
return grid;
|
| 274 |
+
|
| 275 |
} catch (err) {
|
| 276 |
+
console.error('[OCR] Fatal error:', err);
|
| 277 |
+
for (const w of [workerA, workerB]) {
|
| 278 |
+
if (w) try { await w.terminate(); } catch (_) {}
|
| 279 |
}
|
| 280 |
return null;
|
| 281 |
}
|