import cors from "cors"; import { createHash } from "crypto"; import express from "express"; import fs from "fs/promises"; import { chromium } from "playwright"; const port = Number(process.env.PORT || 3001); const maxBaseUrl = process.env.MAX_BASE_URL || "https://web.max.ru/"; const userDataDir = process.env.MAX_USER_DATA_DIR || "/data/max-profile"; const headless = (process.env.MAX_HEADLESS || "true").toLowerCase() !== "false"; const domDownloadPrefix = "qmax-dom-download:"; let context; let page; let lastError = null; let pageOperation = Promise.resolve(); const networkLog = []; const app = express(); app.use(cors()); app.use(express.json({ limit: "10mb" })); app.get("/health", (_req, res) => { res.json({ status: "ok", serverTime: new Date().toISOString() }); }); app.get("/status", async (_req, res) => { res.json(await withPageLock(() => status())); }); app.post("/login/start", async (req, res) => { try { const phoneNumber = String(req.body?.phoneNumber || "").trim(); if (!phoneNumber) { return res.status(400).json({ error: "phoneNumber is required" }); } const result = await withPageLock(async () => { const p = await ensurePage(); await p.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); await p.waitForTimeout(1500); await openPhoneLogin(p); const filled = await fillFirst(p, normalizePhoneNumberForMax(phoneNumber), [ "input[type='tel']", "input[autocomplete='tel']", "input[name*='phone' i]", "input[placeholder*='тел' i]", "input" ]); if (filled) { await clickLikelySubmitFixed(p); await p.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); await p.waitForTimeout(1500); } return await status("LoginStarted"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/login/code", async (req, res) => { try { const code = normalizeLoginCode(req.body?.code); if (!code) { return res.status(400).json({ error: "code is required" }); } const result = await withPageLock(async () => { const p = await ensurePage(); const waitMs = clampNumber(req.body?.waitMs ?? req.query?.waitMs, 0, 120000, 45000); const codeScreenReady = await waitForLoginStage(p, "CodeEntry", waitMs); if (!codeScreenReady) { const currentStatus = await status("CodeScreenNotReady"); currentStatus.lastError = "MAX is not showing the SMS code entry screen."; return currentStatus; } const filled = await fillLoginCode(p, code); if (!filled) { await p.keyboard.insertText(code); } await clickLikelySubmitFixed(p); await p.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); await p.waitForTimeout(2500); return await status("CodeSubmitted"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/snapshot", async (_req, res) => { try { const snapshot = await withPageLock(async () => { const p = await ensurePage(); const screenshot = await p.screenshot({ fullPage: true, type: "png" }); return { url: p.url(), title: await safeTitle(p), bodyText: await safeBodyText(p), screenshotPngBase64: screenshot.toString("base64"), capturedAt: new Date().toISOString() }; }); res.json(snapshot); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/browser/click", async (req, res) => { try { const result = await withPageLock(async () => { const p = await ensurePage(); await p.mouse.click(Number(req.body?.x), Number(req.body?.y)); await p.waitForTimeout(500); return await status("Clicked"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/browser/type", async (req, res) => { try { const result = await withPageLock(async () => { const p = await ensurePage(); await p.keyboard.insertText(String(req.body?.text || "")); return await status("Typed"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/browser/press", async (req, res) => { try { const result = await withPageLock(async () => { const p = await ensurePage(); await p.keyboard.press(String(req.body?.key || "Enter")); await p.waitForTimeout(500); return await status("Pressed"); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/inspect/indexeddb", async (_req, res) => { try { const databases = await withPageLock(async () => { const p = await ensurePage(); return await p.evaluate(async () => { if (!indexedDB.databases) return []; const dbs = await indexedDB.databases(); return dbs.map((db) => ({ name: db.name, version: db.version })); }); }); res.json({ databases }); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/inspect/indexeddb/sample", async (req, res) => { try { const sampleLimit = clampNumber(req.query?.sampleLimit, 1, 10, 3); const dbLimit = clampNumber(req.query?.dbLimit, 1, 20, 10); const storeLimit = clampNumber(req.query?.storeLimit, 1, 80, 40); const stringLimit = clampNumber(req.query?.stringLimit, 16, 512, 96); const result = await withPageLock(async () => { const p = await ensurePage(); return await inspectIndexedDbSamples(p, { sampleLimit, dbLimit, storeLimit, stringLimit }); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/inspect/dom", async (_req, res) => { try { res.json(await withPageLock(async () => { const p = await ensurePage(); return await inspectDom(p); })); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/inspect/storage", async (_req, res) => { try { res.json(await withPageLock(async () => { const p = await ensurePage(); return await inspectStorage(p); })); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.get("/inspect/network", async (_req, res) => { res.json({ capturedAt: new Date().toISOString(), entries: networkLog.slice(-200) }); }); app.get("/media/fetch", async (req, res) => { try { const mediaUrl = String(req.query?.url || "").trim(); if (!isAllowedMediaUrl(mediaUrl)) { return res.status(400).json({ error: "Unsupported media URL." }); } const media = await withPageLock(async () => { const p = await ensurePage(); return await fetchMediaThroughMaxSession(p, mediaUrl); }); res.setHeader("Content-Type", media.contentType || "application/octet-stream"); res.setHeader("Content-Length", String(media.buffer.length)); res.send(media.buffer); } catch (error) { res.status(502).json(errorStatus(error)); } }); app.get("/updates", async (_req, res) => { try { const result = await withPageLock(async () => { const p = await ensurePage(); const currentStatus = await status(); if (!currentStatus.isAuthorized) { return []; } await clearSidebarSearch(p); return await extractUpdates(p); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/chat/history", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const result = await withPageLock(async () => { const p = await ensurePage(); const currentStatus = await status(); if (!currentStatus.isAuthorized) { return null; } if (externalChatId) { const opened = await ensureDomChatOpen(p, externalChatId, 1000); if (!opened) { return null; } } return await extractOpenChatHistory(p, externalChatId); }); if (!result) { return res.status(409).json({ error: "MAX is not authorized." }); } res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/chat/presence", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const result = await withPageLock(async () => { const p = await ensurePage(); const currentStatus = await status(); if (!currentStatus.isAuthorized) { return { isTyping: false, statusText: null, updatedAt: new Date().toISOString() }; } if (externalChatId) { await ensureDomChatOpen(p, externalChatId, 600); } return await extractOpenChatPresence(p); }); res.json(result); } catch (error) { res.status(500).json(errorStatus(error)); } }); app.post("/send/text", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const text = String(req.body?.text || ""); if (!text) { return res.json({ success: false, externalMessageId: null, error: "Text is empty." }); } const result = await withPageLock(async () => { const p = await ensurePage(); if (externalChatId) { const opened = await ensureDomChatOpen(p, externalChatId, 600); if (!opened) { return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open." }; } } const externalMessageId = await sendTextAndConfirm(p, externalChatId, text); if (!externalMessageId) { return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing text message." }; } return { success: true, externalMessageId, error: null }; }); res.json(result); } catch (error) { res.json({ success: false, externalMessageId: null, error: String(error?.message || error) }); } }); app.post("/send/attachment", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const path = String(req.body?.path || "").trim(); const caption = String(req.body?.caption || ""); if (!path) { return res.json({ success: false, externalMessageId: null, error: "Attachment path is empty." }); } const result = await withPageLock(async () => { const p = await ensurePage(); if (externalChatId) { const opened = await ensureDomChatOpen(p, externalChatId, 600); if (!opened) { return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open." }; } } const uploaded = await uploadAttachmentFile(p, path, caption); if (!uploaded) { return { success: false, externalMessageId: null, error: "MAX attachment upload controls were not found or rejected the file." }; } return { success: true, externalMessageId: `web-file-${Date.now()}`, error: null }; }); res.json(result); } catch (error) { res.json({ success: false, externalMessageId: null, error: String(error?.message || error) }); } }); app.post("/message/edit", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const externalMessageId = String(req.body?.externalMessageId || "").trim(); const currentText = String(req.body?.currentText || ""); const text = String(req.body?.text || "").trim(); if (!externalChatId || !externalMessageId || !text) { return res.json({ success: false, error: "externalChatId, externalMessageId and text are required." }); } const result = await withPageLock(async () => { const p = await ensurePage(); return await editMessageInMax(p, externalChatId, externalMessageId, currentText, text); }); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); app.post("/message/delete", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const externalMessageId = String(req.body?.externalMessageId || "").trim(); const currentText = String(req.body?.currentText || ""); if (!externalChatId || !externalMessageId) { return res.json({ success: false, error: "externalChatId and externalMessageId are required." }); } const result = await withPageLock(async () => { const p = await ensurePage(); return await deleteMessageInMax(p, externalChatId, externalMessageId, currentText); }); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); app.post("/message/reaction", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const externalMessageId = String(req.body?.externalMessageId || "").trim(); const currentText = String(req.body?.currentText || ""); const emoji = String(req.body?.emoji || "").trim(); if (!externalChatId || !externalMessageId || !emoji) { return res.json({ success: false, error: "externalChatId, externalMessageId and emoji are required." }); } const result = await withPageLock(async () => { const p = await ensurePage(); return await setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji); }); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); app.delete("/message/reaction", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); const externalMessageId = String(req.body?.externalMessageId || "").trim(); const currentText = String(req.body?.currentText || ""); const emoji = String(req.body?.emoji || "").trim(); if (!externalChatId || !externalMessageId || !emoji) { return res.json({ success: false, error: "externalChatId, externalMessageId and emoji are required." }); } const result = await withPageLock(async () => { const p = await ensurePage(); return await clearMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji); }); res.json(result); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } }); app.listen(port, () => { console.log(`QMAX MAX worker listening on ${port}, headless=${headless}`); }); async function ensurePage() { if (page) return page; await cleanupChromiumProfileLocks(userDataDir); context = await chromium.launchPersistentContext(userDataDir, { headless, acceptDownloads: true, locale: "ru-RU", timezoneId: process.env.MAX_TIMEZONE_ID || "Europe/Moscow", viewport: { width: 1280, height: 900 }, args: ["--disable-dev-shm-usage", "--no-sandbox"] }); page = context.pages()[0] || await context.newPage(); attachNetworkCapture(page); if (!page.url() || page.url() === "about:blank") { await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); } return page; } async function withPageLock(operation) { const previousOperation = pageOperation; let release; pageOperation = new Promise((resolve) => { release = resolve; }); await previousOperation.catch(() => {}); try { return await operation(); } finally { release(); } } async function status(overrideStatus = null) { try { const p = await ensurePage(); const title = await safeTitle(p); const url = p.url(); const bodyText = await safeBodyText(p); const loginStage = detectLoginStageFromText(url, title, bodyText); return { mode: "Worker", isAuthorized: loginStage === "Authorized", loginStage, status: overrideStatus || "BrowserReady", url, title, lastError, updatedAt: new Date().toISOString() }; } catch (error) { return errorStatus(error); } } function errorStatus(error) { lastError = String(error?.message || error); return { mode: "Worker", isAuthorized: false, status: "WorkerError", url: page?.url?.() || null, title: null, lastError, updatedAt: new Date().toISOString() }; } function isAllowedMediaUrl(value) { const raw = String(value || "").trim(); if (!raw) return false; if (isDomDownloadUrl(raw)) return true; if (raw.startsWith("blob:")) { try { const inner = new URL(raw.slice("blob:".length)); return inner.protocol === "https:" && !isPrivateHost(inner.hostname); } catch { return false; } } try { const parsed = new URL(raw); return ["http:", "https:"].includes(parsed.protocol) && !isPrivateHost(parsed.hostname); } catch { return false; } } function isDomDownloadUrl(value) { return String(value || "").startsWith(domDownloadPrefix); } function parseDomDownloadUrl(value) { const raw = String(value || ""); if (!isDomDownloadUrl(raw)) { return null; } try { return JSON.parse(decodeURIComponent(raw.slice(domDownloadPrefix.length))); } catch { return null; } } function isPrivateHost(hostname) { const host = String(hostname || "").toLowerCase(); if (!host || host === "localhost" || host.endsWith(".localhost")) return true; if (/^(127|10)\./.test(host)) return true; if (/^192\.168\./.test(host)) return true; if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true; if (/^(0|169\.254)\./.test(host)) return true; if (host === "::1" || host.startsWith("fc") || host.startsWith("fd")) return true; return false; } async function fetchMediaThroughMaxSession(p, mediaUrl) { if (isDomDownloadUrl(mediaUrl)) { return await downloadDomAttachmentThroughClick(p, parseDomDownloadUrl(mediaUrl)); } if (mediaUrl.startsWith("blob:")) { const result = await p.evaluate(async (url) => { const response = await fetch(url); if (!response.ok) { throw new Error(`Blob fetch failed: ${response.status}`); } const arrayBuffer = await response.arrayBuffer(); const bytes = Array.from(new Uint8Array(arrayBuffer)); return { contentType: response.headers.get("content-type") || "application/octet-stream", bytes }; }, mediaUrl); return { contentType: result.contentType, buffer: Buffer.from(result.bytes) }; } const response = await context.request.get(mediaUrl, { timeout: 60000 }); if (!response.ok()) { throw new Error(`Media fetch failed: ${response.status()} ${response.statusText()}`); } const headers = response.headers(); return { contentType: String(headers["content-type"] || "application/octet-stream").split(";")[0], buffer: await response.body() }; } async function downloadDomAttachmentThroughClick(p, request) { const fileName = String(request?.fileName || "").trim(); const label = String(request?.label || fileName).trim(); if (!fileName && !label) { throw new Error("DOM download request does not include a file label."); } const downloadPromise = p.waitForEvent("download", { timeout: 20000 }); const clicked = await p.evaluate(({ fileName, label }) => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const expectedFile = cleanText(fileName, 160).toLowerCase(); const expectedLabel = cleanText(label, 300).toLowerCase(); const buttons = Array.from(document.querySelectorAll("button,[role='button']")) .filter((node) => node instanceof HTMLElement && isVisible(node)); const match = buttons.find((button) => { const text = cleanText(button.innerText || button.textContent || button.getAttribute("aria-label"), 300).toLowerCase(); if (!text) return false; if (expectedFile && text.includes(expectedFile)) return true; return expectedLabel && (text === expectedLabel || text.includes(expectedLabel)); }); if (!match) { return false; } match.click(); return true; }, { fileName, label }).catch(() => false); if (!clicked) { downloadPromise.catch(() => null); throw new Error(`MAX file download button was not found for ${fileName || label}.`); } const download = await downloadPromise; const path = await download.path(); if (!path) { throw new Error("MAX file download did not produce a local file."); } const buffer = await fs.readFile(path); await download.delete().catch(() => {}); return { contentType: contentTypeFromFileName(download.suggestedFilename() || fileName), buffer }; } function contentTypeFromFileName(fileName) { const ext = String(fileName || "").toLowerCase().split(".").pop(); switch (ext) { case "txt": return "text/plain"; case "csv": return "text/csv"; case "vcf": return "text/vcard"; case "pdf": return "application/pdf"; case "zip": return "application/zip"; case "rar": return "application/vnd.rar"; case "7z": return "application/x-7z-compressed"; case "doc": return "application/msword"; case "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; case "xls": return "application/vnd.ms-excel"; case "xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; case "ppt": return "application/vnd.ms-powerpoint"; case "pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; default: return "application/octet-stream"; } } function attachNetworkCapture(p) { if (p.__qmaxNetworkCaptureAttached) { return; } p.__qmaxNetworkCaptureAttached = true; p.on("response", async (response) => { try { const request = response.request(); const url = response.url(); if (!url.includes("max.ru")) { return; } const headers = response.headers(); networkLog.push({ at: new Date().toISOString(), method: request.method(), url: redactUrl(url), status: response.status(), resourceType: request.resourceType(), contentType: String(headers["content-type"] || "").split(";")[0] }); if (networkLog.length > 500) { networkLog.splice(0, networkLog.length - 500); } } catch { // Network inspection is best-effort and must never break the bridge. } }); } function redactUrl(value) { try { const url = new URL(value); for (const key of Array.from(url.searchParams.keys())) { if (/token|auth|code|session|secret|key|hash/i.test(key)) { url.searchParams.set(key, "[redacted]"); } } return url.toString(); } catch { return String(value || ""); } } async function inspectDom(p) { return await p.evaluate(() => { const cleanText = (value, limit = 500) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit); const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const elements = Array.from(document.querySelectorAll("a,button,input,textarea,[role],[data-testid],[class]")) .filter(isVisible) .map((element) => { const rect = element.getBoundingClientRect(); return { tag: element.tagName.toLowerCase(), role: element.getAttribute("role"), id: element.id || null, className: cleanText(element.className, 180), testId: element.getAttribute("data-testid"), ariaLabel: element.getAttribute("aria-label"), href: element.getAttribute("href"), text: cleanText(element.innerText || element.value || element.getAttribute("aria-label")), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) } }; }) .filter((element) => element.text || element.ariaLabel || element.testId || element.role) .slice(0, 400); return { url: location.href, title: document.title, bodyText: cleanText(document.body?.innerText, 2000), capturedAt: new Date().toISOString(), viewport: { width: window.innerWidth, height: window.innerHeight }, elements }; }); } async function inspectStorage(p) { return await p.evaluate(async () => { const storageKeys = (storage) => { const keys = []; for (let index = 0; index < storage.length; index++) { const key = storage.key(index); const value = key ? storage.getItem(key) : null; keys.push({ key, valueLength: value ? value.length : 0 }); } return keys; }; const indexedDbDatabases = []; if (indexedDB.databases) { for (const dbInfo of await indexedDB.databases()) { const entry = { name: dbInfo.name, version: dbInfo.version, objectStores: [] }; if (dbInfo.name) { try { const opened = await new Promise((resolve, reject) => { const request = indexedDB.open(dbInfo.name); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); for (const storeName of Array.from(opened.objectStoreNames)) { const transaction = opened.transaction(storeName, "readonly"); const store = transaction.objectStore(storeName); const count = await new Promise((resolve) => { const request = store.count(); request.onerror = () => resolve(null); request.onsuccess = () => resolve(request.result); }); entry.objectStores.push({ name: storeName, count }); } opened.close(); } catch { entry.error = "open failed"; } } indexedDbDatabases.push(entry); } } return { url: location.href, capturedAt: new Date().toISOString(), localStorage: storageKeys(localStorage), sessionStorage: storageKeys(sessionStorage), indexedDbDatabases }; }); } async function inspectIndexedDbSamples(p, options) { return await p.evaluate(async (opts) => { const sensitiveKeyPattern = /(token|auth|session|secret|cookie|password|phone|email|jwt|bearer|refresh|access|credential|csrf|hash|salt|signature)/i; const truncate = (value, limit) => { const text = String(value ?? ""); return text.length > limit ? `${text.slice(0, limit)}...` : text; }; const summarize = (value, keyHint = "", depth = 0) => { const type = Array.isArray(value) ? "array" : typeof value; if (value === null || value === undefined) { return { type: String(value) }; } if (sensitiveKeyPattern.test(keyHint)) { return { type, redacted: true }; } if (type === "string") { return { type, length: value.length, preview: truncate(value, opts.stringLimit) }; } if (type === "number" || type === "boolean") { return { type, value }; } if (value instanceof Date) { return { type: "date", value: value.toISOString() }; } if (value instanceof ArrayBuffer) { return { type: "arrayBuffer", byteLength: value.byteLength }; } if (ArrayBuffer.isView(value)) { return { type: value.constructor.name, byteLength: value.byteLength }; } if (Array.isArray(value)) { return { type, length: value.length, items: depth >= 2 ? [] : value.slice(0, 5).map((item, index) => summarize(item, `${keyHint}[${index}]`, depth + 1)) }; } if (type === "object") { const entries = Object.entries(value).slice(0, 40); return { type, keys: Object.keys(value).slice(0, 80), fields: depth >= 3 ? {} : Object.fromEntries(entries.map(([key, fieldValue]) => [key, summarize(fieldValue, key, depth + 1)])) }; } return { type }; }; const requestAsPromise = (request) => new Promise((resolve, reject) => { request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); const openDatabase = (name) => new Promise((resolve, reject) => { const request = indexedDB.open(name); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); const databases = indexedDB.databases ? (await indexedDB.databases()).filter((db) => db.name).slice(0, opts.dbLimit) : []; const result = []; for (const dbInfo of databases) { const database = { name: dbInfo.name, version: dbInfo.version, stores: [] }; try { const opened = await openDatabase(dbInfo.name); const storeNames = Array.from(opened.objectStoreNames).slice(0, opts.storeLimit); for (const storeName of storeNames) { const transaction = opened.transaction(storeName, "readonly"); const store = transaction.objectStore(storeName); const count = await requestAsPromise(store.count()).catch(() => null); const records = await requestAsPromise(store.getAll(undefined, opts.sampleLimit)).catch(() => []); database.stores.push({ name: storeName, keyPath: store.keyPath, autoIncrement: store.autoIncrement, count, samples: records.map((record) => summarize(record)) }); } opened.close(); } catch (error) { database.error = String(error?.message || error); } result.push(database); } return { url: location.href, capturedAt: new Date().toISOString(), limits: opts, databases: result }; }, options); } function cleanComparableText(value, limit = 500) { return String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); } function isGenericMediaText(value) { return /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i .test(cleanComparableText(value, 120)); } async function extractUpdates(p) { const raw = await p.evaluate(() => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); const linesFrom = (value) => String(value || "") .split(/\n+/) .map((line) => cleanText(line, 160)) .filter(Boolean); const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 20 && rect.height > 12 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const badText = /(\u0432\u043e\u0439\u0442\u0438|qr|\u043d\u0435 \u0440\u043e\u0431\u043e\u0442|\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430|\u043f\u043e\u043b\u0438\u0442\u0438\u043a|\u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d|\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434|\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434|\u043a\u043e\u0434 max|\u0441\u043c\u0441|sms)/i; const isGenericMediaText = (value) => /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i.test(cleanText(value, 120)); const absoluteUrl = (value) => { const raw = String(value || "").trim(); if (!raw || raw.startsWith("data:")) return null; try { return new URL(raw, location.href).href; } catch { return null; } }; const cssBackgroundUrl = (element) => { const image = window.getComputedStyle(element).backgroundImage || ""; const match = image.match(/url\(["']?(.+?)["']?\)/i); return match ? absoluteUrl(match[1]) : null; }; const extractAvatarUrl = (element) => { const rowRect = element.getBoundingClientRect(); const candidates = Array.from(element.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); for (const node of candidates) { if (!(node instanceof Element)) continue; const rect = node.getBoundingClientRect(); if (rect.width < 24 || rect.height < 24 || rect.width > 96 || rect.height > 96) continue; if (rect.left > rowRect.left + rowRect.width * 0.45) continue; const source = node.currentSrc || node.src || node.href?.baseVal || node.getAttribute("src") || node.getAttribute("href") || cssBackgroundUrl(node); const url = absoluteUrl(source); if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { return url; } } return null; }; const extractTitle = (element, lines) => { const titleNode = element.querySelector("h3, [class*='title' i] [class*='name' i], [class*='name' i]"); const title = cleanText(titleNode?.innerText || titleNode?.textContent, 120); return title || lines.find((line) => line && !/^\d{1,3}$/.test(line)) || ""; }; const extractPreview = (element, title, lines) => { const timeText = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText); const ignored = new Set([ title, timeText, cleanText(element.querySelector("[class*='badge' i], [class*='indicator' i], [class*='counter' i]")?.innerText) ].filter(Boolean)); const candidates = Array.from(element.querySelectorAll("span[class*='text' i], div[class*='text' i], p, [class*='subtitle' i]")) .map((node) => cleanText(node.innerText || node.textContent, 240)) .filter((text) => text && !ignored.has(text)) .filter((text) => !/^\d{1,3}$/.test(text)); return candidates.find((text) => text !== title) || lines.find((line) => line !== title && !ignored.has(line) && !/^\d{1,3}$/.test(line)) || null; }; const extractTimeText = (element, lines) => { const direct = cleanText(element.querySelector("[class*='time' i], [class*='meta' i]")?.innerText, 80); if (direct) return direct; return lines.find((line) => /^\d{1,2}:\d{2}$/.test(line) || /^(\u0432\u0447\u0435\u0440\u0430|\u0441\u0435\u0433\u043e\u0434\u043d\u044f)$/i.test(line) || /^\d{1,2}\s+[^\s.]+\.?$/i.test(line) || /^(mon|tue|wed|thu|fri|sat|sun|\u043f\u043d|\u0432\u0442|\u0441\u0440|\u0447\u0442|\u043f\u0442|\u0441\u0431|\u0432\u0441)$/i.test(line) ) || null; }; const chatSelector = [ "aside button.cell", "aside button[class*='cell' i]", "aside [role='presentation'] button", "aside [class*='wrapper' i] button", "[data-testid*='chat' i]", "[data-testid*='dialog' i]", "[data-testid*='conversation' i]", "a[href*='chat' i]", "a[href*='dialog' i]" ].join(","); const seen = new Set(); const chats = []; for (const element of Array.from(document.querySelectorAll(chatSelector))) { if (!isVisible(element)) continue; const rect = element.getBoundingClientRect(); const text = cleanText(element.innerText || element.textContent, 400); if (!text || text.length < 2 || text.length > 380 || badText.test(text)) continue; const lines = linesFrom(element.innerText || element.textContent); if (lines.length === 0) continue; const title = extractTitle(element, lines); if (!title || title.length < 2 || badText.test(title)) continue; const semantic = [ element.getAttribute("role"), element.getAttribute("data-testid"), element.className, element.getAttribute("href") ].join(" "); const looksLikeChat = element.matches("button.cell, button[class*='cell' i]") || lines.length >= 2 || /chat|dialog|conversation|listitem|cell/i.test(semantic) || (rect.left > 60 && rect.left < window.innerWidth * 0.55); if (!looksLikeChat) continue; const key = `${title}|${text}`; if (seen.has(key)) continue; seen.add(key); chats.push({ title, text, preview: extractPreview(element, title, lines), timeText: extractTimeText(element, lines), avatarUrl: extractAvatarUrl(element), href: element.getAttribute("href") || null, rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) } }); if (chats.length >= 80) break; } return { url: location.href, title: document.title, chats }; }); const now = new Date().toISOString(); return raw.chats.map((chat, index) => { const externalId = makeDomChatExternalId(chat.title, chat.text, chat.href, index); const outgoing = /^\s*(\u0432\u044b|you)\s*:/i.test(chat.preview || ""); const messageText = (chat.preview || "").replace(/^(\u0432\u044b|you)\s*:\s*/i, "").trim(); return { externalId, title: chat.title, avatarUrl: chat.avatarUrl || null, updatedAt: now, lastMessagePreview: messageText || null, lastMessageIsOutgoing: outgoing, lastMessageAt: messageText ? now : null, lastMessageTimeText: chat.timeText || null, messages: [] }; }); } async function extractOpenChatHistory(p, requestedExternalChatId) { const requestedTitle = decodeDomChatTitle(requestedExternalChatId); const raw = await p.evaluate((fallbackTitle) => { const cleanText = (value, limit = 4000) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+\n/g, "\n") .replace(/\n\s+/g, "\n") .replace(/[ \t]+/g, " ") .trim() .slice(0, limit); const isGenericMediaText = (value) => /^(\u0444\u043e\u0442\u043e|photo|image|gif|\u0432\u0438\u0434\u0435\u043e|video|\u0430\u0443\u0434\u0438\u043e|audio|\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435|voice|\u0441\u0442\u0438\u043a\u0435\u0440|sticker|\u044d\u043c\u043e\u0434\u0437\u0438|emoji|\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b[\u0435\u0451]\u043d(?:\u043d\u044b\u0435|\u043d\u043e\u0435)?\s+\u0444\u043e\u0442\u043e)$/i.test(cleanText(value, 120)); const isVisible = (element) => { if (!(element instanceof HTMLElement)) return false; const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const opened = document.querySelector("[class*='openedChat' i]") || document.querySelector("main") || document.body; const openedRect = opened.getBoundingClientRect(); const stripChatWindowPrefix = (value) => cleanText(value, 160) .replace(/^\u041e\u043a\u043d\u043e\s+\u0447\u0430\u0442\u0430\s+\u0441\s+/i, "") .trim(); const selectedTitle = cleanText(document.querySelector("aside button[class*='selected' i] h3")?.innerText, 160); const header = opened.querySelector("[class*='header' i]"); const headerTitle = cleanText( header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || header?.querySelector("button[aria-label]")?.innerText, 160); const title = stripChatWindowPrefix(selectedTitle) || stripChatWindowPrefix(headerTitle) || stripChatWindowPrefix(fallbackTitle) || "\u0427\u0430\u0442 MAX"; const parseDate = (dateLabel, timeText) => { const now = new Date(); const timeMatch = String(timeText || "").match(/(\d{1,2}):(\d{2})/); const hour = timeMatch ? Number(timeMatch[1]) : 12; const minute = timeMatch ? Number(timeMatch[2]) : 0; const text = cleanText(dateLabel, 80).toLowerCase(); const futureToleranceMs = 10 * 60 * 1000; const setDayOffset = (days) => { const result = new Date(now); result.setHours(hour, minute, 0, 0); result.setDate(result.getDate() - days); if (days === 0 && result.getTime() - now.getTime() > futureToleranceMs) { result.setDate(result.getDate() - 1); } return result.toISOString(); }; if (!text || text.includes("\u0441\u0435\u0433\u043e\u0434\u043d\u044f")) { return setDayOffset(0); } if (text.includes("\u043f\u043e\u0437\u0430\u0432\u0447\u0435\u0440\u0430")) { return setDayOffset(2); } if (text.includes("\u0432\u0447\u0435\u0440\u0430")) { return setDayOffset(1); } const months = { "\u044f\u043d\u0432\u0430\u0440\u044f": 0, "\u0444\u0435\u0432\u0440\u0430\u043b\u044f": 1, "\u043c\u0430\u0440\u0442\u0430": 2, "\u0430\u043f\u0440\u0435\u043b\u044f": 3, "\u043c\u0430\u044f": 4, "\u0438\u044e\u043d\u044f": 5, "\u0438\u044e\u043b\u044f": 6, "\u0430\u0432\u0433\u0443\u0441\u0442\u0430": 7, "\u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044f": 8, "\u043e\u043a\u0442\u044f\u0431\u0440\u044f": 9, "\u043d\u043e\u044f\u0431\u0440\u044f": 10, "\u0434\u0435\u043a\u0430\u0431\u0440\u044f": 11 }; const match = text.match(/(\d{1,2})\s+([^\s.]+)\.?\s*(\d{4})?/i); if (match) { const day = Number(match[1]); const month = months[match[2]]; const year = match[3] ? Number(match[3]) : now.getFullYear(); if (Number.isFinite(day) && month !== undefined && Number.isFinite(year)) { const parsed = new Date(year, month, day, hour, minute, 0, 0); if (!match[3] && parsed.getTime() - now.getTime() > futureToleranceMs) { parsed.setFullYear(year - 1); } return parsed.toISOString(); } } return setDayOffset(0); }; const absoluteUrl = (value) => { const raw = String(value || "").trim(); if (!raw || raw.startsWith("data:")) return null; try { return new URL(raw, location.href).href; } catch { return null; } }; const extensionFromUrl = (url) => { try { const pathname = new URL(url).pathname; const match = pathname.match(/\.([a-z0-9]{2,8})$/i); return match ? match[1].toLowerCase() : ""; } catch { return ""; } }; const srcFromSrcset = (value) => { const items = String(value || "") .split(",") .map((part) => part.trim().split(/\s+/)[0]) .filter(Boolean); return items.at(-1) || null; }; const isStickerUrl = (url) => /(^|\/)(stickers?|sticker-packs?)(\/|$)|st\.max\.ru\/stickers?/i.test(url || ""); const isEmojiUrl = (url) => /(^|\/)emojis?(\/|$)|st\.max\.ru\/emojis?/i.test(url || ""); const phoneFromText = (value) => { const match = cleanText(value, 500).match(/(?:\+?\d[\d\s().-]{6,}\d)/); if (!match) return null; const phone = match[0] .replace(/[^\d+]/g, "") .replace(/(?!^)\+/g, ""); return phone.length >= 7 ? phone : null; }; const isDownloadText = (value) => /^(download|\u0441\u043a\u0430\u0447\u0430\u0442\u044c|\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c|\u043e\u0442\u043a\u0440\u044b\u0442\u044c|open)$/i.test(cleanText(value, 80)); const vcardEscape = (value) => cleanText(value, 200) .replace(/\\/g, "\\\\") .replace(/\n/g, "\\n") .replace(/;/g, "\\;") .replace(/,/g, "\\,"); const safeContactFileName = (value) => { const clean = cleanText(value || "\u041a\u043e\u043d\u0442\u0430\u043a\u0442", 80) .replace(/[\\/:*?"<>|]+/g, " ") .trim() || "\u041a\u043e\u043d\u0442\u0430\u043a\u0442"; return `${clean.slice(0, 70)}.vcf`; }; const domDownloadUrlFromFile = (label, fileName) => `qmax-dom-download:${encodeURIComponent(JSON.stringify({ label: cleanText(label, 300), fileName: cleanText(fileName, 160) }))}`; const fileSizeBytesFromLabel = (label) => { const match = cleanText(label, 300).match(/(\d+(?:[.,]\d+)?)\s*(b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)\b/i); if (!match) return null; const value = Number(match[1].replace(",", ".")); if (!Number.isFinite(value) || value < 0) return null; const unit = match[2].toLowerCase(); const factor = unit === "gb" || unit === "\u0433\u0431" ? 1024 * 1024 * 1024 : unit === "mb" || unit === "\u043c\u0431" ? 1024 * 1024 : unit === "kb" || unit === "\u043a\u0431" ? 1024 : 1; return Math.round(value * factor); }; const kindFromMedia = (tagName, url, label) => { const ext = extensionFromUrl(url); const text = `${tagName || ""} ${url || ""} ${label || ""}`.toLowerCase(); if (ext === "vcf" || /vcard|text\/vcard/i.test(text)) return "Contact"; if (isStickerUrl(url) || /sticker|\u0441\u0442\u0438\u043a\u0435\u0440/i.test(text)) return "Sticker"; if (tagName === "video" || /\.(mp4|mov|webm|mkv|m4v|3gp)(\?|#|$)/i.test(url || "")) return "Video"; if (tagName === "audio" || /\.(ogg|opus|m4a|aac|mp3|wav|flac|oga)(\?|#|$)/i.test(url || "") || /voice|audio|голос|аудио/i.test(text)) return "VoiceNote"; if (ext === "gif" || /gif/i.test(text)) return "Gif"; if (tagName === "img" || tagName === "image" || isEmojiUrl(url) || /\.(jpg|jpeg|png|webp|gif|bmp|heic|heif|avif)(\?|#|$)/i.test(url || "")) return "Image"; return "File"; }; const contentTypeFromKind = (kind, url) => { const ext = extensionFromUrl(url); if (kind === "Gif") return "image/gif"; if (kind === "Image") return ext ? `image/${ext === "jpg" ? "jpeg" : ext}` : "image/*"; if (kind === "Video") return ext ? `video/${ext === "mov" ? "quicktime" : ext}` : "video/*"; if (kind === "VoiceNote") return ext ? `audio/${ext === "m4a" ? "mp4" : ext}` : "audio/*"; if (kind === "Sticker") return ext ? `image/${ext === "jpg" ? "jpeg" : ext}` : "image/webp"; if (kind === "Contact") return "text/vcard; charset=utf-8"; return "application/octet-stream"; }; const fileNameFromMedia = (label, kind, url, index) => { const labelLines = String(label || "") .split(/\n+| {2,}/) .map((line) => cleanText(line, 160)) .filter(Boolean); const hasKnownExtension = (value) => /\.(jpg|jpeg|png|webp|gif|bmp|heic|heif|avif|mp4|mov|webm|mkv|m4v|3gp|ogg|opus|m4a|aac|mp3|wav|flac|oga|pdf|docx?|xlsx?|pptx?|zip|rar|7z|txt|rtf|csv|html?|apk|vcf)$/i.test(value); const preferredLine = labelLines.find((line) => hasKnownExtension(line) && !isDownloadText(line)) || labelLines.find((line) => !isDownloadText(line) && !/^\d{1,2}:\d{2}$/.test(line) && !/^\d+(?:[.,]\d+)?\s*(?:b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)$/i.test(line)) || label; const clean = cleanText(preferredLine, 120).replace(/[\\/:*?"<>|]+/g, " ").trim(); const hasExtension = hasKnownExtension(clean); const isFallback = /^((photo|image|video|audio|voice|gif)|(\u0444\u043e\u0442\u043e|\u0432\u0438\u0434\u0435\u043e|\u0430\u0443\u0434\u0438\u043e|\u0433\u043e\u043b\u043e\u0441))/i.test(clean) || isDownloadText(clean) || /(\u0431\u0440\u0430\u0443\u0437\u0435\u0440.*\u043d\u0435\s+\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442|your browser.*support|not supported|\[object object\])/i.test(clean); const embeddedFileName = clean.match(/(?:^|\s)([^\s\\/:*?"<>|]+\.[a-z0-9]{2,8})(?=\s|$)/i)?.[1]; if (kind === "File" && embeddedFileName) { return embeddedFileName.slice(0, 100); } if (clean && !isFallback && (kind === "File" || hasExtension)) { return clean.slice(0, 100); } const ext = extensionFromUrl(url) || (kind === "Video" ? "mp4" : kind === "VoiceNote" ? "ogg" : kind === "Gif" ? "gif" : kind === "Sticker" ? "webp" : kind === "Contact" ? "vcf" : kind === "Image" ? "jpg" : "bin"); return `max-${kind.toLowerCase()}-${index + 1}.${ext}`; }; const cssBackgroundUrl = (element) => { const image = window.getComputedStyle(element).backgroundImage || ""; const match = image.match(/url\(["']?(.+?)["']?\)/i); return match ? absoluteUrl(match[1]) : null; }; const extractAvatarUrl = (root) => { const candidates = Array.from(root.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i], [class*='photo' i]")); for (const node of candidates) { if (!(node instanceof Element)) continue; const rect = node.getBoundingClientRect(); if (rect.width < 24 || rect.height < 24 || rect.width > 112 || rect.height > 112) continue; const source = node.currentSrc || node.src || node.href?.baseVal || node.getAttribute("src") || node.getAttribute("href") || cssBackgroundUrl(node); const url = absoluteUrl(source); if (url && !/pattern|sprite|emoji|sticker|logo/i.test(url)) { return url; } } return null; }; const deliveryStateFromMessage = (wrapper, bubble, isOutgoing) => { if (!isOutgoing) return null; const statusNodes = Array.from(wrapper.querySelectorAll("[aria-label], [title], [class*='mark' i], [class*='check' i], [class*='status' i], [class*='delivery' i], [class*='read' i], svg")); const statusText = statusNodes.map((node) => [ node.getAttribute?.("aria-label"), node.getAttribute?.("title"), node.getAttribute?.("data-testid"), node.className?.baseVal || node.className, node.textContent ].filter(Boolean).join(" ")).join(" ").toLowerCase(); const wrapperText = [ wrapper.className, bubble.className, statusText ].join(" ").toLowerCase(); if (/\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d|\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440|read|seen|viewed/.test(wrapperText)) { return "Read"; } if (/\u0434\u043e\u0441\u0442\u0430\u0432|delivered|doneall|done_all|double|checks|check-double/.test(wrapperText)) { return "Delivered"; } if (/прочитан|прочитано|просмотр|read|seen|viewed/.test(wrapperText)) { return "Read"; } if (/достав|delivered|doneall|done_all|double|checks|check-double/.test(wrapperText)) { return "Delivered"; } return "Sent"; }; const collectAttachments = (bubble) => { const candidates = []; const contactRoots = []; const contactNodes = Array.from(bubble.querySelectorAll("[class*='contact' i], [data-testid*='contact' i], [aria-label*='contact' i], [class*='phone' i], [data-testid*='phone' i], [href^='tel:']")); const bubbleText = cleanText(bubble.innerText || bubble.textContent, 800); const bubblePhone = phoneFromText(bubbleText); const smallAvatar = Array.from(bubble.querySelectorAll("img, image, [style*='background' i], [class*='avatar' i]")) .some((node) => { const rect = node.getBoundingClientRect?.(); if (!rect) return false; const ratio = rect.height > 0 ? rect.width / rect.height : 0; return rect.width >= 24 && rect.height >= 24 && rect.width <= 112 && rect.height <= 112 && ratio > 0.75 && ratio < 1.35; }); const bubbleLooksLikeContact = /contact|\u043a\u043e\u043d\u0442\u0430\u043a\u0442|\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone|mobile/i.test(bubbleText) || (smallAvatar && bubblePhone); if (bubbleLooksLikeContact) { contactRoots.push(bubble); } for (const node of contactNodes) { const root = node.closest("[class*='bubbleContent' i], [class*='message' i], [class*='contact' i], [role='button'], button, a") || node; if (!contactRoots.includes(root)) { contactRoots.push(root); } } for (const root of contactRoots) { const rootText = cleanText(root.innerText || root.textContent || root.getAttribute?.("aria-label"), 800); const semantic = [ root.getAttribute?.("aria-label"), root.getAttribute?.("title"), root.getAttribute?.("href"), root.className?.baseVal || root.className ].filter(Boolean).join(" "); const phone = phoneFromText(root.getAttribute?.("href")) || phoneFromText(rootText); if (!phone && !/contact|\u043a\u043e\u043d\u0442\u0430\u043a\u0442|\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone|mobile/i.test(`${rootText} ${semantic}`)) { continue; } const lines = String(rootText || "") .split(/\n+| {2,}/) .map((line) => cleanText(line, 120)) .filter(Boolean); const name = lines.find((line) => !phoneFromText(line) && !isDownloadText(line) && !/^(contact|\u043a\u043e\u043d\u0442\u0430\u043a\u0442|\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone|mobile)$/i.test(line) && !/^\d{1,2}:\d{2}$/.test(line) ) || "\u041a\u043e\u043d\u0442\u0430\u043a\u0442"; const vcard = `BEGIN:VCARD\nVERSION:3.0\nFN:${vcardEscape(name)}\n${phone ? `TEL;TYPE=CELL:${phone}\n` : ""}END:VCARD\n`; candidates.push({ url: null, label: name, kind: "Contact", fileName: safeContactFileName(name), contentType: "text/vcard; charset=utf-8", textContent: vcard }); } const fileButtons = Array.from(bubble.querySelectorAll("button,[role='button']")); for (const node of fileButtons) { if (!(node instanceof HTMLElement)) continue; if (contactRoots.some((root) => root !== node && root.contains?.(node))) continue; const label = cleanText( node.innerText || node.textContent || node.getAttribute("aria-label") || node.getAttribute("title"), 300); const looksLikeFile = /\.[a-z0-9]{2,8}\b/i.test(label) && /download|\u0441\u043a\u0430\u0447\u0430\u0442\u044c|\u0444\u0430\u0439\u043b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\d+(?:[.,]\d+)?\s*(?:b|kb|mb|gb|\u0431|\u043a\u0431|\u043c\u0431|\u0433\u0431)\b/i.test(label); if (!looksLikeFile) continue; const fileName = fileNameFromMedia(label, "File", "", candidates.length); if (!/\.[a-z0-9]{2,8}$/i.test(fileName)) continue; candidates.push({ url: domDownloadUrlFromFile(label, fileName), label, kind: "File", fileName, contentType: contentTypeFromKind("File", fileName), fileSizeBytes: fileSizeBytesFromLabel(label) }); } const nodes = Array.from(bubble.querySelectorAll("img, image, picture, video, audio, source, a[href], button, [role='button'], [download], [style*='background' i], [class*='sticker' i], [class*='emoji' i], [class*='media' i], [class*='photo' i], [class*='image' i], [class*='file' i], [class*='document' i], [class*='attachment' i], [class*='download' i]")); for (const node of nodes) { if (contactRoots.some((root) => root !== node && root.contains?.(node))) continue; const tagName = String(node.tagName || "").toLowerCase(); const closestLink = node.closest?.("a[href]"); const nestedLink = node.querySelector?.("a[href]"); let rawUrl = node.currentSrc || node.src || node.poster || node.href?.baseVal || node.href || node.dataset?.url || node.dataset?.href || node.dataset?.downloadUrl || node.dataset?.fileUrl || node.getAttribute("src") || node.getAttribute("href") || node.getAttribute("xlink:href") || node.getAttribute("data-url") || node.getAttribute("data-href") || node.getAttribute("data-download-url") || node.getAttribute("data-file-url") || node.getAttribute("download-url") || nestedLink?.href || closestLink?.href || srcFromSrcset(node.getAttribute("srcset")); if (!rawUrl && tagName === "picture") { const source = node.querySelector("source,img"); rawUrl = source?.currentSrc || source?.src || source?.getAttribute("src") || srcFromSrcset(source?.getAttribute("srcset")); } let url = absoluteUrl(rawUrl); if (!url) { url = cssBackgroundUrl(node); } if (!url) continue; const rect = node.getBoundingClientRect?.(); const importantSmallMedia = isEmojiUrl(url) || isStickerUrl(url); if (rect && !importantSmallMedia && (rect.width < 18 || rect.height < 18)) continue; if (rect && importantSmallMedia && (rect.width < 8 || rect.height < 8)) continue; const labelRoot = node.closest?.("[class*='file' i], [class*='document' i], [class*='attachment' i], [class*='download' i], [role='button'], button, a") || node; const label = cleanText( labelRoot.getAttribute?.("aria-label") || labelRoot.getAttribute?.("alt") || labelRoot.getAttribute?.("title") || labelRoot.getAttribute?.("download") || labelRoot.innerText || node.getAttribute("aria-label") || node.getAttribute("alt") || node.getAttribute("title") || node.getAttribute("download") || node.textContent, 160); if (/profile|avatar|\u0430\u0432\u0430\u0442\u0430\u0440/i.test(label)) continue; const mediaTag = tagName === "source" ? String(node.parentElement?.tagName || "").toLowerCase() : tagName; const kind = kindFromMedia(mediaTag, url, label); const semantic = [ tagName, node.getAttribute?.("role"), node.getAttribute?.("aria-label"), node.getAttribute?.("title"), node.className?.baseVal || node.className, label ].filter(Boolean).join(" "); const fileLike = tagName === "a" || tagName === "button" || /file|document|attachment|download|\u0444\u0430\u0439\u043b|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u043a\u0430\u0447\u0430\u0442\u044c/i.test(semantic); if (kind === "File" && !fileLike) continue; candidates.push({ url, label, kind }); } const seenUrls = new Set(); return candidates .filter((candidate) => { const identity = candidate.url || `${candidate.kind}:${candidate.fileName || candidate.label}:${candidate.textContent || ""}`; if (seenUrls.has(identity)) return false; seenUrls.add(identity); return true; }) .slice(0, 8) .map((candidate, index) => ({ externalId: `${candidate.kind}:${index}:${candidate.url || candidate.fileName || candidate.label}`, fileName: candidate.fileName || fileNameFromMedia(candidate.label, candidate.kind, candidate.url, index), contentType: candidate.contentType || contentTypeFromKind(candidate.kind, candidate.url), fileSizeBytes: candidate.textContent ? new Blob([candidate.textContent]).size : candidate.fileSizeBytes ?? null, remoteUrl: candidate.url, kind: candidate.kind, sortOrder: index, textContent: candidate.textContent || null })); }; const wrappers = Array.from(opened.querySelectorAll("[class*='messageWrapper' i]")) .filter(isVisible); const messages = []; const seen = new Set(); for (const wrapper of wrappers) { const bubble = wrapper.querySelector("[class*='bubbleContent' i]") || wrapper; const item = wrapper.closest("[class*='item' i]"); const dateLabel = cleanText(item?.querySelector("[class*='capsule' i]")?.innerText, 80); const metaText = cleanText(wrapper.querySelector("[class*='meta' i]")?.innerText, 80); const timeText = metaText.match(/\b\d{1,2}:\d{2}\b/)?.[0] || ""; const textParts = Array.from(bubble.querySelectorAll("span[class*='text' i], div[class*='text' i]")) .filter((node) => !node.closest("[class*='meta' i]")) .filter((node) => !node.closest("[class*='mark' i]")) .filter((node) => !node.closest("[class*='link' i]")) .filter((node) => !node.closest("button[class*='author' i]")) .filter((node) => !String(node.className || "").toLowerCase().includes("placeholder")) .map((node) => cleanText(node.innerText || node.textContent, 2000)) .filter((text) => text && !/^\d{1,2}:\d{2}$/.test(text)); let text = Array.from(new Set(textParts)).join("\n").trim(); if (!text) { const media = bubble.querySelector("[aria-label]"); const mediaLabel = cleanText(media?.getAttribute("aria-label"), 120); if (mediaLabel && !/profile/i.test(mediaLabel)) { text = mediaLabel; } } const attachments = collectAttachments(bubble); if (isGenericMediaText(text)) { if (attachments.length > 0) { text = ""; } else { continue; } } if ((!text || /^\d{1,2}:\d{2}$/.test(text)) && attachments.length === 0) { continue; } if (/^\d{1,2}:\d{2}$/.test(text)) { text = ""; } const rect = bubble.getBoundingClientRect(); const classText = [ wrapper.className, bubble.className, wrapper.querySelector("[class*='bordersWrapper' i]")?.className ].join(" "); const isOutgoing = /--right|outgoing|own|self/i.test(classText) || rect.left > openedRect.left + openedRect.width * 0.45; const deliveryState = deliveryStateFromMessage(wrapper, bubble, isOutgoing); const attachmentKey = attachments.map((attachment) => attachment.externalId).join(","); const key = `${text}|${attachmentKey}|${timeText}|${dateLabel}|${isOutgoing}|${Math.round(rect.y)}`; if (seen.has(key)) { continue; } seen.add(key); messages.push({ text, timeText, dateLabel, isOutgoing, deliveryState, sentAt: parseDate(dateLabel, timeText), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, attachments }); } return { url: location.href, title, avatarUrl: extractAvatarUrl(header || opened), messages }; }, requestedTitle); const externalId = requestedExternalChatId || makeDomChatExternalId(raw.title, "", null, 0); const now = new Date().toISOString(); const externalIdOrdinals = new Map(); const sentAtOffsets = new Map(); const orderedSentAt = (sentAt) => { const base = sentAt || now; const offset = sentAtOffsets.get(base) || 0; sentAtOffsets.set(base, offset + 1); if (offset === 0) { return base; } const date = new Date(base); if (Number.isNaN(date.getTime())) { return base; } date.setMilliseconds(date.getMilliseconds() + offset); return date.toISOString(); }; return { externalId, title: raw.title || requestedTitle || "MAX chat", avatarUrl: raw.avatarUrl || null, updatedAt: now, messages: raw.messages.map((message) => { const baseExternalId = stableHash(externalId, message.text, message.timeText, message.sentAt, message.isOutgoing); const ordinal = externalIdOrdinals.get(baseExternalId) || 0; externalIdOrdinals.set(baseExternalId, ordinal + 1); const messageExternalId = ordinal === 0 ? `domhist:${baseExternalId}` : `domhist:${baseExternalId}:${ordinal}`; return { externalId: messageExternalId, senderExternalId: message.isOutgoing ? "self" : externalId, senderName: message.isOutgoing ? null : (raw.title || requestedTitle || null), isOutgoing: message.isOutgoing, text: message.text, sentAt: orderedSentAt(message.sentAt), deliveryState: message.deliveryState || null, rect: message.rect || null, attachments: (message.attachments || []).map((attachment, index) => ({ ...attachment, externalId: `dommedia:${stableHash(externalId, messageExternalId, attachment.remoteUrl || attachment.externalId || "", String(index))}` })) }; }) }; } async function extractOpenChatPresence(p) { return await p.evaluate(() => { const cleanText = (value, limit = 500) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, limit); const opened = document.querySelector("[class*='openedChat' i]") || document.querySelector("main") || document.body; const header = opened.querySelector("[class*='header' i]") || opened; const candidates = Array.from(header.querySelectorAll("[class*='subtitle' i], [class*='status' i], [class*='typing' i], span, div")) .map((node) => cleanText(node.innerText || node.textContent, 160)) .filter(Boolean); const bodyCandidates = Array.from(opened.querySelectorAll("[class*='typing' i], [aria-label*='typing' i], [aria-label*='\u043f\u0435\u0447\u0430\u0442' i]")) .map((node) => cleanText( node.innerText || node.textContent || node.getAttribute("aria-label") || node.getAttribute("title"), 160)) .filter(Boolean); const all = [...candidates, ...bodyCandidates]; const statusText = all.find((text) => /(\u043f\u0435\u0447\u0430\u0442|\u043d\u0430\u0431\u0438\u0440\u0430|\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430|typing|recording)/i.test(text) ) || null; return { isTyping: Boolean(statusText), statusText, updatedAt: new Date().toISOString() }; }); } async function clearSidebarSearch(p) { await p.evaluate(() => { const isVisible = (element) => { if (!(element instanceof HTMLElement)) return false; const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const setNativeValue = (element, value) => { const descriptor = Object.getOwnPropertyDescriptor(element.constructor.prototype, "value"); if (descriptor?.set) { descriptor.set.call(element, value); } else { element.value = value; } }; const inputs = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']")) .filter(isVisible) .filter((element) => { const rect = element.getBoundingClientRect(); const text = [ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("data-testid"), element.className, element.id, element.getAttribute("name") ].join(" ").toLowerCase(); return rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32 && /search|\u043f\u043e\u0438\u0441\u043a/.test(text); }); for (const input of inputs) { if ("value" in input) { if (!input.value) continue; setNativeValue(input, ""); } else { if (!input.textContent) continue; input.textContent = ""; } input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null })); input.dispatchEvent(new Event("change", { bubbles: true })); } }).catch(() => {}); await p.keyboard.press("Escape").catch(() => {}); await p.waitForTimeout(150); } async function openDomChatByExternalId(p, externalChatId) { const title = decodeDomChatTitle(externalChatId) || externalChatId; if (!title) { return false; } await clearSidebarSearch(p); return await p.evaluate((requestedTitle) => { const cleanText = (value) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim(); const normalize = (value) => cleanText(value).toLowerCase(); const wanted = normalize(requestedTitle); if (!wanted) { return false; } const opened = document.querySelector("[class*='openedChat' i]") || document.querySelector("main") || document.body; const header = opened.querySelector("[class*='header' i]"); const headerTitle = normalize( header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || header?.querySelector("button[aria-label]")?.innerText); const selectedTitle = normalize(document.querySelector("aside button[class*='selected' i] h3")?.innerText); const currentTitles = [selectedTitle, headerTitle].filter(Boolean); if (currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) { return true; } const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]")); const match = buttons.find((button) => { const titleNode = button.querySelector("h3, [class*='title' i], [class*='name' i]"); const title = normalize(titleNode?.innerText || titleNode?.textContent); const text = normalize(button.innerText || button.textContent); return title === wanted || title.includes(wanted) || wanted.includes(title) || text.startsWith(wanted); }); if (!match) { return false; } match.scrollIntoView({ block: "center", inline: "nearest" }); match.click(); return true; }, title).catch(() => false); } async function ensureDomChatOpen(p, externalChatId, waitMs = 600) { const title = decodeDomChatTitle(externalChatId) || externalChatId; if (!title) { return true; } const opened = await openDomChatByExternalId(p, externalChatId); if (!opened) { return false; } await p.waitForTimeout(waitMs); const verified = await p.evaluate((requestedTitle) => { const cleanText = (value) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim(); const normalize = (value) => cleanText(value).toLowerCase(); const wanted = normalize(requestedTitle); const opened = document.querySelector("[class*='openedChat' i]") || document.querySelector("main") || document.body; const selectedTitle = cleanText(document.querySelector("aside button[class*='selected' i] h3")?.innerText, 160); const header = opened.querySelector("[class*='header' i]"); const headerTitle = cleanText( header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText || header?.querySelector("button[aria-label]")?.innerText, 160); const current = normalize(selectedTitle || headerTitle); if (!current) { return null; } return current === wanted || current.includes(wanted) || wanted.includes(current); }, title).catch(() => null); return verified === null ? opened : verified; } async function sendTextAndConfirm(p, externalChatId, text) { const wanted = cleanComparableText(text, 1000); if (!wanted) { return null; } const initialMatches = await countOutgoingTextMatches(p, externalChatId, text); const attempts = [ { name: "send button", action: () => clickSendButton(p) }, { name: "Enter", action: async () => { await p.keyboard.press("Enter"); return true; } }, { name: "Control+Enter", action: async () => { await p.keyboard.press("Control+Enter"); return true; } }, { name: "composer fallback button", action: () => clickComposerRightmostButton(p) } ]; for (const attempt of attempts) { if (!(await fillComposerText(p, text, true))) { return null; } const composerText = await readComposerText(p); if (composerText !== null && !messageTextMatches(composerText, wanted)) { continue; } const triggered = await attempt.action().catch(() => false); if (!triggered) { continue; } const externalId = await findSentOutgoingExternalId(p, externalChatId, text, initialMatches, 10000); if (externalId) { return externalId; } const currentComposerText = await readComposerText(p); if (currentComposerText !== null && currentComposerText.length === 0) { return null; } } return null; } async function fillComposerText(p, text, clearFirst) { for (let attempt = 0; attempt < 2; attempt++) { if (!(await focusComposer(p))) { break; } if (clearFirst) { await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); await p.keyboard.press("Backspace"); } await p.keyboard.insertText(text); await p.waitForTimeout(300); const currentText = await readComposerText(p); if (currentText !== null && messageTextMatches(currentText, text)) { return true; } } const selectors = [ "[data-testid='composer'] [role='textbox']", "[data-testid='composer'] [contenteditable='true']", "[class*='composer' i] [role='textbox']", "[class*='composer' i] [contenteditable='true']", "[class*='composer' i] [class*='contenteditable' i]" ]; for (const selector of selectors) { try { const composer = p.locator(selector).last(); if ((await composer.count()) === 0) { continue; } await composer.click({ timeout: 3000 }); if (clearFirst) { await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); await p.keyboard.press("Backspace"); } await p.keyboard.insertText(text); await p.waitForTimeout(250); const currentText = await readComposerText(p); if (currentText !== null && messageTextMatches(currentText, text)) { return true; } } catch { // Try the next composer shape. } } return false; } async function focusComposer(p) { const point = await p.evaluate(() => { const cleanText = (value) => String(value || "") .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .toLowerCase(); const isVisible = (element) => { if (!(element instanceof HTMLElement)) return false; const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const isSearchLike = (element, rect) => { const text = cleanText([ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("data-testid"), element.className, element.id, element.getAttribute("name") ].join(" ")); return /search|\u043f\u043e\u0438\u0441\u043a/.test(text) || (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); }; const selectors = [ "[data-testid='composer'] [role='textbox']", "[data-testid='composer'] [contenteditable='true']", "[class*='composer' i] [role='textbox']", "[class*='composer' i] [contenteditable='true']", "[class*='composer' i] [class*='contenteditable' i]", "[role='textbox'][class*='contenteditable' i]", "[contenteditable='true']", "textarea" ]; const candidates = selectors .flatMap((selector) => Array.from(document.querySelectorAll(selector))) .filter(isVisible) .filter((element) => { const rect = element.getBoundingClientRect(); return !isSearchLike(element, rect) && rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.50; }) .map((element) => { const rect = element.getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2), bottom: Math.round(rect.bottom), area: Math.round(rect.width * rect.height) }; }) .sort((a, b) => b.bottom - a.bottom || b.area - a.area); return candidates[0] || { x: Math.round(window.innerWidth * 0.55), y: Math.round(window.innerHeight - 40) }; }).catch(() => null); if (!point) { return false; } await p.mouse.click(point.x, point.y); await p.waitForTimeout(150); return true; } async function waitForComposerToClear(p, timeoutMs = 4000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const text = await readComposerText(p); if (text !== null && text.length === 0) { return true; } await p.waitForTimeout(250); } return false; } async function readComposerText(p) { return await p.evaluate(() => { const cleanText = (value) => String(value || "") .replace(/\u00a0/g, " ") .replace(/[\u200b\u200c\u200d\ufeff]/g, "") .replace(/\s+/g, " ") .trim(); const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const isSearchLike = (element, rect) => { const text = cleanText([ element.getAttribute("aria-label"), element.getAttribute("placeholder"), element.getAttribute("title"), element.getAttribute("data-testid"), element.className, element.id, element.getAttribute("name") ].join(" ")).toLowerCase(); return /search|\u043f\u043e\u0438\u0441\u043a/.test(text) || (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); }; const selectors = [ "[data-testid='composer'] [role='textbox']", "[data-testid='composer'] [contenteditable='true']", "[class*='composer' i] [role='textbox']", "[class*='composer' i] [contenteditable='true']", "[class*='composer' i] [class*='contenteditable' i]", "[role='textbox']", "[contenteditable='true']", "textarea" ]; const candidates = selectors .flatMap((selector) => Array.from(document.querySelectorAll(selector))) .filter((element) => element instanceof HTMLElement && isVisible(element)) .filter((element) => { const rect = element.getBoundingClientRect(); return !isSearchLike(element, rect) && rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.50; }) .sort((a, b) => { const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect(); return br.bottom - ar.bottom || (br.width * br.height) - (ar.width * ar.height); }); const composer = candidates[0]; if (!composer) { return null; } const value = "value" in composer ? composer.value : ""; return cleanText(value || composer.innerText || composer.textContent); }).catch(() => null); } function messageTextMatches(value, wantedText) { const text = cleanComparableText(value, 1000).toLowerCase(); const wanted = cleanComparableText(wantedText, 1000).toLowerCase(); return Boolean(text && wanted && ( text === wanted || text.endsWith(`: ${wanted}`) || text.endsWith(wanted) || text.includes(wanted) )); } async function getOutgoingTextMatches(p, externalChatId, text) { const wanted = cleanComparableText(text, 1000); if (!wanted) { return []; } const history = await extractOpenChatHistory(p, externalChatId).catch(() => null); return (history?.messages || []).filter((message) => message.isOutgoing && messageTextMatches(message.text, wanted)); } async function countOutgoingTextMatches(p, externalChatId, text) { return (await getOutgoingTextMatches(p, externalChatId, text)).length; } async function findSentOutgoingExternalId(p, externalChatId, text, previousMatchCount = 0, timeoutMs = 15000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await p.waitForTimeout(500); const matches = await getOutgoingTextMatches(p, externalChatId, text); if (matches.length > previousMatchCount) { const latest = matches[matches.length - 1]; if (latest?.externalId) { return latest.externalId; } } } return null; } async function uploadAttachmentFile(p, path, caption) { try { const fileStat = await fs.stat(path); if (!fileStat.isFile()) { return false; } } catch { return false; } let attached = false; const uploadButton = p.locator("[class*='composer' i] button").first(); if ((await uploadButton.count()) > 0) { try { const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 5000 }); await uploadButton.click({ timeout: 3000 }); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(path); attached = true; } catch { attached = false; } } if (!attached) { try { const input = p.locator("input[type='file']").last(); if ((await input.count()) > 0) { await input.setInputFiles(path); attached = true; } } catch { attached = false; } } if (!attached) { return false; } await p.waitForTimeout(1200); if (caption.trim()) { await fillComposerText(p, caption.trim(), false); await p.waitForTimeout(300); } if (!(await clickSendButton(p))) { return false; } await p.waitForTimeout(1200); return caption.trim() ? await waitForComposerToClear(p, 3000) : true; } async function clickSendButton(p) { const point = await p.evaluate(() => { const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const buttons = Array.from(document.querySelectorAll("button, [role='button']")) .filter((button) => !button.disabled && isVisible(button)); const match = buttons.find((button) => { const text = [ button.getAttribute("aria-label"), button.getAttribute("title"), button.getAttribute("data-testid"), button.className, button.innerText, button.textContent ].join(" ").trim().toLowerCase(); return /send|submit|\u043e\u0442\u043f\u0440\u0430\u0432/.test(text); }); if (!match) { return null; } const rect = match.getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; }).catch(() => false); if (!point) { return false; } await p.mouse.click(point.x, point.y); return true; } async function clickComposerRightmostButton(p) { const point = await p.evaluate(() => { const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const buttons = Array.from(document.querySelectorAll("[class*='composer' i] button, [class*='composer' i] [role='button']")) .filter((button) => !button.disabled && isVisible(button)) .sort((a, b) => b.getBoundingClientRect().left - a.getBoundingClientRect().left); const button = buttons[0]; if (!button) { return null; } const rect = button.getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; }).catch(() => false); if (!point) { return false; } await p.mouse.click(point.x, point.y); return true; } async function editMessageInMax(p, externalChatId, externalMessageId, currentText, text) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) { return { success: false, error: "MAX message was not found on the visible chat history." }; } if (!(await openMessageMenu(p, target.rect))) { return { success: false, error: "MAX message menu was not opened." }; } if (!(await clickActionByText(p, ["\u0440\u0435\u0434\u0430\u043a\u0442", "\u0438\u0437\u043c\u0435\u043d", "edit"]))) { return { success: false, error: "MAX edit action was not found in the message menu." }; } await p.waitForTimeout(600); if (!(await fillComposerText(p, text, true))) { return { success: false, error: "MAX edit composer was not found." }; } if (!(await clickSendButton(p))) { await p.keyboard.press("Enter"); } await p.waitForTimeout(1000); return { success: true, error: null }; } async function deleteMessageInMax(p, externalChatId, externalMessageId, currentText) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) { return { success: false, error: "MAX message was not found on the visible chat history." }; } if (!(await openMessageMenu(p, target.rect))) { return { success: false, error: "MAX message menu was not opened." }; } if (!(await clickActionByText(p, ["\u0443\u0434\u0430\u043b", "delete", "remove"]))) { return { success: false, error: "MAX delete action was not found in the message menu." }; } await p.waitForTimeout(500); await clickActionByText(p, ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "\u0434\u0430", "delete", "yes", "ok"]); await p.waitForTimeout(1000); return { success: true, error: null }; } async function setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) { return { success: false, error: "MAX message was not found on the visible chat history." }; } if (!(await openMessageMenu(p, target.rect))) { return { success: false, error: "MAX message menu was not opened." }; } if (await clickActionByText(p, [emoji])) { await p.waitForTimeout(600); return { success: true, error: null }; } await clickActionByText(p, ["\u0440\u0435\u0430\u043a", "reaction", "emoji"]); await p.waitForTimeout(500); if (!(await clickActionByText(p, [emoji]))) { return { success: false, error: "MAX reaction picker did not expose the requested emoji." }; } await p.waitForTimeout(600); return { success: true, error: null }; } async function clearMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) { return { success: false, error: "MAX message was not found on the visible chat history." }; } if (await clickReactionNearMessage(p, target.rect, emoji)) { await p.waitForTimeout(600); return { success: true, error: null }; } if (!(await openMessageMenu(p, target.rect))) { return { success: false, error: "MAX message menu was not opened." }; } if (await clickActionByText(p, [emoji, "\u0443\u0431\u0440\u0430\u0442\u044c \u0440\u0435\u0430\u043a", "remove reaction", "clear reaction"])) { await p.waitForTimeout(600); return { success: true, error: null }; } return { success: false, error: "MAX reaction clear action was not found." }; } async function locateMessageForAction(p, externalChatId, externalMessageId, currentText) { if (externalChatId) { const opened = await ensureDomChatOpen(p, externalChatId, 700); if (!opened) { return null; } } const history = await extractOpenChatHistory(p, externalChatId); const textHint = cleanComparableText(currentText, 500); let message = history.messages.find((item) => item.externalId === externalMessageId); if (!message && textHint) { const matches = history.messages.filter((item) => { const text = cleanComparableText(item.text, 500); return text && (text === textHint || text.includes(textHint) || textHint.includes(text)); }); message = matches[matches.length - 1]; } if (!message?.rect) { return null; } return message; } async function openMessageMenu(p, rect) { const point = rectCenter(rect); await p.mouse.move(point.x, point.y); await p.waitForTimeout(250); if (await clickMessageMenuButton(p, rect)) { await p.waitForTimeout(500); if (await hasActionSurface(p)) { return true; } } await p.mouse.click(point.x, point.y, { button: "right" }); await p.waitForTimeout(500); if (await hasActionSurface(p)) { return true; } await p.mouse.dblclick(point.x, point.y); await p.waitForTimeout(500); return await hasActionSurface(p); } async function clickMessageMenuButton(p, rect) { return await p.evaluate((targetRect) => { const isVisible = (element) => { const style = window.getComputedStyle(element); const nodeRect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && nodeRect.width > 0 && nodeRect.height > 0 && nodeRect.bottom >= 0 && nodeRect.right >= 0 && nodeRect.top <= window.innerHeight && nodeRect.left <= window.innerWidth; }; const expanded = { left: targetRect.x - 60, right: targetRect.x + targetRect.width + 90, top: targetRect.y - 40, bottom: targetRect.y + targetRect.height + 40 }; const inArea = (element) => { const rect = element.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; return centerX >= expanded.left && centerX <= expanded.right && centerY >= expanded.top && centerY <= expanded.bottom; }; const buttons = Array.from(document.querySelectorAll("button,[role='button']")) .filter((button) => button instanceof HTMLElement && isVisible(button) && inArea(button)); const match = buttons.find((button) => { const text = String(button.innerText || button.getAttribute("aria-label") || button.getAttribute("title") || "").toLowerCase(); const cls = String(button.className || "").toLowerCase(); return text.includes("\u0435\u0449") || text.includes("more") || text.includes("menu") || text.includes("actions") || cls.includes("more") || cls.includes("menu") || text === "\u22ef" || text === "\u2026"; }) || buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0]; if (!match) { return false; } match.click(); return true; }, rect).catch(() => false); } async function hasActionSurface(p) { return await p.evaluate(() => { const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const labels = /(\u0440\u0435\u0434\u0430\u043a\u0442|\u0438\u0437\u043c\u0435\u043d|\u0443\u0434\u0430\u043b|\u0440\u0435\u0430\u043a|edit|delete|remove|reaction)/i; return Array.from(document.querySelectorAll("[role='menu'],[role='menuitem'],[class*='menu' i],[class*='popover' i],[class*='dropdown' i],button")) .filter((element) => element instanceof HTMLElement && isVisible(element)) .some((element) => labels.test(element.innerText || element.textContent || element.getAttribute("aria-label") || "")); }).catch(() => false); } async function clickActionByText(p, labels) { return await p.evaluate((rawLabels) => { const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); if (normalizedLabels.length === 0) { return false; } const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const textOf = (element) => String( element.innerText || element.textContent || element.getAttribute("aria-label") || element.getAttribute("title") || "" ).trim().toLowerCase(); const candidates = Array.from(document.querySelectorAll("button,[role='button'],[role='menuitem'],span,div")) .filter((element) => element instanceof HTMLElement && isVisible(element)); const match = candidates.find((element) => { const text = textOf(element); return text && normalizedLabels.some((label) => text === label || text.includes(label)); }); if (!match) { return false; } const clickable = match.closest("button,[role='button'],[role='menuitem']") || match; clickable.click(); return true; }, labels).catch(() => false); } async function clickReactionNearMessage(p, rect, emoji) { return await p.evaluate(({ targetRect, requestedEmoji }) => { const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const expanded = { left: targetRect.x - 40, right: targetRect.x + targetRect.width + 80, top: targetRect.y - 20, bottom: targetRect.y + targetRect.height + 70 }; const inArea = (element) => { const rect = element.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; return centerX >= expanded.left && centerX <= expanded.right && centerY >= expanded.top && centerY <= expanded.bottom; }; const match = Array.from(document.querySelectorAll("button,[role='button'],span,div")) .filter((element) => element instanceof HTMLElement && isVisible(element) && inArea(element)) .find((element) => String(element.innerText || element.textContent || element.getAttribute("aria-label") || "").includes(requestedEmoji)); if (!match) { return false; } const clickable = match.closest("button,[role='button']") || match; clickable.click(); return true; }, { targetRect: rect, requestedEmoji: emoji }).catch(() => false); } function rectCenter(rect) { return { x: Math.round(Number(rect.x || 0) + Number(rect.width || 0) / 2), y: Math.round(Number(rect.y || 0) + Number(rect.height || 0) / 2) }; } function makeDomChatExternalId(title, text, href, index) { const encodedTitle = Buffer.from(String(title || ""), "utf8") .toString("base64url") .slice(0, 120); return `dom:${stableHash("chat", title, href)}:${encodedTitle}`; } function decodeDomChatTitle(externalChatId) { const parts = String(externalChatId || "").split(":"); if (parts.length < 3 || parts[0] !== "dom") { return null; } try { return Buffer.from(parts[2], "base64url").toString("utf8"); } catch { return null; } } function stableHash(...parts) { return createHash("sha256") .update(parts.map((part) => String(part ?? "")).join("\n")) .digest("hex") .slice(0, 24); } function clampNumber(value, min, max, fallback) { const number = Number(value); if (!Number.isFinite(number)) { return fallback; } return Math.max(min, Math.min(max, Math.trunc(number))); } async function fillFirst(p, value, selectors) { for (const selector of selectors) { try { const locator = p.locator(selector).first(); if ((await locator.count()) === 0) continue; await clearAndInsertText(p, locator, value); return true; } catch { // Try the next likely selector. } } return false; } async function clearAndInsertText(p, locator, text) { await locator.click({ timeout: 5000 }); await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); await p.keyboard.press("Backspace"); await p.keyboard.insertText(String(text)); } async function fillLoginCode(p, code) { const filled = await p.evaluate((value) => { const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth; }; const setControlValue = (node, nextValue) => { if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { const proto = node instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); if (descriptor?.set) { descriptor.set.call(node, nextValue); } else { node.value = nextValue; } } else { node.textContent = nextValue; } node.dispatchEvent(new InputEvent("input", { bubbles: true, data: nextValue, inputType: "insertText" })); node.dispatchEvent(new Event("change", { bubbles: true })); }; const inputs = Array.from(document.querySelectorAll("input, textarea, [contenteditable='true']")) .filter((node) => node instanceof HTMLElement && isVisible(node)) .filter((node) => { const type = String(node.getAttribute("type") || "").toLowerCase(); return !["hidden", "submit", "button", "checkbox", "radio"].includes(type); }); const codeInputs = inputs.filter((node) => { const hint = [ node.getAttribute("autocomplete"), node.getAttribute("name"), node.getAttribute("placeholder"), node.getAttribute("aria-label"), node.className, node.getAttribute("inputmode") ].join(" "); return /one-time|otp|code|pin|verification|numeric/i.test(hint) || Number(node.getAttribute("maxlength") || 0) <= 1; }); const targets = codeInputs.length > 0 ? codeInputs : inputs; if (targets.length === 0) { return false; } if (targets.length > 1 && targets.every((node) => Number(node.getAttribute("maxlength") || 1) <= 1)) { for (let index = 0; index < Math.min(value.length, targets.length); index += 1) { targets[index].focus(); setControlValue(targets[index], value[index]); } return true; } targets[0].focus(); setControlValue(targets[0], value); return true; }, code).catch(() => false); if (filled) { return true; } const inputFilled = await fillFirst(p, code, [ "input[autocomplete='one-time-code']", "input[inputmode='numeric']", "input[type='tel']", "input[type='text']", "input:not([type])", "input" ]); if (inputFilled) { return true; } for (const selector of ["[role='group'].code", "[role='group'][class*='code' i]", ".code", "[role='group']"]) { try { const group = p.locator(selector).first(); if ((await group.count()) === 0) continue; await group.click({ timeout: 3000 }); await p.keyboard.insertText(code); return true; } catch { // Try the next OTP container shape. } } return false; } async function openPhoneLogin(p) { if (await hasPhoneInput(p)) { return; } const phoneLoginPattern = /(\u0432\u043e\u0439\u0442\u0438.*\u043d\u043e\u043c\u0435\u0440|\u043d\u043e\u043c\u0435\u0440.*\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone)/i; try { const byText = p.getByText(phoneLoginPattern).last(); if ((await byText.count()) > 0) { await byText.click({ timeout: 3000 }); await p.waitForTimeout(1200); } } catch { // Fall through to DOM text matching below. } if (await hasPhoneInput(p)) { return; } await p.evaluate(() => { const phoneLoginPattern = /(\u0432\u043e\u0439\u0442\u0438.*\u043d\u043e\u043c\u0435\u0440|\u043d\u043e\u043c\u0435\u0440.*\u0442\u0435\u043b\u0435\u0444\u043e\u043d|phone)/i; const elements = Array.from(document.querySelectorAll("button,a,[role=button],div,span")); const match = elements.find((element) => phoneLoginPattern.test(element.textContent || "")); if (match) { match.click(); } }); await p.waitForTimeout(1200); } async function hasPhoneInput(p) { return (await p.locator("input[type='tel'], input[autocomplete='tel']").count()) > 0; } async function waitForLoginStage(p, expectedStage, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { const stage = await detectLoginStage(p); if (stage === expectedStage) { return true; } if (timeoutMs === 0) { return false; } await p.waitForTimeout(1000); } return false; } async function detectLoginStage(p) { const title = await safeTitle(p); const url = p.url(); const bodyText = await safeBodyText(p); return detectLoginStageFromText(url, title, bodyText); } function detectLoginStageFromText(url, title, bodyText) { const currentUrl = String(url || "").toLowerCase(); const currentTitle = String(title || "").toLowerCase(); const body = String(bodyText || "").replace(/\s+/g, " ").trim().toLowerCase(); if (!body && currentTitle.includes("max") && currentUrl.includes("web.max.ru")) { return "Loading"; } if (body.includes("\u043d\u0435 \u0440\u043e\u0431\u043e\u0442")) { return "Captcha"; } if (body.includes("qr") || body.includes("\u0432\u043e\u0439\u0434\u0438\u0442\u0435 \u0432 max \u043f\u043e qr")) { return "QrLogin"; } const looksLikeAuthorizedShell = currentUrl.includes("web.max.ru") && (/web\.max\.ru\/\d+/.test(currentUrl) || currentTitle.includes("\u0447\u0430\u0442") || currentTitle.includes("\u043d\u0435\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d") || currentTitle.includes("max")); if (looksLikeAuthorizedShell && !currentUrl.includes("login")) { return "Authorized"; } const phoneScreenMarkers = [ "\u0441 \u043a\u0430\u043a\u0438\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c", "\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", "\u043d\u0430 \u043d\u0435\u0433\u043e \u043f\u0440\u0438\u0434\u0435\u0442 \u0441\u043c\u0441 \u0441 \u043a\u043e\u0434\u043e\u043c" ]; if (phoneScreenMarkers.some((marker) => body.includes(marker))) { return "PhoneEntry"; } const codeMarkers = [ "\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b\u0438 \u043a\u043e\u0434", "\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434", "\u043a\u043e\u0434 \u0438\u0437 sms", "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043c\u0441", "\u043a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434", "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043e\u043e\u0431\u0449\u0435\u043d", "\u0435\u0441\u043b\u0438 \u043d\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u0441\u043c\u0441", "\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434" ]; if (codeMarkers.some((marker) => body.includes(marker))) { return "CodeEntry"; } const loginMarkers = [ "\u0432\u043e\u0439\u0442\u0438", "\u043f\u043e\u043b\u0438\u0442\u0438\u043a", "\u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d" ]; if (loginMarkers.some((marker) => body.includes(marker))) { return "Login"; } if (looksLikeAuthorizedShell) { return "Authorized"; } return "Unknown"; } async function isCodeEntryScreen(p) { return await p.evaluate(() => { const text = String(document.body?.innerText || "").replace(/\s+/g, " ").trim().toLowerCase(); const inputs = Array.from(document.querySelectorAll("input")); const hasOneTimeCodeInput = inputs.some((input) => String(input.getAttribute("autocomplete") || "").toLowerCase() === "one-time-code"); if (hasOneTimeCodeInput) { return true; } if (text.includes("\u043d\u0435 \u0440\u043e\u0431\u043e\u0442")) { return false; } const phoneScreenMarkers = [ "\u0441 \u043a\u0430\u043a\u0438\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c", "\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", "\u043d\u0430 \u043d\u0435\u0433\u043e \u043f\u0440\u0438\u0434\u0435\u0442 \u0441\u043c\u0441 \u0441 \u043a\u043e\u0434\u043e\u043c" ]; if (phoneScreenMarkers.some((marker) => text.includes(marker))) { return false; } const codeMarkers = [ "\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b\u0438 \u043a\u043e\u0434", "\u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434", "\u043a\u043e\u0434 \u0438\u0437 sms", "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043c\u0441", "\u043a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434", "\u043a\u043e\u0434 \u0438\u0437 \u0441\u043e\u043e\u0431\u0449\u0435\u043d", "\u0435\u0441\u043b\u0438 \u043d\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u0441\u043c\u0441", "\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u043e\u0432\u044b\u0439 \u043a\u043e\u0434" ]; return inputs.length > 0 && codeMarkers.some((marker) => text.includes(marker)); }); } function normalizePhoneNumberForMax(phoneNumber) { const digits = String(phoneNumber || "").replace(/\D/g, ""); if (digits.length === 11 && (digits.startsWith("8") || digits.startsWith("7"))) { return digits.slice(1); } return digits || String(phoneNumber || "").trim(); } function normalizeLoginCode(value) { return String(value || "").replace(/\D/g, "").trim(); } async function cleanupChromiumProfileLocks(profileDir) { await Promise.all(["SingletonLock", "SingletonCookie", "SingletonSocket"].map((name) => fs.rm(`${profileDir}/${name}`, { force: true, recursive: true }).catch(() => {}) )); } async function clickLikelySubmitFixed(p) { const labels = [ "\u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c", "\u0432\u043e\u0439\u0442\u0438", "\u0434\u0430\u043b\u0435\u0435", "\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c", "continue", "next", "login" ]; const clicked = await p.evaluate((knownLabels) => { const normalized = knownLabels.map((x) => x.toLowerCase()); const buttons = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]")); const match = buttons.find((button) => { const text = (button.innerText || button.value || button.getAttribute("aria-label") || "").trim().toLowerCase(); return !button.disabled && text && normalized.some((label) => text.includes(label)); }) || buttons.find((button) => !button.disabled); if (match) { match.click(); return true; } return false; }, labels); if (!clicked) { await p.keyboard.press("Enter"); } } async function clickLikelySubmit(p) { const labels = ["продолжить", "войти", "далее", "отправить", "continue", "next", "login"]; const clicked = await p.evaluate((knownLabels) => { const normalized = knownLabels.map((x) => x.toLowerCase()); const buttons = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]")); const match = buttons.find((button) => { const text = (button.innerText || button.value || button.getAttribute("aria-label") || "").trim().toLowerCase(); return text && normalized.some((label) => text.includes(label)); }) || buttons.find((button) => !button.disabled); if (match) { match.click(); return true; } return false; }, labels); if (!clicked) { await p.keyboard.press("Enter"); } } async function clickTextIfVisible(p, text) { try { const locator = p.getByText(text, { exact: false }).first(); if ((await locator.count()) > 0) { await locator.click({ timeout: 1500 }); } } catch { // Chat may already be open. } } async function safeTitle(p) { try { return await p.title(); } catch { return null; } } async function safeBodyText(p) { try { return (await p.locator("body").innerText({ timeout: 1500 })).replace(/\s+/g, " ").trim(); } catch { return ""; } } function likelyAuthorized(url, title, bodyText) { if (!url) return false; if (url.toLowerCase().includes("login")) return false; const body = String(bodyText || "").toLowerCase(); const loginMarkers = [ "\u043d\u0435 \u0440\u043e\u0431\u043e\u0442", "\u0432\u043e\u0439\u0442\u0438", "\u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", "qr", "\u043a\u043e\u0434\u043e\u043c" ]; if (loginMarkers.some((marker) => body.includes(marker))) return false; return String(title || "").toLowerCase().includes("max"); }