Fix MAX voice message sync
This commit is contained in:
+246
-3
@@ -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]"))
|
||||
|
||||
Reference in New Issue
Block a user