diff --git a/worker/src/server.js b/worker/src/server.js index fea1f33..4dbae7a 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -9,6 +9,7 @@ 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:"; let context; let page; @@ -524,7 +525,7 @@ function errorStatus(error) { function isAllowedMediaUrl(value) { const raw = String(value || "").trim(); if (!raw) return false; - if (isDomDownloadUrl(raw)) return true; + if (isDomDownloadUrl(raw) || isDomAudioUrl(raw)) return true; if (raw.startsWith("blob:")) { try { const inner = new URL(raw.slice("blob:".length)); @@ -546,6 +547,10 @@ function isDomDownloadUrl(value) { return String(value || "").startsWith(domDownloadPrefix); } +function isDomAudioUrl(value) { + return String(value || "").startsWith(domAudioPrefix); +} + function parseDomDownloadUrl(value) { const raw = String(value || ""); if (!isDomDownloadUrl(raw)) { @@ -559,6 +564,19 @@ function parseDomDownloadUrl(value) { } } +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 isPrivateHost(hostname) { const host = String(hostname || "").toLowerCase(); if (!host || host === "localhost" || host.endsWith(".localhost")) return true; @@ -575,6 +593,10 @@ async function fetchMediaThroughMaxSession(p, mediaUrl) { return await downloadDomAttachmentThroughClick(p, parseDomDownloadUrl(mediaUrl)); } + if (isDomAudioUrl(mediaUrl)) { + return await downloadDomAudioThroughPlay(p, parseDomAudioUrl(mediaUrl)); + } + if (mediaUrl.startsWith("blob:")) { const result = await p.evaluate(async (url) => { const response = await fetch(url); @@ -670,6 +692,163 @@ async function downloadDomAttachmentThroughClick(p, request) { }; } +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}.`); +} + function contentTypeFromFileName(fileName) { const ext = String(fileName || "").toLowerCase().split(".").pop(); switch (ext) { @@ -1303,6 +1482,37 @@ async function extractOpenChatHistory(p, requestedExternalChatId) { 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 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 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; @@ -1475,6 +1685,40 @@ async function extractOpenChatHistory(p, requestedExternalChatId) { }); } + 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 fileButtons = Array.from(bubble.querySelectorAll("button,[role='button']")); for (const node of fileButtons) { if (!(node instanceof HTMLElement)) continue; @@ -1602,8 +1846,7 @@ async function extractOpenChatHistory(p, requestedExternalChatId) { const bubble = wrapper.querySelector("[class*='bubbleContent' i]") || wrapper; const item = wrapper.closest("[class*='item' i]"); const dateLabel = cleanText(item?.querySelector("[class*='capsule' i]")?.innerText, 80); - const metaText = cleanText(wrapper.querySelector("[class*='meta' i]")?.innerText, 80); - const timeText = metaText.match(/\b\d{1,2}:\d{2}\b/)?.[0] || ""; + const 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]"))