Fix MAX voice message sync

This commit is contained in:
sevenhill
2026-07-04 09:00:59 +03:00
parent 9fcc659438
commit fcd345dfb0
+246 -3
View File
@@ -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]"))