3913 lines
142 KiB
JavaScript
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");
|
|
}
|