// ========== config ==========
const PROD_API_BASE = "https://api.sostavika.ru";
const LOCAL_API_BASE = "http://127.0.0.1:8000";
const DEFAULT_API_BASE = (location.hostname === "127.0.0.1" || location.hostname === "localhost")
? LOCAL_API_BASE
: PROD_API_BASE;
const HISTORY_KEY = "beautyscan.search_history_v1";
const TELEMETRY_KEY = "beautyscan.telemetry_v1";
const LAST_ANALYSIS_KEY = "beautyscan.last_analysis_v1";
const SEARCH_DEBOUNCE_MS = 300;
const MAX_SCAN_PHOTOS = 3;
const PRODUCT_PLACEHOLDER_SRC = "assets/product-placeholder.svg?v=1";
const FRONTEND_RELEASE_PATH = "/data/frontend_release.json";
const OCR_TOKEN_INDEX_MANIFEST_PATH = "/data/ocr_token_index/manifest.json";
const OCR_SEARCH_WORKER_PATH = "assets/ocr-search-worker.js?v=v30-empty-bucket-is-no-results";
const VISUAL_HASH_INDEX_PATH = "data/image_visual_hash_index.json";
const VISUAL_IMAGE_SEARCH_ENABLED = false;
function approvedScanApiBase(value, fallback) {
const allowedOrigins = new Set([
new URL(PROD_API_BASE).origin,
location.origin,
]);
try {
const parsed = new URL(String(value || fallback), location.origin);
if (!allowedOrigins.has(parsed.origin)) return fallback;
return parsed.href.replace(/\/$/, "");
} catch {
return fallback;
}
}
// ========== globals ==========
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const app = $("#spaApp");
const tabbar = $("#spaTabbar");
const loadingOverlay = $("#loadingOverlay");
const screens = {};
$$(".spa-screen").forEach(s => { screens[s.dataset.route] = s; });
const IS_LOCAL_HOST = location.hostname === "127.0.0.1" || location.hostname === "localhost";
const DEBUG = false;
const API_BASE = DEFAULT_API_BASE;
const DEFAULT_SCAN_API_BASE = IS_LOCAL_HOST ? PROD_API_BASE : API_BASE;
const SCAN_API_BASE = DEFAULT_SCAN_API_BASE;
let mediaStream = null;
let mediaPermissionDenied = false;
let lastScanResult = null;
let lastSearchAbort = null;
let lastProductRoute = "#scan";
let lastDebugPayload = null;
let healthPayload = null;
let cameraPausedForBatch = false;
let cameraOptedIn = false;
let cameraRequestSeq = 0;
let cameraStarting = false;
const scanPhotos = [];
const appLogs = [];
const networkLog = [];
let frontendReleasePromise = null;
let activeFrontendRelease = null;
let ocrSearchWorker = null;
let ocrSearchRequestSeq = 0;
const ocrSearchPending = new Map();
let visualHashIndexPromise = null;
const podruzhkaShardCache = new Map();
const productContextCache = new Map();
const ingredientContextItemCache = new Map();
const routeScrollPositions = new Map();
let activeHash = location.hash || "#scan";
let previousHash = "#scan";
let previousRoute = "scan";
let renderSequence = 0;
let scanAbortController = null;
let scanAbortReason = null;
let loadingTimer = null;
let loadingStartedAt = 0;
let loadingReturnFocus = null;
let loadingOriginHash = "#scan";
let historyFilter = "all";
let activateSearchRoute = null;
let lastRetryAction = null;
// ========== hash router ==========
function parseHash() {
const h = (location.hash || "#scan").slice(1);
const [route, ...rest] = h.split("/");
return { route: route || "scan", arg: rest.join("/") || null };
}
function navigate(hash) {
if (location.hash === hash) {
render();
} else {
location.hash = hash;
}
}
const TAB_ROUTES = new Set(["scan", "history", "search"]);
function backTargetForRoute(route, routeHash = previousHash) {
if (route === "search") return { href: "#search", label: "← К результатам" };
if (route === "history") return { href: "#history", label: "← К истории" };
if (route === "ambig") return { href: "#ambig", label: "← К вариантам" };
if (route === "nf") return { href: "#nf", label: "← К результату" };
if (route === "i" && routeHash && routeHash.startsWith("#i/")) return { href: routeHash, label: "← К ингредиенту" };
if (route === "p") return { href: lastProductRoute || "#scan", label: "← К товару" };
return { href: "#scan", label: "← К сканеру" };
}
async function render() {
const renderId = ++renderSequence;
const { route, arg } = parseHash();
const routeMeta = {
scan: ["Распознать косметику — Составика", "Распознай косметику по фото или найди товар по названию."],
"cam-deny": ["Камера недоступна — Составика", "Разреши камеру или выбери готовое фото косметики."],
search: ["Поиск косметики — Составика", "Поиск косметики по названию, бренду, артикулу или ссылке магазина."],
history: ["История — Составика", "Недавно открытые и распознанные товары на этом устройстве."],
ambig: ["Выбери товар — Составика", "Похожие товары, найденные по тексту на упаковке."],
nf: ["Товар не найден — Составика", "Повтори фото или продолжи поиск косметики по названию."],
err: ["Ошибка — Составика", "Не удалось завершить действие. Можно повторить попытку."],
};
if (routeMeta[route]) setPageMeta(routeMeta[route][0], routeMeta[route][1]);
Object.values(screens).forEach(s => {
s.classList.remove("on");
s.setAttribute("aria-hidden", "true");
s.removeAttribute("role");
});
const screen = screens[route] || screens.scan;
screen.classList.add("on");
screen.setAttribute("aria-hidden", "false");
screen.setAttribute("role", "main");
// tabbar visible only on root tabs
if (TAB_ROUTES.has(route)) {
tabbar.removeAttribute("hidden");
$$(".spa-tabbar a").forEach(a => {
const active = a.dataset.tab === route;
a.classList.toggle("on", active);
if (active) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
});
} else {
tabbar.setAttribute("hidden", "");
}
document.querySelector(".spa-footer")?.classList.toggle("on", !TAB_ROUTES.has(route));
// route-specific render + camera mgmt
if (route === "scan") {
updateScanPhotoUi();
if (cameraOptedIn && !cameraPausedForBatch) await ensureCamera();
else if (!cameraPausedForBatch) showCameraIdleState();
else stopCamera();
} else {
stopCamera();
}
if (route === "p" && arg) {
const back = backTargetForRoute(previousRoute);
$("#productBack").href = back.href;
$("#productBack").textContent = back.label;
await renderProduct(decodeURIComponent(arg));
} else if (route === "i" && arg) {
const ingredientRoute = parseIngredientRouteArg(arg);
const ingredientBackHref = ingredientRoute.productId
? `#p/${encodeURIComponent(ingredientRoute.productId)}`
: "#search";
$("#ingredientBack").setAttribute("href", ingredientBackHref);
$("#ingredientBack").textContent = ingredientRoute.productId ? "← К составу" : "← К поиску";
await renderIngredient(arg);
} else if (route === "history") {
await renderHistory();
} else if (route === "search") {
if (activateSearchRoute) activateSearchRoute();
else $("#searchInput").focus();
} else if (route === "ambig") {
await renderAmbig();
} else if (route === "nf") {
await renderNotFound();
}
const hashKey = location.hash || "#scan";
const restoreY = routeScrollPositions.get(hashKey) || 0;
requestAnimationFrame(() => {
if (renderId !== renderSequence || parseHash().route !== route) return;
window.scrollTo(0, restoreY);
if (route !== "search" && route !== "scan") {
const heading = screen.querySelector("h1");
if (heading) {
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true });
}
}
});
announce(route === "p" ? "Карточка товара открыта" : route === "i" ? "Описание ингредиента открыто" : route === "search" ? "Поиск открыт" : "Экран открыт");
trackEvent("route_view", { route_name: route });
}
window.addEventListener("hashchange", async () => {
routeScrollPositions.set(activeHash, window.scrollY);
previousHash = activeHash;
previousRoute = (activeHash || "#scan").slice(1).split("/")[0] || "scan";
activeHash = location.hash || "#scan";
await render();
});
// ========== camera ==========
function showCameraIdleState() {
const vf = $("#viewfinder");
const shutter = $("#shutterBtn");
if (vf) vf.classList.add("camera-idle");
if (shutter) {
shutter.disabled = false;
shutter.setAttribute("aria-label", "Открыть камеру");
}
shutter?.closest(".shutter-row")?.classList.remove("camera-off");
const caption = document.querySelector(".shutter-caption");
if (caption) caption.textContent = "Открыть камеру";
const hint = $("#vfHint");
if (hint) hint.textContent = "Открой камеру или выбери фото";
}
async function ensureCamera() {
const currentRoute = parseHash().route;
if (currentRoute !== "scan") return;
if (mediaStream || cameraStarting) return;
if (mediaPermissionDenied) {
const vf = $("#viewfinder");
if (vf) vf.classList.add("camera-off");
$("#shutterBtn").disabled = true;
$("#shutterBtn").closest(".shutter-row")?.classList.add("camera-off");
$("#vfHint").textContent = "Камера недоступна. Выбери фото или ищи по названию.";
return;
}
const requestId = ++cameraRequestSeq;
cameraStarting = true;
const video = $("#vfVideo");
const hint = $("#vfHint");
const shutter = $("#shutterBtn");
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("not_supported");
}
hint.textContent = "запрос камеры...";
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" } },
audio: false
});
if (requestId !== cameraRequestSeq || parseHash().route !== "scan" || cameraPausedForBatch) {
stream.getTracks().forEach(t => t.stop());
return;
}
mediaStream = stream;
video.srcObject = mediaStream;
$("#viewfinder")?.classList.remove("camera-idle", "camera-off");
shutter.disabled = false;
shutter.setAttribute("aria-label", "Сфотографировать");
shutter.closest(".shutter-row")?.classList.remove("camera-off");
const caption = document.querySelector(".shutter-caption");
if (caption) caption.textContent = "Сфотографировать";
hint.textContent = "";
} catch (err) {
if (requestId !== cameraRequestSeq) return;
mediaPermissionDenied = true;
$("#shutterBtn").disabled = true;
const reason = err && err.name === "NotAllowedError" ? "Браузер не разрешил доступ. Измени разрешение камеры в настройках браузера или выбери готовое фото."
: err && err.name === "NotFoundError" ? "Камера на устройстве не найдена."
: "Камера недоступна. Используй галерею или буфер обмена.";
$("#camDenyReason").textContent = reason;
const vf = $("#viewfinder");
if (vf) vf.classList.add("camera-off");
$("#shutterBtn").closest(".shutter-row")?.classList.add("camera-off");
hint.textContent = "Камера недоступна. Выбери фото или ищи по названию.";
if (parseHash().route === "scan") navigate("#cam-deny");
} finally {
if (requestId === cameraRequestSeq) cameraStarting = false;
}
}
function stopCamera() {
cameraRequestSeq += 1;
cameraStarting = false;
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
mediaStream = null;
const video = $("#vfVideo");
if (video) video.srcObject = null;
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopCamera();
else if (cameraOptedIn && parseHash().route === "scan" && !cameraPausedForBatch) ensureCamera();
});
window.addEventListener("pagehide", stopCamera);
window.addEventListener("beforeunload", stopCamera);
function captureFromVideo() {
const video = $("#vfVideo");
const canvas = $("#vfCanvas");
if (!video || !video.videoWidth) return null;
const w = video.videoWidth;
const h = video.videoHeight;
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(video, 0, 0, w, h);
return canvas.toDataURL("image/jpeg", 0.85).split(",")[1];
}
function updateScanPhotoUi() {
const batch = $("#scanBatch");
const thumbs = $("#scanThumbs");
const title = $("#scanBatchTitle");
const recognize = $("#recognizePhotosBtn");
const addSide = $("#addSideBtn");
if (!batch || !thumbs || !title) return;
const count = scanPhotos.length;
batch.hidden = count === 0;
title.textContent = count === 1 ? "1 фото готово" : `${count} фото готовы`;
thumbs.innerHTML = scanPhotos.map((p, idx) => `
${idx + 1}
`).join("");
thumbs.querySelectorAll("[data-rotate-photo]").forEach(btn => {
btn.addEventListener("click", async () => {
const idx = Number(btn.dataset.rotatePhoto);
const photo = scanPhotos[idx];
if (!photo) return;
btn.disabled = true;
try {
const img = await loadImage(photo.dataUrl);
const canvas = document.createElement("canvas");
canvas.width = img.naturalHeight;
canvas.height = img.naturalWidth;
const ctx = canvas.getContext("2d");
ctx.translate(canvas.width, 0);
ctx.rotate(Math.PI / 2);
ctx.drawImage(img, 0, 0);
photo.dataUrl = canvas.toDataURL("image/jpeg", 0.88);
photo.base64 = photo.dataUrl.split(",")[1];
updateScanPhotoUi();
showToast("Фото повернуто");
} catch {
showToast("Не удалось повернуть фото", "bad");
} finally {
if (btn.isConnected) btn.disabled = false;
}
});
});
thumbs.querySelectorAll("[data-remove-photo]").forEach(btn => {
btn.addEventListener("click", () => {
const idx = Number(btn.dataset.removePhoto);
scanPhotos.splice(idx, 1);
if (scanPhotos.length === 0) cameraPausedForBatch = false;
updateScanPhotoUi();
if (scanPhotos.length === 0) {
if (cameraOptedIn) ensureCamera();
else showCameraIdleState();
}
});
});
if (recognize) recognize.textContent = count > 1 ? `Распознать ${count} фото` : "Распознать";
if (addSide) {
addSide.disabled = count >= MAX_SCAN_PHOTOS;
addSide.textContent = count >= MAX_SCAN_PHOTOS ? `Максимум ${MAX_SCAN_PHOTOS} фото` : `Добавить ещё фото (${count + 1}/${MAX_SCAN_PHOTOS})`;
}
const vf = $("#viewfinder");
if (vf) {
vf.classList.toggle("has-batch", count > 0);
vf.classList.toggle("camera-off", count > 0 && !mediaStream);
}
$("#vfHint").textContent = count ? "Кадр сохранён. Камера выключена. Добавь ещё сторону или распознай." : "Наведи на этикетку";
}
function addScanPhoto(base64, source) {
if (!base64) return false;
if (scanPhotos.length >= MAX_SCAN_PHOTOS) {
$("#vfHint").textContent = `Максимум ${MAX_SCAN_PHOTOS} фото`;
return false;
}
const photo = {
base64,
dataUrl: `data:image/jpeg;base64,${base64}`,
source: source || "camera",
ts: Date.now(),
};
scanPhotos.push(photo);
cameraPausedForBatch = true;
stopCamera();
updateScanPhotoUi();
addAppLog("info", `Добавлен кадр ${scanPhotos.length}/${MAX_SCAN_PHOTOS}: ${source || "camera"}`);
assessScanImageQuality(base64).then(quality => {
photo.quality = quality;
if (quality.issue) {
$("#vfHint").textContent = quality.message;
showToast(quality.message, "warn");
}
}).catch(() => {});
return true;
}
function clearScanPhotos({ restartCamera = true } = {}) {
scanPhotos.splice(0, scanPhotos.length);
cameraPausedForBatch = false;
updateScanPhotoUi();
if (restartCamera && cameraOptedIn && parseHash().route === "scan") ensureCamera();
else if (parseHash().route === "scan") showCameraIdleState();
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
async function assessScanImageQuality(base64) {
const img = await loadImage(`data:image/jpeg;base64,${base64}`);
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0, 64, 64);
const pixels = ctx.getImageData(0, 0, 64, 64).data;
const gray = [];
for (let idx = 0; idx < pixels.length; idx += 4) gray.push(pixels[idx] * 0.299 + pixels[idx + 1] * 0.587 + pixels[idx + 2] * 0.114);
const mean = gray.reduce((sum, value) => sum + value, 0) / gray.length;
const deviation = Math.sqrt(gray.reduce((sum, value) => sum + (value - mean) ** 2, 0) / gray.length);
let edge = 0;
for (let y = 1; y < 63; y += 1) {
for (let x = 1; x < 63; x += 1) {
const idx = y * 64 + x;
edge += Math.abs(gray[idx] - gray[idx - 1]) + Math.abs(gray[idx] - gray[idx - 64]);
}
}
edge /= 62 * 62 * 2;
if (Math.min(img.naturalWidth, img.naturalHeight) < 480) {
return { issue: "small", message: "Фото небольшое. Сними этикетку ближе.", width: img.naturalWidth, height: img.naturalHeight, deviation, edge };
}
if (deviation < 16) {
return { issue: "low_contrast", message: "Текст плохо контрастирует с упаковкой. Добавь больше света.", width: img.naturalWidth, height: img.naturalHeight, deviation, edge };
}
if (edge < 7) {
return { issue: "soft", message: "Фото может быть размыто. Зафиксируй камеру и сними ещё раз.", width: img.naturalWidth, height: img.naturalHeight, deviation, edge };
}
return { issue: null, message: "Фото подходит для распознавания.", width: img.naturalWidth, height: img.naturalHeight, deviation, edge };
}
function hexToHashParts(hex) {
const value = String(hex || "").padStart(16, "0").slice(-16);
return [parseInt(value.slice(0, 8), 16) >>> 0, parseInt(value.slice(8), 16) >>> 0];
}
function popcount32(value) {
let x = value >>> 0;
x = x - ((x >>> 1) & 0x55555555);
x = (x & 0x33333333) + ((x >>> 2) & 0x33333333);
return (((x + (x >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24;
}
function hammingHashParts(a, b) {
return popcount32((a[0] ^ b[0]) >>> 0) + popcount32((a[1] ^ b[1]) >>> 0);
}
function ahash64FromPixels(pixels) {
const values = [];
for (let idx = 0; idx < pixels.length; idx += 4) {
values.push(pixels[idx] * 0.299 + pixels[idx + 1] * 0.587 + pixels[idx + 2] * 0.114);
}
const avg = values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
let value = 0n;
values.forEach((pixel, idx) => {
if (pixel > avg) value |= (1n << BigInt(idx));
});
return value.toString(16).padStart(16, "0");
}
async function visualFingerprintFromBase64(base64) {
const img = await loadImage(`data:image/jpeg;base64,${base64}`);
const canvas = document.createElement("canvas");
canvas.width = 9;
canvas.height = 8;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0, 9, 8);
const pixels = ctx.getImageData(0, 0, 9, 8).data;
let value = 0n;
let bit = 0n;
for (let y = 0; y < 8; y += 1) {
for (let x = 0; x < 8; x += 1) {
const left = (y * 9 + x) * 4;
const right = (y * 9 + x + 1) * 4;
const leftGray = pixels[left] * 0.299 + pixels[left + 1] * 0.587 + pixels[left + 2] * 0.114;
const rightGray = pixels[right] * 0.299 + pixels[right + 1] * 0.587 + pixels[right + 2] * 0.114;
if (leftGray > rightGray) value |= (1n << bit);
bit += 1n;
}
}
const dhash64 = value.toString(16).padStart(16, "0");
canvas.width = 8;
canvas.height = 8;
ctx.drawImage(img, 0, 0, 8, 8);
const ahash64 = ahash64FromPixels(ctx.getImageData(0, 0, 8, 8).data);
canvas.width = 1;
canvas.height = 1;
ctx.drawImage(img, 0, 0, 1, 1);
const avgPixel = ctx.getImageData(0, 0, 1, 1).data;
return { dhash64, ahash64, avgRgb: [avgPixel[0], avgPixel[1], avgPixel[2]] };
}
async function dhash64FromBase64(base64) {
return (await visualFingerprintFromBase64(base64)).dhash64;
}
async function loadVisualHashIndex() {
if (!visualHashIndexPromise) {
visualHashIndexPromise = fetch(VISUAL_HASH_INDEX_PATH, { cache: "no-store" })
.then(resp => resp.ok ? resp.json() : null)
.then(payload => {
const rows = (payload && payload.rows) || [];
const schema = payload && payload.schema;
return rows.map(row => {
const v3 = schema === "visual_multihash_v3_compact";
const v2 = schema === "visual_multihash_v2";
const hash = String(row[0] || "");
const ahash = (v2 || v3) ? String(row[1] || "") : "";
const offset = v2 ? 3 : 1;
return {
hash,
parts: hexToHashParts(hash),
ahash,
ahashParts: ahash ? hexToHashParts(ahash) : null,
avgRgb: (v2 || v3) && Array.isArray(row[2]) ? row[2] : null,
product_id: v3 ? row[3] : row[offset],
source: v3 ? null : row[offset + 1],
brand: v3 ? null : row[offset + 2],
display_name: v3 ? null : row[offset + 3],
image_url: v3 ? null : row[offset + 4],
product_shard: v3 ? null : row[offset + 5],
public_search_key: v3 ? null : row[offset + 6],
};
}).filter(row => row.hash && row.product_id);
})
.catch(() => []);
}
return visualHashIndexPromise;
}
function colorDistanceRgb(a, b) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length < 3 || b.length < 3) return 0;
return (Math.abs(Number(a[0]) - Number(b[0])) + Math.abs(Number(a[1]) - Number(b[1])) + Math.abs(Number(a[2]) - Number(b[2]))) / 32;
}
function visualFingerprintDistance(query, row) {
let distance = hammingHashParts(query.parts, row.parts);
if (query.ahashParts && row.ahashParts) distance += hammingHashParts(query.ahashParts, row.ahashParts);
distance += colorDistanceRgb(query.avgRgb, row.avgRgb);
return distance;
}
async function findVisualImageCandidates(imageBase64, limit = 6) {
const rows = await loadVisualHashIndex();
if (!rows || rows.length === 0) return [];
const fingerprint = await visualFingerprintFromBase64(imageBase64);
const query = { ...fingerprint, parts: hexToHashParts(fingerprint.dhash64), ahashParts: hexToHashParts(fingerprint.ahash64) };
const scored = rows.map(row => ({ row, distance: visualFingerprintDistance(query, row) }))
.sort((a, b) => (a.distance - b.distance) || String(a.row.product_id).localeCompare(String(b.row.product_id)));
const candidates = scored.slice(0, Math.max(1, limit)).map(({ row, distance }) => {
return {
product_id: row.product_id,
source: row.source,
brand: row.brand,
name: row.display_name,
display_name: row.display_name,
search_subtitle: "",
image_url: row.image_url,
score: Math.max(0.35, Math.min(0.99, 0.99 - distance / 120)),
visual_distance: Math.round(distance * 100) / 100,
candidate: fingerprint.dhash64,
matched_alias: "visual-hash",
reference_name: "визуальное совпадение",
product_shard: row.product_shard,
public_search_key: row.public_search_key,
};
});
return enrichCandidatePreviews(candidates);
}
function mergeVisualCandidates(detail, visualCandidates) {
const byId = new Map();
const add = (item, baseRank) => {
if (!item || !item.product_id) return;
const prev = byId.get(item.product_id);
const rank = Number.isFinite(baseRank) ? baseRank : 0;
if (prev && prev._rank >= rank) return;
byId.set(item.product_id, { ...item, _rank: rank });
};
(visualCandidates || []).forEach((item, idx) => {
const dist = Number(item && item.visual_distance);
add(item, 1000 - (Number.isFinite(dist) ? dist * 12 : 200) - idx);
});
((detail && detail.candidate_products) || []).forEach((item, idx) => {
const score = Number(item && item.score);
add(item, (Number.isFinite(score) ? score * 100 : 50) - idx);
});
const merged = Array.from(byId.values()).sort((a, b) => (b._rank || 0) - (a._rank || 0)).map(item => {
const copy = { ...item };
delete copy._rank;
return copy;
});
return { ...(detail || {}), candidate_products: merged.slice(0, 8), visual_candidate_products: visualCandidates || [] };
}
function bestVisualAutoCandidate(visualCandidates) {
const rows = (visualCandidates || []).filter(item => item && item.product_id && Number.isFinite(Number(item.visual_distance)));
if (!rows.length) return null;
rows.sort((a, b) => Number(a.visual_distance) - Number(b.visual_distance));
const top = rows[0];
const second = rows[1];
const topDistance = Number(top.visual_distance);
const secondDistance = second ? Number(second.visual_distance) : 99;
if (topDistance <= 4 && secondDistance - topDistance >= 4) return top;
if (topDistance === 0 && (!second || secondDistance > 0 || second.product_id === top.product_id)) return top;
return null;
}
async function composeScanPhotosBase64() {
if (scanPhotos.length === 0) return null;
if (scanPhotos.length === 1) return scanPhotos[0].base64;
const images = await Promise.all(scanPhotos.map(p => loadImage(p.dataUrl)));
const maxNaturalWidth = Math.max(...images.map(img => img.naturalWidth || img.width || 1));
let targetWidth = Math.min(1280, maxNaturalWidth);
const gap = 28;
let scaled = images.map(img => {
const w = img.naturalWidth || img.width || 1;
const h = img.naturalHeight || img.height || 1;
const ratio = targetWidth / w;
return { img, w: targetWidth, h: Math.round(h * ratio) };
});
const maxTotalHeight = 3600;
let totalHeight = scaled.reduce((sum, item) => sum + item.h, 0) + gap * (scaled.length - 1);
if (totalHeight > maxTotalHeight) {
const shrink = maxTotalHeight / totalHeight;
targetWidth = Math.max(720, Math.round(targetWidth * shrink));
scaled = images.map(img => {
const w = img.naturalWidth || img.width || 1;
const h = img.naturalHeight || img.height || 1;
const ratio = targetWidth / w;
return { img, w: targetWidth, h: Math.round(h * ratio) };
});
totalHeight = scaled.reduce((sum, item) => sum + item.h, 0) + gap * (scaled.length - 1);
}
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = totalHeight;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
let y = 0;
scaled.forEach((item, idx) => {
if (idx > 0) {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, y, canvas.width, gap);
ctx.strokeStyle = "#d6d6d4";
ctx.setLineDash([10, 10]);
ctx.beginPath();
ctx.moveTo(24, y + Math.round(gap / 2));
ctx.lineTo(canvas.width - 24, y + Math.round(gap / 2));
ctx.stroke();
ctx.setLineDash([]);
y += gap;
}
ctx.drawImage(item.img, 0, y, item.w, item.h);
y += item.h;
});
return canvas.toDataURL("image/jpeg", 0.86).split(",")[1];
}
function canvasBase64FromImage(img, { cropBox = null, maxSide = 1600, minLongSide = 0, contrast = false } = {}) {
const naturalWidth = img.naturalWidth || img.width || 1;
const naturalHeight = img.naturalHeight || img.height || 1;
const box = cropBox || { x: 0, y: 0, w: naturalWidth, h: naturalHeight };
const longSide = Math.max(box.w, box.h) || 1;
let scale = Math.min(1, maxSide / longSide);
if (minLongSide && longSide < minLongSide) scale = Math.max(scale, minLongSide / longSide);
if (longSide * scale > maxSide) scale = maxSide / longSide;
const width = Math.max(1, Math.round(box.w * scale));
const height = Math.max(1, Math.round(box.h * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
ctx.filter = contrast ? "contrast(1.55) saturate(1.05)" : "none";
ctx.drawImage(img, box.x, box.y, box.w, box.h, 0, 0, width, height);
ctx.filter = "none";
return canvas.toDataURL("image/jpeg", 0.9).split(",")[1];
}
function contentCropBoxForImage(img) {
const naturalWidth = img.naturalWidth || img.width || 1;
const naturalHeight = img.naturalHeight || img.height || 1;
const probeLongSide = 420;
const scale = Math.min(1, probeLongSide / Math.max(naturalWidth, naturalHeight));
const width = Math.max(1, Math.round(naturalWidth * scale));
const height = Math.max(1, Math.round(naturalHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
const data = ctx.getImageData(0, 0, width, height).data;
let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const idx = (y * width + x) * 4;
const r = data[idx], g = data[idx + 1], b = data[idx + 2], a = data[idx + 3];
if (a > 20 && (r + g + b) / 3 < 246) {
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
}
}
}
if (maxX < minX || maxY < minY) return null;
const boxW = maxX - minX + 1;
const boxH = maxY - minY + 1;
if (boxW < width * 0.18 || boxH < height * 0.18) return null;
if (boxW > width * 0.96 && boxH > height * 0.96) return null;
const pad = Math.max(6, Math.round(Math.max(width, height) * 0.04));
const sx = 1 / scale;
const x = Math.max(0, Math.round((minX - pad) * sx));
const y = Math.max(0, Math.round((minY - pad) * sx));
const right = Math.min(naturalWidth, Math.round((maxX + pad) * sx));
const bottom = Math.min(naturalHeight, Math.round((maxY + pad) * sx));
return { x, y, w: Math.max(1, right - x), h: Math.max(1, bottom - y) };
}
async function buildScanOcrVariants(imageBase64, maxVariants = 3) {
const img = await loadImage(`data:image/jpeg;base64,${imageBase64}`);
const naturalWidth = img.naturalWidth || img.width || 1;
const naturalHeight = img.naturalHeight || img.height || 1;
const variants = [
{ name: "fit", options: { maxSide: 1600 } },
{ name: "fit_contrast", options: { maxSide: 1800, minLongSide: 1200, contrast: true } },
];
const contentBox = contentCropBoxForImage(img);
if (contentBox) {
variants.push({ name: "content_crop", options: { cropBox: contentBox, maxSide: 1800, minLongSide: 1000 } });
variants.push({ name: "content_crop_contrast", options: { cropBox: contentBox, maxSide: 1800, minLongSide: 1200, contrast: true } });
}
const centerFraction = 0.82;
variants.push({
name: "center_crop",
options: {
cropBox: {
x: Math.round(naturalWidth * (1 - centerFraction) / 2),
y: Math.round(naturalHeight * (1 - centerFraction) / 2),
w: Math.round(naturalWidth * centerFraction),
h: Math.round(naturalHeight * centerFraction),
},
maxSide: 1800,
minLongSide: 1000,
},
});
const out = [];
const seen = new Set();
for (const variant of variants) {
const base64 = canvasBase64FromImage(img, variant.options);
const key = `${base64.length}:${base64.slice(0, 96)}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ name: variant.name, image_base64: base64 });
if (out.length >= maxVariants) break;
}
return out.length ? out : [{ name: "original", image_base64: imageBase64 }];
}
// ========== file / paste fallback ==========
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const data = String(reader.result || "");
const idx = data.indexOf(",");
resolve(idx >= 0 ? data.slice(idx + 1) : data);
};
reader.onerror = () => reject(new Error("file_read_failed"));
reader.readAsDataURL(file);
});
}
async function pasteFromClipboard() {
try {
if (!navigator.clipboard || !navigator.clipboard.read) {
throw new Error("clipboard_not_supported");
}
const items = await navigator.clipboard.read();
for (const item of items) {
for (const type of item.types) {
if (type.startsWith("image/")) {
const blob = await item.getType(type);
const file = new File([blob], "pasted.png", { type });
return file;
}
}
}
throw new Error("no_image_in_clipboard");
} catch (err) {
throw err;
}
}
// ========== API + debug telemetry ==========
function safeJson(text) {
try { return JSON.parse(text); } catch { return { raw: text }; }
}
function summarizeBody(body) {
if (!body || typeof body !== "object") return body || null;
const copy = { ...body };
if (copy.image_base64) copy.image_base64 = `[скрыто в логе: base64, ${String(copy.image_base64).length} символов]`;
return copy;
}
function addAppLog(level, msg) {
if (!DEBUG) return;
const ts = new Date().toLocaleTimeString("ru-RU", { hour12: false });
appLogs.unshift(`[${ts}] [${level}] ${msg}`);
appLogs.splice(80);
updateAdminDrawer();
}
function addNetworkLog(entry) {
if (!DEBUG) return;
networkLog.unshift(entry);
networkLog.splice(40);
updateAdminDrawer();
}
function normLookupText(value) {
return String(value || "")
.replace(/Й/g, "__CYR_SHORT_I_UP__").replace(/й/g, "__CYR_SHORT_I_LOW__")
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/__CYR_SHORT_I_UP__/g, "Й").replace(/__CYR_SHORT_I_LOW__/g, "й")
.toLowerCase().replace(/[\/\\_&`"']/g, " ").replace(/\s+/g, " ").trim();
}
const OCR_LOOKALIKE_LATIN = {
А: "A", В: "B", Е: "E", К: "K", М: "M", Н: "H", О: "O", Р: "P", С: "C", Т: "T", У: "Y", Х: "X",
а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t", у: "y", х: "x",
};
function ocrLookalikeVariants(value) {
const variants = [];
const raw = String(value || "");
const tokens = raw.match(/[A-Za-zА-Яа-яЁё0-9-]{3,}/g) || [];
for (const token of tokens) {
const letters = Array.from(token).filter(ch => /[A-Za-zА-Яа-яЁё]/.test(ch));
if (letters.length < 3) continue;
const upperish = letters.filter(ch => ch.toUpperCase() === ch && ch.toLowerCase() !== ch.toUpperCase()).length / letters.length >= 0.6;
if (!upperish) continue;
let changed = false;
const mapped = Array.from(token).map(ch => {
if (OCR_LOOKALIKE_LATIN[ch]) {
changed = true;
return OCR_LOOKALIKE_LATIN[ch];
}
return ch;
}).join("");
if (!changed || !/^[A-Za-z0-9-]{3,}$/.test(mapped)) continue;
variants.push(mapped.toLowerCase());
}
return Array.from(new Set(variants));
}
function dottedAcronymTerms(value) {
const text = String(value || "");
const out = [];
const add = token => {
const compact = String(token || "").replace(/[^0-9A-Za-zА-Яа-яЁё]+/g, "").toLowerCase();
if (compact.length >= 3 && compact.length <= 12 && !out.includes(compact)) out.push(compact);
};
for (const match of text.matchAll(/\b(?:[A-Za-zА-Яа-яЁё]\.){2,}[A-Za-zА-Яа-яЁё]?\.?/g)) add(match[0]);
for (const match of text.matchAll(/\b(?:[A-ZА-ЯЁ]\s+){2,}[A-ZА-ЯЁ]\b/g)) add(match[0]);
return out;
}
function shortIVariants(value) {
const base = normLookupText(value);
return base.includes("й") ? [base.replace(/й/g, "и")] : [];
}
function phraseSignalTerms(value, limit = 24) {
const base = normLookupText(value)
.replace(/([a-z])([а-яё])/g, "$1 $2")
.replace(/([а-яё])([a-z])/g, "$1 $2")
.replace(/[.,;:()[\]{}+!?|\-]+/g, " ");
const tokens = base.split(/\s+/)
.map(t => t.trim())
.filter(t => (t.length >= 2 || t === "k" || t === "q") && !SEARCH_QUERY_STOPWORDS.has(t) && !/^\d{4,}$/.test(t));
const out = [];
for (const size of [2, 3]) {
for (let idx = 0; idx <= tokens.length - size; idx += 1) {
const chunk = tokens.slice(idx, idx + size);
if (!chunk.some(token => /[a-zа-яё]{4,}/i.test(token))) continue;
const compact = `${chunk.map(token => token.toLowerCase().replace(/[^0-9a-zа-яё]+/g, "")).join("")}ph`;
if (compact.length >= 8 && compact.length <= 40 && !out.includes(compact)) out.push(compact);
if (out.length >= limit) return out;
}
}
return out;
}
const SEARCH_QUERY_STOPWORDS = new Set([
"a", "an", "and", "or", "of", "to", "for", "with", "without", "the", "in", "on", "by",
"для", "и", "или", "с", "со", "без", "на", "в", "во", "от", "по", "из", "к", "у",
"ml", "мл", "g", "gr", "г", "шт", "pcs", "ppm",
]);
const SEARCH_QUERY_ALIAS_RULES = [
["face serum", "сыворотка для лица serum syvorotka"],
["facial serum", "сыворотка для лица serum syvorotka"],
["serum", "сыворотка syvorotka"],
["syvorotka", "сыворотка serum"],
["сыворот", "serum syvorotka"],
["face cream", "крем для лица cream"],
["facial cream", "крем для лица cream"],
["cream", "крем"],
["крем", "cream"],
["cleanser", "очищающее средство умывания пенка cleansing wash foam"],
["cleansing", "очищение очищающая умывания cleanser wash foam"],
["wash", "умывания очищение пенка cleanser cleansing"],
["foam", "пенка умывания очищающая cleanser cleansing"],
["penka", "пенка foam cleanser"],
["umyvaniya", "умывания wash cleanser"],
["умыван", "wash cleanser cleansing foam"],
["пенк", "foam cleanser cleansing wash"],
["очища", "cleansing cleanser wash"],
["toner", "тонер"],
["тоник", "toner"],
["lotion", "лосьон"],
["gel", "гель"],
["гель", "gel"],
["mask", "маска"],
["маска", "mask"],
["balm", "бальзам"],
["бальзам", "balm"],
["sunscreen", "солнцезащитный spf санскрин"],
["sun screen", "солнцезащитный spf санскрин"],
["spf", "солнцезащитный sunscreen"],
["micellar water", "мицеллярная вода micellar"],
["micellar", "мицеллярная"],
["upikar", "lipikar"],
["v tiver", "vetiver"],
["vetiver", "v tiver"],
["sensibio", "сенсибио bioderma"],
["essential oil", "эфирное масло масло"],
["limette", "lime лайм"],
["lime", "limette лайм"],
["wax", "воск депиляции"],
["cera", "воск"],
["depilatory", "депиляции воск"],
["hair removal", "депиляции воск"],
["vinotherapy", "вино винный"],
["vinoterapia", "вино винный"],
["shampoo", "шампунь"],
["conditioner", "кондиционер"],
["mist", "мист спрей"],
["spray", "спрей мист"],
["milk", "молочко"],
["oil", "масло"],
["powder", "пудра"],
["mascara", "тушь ресниц"],
["foundation", "тональный тональная основа"],
["concealer", "консилер"],
["highlighter", "хайлайтер"],
["blush", "румяна"],
["lip", "губ губы"],
["eye", "глаз век ресниц"],
["face", "лицо лица"],
["body", "тело тела"],
["hair", "волос волосы"],
["madecassoside", "мадекассосид мадекассосидом cica centella"],
["madecassos", "мадекассосид мадекассосидом madecassoside"],
["мадекассосид", "madecassoside madecassos cica centella"],
["cica", "цика центелла мадекассосид madecassoside"],
["centella", "центелла cica мадекассосид"],
["центел", "centella cica madecassoside"],
["blemishrepair", "blemish repair восстанавливающая восстановление против несовершенств"],
["blemish repair", "восстанавливающая восстановление против несовершенств repair"],
["blemish", "несовершенств проблемной кожи"],
["repair", "восстанавливающая восстановление"],
["restoring", "восстанавливающая восстановление"],
["recovery", "восстанавливающая восстановление"],
["восстан", "repair restoring recovery"],
["niacinamide", "ниацинамид"],
["ниацинамид", "niacinamide"],
["retinol", "ретинол"],
["ретинол", "retinol"],
["retinal", "ретиналь"],
["ретиналь", "retinal"],
["hyaluronic", "гиалуроновая гиалуроновой hyaluronate"],
["hyaluronate", "гиалуронат гиалуроновая гиалуроновой"],
["гиалур", "hyaluronic hyaluronate"],
["keratin", "кератин"],
["кератин", "keratin"],
["collagen", "коллаген"],
["коллаген", "collagen"],
["peptide", "пептид пептиды"],
["пептид", "peptide"],
["ceramide", "керамид керамиды"],
["керамид", "ceramide"],
["vitamin c", "витамин c витамин с"],
["vitamin", "витамин"],
["panthenol", "пантенол"],
["пантенол", "panthenol"],
["glycerin", "глицерин"],
["глицерин", "glycerin"],
["salicylic", "салициловая салициловой bha"],
["glycolic", "гликолевая гликолевой aha"],
["lactic", "молочная молочной aha"],
["azelaic", "азелаиновая азелаиновой"],
["tea tree", "чайное дерево чайного дерева"],
["teatree", "чайное дерево чайного дерева tea tree"],
["чайного дерева", "tea tree teatree"],
["green tea", "зеленый чай зеленого чая"],
["зеленого чая", "green tea"],
["coconut", "кокос coconut"],
["кокос", "coconut"],
["clay", "глина глиной"],
["глин", "clay"],
["white clay", "белая глина белой глиной"],
["calamine", "каламин каламином"],
["каламин", "calamine"],
["mi x i t", "mixit"],
["m i x i t", "mixit"],
["migit", "mixit"],
["scret", "secret"],
["glon", "glow"],
["gow", "glow"],
["higlighter", "highlighter хайлайтер"],
["highlighter", "хайлайтер"],
["хайлайтер", "highlighter"],
["tonik", "toner тоник"],
["esencja", "essence эссенция"],
["peptydy", "peptide пептид пептиды"],
["miedziowe", "copper медные"],
["b e l", "lebel"],
["cypress", "кипарис"],
["skin ph oria", "skinphoria"],
["skinphoria", "skin phoria"],
["opchidee", "orchidee orchidée orchidee imperiale"],
["orchidee", "orchidée"],
["imperials", "imperiale imperial"],
["it s skin", "its it's skin"],
["it's skin", "its it skin"],
["its skin", "it's it skin"],
["it s sk", "it's skin its skin it skin power 10 formula"],
["it's sk", "it's skin its skin it skin power 10 formula"],
["ashion", "fashion"],
["jewelery", "jewelry jewellery аксессуары украшения резинки волос"],
["jewelry", "jewellery аксессуары украшения резинки волос"],
["jewellery", "jewelry аксессуары украшения резинки волос"],
["поматневару", "aromatherapy aromatherapy relax"],
["vinotherapy 5ar", "vinotherapy 5ab вино"],
["vinoterapia sab", "vinoterapia 5ab вино"],
["5ar", "5ab"],
["l oreal", "loreal oreal"],
["loreal", "l oreal"],
];
const SEARCH_WORD_ONLY_ALIAS_NEEDLES = new Set(["lip"]);
const SEARCH_PACKAGE_UNIT_ALIASES = new Map([
["fl oz", "floz"], ["fl. oz", "floz"], ["floz", "floz"],
["ml", "ml"], ["мл", "ml"],
["g", "g"], ["gr", "g"], ["г", "g"], ["гр", "g"],
["kg", "kg"], ["кг", "kg"],
["l", "l"], ["л", "l"],
["oz", "oz"], ["унц", "oz"],
["pcs", "pcs"], ["шт", "pcs"]
]);
function packageSignalTerms(value) {
const text = normLookupText(String(value || "").replace(/,/g, "."));
const out = [];
const add = token => {
if (token && !out.includes(token)) out.push(token);
};
for (const match of text.matchAll(/(? {
if (token && !out.includes(token)) out.push(token);
};
if (/\bap\s*max\b|\bapmax\b/i.test(text)) add("apmax");
if (/\bap\s*m\b|\bapm\b/i.test(text)) add("apm");
return out;
}
function appendUniqueWords(parts, value) {
const seen = new Set(normLookupText(parts.join(" ")).split(" ").filter(Boolean));
normLookupText(value).split(" ").forEach(word => {
if (!word || seen.has(word)) return;
seen.add(word);
parts.push(word);
});
}
function searchAliasMatches(haystack, needle) {
if (SEARCH_WORD_ONLY_ALIAS_NEEDLES.has(needle)) {
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(^|\\s)${escaped}($|\\s)`).test(haystack);
}
return haystack.includes(needle);
}
function expandSearchText(value) {
const variants = ocrLookalikeVariants(value);
const shortI = shortIVariants(value);
const packageTerms = packageSignalTerms(value);
const variantTerms = variantSignalTerms(value);
const acronymTerms = dottedAcronymTerms(value);
const phraseTerms = phraseSignalTerms(value);
const base = normLookupText(value)
.replace(/([a-z])([а-яё])/g, "$1 $2")
.replace(/([а-яё])([a-z])/g, "$1 $2")
.replace(/[.,;:()[\]{}+!?|\-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!base) return "";
const parts = [...packageTerms, ...variantTerms, base, ...acronymTerms, ...phraseTerms];
[...shortI, ...variants].forEach(variant => appendUniqueWords(parts, variant));
const aliasHaystack = normLookupText([base, ...shortI, ...variants].join(" "));
for (const [needle, aliases] of SEARCH_QUERY_ALIAS_RULES) {
if (searchAliasMatches(aliasHaystack, needle)) appendUniqueWords(parts, aliases);
}
return normLookupText(parts.join(" "));
}
function searchTerms(value) {
const terms = expandSearchText(value).split(" ")
.map(t => t.trim())
.filter(t => t.length >= 2 || /^spf$/i.test(t))
.filter(t => !SEARCH_QUERY_STOPWORDS.has(t));
return Array.from(new Set(terms)).slice(0, 48);
}
const WEAK_OCR_GENERIC_SIGNAL_TERMS = new Set([
"pdrn","retinol","ретинол","retinal","ретиналь","niacinamide","ниацинамид",
"madecassoside","madecassos","мадекассосид","мадекассосидом",
"ceramide","ceramides","керамид","керамиды",
"hyaluronic","hyaluronate","гиалуроновая","гиалуроновой","гиалуронат",
"keratin","кератин","collagen","коллаген","vitamin","витамин",
"panthenol","пантенол","salicylic","салициловая","салициловой",
"active","ingredient","ингредиент","актив","protein","протеин",
"accessory","accessories","ashion","fashion","jewelery","jewelry","jewellery",
"аксессуар","аксессуары","украшения","lip","lips","губ","губы",
"ocr","query","normalized",
]);
const OCR_LINE_ONLY_TERMS = new Set([
"lipikar","upikar","effaclar","efaclar","anthelios","cicaplast","toleriane",
"sensibio","sebium","sebiu","hydrabio","uvmune","липикар","эфаклар",
"антелиос","цикапласт","сенсибио","себиум",
]);
const OCR_LINE_BRAND_CONTEXT_TERMS = new Set([
"la","roche","posay","laroche","bioderma","vichy","avene","eau","thermale",
]);
function rawOcrLooksLikeLineOnlySignal(rawTerms) {
const terms = Array.from(new Set(rawTerms || []));
if (!terms.some(term => OCR_LINE_ONLY_TERMS.has(term))) return false;
if (terms.some(term => /^\d{2,}$/.test(term))) return false;
const hasDisambiguator = terms.some(term => /^(apm|apmax|baume|balm|бальзам|липидовосполняющ|syndet|синдет|serum|сыворотк|syvorotka|cream|крем|gel|гель|cleanser|cleansing|foam|пенк|умыван|mist|spray|спрей|мист|spf|h2o|ultra|niacinamide|ниацинамид|retinol|ретинол|pdrn)$/i.test(term));
if (hasDisambiguator) return false;
const leftovers = terms.filter(term =>
!OCR_LINE_ONLY_TERMS.has(term) &&
!OCR_LINE_BRAND_CONTEXT_TERMS.has(term) &&
!WEAK_OCR_GENERIC_SIGNAL_TERMS.has(term)
);
return leftovers.length <= 1;
}
const SOURCE_LOOKUP_ALIASES = [
["podruzhka", ["podruzhka", "podrygka", "подружка", "подружке", "www.podrygka.ru", "podrygka.ru"]],
["goldapple", ["goldapple", "gold apple", "золотое яблоко", "goldapple.ru", "www.goldapple.ru"]],
["mixit", ["mixit", "миксит", "mixit.ru", "www.mixit.ru"]],
["rivegauche", ["rivegauche", "rive gauche", "рив гош", "rivegauche.ru", "www.rivegauche.ru"]],
["letu", ["letu", "letual", "лэтуаль", "летуаль", "лету", "letu.ru", "www.letu.ru"]],
["iledebeaute", ["iledebeaute", "ile de beaute", "иль де боте", "iledebeaute.ru", "www.iledebeaute.ru"]],
["beautydepot", ["beautydepot", "beauty depot", "бьюти депо", "beautydepot.ru", "www.beautydepot.ru"]],
["hollyshop", ["hollyshop", "holly shop", "холли шоп", "hollyshop.ru", "www.hollyshop.ru"]],
["krasotkapro", ["krasotkapro", "krasotka pro", "красотка про", "krasotkapro.ru", "www.krasotkapro.ru"]],
["parfum_lider", ["parfum_lider", "parfum lider", "parfum-lider", "парфюм лидер", "parfum-lider.ru", "www.parfum-lider.ru"]],
["proficosmetics", ["proficosmetics", "profi cosmetics", "профкосметикс", "proficosmetics.ru", "www.proficosmetics.ru"]],
];
const SOURCE_BY_ALIAS = SOURCE_LOOKUP_ALIASES.reduce((acc, [source, aliases]) => {
aliases.forEach(alias => { acc[normLookupText(alias)] = source; });
return acc;
}, {});
const SOURCE_BY_HOST = {
"podrygka.ru": "podruzhka",
"www.podrygka.ru": "podruzhka",
"goldapple.ru": "goldapple",
"www.goldapple.ru": "goldapple",
"mixit.ru": "mixit",
"www.mixit.ru": "mixit",
"rivegauche.ru": "rivegauche",
"www.rivegauche.ru": "rivegauche",
"letu.ru": "letu",
"www.letu.ru": "letu",
"iledebeaute.ru": "iledebeaute",
"www.iledebeaute.ru": "iledebeaute",
"beautydepot.ru": "beautydepot",
"www.beautydepot.ru": "beautydepot",
"hollyshop.ru": "hollyshop",
"www.hollyshop.ru": "hollyshop",
"krasotkapro.ru": "krasotkapro",
"www.krasotkapro.ru": "krasotkapro",
"parfum-lider.ru": "parfum_lider",
"www.parfum-lider.ru": "parfum_lider",
"proficosmetics.ru": "proficosmetics",
"www.proficosmetics.ru": "proficosmetics",
};
const SOURCE_DISPLAY_NAMES = {
podruzhka: "Подружка",
goldapple: "Золотое Яблоко",
mixit: "MIXIT",
rivegauche: "Рив Гош",
letu: "Лэтуаль",
iledebeaute: "Иль де Ботэ",
beautydepot: "BeautyDepot",
hollyshop: "Hollyshop",
krasotkapro: "КрасоткаПро",
parfum_lider: "Парфюм Лидер",
proficosmetics: "ProfiCosmetics",
};
function normalizeSourceProductId(value) {
return String(value || "")
.toLowerCase()
.replace(/[?#].*$/, "")
.replace(/^\/+|\/+$/g, "")
.replace(/^[^a-zа-яё0-9]+|[^a-zа-яё0-9]+$/gi, "")
.trim();
}
function sourceProductIdFromProductId(productId) {
const parts = String(productId || "").toLowerCase().split(":");
return normalizeSourceProductId(parts.length > 1 ? parts.slice(1).join(":") : productId);
}
function normalizeExactUrl(value) {
try {
const url = new URL(String(value || ""));
url.hash = "";
url.search = "";
url.hostname = url.hostname.toLowerCase().replace(/^m\./, "www.");
url.pathname = url.pathname.replace(/\/+$/g, "");
return url.toString().replace(/\/$/g, "");
} catch {
return "";
}
}
function addProductIdCandidate(intent, source, sourceProductId) {
const src = SOURCE_BY_ALIAS[normLookupText(source)] || source;
const sid = normalizeSourceProductId(sourceProductId);
if (!src || !sid || sid.length < 2) return;
intent.sources.add(src);
intent.sourceProductIds.add(sid);
intent.exactProductIds.add(`${src}:${sid}`);
}
function collectUrlLookupSignals(raw, intent) {
const urlMatches = String(raw || "").match(/https?:\/\/[^\s<>"']+/gi) || [];
for (const matchedUrl of urlMatches) {
const rawUrl = matchedUrl.replace(/[),.;]+$/g, "");
try {
const url = new URL(rawUrl);
const host = url.hostname.toLowerCase().replace(/^m\./, "www.");
const source = SOURCE_BY_HOST[host] || SOURCE_BY_HOST[host.replace(/^www\./, "")];
if (!source) continue;
intent.sources.add(source);
intent.sourceUrls.push({ source, source_url: rawUrl, source_url_norm: normalizeExactUrl(rawUrl) });
const segments = url.pathname.split("/").map(normalizeSourceProductId).filter(Boolean);
for (const segment of segments) {
const leadingNumber = segment.match(/^(\d{4,})(?:[-_].*)?$/);
if (leadingNumber) addProductIdCandidate(intent, source, leadingNumber[1]);
if (/[-_]/.test(segment) && /^[a-z0-9][a-z0-9_-]{2,}$/i.test(segment) && !/^(catalog|product|products|shop|ru|en)$/i.test(segment)) {
addProductIdCandidate(intent, source, segment);
}
}
} catch {
// OCR sometimes returns broken URL fragments; normal text matching still runs.
}
}
}
function parseSearchIntent(raw) {
const intent = {
sources: new Set(),
sourceProductIds: new Set(),
exactProductIds: new Set(),
sourceUrls: [],
hasSourceAndId: false,
};
const text = normLookupText(raw);
if (!text) return intent;
collectUrlLookupSignals(raw, intent);
for (const [alias, source] of Object.entries(SOURCE_BY_ALIAS)) {
if (!alias) continue;
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(^|\\s)${escaped}(?=\\s|$)`, "i");
if (re.test(text)) intent.sources.add(source);
}
String(raw || "").toLowerCase().replace(/([a-z_-]+)[:#\/]+([a-z0-9][a-z0-9_-]{1,})/gi, (match, maybeSource, maybeId) => {
const source = SOURCE_BY_ALIAS[normLookupText(maybeSource)];
if (source) addProductIdCandidate(intent, source, maybeId);
return match;
});
const tokens = text.split(" ").filter(Boolean);
const strongIds = tokens
.map(normalizeSourceProductId)
.filter(t => /^\d{4,}$/.test(t) || (/[-_]/.test(t) && /^[a-z0-9][a-z0-9_-]{4,}$/i.test(t)));
if (intent.sources.size > 0) {
for (const source of intent.sources) {
for (const sid of strongIds) addProductIdCandidate(intent, source, sid);
}
}
intent.hasSourceAndId = intent.sources.size > 0 && intent.sourceProductIds.size > 0;
return intent;
}
function isExactProductIntentMatch(item, intent, needle = "") {
if (!item) return false;
const id = String(item.product_id || "").toLowerCase();
const sid = item._sourceProductId || sourceProductIdFromProductId(id);
if (needle && (id === needle || id.endsWith(`:${needle}`))) return true;
if (intent && intent.exactProductIds && intent.exactProductIds.has(id)) return true;
if (intent && intent.sourceUrls && item._productUrlNorm) {
for (const row of intent.sourceUrls) {
if (row.source === item.source && row.source_url_norm && row.source_url_norm === item._productUrlNorm) return true;
}
}
if (intent && intent.hasSourceAndId && intent.sources.has(item.source) && intent.sourceProductIds.has(sid)) return true;
return false;
}
function firstSetValue(values) {
for (const value of values || []) return value;
return null;
}
function missingExactLookupHtml(query) {
const intent = parseSearchIntent(query);
if (!intent.hasSourceAndId) return "";
const source = firstSetValue(intent.sources);
const sourceProductId = firstSetValue(intent.sourceProductIds);
if (!source || !sourceProductId) return "";
const sourceName = SOURCE_DISPLAY_NAMES[source] || source;
const sourceUrl = (intent.sourceUrls || []).find(row => row.source === source)?.source_url || "";
const link = sourceUrl
? `Открыть в магазине`
: "";
return `
${imageTag(null, '')}
точный источник распознан
${escapeHtml(sourceName)}
${escapeHtml(`${source}:${sourceProductId}`)}
статускарточки нет в локальной базе
${link ? `
${link}
` : ''}
`;
}
const PRODUCT_TYPE_LABEL_RU = {
"eyeshadow": "Тени для век",
"eye shadow": "Тени для век",
"liquid eyeshadow": "Жидкие тени для век",
"lipstick": "Помада",
"lip gloss": "Блеск для губ",
"mascara": "Тушь для ресниц",
"foundation": "Тональное средство",
"concealer": "Консилер",
"blush": "Румяна",
"highlighter": "Хайлайтер",
"perfume": "Парфюм",
"eau de parfum": "Парфюмированная вода",
"makeup brushes": "Кисть для макияжа",
"makeup brush": "Кисть для макияжа",
"brushes": "Кисть",
"brush": "Кисть",
"cosmetic bags": "Косметичка",
"cosmetic bag": "Косметичка",
"hair brushes": "Щетка для волос",
"hair brush": "Щетка для волос",
"scrunchies": "Резинка для волос",
"scrunchie": "Резинка для волос",
"facial fluid": "Флюид для лица",
"face fluid": "Флюид для лица",
"body sprays & mists": "Спрей для тела",
"body spray": "Спрей для тела",
"facial sprays & mists": "Спрей для лица",
"face spray": "Спрей для лица",
"face milk": "Молочко для лица",
"facial milk": "Молочко для лица",
"facial gel": "Гель для лица",
"face gel": "Гель для лица",
"shower gel": "Гель для душа",
"sunscreen": "Солнцезащитное средство",
"sun protection": "Солнцезащитное средство",
"facial cream": "Крем для лица",
"face cream": "Крем для лица",
"cream": "Крем",
"facial serum": "Сыворотка для лица",
"face serum": "Сыворотка для лица",
"serum": "Сыворотка",
"cleanser": "Очищающее средство",
"facial cleanser": "Очищающее средство для лица",
"micellar water": "Мицеллярная вода",
"toner": "Тонер",
"lotion": "Лосьон",
"body lotion": "Лосьон для тела",
"deodorant": "Дезодорант",
"shampoo": "Шампунь",
"conditioner": "Кондиционер",
"hair mask": "Маска для волос",
"face mask": "Маска для лица",
"sheet mask": "Тканевая маска",
"body scrub": "Скраб для тела",
"facial scrub": "Скраб для лица",
"lip balm": "Бальзам для губ",
"eye cream": "Крем для век",
};
const PRODUCT_VALUE_LABEL_RU = {
"sparkling": "сияющий",
"matte": "матовый",
"glossy": "глянцевый",
"satin": "сатиновый",
"liquid": "жидкая",
"cream": "кремовая",
"powder": "пудровая",
"solid": "твердая",
"eyes": "глаза",
"lips": "губы",
"face": "лицо",
"body": "тело",
"hair": "волосы",
"face, body": "лицо и тело",
"body, face": "тело и лицо",
"yes": "да",
"no": "нет",
"for everyone": "для всех",
"male & female": "для всех",
"kids": "детям",
"for all skin types": "для всех типов кожи",
"for oily skin": "для жирной кожи",
"for sensitive skin": "для чувствительной кожи",
"for combination skin": "для комбинированной кожи",
"for dry skin": "для сухой кожи",
"for dehydrated skin": "для обезвоженной кожи",
"for problematic skin": "для проблемной кожи",
"for acne-prone skin": "для кожи с высыпаниями",
"sun protection": "солнцезащита",
"cleansing": "очищение",
"moisturizing": "увлажнение",
"mattifying": "матирование",
"makeup remover": "снятие макияжа",
"anti-imperfections": "против несовершенств",
"anti-blackheads": "против черных точек",
"tone correction": "выравнивание тона",
"regeneration": "восстановление",
"exfoliation": "отшелушивание",
};
const LEGACY_FRONTEND_RELEASE = {
schema: "beautyscan_frontend_release_legacy",
release_id: "legacy",
source_run_id: null,
product_shard_root: "data/podruzhka_review_shards/products",
ingredient_shard_root: "data/podruzhka_review_shards/ingredients",
token_manifest_path: OCR_TOKEN_INDEX_MANIFEST_PATH,
};
async function currentFrontendRelease() {
if (!frontendReleasePromise) {
frontendReleasePromise = fetchWithTimeout(FRONTEND_RELEASE_PATH, { cache: "no-cache" }, 8000)
.then(resp => {
if (!resp.ok) throw new Error(`release_pointer_http_${resp.status}`);
return resp.json();
})
.then(payload => {
if (!payload || !payload.release_id) throw new Error("release_pointer_invalid");
return payload;
})
.catch(error => {
frontendReleasePromise = null;
queueMicrotask(() => {
renderError("Каталог обновляется. Попробуй ещё раз через минуту.", () => location.reload());
navigate("#err");
});
throw error;
})
.then(payload => {
activeFrontendRelease = payload;
return payload;
});
}
return frontendReleasePromise;
}
async function fetchWithTimeout(resource, options = {}, timeoutMs = 15000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort("request_timeout"), timeoutMs);
try {
return await fetch(resource, { ...options, signal: options.signal || controller.signal });
} finally {
clearTimeout(timer);
}
}
function stampCachedPayload(payload, release) {
return {
...(payload || {}),
_frontend_release_id: release && release.release_id || null,
_source_run_id: release && release.source_run_id || null,
};
}
function cachedPayloadMatchesRelease(payload, release) {
if (!payload || !release) return false;
return payload._frontend_release_id === release.release_id
&& payload._source_run_id === (release.source_run_id || null);
}
function announce(message) {
const node = $("#appStatus");
if (!node) return;
node.textContent = "";
requestAnimationFrame(() => { node.textContent = String(message || ""); });
}
function showToast(message, tone = "") {
const node = $("#spaToast");
if (!node) return;
node.textContent = String(message || "");
node.className = `spa-toast ${tone}`.trim();
node.hidden = false;
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => { node.hidden = true; }, 2800);
}
function setPageMeta(title, description) {
const cleanTitle = String(title || "Составика — сканер косметики").replace(/\s+/g, " ").trim();
const cleanDescription = String(description || "Сканер состава косметики по фото и названию.").replace(/\s+/g, " ").trim();
document.title = cleanTitle.length > 90 ? `${cleanTitle.slice(0, 87).trim()}…` : cleanTitle;
const node = document.querySelector('meta[name="description"]');
if (node) node.setAttribute("content", cleanDescription.length > 170 ? `${cleanDescription.slice(0, 167).trim()}…` : cleanDescription);
}
function trackEvent(name, fields = {}) {
const row = {
name: String(name || "event"),
route: parseHash().route,
at: new Date().toISOString(),
...fields,
};
window.dispatchEvent(new CustomEvent("beautyscan:telemetry", { detail: row }));
try {
const previous = JSON.parse(localStorage.getItem(TELEMETRY_KEY) || "[]");
const rows = Array.isArray(previous) ? previous : [];
rows.push(row);
localStorage.setItem(TELEMETRY_KEY, JSON.stringify(rows.slice(-200)));
} catch {}
if (DEBUG) addAppLog("info", `event:${row.name}`);
}
function localOcrConfidence(rawScore, matchRatio = 0) {
const score = Number(rawScore);
if (!Number.isFinite(score)) return 0.72;
const positiveScore = Math.max(0, Math.min(1400, score));
const ratio = Math.max(0, Math.min(0.8, Number(matchRatio) || 0));
return Math.min(0.96, Math.max(0.58, 0.58 + positiveScore / 1400 * 0.34 + ratio * 0.05));
}
function ocrSearchQueryTerms(query) {
return Array.from(new Set(normLookupText(expandSearchText(query || ""))
.split(/\s+/)
.filter(term => term && (term.length >= 3 || /^\d{2,}$/.test(term)))
));
}
function ocrSearchQueryLooksLineOnly(query) {
return rawOcrLooksLikeLineOnlySignal(ocrSearchQueryTerms(query));
}
function ocrSearchQueryHasStrongDisambiguator(query) {
const text = normLookupText(expandSearchText(query || ""));
if (packageSignalTerms(query || "").length) return true;
if (/\b\d{5,}\b/.test(text)) return true;
return /\b(apm|apmax|baume|balm|бальзам|липидовосполняющ|syndet|синдет|serum|сыворотк|syvorotka|cream|крем|gel|гель|cleanser|cleansing|foam|пенк|умыван|mist|spray|спрей|мист|spf|h2o|ultra|niacinamide|ниацинамид|retinol|ретинол|retinal|ретиналь|pdrn|madecassoside|madecassos|мадекассосид|ceramide|керамид|hyaluronic|гиалур|eye|eyes|глаз|век)\b/i.test(text);
}
function ocrSearchCandidateVariantMismatch(query, item) {
if (!item) return false;
const querySignal = normLookupText(expandSearchText(query || ""));
const rowSignal = normLookupText(expandSearchText([
item.display_name || item.name || item._displayName,
item.search_subtitle,
].filter(Boolean).join(" ")));
if (!querySignal || !rowSignal) return false;
const queryHasApMax = /\b(apmax|ap\s*max)\b/i.test(querySignal);
const queryHasApM = /\b(apm|ap\s*m)\b/i.test(querySignal);
const rowHasApMax = /\b(apmax|ap\s*max)\b/i.test(rowSignal);
const rowHasApM = /\b(apm|ap\s*m)\b/i.test(rowSignal);
return (queryHasApMax && rowHasApM && !rowHasApMax) || (queryHasApM && rowHasApMax && !rowHasApM);
}
function localOcrConfidenceForQuery(rawScore, matchRatio = 0, query = "", matchedTerms = 0, item = null) {
let confidence = localOcrConfidence(rawScore, matchRatio);
if (ocrSearchQueryLooksLineOnly(query)) confidence = Math.min(confidence, 0.76);
else if (!ocrSearchQueryHasStrongDisambiguator(query)) confidence = Math.min(confidence, 0.84);
if ((Number(matchedTerms) || 0) <= 1 && (Number(matchRatio) || 0) < 0.16) confidence = Math.min(confidence, 0.82);
if (ocrSearchCandidateVariantMismatch(query, item)) confidence = Math.min(confidence, 0.78);
return confidence;
}
function localSearchCandidateFromWorkerItem(item, query, data = null) {
const rawScore = Number(item && item.score);
const confidence = localOcrConfidenceForQuery(rawScore, item && item.matchRatio, query, item && item.matchedTerms, item);
const row = {
product_id: item.product_id,
source: item.source,
brand: item.brand,
name: item.name || item.display_name,
display_name: item.display_name || item.name,
search_subtitle: item.search_subtitle || "",
image_url: item.image_url,
product_shard: item.product_shard,
public_search_ready: item.public_search_ready !== false,
public_search_hidden_reason: item.public_search_hidden_reason || null,
safety_score_10: item.safety_score_10,
score: confidence,
search_query: query,
matched_alias: "ocr-query-normalized",
reference_name: "OCR + локальные алиасы",
local_ocr_candidate: true,
};
row._localSearchScore = rawScore || 0;
row._localSearchMatchedTerms = item.matchedTerms || 0;
row._localSearchMatchRatio = item.matchRatio || 0;
return row;
}
function ensureOcrSearchWorker() {
if (!ocrSearchWorker && window.Worker) {
ocrSearchWorker = new Worker(OCR_SEARCH_WORKER_PATH);
ocrSearchWorker.onmessage = event => {
const payload = event.data || {};
const pending = ocrSearchPending.get(payload.id);
if (!pending) return;
ocrSearchPending.delete(payload.id);
if (payload.ok) pending.resolve(payload);
else pending.reject(new Error(payload.error || "OCR search worker failed"));
};
ocrSearchWorker.onerror = event => {
for (const pending of ocrSearchPending.values()) pending.reject(new Error(event.message || "OCR search worker error"));
ocrSearchPending.clear();
ocrSearchWorker.terminate();
ocrSearchWorker = null;
};
}
return ocrSearchWorker;
}
async function localOcrWorkerSearch(q, limit = 60, data = null) {
const worker = ensureOcrSearchWorker();
if (!worker) return null;
const release = await currentFrontendRelease();
const manifestPath = release.token_manifest_path || OCR_TOKEN_INDEX_MANIFEST_PATH;
// A worker resolves relative fetch URLs from /assets/, not from the document.
// Pass an origin-absolute URL so immutable release manifests and buckets share one base.
const manifestUrl = new URL(manifestPath, location.origin);
manifestUrl.searchParams.set("r", release.release_id || "legacy");
const versionedManifestPath = manifestUrl.href;
const id = ++ocrSearchRequestSeq;
const response = await new Promise((resolve, reject) => {
ocrSearchPending.set(id, { resolve, reject });
worker.postMessage({ id, type: "search", path: versionedManifestPath, query: q, limit });
});
if (release.source_run_id && response.source_run_id && response.source_run_id !== release.source_run_id) {
throw new Error(`search_release_mismatch:${response.source_run_id}:${release.source_run_id}`);
}
return (response.rows || []).map(item => localSearchCandidateFromWorkerItem(item, q, data));
}
async function loadPodruzhkaShard(path) {
if (!path) return null;
const release = await currentFrontendRelease();
const cacheKey = `${release.release_id || "legacy"}:${path}`;
if (!podruzhkaShardCache.has(cacheKey)) {
const versionedPath = `${path}${path.includes("?") ? "&" : "?"}r=${encodeURIComponent(release.release_id || "legacy")}`;
podruzhkaShardCache.set(cacheKey, fetchWithTimeout(versionedPath, { cache: "force-cache" }, 15000)
.then(resp => resp.ok ? resp.json() : null)
.then(payload => {
if (payload && release.source_run_id && payload.source_run_id && payload.source_run_id !== release.source_run_id) {
throw new Error(`shard_release_mismatch:${payload.source_run_id}:${release.source_run_id}`);
}
return payload;
})
.catch(() => {
podruzhkaShardCache.delete(cacheKey);
return null;
}));
}
return podruzhkaShardCache.get(cacheKey);
}
async function getPodruzhkaProduct(data, productId) {
if (productContextCache.has(productId)) return productContextCache.get(productId);
const inlineProduct = (data.products || []).find(p => p.product_id === productId);
if (inlineProduct) {
cacheProductContext(inlineProduct);
return inlineProduct;
}
const indexItem = (data.search_index || []).find(p => p.product_id === productId);
const shardPath = (data.product_shards || {})[productId] || (indexItem && indexItem.product_shard);
const shard = await loadPodruzhkaShard(shardPath);
const product = ((shard && shard.products) || {})[productId] || null;
if (product) cacheProductContext(product);
return product;
}
function localProductTypeSignalBoost(item, querySignal) {
const rowSignal = normLookupText([
item && (item.display_name || item.name || item._displayName),
item && item.search_subtitle,
].filter(Boolean).join(" "));
let score = 0;
const queryHasBalm = /\b(baume|balm|бальзам|липидовосполняющ|lipid|ap\s*m|ap\s*max|apm|apmax)\b/i.test(querySignal);
const rowHasBalm = /\b(baume|balm|бальзам|липидовосполняющ|ap\s*m|apm|ap\s*max|apmax)\b/i.test(rowSignal);
const rowHasSyndetCleanser = /\b(syndet|синдет|shower|душ|гель|gel|cleanser|cleansing)\b/i.test(rowSignal);
const rowHasNonBalmCareType = /\b(serum|сыворотка|cream|крем|gel|гель|cleanser|cleansing|пенка|toner|тонер|тоник|shampoo|шампунь|маска|mask)\b/i.test(rowSignal) && !rowHasBalm;
if (queryHasBalm && rowHasBalm) score += 280;
else if (queryHasBalm && rowHasSyndetCleanser) score -= 320;
else if (queryHasBalm && rowHasNonBalmCareType) score -= 300;
const queryHasSyndet = /\b(syndet|синдет|shower|душ|гель|gel|cleanser|cleansing)\b/i.test(querySignal);
if (queryHasSyndet && rowHasSyndetCleanser) score += 160;
else if (queryHasSyndet && rowHasBalm) score -= 140;
const queryHasLipikar = /\b(lipikar|upikar|липикар)\b/i.test(querySignal);
const queryHasLipikarBaumeCare = queryHasLipikar && /\b(ap\s*(?:m|max)|apm|apmax|baume|balm|бальзам|липидовосполняющ|атоп|сухой|сухая|тройного)\b/i.test(querySignal);
const rowHasLipikarBaumeCare = rowHasBalm || /\b(ap\s*m|apm|ap\s*max|apmax|baume|липидовосполняющ)\b/i.test(rowSignal);
if (queryHasLipikarBaumeCare && rowHasLipikarBaumeCare) score += 300;
else if (queryHasLipikarBaumeCare && rowHasSyndetCleanser) score -= 380;
const queryHasApMax = /\b(apmax|ap\s*max)\b/i.test(querySignal);
const queryHasApM = /\b(apm|ap\s*m)\b/i.test(querySignal);
const rowHasApMax = /\b(apmax|ap\s*max)\b/i.test(rowSignal);
const rowHasApM = /\b(apm|ap\s*m)\b/i.test(rowSignal);
if (queryHasApMax && rowHasApM && !rowHasApMax) score -= 260;
if (queryHasApM && rowHasApMax && !rowHasApM) score -= 260;
if (queryHasLipikar && !queryHasBalm && !queryHasSyndet) {
if (rowHasBalm) score += 180;
else if (rowHasSyndetCleanser) score -= 180;
}
const queryHasFaceCleanser = /(?:\b(?:foam|cleanser|cleansing|wash|face)\b|пенк\w*|умыван\w*|очищающ\w*|лиц\w*)/i.test(querySignal) && /(?:\bface\b|лиц\w*|умыван\w*)/i.test(querySignal);
const rowHasFaceCleanser = /(?:\b(?:foam|cleanser|cleansing|face)\b|пенк\w*|умыван\w*|очищающ\w*|лиц\w*)/i.test(rowSignal) && /(?:\bface\b|лиц\w*|умыван\w*)/i.test(rowSignal);
const rowHasShowerGel = /\b(shower|душ|гель для душа|гель душа)\b/i.test(rowSignal);
if (queryHasFaceCleanser && rowHasFaceCleanser) score += 320;
else if (queryHasFaceCleanser && rowHasShowerGel) score -= 360;
const queryHasEyeArea = /\b(eye|eyes|глаз|глаза|глазной|глазная|век|века)\b/i.test(querySignal) || /вокруг\s+глаз/i.test(querySignal);
const rowHasEyeArea = /\b(eye|eyes|глаз|глаза|глазной|глазная|век|века)\b/i.test(rowSignal) || /вокруг\s+глаз/i.test(rowSignal);
const rowHasNonEyeFaceCare = /\b(serum|сыворотка|cream|крем|gel|гель|toner|тонер|тоник)\b/i.test(rowSignal) && !rowHasEyeArea;
if (queryHasEyeArea && rowHasEyeArea) score += 240;
else if (queryHasEyeArea && rowHasNonEyeFaceCare) score -= 220;
return score;
}
async function productShardPathForId(productId) {
const release = await currentFrontendRelease();
const bytes = new TextEncoder().encode(String(productId || ""));
const digest = await crypto.subtle.digest("SHA-256", bytes);
const hex = Array.from(new Uint8Array(digest)).map(byte => byte.toString(16).padStart(2, "0")).join("").slice(0, 3);
return `${release.product_shard_root || "data/podruzhka_review_shards/products"}/${hex}.json`;
}
async function ingredientShardPathForKey(ingredientKey) {
const release = await currentFrontendRelease();
const bytes = new TextEncoder().encode(String(ingredientKey || ""));
const digest = await crypto.subtle.digest("SHA-256", bytes);
const hex = Array.from(new Uint8Array(digest)).map(byte => byte.toString(16).padStart(2, "0")).join("").slice(0, 2);
return `${release.ingredient_shard_root || "data/podruzhka_review_shards/ingredients"}/${hex}.json`;
}
async function previewShardPathForId(productId) {
const release = await currentFrontendRelease();
if (!release.preview_shard_root) return null;
const bytes = new TextEncoder().encode(String(productId || ""));
const digest = await crypto.subtle.digest("SHA-256", bytes);
const hex = Array.from(new Uint8Array(digest)).map(byte => byte.toString(16).padStart(2, "0")).join("").slice(0, 3);
return `${release.preview_shard_root}/${hex}.json`;
}
async function prefetchProductById(productId) {
if (!productId || productContextCache.has(productId)) return;
const shardPath = await productShardPathForId(productId);
await loadPodruzhkaShard(shardPath);
}
async function prefetchIngredientByKey(ingredientKey) {
if (!ingredientKey) return;
const shardPath = await ingredientShardPathForKey(ingredientKey);
await loadPodruzhkaShard(shardPath);
}
function scheduleProductIngredientPrefetch(product) {
const keys = (product && product.composition || [])
.filter(item => item && item.has_ingredient_page !== false)
.map(item => item.ingredient_page_key || item.inci_norm || item.inci)
.filter(Boolean)
.slice(0, 8);
if (!keys.length) return;
const run = () => Promise.all(keys.map(key => prefetchIngredientByKey(key))).catch(() => {});
if (window.requestIdleCallback) window.requestIdleCallback(run, { timeout: 1800 });
else setTimeout(run, 350);
}
async function enrichCandidatePreviews(candidates) {
return Promise.all((candidates || []).map(async candidate => {
if (!candidate || !candidate.product_id) return candidate;
if (candidateImageUrl(candidate) && candidate.description) return candidate;
try {
const previewPath = await previewShardPathForId(candidate.product_id);
const shardPath = previewPath || candidate.product_shard || await productShardPathForId(candidate.product_id);
const shard = await loadPodruzhkaShard(shardPath);
const product = ((shard && shard.products) || {})[candidate.product_id];
if (!product) return candidate;
const displayName = product.display_name || displayProductName(product) || product.name;
return {
...candidate,
brand: candidate.brand || product.brand,
name: candidate.name || displayName || product.name,
display_name: candidate.display_name || displayName || product.name,
search_subtitle: candidate.search_subtitle || product.search_subtitle || "",
description: candidate.description || product.description || product.short_description || (product.content && product.content.description) || "",
image_url: product.image_url || candidate.image_url,
product_shard: product.product_shard || candidate.product_shard || (previewPath ? await productShardPathForId(candidate.product_id) : shardPath),
product_url: candidate.product_url || product.product_url,
public_search_ready: candidate.public_search_ready !== false && product.public_search_ready !== false,
public_search_hidden_reason: candidate.public_search_hidden_reason || product.public_search_hidden_reason || null,
};
} catch {
return candidate;
}
}));
}
function ingredientLookupKeys(value) {
const raw = String(value || "").trim();
const normalized = normLookupText(raw)
.replace(/[(),.;:]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return Array.from(new Set([
raw,
raw.toLowerCase(),
normalized,
normalized.replace(/\s+-\s+/g, "-"),
normalized.replace(/\s+/g, "-"),
].filter(Boolean)));
}
async function getPodruzhkaIngredient(data, ingredientKey) {
const keys = ingredientLookupKeys(ingredientKey);
const inline = data.ingredients || {};
for (const key of keys) {
if (inline[key]) return inline[key];
}
const shardMap = data.ingredient_shards || {};
const shardPaths = [];
for (const key of keys) {
if (shardMap[key]) shardPaths.push(shardMap[key]);
shardPaths.push(await ingredientShardPathForKey(key));
}
const wanted = normLookupText(ingredientKey).replace(/[(),.;:]+/g, " ").replace(/\s+/g, " ").trim();
for (const shardPath of Array.from(new Set(shardPaths.filter(Boolean)))) {
const shard = await loadPodruzhkaShard(shardPath);
const rows = (shard && shard.ingredients) || {};
for (const key of keys) {
if (rows[key]) return rows[key];
}
const fuzzy = Object.entries(rows).find(([key, value]) => {
const candidate = normLookupText(key).replace(/[(),.;:]+/g, " ").replace(/\s+/g, " ").trim();
const inci = normLookupText((value && (value.canonical_inci || value.inci)) || "").replace(/[(),.;:]+/g, " ").replace(/\s+/g, " ").trim();
return candidate === wanted || inci === wanted;
})?.[1];
if (fuzzy) return fuzzy;
}
return null;
}
function getPodruzhkaAlternatives(data, productId) {
const inline = data.products || [];
if (inline.length) {
const seenInline = new Set();
return inline
.filter(p => p.product_id !== productId)
.filter(p => {
const key = publicProductIdentityKey(p);
if (seenInline.has(key)) return false;
seenInline.add(key);
return true;
})
.slice(0, 4)
.map(p => ({
product_id: p.product_id,
source: p.source,
brand: p.brand,
name: p.name,
display_name: p.display_name || (p.characteristics ? displayProductName(p) : p.name),
search_subtitle: p.search_subtitle || "",
image_url: p.image_url,
}));
}
const index = data.search_index || [];
const current = index.find(p => p.product_id === productId) || {};
const seen = new Set([publicProductIdentityKey(current)]);
const picked = [];
const addCandidate = (p) => {
if (!p || p.product_id === productId || !isPublicSearchVisibleProduct(p, "")) return;
const key = publicProductIdentityKey(p);
if (seen.has(key)) return;
seen.add(key);
picked.push(p);
};
for (const p of index) {
if (picked.length >= 4) break;
if (current.brand && p.brand === current.brand) addCandidate(p);
}
for (const p of index) {
if (picked.length >= 4) break;
if (current.source && p.source === current.source) addCandidate(p);
}
return picked
.map(p => ({
product_id: p.product_id,
source: p.source,
brand: p.brand,
name: p.name,
display_name: p.display_name || p.name,
search_subtitle: p.search_subtitle || "",
image_url: p.image_url,
}));
}
function publicProductIdentityKey(item) {
if (item && item.public_search_key) return item.public_search_key;
const displayName = item && (item.display_name || (item.characteristics ? displayProductName(item) : item.name));
return normLookupText([item && item.source, item && item.brand, displayName, item && item.search_subtitle].filter(Boolean).join(" "));
}
function isWeakGoldAppleName(value) {
const n = normLookupText(value);
if (!n) return true;
if (n.length <= 2) return true;
if (/^#?\d+[a-zа-яё-]*$/i.test(n)) return true;
if (/^[a-zа-яё]{0,4}\d{1,4}[a-zа-яё]{0,4}$/i.test(n)) return true;
return false;
}
function isPublicSearchVisibleProduct(item, needle = "", intent = null) {
if (!item) return false;
const exactIdLookup = isExactProductIntentMatch(item, intent, needle);
if (exactIdLookup) return true;
if (item.public_search_ready === false) return false;
if (item.source !== "goldapple") return true;
if (item.safety_score_10 == null) return false;
if (isWeakGoldAppleName(item.name)) return false;
return true;
}
async function localPodruzhkaApiGet(path) {
const url = new URL(path, location.origin);
if (url.pathname === "/product") {
const id = url.searchParams.get("id");
const shardPath = await productShardPathForId(id);
const product = await getPodruzhkaProduct({ products: [], search_index: [{ product_id: id, product_shard: shardPath }], product_shards: {} }, id);
if (!product) return null;
return {
status: 200,
payload: {
status: "ok",
source: "local_podruzhka_review_fixture",
source_run_id: null,
product,
alternatives: [],
},
};
}
if (url.pathname === "/ingredient") {
const key = normLookupText(url.searchParams.get("inci"));
const ingredient = await getPodruzhkaIngredient({}, key);
if (!ingredient) return null;
return { status: 200, payload: ingredient };
}
return null;
}
function searchCandidateFromProduct(product, shardPath = "") {
const displayName = displayProductName(product) || product.name || "";
return {
product_id: product.product_id,
source: product.source,
brand: product.brand,
name: product.name,
display_name: displayName,
search_subtitle: product.search_subtitle || "",
description: product.description || "",
image_url: product.image_url,
product_shard: shardPath,
product_url: product.product_url,
safety_score_10: product.safety_score_10,
public_search_ready: product.public_search_ready !== false,
public_search_hidden_reason: product.public_search_hidden_reason || null,
exact_lookup: true,
};
}
async function directExactLookup(query) {
const intent = parseSearchIntent(query);
const raw = String(query || "").trim().toLowerCase();
if (/^[a-z_]+:[^\s]+$/i.test(raw)) intent.exactProductIds.add(raw);
const ids = Array.from(intent.exactProductIds || []);
for (const productId of ids.slice(0, 8)) {
const shardPath = await productShardPathForId(productId);
const product = await getPodruzhkaProduct(
{ products: [], search_index: [{ product_id: productId, product_shard: shardPath }], product_shards: {} },
productId,
);
if (product) return [searchCandidateFromProduct(product, shardPath)];
}
return [];
}
async function localPodruzhkaSearch(q, limit = 60) {
const exactRows = await directExactLookup(q);
if (exactRows.length) return exactRows;
try {
const workerRows = await localOcrWorkerSearch(q, Math.max(limit, 80), null);
if (workerRows) {
return workerRows
.filter(item => item && item.public_search_ready !== false)
.slice(0, limit);
}
} catch (err) {
addAppLog("warn", `Локальный token-поиск недоступен: ${err && err.message ? err.message : err}`);
}
throw new Error("token_search_unavailable");
}
async function apiPost(path, body, options = {}) {
const base = (options.base || API_BASE).replace(/\/$/, "");
const url = `${base}${path}`;
const started = performance.now();
let status = 0;
let payload = null;
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: options.signal,
});
status = resp.status;
payload = safeJson(await resp.text());
return { status, payload };
} finally {
addNetworkLog({
method: options.methodLabel || "POST",
path,
base,
status,
ms: Math.round(performance.now() - started),
request: summarizeBody(body),
response: payload,
at: new Date().toISOString(),
});
}
}
async function apiGet(path) {
const local = await localPodruzhkaApiGet(path);
if (local) {
addNetworkLog({
method: "LOCAL",
path,
status: local.status,
ms: 0,
request: null,
response: local.payload,
at: new Date().toISOString(),
});
return local;
}
throw new Error(`static_catalog_miss:${path}`);
}
function updateLoadingStage(stage, detail, progress = null) {
$("#loadingStage").textContent = stage || "Читаем упаковку";
$("#loadingDetail").textContent = detail || "";
if (progress != null) $("#loadingProgress").style.width = `${Math.max(4, Math.min(100, progress))}%`;
announce(`${stage || "Обработка"}. ${detail || ""}`);
}
function showLoading(on, options = {}) {
loadingOverlay.classList.toggle("on", !!on);
loadingOverlay.setAttribute("aria-hidden", on ? "false" : "true");
clearInterval(loadingTimer);
if (!on) {
loadingStartedAt = 0;
$("#loadingProgress").style.width = "0%";
$$(".spa-screen").forEach(screen => { screen.inert = false; });
tabbar.inert = false;
document.querySelector(".spa-footer").inert = false;
document.querySelector(".spa-skip-link").inert = false;
if (loadingReturnFocus && loadingOriginHash === (location.hash || "#scan") && document.contains(loadingReturnFocus)) {
requestAnimationFrame(() => loadingReturnFocus.focus({ preventScroll: true }));
}
loadingReturnFocus = null;
return;
}
loadingOriginHash = location.hash || "#scan";
loadingReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
$$(".spa-screen").forEach(screen => { screen.inert = true; });
tabbar.inert = true;
document.querySelector(".spa-footer").inert = true;
document.querySelector(".spa-skip-link").inert = true;
loadingStartedAt = performance.now();
$("#loadingCancelBtn").hidden = options.cancellable === false;
updateLoadingStage(options.stage, options.detail, options.progress || 8);
const updateElapsed = () => {
const elapsed = Math.max(0, Math.floor((performance.now() - loadingStartedAt) / 1000));
$("#loadingElapsed").textContent = `${elapsed} сек.`;
};
updateElapsed();
loadingTimer = setInterval(updateElapsed, 1000);
requestAnimationFrame(() => {
const target = $("#loadingCancelBtn").hidden ? loadingOverlay : $("#loadingCancelBtn");
if (target === loadingOverlay) loadingOverlay.setAttribute("tabindex", "-1");
target.focus({ preventScroll: true });
});
}
// ========== scan flow ==========
function scanResponseDetail(res) {
const payload = res && res.payload && typeof res.payload === "object" ? res.payload : {};
return payload.payload && typeof payload.payload === "object" ? payload.payload : payload;
}
function isPlaceholderOcrDetail(detail) {
const text = normLookupText([...(detail && detail.ocr_lines || []), ...(detail && detail.recognized_candidates || [])].map(item => {
if (typeof item === "string") return item;
return item && (item.candidate || item.text || item.name);
}).join(" "));
return /(этой картинки больше нет|картинки больше нет|image no longer available|image not found|no image available)/i.test(text);
}
function scanDetailHasUsefulOcr(detail) {
return collectOcrSearchQueries(detail || {}).length > 0
&& !isPlaceholderOcrDetail(detail || {})
&& !scanOkOcrSignalIsWeak(detail || {});
}
async function runScan(imageBase64) {
const scanStartedAt = performance.now();
const controller = new AbortController();
scanAbortController = controller;
scanAbortReason = null;
const scanTimeout = setTimeout(() => {
if (scanAbortController !== controller || controller.signal.aborted) return;
scanAbortReason = "timeout";
controller.abort();
}, 18000);
showLoading(true, { stage: "Читаем упаковку", detail: "Готовим изображение", progress: 8, cancellable: true });
stopCamera();
trackEvent("scan_started", { photo_count: scanPhotos.length || 1 });
addAppLog("info", `Готовлю OCR-варианты и отправляю изображение в /scan через ${SCAN_API_BASE}...`);
const visualCandidatesPromise = VISUAL_IMAGE_SEARCH_ENABLED
? findVisualImageCandidates(imageBase64, 6).catch(() => [])
: Promise.resolve([]);
try {
const variants = await buildScanOcrVariants(imageBase64, 3);
const attempts = [];
let res = null;
let lastError = null;
for (const [idx, variant] of variants.entries()) {
try {
updateLoadingStage("Читаем упаковку", `Проверяем вариант ${idx + 1} из ${variants.length}`, 18 + idx * 20);
addAppLog("info", `OCR-вариант ${idx + 1}/${variants.length}: ${variant.name}`);
const candidateRes = await apiPost("/scan", { image_base64: variant.image_base64 }, {
base: SCAN_API_BASE,
methodLabel: `POST OCR ${variant.name}`,
signal: controller.signal,
});
const detail = scanResponseDetail(candidateRes);
const useful = scanDetailHasUsefulOcr(detail);
attempts.push({
variant: variant.name,
status: candidateRes.status,
remote_status: detail && detail.status,
ocr_line_count: (detail && detail.ocr_lines || []).length,
query_count: collectOcrSearchQueries(detail || {}).length,
placeholder: isPlaceholderOcrDetail(detail || {}),
weak_signal: scanOkOcrSignalIsWeak(detail || {}),
});
if (!res) res = candidateRes;
if (useful) {
res = candidateRes;
break;
}
} catch (err) {
if (controller.signal.aborted) throw err;
lastError = err;
attempts.push({ variant: variant.name, error: err && err.message ? err.message : String(err) });
}
}
if (!res) throw lastError || new Error("ocr_failed");
res.ocr_variant_attempts = attempts;
if (res.payload && typeof res.payload === "object") res.payload.ocr_variant_attempts = attempts;
const visualCandidates = await visualCandidatesPromise;
res.visual_candidate_products = visualCandidates;
if (res.payload && typeof res.payload === "object") res.payload.visual_candidate_products = visualCandidates;
lastScanResult = res;
lastDebugPayload = res.payload;
addAppLog("info", `Распознавание завершено. HTTP ${res.status}`);
const completedDetail = scanResponseDetail(res);
trackEvent("ocr_completed", {
http_status: res.status,
text_found: scanDetailHasUsefulOcr(completedDetail),
attempt_count: attempts.length,
elapsed_ms: Math.round(performance.now() - scanStartedAt),
});
updateLoadingStage("Ищем товар", "Сопоставляем текст с каталогом", 78);
await handleScanResult(res);
} catch (err) {
if (controller.signal.aborted || (err && err.name === "AbortError")) {
if (scanAbortReason === "user_cancelled") {
addAppLog("info", "Распознавание отменено пользователем.");
trackEvent("scan_cancelled", { elapsed_ms: Math.round(performance.now() - scanStartedAt) });
return;
}
addAppLog("warn", "Распознавание остановлено или превысило 18 секунд.");
renderNotFoundData({ status: "not_found", scan_timeout: true, ocr_lines: [], candidate_products: [] });
trackEvent("scan_timeout", { elapsed_ms: Math.round(performance.now() - scanStartedAt) });
navigate("#nf");
return;
}
const visualCandidates = await visualCandidatesPromise;
if (visualCandidates && visualCandidates.length) {
addAppLog("warn", "OCR недоступен, показываю визуальные совпадения.");
renderAmbigData({ status: "ambiguous", candidate_products: visualCandidates, visual_candidate_products: visualCandidates });
navigate("#ambig");
return;
}
addAppLog("error", "Ошибка сети при /scan: " + (err.message || err));
trackEvent("scan_failed", { error: String(err && err.message || err || "scan_failed").slice(0, 120), elapsed_ms: Math.round(performance.now() - scanStartedAt) });
renderError("Не удалось связаться с сервером.", runScanFromPhotos);
navigate("#err");
} finally {
clearTimeout(scanTimeout);
if (scanAbortController === controller) {
scanAbortController = null;
scanAbortReason = null;
}
showLoading(false);
}
}
async function runScanFromPhotos() {
if (scanPhotos.length === 0) {
const b64 = captureFromVideo();
if (!b64) {
$("#vfHint").textContent = "сначала сделай фото";
return;
}
addScanPhoto(b64, "camera");
}
showLoading(true, { stage: "Готовим фото", detail: "Проверяем размер и ориентацию", progress: 5, cancellable: false });
try {
const composite = await composeScanPhotosBase64();
showLoading(false);
if (!composite) return;
addAppLog("info", scanPhotos.length > 1 ? `Склеено ${scanPhotos.length} фото в один OCR-запрос` : "Отправляю один кадр");
await runScan(composite);
} catch (err) {
showLoading(false);
renderError("Не удалось подготовить фото к распознаванию.", runScanFromPhotos);
navigate("#err");
}
}
function rawOcrSignalTerms(detail) {
const values = [];
const push = (value) => {
if (value == null) return;
if (typeof value === "object") {
push(value.candidate || value.text || value.name || "");
return;
}
let raw = String(value || "").trim();
raw = raw.replace(/\bocr[-\s]+query[-\s]+normalized\b/gi, " ");
raw = raw.replace(/\bmanual[-\s]+search[-\s]+fallback\b/gi, " ");
raw = raw.replace(/\s+/g, " ").trim();
if (raw) values.push(raw);
};
(detail && detail.ocr_lines || []).forEach(push);
(detail && detail.recognized_candidates || []).forEach(push);
const variants = values.flatMap(value => ocrLookalikeVariants(value));
const normalized = normLookupText([...values, ...variants].join(" "))
.replace(/([a-z])([а-яё])/g, "$1 $2")
.replace(/([а-яё])([a-z])/g, "$1 $2")
.replace(/[^0-9a-zа-яё]+/g, " ");
return Array.from(new Set(normalized.split(/\s+/)
.map(term => term.trim())
.filter(term => (term.length >= 3 || /^\d{2,}$/.test(term) || /^(h2o|ap)$/i.test(term)) && !SEARCH_QUERY_STOPWORDS.has(term))
)).slice(0, 48);
}
function rawOcrHasStrongProductSignal(rawTerms) {
const terms = rawTerms || [];
if (terms.some(term => /^\d{5,}$/.test(term))) return true;
if (!terms.length) return false;
if (terms.every(term => WEAK_OCR_GENERIC_SIGNAL_TERMS.has(term))) return false;
if (rawOcrLooksLikeLineOnlySignal(terms)) return false;
const specific = terms.filter(term => {
if (WEAK_OCR_GENERIC_SIGNAL_TERMS.has(term)) return false;
return /^(apm|apmax|baume|balm|бальзам|lipikar|upikar|effaclar|anthelios|cicaplast|toleriane|sensibio|sebium|uvmune|h2o|ocean|mist|syndet|serum|сыворотк|syvorotka|foam|пенк|cleanser|cleansing|умыван|крем|cream|toner|тонер|тоник|шампун|shampoo|conditioner|кондиционер|mask|маска|clay|глина|глиной|spf|sunscreen)$/i.test(term);
});
if (specific.length >= 2) return true;
if (specific.length >= 1 && terms.length >= 3) return true;
return terms.length >= 5 && terms.some(term => !WEAK_OCR_GENERIC_SIGNAL_TERMS.has(term));
}
function scanOkOcrSignalIsWeak(detail) {
const rawTerms = rawOcrSignalTerms(detail || {});
if (rawTerms.some(term => /^\d{5,}$/.test(term))) return false;
const localCandidateCount = (detail && detail.candidate_products || []).length;
if (rawTerms.length === 0 && collectOcrSearchQueries(detail || {}).length > 0) return true;
if (rawTerms.length > 0 && rawTerms.every(term => WEAK_OCR_GENERIC_SIGNAL_TERMS.has(term))) return true;
if (rawOcrLooksLikeLineOnlySignal(rawTerms)) return true;
if (!rawOcrHasStrongProductSignal(rawTerms) && (localCandidateCount >= 2 || rawTerms.length <= 1)) return true;
const queries = collectOcrSearchQueries(detail || {});
const terms = searchTerms(queries.join(" "));
if (terms.some(term => /^\d{5,}$/.test(term))) return false;
const signalTerms = terms.filter(term => {
if (term.length < 4 && !/^\d{4,}$/.test(term)) return false;
if (/^(dear|skin|care|face|body|with|для|лица|кожа|уход)$/i.test(term)) return false;
return true;
});
if (signalTerms.length === 0 && queries.length > 0) return true;
if (signalTerms.length < 2) return true;
if (signalTerms.every(term => WEAK_OCR_GENERIC_SIGNAL_TERMS.has(term))) return true;
const productSpecificTerms = signalTerms.filter(term => {
if (WEAK_OCR_GENERIC_SIGNAL_TERMS.has(term)) return true;
if (/^(serum|сыворотк|syvorotka|foam|пенк|cleanser|cleansing|умыван|крем|cream|toner|тонер|шампун|shampoo|conditioner|кондиционер|mask|маска|balm|baume|бальзам|spf|sunscreen|lipikar|effaclar|anthelios|cicaplast|toleriane|sensibio|sebium|uvmune)$/i.test(term)) return true;
if (/^pkg/.test(term)) return true;
return false;
});
return productSpecificTerms.length === 0 && signalTerms.length <= 2;
}
async function reviewOkScanResult(payload) {
const product = payload && payload.product;
if (!product || !product.product_id) return { accept: false, detail: payload || {} };
const reviewDetail = await enrichDetailWithLocalCandidates({ ...(payload || {}), status: "ambiguous" }, 6);
const candidateIds = (reviewDetail.candidate_products || []).map(item => item && item.product_id).filter(Boolean);
const hitRank = candidateIds.indexOf(product.product_id) + 1;
if (hitRank > 0 && hitRank <= 3) return { accept: true, detail: reviewDetail };
const weak = scanOkOcrSignalIsWeak(reviewDetail);
if (weak || candidateIds.length > 0) {
return {
accept: false,
detail: {
...reviewDetail,
status: candidateIds.length > 0 ? "ambiguous" : "not_found",
remote_ok_product: product,
rejected_remote_ok_reason: weak ? "weak_ocr_not_confirmed" : "local_search_disagrees",
},
};
}
return { accept: true, detail: reviewDetail };
}
function navigateToVisualCandidate(candidate) {
addAppLog("info", `Визуально найден продукт: ${candidate.display_name || candidate.name || candidate.product_id}`);
clearScanPhotos({ restartCamera: false });
navigate(`#p/${encodeURIComponent(candidate.product_id)}`);
}
function weakOcrNotFoundDetail(detail) {
return { ...(detail || {}), status: "not_found", weak_ocr_signal: true, candidate_products: [] };
}
async function handleScanResult({ status, payload, visual_candidate_products }) {
const detail = { ...((payload && payload.detail) || payload || {}) };
if (payload && payload.ocr_variant_attempts && !detail.ocr_variant_attempts) detail.ocr_variant_attempts = payload.ocr_variant_attempts;
const visualCandidates = visual_candidate_products || (payload && payload.visual_candidate_products) || detail.visual_candidate_products || [];
const visualAuto = bestVisualAutoCandidate(visualCandidates);
const apiStatus = String(detail.status || payload.status || "").toLowerCase();
if (visualAuto && (!payload || !payload.product || payload.product.product_id !== visualAuto.product_id)) {
navigateToVisualCandidate(visualAuto);
return;
}
if (status === 200 && apiStatus === "ok" && payload.product) {
const reviewed = await reviewOkScanResult(payload);
if (!reviewed.accept) {
addAppLog("warn", "OCR ok не подтвержден локальным индексом; показываю кандидатов.");
const rejectedDetail = mergeVisualCandidates(reviewed.detail || detail, visualCandidates);
if (scanOkOcrSignalIsWeak(rejectedDetail)) {
renderNotFoundData(weakOcrNotFoundDetail(rejectedDetail));
navigate("#nf");
return;
}
if ((rejectedDetail.candidate_products || []).length > 0) {
renderAmbigData(rejectedDetail);
navigate("#ambig");
} else {
renderNotFoundData(rejectedDetail);
navigate("#nf");
}
return;
}
// cache + history + nav
try {
const release = await currentFrontendRelease();
localStorage.setItem(LAST_ANALYSIS_KEY, JSON.stringify(stampCachedPayload(payload, release)));
} catch {}
appendHistory(payload.product, payload, "scan");
addAppLog("info", `Распознан продукт: ${payload.product.name || payload.product.product_id}`);
const pid = payload.product.product_id;
trackEvent("scan_product_matched", { product_id: pid, source: payload.product.source || "" });
clearScanPhotos({ restartCamera: false });
navigate(`#p/${encodeURIComponent(pid)}`);
return;
}
if (status === 404 && apiStatus === "ambiguous") {
addAppLog("warn", "Неоднозначное совпадение. HTTP 404");
const mergedDetail = mergeVisualCandidates(detail, visualCandidates);
const mergedAuto = bestVisualAutoCandidate(mergedDetail.visual_candidate_products);
if (mergedAuto) {
navigateToVisualCandidate(mergedAuto);
return;
}
if (scanOkOcrSignalIsWeak(mergedDetail)) {
renderNotFoundData(weakOcrNotFoundDetail(mergedDetail));
navigate("#nf");
return;
}
renderAmbigData(mergedDetail);
navigate("#ambig");
return;
}
if (status === 404 && apiStatus === "not_found") {
addAppLog("warn", "Продукт не найден. HTTP 404");
const mergedDetail = mergeVisualCandidates(detail, visualCandidates);
const mergedAuto = bestVisualAutoCandidate(mergedDetail.visual_candidate_products);
if (mergedAuto) {
navigateToVisualCandidate(mergedAuto);
return;
}
if (scanOkOcrSignalIsWeak(mergedDetail)) {
renderNotFoundData(weakOcrNotFoundDetail(mergedDetail));
navigate("#nf");
return;
}
if ((mergedDetail.candidate_products || []).length > 0) {
renderAmbigData(mergedDetail);
navigate("#ambig");
return;
}
renderNotFoundData(mergedDetail);
navigate("#nf");
return;
}
renderError("Сервер не смог завершить распознавание.", runScanFromPhotos);
navigate("#err");
}
// ========== history (localStorage) ==========
function loadHistory() {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); }
catch { return []; }
}
function appendHistory(product, fullPayload, origin = null) {
const list = loadHistory();
const previous = list.find(x => x.productId === product.product_id) || null;
const entry = {
productId: product.product_id,
name: displayProductName(product) || cleanPublicProductName(product.name),
brand: product.brand,
imageUrl: normalizeImageUrl(product.image_url || ""),
safetyScore10: product.safety_score_10 != null ? product.safety_score_10 : score10FromAny(product.safety_score),
safetyScore: product.safety_score,
scoreTone: product.score_tone || "",
releaseId: fullPayload && fullPayload._frontend_release_id || (activeFrontendRelease && activeFrontendRelease.release_id) || "legacy",
sourceRunId: fullPayload && fullPayload._source_run_id || (activeFrontendRelease && activeFrontendRelease.source_run_id) || null,
publicSearchReady: product.public_search_ready !== false,
origin: origin || (previousRoute === "search" ? "search" : previousRoute === "ambig" ? "scan" : (previous && previous.origin) || "viewed"),
ts: Date.now(),
};
const filtered = list.filter(x => x.productId !== entry.productId);
filtered.unshift(entry);
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered.slice(0, 30))); } catch {}
}
// ========== render: product ==========
function scoreToneFromPayload(value, fallback = "") {
const raw = typeof value === "string" ? value : (value && (value.score_tone || value.tone));
return ["ok", "warn", "bad", "neutral"].includes(String(raw || "")) ? String(raw) : fallback;
}
function dotToneFromIngredient(ing, product = null) {
return scoreToneFromPayload(ing, "neutral");
}
function fmtScore10(n) {
if (n == null || isNaN(n)) return "—";
return `${fmtScoreOne(n)}/10`;
}
function fmtScoreNum(n) {
if (n == null || isNaN(n)) return "—";
return fmtScoreOne(n);
}
function fmtScoreOne(n) {
if (n == null || isNaN(n)) return "—";
return Number(n).toFixed(1).replace(/\.0$/, "");
}
function score10FromAny(n) {
if (n == null || isNaN(n)) return null;
const v = Number(n);
return v > 10 ? Math.max(0, Math.min(10, v / 10)) : Math.max(0, Math.min(10, v));
}
function formatOfferPrice(value, currency) {
if (value == null || value === "") return "";
if (currency && String(currency).toUpperCase() !== "RUB") return "";
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return "";
return `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(numeric)} ₽`;
}
function ingredientScore10(ing) {
if (!ing) return null;
const direct = ing.safety_score_10;
return direct != null && !isNaN(direct)
? score10FromAny(direct)
: score10FromAny(ing.ingredient_safety_score);
}
function ingredientEvidence(ing) {
if (!ing) return [];
if (Array.isArray(ing.evidence)) return ing.evidence;
if (Array.isArray(ing.evidence_summary)) return ing.evidence_summary;
if (Array.isArray(ing.evidence_summary_json)) return ing.evidence_summary_json;
return [];
}
function ingredientFunctionLabels(ing) {
const values = [];
if (Array.isArray(ing && ing.functions)) values.push(...ing.functions);
if (ing && ing.ingredient_class) values.push(ing.ingredient_class);
const labels = [];
values.forEach(value => {
const label = functionLabelRu(value);
const inci = String((ing && (ing.inci_norm || ing.inci)) || "").toUpperCase();
if (label === "растительный экстракт" && !/(?:EXTRACT|LEAF WATER|FLOWER WATER|ROOT WATER|FRUIT WATER|JUICE)/.test(inci)) return;
if (label === "очищающий ПАВ" && /(?:PHENYLENEDIAMINE|AMINOPHENOL|DIAMINOPYRAZOLE|RESORCINOL)/.test(inci)) return;
const key = label.trim().toLowerCase();
if (key && !labels.some(existing => existing.toLowerCase() === key)) labels.push(label);
});
return labels.slice(0, 4);
}
const FUNCTION_LABEL_RU = {
solvent: "растворитель",
vehicle: "основа",
humectant: "увлажняющий компонент",
emollient: "смягчающий компонент",
occlusive: "защитная пленка",
surfactant: "очищающий ПАВ",
"cleansing agent": "очищающий ПАВ",
emulsifier: "эмульгатор",
"emulsion stabilizer": "стабилизатор эмульсии",
preservative: "консервант",
fragrance: "отдушка",
perfuming: "отдушка",
allergen: "потенциальный аллерген",
"fragrance allergen": "компонент отдушки",
colorant: "краситель",
"uv filter": "УФ-фильтр",
"ultraviolet filter": "УФ-фильтр",
antioxidant: "антиоксидант",
chelator: "стабилизатор формулы",
"chelating agent": "стабилизатор",
"ph adjuster": "регулятор pH",
"pH adjuster": "регулятор pH",
buffering: "регулятор pH",
"buffering agent": "регулятор pH",
"film former": "пленкообразователь",
"viscosity modifier": "текстура",
"viscosity controlling": "текстура",
thickener: "загуститель",
stabilizer: "стабилизатор",
active: "актив",
vitamin: "витамин",
cleanser: "очищающий компонент",
"preservative booster": "поддержка консерванта",
"emulsifier support": "поддержка эмульсии",
"barrier lipid": "липидный барьер",
"fatty acid": "жирная кислота",
"amino acid/peptide": "аминокислота/пептид",
"botanical extract": "растительный экстракт",
"soothing active": "успокаивающий компонент",
exfoliant: "отшелушивание",
retinoid: "ретиноид",
silicone: "силикон",
"skin conditioning": "уход за кожей",
"skin conditioner": "уход за кожей",
"hair conditioning": "уход за волосами",
"hair conditioner": "уход за волосами",
"antistatic agent": "антистатик",
absorbent: "абсорбент",
abrasive: "абразив",
adsorbent: "абсорбент",
"anticaking agent": "против слеживания",
astringent: "вяжущий компонент",
binder: "связующий компонент",
binding: "связующий компонент",
"bulking agent": "наполнитель",
deodorant: "дезодорирующий компонент",
"deodorant agent": "дезодорирующий компонент",
denaturant: "денатурант",
depilatory: "депилятор",
"foam booster": "усилитель пены",
"foam stabilizer": "стабилизатор пены",
"hair fixative": "фиксация волос",
opacifying: "делает текстуру непрозрачной",
"opacifying agent": "делает текстуру непрозрачной",
oralcare: "уход за полостью рта",
"oral care": "уход за полостью рта",
"skin protectant": "защитный компонент",
"oxidative hair dye precursor": "компонент окислительного красителя",
"slip modifier": "улучшает скольжение",
other: "",
};
function functionLabelRu(value) {
const raw = String(value || "").trim();
if (!raw) return "";
const key = raw.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
if (key === "other" || key === "unknown" || key === "none") return "";
const translated = FUNCTION_LABEL_RU[key] || FUNCTION_LABEL_RU[raw.toLowerCase()];
if (translated) return translated;
return /[a-z]{3,}/i.test(raw) ? "" : raw;
}
function publicRuDescription(value) {
const text = String(value || "").trim();
if (!text) return "";
if (/[А-Яа-яЁё]/.test(text)) return text;
return /[A-Za-z]{3,}/.test(text) ? "" : text;
}
function publicProfileTextKey(value) {
return String(value || "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[«»"'`]+/g, "")
.replace(/[^0-9a-zа-я]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function publicProfileLabelKeys(value) {
return String(value || "")
.split(/[,;]+/)
.map(publicProfileTextKey)
.filter(Boolean);
}
function isPublicProfileMetaText(value) {
const text = String(value || "").trim();
return /\b(?:INCI|CID|CAS)\s*:/i.test(text) || /Справочные источники/i.test(text);
}
function profileParagraphDuplicatesRoles(paragraph, roleLabels) {
const key = publicProfileTextKey(paragraph);
if (!key || !roleLabels.length) return false;
const roleKeys = roleLabels.map(publicProfileTextKey).filter(Boolean);
const roleJoined = publicProfileTextKey(roleKeys.join(" "));
if (key === roleJoined) return true;
const roleSet = new Set(roleKeys);
const paragraphLabels = publicProfileLabelKeys(paragraph);
return paragraphLabels.length > 0 && paragraphLabels.every(label => roleSet.has(label));
}
function uniqueProfileParagraphs(paragraphs, roleLabels = [], seenKeys = new Set()) {
const out = [];
(paragraphs || []).forEach(part => {
const text = String(part || "").replace(/\s+/g, " ").trim();
if (!text) return;
if (isPublicProfileMetaText(text)) return;
if (profileParagraphDuplicatesRoles(text, roleLabels)) return;
const key = publicProfileTextKey(text);
if (!key || seenKeys.has(key)) return;
seenKeys.add(key);
out.push(text);
});
return out;
}
function cosmeticIngredientDescription(data) {
const source = String(data?.description_source || "").toLowerCase();
if (!source || ["knowledge_model", "cosmetic_editorial", "class_fallback", "model"].includes(source)) return [];
const external = publicRuDescription(data?.description);
if (!external) return [];
const low = external.toLowerCase();
if (/(knowledge model|medical-cosmetic|medical\/regulatory)/i.test(low)) return [];
if (low.includes("описание") && low.includes("сформировано")) return [];
if (low.includes("расчетная") && low.includes("оценка") && low.includes("безопасности")) return [];
if (/(фототрансдук|опсин|сетчатк|родопсин|хромофор|палочк|колбочк|световоспр|зритель)/i.test(low)) return [];
return external
.split(/\n\s*\n+/)
.map(s => s.replace(/\s+/g, " ").trim())
.filter(s => s && !isPublicProfileMetaText(s));
}
function ingredientNeedsReview(ing) {
return Boolean(ing && ing.needs_review === true);
}
function publicIngredientNote(ing) {
const serviceNote = String((ing && (ing.note_long || ing.note)) || "").trim();
if (serviceNote) {
const allowed = [];
for (const part of serviceNote.split(/(?<=\.)\s+/)) {
const cleaned = part.trim();
if (!cleaned) continue;
if (/p50|концентрац|косметико-токсиколог|метод оценки|medical_cosmetic|knowledge_model|википедия:/i.test(cleaned)) continue;
if (/оценка учитывает тип средства|оценка ингредиента уточнена с учетом типа средства|оценка уточнена по внешним данным|оценка уточнена прямыми/i.test(cleaned)) {
allowed.push(cleaned.replace("Оценка ингредиента уточнена с учетом типа средства", "Оценка учитывает тип средства"));
}
}
return allowed.join(" ");
}
return "";
}
function publicReasonText(value) {
const cleaned = String(value || "").trim();
if (!cleaned) return "";
if (/evidence|coverage|confidence|resolve|подтвержден|подтверждений|внешних подтверж|распознан|нормализац|уточнения по|не полностью уверенно|мало внешних/i.test(cleaned)) {
return "";
}
return cleaned;
}
function publicScoreReasons(product) {
const reasons = [];
const add = (text) => {
const cleaned = publicReasonText(text);
if (cleaned && !reasons.some(x => normLookupText(x) === normLookupText(cleaned))) reasons.push(cleaned);
};
(Array.isArray(product.top_attention_reasons) ? product.top_attention_reasons : []).forEach(reason => {
add(reason);
});
return reasons.slice(0, 5);
}
function productCompositionStats(productScore, composition, backendSummary = null, product = null) {
const total = composition.length;
let scored = 0;
let unknown = 0;
let evidenceOnly = 0;
let high = 0;
let medium = 0;
let low = 0;
let worstKnownScore = null;
for (const ing of composition) {
const s = ingredientScore10(ing, product);
if (s == null) {
unknown += 1;
if (ingredientEvidence(ing).length) evidenceOnly += 1;
continue;
}
scored += 1;
const bucket = String(ing.score_bucket || "");
if (bucket === "high") high += 1;
else if (bucket === "medium") medium += 1;
else if (bucket === "low") low += 1;
worstKnownScore = worstKnownScore == null ? s : Math.min(worstKnownScore, s);
}
const backendScore = score10FromAny(productScore);
if (backendSummary && typeof backendSummary === "object") {
const bsTotal = Number(backendSummary.total_ingredients);
const bsScored = Number(backendSummary.scored_ingredients);
const bsUnknown = Number(backendSummary.unknown_ingredients);
const bsHigh = Number(backendSummary.high_risk_ingredients);
const bsMedium = Number(backendSummary.medium_risk_ingredients);
const bsLow = Number(backendSummary.low_risk_ingredients);
if (Number.isFinite(bsTotal) && bsTotal >= 0) {
// Backend computes the same invariant from enriched composition.
// Prefer it when available so SPA, legacy pages and API clients agree.
scored = Number.isFinite(bsScored) ? bsScored : scored;
unknown = Number.isFinite(bsUnknown) ? bsUnknown : Math.max(0, bsTotal - scored);
evidenceOnly = Number(backendSummary.evidence_only_ingredients ?? evidenceOnly) || evidenceOnly;
high = Number.isFinite(bsHigh) ? bsHigh : high;
medium = Number.isFinite(bsMedium) ? bsMedium : medium;
low = Number.isFinite(bsLow) ? bsLow : low;
}
}
let displayScore = backendScore;
if (total > 0) {
if (scored === 0) displayScore = null;
else displayScore = backendScore == null ? worstKnownScore : backendScore;
}
const coveragePct = total ? Math.round((scored / total) * 100) : null;
const partial = !!(total && scored < total);
const confidence = !total ? "no_composition" : scored === 0 ? "no_medical_score" : coveragePct < 25 ? "very_low" : coveragePct < 50 ? "low" : coveragePct < 80 ? "medium" : "high";
return { total, scored, unknown, evidenceOnly, high, medium, low, worstKnownScore, backendScore, displayScore, coveragePct, partial, confidence };
}
function scoreUncertainty(obj) {
const raw = obj && (obj.safety_10_uncertainty || obj.safety_score_10_uncertainty || obj.uncertainty);
if (Array.isArray(raw) && raw.length >= 2) return { low: raw[0], high: raw[1] };
if (raw && typeof raw === "object") {
const low = raw.low ?? raw.min ?? raw[0];
const high = raw.high ?? raw.max ?? raw[1];
if (low != null || high != null) return { low, high };
}
const low = obj && (obj.safety_score_10_low ?? obj.safety_10_low);
const high = obj && (obj.safety_score_10_high ?? obj.safety_10_high);
return (low != null || high != null) ? { low, high } : null;
}
// Russian label per axis name
const AXIS_LABEL = {
carcinogenicity: "канцероген.",
repro_tox: "репро. токс.",
endocrine_disruption: "эндокр. дизр.",
sensitization: "аллерг.",
irritation: "раздражение",
regulatory: "регуляция",
bioaccumulation: "биоаккум.",
knowledge_model: "",
systemic_margin_risk: "системный запас",
sensitization_risk: "аллерг.",
irritation_risk: "раздражение",
respiratory_risk: "ингаляция",
regulatory_risk: "регуляция",
};
function escapeHtml(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">","\"":"""}[c]));
}
function formatDateShort(value) {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value).slice(0, 10);
return d.toLocaleDateString("ru-RU", { year: "numeric", month: "2-digit", day: "2-digit" });
}
function productCharacteristic(p, key) {
const source = p && p.characteristics;
if (!source || typeof source !== "object") return "";
const wanted = String(key || "").toLowerCase();
const foundKey = Object.keys(source).find(k => String(k || "").toLowerCase() === wanted);
return foundKey ? String(source[foundKey] || "").trim() : "";
}
function productTypeLabel(p) {
const productType = productCharacteristic(p, "product type").toLowerCase();
return PRODUCT_TYPE_LABEL_RU[productType] || "";
}
function titleCaseLatinProductName(value) {
const raw = cleanPublicProductName(value);
if (!raw || /[А-Яа-яЁё]/.test(raw)) return raw;
const acronym = new Set(["SPF", "UV", "PDRN", "BHA", "AHA", "PHA", "BB", "CC", "HD", "3D", "4D"]);
return raw.replace(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g, word => {
const up = word.toUpperCase();
if (acronym.has(up)) return up;
if (/^\d+[A-Za-z]*$/.test(word)) return word.toUpperCase();
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
}
function cleanPublicProductName(value) {
return String(value || "")
.replace(/[`]+/g, "")
.replace(/\s+/g, " ")
.trim();
}
function displayProductName(p) {
const rawName = cleanPublicProductName((p && (p.display_name || p.name)) || "");
if (!p || p.source !== "goldapple") return rawName;
const prettyName = titleCaseLatinProductName(rawName);
const typeLabel = productTypeLabel(p);
if (!isWeakGoldAppleName(rawName) && typeLabel && !normLookupText(rawName).includes(normLookupText(typeLabel))) {
return `${typeLabel} ${prettyName}`.trim();
}
if (!isWeakGoldAppleName(rawName)) return prettyName;
if (typeLabel) return `${typeLabel} ${rawName}`.trim();
return rawName;
}
function productValueLabel(value) {
const raw = String(value || "").trim();
if (!raw) return "";
const translated = PRODUCT_VALUE_LABEL_RU[raw.toLowerCase()];
if (translated) return translated;
return /[A-Za-z]/.test(raw) ? "" : raw;
}
function productIdentityFacts(p) {
const rows = [];
const type = productTypeLabel(p);
if (type) rows.push(["тип", type]);
const texture = productValueLabel(productCharacteristic(p, "texture"));
if (texture) rows.push(["текстура", texture]);
const finish = productValueLabel(productCharacteristic(p, "finish"));
if (finish) rows.push(["финиш", finish]);
const useFor = productValueLabel(productCharacteristic(p, "use for"));
if (useFor) rows.push(["зона", useFor]);
const waterproof = productValueLabel(productCharacteristic(p, "waterproof"));
if (waterproof) rows.push(["водостойкость", waterproof]);
return rows;
}
function materialOrReferenceCompositionLabel(p) {
const status = String((p && p.composition_status) || "").toLowerCase();
const raw = String((p && p.composition_raw_text) || "").toLowerCase();
const productType = productCharacteristic(p, "product type").toLowerCase();
const accessoryText = `${productType} ${raw}`;
if (status === "material_composition") return "Материалы";
if (/(brush|case|bag|scrunch|bristles|handle|cotton|silk|nylon|polyamide|polyurethane|wood|aluminium|aluminum|plastic|leather|fabric|полиуретан|щет|кист|косметич|резинк)/i.test(accessoryText)) return "Материалы";
return "Состав справочно";
}
const MATERIAL_LABEL_RU = {
"wood": "дерево",
"steel": "сталь",
"stainless steel": "нержавеющая сталь",
"plastic": "пластик",
"abs": "АБС-пластик",
"polypropylene": "полипропилен",
"aluminium": "алюминий",
"aluminum": "алюминий",
"zinc alloy": "цинковый сплав",
"thermoplastic": "термопластик",
"microfiber": "микрофибра",
"microfibre": "микрофибра",
"hydrophilic polyurethane": "гидрофильный полиуретан",
"nylon bristles": "нейлоновый ворс",
"synthetic bristles": "синтетический ворс",
"natural bristles": "натуральный ворс",
};
function publicCompositionItemName(ingredient, product) {
const raw = String((ingredient && (ingredient.inci || ingredient.inci_norm)) || "").trim();
const material = String((product && product.composition_status) || "") === "material_composition" || String((product && product.composition_applicability) || "") === "visible_non_scoring";
return material ? (MATERIAL_LABEL_RU[raw.toLowerCase()] || raw) : raw;
}
function localizeMaterialText(value) {
let text = String(value || "");
for (const [source, translated] of Object.entries(MATERIAL_LABEL_RU)) {
text = text.replace(new RegExp(source, "gi"), translated);
}
return text;
}
function normalizeImageUrl(url) {
const raw = String(url || "").trim();
if (!raw) return "";
return raw
.replace(/\$\{screen\}\.\$\{format\}/g, "fullhd.jpg")
.replace(/\$\{screen\}/g, "fullhd")
.replace(/\$\{format\}/g, "jpg")
.replace(/(\.(?:jpe?g|png|webp|gif))\/+($|[?#])/i, "$1$2");
}
function displayImageUrl(url) {
const normalized = normalizeImageUrl(url);
if (!normalized) return "";
return normalized;
}
window.__beautyImageFallback = function(img) {
try {
const queue = JSON.parse(img.getAttribute("data-fallback-srcs") || "[]");
while (queue.length) {
const next = queue.shift();
if (next && next !== img.src) {
img.setAttribute("data-fallback-srcs", JSON.stringify(queue));
img.src = next;
return;
}
}
} catch {}
if (img.dataset.placeholderAttempted) return;
img.dataset.placeholderAttempted = "1";
img.src = PRODUCT_PLACEHOLDER_SRC;
img.classList.add("product-placeholder-img");
};
document.addEventListener("error", event => {
const img = event.target;
if (img instanceof HTMLImageElement && img.hasAttribute("data-beauty-image")) window.__beautyImageFallback(img);
}, true);
function imageTag(url, alt = "", fallbackUrls = []) {
const ordered = [url, ...(Array.isArray(fallbackUrls) ? fallbackUrls : [])]
.map(displayImageUrl)
.filter(Boolean);
const unique = Array.from(new Set(ordered));
const src = unique[0] || PRODUCT_PLACEHOLDER_SRC;
const originalSrc = normalizeImageUrl(url);
const fallbacks = unique.slice(1);
const isPlaceholder = src === PRODUCT_PLACEHOLDER_SRC;
const altText = alt === "" ? "" : (alt || "Фото товара недоступно");
return `
`;
}
function parseIngredientRouteArg(rawArg) {
const raw = String(rawArg || "");
const marker = "/p/";
const markerIndex = raw.indexOf(marker);
if (markerIndex < 0) {
return { ingredientKey: decodeURIComponent(raw), productId: null };
}
return {
ingredientKey: decodeURIComponent(raw.slice(0, markerIndex)),
productId: decodeURIComponent(raw.slice(markerIndex + marker.length)),
};
}
function contextCacheKey(productId, ingredientKey) {
return `${String(productId || '')}::${normLookupText(ingredientKey).replace(/[(),.;:]+/g, " ").replace(/\s+/g, " ").trim()}`;
}
function cacheProductContext(product) {
if (!product || !product.product_id) return;
productContextCache.set(product.product_id, product);
const formulaItems = [];
if (Array.isArray(product.formulas)) {
product.formulas.forEach(formula => {
if (Array.isArray(formula && formula.items)) formulaItems.push(...formula.items);
});
}
const items = formulaItems.length ? formulaItems : (Array.isArray(product.composition) ? product.composition : []);
items.forEach(item => {
[
item && item.ingredient_page_key,
item && item.inci_norm,
item && item.inci,
item && item.ingredient_id && String(item.ingredient_id).replace(/^inci:/, ""),
].filter(Boolean).forEach(key => {
ingredientContextItemCache.set(contextCacheKey(product.product_id, key), item);
});
});
}
async function productForIngredientContext(productId) {
if (!productId) return null;
if (productContextCache.has(productId)) return productContextCache.get(productId);
try {
const shardPath = await productShardPathForId(productId);
let product = await getPodruzhkaProduct(
{ products: [], search_index: [{ product_id: productId, product_shard: shardPath }], product_shards: {} },
productId
);
return product || null;
} catch {
return null;
}
}
function contextIngredientFromProduct(product, ingredientKey) {
if (!product) return null;
const cached = ingredientContextItemCache.get(contextCacheKey(product.product_id, ingredientKey));
if (cached) return cached;
const wanted = normLookupText(ingredientKey).replace(/[(),.;:]+/g, " ").replace(/\s+/g, " ").trim();
const formulaItems = [];
if (Array.isArray(product.formulas)) {
product.formulas.forEach(formula => {
if (Array.isArray(formula && formula.items)) formulaItems.push(...formula.items);
});
}
const items = formulaItems.length ? formulaItems : (Array.isArray(product.composition) ? product.composition : []);
return items.find(item => {
const keys = [
item && item.ingredient_page_key,
item && item.inci_norm,
item && item.inci,
item && item.ingredient_id && String(item.ingredient_id).replace(/^inci:/, ""),
].filter(Boolean);
return keys.some(key => {
const normalized = normLookupText(key).replace(/[(),.;:]+/g, " ").replace(/\s+/g, " ").trim();
return normalized === wanted || normalized.replace(/\s+/g, "-") === wanted.replace(/\s+/g, "-");
});
}) || null;
}
function candidateImageUrl(c) {
return normalizeImageUrl(c && (c.image_url || c.image || c.product_image_url || c.front_image_url || c.thumbnail_url || ""));
}
function candidateProductDescription(c) {
const text = publicRuDescription(c && (c.description || c.product_description || c.short_description || ""));
if (!text) return "";
const cleaned = text.replace(/\s+/g, " ").trim();
if (cleaned.length <= 260) return cleaned;
const cut = cleaned.slice(0, 260);
return `${cut.replace(/\s+\S*$/, "").trim()}…`;
}
async function renderProduct(productId) {
const loadStarted = performance.now();
trackEvent("product_load_started", { product_id: productId });
lastProductRoute = `#p/${encodeURIComponent(productId)}`;
const body = $("#productBody");
body.setAttribute("aria-busy", "true");
body.innerHTML = 'Загружаем карточку
';
const release = await currentFrontendRelease();
const cached = (() => { try { return JSON.parse(localStorage.getItem(LAST_ANALYSIS_KEY) || "null"); } catch { return null; } })();
let payload = null;
if (cachedPayloadMatchesRelease(cached, release) && cached.product && cached.product.product_id === productId) {
payload = cached;
cacheProductContext(payload.product);
}
try {
const res = await apiGet(`/product?id=${encodeURIComponent(productId)}`);
if (res.status === 200 && res.payload && res.payload.product) {
payload = res.payload;
cacheProductContext(payload.product);
try { localStorage.setItem(LAST_ANALYSIS_KEY, JSON.stringify(stampCachedPayload(payload, release))); } catch {}
appendHistory(payload.product, payload);
}
} catch {}
if (!payload || !payload.product) {
body.removeAttribute("aria-busy");
const sourceId = sourceProductIdFromProductId(productId);
body.innerHTML = `
Карточка недоступна
Товар не найден в текущем выпуске каталога.
${escapeHtml(productId)}
К сканеру
`;
const searchButton = $("#missingProductSearchBtn");
if (searchButton) searchButton.onclick = () => {
sessionStorage.setItem("beautyscan.search_seed", sourceId || productId);
navigate("#search");
};
setPageMeta("Карточка недоступна — Составика", "Товар не найден в текущем выпуске каталога.");
announce("Карточка товара недоступна");
trackEvent("product_load_failed", { product_id: productId, load_ms: Math.round(performance.now() - loadStarted) });
return;
}
cacheProductContext(payload.product);
body.removeAttribute("aria-busy");
body.innerHTML = renderProductHtml(payload);
const productTitle = [payload.product.brand, displayProductName(payload.product) || payload.product.name].filter(Boolean).join(" — ");
setPageMeta(`${productTitle || "Товар"} — Составика`, candidateProductDescription(payload.product) || "Состав, оценка компонентов и предложения магазинов.");
setupProductGallery();
scheduleProductIngredientPrefetch(payload.product);
renderDebug($("#debugProduct"), payload);
trackEvent("product_opened", {
product_id: productId,
source: payload.product.source || "",
score_available: payload.product.safety_score_10 != null,
load_ms: Math.round(performance.now() - loadStarted),
});
}
function setupProductGallery() {
const hero = $("#productHeroImage");
if (!hero) return;
$$("[data-gallery-src]").forEach(button => {
button.addEventListener("click", () => {
const src = button.dataset.gallerySrc;
if (!src) return;
hero.src = src;
$$("[data-gallery-src]").forEach(item => {
const selected = item === button;
item.classList.toggle("on", selected);
item.setAttribute("aria-pressed", selected ? "true" : "false");
});
});
});
}
function renderProductHtml(payload) {
const p = payload.product;
const rawScore = p.safety_score_10 != null ? p.safety_score_10 : score10FromAny(p.safety_score);
const composition = Array.isArray(p.composition) ? p.composition : [];
const hasPublicCompositionItems = composition.length > 0 || (Array.isArray(p.formulas) && p.formulas.some(formula => (
formula && Array.isArray(formula.items) && formula.items.length > 0 && formula.used_for_scoring !== false
)));
const compositionStats = productCompositionStats(rawScore, composition, p.composition_score_summary || p.safety_score_summary, p);
const scoreStatus = p.score_status || "";
const hasDisplayedScore = rawScore != null && hasPublicCompositionItems && scoreStatus !== "insufficient_official_evidence" && scoreStatus !== "no_official_evidence";
let score = hasDisplayedScore ? compositionStats.displayScore : null;
const prices = (p.prices || p.price_summary || []);
const alternatives = Array.isArray(payload.alternatives) ? payload.alternatives : [];
const brandLine = p.brand ? `${escapeHtml(p.brand)}
` : '';
const productName = displayProductName(p);
const gallery = Array.from(new Set([p.image_url, ...(p.image_gallery || [])].map(displayImageUrl).filter(Boolean))).slice(0, 6);
const heroSrc = gallery[0] || PRODUCT_PLACEHOLDER_SRC;
const heroImg = `
`;
const galleryHtml = gallery.length > 1 ? `${gallery.map((src, idx) => ``).join('')}
` : '';
const identityFacts = productIdentityFacts(p);
const identityHtml = identityFacts.length ? `
${identityFacts.map(([label, value]) => `${escapeHtml(label)}: ${escapeHtml(value)}`).join('')}
` : '';
const prov = p.composition_provenance || {};
const compositionStatus = p.composition_status || "";
const applicability = p.composition_applicability || "";
const isVisibleNonScoring = applicability === "visible_non_scoring" || !!prov.non_scoring || compositionStatus === "key_ingredients_only";
const compositionSuppressed = !!(p.composition_suppressed || prov.suppressed);
const suppressionReason = p.composition_suppression_reason || prov.suppression_reason || "";
const suppressedByStatus = String(suppressionReason || '').includes('composition_status_below');
const suppressionText = 'Полный состав пока не загружен.';
const provenanceHtml = '';
const formulaSections = Array.isArray(p.formulas)
? p.formulas.filter(f => f && Array.isArray(f.items) && f.items.length)
: [];
const shownCompositionCount = formulaSections.length > 1
? formulaSections.reduce((sum, f) => sum + (f.items ? f.items.length : 0), 0)
: composition.length;
const renderIngredientRows = (items, keyPrefix = 'main') => items.map((ing, idx) => {
const rowKey = `${keyPrefix}-${idx}`;
const tn = dotToneFromIngredient(ing, p);
const s10 = ingredientScore10(ing, p);
const scoreLabel = s10 != null ? fmtScore10(s10) : '';
const inciLow = (ing.inci_norm || ing.inci || '').toLowerCase();
const ingredientPageKey = (ing.ingredient_page_key || inciLow || '').toLowerCase();
const canOpenIngredient = !!ingredientPageKey;
const roleLine = ingredientFunctionLabels(ing).join(", ");
const tag = canOpenIngredient ? 'a' : 'div';
const href = canOpenIngredient ? ` href="#i/${encodeURIComponent(ingredientPageKey)}/p/${encodeURIComponent(p.product_id || '')}" data-prefetch-ingredient="${escapeHtml(ingredientPageKey)}"` : '';
return `
<${tag} class="spa-ing" data-idx="${escapeHtml(rowKey)}"${href}>
${s10 != null ? `` : ''}
${escapeHtml(publicCompositionItemName(ing, p))}
${roleLine ? `
${escapeHtml(roleLine)}
` : ''}
${scoreLabel ? `${escapeHtml(scoreLabel)}` : ''}
${canOpenIngredient ? '→' : ''}
${tag}>
`;
}).join('');
const formulaCompositionHtml = formulaSections.length > 1 ? `
Состав
${formulaSections.map((formula, formulaIdx) => `
${escapeHtml(formula.formula_name || `Формула ${formulaIdx + 1}`)}
${renderIngredientRows(formula.items || [], `formula-${formulaIdx}`)}
`).join('')}
` : '';
const rawCompositionText = String(p.composition_raw_text || prov.raw_text || "").trim();
const referenceLabel = materialOrReferenceCompositionLabel(p);
const allowReferenceComposition = referenceLabel === "Материалы" || compositionStatus === "material_composition";
const referenceCompositionHtml = rawCompositionText && allowReferenceComposition ? `
${escapeHtml(referenceLabel)}
${escapeHtml(localizeMaterialText(rawCompositionText))}
` : "";
const compositionHtml = compositionSuppressed ? `
Полный состав
${escapeHtml(suppressionText)}
` : shownCompositionCount === 0 ? (referenceCompositionHtml || 'Полный состав пока не загружен.
') : formulaCompositionHtml || `
Состав
${renderIngredientRows(composition)}
`;
const pricesHtml = prices.length === 0 ? '' : `
Где купить
`;
const altsHtml = alternatives.length === 0 ? '' : `
Похожие
`;
const tone = scoreToneFromPayload(p);
const titleScoreHtml = isVisibleNonScoring || score == null ? '' : `${fmtScoreNum(score)}из 10 · как считаем`;
const productDescription = publicRuDescription(p.description || '');
const sellerName = SOURCE_DISPLAY_NAMES[p.source] || p.source || 'магазина';
const descriptionLead = productDescription.length > 320
? `${productDescription.slice(0, 320).replace(/\s+\S*$/, "").trim()}…`
: productDescription;
const descriptionHtml = productDescription ? `
Описание продавца · ${escapeHtml(sellerName)}
${escapeHtml(descriptionLead)}
${descriptionLead !== productDescription ? `Читать полностью
${escapeHtml(productDescription)}
` : ''}
` : '';
return `
${heroImg}
${galleryHtml}
${brandLine}
${escapeHtml(productName || '')}
${titleScoreHtml}
${identityHtml}
${provenanceHtml}
${pricesHtml}
${descriptionHtml}
${compositionHtml}
${altsHtml}
`;
}
// ========== render: ingredient ==========
async function renderIngredient(rawArg) {
const loadStarted = performance.now();
const body = $("#ingredientBody");
const route = parseIngredientRouteArg(rawArg);
const inciNorm = route.ingredientKey;
trackEvent("ingredient_load_started", { ingredient_key: inciNorm, product_id: route.productId || "" });
const cachedProduct = route.productId ? productContextCache.get(route.productId) : null;
const cachedItem = cachedProduct ? contextIngredientFromProduct(cachedProduct, inciNorm) : null;
if (cachedItem) {
const cachedScore = ingredientScore10(cachedItem);
body.innerHTML = `
${cachedScore != null ? `
${escapeHtml(fmtScoreNum(cachedScore))}
` : ''}
${escapeHtml(cachedItem.inci || cachedItem.inci_norm || inciNorm)}
`;
} else {
body.innerHTML = 'Загружаем описание ингредиента
';
}
let payload = null;
const contextPromise = route.productId ? productForIngredientContext(route.productId) : Promise.resolve(null);
try {
payload = await getPodruzhkaIngredient({}, inciNorm);
if (!payload && !cachedItem) {
const res = await apiGet(`/ingredient?inci=${encodeURIComponent(inciNorm)}`);
if (res.status === 200 && res.payload) payload = res.payload;
}
} catch {}
const contextProduct = await contextPromise;
if (!payload) {
const fallbackScore = cachedItem ? ingredientScore10(cachedItem) : null;
body.innerHTML = `
`;
announce("Описание ингредиента пока обновляется");
setPageMeta(`${cachedItem && (cachedItem.inci || cachedItem.inci_norm) || inciNorm} — Составика`, "Роль компонента в косметике и товары с этим ингредиентом.");
trackEvent("ingredient_load_failed", { ingredient_key: inciNorm, product_id: route.productId || "", load_ms: Math.round(performance.now() - loadStarted) });
return;
}
let contextItem = null;
if (route.productId) {
contextItem = contextIngredientFromProduct(contextProduct, inciNorm);
}
body.innerHTML = renderIngredientHtml(payload, { product: contextProduct, item: contextItem });
const ingredientName = payload.canonical_inci || payload.inci || inciNorm;
const ingredientDescription = (payload.cosmetic_description_paragraphs || [])[0] || (payload.cosmetic_role_labels || []).join(", ") || "Роль компонента в косметике и товары с этим ингредиентом.";
setPageMeta(`${ingredientName} — Составика`, ingredientDescription);
renderDebug($("#debugIngredient"), { ...payload, context_product: contextProduct, context_item: contextItem });
trackEvent("ingredient_opened", { ingredient_key: inciNorm, product_id: route.productId || "", load_ms: Math.round(performance.now() - loadStarted) });
}
function renderIngredientHtml(data, context = {}) {
const inci = data.canonical_inci || data.inci || '';
const aliasesArr = Array.isArray(data.aliases) ? data.aliases : [];
const ruAlias = aliasesArr.find(a => /[А-Яа-я]/.test(a)) || data.name_ru || '';
const contextItem = context.item || null;
const contextProduct = context.product || null;
const score = contextItem ? ingredientScore10(contextItem) : null;
const worst = contextItem ? contextItem.worst_axis : data.worst_axis;
const fns = Array.isArray(contextItem && contextItem.functions)
? contextItem.functions
: Array.isArray(data.functions) ? data.functions : [];
const products = Array.isArray(data.products) ? data.products : [];
const roleLabels = [];
[
...(Array.isArray(data.cosmetic_role_labels) ? data.cosmetic_role_labels : []),
...(contextItem ? ingredientFunctionLabels(contextItem) : []),
...fns.map(functionLabelRu).filter(Boolean),
].forEach(label => {
const cleaned = String(label || "").trim();
const key = publicProfileTextKey(cleaned);
if (cleaned && key && !roleLabels.some(existing => publicProfileTextKey(existing) === key)) roleLabels.push(cleaned);
});
const cosmeticParts = Array.isArray(data.cosmetic_description_paragraphs)
? data.cosmetic_description_paragraphs.filter(Boolean)
: [];
const descriptionPartsRaw = cosmeticParts.length ? cosmeticParts : cosmeticIngredientDescription(data);
const chemicalParts = Array.isArray(data.chemical_description_paragraphs)
? data.chemical_description_paragraphs.filter(Boolean)
: [];
const tone = contextItem ? dotToneFromIngredient(contextItem) : scoreToneFromPayload(data);
const titleScoreHtml = score != null ? `${fmtScoreNum(score)}как считаем` : '';
const seenProfileKeys = new Set();
const descriptionParts = uniqueProfileParagraphs(descriptionPartsRaw, roleLabels, seenProfileKeys);
const chemicalDescriptionParts = uniqueProfileParagraphs(chemicalParts, roleLabels, seenProfileKeys);
const roleOnlyProfile = roleLabels.length > 0 && descriptionParts.length === 0 && chemicalDescriptionParts.length === 0;
const cosmeticRoleBlock = roleLabels.length === 0 && descriptionParts.length === 0 ? `
Описание ингредиента
Косметическое описание пока неизвестно.
` : `
${roleOnlyProfile ? "Роль в составе" : "Описание ингредиента"}
${roleLabels.length ? `
${escapeHtml(roleLabels.slice(0, 4).join(", "))}
` : ''}
${descriptionParts
.map(p => `
${escapeHtml(p)}
`)
.join('')}
`;
const chemicalBlock = chemicalDescriptionParts.length === 0 ? '' : `
Химическая справка
${chemicalDescriptionParts.map(p => `
${escapeHtml(p)}
`).join('')}
`;
const fnsBlock = '';
const productsBlock = products.length === 0 ? '' : `
Встречается в
`;
return `
${titleScoreHtml}
${escapeHtml(inci)}
${ruAlias ? `${escapeHtml(ruAlias)}
` : ''}
${contextProduct ? `в составе: ${escapeHtml(contextProduct.display_name || contextProduct.name || contextProduct.brand || '')}
` : ''}
${cosmeticRoleBlock}
${chemicalBlock}
${fnsBlock}
${productsBlock}
`;
}
// ========== render: ambig ==========
let ambigLastDetail = null;
function renderAmbigData(detail) { ambigLastDetail = detail; }
function isLocalOcrCandidate(item) {
if (!item) return false;
const matchedAlias = String(item.matched_alias || "");
const referenceName = String(item.reference_name || "");
return item.local_ocr_candidate === true ||
/^ocr[-\s]+query[-\s]+normalized$/i.test(matchedAlias) ||
/^ocr\s+\+\s+локальные\s+алиасы$/i.test(referenceName) ||
item._localSearchScore != null;
}
function scanNoCandidateHtml(detail) {
const attempts = detail && Array.isArray(detail.ocr_variant_attempts) ? detail.ocr_variant_attempts : [];
const hasOcrText = (detail && detail.ocr_lines || []).length > 0 || (detail && detail.recognized_candidates || []).length > 0;
if (detail && detail.scan_timeout) {
return `
Распознавание заняло слишком много времени
Фото сохранено. Можно вернуться, приблизить этикетку или найти товар по названию.
`;
}
if (isPlaceholderOcrDetail(detail || {})) {
return `
На фото заглушка
На изображении нет этикетки товара. Попробуй другое фото упаковки.
`;
}
if (!hasOcrText) {
const attemptsText = attempts.length > 1 ? `Проверено ${attempts.length} вариантов изображения.` : '';
return `
Текст не прочитан
Сфотографируй ближе название на лицевой стороне или добавь второй кадр с этикеткой. ${escapeHtml(attemptsText)}
`;
}
if (detail && detail.weak_ocr_signal) {
return `
Мало текста для точного поиска
На фото прочитался только общий бренд, актив или короткий фрагмент. Добавь кадр, где видны бренд, название средства, тип, объем или артикул.
`;
}
return `
Совпадений нет
Текст распознан, но в локальном каталоге нет уверенного совпадения.
`;
}
function collectOcrSearchQueries(detail) {
const lineRows = [];
const sanitizeOcrSearchValue = (value) => {
let raw = String(value || "").trim();
raw = raw.replace(/\bocr[-\s]+query[-\s]+normalized\b/gi, " ");
raw = raw.replace(/\bmanual[-\s]+search[-\s]+fallback\b/gi, " ");
raw = raw.replace(/^(?:в сравнение|поделиться|постоплата|соц\.?\s*сертификат|ещ[её]|оплата после получения|о товаре|перейти|доставка и возврат)\s+/gi, " ");
raw = raw.replace(/\s+(?:в сравнение|поделиться|постоплата|соц\.?\s*сертификат|ещ[её]|оплата после получения|о товаре|перейти|доставка и возврат)$/gi, " ");
if (/\blipikar\b/i.test(normLookupText(raw))) raw = raw.replace(/\b(губ|губы)\b/gi, " ");
return raw.replace(/\s+/g, " ").trim();
};
const cleanOcrValue = (value) => {
const raw = sanitizeOcrSearchValue(value);
const text = normLookupText(raw);
if (!text || (text.length < 3 && !/^\d{1,2}$/.test(text)) || text.length > 360) return null;
return { raw, text };
};
const isOcrUiNoise = (text) => {
if (!text) return true;
if (/^(состав|ingredients?|ingredient list|aqua|water|made in|volume|объем|способ применения|изготовитель|производитель)$/i.test(text)) return true;
if (/^(красота и здоровье|уход за лицом|уход за телом|уход за волосами|увлажнение и питание|очищение|бальзамы и гели|в сравнение|поделиться|постоплата|соц сертификат|оплата после получения|о товаре|перейти|доставка и возврат|магазин|бренд|тип|аромат|тип кожи|объем мл|фото и видео покупателей|похожее от магазина|все товары из серии)$/i.test(text)) return true;
if (/^\d+(?:[.,]\d+)?(?:\s*[•*]\s*\d[\d\s]*(?:отзыв|отзывов|вопрос|вопросов))?$/.test(text) && !/^\d{1,2}$/.test(text)) return true;
if (/^\d+\s+\d+\s*[•*]\s*\d[\d\s]*(?:отзыв|отзывов|вопрос|вопросов)?$/i.test(text)) return true;
if (/^\d[\d\s]*\s*(₽|руб|р|вопрос|вопросов|отзыв|отзывов)$/i.test(text)) return true;
return false;
};
const isHardWindowNoise = (text) => {
const label = normLookupText(text).replace(/[.,;:()[\]{}+!?|\-]+/g, " ").replace(/\s+/g, " ").trim();
return /^(артикул(?:\s+\d+)?|sku(?:\s+\w+)?|в сравнение|поделиться|постоплата|соц сертификат|еще|ещё|оплата после получения|о товаре|перейти|доставка и возврат|магазин|фото и видео покупателей|похожее от магазина|все товары из серии)$/i.test(label || "");
};
const ocrLabelKey = (text) => {
const label = normLookupText(text).replace(/[.,;:()[\]{}+!?|\-]+/g, " ").replace(/\s+/g, " ").trim();
if (/^(бренд|brand)$/i.test(label || "")) return "brand";
if (/^(тип|type)$/i.test(label || "")) return "type";
if (/^(бьюти ингредиент|ингредиент|актив|active ingredient|beauty ingredient)$/i.test(label || "")) return "active";
if (/^(объем мл|объ[её]м мл|объем|объ[её]м|volume|volume ml)$/i.test(label || "")) return "volume_ml";
return "";
};
const inlineLabelValue = (text, raw) => {
const normalized = text || "";
const rawText = String(raw || "");
for (const [key, pattern] of [
["brand", /^(?:бренд|brand)\s+(.+)$/i],
["type", /^(?:тип|type)\s+(.+)$/i],
["active", /^(?:бьюти ингредиент|ингредиент|актив|active ingredient|beauty ingredient)\s+(.+)$/i],
["volume_ml", /^(?:объем мл|объ[её]м мл|объем|объ[её]м|volume|volume ml)\s+(.+)$/i],
]) {
const match = normalized.match(pattern);
if (match && match[1]) return { key, value: rawText.replace(new RegExp(`^\\\\s*${match[0].slice(0, match[0].length - match[1].length).trim()}\\\\s*`, "i"), "").trim() || match[1] };
}
return null;
};
const normalizeStructuredValue = (key, raw, text) => {
if (!raw || !text || isHardWindowNoise(text)) return "";
if (ocrLabelKey(text)) return "";
if (key === "volume_ml") {
const numbers = text.match(/\b\d{1,4}\b/g) || [];
if (/\b\d{1,4}\s*(мл|ml)\b/i.test(raw)) return raw;
if (numbers.length === 1) return `${numbers[0]} мл`;
return "";
}
if (/^\d+(?:[.,]\d+)?$/.test(text)) return "";
return raw;
};
const nextStructuredValue = (idx, key) => {
for (let offset = 1; offset <= 4 && idx + offset < lineRows.length; offset += 1) {
const row = lineRows[idx + offset];
if (!row) continue;
if (ocrLabelKey(row.text)) break;
const value = normalizeStructuredValue(key, row.raw, row.text);
if (value) return value;
}
return "";
};
const productLikeScore = (raw, text) => {
if (!raw || !text) return -100;
let score = 0;
const wordCount = text.split(/\s+/).filter(Boolean).length;
if (isOcrUiNoise(text)) score -= 32;
if (/[a-zа-яё]{4,}/i.test(text)) score += 8;
if (/[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё'’.-]{2,}\s+[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё'’.-]{2,}/.test(raw)) score += 10;
if (/\b(?:serum|сыворотк|foam|пенк|cleanser|cleansing|wash|cream|крем|toner|тонер|тоник|baume|balm|бальзам|лосьон|молочко|гель|душ|shampoo|шампун|conditioner|кондиционер|mask|маска|spf|sunscreen|санскрин|флюид|эссенц|мицелляр|lipikar|effaclar|anthelios|cicaplast|hyalu|toleriane)\b/i.test(text)) score += 24;
if (/\b(?:madecassoside|мадекассосид|retinol|ретинол|retinal|ретиналь|niacinamide|ниацинамид|pdrn|calamine|каламин|clay|глин|ceramide|керамид|hyaluronic|гиалур|tea tree|чайное дерево|centella|центелл)\b/i.test(text)) score += 16;
if (/\b(?:la roche posay|mediheal|tocobo|innisfree|mixit|eveline|scinic|vt|skinphoria|depilflax|botavikos|revolution|guerlain|thalgo|bioderma|loreal|l oreal)\b/i.test(text)) score += 14;
if (/\d{4,}/.test(text)) score += 8;
if (/\b\d{5,}\b/.test(text)) score += 28;
if (/^\d{1,2}$/.test(text)) score += 6;
if (/\b(?:podruzhka|podrygka|goldapple|rivegauche|letu|iledebeaute|beautydepot|hollyshop|krasotkapro|parfum[_ -]?lider|proficosmetics)\b/i.test(text)) score += 22;
if (packageSignalTerms(raw).length) score += 14;
if (wordCount >= 5 && wordCount <= 32) score += 10;
else if (wordCount > 32 && wordCount <= 56) score += 3;
else if (wordCount > 56) score -= 10;
if (/^(артикул|бренд|тип|аромат|тип кожи|объем)/i.test(text)) score -= 10;
return score;
};
const longOcrStopPattern = /\b(?:постоплата|соц\.?\s*сертификат|оплата после получения|о товаре|перейти к описанию|доставка и возврат|магазин|фото и видео покупателей|похожее от магазина|все товары из серии|в сравнение|поделиться|отзывов|вопросов)\b/i;
const longOcrAnchorPattern = /\b(?:la\s*roche[-\s]?posay|mediheal|tocobo|innisfree|mixit|eveline|scinic|skinphoria|guerlain|bioderma|vichy|avene|loreal|lipikar|upikar|effaclar|anthelios|cicaplast|toleriane|baume|balm|бальзам|липидовосполняющ|syndet|синдет|сыворотк|serum|пенк|foam|cleanser|cleansing|крем|cream|тонер|toner|гель|gel|spf|шампун|shampoo|маска|mask)\b/i;
const longOcrProductSegments = (value) => {
const raw = sanitizeOcrSearchValue(value);
if (!raw || raw.length <= 360) return [];
const out = [];
const seen = new Set();
const addSegment = (segment) => {
let text = sanitizeOcrSearchValue(String(segment || ""));
text = text.split(longOcrStopPattern)[0] || text;
text = text.replace(/\b\d+(?:[.,]\d+)?\s*[•*]\s*\d[\d\s]*(?:отзыв(?:ов|а)?|вопрос(?:ов|а)?)?\b/gi, " ");
text = text.replace(/\s+/g, " ").trim();
if (text.length < 12) return;
if (text.length > 340) text = text.slice(0, 340).replace(/\s+\S*$/, "").trim();
const key = normLookupText(text);
if (!key || seen.has(key)) return;
if (!longOcrAnchorPattern.test(key)) return;
seen.add(key);
out.push(text);
};
raw.split(/[\r\n]+/).forEach(line => {
if (String(line || "").length <= 360) addSegment(line);
});
const words = raw.split(/\s+/).filter(Boolean);
const normalizedWords = words.map(word => normLookupText(word));
normalizedWords.forEach((word, idx) => {
if (!longOcrAnchorPattern.test(word)) return;
const start = Math.max(0, idx - 2);
const end = Math.min(words.length, idx + 36);
addSegment(words.slice(start, end).join(" "));
});
return out.slice(0, 14);
};
const pushLine = (value, origin = "line", index = lineRows.length) => {
const cleaned = cleanOcrValue(value);
if (cleaned) {
lineRows.push({ ...cleaned, origin, index, score: productLikeScore(cleaned.raw, cleaned.text) });
return;
}
longOcrProductSegments(value).forEach((segment, segmentIndex) => {
const segmentCleaned = cleanOcrValue(segment);
if (!segmentCleaned) return;
lineRows.push({ ...segmentCleaned, origin: `${origin}_segment`, index: index + segmentIndex / 100, score: productLikeScore(segmentCleaned.raw, segmentCleaned.text) + 18 });
});
};
(detail.ocr_lines || []).forEach((line, idx) => pushLine(line, "ocr_line", idx));
(detail.recognized_candidates || []).forEach(item => {
pushLine(item && (item.candidate || item.text || item.name), "recognized_candidate", lineRows.length);
});
(detail.candidate_products || []).forEach(item => {
if (isLocalOcrCandidate(item)) return;
pushLine(item && item.candidate, "remote_candidate", lineRows.length);
});
if (/(этой картинки больше нет|картинки больше нет|image no longer available|image not found|no image available)/i.test(normLookupText(lineRows.map(row => row.raw).join(" ")))) return [];
const candidateRows = [];
const addCandidateRow = (raw, baseScore) => {
const cleaned = cleanOcrValue(raw);
if (!cleaned) return;
const score = baseScore + productLikeScore(cleaned.raw, cleaned.text);
if (score < 4) return;
candidateRows.push({ ...cleaned, score });
};
lineRows.forEach(row => addCandidateRow(row.raw, row.score));
const structuredFacts = {};
lineRows.forEach((row, idx) => {
const inline = inlineLabelValue(row.text, row.raw);
if (inline && !structuredFacts[inline.key]) {
const value = normalizeStructuredValue(inline.key, inline.value, normLookupText(inline.value));
if (value) structuredFacts[inline.key] = value;
}
const key = ocrLabelKey(row.text);
if (key && !structuredFacts[key]) {
const value = nextStructuredValue(idx, key);
if (value) structuredFacts[key] = value;
}
});
const hasStandaloneProductLine = lineRows.some((row, idx) => {
const prev = lineRows[idx - 1];
if (!row || isOcrUiNoise(row.text) || isHardWindowNoise(row.text) || ocrLabelKey(row.text)) return false;
if (prev && ocrLabelKey(prev.text)) return false;
const wordCount = row.text.split(/\s+/).filter(Boolean).length;
return wordCount >= 2 && row.score >= 18;
});
const structuredParts = [structuredFacts.brand, structuredFacts.type, structuredFacts.active, structuredFacts.volume_ml].filter(Boolean);
if (structuredFacts.brand && structuredParts.length >= 2) addCandidateRow(structuredParts.join(" "), hasStandaloneProductLine ? 8 : 40);
lineRows.forEach((row, idx) => {
if (isOcrUiNoise(row.text) || isHardWindowNoise(row.text)) return;
const leadTerms = (row.text.match(/[0-9a-zа-яё]+/gi) || []).filter(Boolean);
if (!leadTerms.length || leadTerms.length > 4) return;
if (/^\d+(?:\s+\d+)*$/.test(row.text)) return;
const following = [];
for (const nextRow of lineRows.slice(idx + 1, idx + 8)) {
if (isHardWindowNoise(nextRow.text) || isOcrUiNoise(nextRow.text) || ocrLabelKey(nextRow.text)) continue;
following.push(nextRow);
if (following.length >= 4) break;
}
if (!following.length) return;
const followScore = following.reduce((sum, item) => sum + item.score, 0);
if (followScore < 10 && following.length < 2) return;
const windowParts = following[0] && normLookupText(following[0].raw).startsWith(normLookupText(row.raw))
? following.map(item => item.raw)
: [row.raw, ...following.map(item => item.raw)];
addCandidateRow(windowParts.join(" "), row.score + followScore + 48);
});
lineRows.forEach((row, idx) => {
const prev = lineRows[idx - 1];
const next = lineRows[idx + 1];
const prevOk = prev && !isHardWindowNoise(prev.text);
const rowOk = !isHardWindowNoise(row.text);
const nextOk = next && !isHardWindowNoise(next.text);
const third = lineRows[idx + 2];
const thirdOk = third && !isHardWindowNoise(third.text);
if (prevOk && rowOk && (row.score > 12 || prev.score > 12)) addCandidateRow(`${prev.raw} ${row.raw}`, row.score + prev.score + 8);
if (rowOk && nextOk && (row.score > 12 || next.score > 12)) addCandidateRow(`${row.raw} ${next.raw}`, row.score + next.score + 8);
if (rowOk && nextOk && thirdOk && (row.score > 18 || next.score > 18 || third.score > 18)) {
addCandidateRow(`${row.raw} ${next.raw} ${lineRows[idx + 2].raw}`, row.score + next.score + lineRows[idx + 2].score + 4);
}
for (const size of [4, 5]) {
const windowRows = lineRows.slice(idx, idx + size);
if (windowRows.length !== size) continue;
if (windowRows.some(item => isHardWindowNoise(item.text))) continue;
const windowScore = windowRows.reduce((sum, item) => sum + item.score, 0);
if (windowScore < 34) continue;
addCandidateRow(windowRows.map(item => item.raw).join(" "), windowScore + 2);
}
});
candidateRows.sort((a, b) => b.score - a.score);
const rawCandidates = [];
const seenRaw = new Set();
const addRawCandidate = (raw) => {
const cleaned = cleanOcrValue(raw);
if (!cleaned) return;
const key = cleaned.text;
const exactArticleSignal = /\b(?:артикул|sku)\s+(?:\d{5,}|[0-9a-zа-яё][0-9a-zа-яё\s-]{4,})\b/i.test(key);
if ((isOcrUiNoise(key) || isHardWindowNoise(key)) && !exactArticleSignal) return;
if (!key || seenRaw.has(key)) return;
seenRaw.add(key);
rawCandidates.push(cleaned.raw);
};
candidateRows.slice(0, 18).forEach(row => addRawCandidate(row.raw));
if (rawCandidates.length === 0) lineRows.slice(0, 12).forEach(row => addRawCandidate(row.raw));
const contextParts = candidateRows
.filter(row => row.score >= 18 && !isOcrUiNoise(row.text))
.slice(0, 5)
.map(row => row.raw);
const joinedContext = contextParts.join(" ");
if (contextParts.length >= 2 && normLookupText(joinedContext).length <= 300) addRawCandidate(joinedContext);
const ocrQuerySpecificityScore = (query) => {
const normalizedQuery = normLookupText(query);
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
const generic = new Set(["brand","бренд","type","тип","для","лица","гель","gel","душа","душ","shower","пенка","foam","cleanser","cleansing","wash","мыло","soap","натуральное","natural","парфюмированное","perfumed","ароматом","объем","мл","ml","бьюти","ингредиент"]);
let score = 0;
for (const term of terms) {
if (/^pkg/.test(term) || /^\d{4,}$/.test(term)) score += 70;
else if (/^\d{1,3}$/.test(term)) score += 18;
else if (generic.has(term)) score += 1;
else if (term.length >= 7) score += 22;
else if (term.length >= 4) score += 10;
}
if (/\b(аромат|ароматом|scent|fragrance|мускус|мускуса|нарцисс|лилия|миндаль|бергамот|гардения|вишн|амбр|тонка)\b/i.test(query)) score += 80;
if (/(?:\b(?:для лица|face|foam|cleanser|cleansing)\b|умыван\w*|пенк\w*)/i.test(query)) score += 45;
if (/\b(?:артикул|sku)\s+\d{5,}\b/i.test(normalizedQuery)) score += 900;
else if (/\b(?:артикул|sku)\s+[0-9a-zа-яё][0-9a-zа-яё\s-]{4,}\b/i.test(normalizedQuery)) score += 760;
const attributeSignals = (normalizedQuery.match(/\b(?:бьюти|ингредиент|объем|зона|свойства|кожа|кого|текстура|финиш)\b/g) || []).length;
if (attributeSignals >= 2) score -= Math.min(900, 180 + attributeSignals * 90);
if (/\b\d+\s+\d+\s*[•*]\s*\d[\d\s]*(?:отзыв|отзывов|вопрос|вопросов)?\b/i.test(normalizedQuery)) score -= 140;
return score;
};
const expanded = Array.from(new Set(rawCandidates.map(expandSearchText).filter(Boolean)));
const combined = normLookupText(joinedContext).length <= 300 ? expandSearchText(joinedContext) : "";
return Array.from(new Set([...expanded, combined].filter(Boolean)))
.sort((a, b) => ocrQuerySpecificityScore(b) - ocrQuerySpecificityScore(a))
.slice(0, 8);
}
function ocrSearchQueryMergePenalty(query) {
const text = normLookupText(query || "");
const attributeSignals = (text.match(/\b(?:бьюти|ингредиент|объем|зона|свойства|кожа|кого|текстура|финиш)\b/g) || []).length;
let penalty = 0;
if (attributeSignals >= 2) penalty += Math.min(620, 180 + attributeSignals * 110);
if (/\bбьюти\s+ингредиент\b/i.test(text)) penalty += 220;
if (/\b\d+\s+\d+\s*[•*]\s*\d[\d\s]*(?:отзыв|отзывов|вопрос|вопросов)?\b/i.test(text)) penalty += 160;
return penalty;
}
function localSearchCandidateFromIndexItem(item, query) {
const rawScore = Number(item && item._localSearchScore);
const confidence = localOcrConfidenceForQuery(rawScore, item && item._localSearchMatchRatio, query, item && item._localSearchMatchedTerms, item);
return {
product_id: item.product_id,
source: item.source,
brand: item.brand,
name: item.name,
display_name: item.display_name || item.name,
search_subtitle: item.search_subtitle || "",
image_url: item.image_url,
score: confidence,
search_query: query,
matched_alias: "ocr-query-normalized",
reference_name: "OCR + локальные алиасы",
local_ocr_candidate: true,
product_shard: item.product_shard,
public_search_key: item.public_search_key,
safety_score_10: item.safety_score_10,
};
}
function ocrCandidateEquivalenceTerms(item) {
const signal = normLookupText([
item && item.brand,
item && (item.display_name || item.name),
item && item.search_subtitle,
].filter(Boolean).join(" "));
const generic = new Set([
"для","with","and","the","лицо","лица","тело","тела","кожа","кожи","типов","всех","свойства","зона","кого",
"skin","face","body","care","female","male","all","types","увлажнение","очищение","питание","nourishing",
]);
return signal
.split(/\s+/)
.filter(term => term && term.length >= 3 && !generic.has(term))
.map(term => {
if (term === "upikar") return "lipikar";
if (term === "baume") return "balm";
if (/^сыворот/.test(term) || term === "syvorotka") return "serum";
if (/^глаз/.test(term) || /^век/.test(term)) return "eye";
return term;
});
}
function ocrCandidateBrandKey(item) {
return normLookupText(item && item.brand).replace(/\s+/g, "");
}
function ocrCandidateBrandsCompatible(a, b) {
const brandA = ocrCandidateBrandKey(a);
const brandB = ocrCandidateBrandKey(b);
if (!brandA || !brandB) return false;
if (brandA === brandB) return true;
const shorter = brandA.length <= brandB.length ? brandA : brandB;
const longer = brandA.length <= brandB.length ? brandB : brandA;
return shorter.length >= 5 && longer.endsWith(shorter);
}
function ocrCandidateTypeConflict(a, b) {
const left = normLookupText([a && (a.display_name || a.name), a && a.search_subtitle].filter(Boolean).join(" "));
const right = normLookupText([b && (b.display_name || b.name), b && b.search_subtitle].filter(Boolean).join(" "));
const has = (value, pattern) => pattern.test(value);
const leftBalm = has(left, /\b(baume|balm|бальзам|apm|ap\s*m)\b/i);
const rightBalm = has(right, /\b(baume|balm|бальзам|apm|ap\s*m)\b/i);
const leftWash = has(left, /\b(syndet|синдет|shower|душ|гель|gel|cleanser|cleansing|пенк|умыван)\b/i);
const rightWash = has(right, /\b(syndet|синдет|shower|душ|гель|gel|cleanser|cleansing|пенк|умыван)\b/i);
const leftMist = has(left, /\b(mist|spray|спрей|мист)\b/i);
const rightMist = has(right, /\b(mist|spray|спрей|мист)\b/i);
const leftHairWash = has(left, /\b(conditioner|кондиционер|shampoo|шампун)\b/i);
const rightHairWash = has(right, /\b(conditioner|кондиционер|shampoo|шампун)\b/i);
if ((leftBalm && rightWash && !rightBalm) || (rightBalm && leftWash && !leftBalm)) return true;
if ((leftMist && rightHairWash) || (rightMist && leftHairWash)) return true;
return false;
}
function ocrCandidatesEquivalent(a, b) {
if (!a || !b) return false;
if (a.product_id && b.product_id && a.product_id === b.product_id) return true;
if (a.public_search_key && b.public_search_key && a.public_search_key === b.public_search_key) return true;
if (!ocrCandidateBrandsCompatible(a, b)) return false;
if (ocrCandidateTypeConflict(a, b)) return false;
const termsA = new Set(ocrCandidateEquivalenceTerms(a));
const termsB = new Set(ocrCandidateEquivalenceTerms(b));
const shared = [...termsA].filter(term => termsB.has(term));
const strongShared = shared.filter(term => term.length >= 5 || /^(apm|lipikar|anthelios|effaclar|cicaplast|retinol|niacinamide|pdrn|hyaluron|spf\d*)$/i.test(term));
if (strongShared.length >= 2) return true;
if (shared.length >= 3 && Math.min(termsA.size, termsB.size) <= 7) return true;
return false;
}
function ocrCandidateQualityScore(item, rankScore) {
let score = Number(rankScore) || 0;
if (item && item.safety_score_10 != null) score += 340;
if (item && item.image_url) score += 60;
if (item && item.product_shard) score += 40;
if (item && item.search_subtitle) score += 25;
if (item && item.source === "goldapple" && item.safety_score_10 == null) score -= 320;
return score;
}
function dedupeEquivalentOcrCandidates(candidates, rankById) {
const picked = [];
for (const item of candidates || []) {
const rank = rankById.get(item.product_id) || 0;
const equivalentIndex = picked.findIndex(existing => ocrCandidatesEquivalent(existing, item));
if (equivalentIndex < 0) {
picked.push(item);
continue;
}
const current = picked[equivalentIndex];
if (ocrCandidateQualityScore(item, rank) > ocrCandidateQualityScore(current, rankById.get(current.product_id) || 0)) {
picked[equivalentIndex] = item;
}
}
return picked.sort((a, b) => {
return ocrCandidateQualityScore(b, rankById.get(b.product_id) || 0) - ocrCandidateQualityScore(a, rankById.get(a.product_id) || 0);
});
}
async function enrichDetailWithLocalCandidates(detail, limit = 6) {
const byId = new Map();
const rankById = new Map();
const addCandidate = (candidate, rank) => {
if (!candidate || !candidate.product_id) return;
const prev = rankById.get(candidate.product_id);
if (prev != null && prev >= rank) return;
byId.set(candidate.product_id, candidate);
rankById.set(candidate.product_id, rank);
};
(detail.candidate_products || []).forEach((item, idx) => {
if (isLocalOcrCandidate(item)) return;
const remoteScore = Number(item && item.score);
addCandidate(item, (Number.isFinite(remoteScore) ? remoteScore * 100 : 50) - idx);
});
const queries = collectOcrSearchQueries(detail);
for (const [queryIndex, query] of queries.slice(0, 5).entries()) {
const local = await localPodruzhkaSearch(query, limit * 2);
local.forEach((item, idx) => {
const localScore = Number(item && item._localSearchScore) || 0;
addCandidate(localSearchCandidateFromIndexItem(item, query), 100 + localScore - idx - queryIndex * 80 - ocrSearchQueryMergePenalty(query));
});
}
const sorted = Array.from(byId.values()).sort((a, b) => {
return (rankById.get(b.product_id) || 0) - (rankById.get(a.product_id) || 0);
});
const deduped = dedupeEquivalentOcrCandidates(sorted, rankById);
return { ...detail, candidate_products: deduped.slice(0, limit), local_search_queries: queries };
}
async function renderAmbig() {
const body = $("#ambigBody");
body.setAttribute("aria-busy", "true");
body.innerHTML = 'Подбираем товары
';
const rawDetail = ambigLastDetail || {};
let detail;
let candidates;
try {
detail = await enrichDetailWithLocalCandidates(rawDetail, 6);
candidates = (await enrichCandidatePreviews(detail.candidate_products || []))
.filter(c => c && c.public_search_ready !== false);
} catch (error) {
body.removeAttribute("aria-busy");
renderNotFoundData({ ...rawDetail, candidate_lookup_error: true });
navigate("#nf");
return;
}
const debugDetail = { ...detail, candidate_products: candidates };
body.removeAttribute("aria-busy");
if (candidates.length === 0) {
renderNotFoundData(debugDetail);
navigate("#nf");
return;
} else {
$("#ambigHeading").textContent = "Несколько похожих средств";
$("#ambigLead").textContent = "Выбери своё.";
body.innerHTML = candidates.slice(0, 3).map((c) => {
const img = candidateImageUrl(c);
const name = c.display_name || c.name || '';
const title = [c.brand, name].filter(Boolean).join(" — ");
const description = candidateProductDescription(c) || localizeSearchSubtitle(c.search_subtitle || "");
return `
${imageTag(img, '')}
${escapeHtml(title || name)}
${description ? `
${escapeHtml(description)}
` : ''}
`;
}).join('') + `
`;
}
trackEvent("scan_candidates_shown", { candidate_count: Math.min(3, candidates.length) });
renderDebug($("#debugAmbig"), debugDetail);
}
// ========== render: not-found ==========
let nfLastDetail = null;
function renderNotFoundData(detail) { nfLastDetail = detail; }
function buildOcrSearchSeed(lines) {
return collectOcrSearchQueries({ ocr_lines: lines || [] })[0] || "";
}
async function renderNotFound() {
const detail = await enrichDetailWithLocalCandidates(nfLastDetail || {}, 6);
nfLastDetail = detail;
const ocr = (detail.ocr_lines || []).slice(0, 12);
const emptyOcrHtml = ocr.length === 0 ? scanNoCandidateHtml(detail) : '';
const notFoundHeading = detail.scan_timeout
? "Распознавание не завершилось"
: ocr.length === 0 ? "Текст не распознан" : "Товар не найден";
$("#nfHeading").textContent = notFoundHeading;
setPageMeta(`${notFoundHeading} — Составика`, detail.scan_timeout
? "Фото сохранено: можно повторить распознавание или найти товар по названию."
: ocr.length === 0
? "Текст на фото не удалось прочитать. Можно выбрать другое фото или перейти к поиску."
: "Товар не найден по тексту с упаковки. Можно продолжить поиск вручную.");
$("#nfOcrLead").hidden = ocr.length === 0;
$("#nfOcr").hidden = ocr.length === 0;
$("#nfOcr").innerHTML = ocr.length === 0
? ''
: ocr.map(l => `${escapeHtml(l)}`).join('');
const seed = (detail.local_search_queries || [])[0] || buildOcrSearchSeed(ocr);
const hints = await enrichCandidatePreviews((detail.candidate_products || []).slice(0, 6));
const missingExact = missingExactLookupHtml(seed);
$("#nfHints").innerHTML = emptyOcrHtml || missingExact || (hints.length === 0 ? '' : `
Возможно одно из этих
${renderSearchRows(hints)}
`);
$("#nfSearchBtn").onclick = () => {
if (seed) sessionStorage.setItem("beautyscan.search_seed", seed);
navigate("#search");
};
trackEvent("scan_not_found_shown", { ocr_line_count: ocr.length, candidate_hint_count: hints.length, has_search_seed: !!seed });
renderDebug($("#debugNf"), detail);
}
// ========== render: error ==========
function renderError(msg, retryAction = null) {
$("#errBody").textContent = msg || 'Ошибка.';
lastRetryAction = typeof retryAction === "function" ? retryAction : null;
$("#errRetryBtn").textContent = lastRetryAction ? "Повторить" : "Вернуться к сканеру";
$("#errRetryBtn").onclick = async () => {
if (lastRetryAction) {
navigate("#scan");
await lastRetryAction();
return;
}
navigate("#scan");
};
renderDebug($("#debugErr"), { lastResult: lastScanResult });
}
// ========== render: history ==========
async function renderHistory() {
const release = await currentFrontendRelease();
const list = loadHistory().filter(entry => {
if (!entry || entry.publicSearchReady === false) return false;
if (!entry.releaseId) return release.release_id === "legacy";
return entry.releaseId === release.release_id && (entry.sourceRunId || null) === (release.source_run_id || null);
});
const body = $("#historyBody");
if (list.length === 0) {
body.innerHTML = 'История пока пуста
Открытые товары появятся здесь.
Найти товар ';
return;
}
const query = normLookupText($("#historySearchInput").value || "");
const filtered = list.filter(entry => {
if (historyFilter !== "all" && entry.origin !== historyFilter) return false;
if (!query) return true;
return normLookupText([entry.brand, entry.name, entry.productId].filter(Boolean).join(" ")).includes(query);
});
if (filtered.length === 0) {
body.innerHTML = 'В истории ничего не найдено
Измени запрос или выбери другой тип истории.
';
return;
}
const groups = groupByDay(filtered);
body.innerHTML = Object.entries(groups).map(([label, items]) => `
${escapeHtml(label)}
${items.map(e => {
const historyName = cleanPublicProductName(e.name || '');
return `
`}).join('')}
`).join('');
$$('[data-history-delete]').forEach(button => {
button.addEventListener('click', () => {
const productId = button.dataset.historyDelete;
const next = loadHistory().filter(entry => entry.productId !== productId);
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(next)); } catch {}
void renderHistory();
showToast("Удалено из истории");
trackEvent("history_item_deleted");
});
});
}
function groupByDay(list) {
const groups = {};
const now = Date.now();
list.forEach(e => {
const ageDays = Math.floor((now - e.ts) / 86400000);
let label;
if (ageDays === 0) label = 'сегодня';
else if (ageDays === 1) label = 'вчера';
else if (ageDays < 7) label = `${ageDays} дн. назад`;
else if (ageDays < 30) label = 'в этом месяце';
else label = 'давно';
(groups[label] = groups[label] || []).push(e);
});
return groups;
}
// ========== render: search ==========
function localizeSearchSubtitle(value) {
let text = String(value || "").trim();
if (!text) return "";
const replacements = [
["male & female", "для всех"], ["for all skin types", "для всех типов кожи"],
["for sensitive skin", "для чувствительной кожи"], ["for dry skin", "для сухой кожи"],
["for oily skin", "для жирной кожи"], ["for combination skin", "для комбинированной кожи"],
["nourishing", "питание"], ["moisturizing", "увлажнение"], ["cleansing", "очищение"],
["irritation care", "уход за раздражённой кожей"], ["regeneration", "восстановление"],
["hyaluronic acid", "гиалуроновая кислота"], ["niacinamide", "ниацинамид"],
["vitamin c", "витамин С"], ["tea tree", "чайное дерево"],
["after sun", "после солнца"], ["natural", "натуральный"], ["female", "для женщин"], ["male", "для мужчин"],
];
for (const [from, to] of replacements) text = text.replace(new RegExp(from, "gi"), to);
return text.split("·").map(part => part.trim()).filter(part => {
const scrubbed = part.replace(/\b(?:SPF|UVA|UVB|UV|PDRN|AHA|BHA|PHA|BB|CC)\b/gi, "");
return !/[A-Za-z]/.test(scrubbed);
}).join(" · ");
}
function searchSubtitleHtml(value) {
const subtitle = localizeSearchSubtitle(value);
return subtitle ? `${escapeHtml(subtitle)}
` : "";
}
function renderSearchRows(items) {
return (items || []).map(it => {
const displayName = cleanPublicProductName(it.display_name || it.name || "");
const subtitle = localizeSearchSubtitle(it.search_subtitle || "");
const sourceName = SOURCE_DISPLAY_NAMES[it.source] || it.source || "";
const meta = [subtitle, sourceName].filter(Boolean).join(" · ");
const score = score10FromAny(it.safety_score_10);
return `
${imageTag(it.image_url, '')}
${it.brand ? `
${escapeHtml(it.brand)}
` : ''}
${escapeHtml(displayName)}
${meta ? `
${escapeHtml(meta)}
` : ''}
${score != null ? `${escapeHtml(fmtScoreNum(score))}` : ''}→
`;
}).join('');
}
function setupSearch() {
const input = $("#searchInput");
const body = $("#searchBody");
const controls = $("#searchControls");
const sourceFilter = $("#searchSourceFilter");
const scoreFilter = $("#searchScoreFilter");
const seed = sessionStorage.getItem("beautyscan.search_seed");
if (seed) {
input.value = seed;
sessionStorage.removeItem("beautyscan.search_seed");
}
let timer = null;
let requestSeq = 0;
let currentItems = [];
let visibleCount = 20;
let renderSeq = 0;
const renderEmpty = () => {
controls.hidden = true;
body.innerHTML = `
Найди товар в каталоге
Можно ввести название, бренд, артикул или вставить ссылку магазина.
`;
};
const filteredItems = () => {
const source = sourceFilter.value;
const minScore = Number(scoreFilter.value || 0);
return currentItems.filter(item => {
if (source && item.source !== source) return false;
const score = score10FromAny(item.safety_score_10);
if (minScore && (score == null || score < minScore)) return false;
return true;
});
};
const renderCurrent = async () => {
const seq = ++renderSeq;
const filtered = filteredItems();
const visibleRows = filtered.slice(0, visibleCount);
controls.hidden = currentItems.length === 0;
body.innerHTML = ``;
const shown = await enrichCandidatePreviews(visibleRows);
if (seq !== renderSeq) return;
const enrichedById = new Map(shown.map(item => [item.product_id, item]));
currentItems = currentItems.map(item => enrichedById.get(item.product_id) || item);
body.innerHTML = `
Лучшие совпадения: ${filtered.length}
${renderSearchRows(shown)}
${shown.length < filtered.length ? `` : ''}
`;
const more = $("#searchMoreBtn");
if (more) more.onclick = () => { visibleCount += 20; void renderCurrent(); };
announce(`Показано товаров: ${shown.length}`);
};
const run = async () => {
renderSeq += 1;
const q = input.value.trim();
const seq = ++requestSeq;
if (!q) { currentItems = []; renderEmpty(); return; }
visibleCount = 20;
controls.hidden = true;
body.innerHTML = ``;
announce("Ищем по каталогу");
trackEvent("search_started", { query_length: q.length, exact_intent: parseSearchIntent(q).exactProductIds.size > 0 });
try {
const localLimit = 100;
const started = performance.now();
const localItems = await localPodruzhkaSearch(q, localLimit);
if (seq !== requestSeq) return;
addNetworkLog({
method: "LOCAL",
path: `/search?q=${q}&limit=${localLimit}`,
status: 200,
ms: Math.round(performance.now() - started),
request: null,
response: { results: localItems },
at: new Date().toISOString(),
});
const missingExact = missingExactLookupHtml(q);
if (localItems.length === 0) {
currentItems = [];
controls.hidden = true;
body.innerHTML = missingExact || `
По этому запросу товаров нет
Проверь название, введи артикул или попробуй только бренд и линейку.
Распознать по фото
`;
announce("Товары не найдены");
trackEvent("search_results", { result_count: 0, search_ms: Math.round(performance.now() - started) });
return;
}
currentItems = localItems;
const sources = Array.from(new Set(localItems.map(item => item.source).filter(Boolean))).sort();
sourceFilter.innerHTML = '' + sources.map(source => ``).join('');
await renderCurrent();
trackEvent("search_results", { result_count: localItems.length, search_ms: Math.round(performance.now() - started) });
} catch (err) {
if (seq !== requestSeq) return;
controls.hidden = true;
body.innerHTML = 'Поиск временно недоступен
Попробуй ещё раз или распознай товар по фото.
';
const retry = $("#retrySearchBtn");
if (retry) retry.onclick = run;
announce("Поиск временно недоступен");
trackEvent("search_failed", { error: String(err && err.message || err || "search_failed").slice(0, 120) });
}
};
activateSearchRoute = () => {
const pendingSeed = sessionStorage.getItem("beautyscan.search_seed");
if (pendingSeed) {
input.value = pendingSeed;
sessionStorage.removeItem("beautyscan.search_seed");
void run();
}
input.focus();
};
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(run, SEARCH_DEBOUNCE_MS);
});
sourceFilter.addEventListener("change", () => { visibleCount = 20; void renderCurrent(); });
scoreFilter.addEventListener("change", () => { visibleCount = 20; void renderCurrent(); });
body.addEventListener("click", event => {
const button = event.target.closest("[data-search-example]");
if (!button) return;
input.value = button.dataset.searchExample || "";
run();
});
if (seed) run();
else renderEmpty();
}
// ========== debug ==========
function debugMetric(label, value) {
return `${escapeHtml(label)}${escapeHtml(value == null || value === '' ? '—' : value)}
`;
}
function compactJson(value) {
try { return JSON.stringify(value, null, 2); } catch { return String(value); }
}
async function refreshHealth() {
if (!DEBUG) return;
addAppLog("info", "Проверка /health...");
try {
const res = await apiGet("/health");
healthPayload = res.payload || null;
addAppLog(res.status === 200 ? "info" : "warn", `Проверка /health: HTTP ${res.status}`);
} catch (err) {
healthPayload = null;
addAppLog("warn", "Локальный API /health недоступен.");
}
updateAdminDrawer();
}
function updateAdminDrawer() {
if (!DEBUG) return;
const payload = lastDebugPayload || {};
const detail = payload.detail || payload || {};
const health = healthPayload || {};
const coverage = health.coverage || {};
const lastNet = networkLog[0] || {};
const metrics = $("#adminMetrics");
if (metrics) {
metrics.innerHTML = [
debugMetric("Статус API", health.status || "—"),
debugMetric("Версия score", health.score_version || coverage.score_version || "—"),
debugMetric("Размер базы", health.products_count != null ? `${health.products_count} продуктов / ${health.ingredients_count} ингредиентов` : "—"),
debugMetric("Составы in-scope", coverage.composition_in_scope_products),
debugMetric("C3+/C5 in-scope", coverage.composition_in_scope_c3plus != null ? `${coverage.composition_in_scope_c3plus} / ${coverage.composition_in_scope_c5 || 0}` : "—"),
debugMetric("Последний endpoint", lastNet.path),
debugMetric("Последний HTTP", lastNet.status),
debugMetric("Время ответа", lastNet.ms != null ? `${lastNet.ms} мс` : "—"),
].join("");
}
const solver = $("#adminSolver");
if (solver) {
const breakdown = detail.solver_breakdown || payload.solver_breakdown || {};
solver.innerHTML = [
debugMetric("Статус сканера", detail.status || payload.status || "—"),
debugMetric("Mode", detail.solver_mode || payload.solver_mode || payload.resolution_mode || "—"),
debugMetric("Solver", (detail.solver_ms ?? payload.solver_ms) != null ? `${detail.solver_ms ?? payload.solver_ms} мс` : "—"),
debugMetric("Retrieval", breakdown.retrieval_ms != null ? `${breakdown.retrieval_ms} мс` : "—"),
debugMetric("Rerank", breakdown.rerank_ms != null ? `${breakdown.rerank_ms} мс` : "—"),
debugMetric("ID анализа", detail.analysis_id || payload.analysis_id || "—"),
].join("");
}
const ocr = detail.ocr_lines || payload.ocr_lines || [];
const recognized = detail.recognized_candidates || payload.recognized_candidates || [];
const ocrNode = $("#adminOcrLines");
const recNode = $("#adminRecognized");
const rawNode = $("#adminRawJson");
const logNode = $("#adminLogs");
const netNode = $("#adminNetwork");
if (ocrNode) ocrNode.textContent = ocr.length ? ocr.join("\n") : "—";
if (recNode) recNode.textContent = recognized.length ? recognized.join("\n") : "—";
if (rawNode) rawNode.textContent = compactJson(payload && Object.keys(payload).length ? payload : health);
if (logNode) logNode.textContent = appLogs.length ? appLogs.join("\n") : "—";
if (netNode) {
netNode.innerHTML = networkLog.length ? networkLog.map(n => `
${escapeHtml(n.method)} ${escapeHtml(n.path)} | HTTP ${escapeHtml(n.status || '—')} | ${escapeHtml(n.ms || 0)} мс
${escapeHtml(n.at || '')}
`).join("") : "—";
}
}
function renderDebug(node, payload) {
if (!DEBUG || !node) return;
lastDebugPayload = payload || lastDebugPayload;
updateAdminDrawer();
try {
node.innerHTML = `
raw payload
${escapeHtml(JSON.stringify(payload, null, 2))}
`;
} catch (e) { node.textContent = String(e); }
}
// ========== wire shutter / file / paste / share ==========
$("#shutterBtn").addEventListener("click", async () => {
if (!mediaStream) {
cameraOptedIn = true;
mediaPermissionDenied = false;
await ensureCamera();
return;
}
if (scanPhotos.length > 0 && !mediaStream && scanPhotos.length < MAX_SCAN_PHOTOS) {
cameraPausedForBatch = false;
$("#vfHint").textContent = "Наведи на другую сторону";
await ensureCamera();
return;
}
const b64 = captureFromVideo();
if (!b64) {
$("#vfHint").textContent = "не получилось — попробуй снова";
return;
}
addScanPhoto(b64, "camera");
});
$("#quickPhotoSearchBtn").addEventListener("click", () => $("#fileInput").click());
$("#fileInput").addEventListener("change", async (ev) => {
const files = Array.from(ev.target.files || []).slice(0, MAX_SCAN_PHOTOS - scanPhotos.length);
if (files.length === 0) return;
try {
for (const file of files) {
const b64 = await fileToBase64(file);
addScanPhoto(b64, "gallery");
}
if (parseHash().route === "cam-deny") navigate("#scan");
} catch (err) { renderError("Не удалось прочитать файл.", () => $("#fileInput").click()); navigate("#err"); }
ev.target.value = "";
});
$("#pasteBtn").addEventListener("click", async () => {
try {
const file = await pasteFromClipboard();
const b64 = await fileToBase64(file);
addScanPhoto(b64, "clipboard");
} catch (err) {
$("#vfHint").textContent = "в буфере нет картинки";
}
});
$("#recognizePhotosBtn").addEventListener("click", runScanFromPhotos);
$("#addSideBtn").addEventListener("click", () => {
if (scanPhotos.length >= MAX_SCAN_PHOTOS) return;
cameraOptedIn = true;
cameraPausedForBatch = false;
$("#vfHint").textContent = "Наведи на другую сторону";
ensureCamera();
});
$("#clearPhotosBtn").addEventListener("click", () => clearScanPhotos({ restartCamera: true }));
$("#loadingCancelBtn").addEventListener("click", () => {
if (scanAbortController && !scanAbortController.signal.aborted) {
scanAbortReason = "user_cancelled";
scanAbortController.abort();
}
showLoading(false);
showToast("Распознавание отменено");
navigate("#scan");
});
$("#camDenyFile").addEventListener("click", () => $("#fileInput").click());
$("#camDenyPaste").addEventListener("click", async () => {
try {
const file = await pasteFromClipboard();
const b64 = await fileToBase64(file);
addScanPhoto(b64, "clipboard");
navigate("#scan");
} catch (err) {
$("#camDenyReason").textContent = "В буфере нет картинки.";
}
});
$("#camDenyRetry").addEventListener("click", () => {
cameraOptedIn = true;
mediaPermissionDenied = false;
navigate("#scan");
});
document.addEventListener("keydown", event => {
if (event.key === "Tab" && loadingOverlay.classList.contains("on")) {
const cancel = $("#loadingCancelBtn");
const target = cancel.hidden ? loadingOverlay : cancel;
event.preventDefault();
target.focus({ preventScroll: true });
return;
}
if (event.key === "Escape" && loadingOverlay.classList.contains("on") && !$("#loadingCancelBtn").hidden) {
event.preventDefault();
$("#loadingCancelBtn").click();
}
});
$(".spa-skip-link").addEventListener("click", event => {
event.preventDefault();
const currentScreen = document.querySelector(".spa-screen.on");
const target = currentScreen && (currentScreen.querySelector("h1") || currentScreen);
if (!target) return;
target.setAttribute("tabindex", "-1");
target.focus({ preventScroll: false });
});
async function shareCurrentPage(button) {
const url = location.href;
try {
if (navigator.share) {
await navigator.share({ title: document.title, url });
showToast("Ссылка отправлена");
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(url);
else {
const field = document.createElement("textarea");
field.value = url;
field.setAttribute("readonly", "");
field.style.position = "fixed";
field.style.opacity = "0";
document.body.appendChild(field);
field.select();
if (!document.execCommand("copy")) throw new Error("copy_failed");
field.remove();
}
button.textContent = "Скопировано";
showToast("Ссылка скопирована");
setTimeout(() => { button.textContent = "Поделиться"; }, 1600);
} catch (err) {
showToast("Не удалось поделиться ссылкой", "bad");
button.textContent = "Повторить";
setTimeout(() => { button.textContent = "Поделиться"; }, 2200);
}
}
$("#shareProductBtn").addEventListener("click", () => shareCurrentPage($("#shareProductBtn")));
$("#shareIngredientBtn").addEventListener("click", () => shareCurrentPage($("#shareIngredientBtn")));
const prefetchLinkedRoute = event => {
const productLink = event.target.closest && event.target.closest("[data-prefetch-product]");
if (productLink) void prefetchProductById(productLink.dataset.prefetchProduct);
const ingredientLink = event.target.closest && event.target.closest("[data-prefetch-ingredient]");
if (ingredientLink) void prefetchIngredientByKey(ingredientLink.dataset.prefetchIngredient);
};
document.addEventListener("pointerover", prefetchLinkedRoute, { passive: true });
document.addEventListener("focusin", prefetchLinkedRoute);
document.addEventListener("click", event => {
const offer = event.target.closest("[data-offer-link]");
if (!offer) return;
trackEvent("offer_opened", {
product_id: offer.dataset.offerLink || "",
source: offer.dataset.offerSource || "",
});
});
$("#clearHistoryBtn").addEventListener("click", () => {
if (!loadHistory().length) return;
if (!window.confirm("Очистить историю открытых товаров на этом устройстве?")) return;
localStorage.removeItem(HISTORY_KEY);
void renderHistory();
showToast("История очищена");
trackEvent("history_cleared");
});
$("#historySearchInput").addEventListener("input", () => void renderHistory());
$$('[data-history-filter]').forEach(button => {
button.addEventListener('click', () => {
historyFilter = button.dataset.historyFilter || 'all';
$$('[data-history-filter]').forEach(item => {
const active = item === button;
item.classList.toggle('on', active);
item.setAttribute('aria-pressed', active ? 'true' : 'false');
});
void renderHistory();
});
});
if (DEBUG) {
$("#adminDrawer").inert = true;
$("#debugFab").addEventListener("click", () => {
$("#adminDrawer").hidden = false;
$("#adminDrawer").inert = false;
$("#adminDrawer").classList.add("open");
$("#adminDrawer").setAttribute("aria-hidden", "false");
updateAdminDrawer();
});
$("#adminClose").addEventListener("click", () => {
$("#adminDrawer").classList.remove("open");
$("#adminDrawer").setAttribute("aria-hidden", "true");
$("#adminDrawer").inert = true;
setTimeout(() => { $("#adminDrawer").hidden = true; }, 230);
$("#debugFab").focus();
});
$("#adminHealthBtn").addEventListener("click", refreshHealth);
document.addEventListener("keydown", event => {
if (event.key === "Escape" && $("#adminDrawer").classList.contains("open")) $("#adminClose").click();
});
}
// ========== init ==========
setupSearch();
if (DEBUG) {
addAppLog("info", "Готово к анализу");
refreshHealth();
}
if (!location.hash) location.hash = "#scan";
render();