Fix MAX emoji attachments

This commit is contained in:
sevenhill
2026-07-04 15:56:39 +03:00
parent 438f7323d1
commit b3192d793d
6 changed files with 319 additions and 18 deletions
+193 -11
View File
@@ -11,6 +11,7 @@ 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 page;
@@ -528,7 +529,7 @@ function errorStatus(error) {
function isAllowedMediaUrl(value) {
const raw = String(value || "").trim();
if (!raw) return false;
if (isDomDownloadUrl(raw) || isDomAudioUrl(raw) || isDomStickerUrl(raw)) return true;
if (isDomDownloadUrl(raw) || isDomAudioUrl(raw) || isDomStickerUrl(raw) || isDomEmojiUrl(raw)) return true;
if (raw.startsWith("blob:")) {
try {
const inner = new URL(raw.slice("blob:".length));
@@ -558,6 +559,10 @@ 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)) {
@@ -597,6 +602,19 @@ function parseDomStickerUrl(value) {
}
}
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 isPrivateHost(hostname) {
const host = String(hostname || "").toLowerCase();
if (!host || host === "localhost" || host.endsWith(".localhost")) return true;
@@ -621,6 +639,17 @@ async function fetchMediaThroughMaxSession(p, mediaUrl) {
return await downloadDomStickerThroughScreenshot(p, parseDomStickerUrl(mediaUrl));
}
if (isDomEmojiUrl(mediaUrl)) {
return await downloadDomStickerThroughScreenshot(p, {
...parseDomEmojiUrl(mediaUrl),
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);
@@ -876,12 +905,16 @@ async function downloadDomAudioThroughPlay(p, request) {
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] canvas, canvas");
const kindLabel = String(request?.kindLabel || "MAX sticker canvas");
const selectorForTestId = (value) => `[data-testid="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
const screenshotCandidate = async (handles, requestedIndex) => {
const candidates = [];
for (const handle of handles) {
const meta = await handle.evaluate((node, requestedTimeText) => {
const element = node instanceof HTMLElement ? node : node.parentElement;
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;
}
@@ -924,27 +957,29 @@ async function downloadDomStickerThroughScreenshot(p, request) {
wrapper.className,
wrapperText
].filter(Boolean).join(" ").toLowerCase();
const looksLikeSticker = /sticker|\u0441\u0442\u0438\u043a\u0435\u0440/.test(semantic) ||
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 (rect.width >= 72 && rect.height >= 72) score += 16;
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 < 40 || rect.height < 40) score -= 30;
if (area < 4096) score -= 20;
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).catch(() => null);
}, { timeText, acceptSmall: Boolean(request?.acceptSmall) }).catch(() => null);
if (!meta || meta.score <= 0) {
await handle.dispose().catch(() => {});
@@ -957,6 +992,12 @@ async function downloadDomStickerThroughScreenshot(p, request) {
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);
const target = sorted[Math.max(0, Math.min(requestedIndex, sorted.length - 1))];
@@ -964,8 +1005,68 @@ async function downloadDomStickerThroughScreenshot(p, request) {
return null;
}
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 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;
};
await target.handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
const buffer = await target.handle.screenshot({ type: "png", timeout: 10000 });
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) {
try {
buffer = await target.handle.screenshot({ type: "png", timeout: 5000 });
} catch (error) {
throw error;
}
}
for (const item of candidates) {
await item.handle.dispose().catch(() => {});
}
@@ -981,10 +1082,10 @@ async function downloadDomStickerThroughScreenshot(p, request) {
}
}
const handles = await p.$$("[data-testid^='sticker-'], button[class*='sticker' i], [class*='sticker' i] canvas, canvas");
const handles = await p.$$(fallbackSelector);
const result = await screenshotCandidate(handles, index);
if (!result) {
throw new Error("MAX sticker canvas was not found.");
throw new Error(`${kindLabel} was not found.`);
}
return result;
@@ -1730,6 +1831,11 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
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"))
@@ -1760,6 +1866,10 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
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;
@@ -1966,6 +2076,78 @@ async function extractOpenChatHistory(p, requestedExternalChatId) {
});
}
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]"))