6322 lines
225 KiB
JavaScript
6322 lines
225 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:";
|
|
const domEmojiPrefix = "qmax-dom-emoji:";
|
|
|
|
let context;
|
|
let contextInitialization;
|
|
let page;
|
|
let lastError = null;
|
|
const pagesByRole = new Map();
|
|
const pageOperations = new Map();
|
|
const networkLog = [];
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json({ limit: "10mb" }));
|
|
|
|
function normalizeMaxChatUrl(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw) return "";
|
|
|
|
try {
|
|
const url = new URL(raw, maxBaseUrl);
|
|
const base = new URL(maxBaseUrl);
|
|
if (url.origin !== base.origin) {
|
|
return "";
|
|
}
|
|
|
|
return url.href;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
async function getCurrentChatUrl(p) {
|
|
const current = normalizeMaxChatUrl(p.url());
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
|
|
const base = new URL(maxBaseUrl).href;
|
|
return current === base ? null : current;
|
|
}
|
|
|
|
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 role = String(req.query?.role || "default").trim() || "default";
|
|
const p = await ensurePage(role);
|
|
return await inspectDom(p);
|
|
}, String(req.query?.role || "default").trim() || "default"));
|
|
} 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("/inspect/chat-urls", async (req, res) => {
|
|
try {
|
|
const limit = clampNumber(req.query?.limit, 1, 800, 600);
|
|
const result = await withPageLock(async () => {
|
|
const p = await ensurePage("incoming");
|
|
const currentStatus = await status(null, p);
|
|
if (!currentStatus.isAuthorized) {
|
|
return { chats: [], error: "MAX is not authorized." };
|
|
}
|
|
|
|
return { chats: await collectSidebarChatUrls(p, limit), error: null };
|
|
}, "incoming");
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json(errorStatus(error));
|
|
}
|
|
});
|
|
|
|
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("incoming");
|
|
return await fetchMediaThroughMaxSession(p, mediaUrl);
|
|
}, "incoming");
|
|
|
|
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("incoming");
|
|
const currentStatus = await status(null, p);
|
|
if (!currentStatus.isAuthorized) {
|
|
return [];
|
|
}
|
|
|
|
await clearSidebarSearch(p);
|
|
await resetSidebarListToTop(p);
|
|
return await extractUpdates(p);
|
|
}, "incoming");
|
|
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 chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
|
|
const result = await withPageLock(async () => {
|
|
const p = await ensurePage("incoming");
|
|
const currentStatus = await status(null, p);
|
|
if (!currentStatus.isAuthorized) {
|
|
return null;
|
|
}
|
|
|
|
if (externalChatId) {
|
|
const opened = await ensureDomChatOpen(p, externalChatId, 1000, false, chatUrl);
|
|
if (!opened) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
await scrollOpenChatToLatest(p);
|
|
const update = await extractOpenChatHistory(p, externalChatId);
|
|
return { ...update, chatUrl: await getCurrentChatUrl(p) };
|
|
}, "incoming");
|
|
|
|
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/resolve-url", async (req, res) => {
|
|
try {
|
|
const externalChatId = String(req.body?.externalChatId || "").trim();
|
|
if (!externalChatId) {
|
|
return res.status(400).json({ success: false, chatUrl: null, error: "externalChatId is required." });
|
|
}
|
|
|
|
const result = await withPageLock(async () => {
|
|
const p = await ensurePage("commands");
|
|
const currentStatus = await status(null, p);
|
|
if (!currentStatus.isAuthorized) {
|
|
return { success: false, chatUrl: null, error: "MAX is not authorized." };
|
|
}
|
|
|
|
const chatUrl = await resolveDomChatUrl(p, externalChatId);
|
|
return {
|
|
success: Boolean(chatUrl),
|
|
chatUrl: chatUrl || null,
|
|
error: chatUrl ? null : "MAX chat URL was not found."
|
|
};
|
|
}, "commands");
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json(errorStatus(error));
|
|
}
|
|
});
|
|
|
|
app.post("/chat/clear-history", async (req, res) => {
|
|
try {
|
|
const externalChatId = String(req.body?.externalChatId || "").trim();
|
|
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
|
|
if (!externalChatId) {
|
|
return res.json({ success: false, error: "externalChatId is required." });
|
|
}
|
|
|
|
const result = await withPageLock(async () => {
|
|
const p = await ensurePage("commands");
|
|
return await clearChatHistoryInMax(p, externalChatId, chatUrl);
|
|
}, "commands");
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.json({ success: false, error: String(error?.message || error) });
|
|
}
|
|
});
|
|
|
|
app.post("/chat/delete", async (req, res) => {
|
|
try {
|
|
const externalChatId = String(req.body?.externalChatId || "").trim();
|
|
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
|
|
if (!externalChatId) {
|
|
return res.json({ success: false, error: "externalChatId is required." });
|
|
}
|
|
|
|
const result = await withPageLock(async () => {
|
|
const p = await ensurePage("commands");
|
|
return await deleteChatInMax(p, externalChatId, chatUrl);
|
|
}, "commands");
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.json({ success: false, error: String(error?.message || error) });
|
|
}
|
|
});
|
|
|
|
app.post("/chat/menu-probe", async (req, res) => {
|
|
try {
|
|
const externalChatId = String(req.body?.externalChatId || "").trim();
|
|
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
|
|
const labels = Array.isArray(req.body?.labels) && req.body.labels.length > 0
|
|
? req.body.labels.map((label) => String(label || ""))
|
|
: ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "delete chat", "delete conversation", "remove chat"];
|
|
const probeAction = Boolean(req.body?.probeAction);
|
|
if (!externalChatId) {
|
|
return res.json({ success: false, error: "externalChatId is required.", chatUrl: null });
|
|
}
|
|
|
|
const result = await withPageLock(async () => {
|
|
const p = await ensurePage("commands");
|
|
const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, labels);
|
|
const actionClicked = menuOpened && probeAction
|
|
? await clickActionByTextWithRetry(p, labels, 1500)
|
|
: false;
|
|
if (actionClicked) {
|
|
await p.waitForTimeout(700);
|
|
}
|
|
const visibleActions = actionClicked ? await readVisibleActionTexts(p) : [];
|
|
await p.keyboard.press("Escape").catch(() => {});
|
|
await clearSidebarSearch(p);
|
|
return {
|
|
success: menuOpened,
|
|
error: menuOpened ? null : "MAX chat menu was not opened.",
|
|
chatUrl: await getCurrentChatUrl(p),
|
|
actionClicked,
|
|
visibleActions
|
|
};
|
|
}, "commands");
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.json({ success: false, error: String(error?.message || error), chatUrl: null });
|
|
}
|
|
});
|
|
|
|
app.post("/chat/clear-composer", async (req, res) => {
|
|
try {
|
|
const externalChatId = String(req.body?.externalChatId || "").trim();
|
|
const result = await withPageLock(async () => {
|
|
const p = await ensurePage("outgoing");
|
|
const currentStatus = await status(null, p);
|
|
if (!currentStatus.isAuthorized) {
|
|
return { success: false, error: "MAX is not authorized.", composerText: null };
|
|
}
|
|
|
|
if (externalChatId) {
|
|
const opened = await ensureDomChatOpen(p, externalChatId, 2000, true);
|
|
if (!opened) {
|
|
return { success: false, error: "MAX chat was not found or did not open.", composerText: null };
|
|
}
|
|
}
|
|
|
|
const success = await clearComposerInput(p);
|
|
return { success, error: success ? null : "MAX composer was not cleared.", composerText: await readComposerText(p) };
|
|
}, "outgoing");
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json(errorStatus(error));
|
|
}
|
|
});
|
|
|
|
app.post("/chat/draft-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, error: "Text is empty.", composerText: null });
|
|
}
|
|
|
|
const result = await withPageLock(async () => {
|
|
const p = await ensurePage("outgoing");
|
|
const currentStatus = await status(null, p);
|
|
if (!currentStatus.isAuthorized) {
|
|
return { success: false, error: "MAX is not authorized.", composerText: null };
|
|
}
|
|
|
|
if (externalChatId) {
|
|
const opened = await ensureDomChatOpen(p, externalChatId, 2000, true);
|
|
if (!opened) {
|
|
return { success: false, error: "MAX chat was not found or did not open.", composerText: null };
|
|
}
|
|
}
|
|
|
|
const success = await fillComposerText(p, text, true);
|
|
return { success, error: success ? null : "MAX composer was not filled.", composerText: await readComposerText(p) };
|
|
}, "outgoing");
|
|
|
|
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 chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
|
|
const result = await withPageLock(async () => {
|
|
const p = await ensurePage("incoming");
|
|
const currentStatus = await status(null, p);
|
|
if (!currentStatus.isAuthorized) {
|
|
return { isTyping: false, statusText: null, updatedAt: new Date().toISOString() };
|
|
}
|
|
|
|
if (externalChatId) {
|
|
await ensureDomChatOpen(p, externalChatId, 600, false, chatUrl);
|
|
}
|
|
|
|
return await extractOpenChatPresence(p);
|
|
}, "incoming");
|
|
|
|
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 chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
|
|
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("outgoing");
|
|
|
|
if (externalChatId) {
|
|
const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl);
|
|
if (!opened) {
|
|
return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open.", chatUrl: null };
|
|
}
|
|
}
|
|
|
|
const externalMessageId = await sendTextAndConfirm(p, externalChatId, text);
|
|
if (!externalMessageId) {
|
|
return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing text message.", chatUrl: await getCurrentChatUrl(p) };
|
|
}
|
|
return { success: true, externalMessageId, error: null, chatUrl: await getCurrentChatUrl(p) };
|
|
}, "outgoing");
|
|
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 chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
|
|
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("outgoing");
|
|
if (externalChatId) {
|
|
const opened = await ensureDomChatOpen(p, externalChatId, 8000, true, chatUrl);
|
|
if (!opened) {
|
|
return { success: false, externalMessageId: null, error: "MAX chat was not found or did not open.", chatUrl: null };
|
|
}
|
|
}
|
|
|
|
const externalMessageId = await sendAttachmentAndConfirm(p, externalChatId, path, caption);
|
|
if (!externalMessageId) {
|
|
return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing attachment.", chatUrl: await getCurrentChatUrl(p) };
|
|
}
|
|
|
|
return { success: true, externalMessageId, error: null, chatUrl: await getCurrentChatUrl(p) };
|
|
}, "outgoing");
|
|
|
|
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("outgoing");
|
|
return await editMessageInMax(p, externalChatId, externalMessageId, currentText, text);
|
|
}, "outgoing");
|
|
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("outgoing");
|
|
return await deleteMessageInMax(p, externalChatId, externalMessageId, currentText);
|
|
}, "outgoing");
|
|
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("outgoing");
|
|
return await setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji);
|
|
}, "outgoing");
|
|
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("outgoing");
|
|
return await clearMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji);
|
|
}, "outgoing");
|
|
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(role = "default") {
|
|
const normalizedRole = String(role || "default");
|
|
const existing = pagesByRole.get(normalizedRole);
|
|
if (existing && !existing.isClosed()) {
|
|
await waitForRolePageReady(existing, normalizedRole);
|
|
return existing;
|
|
}
|
|
|
|
await ensureContext();
|
|
|
|
if (normalizedRole === "default") {
|
|
if (!page || page.isClosed()) {
|
|
page = context.pages().find((candidate) => !candidate.isClosed()) || await context.newPage();
|
|
pagesByRole.set("default", page);
|
|
attachNetworkCapture(page);
|
|
if (!page.url() || page.url() === "about:blank") {
|
|
await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
}
|
|
}
|
|
|
|
return page;
|
|
}
|
|
|
|
const rolePage = await context.newPage();
|
|
pagesByRole.set(normalizedRole, rolePage);
|
|
attachNetworkCapture(rolePage);
|
|
await rolePage.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
await waitForRolePageReady(rolePage, normalizedRole);
|
|
return rolePage;
|
|
}
|
|
|
|
async function waitForRolePageReady(rolePage, role) {
|
|
if (role === "default") {
|
|
return;
|
|
}
|
|
|
|
await rolePage.waitForLoadState("domcontentloaded", { timeout: 15000 }).catch(() => {});
|
|
await waitForLoginStage(rolePage, "Authorized", 15000).catch(() => false);
|
|
}
|
|
|
|
async function ensureContext() {
|
|
if (context) return;
|
|
if (!contextInitialization) {
|
|
contextInitialization = (async () => {
|
|
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"]
|
|
});
|
|
await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: maxBaseUrl }).catch(() => {});
|
|
page = context.pages()[0] || await context.newPage();
|
|
pagesByRole.set("default", page);
|
|
attachNetworkCapture(page);
|
|
if (!page.url() || page.url() === "about:blank") {
|
|
await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
}
|
|
})().catch((error) => {
|
|
contextInitialization = null;
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
await contextInitialization;
|
|
}
|
|
|
|
async function withPageLock(operation, role = "default") {
|
|
const normalizedRole = String(role || "default");
|
|
const previousOperation = pageOperations.get(normalizedRole) || Promise.resolve();
|
|
let release;
|
|
const nextOperation = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
pageOperations.set(normalizedRole, nextOperation);
|
|
|
|
await previousOperation.catch(() => {});
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
async function status(overrideStatus = null, statusPage = null) {
|
|
try {
|
|
const p = statusPage || 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,
|
|
pageRoles: getOpenPageRoles(),
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
} catch (error) {
|
|
return errorStatus(error);
|
|
}
|
|
}
|
|
|
|
function errorStatus(error) {
|
|
lastError = String(error?.message || error);
|
|
const fallbackPage = firstKnownPage();
|
|
return {
|
|
mode: "Worker",
|
|
isAuthorized: false,
|
|
status: "WorkerError",
|
|
url: fallbackPage?.url?.() || null,
|
|
title: null,
|
|
lastError,
|
|
pageRoles: getOpenPageRoles(),
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
function firstKnownPage() {
|
|
return page ||
|
|
pagesByRole.get("default") ||
|
|
pagesByRole.get("incoming") ||
|
|
pagesByRole.get("outgoing") ||
|
|
pagesByRole.get("commands") ||
|
|
null;
|
|
}
|
|
|
|
function getOpenPageRoles() {
|
|
return Array.from(pagesByRole.entries())
|
|
.filter(([, rolePage]) => rolePage && !rolePage.isClosed())
|
|
.map(([roleName]) => roleName)
|
|
.sort();
|
|
}
|
|
|
|
function isAllowedMediaUrl(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw) return false;
|
|
if (isDomDownloadUrl(raw) || isDomAudioUrl(raw) || isDomStickerUrl(raw) || isDomEmojiUrl(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 isDomEmojiUrl(value) {
|
|
return String(value || "").startsWith(domEmojiPrefix);
|
|
}
|
|
|
|
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 parseDomEmojiUrl(value) {
|
|
const raw = String(value || "");
|
|
if (!isDomEmojiUrl(raw)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(decodeURIComponent(raw.slice(domEmojiPrefix.length)));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function domMediaPrefixForUrl(value) {
|
|
const raw = String(value || "");
|
|
if (isDomDownloadUrl(raw)) return domDownloadPrefix;
|
|
if (isDomAudioUrl(raw)) return domAudioPrefix;
|
|
if (isDomStickerUrl(raw)) return domStickerPrefix;
|
|
if (isDomEmojiUrl(raw)) return domEmojiPrefix;
|
|
return null;
|
|
}
|
|
|
|
function parseDomMediaUrl(value) {
|
|
const prefix = domMediaPrefixForUrl(value);
|
|
if (!prefix) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(decodeURIComponent(String(value).slice(prefix.length)));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function attachDomMediaChatContext(value, externalChatId, chatUrl) {
|
|
const prefix = domMediaPrefixForUrl(value);
|
|
if (!prefix) {
|
|
return value;
|
|
}
|
|
|
|
const parsed = parseDomMediaUrl(value);
|
|
if (!parsed) {
|
|
return value;
|
|
}
|
|
|
|
const next = {
|
|
...parsed,
|
|
externalChatId: parsed.externalChatId || externalChatId || null,
|
|
chatUrl: normalizeMaxChatUrl(parsed.chatUrl) || normalizeMaxChatUrl(chatUrl) || null
|
|
};
|
|
return `${prefix}${encodeURIComponent(JSON.stringify(next))}`;
|
|
}
|
|
|
|
async function ensureDomMediaChatContext(p, request) {
|
|
const externalChatId = String(request?.externalChatId || "").trim();
|
|
const chatUrl = normalizeMaxChatUrl(request?.chatUrl);
|
|
if (!externalChatId && !chatUrl) {
|
|
return;
|
|
}
|
|
|
|
await ensureDomChatOpen(p, externalChatId, 5000, false, chatUrl);
|
|
}
|
|
|
|
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)) {
|
|
const request = parseDomDownloadUrl(mediaUrl);
|
|
await ensureDomMediaChatContext(p, request);
|
|
return await downloadDomAttachmentThroughClick(p, request);
|
|
}
|
|
|
|
if (isDomAudioUrl(mediaUrl)) {
|
|
const request = parseDomAudioUrl(mediaUrl);
|
|
await ensureDomMediaChatContext(p, request);
|
|
return await downloadDomAudioThroughPlay(p, request);
|
|
}
|
|
|
|
if (isDomStickerUrl(mediaUrl)) {
|
|
const request = parseDomStickerUrl(mediaUrl);
|
|
await ensureDomMediaChatContext(p, request);
|
|
return await downloadDomStickerThroughScreenshot(p, request);
|
|
}
|
|
|
|
if (isDomEmojiUrl(mediaUrl)) {
|
|
const request = parseDomEmojiUrl(mediaUrl);
|
|
await ensureDomMediaChatContext(p, request);
|
|
return await downloadDomStickerThroughScreenshot(p, {
|
|
...request,
|
|
selector: "[data-testid*='emoji' i], [class*='emoji' i], [class*='lottie' i], [class*='emoticon' i], canvas, svg",
|
|
kindLabel: "MAX emoji canvas",
|
|
acceptSmall: true,
|
|
maxClipSize: 256,
|
|
requireTimeMatch: true
|
|
});
|
|
}
|
|
|
|
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 fallbackSelector = String(request?.selector || "[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i]");
|
|
const kindLabel = String(request?.kindLabel || "MAX sticker canvas");
|
|
const selectorForTestId = (value) => `[data-testid="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
|
|
const visualHandleFor = async (handle) => {
|
|
const visual = await handle.evaluateHandle((node) => {
|
|
const element = node instanceof Element ? node : node?.parentElement;
|
|
if (!element) return null;
|
|
|
|
const candidates = [
|
|
...(element.matches("canvas,img,image,svg") ? [element] : []),
|
|
...Array.from(element.querySelectorAll("canvas,img,image,svg,[style*='background' i]"))
|
|
];
|
|
const scored = candidates
|
|
.map((candidate) => {
|
|
const rect = candidate.getBoundingClientRect();
|
|
const style = window.getComputedStyle(candidate);
|
|
if (style.visibility === "hidden" ||
|
|
style.display === "none" ||
|
|
rect.width <= 0 ||
|
|
rect.height <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const semantic = [
|
|
candidate.getAttribute?.("data-testid"),
|
|
candidate.getAttribute?.("aria-label"),
|
|
candidate.getAttribute?.("title"),
|
|
candidate.className?.baseVal || candidate.className,
|
|
candidate.tagName
|
|
].filter(Boolean).join(" ").toLowerCase();
|
|
let score = rect.width * rect.height;
|
|
if (candidate.matches("canvas,img,image,svg")) score += 100000;
|
|
if (/sticker|emoji|lottie|emoticon|\u0441\u0442\u0438\u043a\u0435\u0440|\u044d\u043c\u043e\u0434\u0437\u0438/.test(semantic)) score += 50000;
|
|
return { candidate, score };
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
return scored[0]?.candidate || element;
|
|
}).catch(() => null);
|
|
|
|
const element = visual?.asElement?.();
|
|
if (element) {
|
|
return element;
|
|
}
|
|
|
|
await visual?.dispose?.().catch(() => {});
|
|
return handle;
|
|
};
|
|
const resolveVisualHandles = async (handles) => {
|
|
const resolved = [];
|
|
for (const handle of handles) {
|
|
const visual = await visualHandleFor(handle);
|
|
resolved.push(visual);
|
|
if (visual !== handle) {
|
|
resolved.push(handle);
|
|
}
|
|
}
|
|
return resolved;
|
|
};
|
|
const captureCanvasPng = async (handle) => {
|
|
const result = await handle.evaluate(async (node) => {
|
|
const element = node instanceof Element ? node : node?.parentElement;
|
|
const canvas = element?.matches?.("canvas")
|
|
? element
|
|
: element?.querySelector?.("canvas");
|
|
if (!(canvas instanceof HTMLCanvasElement)) {
|
|
return { hasCanvas: false, blocked: false, dataUrl: null };
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
const hasPaintedPixels = () => {
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
if (width <= 0 || height <= 0) return false;
|
|
|
|
let data;
|
|
try {
|
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
if (!context) return true;
|
|
data = context.getImageData(0, 0, width, height).data;
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
|
|
const totalPixels = width * height;
|
|
const step = Math.max(1, Math.floor(totalPixels / 12000));
|
|
let painted = 0;
|
|
for (let pixel = 0; pixel < totalPixels; pixel += step) {
|
|
if (data[pixel * 4 + 3] > 8) {
|
|
painted++;
|
|
}
|
|
}
|
|
|
|
return painted >= Math.max(6, Math.ceil(totalPixels / step * 0.01));
|
|
};
|
|
|
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
const painted = hasPaintedPixels();
|
|
if (painted === true) {
|
|
try {
|
|
return { hasCanvas: true, blocked: false, dataUrl: canvas.toDataURL("image/png") };
|
|
} catch (_error) {
|
|
return { hasCanvas: true, blocked: true, dataUrl: null };
|
|
}
|
|
}
|
|
|
|
if (painted === null) {
|
|
try {
|
|
return { hasCanvas: true, blocked: true, dataUrl: canvas.toDataURL("image/png") };
|
|
} catch (_error) {
|
|
return { hasCanvas: true, blocked: true, dataUrl: null };
|
|
}
|
|
}
|
|
|
|
await sleep(200);
|
|
}
|
|
|
|
return { hasCanvas: true, blocked: false, dataUrl: null };
|
|
}).catch(() => ({ hasCanvas: false, blocked: false, dataUrl: null }));
|
|
|
|
const dataUrl = String(result?.dataUrl || "");
|
|
const prefix = "data:image/png;base64,";
|
|
return {
|
|
hasCanvas: Boolean(result?.hasCanvas),
|
|
blocked: Boolean(result?.blocked),
|
|
buffer: dataUrl.startsWith(prefix) ? Buffer.from(dataUrl.slice(prefix.length), "base64") : null
|
|
};
|
|
};
|
|
const inspectStickerPng = async (buffer) => {
|
|
const base64 = buffer.toString("base64");
|
|
return await p.evaluate(async (dataUrl) => {
|
|
const image = new Image();
|
|
image.src = dataUrl;
|
|
await image.decode();
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = image.naturalWidth;
|
|
canvas.height = image.naturalHeight;
|
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
if (!context || canvas.width <= 0 || canvas.height <= 0) {
|
|
return null;
|
|
}
|
|
|
|
context.drawImage(image, 0, 0);
|
|
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const totalPixels = canvas.width * canvas.height;
|
|
const step = Math.max(1, Math.floor(totalPixels / 20000));
|
|
let sampled = 0;
|
|
let opaque = 0;
|
|
let dark = 0;
|
|
let white = 0;
|
|
let cyan = 0;
|
|
let sumR = 0;
|
|
let sumG = 0;
|
|
let sumB = 0;
|
|
|
|
for (let pixel = 0; pixel < totalPixels; pixel += step) {
|
|
const offset = pixel * 4;
|
|
const r = data[offset];
|
|
const g = data[offset + 1];
|
|
const b = data[offset + 2];
|
|
const a = data[offset + 3];
|
|
sampled++;
|
|
if (a <= 8) {
|
|
continue;
|
|
}
|
|
|
|
opaque++;
|
|
sumR += r;
|
|
sumG += g;
|
|
sumB += b;
|
|
if (Math.max(r, g, b) < 95) dark++;
|
|
if (Math.min(r, g, b) > 230) white++;
|
|
if (b > r + 25 && g > r + 20 && b > 150 && g > 130) cyan++;
|
|
}
|
|
|
|
return {
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
sampled,
|
|
opaqueRatio: sampled ? opaque / sampled : 0,
|
|
darkRatio: opaque ? dark / opaque : 0,
|
|
whiteRatio: opaque ? white / opaque : 0,
|
|
cyanRatio: opaque ? cyan / opaque : 0,
|
|
avgR: opaque ? sumR / opaque : 0,
|
|
avgG: opaque ? sumG / opaque : 0,
|
|
avgB: opaque ? sumB / opaque : 0
|
|
};
|
|
}, `data:image/png;base64,${base64}`).catch(() => null);
|
|
};
|
|
const isMaxWallpaperCapture = (metrics) => {
|
|
if (!metrics) {
|
|
return false;
|
|
}
|
|
|
|
return metrics.opaqueRatio > 0.98 &&
|
|
metrics.cyanRatio > 0.72 &&
|
|
metrics.darkRatio < 0.03 &&
|
|
metrics.whiteRatio < 0.08 &&
|
|
metrics.avgB > metrics.avgR + 45 &&
|
|
metrics.avgG > metrics.avgR + 30;
|
|
};
|
|
const stripMaxWallpaperFromStickerPng = async (buffer) => {
|
|
const base64 = buffer.toString("base64");
|
|
const strippedDataUrl = await p.evaluate(async (dataUrl) => {
|
|
const image = new Image();
|
|
image.src = dataUrl;
|
|
await image.decode();
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = image.naturalWidth;
|
|
canvas.height = image.naturalHeight;
|
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
if (!context || canvas.width <= 0 || canvas.height <= 0) {
|
|
return null;
|
|
}
|
|
|
|
context.drawImage(image, 0, 0);
|
|
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
const data = imageData.data;
|
|
let kept = 0;
|
|
|
|
for (let offset = 0; offset < data.length; offset += 4) {
|
|
const r = data[offset];
|
|
const g = data[offset + 1];
|
|
const b = data[offset + 2];
|
|
const a = data[offset + 3];
|
|
if (a <= 8) {
|
|
continue;
|
|
}
|
|
|
|
const looksLikeMaxWallpaper = b > r + 25 &&
|
|
g > r + 20 &&
|
|
b > 150 &&
|
|
g > 130;
|
|
if (looksLikeMaxWallpaper) {
|
|
data[offset + 3] = 0;
|
|
continue;
|
|
}
|
|
|
|
kept++;
|
|
}
|
|
|
|
if (kept < Math.max(80, Math.floor(canvas.width * canvas.height * 0.01))) {
|
|
return null;
|
|
}
|
|
|
|
context.putImageData(imageData, 0, 0);
|
|
return canvas.toDataURL("image/png");
|
|
}, `data:image/png;base64,${base64}`).catch(() => null);
|
|
|
|
const prefix = "data:image/png;base64,";
|
|
return typeof strippedDataUrl === "string" && strippedDataUrl.startsWith(prefix)
|
|
? Buffer.from(strippedDataUrl.slice(prefix.length), "base64")
|
|
: null;
|
|
};
|
|
const hasMeaningfulStickerContent = (metrics) => {
|
|
if (!metrics) {
|
|
return false;
|
|
}
|
|
|
|
return metrics.opaqueRatio > 0.01 &&
|
|
(metrics.darkRatio > 0.01 ||
|
|
metrics.whiteRatio > 0.03 ||
|
|
metrics.cyanRatio < 0.5);
|
|
};
|
|
const stickerCaptureResult = async (buffer) => {
|
|
const metrics = await inspectStickerPng(buffer);
|
|
if (isMaxWallpaperCapture(metrics)) {
|
|
const stripped = await stripMaxWallpaperFromStickerPng(buffer);
|
|
if (stripped) {
|
|
const strippedMetrics = await inspectStickerPng(stripped);
|
|
if (!isMaxWallpaperCapture(strippedMetrics) && hasMeaningfulStickerContent(strippedMetrics)) {
|
|
return { contentType: "image/png", buffer: stripped };
|
|
}
|
|
}
|
|
|
|
throw new Error(`${kindLabel} capture matched MAX chat wallpaper instead of sticker.`);
|
|
}
|
|
|
|
return { contentType: "image/png", buffer };
|
|
};
|
|
const screenshotCandidate = async (handles, requestedIndex) => {
|
|
const candidates = [];
|
|
for (const handle of handles) {
|
|
const meta = await handle.evaluate((node, options) => {
|
|
const requestedTimeText = String(options?.timeText || "");
|
|
const acceptSmall = Boolean(options?.acceptSmall);
|
|
const element = node instanceof Element ? 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 semanticRoot = element.closest("[data-testid^='sticker-'], [data-testid*='emoji' i], [class*='sticker' i], [class*='emoji' i], [class*='lottie' i]") || element;
|
|
const semantic = [
|
|
element.getAttribute("data-testid"),
|
|
element.getAttribute("aria-label"),
|
|
element.getAttribute("title"),
|
|
element.className,
|
|
semanticRoot.getAttribute?.("data-testid"),
|
|
semanticRoot.getAttribute?.("aria-label"),
|
|
semanticRoot.getAttribute?.("title"),
|
|
semanticRoot.className,
|
|
wrapper.className,
|
|
wrapperText
|
|
].filter(Boolean).join(" ").toLowerCase();
|
|
const looksLikeSticker = /sticker|\u0441\u0442\u0438\u043a\u0435\u0440|emoji|\u044d\u043c\u043e\u0434\u0437\u0438|lottie|emoticon/.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 (acceptSmall && element.matches("canvas,img,svg")) score += 8;
|
|
if (rect.width >= (acceptSmall ? 20 : 72) && rect.height >= (acceptSmall ? 20 : 72)) score += 16;
|
|
if (rect.width >= 120 && rect.height >= 120) score += 8;
|
|
score += Math.min(12, area / 2500);
|
|
if (rect.width < (acceptSmall ? 12 : 40) || rect.height < (acceptSmall ? 12 : 40)) score -= 30;
|
|
if (area < (acceptSmall ? 256 : 4096)) score -= 20;
|
|
return {
|
|
score,
|
|
x: rect.x,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
y: rect.y,
|
|
area,
|
|
timeMatches: Boolean(timeMatches)
|
|
};
|
|
}, { timeText, acceptSmall: Boolean(request?.acceptSmall) }).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;
|
|
if (timeText && request?.requireTimeMatch && strict.length === 0) {
|
|
for (const item of candidates) {
|
|
await item.handle.dispose().catch(() => {});
|
|
}
|
|
return null;
|
|
}
|
|
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);
|
|
if (sorted.length === 0) {
|
|
return null;
|
|
}
|
|
const selectedIndex = Math.max(0, Math.min(requestedIndex, sorted.length - 1));
|
|
const orderedTargets = [
|
|
...sorted.slice(selectedIndex),
|
|
...sorted.slice(0, selectedIndex)
|
|
];
|
|
|
|
const screenshotClip = async (box) => {
|
|
if (!Number.isFinite(box?.x) ||
|
|
!Number.isFinite(box?.y) ||
|
|
!Number.isFinite(box?.width) ||
|
|
!Number.isFinite(box?.height)) {
|
|
return null;
|
|
}
|
|
|
|
const viewport = p.viewportSize() || { width: 1280, height: 720 };
|
|
const x = Math.max(0, Math.floor(box.x));
|
|
const y = Math.max(0, Math.floor(box.y));
|
|
const width = Math.min(Math.ceil(box.width), Math.max(1, viewport.width - x));
|
|
const height = Math.min(Math.ceil(box.height), Math.max(1, viewport.height - y));
|
|
if (width <= 0 || height <= 0) {
|
|
return null;
|
|
}
|
|
const maxClipSize = Number(request?.maxClipSize || 0);
|
|
if (maxClipSize > 0 && (width > maxClipSize || height > maxClipSize)) {
|
|
return null;
|
|
}
|
|
|
|
return await p.screenshot({
|
|
type: "png",
|
|
clip: { x, y, width, height },
|
|
timeout: 10000
|
|
});
|
|
};
|
|
|
|
const captureErrors = [];
|
|
try {
|
|
for (const target of orderedTargets) {
|
|
const metaBox = {
|
|
x: target.meta.x,
|
|
y: target.meta.y,
|
|
width: target.meta.width,
|
|
height: target.meta.height
|
|
};
|
|
const currentBoxOrMeta = async () => {
|
|
const box = await target.handle.boundingBox().catch(() => null);
|
|
return box && box.width > 0 && box.height > 0 ? box : metaBox;
|
|
};
|
|
|
|
try {
|
|
await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
const canvasCapture = await captureCanvasPng(target.handle);
|
|
if (canvasCapture.buffer) {
|
|
return await stickerCaptureResult(canvasCapture.buffer);
|
|
}
|
|
|
|
let buffer;
|
|
if (request?.acceptSmall) {
|
|
buffer = await screenshotClip(await currentBoxOrMeta());
|
|
if (!buffer) {
|
|
throw new Error(`${kindLabel} clip was not visible.`);
|
|
}
|
|
} else {
|
|
try {
|
|
buffer = await target.handle.screenshot({ type: "png", timeout: 5000 });
|
|
} catch (error) {
|
|
buffer = await screenshotClip(await currentBoxOrMeta());
|
|
if (!buffer) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
if (!buffer) {
|
|
buffer = await target.handle.screenshot({ type: "png", timeout: 5000 });
|
|
}
|
|
return await stickerCaptureResult(buffer);
|
|
} catch (error) {
|
|
captureErrors.push(error?.message || String(error));
|
|
}
|
|
}
|
|
|
|
if (captureErrors.length > 0) {
|
|
throw new Error(`${kindLabel} was not captured: ${captureErrors.slice(0, 5).join("; ")}`);
|
|
}
|
|
|
|
return null;
|
|
} finally {
|
|
for (const item of candidates) {
|
|
await item.handle.dispose().catch(() => {});
|
|
}
|
|
}
|
|
};
|
|
|
|
const index = Number.isFinite(Number(request?.index)) ? Number(request.index) : 0;
|
|
let testIdCaptureError = null;
|
|
if (testId) {
|
|
const handles = await p.$$(selectorForTestId(testId));
|
|
try {
|
|
const result = await screenshotCandidate(await resolveVisualHandles(handles), 0);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
} catch (error) {
|
|
testIdCaptureError = error;
|
|
}
|
|
}
|
|
|
|
const handles = await p.$$(fallbackSelector);
|
|
const result = await screenshotCandidate(await resolveVisualHandles(handles), index);
|
|
if (!result) {
|
|
if (testIdCaptureError) {
|
|
throw testIdCaptureError;
|
|
}
|
|
|
|
throw new Error(`${kindLabel} 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 extractChatUrl = (element) => {
|
|
const candidates = [
|
|
element.getAttribute("href"),
|
|
element.closest("a")?.getAttribute("href"),
|
|
element.getAttribute("data-url"),
|
|
element.getAttribute("data-href"),
|
|
element.getAttribute("data-link"),
|
|
element.getAttribute("data-path")
|
|
];
|
|
for (const attr of Array.from(element.attributes || [])) {
|
|
if (/url|href|link|path/i.test(attr.name)) {
|
|
candidates.push(attr.value);
|
|
}
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
const url = absoluteUrl(candidate);
|
|
if (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 = extractChatUrl(element);
|
|
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 >= 600) 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 < 180 && chats.length < 600; 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,
|
|
chatUrl: chat.href || 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 domEmojiUrlFromCanvas = (testId, timeText, index) => `qmax-dom-emoji:${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 safeEmojiFileName = (testId, index) => {
|
|
const suffix = cleanText(testId, 80).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
|
|
return `max-emoji-${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 emojiSelector = "[data-testid*='emoji' i], [class*='emoji' i], [class*='lottie' i], [class*='emoticon' i], canvas";
|
|
const emojiRoots = [
|
|
...(bubble.matches?.(emojiSelector) ? [bubble] : []),
|
|
...Array.from(bubble.querySelectorAll(emojiSelector))
|
|
];
|
|
const seenEmojiKeys = new Set();
|
|
for (const root of emojiRoots) {
|
|
if (!(root instanceof HTMLElement)) continue;
|
|
if (contactRoots.some((contactRoot) => contactRoot !== root && contactRoot.contains?.(root))) continue;
|
|
if (root.closest?.("[data-testid^='sticker-'], [class*='sticker' i]")) continue;
|
|
|
|
const rootText = cleanText(
|
|
root.innerText ||
|
|
root.textContent ||
|
|
root.getAttribute?.("aria-label") ||
|
|
root.getAttribute?.("title"),
|
|
300);
|
|
const semantic = [
|
|
root.getAttribute?.("data-testid"),
|
|
root.getAttribute?.("aria-label"),
|
|
root.getAttribute?.("title"),
|
|
root.className?.baseVal || root.className,
|
|
rootText,
|
|
bubbleText
|
|
].filter(Boolean).join(" ");
|
|
const hasVisual = root.matches("canvas,svg,img,image") || Boolean(root.querySelector("canvas,svg,img,image"));
|
|
const looksLikeEmoji = /emoji|\u044d\u043c\u043e\u0434\u0437\u0438|lottie|emoticon/i.test(semantic);
|
|
if (!looksLikeEmoji || !hasVisual) {
|
|
continue;
|
|
}
|
|
|
|
const directNode = root.matches("img,image,source")
|
|
? root
|
|
: root.querySelector("img,image,source");
|
|
const directSource = directNode?.currentSrc ||
|
|
directNode?.src ||
|
|
directNode?.href?.baseVal ||
|
|
directNode?.getAttribute?.("src") ||
|
|
directNode?.getAttribute?.("href") ||
|
|
directNode?.getAttribute?.("xlink:href") ||
|
|
srcFromSrcset(directNode?.getAttribute?.("srcset")) ||
|
|
cssBackgroundUrl(root);
|
|
const directUrl = absoluteUrl(directSource);
|
|
if (directUrl && !directUrl.startsWith("blob:")) {
|
|
continue;
|
|
}
|
|
|
|
const wrapper = bubble.closest("[class*='messageWrapper' i]") || bubble;
|
|
const messageTimeText = messageTimeTextFromWrapper(wrapper);
|
|
const testId = root.getAttribute("data-testid") ||
|
|
root.querySelector("[data-testid*='emoji' i], [data-testid*='lottie' i]")?.getAttribute("data-testid") ||
|
|
"";
|
|
const rect = root.getBoundingClientRect();
|
|
if (rect.width < 12 || rect.height < 12) {
|
|
continue;
|
|
}
|
|
const emojiKey = testId || `${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.width)}:${Math.round(rect.height)}`;
|
|
if (seenEmojiKeys.has(emojiKey)) {
|
|
continue;
|
|
}
|
|
seenEmojiKeys.add(emojiKey);
|
|
const label = cleanText(root.getAttribute("aria-label") || rootText || "\u042d\u043c\u043e\u0434\u0437\u0438", 120);
|
|
candidates.push({
|
|
url: domEmojiUrlFromCanvas(testId, messageTimeText, candidates.length),
|
|
label,
|
|
kind: "Sticker",
|
|
fileName: safeEmojiFileName(testId, candidates.length),
|
|
contentType: "image/png",
|
|
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) => {
|
|
const remoteUrl = attachDomMediaChatContext(attachment.remoteUrl, externalId, raw.url);
|
|
return {
|
|
...attachment,
|
|
remoteUrl,
|
|
externalId: `dommedia:${stableHash(externalId, messageExternalId, 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"),
|
|
element.value
|
|
].join(" ").toLowerCase();
|
|
const isSearchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text);
|
|
const isTopSidebarInput = rect.left < window.innerWidth * 0.42 &&
|
|
rect.top < window.innerHeight * 0.34;
|
|
return rect.left < window.innerWidth * 0.42 &&
|
|
rect.top < window.innerHeight * 0.32 &&
|
|
(isSearchLike || isTopSidebarInput);
|
|
});
|
|
|
|
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.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 focusSidebarSearch(p) {
|
|
const focused = await p.evaluate(async () => {
|
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
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 textMeta = (element) => [
|
|
element.getAttribute("aria-label"),
|
|
element.getAttribute("placeholder"),
|
|
element.getAttribute("title"),
|
|
element.getAttribute("data-testid"),
|
|
element.className,
|
|
element.id,
|
|
element.getAttribute("name"),
|
|
element.innerText,
|
|
element.textContent
|
|
].join(" ").toLowerCase();
|
|
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 findInput = () => {
|
|
const aside = document.querySelector("aside") || document.body;
|
|
return Array.from(aside.querySelectorAll("input, textarea, [contenteditable='true'], [role='textbox']"))
|
|
.filter(isVisible)
|
|
.filter((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const text = textMeta(element);
|
|
const isSearchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text);
|
|
const isTopSidebarInput = rect.left < window.innerWidth * 0.42 &&
|
|
rect.top < window.innerHeight * 0.34;
|
|
return isSearchLike || isTopSidebarInput;
|
|
})
|
|
.sort((a, b) => {
|
|
const aSearch = /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(a)) ? 0 : 1;
|
|
const bSearch = /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(b)) ? 0 : 1;
|
|
if (aSearch !== bSearch) return aSearch - bSearch;
|
|
return a.getBoundingClientRect().top - b.getBoundingClientRect().top;
|
|
})[0] || null;
|
|
};
|
|
let input = findInput();
|
|
if (!input) {
|
|
const aside = document.querySelector("aside") || document.body;
|
|
const button = Array.from(aside.querySelectorAll("button, [role='button']"))
|
|
.filter(isVisible)
|
|
.find((element) => /search|\u043f\u043e\u0438\u0441\u043a/.test(textMeta(element)));
|
|
if (button) {
|
|
button.click();
|
|
await delay(200);
|
|
input = findInput();
|
|
}
|
|
}
|
|
if (!input) return false;
|
|
|
|
input.focus();
|
|
if ("value" in input) {
|
|
setNativeValue(input, "");
|
|
} else {
|
|
input.textContent = "";
|
|
}
|
|
input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
return true;
|
|
}).catch(() => false);
|
|
|
|
if (focused) {
|
|
await p.waitForTimeout(100);
|
|
}
|
|
return Boolean(focused);
|
|
}
|
|
|
|
async function searchSidebarChat(p, title) {
|
|
await clearSidebarSearch(p);
|
|
const focused = await focusSidebarSearch(p);
|
|
if (!focused) {
|
|
return false;
|
|
}
|
|
|
|
await p.keyboard.insertText(title);
|
|
await p.waitForTimeout(700);
|
|
return true;
|
|
}
|
|
|
|
async function waitForMaxChatListReady(p, timeoutMs = 15000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const ready = 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 > 20 &&
|
|
rect.height > 12 &&
|
|
rect.bottom >= 0 &&
|
|
rect.right >= 0 &&
|
|
rect.top <= window.innerHeight &&
|
|
rect.left <= window.innerWidth;
|
|
};
|
|
|
|
return Array.from(document.querySelectorAll("aside button.cell, aside button[class*='cell' i], aside button.item, aside button[class*='item' i]"))
|
|
.some(isVisible);
|
|
}).catch(() => false);
|
|
|
|
if (ready) {
|
|
return true;
|
|
}
|
|
|
|
await p.waitForTimeout(250);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function readVisibleSidebarChatRows(p) {
|
|
return await p.evaluate(() => {
|
|
const cleanText = (value, limit = 500) => String(value || "")
|
|
.replace(/\u00a0/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, limit);
|
|
const linesFrom = (value) => String(value || "")
|
|
.split(/\n+/)
|
|
.map((line) => cleanText(line, 180))
|
|
.filter(Boolean);
|
|
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 > 20 &&
|
|
rect.height > 12 &&
|
|
rect.bottom >= 0 &&
|
|
rect.right >= 0 &&
|
|
rect.top <= window.innerHeight &&
|
|
rect.left <= window.innerWidth;
|
|
};
|
|
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, 160);
|
|
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, 80);
|
|
const ignored = new Set([
|
|
title,
|
|
timeText,
|
|
cleanText(element.querySelector("[class*='badge' i], [class*='indicator' i], [class*='counter' i]")?.innerText, 80)
|
|
].filter(Boolean));
|
|
return 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 textOf = (element) => String(
|
|
element.innerText ||
|
|
element.textContent ||
|
|
element.getAttribute("aria-label") ||
|
|
element.getAttribute("title") ||
|
|
""
|
|
).trim().toLowerCase();
|
|
const isMenuButton = (element) => {
|
|
const text = textOf(element);
|
|
const cls = String(element.className || "").toLowerCase();
|
|
return text.includes("\u0435\u0449") ||
|
|
text.includes("more") ||
|
|
text.includes("menu") ||
|
|
text.includes("actions") ||
|
|
text.includes("\u0434\u0435\u0439\u0441\u0442\u0432") ||
|
|
cls.includes("more") ||
|
|
cls.includes("menu") ||
|
|
text === "\u22ef" ||
|
|
text === "\u2026";
|
|
};
|
|
const menuPoint = (element) => {
|
|
const buttons = Array.from(element.querySelectorAll("button,[role='button']"))
|
|
.filter((button) => button instanceof HTMLElement && isVisible(button));
|
|
const match = buttons.find(isMenuButton) ||
|
|
buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0] ||
|
|
null;
|
|
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)
|
|
};
|
|
};
|
|
const selector = [
|
|
"aside [role='presentation']",
|
|
"aside [class*='wrapper' i]",
|
|
"aside button.cell",
|
|
"aside button[class*='cell' i]",
|
|
"aside button.item",
|
|
"aside button[class*='item' i]",
|
|
"aside [role='presentation'] button",
|
|
"aside [class*='wrapper' i] button",
|
|
"aside [data-testid*='chat' i]",
|
|
"aside [data-testid*='dialog' i]",
|
|
"aside [data-testid*='conversation' i]"
|
|
].join(",");
|
|
|
|
return Array.from(document.querySelectorAll(selector))
|
|
.filter(isVisible)
|
|
.map((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const text = cleanText(element.innerText || element.textContent, 500);
|
|
const lines = linesFrom(element.innerText || element.textContent);
|
|
const title = extractTitle(element, lines);
|
|
const avatarUrl = extractAvatarUrl(element);
|
|
const menu = menuPoint(element);
|
|
return {
|
|
title,
|
|
text,
|
|
preview: extractPreview(element, title, lines),
|
|
timeText: extractTimeText(element, lines),
|
|
avatarUrl,
|
|
left: Math.round(rect.left),
|
|
right: Math.round(rect.right),
|
|
x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.50))),
|
|
y: Math.round(rect.top + rect.height / 2),
|
|
menuX: menu?.x || null,
|
|
menuY: menu?.y || null,
|
|
top: Math.round(rect.top)
|
|
};
|
|
})
|
|
.filter((row) => row.title && row.text)
|
|
.filter((row, index, rows) =>
|
|
rows.findIndex((candidate) => candidate.title === row.title && Math.abs(candidate.top - row.top) <= 2) === index)
|
|
.sort((a, b) => a.top - b.top);
|
|
}).catch(() => []);
|
|
}
|
|
|
|
async function scrollSidebarForward(p) {
|
|
return await p.evaluate(() => {
|
|
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) return false;
|
|
const bottom = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
|
|
if (scrollRoot.scrollTop >= bottom - 4) return false;
|
|
const nextTop = Math.min(bottom, scrollRoot.scrollTop + Math.max(220, Math.floor(scrollRoot.clientHeight * 0.85)));
|
|
if (nextTop === scrollRoot.scrollTop) return false;
|
|
scrollRoot.scrollTop = nextTop;
|
|
scrollRoot.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
return true;
|
|
}).catch(() => false);
|
|
}
|
|
|
|
function normalizeDomTitle(value) {
|
|
return cleanComparableText(value, 160).toLowerCase();
|
|
}
|
|
|
|
function rowMatchesDomChatDescriptor(row, descriptor) {
|
|
const wanted = normalizeDomTitle(descriptor?.title);
|
|
const current = normalizeDomTitle(row?.title);
|
|
return Boolean(wanted && current && (
|
|
current === wanted ||
|
|
current.includes(wanted) ||
|
|
wanted.includes(current)
|
|
));
|
|
}
|
|
|
|
function chooseDomChatRow(rows, descriptor) {
|
|
const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor));
|
|
if (matches.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const expectedAvatar = normalizeDomChatFingerprint(descriptor?.avatarUrl);
|
|
if (expectedAvatar) {
|
|
const strict = matches.filter((row) => normalizeDomChatFingerprint(row.avatarUrl) === expectedAvatar);
|
|
if (strict.length > 0) {
|
|
return strict[0];
|
|
}
|
|
}
|
|
|
|
return matches.length === 1 ? matches[0] : null;
|
|
}
|
|
|
|
async function clickMatchingSidebarRowAndReadUrl(p, descriptor) {
|
|
const row = chooseDomChatRow(await readVisibleSidebarChatRows(p), descriptor);
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
await p.mouse.click(row.x, row.y);
|
|
await waitForDomChatReady(p, descriptor.title, 5000, false);
|
|
await p.waitForTimeout(150);
|
|
return await getCurrentChatUrl(p);
|
|
}
|
|
|
|
async function openSidebarChatMenu(p, externalChatId, chatUrl, expectedLabels) {
|
|
const descriptor = decodeDomChatDescriptor(externalChatId);
|
|
if (!descriptor?.title) {
|
|
return false;
|
|
}
|
|
|
|
const targetUrl = normalizeMaxChatUrl(chatUrl);
|
|
const sameChatUrl = (value) => {
|
|
const current = normalizeMaxChatUrl(value);
|
|
return Boolean(targetUrl && current && current === targetUrl);
|
|
};
|
|
|
|
const chooseVisibleRow = async () => {
|
|
const rows = await readVisibleSidebarChatRows(p);
|
|
const matches = rows.filter((row) => rowMatchesDomChatDescriptor(row, descriptor));
|
|
if (matches.length === 0) {
|
|
return { row: null, rows };
|
|
}
|
|
|
|
if (targetUrl) {
|
|
for (const candidate of matches) {
|
|
await p.mouse.click(candidate.x, candidate.y);
|
|
await waitForDomChatReady(p, candidate.title, 5000, false);
|
|
await p.waitForTimeout(150);
|
|
if (sameChatUrl(await getCurrentChatUrl(p))) {
|
|
return { row: candidate, rows };
|
|
}
|
|
}
|
|
|
|
return { row: null, rows };
|
|
}
|
|
|
|
return { row: chooseDomChatRow(rows, descriptor), rows };
|
|
};
|
|
|
|
const clickVisibleRowMenu = async () => {
|
|
const { row, rows } = await chooseVisibleRow();
|
|
if (!row) {
|
|
return false;
|
|
}
|
|
|
|
const x = row.menuX || (row.right ? row.right - 32 : row.x);
|
|
const y = row.menuY || row.y;
|
|
console.info(`[chat/menu] matched sidebar row title=${row.title} menu=(${x},${y}) avatar=${row.avatarUrl || ""} visibleRows=${rows.length}`);
|
|
await p.mouse.move(x, y);
|
|
await p.waitForTimeout(150);
|
|
await p.mouse.click(x, y);
|
|
await p.waitForTimeout(600);
|
|
const opened = await hasMenuActionSurface(p, expectedLabels);
|
|
if (!opened) {
|
|
const menuTexts = await readVisibleActionTexts(p);
|
|
console.warn(`[chat/menu] expected action labels not found; labels=${expectedLabels.join("|")} visible=${menuTexts.slice(0, 20).join("|")}`);
|
|
await p.keyboard.press("Escape").catch(() => {});
|
|
await p.waitForTimeout(150);
|
|
}
|
|
return opened;
|
|
};
|
|
|
|
await clearSidebarSearch(p);
|
|
await resetSidebarListToTop(p);
|
|
await waitForMaxChatListReady(p, 15000);
|
|
if (await clickVisibleRowMenu()) {
|
|
return true;
|
|
}
|
|
|
|
const searched = await searchSidebarChat(p, descriptor.title);
|
|
if (searched && await clickVisibleRowMenu()) {
|
|
return true;
|
|
}
|
|
|
|
await clearSidebarSearch(p);
|
|
await resetSidebarListToTop(p);
|
|
await waitForMaxChatListReady(p, 15000);
|
|
|
|
for (let pageIndex = 0; pageIndex < 220; pageIndex++) {
|
|
if (await clickVisibleRowMenu()) {
|
|
return true;
|
|
}
|
|
|
|
const moved = await scrollSidebarForward(p);
|
|
if (!moved) break;
|
|
await p.waitForTimeout(250);
|
|
}
|
|
|
|
await clearSidebarSearch(p);
|
|
return false;
|
|
}
|
|
|
|
async function resolveDomChatUrl(p, externalChatId) {
|
|
const descriptor = decodeDomChatDescriptor(externalChatId);
|
|
if (!descriptor?.title) {
|
|
return null;
|
|
}
|
|
|
|
const embeddedUrl = normalizeMaxChatUrl(descriptor.href);
|
|
if (embeddedUrl) {
|
|
return embeddedUrl;
|
|
}
|
|
|
|
const opened = await ensureDomChatOpen(p, externalChatId, 5000, false, null);
|
|
if (opened) {
|
|
const currentUrl = await getCurrentChatUrl(p);
|
|
if (currentUrl) {
|
|
return currentUrl;
|
|
}
|
|
}
|
|
|
|
await clearSidebarSearch(p);
|
|
await resetSidebarListToTop(p);
|
|
await waitForMaxChatListReady(p, 15000);
|
|
|
|
let chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor);
|
|
if (chatUrl) {
|
|
await clearSidebarSearch(p);
|
|
return chatUrl;
|
|
}
|
|
|
|
const searched = await searchSidebarChat(p, descriptor.title);
|
|
if (searched) {
|
|
chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor);
|
|
if (chatUrl) {
|
|
await clearSidebarSearch(p);
|
|
return chatUrl;
|
|
}
|
|
}
|
|
|
|
await clearSidebarSearch(p);
|
|
await resetSidebarListToTop(p);
|
|
await waitForMaxChatListReady(p, 15000);
|
|
|
|
let stagnantPages = 0;
|
|
for (let pageIndex = 0; pageIndex < 220; pageIndex++) {
|
|
chatUrl = await clickMatchingSidebarRowAndReadUrl(p, descriptor);
|
|
if (chatUrl) {
|
|
await clearSidebarSearch(p);
|
|
return chatUrl;
|
|
}
|
|
|
|
const moved = await scrollSidebarForward(p);
|
|
if (!moved) break;
|
|
await p.waitForTimeout(250);
|
|
|
|
const rows = await readVisibleSidebarChatRows(p);
|
|
stagnantPages = rows.length === 0 ? stagnantPages + 1 : 0;
|
|
if (stagnantPages >= 4) break;
|
|
}
|
|
|
|
await clearSidebarSearch(p);
|
|
return null;
|
|
}
|
|
|
|
async function collectSidebarChatUrls(p, limit = 600) {
|
|
await clearSidebarSearch(p);
|
|
await resetSidebarListToTop(p);
|
|
await waitForMaxChatListReady(p, 15000);
|
|
|
|
const results = [];
|
|
const seen = new Set();
|
|
let stagnantPages = 0;
|
|
|
|
for (let pageIndex = 0; pageIndex < 220 && results.length < limit; pageIndex++) {
|
|
const rows = await readVisibleSidebarChatRows(p);
|
|
let addedOnPage = 0;
|
|
|
|
for (const row of rows) {
|
|
if (results.length >= limit) break;
|
|
const key = `${row.title}|${row.avatarUrl || ""}|${row.text}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
addedOnPage++;
|
|
|
|
await p.mouse.click(row.x, row.y);
|
|
const ready = await waitForDomChatReady(p, row.title, 5000, false);
|
|
await p.waitForTimeout(150);
|
|
const chatUrl = await getCurrentChatUrl(p);
|
|
results.push({
|
|
externalId: makeDomChatExternalId(row.title, row.text, "", results.length, row.avatarUrl),
|
|
title: row.title,
|
|
preview: row.preview,
|
|
timeText: row.timeText,
|
|
avatarUrl: row.avatarUrl || null,
|
|
chatUrl,
|
|
ready,
|
|
rowText: row.text
|
|
});
|
|
}
|
|
|
|
const moved = await scrollSidebarForward(p);
|
|
if (!moved) break;
|
|
await p.waitForTimeout(250);
|
|
|
|
stagnantPages = addedOnPage === 0 ? stagnantPages + 1 : 0;
|
|
if (stagnantPages >= 4) break;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function openDomChatByUrl(p, chatUrl) {
|
|
const targetUrl = normalizeMaxChatUrl(chatUrl);
|
|
if (!targetUrl) {
|
|
return false;
|
|
}
|
|
|
|
const currentUrl = normalizeMaxChatUrl(p.url());
|
|
if (currentUrl === targetUrl) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
await p.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
await p.waitForTimeout(900);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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, options = {}) {
|
|
const descriptor = decodeDomChatDescriptor(externalChatId);
|
|
const title = descriptor?.title || externalChatId;
|
|
if (!title) {
|
|
return false;
|
|
}
|
|
|
|
if (!options.preserveSidebarSearch) {
|
|
await clearSidebarSearch(p);
|
|
await resetSidebarListToTop(p);
|
|
await waitForMaxChatListReady(p, 15000);
|
|
}
|
|
|
|
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 chatSelector = [
|
|
"aside button.cell",
|
|
"aside button[class*='cell' i]",
|
|
"aside [role='presentation'] button",
|
|
"aside [class*='wrapper' i] button",
|
|
"aside [data-testid*='chat' i]",
|
|
"aside [data-testid*='dialog' i]",
|
|
"aside [data-testid*='conversation' i]",
|
|
"aside a[href*='chat' i]",
|
|
"aside a[href*='dialog' i]"
|
|
].join(",");
|
|
const rows = Array.from(document.querySelectorAll(chatSelector));
|
|
const candidates = rows.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.50))),
|
|
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, requireComposer = false, chatUrl = null) {
|
|
const descriptor = decodeDomChatDescriptor(externalChatId);
|
|
const title = descriptor?.title || externalChatId;
|
|
if (!title) {
|
|
return true;
|
|
}
|
|
|
|
const directUrl = normalizeMaxChatUrl(chatUrl) || normalizeMaxChatUrl(descriptor?.href);
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
if (directUrl) {
|
|
const openedByUrl = await openDomChatByUrl(p, directUrl);
|
|
if (openedByUrl) {
|
|
const readyByUrl = await waitForDomChatReady(
|
|
p,
|
|
title,
|
|
Math.max(waitMs, requireComposer ? 8000 : 2500),
|
|
requireComposer);
|
|
if (readyByUrl) {
|
|
await clearSidebarSearch(p);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
let opened = await openDomChatByExternalId(p, externalChatId);
|
|
if (!opened) {
|
|
const searched = await searchSidebarChat(p, title);
|
|
if (searched) {
|
|
opened = await openDomChatByExternalId(p, externalChatId, { preserveSidebarSearch: true });
|
|
}
|
|
}
|
|
if (!opened) {
|
|
await clearSidebarSearch(p);
|
|
return false;
|
|
}
|
|
|
|
const ready = await waitForDomChatReady(
|
|
p,
|
|
title,
|
|
Math.max(waitMs, requireComposer ? 8000 : 2500),
|
|
requireComposer);
|
|
if (ready) {
|
|
await clearSidebarSearch(p);
|
|
return true;
|
|
}
|
|
|
|
await clearSidebarSearch(p);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function waitForDomChatReady(p, title, timeoutMs = 5000, requireComposer = false) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const state = await p.evaluate(({ requestedTitle, needsComposer }) => {
|
|
const cleanText = (value) => String(value || "")
|
|
.replace(/\u00a0/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
const normalize = (value) => cleanText(value).toLowerCase();
|
|
const wanted = normalize(requestedTitle);
|
|
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 normalizeClassName = (value) => typeof value === "string" ? value : String(value?.baseVal || "");
|
|
const opened = document.querySelector("[class*='openedChat' i]") ||
|
|
document.querySelector("main") ||
|
|
document.body;
|
|
const rightPaneLeft = window.innerWidth * 0.32;
|
|
const headerCandidates = Array.from(opened.querySelectorAll("[class*='header' i], header"))
|
|
.filter((node) => isVisible(node))
|
|
.filter((node) => node.getBoundingClientRect().left > rightPaneLeft);
|
|
const header = headerCandidates[0] || null;
|
|
const headerTitle = cleanText(
|
|
header?.querySelector("h1,h2,h3,[class*='title' i],[class*='name' i]")?.innerText ||
|
|
header?.querySelector("button[aria-label]")?.innerText ||
|
|
header?.innerText,
|
|
160);
|
|
const selectedTitle = cleanText(document.querySelector("aside button[class*='selected' i] h3")?.innerText, 160);
|
|
const composerSelectors = [
|
|
"[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]",
|
|
"textarea"
|
|
];
|
|
const hasComposer = composerSelectors
|
|
.flatMap((selector) => Array.from(document.querySelectorAll(selector)))
|
|
.some((node) => {
|
|
if (!isVisible(node)) return false;
|
|
const rect = node.getBoundingClientRect();
|
|
const text = normalize([
|
|
node.getAttribute("aria-label"),
|
|
node.getAttribute("placeholder"),
|
|
node.getAttribute("title"),
|
|
node.getAttribute("data-testid"),
|
|
normalizeClassName(node.className),
|
|
node.id,
|
|
node.getAttribute("name")
|
|
].join(" "));
|
|
const searchLike = /search|\u043f\u043e\u0438\u0441\u043a/.test(text) ||
|
|
(rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32);
|
|
return !searchLike &&
|
|
rect.left > rightPaneLeft &&
|
|
rect.top > window.innerHeight * 0.45;
|
|
});
|
|
const current = normalize(headerTitle || (hasComposer ? selectedTitle : ""));
|
|
const titleMatches = !wanted ||
|
|
(current && (current === wanted || current.includes(wanted) || wanted.includes(current)));
|
|
const ready = Boolean(titleMatches && header && (!needsComposer || hasComposer));
|
|
return { ready, headerTitle, selectedTitle, hasHeader: Boolean(header), hasComposer };
|
|
}, { requestedTitle: title, needsComposer: requireComposer }).catch(() => null);
|
|
|
|
if (state?.ready) {
|
|
return true;
|
|
}
|
|
|
|
await p.waitForTimeout(250);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function sendTextAndConfirm(p, externalChatId, text) {
|
|
const wanted = cleanComparableText(text, 1000);
|
|
if (!wanted) {
|
|
return null;
|
|
}
|
|
|
|
const initialOutgoingMessages = await getOutgoingMessages(p, externalChatId);
|
|
const initialOutgoingIds = new Set(initialOutgoingMessages.map((message) => message.externalId).filter(Boolean));
|
|
const initialMatches = initialOutgoingMessages
|
|
.filter((message) => messageTextMatches(message.text, wanted))
|
|
.length;
|
|
const attempts = [
|
|
{ name: "send button", action: () => clickSendButton(p) },
|
|
{ name: "composer fallback button", action: () => clickComposerRightmostButton(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; } }
|
|
];
|
|
|
|
for (const attempt of attempts) {
|
|
if (!(await fillComposerText(p, text, true))) {
|
|
console.warn(`[send/text] composer fill failed; chat=${externalChatId || "current"} textLength=${String(text || "").length}`);
|
|
return null;
|
|
}
|
|
|
|
const composerState = await readComposerState(p);
|
|
if (!composerStateMatchesText(composerState, text)) {
|
|
console.warn(`[send/text] composer mismatch after fill; attempt=${attempt.name} composerLength=${composerState?.text?.length ?? -1} richCount=${composerState?.richContentCount ?? -1} wantedLength=${wanted.length}`);
|
|
continue;
|
|
}
|
|
|
|
const triggered = await attempt.action().catch(() => false);
|
|
if (!triggered) {
|
|
console.warn(`[send/text] send trigger was not found; attempt=${attempt.name}`);
|
|
continue;
|
|
}
|
|
|
|
await scrollOpenChatToBottom(p);
|
|
const externalId = await findSentOutgoingExternalId(
|
|
p,
|
|
externalChatId,
|
|
text,
|
|
initialMatches,
|
|
initialOutgoingIds,
|
|
10000);
|
|
if (externalId) {
|
|
console.info(`[send/text] confirmed outgoing message; attempt=${attempt.name} externalId=${externalId}`);
|
|
return externalId;
|
|
}
|
|
|
|
const currentComposerState = await readComposerState(p);
|
|
if (composerStateIsEmpty(currentComposerState)) {
|
|
console.warn(`[send/text] MAX cleared composer but no outgoing message appeared; attempt=${attempt.name}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function sendAttachmentAndConfirm(p, externalChatId, path, caption) {
|
|
const initialOutgoingMessages = await getOutgoingMessages(p, externalChatId);
|
|
const initialOutgoingIds = new Set(initialOutgoingMessages.map((message) => message.externalId).filter(Boolean));
|
|
const wantedCaption = cleanComparableText(caption, 1000);
|
|
const initialMatches = wantedCaption
|
|
? initialOutgoingMessages.filter((message) => messageTextMatches(message.text, wantedCaption)).length
|
|
: 0;
|
|
|
|
console.info(`[send/attachment] starting upload; chat=${externalChatId || "current"} captionLength=${String(caption || "").trim().length} path=${path}`);
|
|
const triggered = await uploadAttachmentFile(p, path, caption);
|
|
if (!triggered) {
|
|
console.warn(`[send/attachment] upload/send trigger failed; chat=${externalChatId || "current"} path=${path}`);
|
|
return null;
|
|
}
|
|
|
|
await scrollOpenChatToBottom(p);
|
|
const externalId = await findSentOutgoingExternalId(
|
|
p,
|
|
externalChatId,
|
|
caption || "",
|
|
initialMatches,
|
|
initialOutgoingIds,
|
|
20000);
|
|
if (externalId) {
|
|
console.info(`[send/attachment] confirmed outgoing attachment; externalId=${externalId}`);
|
|
return externalId;
|
|
}
|
|
|
|
console.warn(`[send/attachment] MAX did not expose a new outgoing attachment; chat=${externalChatId || "current"} path=${path}`);
|
|
return null;
|
|
}
|
|
|
|
async function fillComposerText(p, text, clearFirst) {
|
|
if (clearFirst && !(await clearComposerInput(p))) {
|
|
console.warn("[send/text] composer did not report empty after clear; continuing with input fallback");
|
|
}
|
|
|
|
if (await fillComposerViaClipboard(p, text, false)) {
|
|
return true;
|
|
}
|
|
|
|
if (clearFirst) {
|
|
await clearComposerInput(p);
|
|
}
|
|
if (await fillComposerViaKeyboard(p, text, false)) {
|
|
return true;
|
|
}
|
|
|
|
if (clearFirst) {
|
|
await clearComposerInput(p);
|
|
}
|
|
if (await insertComposerTextViaDom(p, text, false)) {
|
|
await p.waitForTimeout(300);
|
|
if (composerStateMatchesText(await readComposerState(p), text)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
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 state = await readComposerState(p);
|
|
if (composerStateMatchesText(state, 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 state = await readComposerState(p);
|
|
if (composerStateMatchesText(state, text)) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
// Try the next composer shape.
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function clearComposerInput(p) {
|
|
if (!(await focusComposer(p))) {
|
|
return false;
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
|
|
await p.keyboard.press("Backspace");
|
|
await p.waitForTimeout(120);
|
|
if (composerStateIsEmpty(await readComposerState(p))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
await insertComposerTextViaDom(p, "", true);
|
|
await p.waitForTimeout(150);
|
|
return composerStateIsEmpty(await readComposerState(p));
|
|
}
|
|
|
|
async function fillComposerViaClipboard(p, text, clearFirst) {
|
|
if (!(await focusComposer(p))) {
|
|
return false;
|
|
}
|
|
|
|
if (clearFirst) {
|
|
await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
|
|
await p.keyboard.press("Backspace");
|
|
}
|
|
|
|
const wroteClipboard = await p.evaluate(async (value) => {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, text).catch(() => false);
|
|
|
|
if (wroteClipboard) {
|
|
await p.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
|
await p.waitForTimeout(300);
|
|
if (composerStateMatchesText(await readComposerState(p), text)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function fillComposerViaKeyboard(p, text, clearFirst) {
|
|
if (!(await focusComposer(p))) {
|
|
return false;
|
|
}
|
|
|
|
if (clearFirst) {
|
|
await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
|
|
await p.keyboard.press("Backspace");
|
|
await p.waitForTimeout(80);
|
|
}
|
|
|
|
await p.keyboard.insertText(text);
|
|
await p.waitForTimeout(300);
|
|
return composerStateMatchesText(await readComposerState(p), text);
|
|
}
|
|
|
|
async function insertComposerTextViaDom(p, text, clearFirst = false) {
|
|
return await p.evaluate(({ value, clear }) => {
|
|
const cleanText = (textValue) => String(textValue || "")
|
|
.replace(/\u00a0/g, " ")
|
|
.replace(/[\u200b\u200c\u200d\ufeff]/g, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
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 textValue = 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(textValue) ||
|
|
(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]",
|
|
"[role='textbox']",
|
|
"[contenteditable='true']",
|
|
"textarea"
|
|
];
|
|
const candidates = selectors
|
|
.flatMap((selector) => Array.from(document.querySelectorAll(selector)))
|
|
.filter((element) => 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 instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
composer.focus({ preventScroll: true });
|
|
if (clear) {
|
|
if ("value" in composer) {
|
|
composer.value = "";
|
|
} else {
|
|
composer.replaceChildren();
|
|
}
|
|
composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null }));
|
|
}
|
|
|
|
if (!value) {
|
|
composer.dispatchEvent(new Event("change", { bubbles: true }));
|
|
return true;
|
|
}
|
|
|
|
const beforeInput = new InputEvent("beforeinput", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
inputType: "insertText",
|
|
data: value
|
|
});
|
|
composer.dispatchEvent(beforeInput);
|
|
if ("value" in composer) {
|
|
composer.value = `${composer.value || ""}${value}`;
|
|
} else if (!document.execCommand("insertText", false, value)) {
|
|
composer.textContent = `${composer.textContent || ""}${value}`;
|
|
}
|
|
composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
|
composer.dispatchEvent(new Event("change", { bubbles: true }));
|
|
return true;
|
|
}, { value: text, clear: clearFirst }).catch(() => 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);
|
|
if (composerStateMatchesText(await readComposerState(p), 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) {
|
|
if (composerStateIsEmpty(await readComposerState(p))) {
|
|
return true;
|
|
}
|
|
|
|
await p.waitForTimeout(250);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function readComposerText(p) {
|
|
const state = await readComposerState(p);
|
|
return state?.text ?? null;
|
|
}
|
|
|
|
async function readComposerState(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) => {
|
|
if (!(element instanceof Element)) 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(" ")).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 : "";
|
|
const text = cleanText(value || composer.innerText || composer.textContent);
|
|
const richContentCount = Array.from(composer.querySelectorAll([
|
|
"img",
|
|
"canvas",
|
|
"svg",
|
|
"[class*='emoji' i]",
|
|
"[class*='sticker' i]",
|
|
"[class*='lottie' i]",
|
|
"[class*='emoticon' i]",
|
|
"[data-testid*='emoji' i]",
|
|
"[data-testid*='sticker' i]",
|
|
"[data-testid*='lottie' i]"
|
|
].join(",")))
|
|
.filter((element) => isVisible(element))
|
|
.filter((element) => !/placeholder/i.test(String(element.className || "")))
|
|
.length;
|
|
return { text, richContentCount };
|
|
}).catch(() => null);
|
|
}
|
|
|
|
function composerStateIsEmpty(state) {
|
|
return Boolean(state && !state.text && (state.richContentCount || 0) === 0);
|
|
}
|
|
|
|
function composerStateMatchesText(state, wantedText) {
|
|
if (!state) {
|
|
return false;
|
|
}
|
|
|
|
if (messageTextMatches(state.text, wantedText)) {
|
|
return true;
|
|
}
|
|
|
|
if ((state.richContentCount || 0) === 0 || !containsEmojiSyntax(wantedText)) {
|
|
return false;
|
|
}
|
|
|
|
if (isEmojiOnlyText(wantedText)) {
|
|
return true;
|
|
}
|
|
|
|
const composerText = stripEmojiForComparison(state.text);
|
|
const wanted = stripEmojiForComparison(wantedText);
|
|
return Boolean(composerText && wanted && (
|
|
composerText === wanted ||
|
|
composerText.endsWith(wanted) ||
|
|
wanted.endsWith(composerText)
|
|
));
|
|
}
|
|
|
|
function containsEmojiSyntax(value) {
|
|
return /[\p{Extended_Pictographic}\u200d\ufe0f\ufe0e]/u.test(String(value || ""));
|
|
}
|
|
|
|
function isEmojiOnlyText(value) {
|
|
const text = cleanComparableText(value, 1000);
|
|
return Boolean(text && containsEmojiSyntax(text) && !stripEmojiForComparison(text));
|
|
}
|
|
|
|
function stripEmojiForComparison(value) {
|
|
return cleanComparableText(value, 1000)
|
|
.replace(/[\p{Extended_Pictographic}\u200d\ufe0f\ufe0e]/gu, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
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 getOutgoingMessages(p, externalChatId) {
|
|
const history = await extractOpenChatHistory(p, externalChatId).catch(() => null);
|
|
return (history?.messages || []).filter((message) => message.isOutgoing);
|
|
}
|
|
|
|
async function getOutgoingTextMatches(p, externalChatId, text) {
|
|
const wanted = cleanComparableText(text, 1000);
|
|
if (!wanted) {
|
|
return [];
|
|
}
|
|
|
|
return (await getOutgoingMessages(p, externalChatId))
|
|
.filter((message) => 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,
|
|
initialOutgoingIds = new Set(),
|
|
timeoutMs = 15000) {
|
|
const knownIds = initialOutgoingIds instanceof Set
|
|
? initialOutgoingIds
|
|
: new Set(Array.from(initialOutgoingIds || []).filter(Boolean));
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
await p.waitForTimeout(500);
|
|
const outgoingMessages = await getOutgoingMessages(p, externalChatId);
|
|
const matches = outgoingMessages.filter((message) => messageTextMatches(message.text, text));
|
|
if (matches.length > previousMatchCount) {
|
|
const latest = matches[matches.length - 1];
|
|
if (latest?.externalId) {
|
|
return latest.externalId;
|
|
}
|
|
}
|
|
|
|
const newOutgoing = outgoingMessages.filter((message) =>
|
|
message.externalId &&
|
|
!knownIds.has(message.externalId));
|
|
if (newOutgoing.length > 0) {
|
|
return newOutgoing[newOutgoing.length - 1].externalId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function uploadAttachmentFile(p, path, caption) {
|
|
try {
|
|
const fileStat = await fs.stat(path);
|
|
if (!fileStat.isFile()) {
|
|
console.warn(`[send/attachment] file is not a regular file; path=${path}`);
|
|
return false;
|
|
}
|
|
} catch {
|
|
console.warn(`[send/attachment] file does not exist or is not readable; path=${path}`);
|
|
return false;
|
|
}
|
|
|
|
let attached = await attachFileViaUploadButton(p, path);
|
|
|
|
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) {
|
|
console.warn(`[send/attachment] no usable upload control was found; path=${path}`);
|
|
return false;
|
|
}
|
|
|
|
await p.waitForTimeout(2000);
|
|
const previewReady = await waitForAttachmentPreview(p, 10000);
|
|
console.info(`[send/attachment] file selected; previewReady=${previewReady} url=${p.url()}`);
|
|
if (!previewReady) {
|
|
return false;
|
|
}
|
|
|
|
if (caption.trim()) {
|
|
await fillComposerText(p, caption.trim(), false);
|
|
await p.waitForTimeout(300);
|
|
}
|
|
|
|
const sent = await clickSendButton(p) || await clickComposerRightmostButton(p);
|
|
if (!sent) {
|
|
console.warn(`[send/attachment] send button was not found after file selection; path=${path}`);
|
|
return false;
|
|
}
|
|
|
|
await p.waitForTimeout(1500);
|
|
return true;
|
|
}
|
|
|
|
async function attachFileViaUploadButton(p, filePath) {
|
|
const point = 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 buttons = Array.from(document.querySelectorAll("button, [role='button']"))
|
|
.filter((button) => !button.disabled && isVisible(button));
|
|
const candidates = buttons
|
|
.map((button) => {
|
|
const rect = button.getBoundingClientRect();
|
|
const text = [
|
|
button.getAttribute("aria-label"),
|
|
button.getAttribute("title"),
|
|
button.getAttribute("data-testid"),
|
|
button.getAttribute("name"),
|
|
button.className,
|
|
button.innerText,
|
|
button.textContent
|
|
].join(" ").trim().toLowerCase();
|
|
const explicit = /attach|attachment|upload|file|paperclip|\u043f\u0440\u0438\u043a\u0440\u0435\u043f|\u0437\u0430\u0433\u0440\u0443\u0437|\u0444\u0430\u0439\u043b|\u0441\u043a\u0440\u0435\u043f/.test(text);
|
|
const composerLike = rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.55;
|
|
return { button, rect, explicit, composerLike };
|
|
})
|
|
.filter((candidate) => candidate.explicit || candidate.composerLike)
|
|
.sort((a, b) =>
|
|
Number(b.explicit) - Number(a.explicit) ||
|
|
Number(b.composerLike) - Number(a.composerLike) ||
|
|
a.rect.left - b.rect.left ||
|
|
b.rect.top - a.rect.top);
|
|
const button = candidates[0]?.button || null;
|
|
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(() => null);
|
|
|
|
if (!point) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 800 }).catch(() => null);
|
|
await p.mouse.click(point.x, point.y);
|
|
const fileChooser = await fileChooserPromise;
|
|
if (fileChooser) {
|
|
await fileChooser.setFiles(filePath);
|
|
return true;
|
|
}
|
|
|
|
return await attachFileViaMenuItem(p, filePath);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function attachFileViaMenuItem(p, filePath) {
|
|
const preferMedia = /\.(jpe?g|png|webp|gif|heic|mp4|mov|webm)$/i.test(filePath);
|
|
const deadline = Date.now() + 3500;
|
|
let point = null;
|
|
while (Date.now() < deadline && !point) {
|
|
point = await p.evaluate(({ preferMedia }) => {
|
|
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 cleanText = (value) => String(value || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.toLowerCase();
|
|
const menuItems = Array.from(document.querySelectorAll("[role='menuitem'], button[class*='actionsMenuItem' i]"))
|
|
.filter((element) => !element.disabled && isVisible(element))
|
|
.filter((element) => !element.closest?.("[data-testid='composer'], [class*='composer' i]"))
|
|
.map((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const text = cleanText([
|
|
element.getAttribute("aria-label"),
|
|
element.getAttribute("title"),
|
|
element.getAttribute("data-testid"),
|
|
element.className,
|
|
element.innerText,
|
|
element.textContent
|
|
].join(" "));
|
|
const media = /photo|video|media|\u0444\u043e\u0442\u043e|\u0432\u0438\u0434\u0435\u043e/.test(text);
|
|
const file = /file|\u0444\u0430\u0439\u043b/.test(text);
|
|
return { element, rect, text, media, file };
|
|
})
|
|
.filter((item) => item.media || item.file)
|
|
.sort((a, b) => {
|
|
const aPreferred = preferMedia ? a.media : a.file;
|
|
const bPreferred = preferMedia ? b.media : b.file;
|
|
return Number(bPreferred) - Number(aPreferred) ||
|
|
a.rect.top - b.rect.top ||
|
|
a.rect.left - b.rect.left;
|
|
});
|
|
const item = menuItems[0]?.element || null;
|
|
if (!item) {
|
|
return null;
|
|
}
|
|
|
|
const rect = item.getBoundingClientRect();
|
|
return {
|
|
x: Math.round(rect.left + rect.width / 2),
|
|
y: Math.round(rect.top + rect.height / 2)
|
|
};
|
|
}, { preferMedia }).catch(() => null);
|
|
|
|
if (!point) {
|
|
await p.waitForTimeout(100);
|
|
}
|
|
}
|
|
|
|
if (!point) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 5000 }).catch(() => null);
|
|
await p.mouse.click(point.x, point.y);
|
|
const fileChooser = await fileChooserPromise;
|
|
if (!fileChooser) {
|
|
return false;
|
|
}
|
|
|
|
await fileChooser.setFiles(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForAttachmentPreview(p, timeoutMs = 8000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const ready = await p.evaluate(() => {
|
|
const isVisible = (element) => {
|
|
if (!(element instanceof Element)) 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 bottomArea = (element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.left > window.innerWidth * 0.30 && rect.top > window.innerHeight * 0.45;
|
|
};
|
|
const isPendingUploadSurface = (element) => {
|
|
if (element.closest?.("aside, [class*='messageWrapper' i], [class*='bubble' i], [class*='message--' i], [role='listitem']")) {
|
|
return false;
|
|
}
|
|
|
|
const surface = element.closest?.([
|
|
"[data-testid='composer']",
|
|
"[class*='composer' i]",
|
|
"[role='dialog']",
|
|
"[class*='modal' i]",
|
|
"[class*='preview' i]",
|
|
"[class*='attach' i]",
|
|
"[class*='upload' i]",
|
|
"[class*='file' i]",
|
|
"[class*='media' i]"
|
|
].join(","));
|
|
if (surface) {
|
|
return true;
|
|
}
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.left > window.innerWidth * 0.38 &&
|
|
rect.top > window.innerHeight * 0.55 &&
|
|
rect.bottom > window.innerHeight * 0.70;
|
|
};
|
|
const previewSelectors = [
|
|
"[class*='preview' i]",
|
|
"[class*='attach' i]",
|
|
"[class*='upload' i]",
|
|
"[class*='file' i]",
|
|
"[class*='media' i]",
|
|
"img",
|
|
"video"
|
|
];
|
|
const hasPreview = previewSelectors
|
|
.flatMap((selector) => Array.from(document.querySelectorAll(selector)))
|
|
.some((element) => isVisible(element) && bottomArea(element) && isPendingUploadSurface(element));
|
|
const hasFileName = Array.from(document.querySelectorAll("div, span, p"))
|
|
.some((element) => {
|
|
if (!isVisible(element) || !bottomArea(element) || !isPendingUploadSurface(element)) {
|
|
return false;
|
|
}
|
|
|
|
const text = String(element.innerText || element.textContent || "").trim();
|
|
return /\.(jpe?g|png|webp|gif|heic|mp4|mov|webm|mp3|ogg|m4a|wav|pdf|docx?|xlsx?|zip|rar|7z)\b/i.test(text);
|
|
});
|
|
return hasPreview || hasFileName;
|
|
}).catch(() => false);
|
|
|
|
if (ready) {
|
|
return true;
|
|
}
|
|
|
|
await p.waitForTimeout(250);
|
|
}
|
|
|
|
console.warn("[send/attachment] attachment preview was not detected before sending");
|
|
return false;
|
|
}
|
|
|
|
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 candidates = buttons
|
|
.map((button) => {
|
|
const rect = button.getBoundingClientRect();
|
|
const text = [
|
|
button.getAttribute("aria-label"),
|
|
button.getAttribute("title"),
|
|
button.getAttribute("data-testid"),
|
|
button.className,
|
|
button.innerText,
|
|
button.textContent
|
|
].join(" ").trim().toLowerCase();
|
|
const exact = /send message|\u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435/.test(text);
|
|
const generic = /send|submit|\u043e\u0442\u043f\u0440\u0430\u0432/.test(text);
|
|
return {
|
|
button,
|
|
rect,
|
|
exact,
|
|
generic,
|
|
bottomRight: rect.left > window.innerWidth * 0.32 && rect.top > window.innerHeight * 0.55
|
|
};
|
|
})
|
|
.filter((candidate) => candidate.generic);
|
|
const match = candidates
|
|
.sort((a, b) =>
|
|
Number(b.exact) - Number(a.exact) ||
|
|
Number(b.bottomRight) - Number(a.bottomRight) ||
|
|
b.rect.left - a.rect.left ||
|
|
b.rect.top - a.rect.top)[0]?.button || null;
|
|
|
|
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 isComposerButton = (button) => {
|
|
const rect = button.getBoundingClientRect();
|
|
if (rect.left < window.innerWidth * 0.32 || rect.top < window.innerHeight * 0.55) {
|
|
return false;
|
|
}
|
|
const text = [
|
|
button.getAttribute("aria-label"),
|
|
button.getAttribute("title"),
|
|
button.getAttribute("data-testid"),
|
|
button.className,
|
|
button.innerText,
|
|
button.textContent
|
|
].join(" ").trim().toLowerCase();
|
|
return !/sticker|emoji|attach|upload|\u0441\u0442\u0438\u043a\u0435\u0440|\u044d\u043c\u043e\u0434\u0437\u0438|\u0444\u0430\u0439\u043b|\u0437\u0430\u0433\u0440\u0443\u0437/.test(text);
|
|
};
|
|
|
|
const buttons = Array.from(document.querySelectorAll("button, [role='button']"))
|
|
.filter((button) => !button.disabled && isVisible(button) && isComposerButton(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 scrollOpenChatToBottom(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 root = Array.from(document.querySelectorAll("main *, [role='main'] *, [class*='scroll' i]"))
|
|
.filter((element) => element instanceof HTMLElement && isVisible(element))
|
|
.filter((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.left > window.innerWidth * 0.32 &&
|
|
rect.top < window.innerHeight * 0.82 &&
|
|
element.scrollHeight > element.clientHeight + 40;
|
|
})
|
|
.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0];
|
|
if (root instanceof HTMLElement) {
|
|
root.scrollTop = root.scrollHeight;
|
|
root.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
}
|
|
}).catch(() => {});
|
|
await p.waitForTimeout(120);
|
|
}
|
|
|
|
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 clearChatHistoryInMax(p, externalChatId, chatUrl) {
|
|
return await applyChatMenuActionInMax(
|
|
p,
|
|
externalChatId,
|
|
chatUrl,
|
|
["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0438\u0441\u0442\u043e\u0440", "\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0447\u0430\u0442", "clear history", "clear chat"],
|
|
["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c", "\u0434\u0430", "clear", "yes", "ok"],
|
|
"clear history");
|
|
}
|
|
|
|
async function deleteChatInMax(p, externalChatId, chatUrl) {
|
|
return await applyChatMenuActionInMax(
|
|
p,
|
|
externalChatId,
|
|
chatUrl,
|
|
["\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "delete chat", "delete conversation", "remove chat"],
|
|
["\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "\u0434\u0430", "delete", "yes", "ok"],
|
|
"delete chat");
|
|
}
|
|
|
|
async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) {
|
|
const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels);
|
|
if (!menuOpened) {
|
|
return { success: false, error: `MAX ${actionName} menu was not opened.` };
|
|
}
|
|
|
|
if (!(await clickActionByTextWithRetry(p, actionLabels, 1500))) {
|
|
await p.keyboard.press("Escape").catch(() => {});
|
|
await clearSidebarSearch(p);
|
|
return { success: false, error: `MAX ${actionName} action was not found in the chat menu.` };
|
|
}
|
|
|
|
await p.waitForTimeout(700);
|
|
if (!(await clickActionByTextWithRetry(p, confirmLabels, 3000))) {
|
|
const menuTexts = await readVisibleActionTexts(p);
|
|
await p.keyboard.press("Escape").catch(() => {});
|
|
await clearSidebarSearch(p);
|
|
return {
|
|
success: false,
|
|
error: `MAX ${actionName} confirmation was not found. Visible actions: ${menuTexts.slice(0, 12).join(" | ")}`
|
|
};
|
|
}
|
|
await p.waitForTimeout(1200);
|
|
await clearSidebarSearch(p);
|
|
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 openChatMenu(p, expectedLabels) {
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
if (await clickChatMenuButton(p)) {
|
|
await p.waitForTimeout(600);
|
|
if (await hasMenuActionSurface(p, expectedLabels)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
await p.keyboard.press("Escape").catch(() => {});
|
|
await p.waitForTimeout(250);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function clickChatMenuButton(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 textOf = (element) => String(
|
|
element.innerText ||
|
|
element.textContent ||
|
|
element.getAttribute("aria-label") ||
|
|
element.getAttribute("title") ||
|
|
""
|
|
).trim().toLowerCase();
|
|
const chatRoot = document.querySelector("[class*='openedChat' i]") ||
|
|
document.querySelector("main") ||
|
|
document.body;
|
|
const rootRect = chatRoot.getBoundingClientRect();
|
|
const headerBottom = Math.min(window.innerHeight, rootRect.top + 170);
|
|
const buttons = Array.from(document.querySelectorAll("button,[role='button']"))
|
|
.filter((button) => {
|
|
if (!(button instanceof HTMLElement) || !isVisible(button)) return false;
|
|
const rect = button.getBoundingClientRect();
|
|
const centerX = rect.left + rect.width / 2;
|
|
const centerY = rect.top + rect.height / 2;
|
|
return centerX >= rootRect.left + rootRect.width * 0.45 &&
|
|
centerX <= rootRect.right + 8 &&
|
|
centerY >= Math.max(0, rootRect.top) &&
|
|
centerY <= headerBottom;
|
|
});
|
|
const match = buttons.find((button) => {
|
|
const text = textOf(button);
|
|
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;
|
|
}).catch(() => false);
|
|
}
|
|
|
|
async function hasMenuActionSurface(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();
|
|
return Array.from(document.querySelectorAll("[role='menu'],[role='menuitem'],[class*='menu' i],[class*='popover' i],[class*='dropdown' i],button,span,div"))
|
|
.filter((element) => element instanceof HTMLElement && isVisible(element))
|
|
.some((element) => {
|
|
const text = textOf(element);
|
|
return text && normalizedLabels.some((label) => text === label || text.includes(label));
|
|
});
|
|
}, labels).catch(() => false);
|
|
}
|
|
|
|
async function readVisibleActionTexts(p) {
|
|
return 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 textOf = (element) => String(
|
|
element.innerText ||
|
|
element.textContent ||
|
|
element.getAttribute("aria-label") ||
|
|
element.getAttribute("title") ||
|
|
""
|
|
).replace(/\s+/g, " ").trim();
|
|
const selector = [
|
|
"[role='menu']",
|
|
"[role='menuitem']",
|
|
"[class*='menu' i]",
|
|
"[class*='popover' i]",
|
|
"[class*='dropdown' i]",
|
|
"button",
|
|
"[role='button']"
|
|
].join(",");
|
|
const seen = new Set();
|
|
return Array.from(document.querySelectorAll(selector))
|
|
.filter((element) => element instanceof HTMLElement && isVisible(element))
|
|
.map(textOf)
|
|
.filter(Boolean)
|
|
.filter((text) => {
|
|
if (seen.has(text)) return false;
|
|
seen.add(text);
|
|
return true;
|
|
})
|
|
.slice(0, 80);
|
|
}).catch(() => []);
|
|
}
|
|
|
|
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 scoreMatch = (element) => {
|
|
const text = textOf(element);
|
|
if (!text) return 0;
|
|
if (normalizedLabels.some((label) => text === label)) return 4;
|
|
if (normalizedLabels.some((label) => text.startsWith(label))) return 3;
|
|
if (normalizedLabels.some((label) => text.includes(label))) return 2;
|
|
return 0;
|
|
};
|
|
const interactive = Array.from(document.querySelectorAll("button,[role='button'],[role='menuitem']"))
|
|
.filter((element) => element instanceof HTMLElement && isVisible(element));
|
|
const leafTextNodes = Array.from(document.querySelectorAll("span,div"))
|
|
.filter((element) => element instanceof HTMLElement && isVisible(element))
|
|
.filter((element) => element.querySelector("button,[role='button'],[role='menuitem']") == null)
|
|
.filter((element) => element.children.length <= 1);
|
|
const match = [...interactive, ...leafTextNodes]
|
|
.map((element, index) => ({ element, index, score: scoreMatch(element) }))
|
|
.filter((item) => item.score > 0)
|
|
.sort((a, b) => b.score - a.score || a.index - b.index)[0]?.element || null;
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
|
|
const clickable = match.closest("button,[role='button'],[role='menuitem']") || match;
|
|
clickable.click();
|
|
return true;
|
|
}, labels).catch(() => false);
|
|
}
|
|
|
|
async function clickActionByTextWithRetry(p, labels, timeoutMs = 2000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() <= deadline) {
|
|
if (await clickActionByText(p, labels)) {
|
|
return true;
|
|
}
|
|
await p.waitForTimeout(200);
|
|
}
|
|
|
|
return 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: ""
|
|
};
|
|
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");
|
|
}
|