Files
QMAX/worker/src/server.js
T
2026-07-04 14:34:59 +03:00

3913 lines
142 KiB
JavaScript

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:";
const domAudioPrefix = "qmax-dom-audio:";
const domStickerPrefix = "qmax-dom-sticker:";
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);
await resetSidebarListToTop(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;
}
}
await scrollOpenChatToLatest(p);
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) || isDomAudioUrl(raw) || isDomStickerUrl(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 isDomAudioUrl(value) {
return String(value || "").startsWith(domAudioPrefix);
}
function isDomStickerUrl(value) {
return String(value || "").startsWith(domStickerPrefix);
}
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 parseDomAudioUrl(value) {
const raw = String(value || "");
if (!isDomAudioUrl(raw)) {
return null;
}
try {
return JSON.parse(decodeURIComponent(raw.slice(domAudioPrefix.length)));
} catch {
return null;
}
}
function parseDomStickerUrl(value) {
const raw = String(value || "");
if (!isDomStickerUrl(raw)) {
return null;
}
try {
return JSON.parse(decodeURIComponent(raw.slice(domStickerPrefix.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 (isDomAudioUrl(mediaUrl)) {
return await downloadDomAudioThroughPlay(p, parseDomAudioUrl(mediaUrl));
}
if (isDomStickerUrl(mediaUrl)) {
return await downloadDomStickerThroughScreenshot(p, parseDomStickerUrl(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
};
}
async function downloadDomAudioThroughPlay(p, request) {
const duration = String(request?.duration || "").trim();
const timeText = String(request?.timeText || "").trim();
const label = String(request?.label || duration || timeText || "voice").trim();
const point = await p.evaluate(({ duration, timeText, label }) => {
const cleanText = (value, limit = 500) => String(value || "")
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, limit);
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 roots = Array.from(document.querySelectorAll("[class*='attachAudio' i], [class*='voice' i], [class*='audio' i]"))
.filter((node) => node instanceof HTMLElement && isVisible(node))
.map((root) => {
const wrapper = root.closest("[class*='messageWrapper' i], [role='listitem'], [class*='item' i]") || root;
const text = cleanText(`${root.innerText || root.textContent || ""} ${wrapper.innerText || wrapper.textContent || ""}`, 1000);
const semantic = [
root.className,
root.getAttribute("aria-label"),
root.getAttribute("title"),
text
].filter(Boolean).join(" ").toLowerCase();
let score = /attachaudio|audio|voice|\u0433\u043e\u043b\u043e\u0441|\u0430\u0443\u0434\u0438\u043e/i.test(semantic) ? 4 : 0;
if (duration && text.includes(duration)) score += 8;
if (timeText && text.includes(timeText)) score += 3;
if (label && text.toLowerCase().includes(label.toLowerCase())) score += 1;
const rect = root.getBoundingClientRect();
return { root, score, bottom: rect.bottom };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || b.bottom - a.bottom);
const root = roots[0]?.root;
if (!root) {
return null;
}
const clickable = root.querySelector("button,[role='button']") || root;
const rect = clickable.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return null;
}
const rootRect = root.getBoundingClientRect();
const leftPlayX = rootRect.left + Math.min(28, Math.max(12, rootRect.width * 0.12));
return {
x: Math.round(clickable === root ? leftPlayX : rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2)
};
}, { duration, timeText, label }).catch(() => null);
if (!point) {
throw new Error(`MAX voice player was not found for ${label}.`);
}
const waitForVoiceResponse = () => p.waitForResponse((response) => {
try {
const status = response.status();
if (status !== 200 && status !== 206) {
return false;
}
const url = response.url();
if (/\/_app\/immutable\/assets\//i.test(url)) {
return false;
}
const request = response.request();
const resourceType = request.resourceType();
const headers = response.headers();
const contentType = String(headers["content-type"] || "").split(";")[0].toLowerCase();
if (contentType.startsWith("audio/")) {
return true;
}
return resourceType === "media" &&
/\.(ogg|opus|m4a|aac|mp3|wav|flac|oga)(\?|#|$)/i.test(url);
} catch {
return false;
}
}, { timeout: 8000 }).catch(() => null);
for (let attempt = 0; attempt < 3; attempt++) {
const responsePromise = waitForVoiceResponse();
await p.mouse.click(point.x, point.y);
const response = await responsePromise;
if (response) {
const headers = response.headers();
const buffer = await response.body();
if (buffer.length > 0) {
return {
contentType: String(headers["content-type"] || "audio/ogg").split(";")[0],
buffer
};
}
}
await p.waitForTimeout(600);
}
const embedded = await p.evaluate(async ({ duration, timeText }) => {
const cleanText = (value, limit = 500) => String(value || "")
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, limit);
const candidates = Array.from(document.querySelectorAll("audio, video"))
.map((media) => {
const wrapper = media.closest("[class*='messageWrapper' i], [role='listitem'], [class*='item' i]") || media;
const text = cleanText(wrapper.innerText || wrapper.textContent || "", 1000);
const src = media.currentSrc || media.src || media.querySelector?.("source")?.src || "";
let score = src ? 1 : 0;
if (duration && text.includes(duration)) score += 8;
if (timeText && text.includes(timeText)) score += 3;
return { src, score };
})
.filter((item) => item.src && item.score > 0)
.sort((a, b) => b.score - a.score);
const src = candidates[0]?.src;
if (!src || !src.startsWith("blob:")) {
return null;
}
const response = await fetch(src);
if (!response.ok) {
return null;
}
const arrayBuffer = await response.arrayBuffer();
return {
contentType: response.headers.get("content-type") || "audio/ogg",
bytes: Array.from(new Uint8Array(arrayBuffer))
};
}, { duration, timeText }).catch(() => null);
if (embedded?.bytes?.length) {
return {
contentType: embedded.contentType || "audio/ogg",
buffer: Buffer.from(embedded.bytes)
};
}
throw new Error(`MAX voice media response was not captured for ${label}.`);
}
async function downloadDomStickerThroughScreenshot(p, request) {
const testId = String(request?.testId || "").trim();
const timeText = String(request?.timeText || "").trim();
const selectorForTestId = (value) => `[data-testid="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
const screenshotCandidate = async (handles, requestedIndex) => {
const candidates = [];
for (const handle of handles) {
const meta = await handle.evaluate((node, requestedTimeText) => {
const element = node instanceof HTMLElement ? node : node.parentElement;
if (!element) {
return null;
}
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
if (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) {
return null;
}
const wrapper = element.closest("[class*='messageWrapper' i], [role='listitem'], [class*='item' i], [class*='bubbleContent' i], [class*='bordersWrapper' i]") || element;
const cleanText = (value) => String(value || "")
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim();
const wrapperText = cleanText(wrapper.innerText || wrapper.textContent);
const metaText = Array.from(wrapper.querySelectorAll("[class*='meta' i], time"))
.map((item) => cleanText(
item.getAttribute?.("datetime") ||
item.getAttribute?.("aria-label") ||
item.getAttribute?.("title") ||
item.innerText ||
item.textContent))
.filter(Boolean)
.join(" ");
const timeMatches = requestedTimeText &&
(wrapperText.includes(requestedTimeText) || metaText.includes(requestedTimeText));
const semantic = [
element.getAttribute("data-testid"),
element.getAttribute("aria-label"),
element.getAttribute("title"),
element.className,
wrapper.className,
wrapperText
].filter(Boolean).join(" ").toLowerCase();
const looksLikeSticker = /sticker|\u0441\u0442\u0438\u043a\u0435\u0440/.test(semantic) ||
Boolean(element.querySelector("canvas,img,svg")) ||
element.matches("canvas,img,svg");
const area = rect.width * rect.height;
let score = 0;
if (looksLikeSticker) score += 4;
if (timeMatches) score += 40;
if (rect.width >= 72 && rect.height >= 72) score += 16;
if (rect.width >= 120 && rect.height >= 120) score += 8;
score += Math.min(12, area / 2500);
if (rect.width < 40 || rect.height < 40) score -= 30;
if (area < 4096) score -= 20;
return {
score,
width: rect.width,
height: rect.height,
y: rect.y,
area,
timeMatches: Boolean(timeMatches)
};
}, timeText).catch(() => null);
if (!meta || meta.score <= 0) {
await handle.dispose().catch(() => {});
continue;
}
candidates.push({ handle, meta });
}
const strict = timeText
? candidates.filter((candidate) => candidate.meta.timeMatches)
: candidates;
const sorted = (strict.length ? strict : candidates)
.sort((a, b) => b.meta.score - a.meta.score || b.meta.area - a.meta.area || b.meta.y - a.meta.y);
const target = sorted[Math.max(0, Math.min(requestedIndex, sorted.length - 1))];
if (!target) {
return null;
}
await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
const buffer = await target.handle.screenshot({ type: "png", timeout: 10000 });
for (const item of candidates) {
await item.handle.dispose().catch(() => {});
}
return { contentType: "image/png", buffer };
};
const index = Number.isFinite(Number(request?.index)) ? Number(request.index) : 0;
if (testId) {
const handles = await p.$$(selectorForTestId(testId));
const result = await screenshotCandidate(handles, 0);
if (result) {
return result;
}
}
const handles = await p.$$("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i] canvas, canvas");
const result = await screenshotCandidate(handles, index);
if (!result) {
throw new Error("MAX sticker canvas was not found.");
}
return result;
}
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 cssImageUrl = (element) => {
const style = window.getComputedStyle(element);
const value = [
style.backgroundImage,
style.maskImage,
style.webkitMaskImage
].find((item) => item && item !== "none") || "";
const match = value.match(/url\(["']?(.+?)["']?\)/i);
if (!match) return null;
try {
return new URL(match[1], location.href).href;
} catch {
return match[1];
}
};
const elements = Array.from(document.querySelectorAll("a,button,input,textarea,img,picture,video,audio,canvas,svg,[role],[data-testid],[class]"))
.filter(isVisible)
.map((element) => {
const rect = element.getBoundingClientRect();
const tag = element.tagName.toLowerCase();
const mediaSrc = element.currentSrc ||
element.src ||
element.poster ||
element.href?.baseVal ||
element.getAttribute("src") ||
element.getAttribute("href") ||
element.getAttribute("xlink:href") ||
element.getAttribute("data-url") ||
element.getAttribute("data-src") ||
cssImageUrl(element);
return {
tag,
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"),
mediaSrc,
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) => {
const haystack = `${element.tag} ${element.className} ${element.mediaSrc || ""}`.toLowerCase();
return element.text ||
element.ariaLabel ||
element.testId ||
element.role ||
element.mediaSrc ||
/message|bubble|sticker|emoji|attach|media|image|picture|canvas|svg/.test(haystack);
})
.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(async () => {
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 isChatRowVisibleEnough = (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 >= -140 &&
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 = [];
const collectVisibleChats = () => {
for (const element of Array.from(document.querySelectorAll(chatSelector))) {
if (!isChatRowVisibleEnough(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 avatarUrl = extractAvatarUrl(element);
const href = element.getAttribute("href") || element.closest("a")?.getAttribute("href") || null;
const key = `${title}|${avatarUrl || href || text}`;
if (seen.has(key)) continue;
seen.add(key);
chats.push({
title,
text,
preview: extractPreview(element, title, lines),
timeText: extractTimeText(element, lines),
avatarUrl,
href,
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height)
}
});
if (chats.length >= 160) break;
}
};
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const scrollRoot = Array.from(document.querySelectorAll("aside, aside *"))
.filter((element) => element instanceof HTMLElement)
.filter((element) => {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" &&
style.visibility !== "hidden" &&
rect.width > 180 &&
rect.height > 160 &&
element.scrollHeight > element.clientHeight + 80;
})
.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null;
if (scrollRoot) {
const maxTop = () => Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
scrollRoot.scrollTop = 0;
scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true }));
await delay(120);
let previousTop = -1;
for (let page = 0; page < 80 && chats.length < 160; page++) {
collectVisibleChats();
const bottom = maxTop();
if (scrollRoot.scrollTop >= bottom - 4) break;
const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.8)));
if (nextTop === previousTop || nextTop === scrollRoot.scrollTop) break;
previousTop = scrollRoot.scrollTop;
scrollRoot.scrollTop = nextTop;
scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true }));
await delay(120);
}
scrollRoot.scrollTop = 0;
scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true }));
} else {
collectVisibleChats();
}
return {
url: location.href,
title: document.title,
chats
};
});
const now = new Date().toISOString();
const seenExternalIds = new Set();
return raw.chats.flatMap((chat, index) => {
const externalId = makeDomChatExternalId(chat.title, chat.text, chat.href, index, chat.avatarUrl);
if (seenExternalIds.has(externalId)) {
return [];
}
seenExternalIds.add(externalId);
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 domAudioUrlFromVoice = (label, duration, timeText) => `qmax-dom-audio:${encodeURIComponent(JSON.stringify({
label: cleanText(label, 300),
duration: cleanText(duration, 40),
timeText: cleanText(timeText, 40)
}))}`;
const domStickerUrlFromCanvas = (testId, timeText, index) => `qmax-dom-sticker:${encodeURIComponent(JSON.stringify({
testId: cleanText(testId, 120),
timeText: cleanText(timeText, 40),
index
}))}`;
const durationFromText = (value) => cleanText(value, 120).match(/\b\d{1,2}:\d{2}(?::\d{2})?\b/)?.[0] || "";
const messageTimeTextFromWrapper = (wrapper) => {
const metaNodes = Array.from(wrapper.querySelectorAll("[class*='meta' i], time"))
.filter((node) => !node.closest("[class*='attach' i], [class*='audio' i], [class*='voice' i], [class*='duration' i]"));
for (const node of metaNodes) {
const text = cleanText(
node.getAttribute?.("datetime") ||
node.getAttribute?.("aria-label") ||
node.getAttribute?.("title") ||
node.innerText ||
node.textContent,
80);
const match = text.match(/\b\d{1,2}:\d{2}\b/);
if (match) {
return match[0];
}
}
const ownText = cleanText(wrapper.innerText || wrapper.textContent, 500);
const matches = ownText.match(/\b\d{1,2}:\d{2}\b/g) || [];
return matches.at(-1) || "";
};
const safeVoiceFileName = (duration, index) => {
const suffix = cleanText(duration, 40).replace(/[^0-9]+/g, "-").replace(/^-+|-+$/g, "");
return `max-voice-${suffix || index + 1}.ogg`;
};
const safeStickerFileName = (testId, index) => {
const suffix = cleanText(testId, 80).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
return `max-sticker-${suffix || index + 1}.png`;
};
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 voiceRoots = Array.from(bubble.querySelectorAll("[class*='attachAudio' i], [class*='voice' i], [class*='audio' i]"));
for (const root of voiceRoots) {
if (!(root instanceof HTMLElement)) continue;
if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue;
const rootText = cleanText(
root.innerText ||
root.textContent ||
root.getAttribute?.("aria-label") ||
root.getAttribute?.("title"),
300);
const semantic = [
root.className?.baseVal || root.className,
root.getAttribute?.("aria-label"),
root.getAttribute?.("title"),
rootText
].filter(Boolean).join(" ");
if (!/attachaudio|voice|audio|\u0433\u043e\u043b\u043e\u0441|\u0430\u0443\u0434\u0438\u043e/i.test(semantic)) {
continue;
}
const duration = durationFromText(rootText);
const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble;
const messageTimeText = messageTimeTextFromWrapper(wrapper);
const label = cleanText(`\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 ${duration}`.trim(), 120);
candidates.push({
url: domAudioUrlFromVoice(label, duration, messageTimeText),
label,
kind: "VoiceNote",
fileName: safeVoiceFileName(duration, candidates.length),
contentType: "audio/ogg",
fileSizeBytes: null
});
}
const stickerRoots = [
...(bubble.matches?.("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]") ? [bubble] : []),
...Array.from(bubble.querySelectorAll("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]"))
];
const seenStickerKeys = new Set();
for (const root of stickerRoots) {
if (!(root instanceof HTMLElement)) continue;
if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue;
const semantic = [
root.getAttribute("data-testid"),
root.getAttribute("aria-label"),
root.getAttribute("title"),
root.className,
root.innerText || root.textContent
].filter(Boolean).join(" ");
const hasCanvas = root.matches("canvas") || Boolean(root.querySelector("canvas"));
if (!hasCanvas && !/sticker|\u0441\u0442\u0438\u043a\u0435\u0440/i.test(semantic)) {
continue;
}
const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble;
const messageTimeText = messageTimeTextFromWrapper(wrapper);
const testId = root.getAttribute("data-testid") ||
root.querySelector("[data-testid^='sticker-']")?.getAttribute("data-testid") ||
"";
const rect = root.getBoundingClientRect();
const stickerKey = testId || `${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.width)}:${Math.round(rect.height)}`;
if (seenStickerKeys.has(stickerKey)) {
continue;
}
seenStickerKeys.add(stickerKey);
const label = cleanText(root.getAttribute("aria-label") || root.innerText || root.textContent || "\u0421\u0442\u0438\u043a\u0435\u0440", 120);
candidates.push({
url: domStickerUrlFromCanvas(testId, messageTimeText, candidates.length),
label,
kind: "Sticker",
fileName: safeStickerFileName(testId, candidates.length),
contentType: "image/png",
fileSizeBytes: null
});
}
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 mediaSelector = "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]";
const nodes = [
...(bubble.matches?.(mediaSelector) ? [bubble] : []),
...Array.from(bubble.querySelectorAll(mediaSelector))
];
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 semantic = [
tagName,
node.getAttribute?.("role"),
node.getAttribute?.("aria-label"),
node.getAttribute?.("title"),
node.className?.baseVal || node.className,
label
].filter(Boolean).join(" ");
const mediaTag = tagName === "source" ? String(node.parentElement?.tagName || "").toLowerCase() : tagName;
const kind = kindFromMedia(mediaTag, url, `${label} ${semantic}`);
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 existingWrappers = Array.from(opened.querySelectorAll("[class*='messageWrapper' i]"))
.filter(isVisible);
const standaloneMediaRoots = Array.from(opened.querySelectorAll("img, image, picture, video, canvas, svg, [class*='sticker' i], [class*='emoji' i], [class*='lottie' i]"))
.filter(isVisible)
.map((node) => node.closest?.("[class*='messageWrapper' i], [class*='item' i], [class*='bubbleContent' i], [class*='bordersWrapper' i], [class*='attach' i], [class*='sticker' i], [class*='emoji' i]") || node)
.filter((node) => node instanceof HTMLElement)
.filter((node) => {
if (existingWrappers.some((wrapper) => wrapper === node || wrapper.contains(node) || node.contains(wrapper))) {
return false;
}
if (node.closest?.("aside, header, [class*='header' i], [class*='composer' i], [class*='toolbar' i]")) {
return false;
}
const rect = node.getBoundingClientRect();
return rect.width >= 24 &&
rect.height >= 24 &&
rect.left > openedRect.left + 8 &&
rect.top > openedRect.top + 48 &&
rect.bottom < window.innerHeight - 24;
});
const wrappers = [...existingWrappers, ...standaloneMediaRoots]
.filter((wrapper, index, all) => all.findIndex((other) => other === wrapper || other.contains?.(wrapper)) === index)
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
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 timeText = messageTimeTextFromWrapper(wrapper);
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 resetSidebarListToTop(p) {
await p.evaluate(() => {
const aside = document.querySelector("aside");
if (!aside) return;
const candidates = [
aside,
...Array.from(aside.querySelectorAll("[class*='scroll' i], [class*='contentOverflow' i], [class*='cropped' i], [class*='list' i]"))
].filter((element) => element instanceof HTMLElement);
for (const element of candidates) {
if (element.scrollHeight <= element.clientHeight + 2) continue;
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
}
}).catch(() => {});
await p.waitForTimeout(250);
}
async function scrollOpenChatToLatest(p) {
for (let attempt = 0; attempt < 2; attempt++) {
await p.evaluate(() => {
const root = document.querySelector("[class*='openedChat' i]") ||
document.querySelector("main") ||
document.body;
if (!(root instanceof HTMLElement)) return;
const rootRect = root.getBoundingClientRect();
const candidates = [
root,
...Array.from(root.querySelectorAll("[class*='scroll' i], [class*='contentOverflow' i], [class*='cropped' i], [class*='messages' i], [class*='messageList' i], [class*='list' i]"))
].filter((element) => {
if (!(element instanceof HTMLElement)) return false;
const rect = element.getBoundingClientRect();
return rect.width > 120 &&
rect.height > 120 &&
rect.right >= rootRect.left &&
rect.left <= rootRect.right &&
element.scrollHeight > element.clientHeight + 2;
});
for (const element of candidates) {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
}
}).catch(() => {});
await p.waitForTimeout(250);
}
}
async function openDomChatByExternalId(p, externalChatId) {
const descriptor = decodeDomChatDescriptor(externalChatId);
const title = descriptor?.title || externalChatId;
if (!title) {
return false;
}
await clearSidebarSearch(p);
await resetSidebarListToTop(p);
const point = await p.evaluate(async (requested) => {
const cleanText = (value) => String(value || "")
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim();
const absoluteUrl = (value) => {
const raw = String(value || "").trim();
if (!raw || raw.startsWith("data:")) return "";
try {
return new URL(raw, location.href).href;
} catch {
return raw;
}
};
const fingerprintUrl = (value) => {
const raw = absoluteUrl(value);
if (!raw) return "";
try {
const url = new URL(raw, location.href);
const mediaKey = url.searchParams.get("r");
if (mediaKey) return `r:${mediaKey}`;
["fn", "size", "width", "height"].forEach((key) => url.searchParams.delete(key));
return url.href;
} catch {
return raw;
}
};
const cssBackgroundUrl = (element) => {
const image = window.getComputedStyle(element).backgroundImage || "";
const match = image.match(/url\(["']?(.+?)["']?\)/i);
return match ? absoluteUrl(match[1]) : "";
};
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 "";
};
const normalize = (value) => cleanText(value).toLowerCase();
const wanted = normalize(requested?.title);
const expectedAvatar = fingerprintUrl(requested?.avatarUrl);
const expectedHref = fingerprintUrl(requested?.href);
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 hasVisibleMessages = Array.from(document.querySelectorAll("[class*='messageWrapper' i]"))
.some((node) => {
const rect = node.getBoundingClientRect?.();
return rect && rect.width > 40 && rect.height > 20 && rect.right > window.innerWidth * 0.35;
});
const currentTitles = [selectedTitle, headerTitle].filter(Boolean);
if (!expectedAvatar && !expectedHref &&
hasVisibleMessages &&
currentTitles.some((current) => current === wanted || current.includes(wanted) || wanted.includes(current))) {
return { opened: true };
}
const findMatch = () => {
const buttons = Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i]"));
const candidates = buttons.map((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);
const titleMatches = title === wanted ||
title.includes(wanted) ||
wanted.includes(title) ||
text.startsWith(wanted);
if (!titleMatches) {
return null;
}
const avatar = fingerprintUrl(extractAvatarUrl(button));
const href = fingerprintUrl(button.getAttribute("href") || button.closest("a")?.getAttribute("href"));
let score = title === wanted ? 8 : 4;
if (expectedAvatar && avatar === expectedAvatar) score += 20;
if (expectedHref && href === expectedHref) score += 20;
return { button, avatar, href, score };
}).filter(Boolean);
const strict = candidates
.filter((candidate) => !expectedAvatar || candidate.avatar === expectedAvatar)
.filter((candidate) => !expectedHref || candidate.href === expectedHref)
.sort((a, b) => b.score - a.score);
return strict[0] || (candidates.length === 1 ? candidates[0] : null);
};
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let match = findMatch();
if (!match) {
const scrollRoot = Array.from(document.querySelectorAll("aside, aside *"))
.filter((element) => element instanceof HTMLElement)
.filter((element) => {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" &&
style.visibility !== "hidden" &&
rect.width > 180 &&
rect.height > 160 &&
element.scrollHeight > element.clientHeight + 80;
})
.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || null;
if (scrollRoot) {
const maxTop = () => Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
scrollRoot.scrollTop = 0;
scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true }));
await delay(100);
let previousTop = -1;
for (let page = 0; page < 100 && !match; page++) {
match = findMatch();
if (match) break;
const bottom = maxTop();
if (scrollRoot.scrollTop >= bottom - 4) break;
const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.8)));
if (nextTop === previousTop || nextTop === scrollRoot.scrollTop) break;
previousTop = scrollRoot.scrollTop;
scrollRoot.scrollTop = nextTop;
scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true }));
await delay(100);
}
}
}
if (!match) {
return null;
}
match.button.scrollIntoView({ block: "center", inline: "nearest" });
const rect = match.button.getBoundingClientRect();
return {
opened: false,
x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.35))),
y: Math.round(rect.top + rect.height / 2)
};
}, {
title,
avatarUrl: descriptor?.avatarUrl || "",
href: descriptor?.href || ""
}).catch(() => false);
if (!point) {
return false;
}
if (point.opened) {
return true;
}
await p.mouse.click(point.x, point.y);
return true;
}
async function ensureDomChatOpen(p, externalChatId, waitMs = 600) {
const descriptor = decodeDomChatDescriptor(externalChatId);
const title = descriptor?.title || 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) {
if (clearFirst && await fillComposerViaLocator(p, text)) {
return true;
}
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 fillComposerViaLocator(p, text) {
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]"
];
for (const selector of selectors) {
try {
const composer = p.locator(selector).last();
if ((await composer.count()) === 0) {
continue;
}
await composer.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
await composer.click({ timeout: 3000 });
await composer.fill("", { timeout: 3000 });
await composer.fill(text, { timeout: 3000 });
await p.waitForTimeout(200);
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 {
element,
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);
const candidate = candidates[0];
if (candidate?.element instanceof HTMLElement) {
candidate.element.scrollIntoView({ block: "center", inline: "nearest" });
candidate.element.focus({ preventScroll: true });
const rect = candidate.element.getBoundingClientRect();
return {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2)
};
}
return {
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 normalizeDomChatFingerprint(value) {
const raw = cleanComparableText(value, 800);
if (!raw || raw.startsWith("data:")) {
return "";
}
try {
const url = new URL(raw);
const mediaKey = url.searchParams.get("r");
if (mediaKey) {
return `r:${mediaKey}`;
}
["fn", "size", "width", "height"].forEach((key) => url.searchParams.delete(key));
return url.href;
} catch {
return raw;
}
}
function makeDomChatExternalId(title, text, href, index, avatarUrl) {
const descriptor = {
title: cleanComparableText(title, 160),
avatarUrl: normalizeDomChatFingerprint(avatarUrl),
href: normalizeDomChatFingerprint(href)
};
const encodedDescriptor = Buffer.from(JSON.stringify(descriptor), "utf8")
.toString("base64url");
return `dom:${stableHash("chat", descriptor.title, descriptor.href, descriptor.avatarUrl)}:${encodedDescriptor}`;
}
function decodeDomChatDescriptor(externalChatId) {
const parts = String(externalChatId || "").split(":");
if (parts.length < 3 || parts[0] !== "dom") {
return null;
}
try {
const decoded = Buffer.from(parts[2], "base64url").toString("utf8");
const parsed = JSON.parse(decoded);
if (parsed && typeof parsed === "object") {
const title = cleanComparableText(parsed.title, 160);
if (!title) {
return null;
}
return {
title,
avatarUrl: cleanComparableText(parsed.avatarUrl, 800),
href: cleanComparableText(parsed.href, 800)
};
}
} catch {
try {
const title = Buffer.from(parts[2], "base64url").toString("utf8");
return title ? { title, avatarUrl: "", href: "" } : null;
} catch {
return null;
}
}
return null;
}
function decodeDomChatTitle(externalChatId) {
return decodeDomChatDescriptor(externalChatId)?.title || 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");
}