// ========== 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} ${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 `${escapeHtml(altText)}`; } 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 = `${escapeHtml(productName || p.name || p.brand || 'Фото товара')}`; const galleryHtml = gallery.length > 1 ? `` : ''; 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 ? '→' : ''} `; }).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 ? '' : `

Где купить

${prices.map(pr => { const storeId = pr.store || pr.source || ''; const storeName = SOURCE_DISPLAY_NAMES[storeId] || storeId; return ` ${escapeHtml(storeName)}${formatDateShort(pr.updated_at || p.updated_at) ? `· проверено ${escapeHtml(formatDateShort(pr.updated_at || p.updated_at))}` : ''} ${escapeHtml(formatOfferPrice(pr.price, pr.currency))} `}).join('')}
`; const altsHtml = alternatives.length === 0 ? '' : `

Похожие

${alternatives.map(a => ` ${imageTag(a.image_url, '', a.image_gallery || [])}
${a.brand ? `
${escapeHtml(a.brand)}
` : ''}
${escapeHtml(a.display_name || (a.source === 'goldapple' ? titleCaseLatinProductName(a.name || '') : (a.name || '')))}
${searchSubtitleHtml(a.search_subtitle)}
`).join('')}
`; 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 = `
${fallbackScore != null ? `${escapeHtml(fmtScoreNum(fallbackScore))}как считаем` : ''}

${escapeHtml(cachedItem && (cachedItem.inci || cachedItem.inci_norm) || inciNorm)}

Описание этого компонента пока обновляется.

${cachedItem ? `
${escapeHtml(ingredientFunctionLabels(cachedItem).join(', '))}
` : ''} ${route.productId ? 'К составу' : 'Найти товары'}
`; 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 ? '' : `

Встречается в

${products.slice(0, 5).map(pr => ` ${imageTag(pr.image_url, '')}
${pr.brand ? `
${escapeHtml(pr.brand)}
` : ''}
${escapeHtml(pr.name || '')}
`).join('')} ${products.length > 5 ? `
Показано 5 из ${products.length}
` : ''}
`; 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 `
${imageTag(e.imageUrl, '')}
${e.brand ? `
${escapeHtml(e.brand)}
` : ''}
${escapeHtml(historyName)}
${e.origin === 'scan' ? 'распознано по фото' : e.origin === 'search' ? 'открыто из поиска' : 'просмотрено'}
${score10FromAny(e.safetyScore10 ?? e.safetyScore) != null ? `${fmtScoreNum(score10FromAny(e.safetyScore10 ?? e.safetyScore))}` : 'Без оценки'}
`}).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();