From 77c597dfd8f5bf426cee7ef226967e5a4c80dadf Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Tue, 11 Aug 2026 19:11:04 +0300 Subject: [PATCH] Improve market audit UI and capture diagnostics --- .env.local.example | 1 - README.md | 3 +- index.html | 20 +++- src/main.js | 99 +++++++++++++++--- src/parser.js | 29 ++++++ src/style.css | 240 +++++++++++++++++++++++++++++++------------- test/parser.test.js | 21 +++- vite.config.js | 3 +- 8 files changed, 316 insertions(+), 100 deletions(-) diff --git a/.env.local.example b/.env.local.example index 63557a1..6231255 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,2 +1 @@ L2_MARKET_IMPORT_URL=https://l2.dokops.ru/api/l2/market/import -L2_MARKET_IMPORT_TOKEN= diff --git a/README.md b/README.md index 78446dd..5315f62 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,6 @@ ```bash npm install cp .env.local.example .env.local -# Сгенерируй один случайный токен, сразу сохрани его и в .env.local, -# и как новый Gitea Secret HOME_SERVICE_L2_MARKET_IMPORT_TOKEN. npm run dev ``` @@ -94,6 +92,7 @@ npm run dev В результате показывается последний обработанный кадр с рамками и каждый этап отдельно: - зелёная рамка означает, что шаблон прошёл заданный порог; +- жёлтая показывает найденную область предмета, которую OCR ещё не разобрал; - красная показывает лучшее совпадение, которое не прошло порог; - синяя показывает вычисленную область следующего шага; - «Пропущено» означает, что предыдущий обязательный этап не прошёл; diff --git a/index.html b/index.html index 8371968..525ac30 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,17 @@ - L2 Market Parser + Аудит рынка · L2
-
-

Локальный прототип

-

L2 Market Parser

+
+ +
+

Interlude · рынок

+

Аудит рынка

+
@@ -45,6 +48,13 @@
+
@@ -67,7 +77,7 @@ -
+
Как связаны области калибровки
    diff --git a/src/main.js b/src/main.js index a385ddb..824e5f8 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,7 @@ import "./style.css"; import { combineRects, + displayCaptureIssue, isCalibrationReady, isRectInside, parseStoreHeaderText, @@ -17,7 +18,10 @@ import { import { createMarketOutbox } from "./market-outbox.js"; const STORAGE_KEY = "l2-market-parser.calibration.v4"; -const embedded = location.pathname.startsWith("/l2/market-audit/"); +const appHosted = location.pathname.startsWith("/l2/market-audit/"); +const embedded = new URLSearchParams(location.search).has("embedded"); +document.documentElement.dataset.theme = + localStorage.getItem("theme") || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); if (embedded) document.documentElement.classList.add("is-embedded"); const regionNames = { saleAnchor: "Маркер магазина", @@ -63,7 +67,7 @@ const state = { results: [], collecting: false, }; -const marketOutbox = createMarketOutbox(embedded ? "/api/l2/market/import" : undefined); +const marketOutbox = createMarketOutbox(appHosted ? "/api/l2/market/import" : undefined); const catalogMatches = new Map(); const elements = { @@ -72,6 +76,10 @@ const elements = { nav: [...document.querySelectorAll("[data-view]")], views: [...document.querySelectorAll(".view")], addSource: document.querySelector("#add-source"), + captureRequirement: document.querySelector("#capture-requirement"), + captureRequirementTitle: document.querySelector("#capture-requirement-title"), + captureRequirementText: document.querySelector("#capture-requirement-text"), + captureSecureLink: document.querySelector("#capture-secure-link"), sourceList: document.querySelector("#source-list"), calibrationSource: document.querySelector("#calibration-source"), captureReference: document.querySelector("#capture-reference"), @@ -105,6 +113,24 @@ function setSyncStatus(message, tone = "neutral") { elements.syncStatus.dataset.tone = tone; } +function currentDisplayCaptureIssue() { + return displayCaptureIssue({ + secure: window.isSecureContext, + supported: Boolean(navigator.mediaDevices?.getDisplayMedia), + topLevel: window.self === window.top, + }); +} + +function renderCaptureRequirement() { + const issue = currentDisplayCaptureIssue(); + elements.addSource.disabled = Boolean(issue); + elements.captureRequirement.hidden = !issue; + if (!issue) return; + elements.captureRequirementTitle.textContent = issue.title; + elements.captureRequirementText.textContent = issue.detail; + elements.captureSecureLink.hidden = window.isSecureContext; +} + function showView(name) { elements.nav.forEach((button) => { const active = button.dataset.view === name; @@ -142,8 +168,10 @@ async function waitForVideo(video) { } async function addSource() { - if (!navigator.mediaDevices?.getDisplayMedia) { - setStatus("Браузер не поддерживает захват экрана", "error"); + const issue = currentDisplayCaptureIssue(); + if (issue) { + renderCaptureRequirement(); + setStatus(issue.title, "error"); return; } @@ -182,7 +210,7 @@ async function addSource() { setStatus(`Добавлено: ${source.name}`, "success"); } catch (error) { if (error.name === "NotAllowedError") { - setStatus("Захват отменён или не разрешён", "error"); + setStatus("Захват отменён или запрещён. Проверь разрешение браузера на запись экрана", "error"); } else { setStatus(error.message || "Не удалось добавить окно", "error"); } @@ -213,6 +241,28 @@ function sourceEmptyState() { return empty; } +function drawSourceOverlay(source, result) { + const canvas = source.overlayCanvas; + const status = source.overlayStatus; + if (!canvas || !status) return; + + canvas.width = source.video.videoWidth || 1; + canvas.height = source.video.videoHeight || 1; + const context = canvas.getContext("2d"); + context.clearRect(0, 0, canvas.width, canvas.height); + context.lineWidth = Math.max(3, canvas.width / 500); + result?.diagnostics?.boxes.forEach(({ rect, status: boxStatus }) => { + context.strokeStyle = boxColor(boxStatus); + context.strokeRect(rect.x, rect.y, rect.width, rect.height); + }); + + const itemBox = result?.diagnostics?.boxes.find(({ label }) => label === "Название + цена"); + status.innerHTML = ` + ${result?.saleFound ? "Магазин найден" : "Ищу магазин"} + ${itemBox?.status === "found" ? "Предмет распознан" : "Предмет ожидает"} + `; +} + function renderSources() { elements.sourceList.replaceChildren(); @@ -232,7 +282,16 @@ function renderSources() { video.muted = true; video.playsInline = true; video.srcObject = source.stream; - preview.append(video); + const overlay = document.createElement("canvas"); + overlay.className = "source-row__overlay"; + overlay.setAttribute("aria-hidden", "true"); + const overlayStatus = document.createElement("div"); + overlayStatus.className = "source-row__overlay-status"; + overlayStatus.setAttribute("aria-label", "Статус распознавания"); + source.overlayCanvas = overlay; + source.overlayStatus = overlayStatus; + preview.append(video, overlay, overlayStatus); + drawSourceOverlay(source, state.results.find((result) => result.sourceId === source.id)); const info = document.createElement("div"); info.className = "source-row__info"; @@ -528,6 +587,13 @@ function cropPreview(frame, rect) { : null; } +function boxColor(status) { + if (status === "found") return "oklch(0.72 0.16 155)"; + if (status === "pending") return "oklch(0.8 0.16 85)"; + if (status === "not-found") return "oklch(0.68 0.18 25)"; + return "oklch(0.72 0.13 250)"; +} + function annotatedPreview(frame, boxes) { const scale = Math.min(1, 760 / frame.width); const preview = document.createElement("canvas"); @@ -538,7 +604,7 @@ function annotatedPreview(frame, boxes) { context.font = "12px system-ui"; boxes.forEach(({ rect, label, status }) => { - const color = status === "found" ? "#42c884" : status === "not-found" ? "#ef6a67" : "#6d9ff2"; + const color = boxColor(status); const x = rect.x * scale; const y = rect.y * scale; const width = rect.width * scale; @@ -550,7 +616,7 @@ function annotatedPreview(frame, boxes) { const labelWidth = context.measureText(label).width + 10; const labelY = Math.max(0, y - 20); context.fillRect(x, labelY, labelWidth, 20); - context.fillStyle = "#f7f9fc"; + context.fillStyle = "oklch(0.98 0.004 250)"; context.fillText(label, x + 5, labelY + 14); }); @@ -835,7 +901,7 @@ async function saveCalibration() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.calibration)); - if (embedded) { + if (appHosted) { const response = await fetch("/api/l2/market/calibration", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -847,12 +913,12 @@ async function saveCalibration() { setStatus("Калибровка сохранена в браузере", "success"); } } catch { - setStatus(embedded ? "Сервер не смог сохранить калибровку" : "Браузер не смог сохранить калибровку", "error"); + setStatus(appHosted ? "Сервер не смог сохранить калибровку" : "Браузер не смог сохранить калибровку", "error"); } } async function loadSharedCalibration() { - if (!embedded) return; + if (!appHosted) return; try { const response = await fetch("/api/l2/market/calibration", { cache: "no-store" }); if (!response.ok) throw new Error(); @@ -923,7 +989,7 @@ function appendDiagnostics(section, result) { preview.append(image); } const caption = document.createElement("p"); - caption.textContent = "Зелёная рамка найдена, красная не прошла порог, синяя показывает область следующего шага."; + caption.textContent = "Жёлтая рамка ожидает распознавания, зелёная найдена, красная не прошла порог, синяя показывает область следующего шага."; preview.append(caption); const stages = document.createElement("div"); @@ -1297,7 +1363,7 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source const nameRect = resolveFieldRect(tooltipRect, state.calibration.itemNameRegion); const priceRect = resolveFieldRect(tooltipRect, state.calibration.itemPriceRegion); const itemRect = combineRects(nameRect, priceRect); - const itemBox = { rect: itemRect, label: "Название + цена", status: "info" }; + const itemBox = { rect: itemRect, label: "Название + цена", status: "pending" }; diagnostics.boxes.push(itemBox); if ( @@ -1319,7 +1385,7 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source const itemOcr = await recognizeText(frame, itemRect); let item = parseTooltipText(itemOcr.text); - itemBox.status = item ? "found" : "not-found"; + itemBox.status = item ? "found" : "pending"; addDiagnosticStage( diagnostics, "itemName", @@ -1389,7 +1455,7 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source } async function resolveCatalogItem(item) { - if (!embedded) return item; + if (!appHosted) return item; if (!catalogMatches.has(item.name)) { catalogMatches.set( item.name, @@ -1428,6 +1494,7 @@ async function scanSource(source, saleTemplate, tooltipTemplate) { ], }; } + drawSourceOverlay(source, result); } function loadCalibrationTemplates() { @@ -1555,6 +1622,7 @@ async function syncMarketOutbox() { function clearResults() { state.results = []; + state.sources.forEach((source) => drawSourceOverlay(source, null)); renderResults(); setStatus("Результаты очищены", "neutral"); } @@ -1635,4 +1703,5 @@ renderSources(); renderCalibration(); renderResults(); updateDrawHint(); +renderCaptureRequirement(); void loadSharedCalibration(); diff --git a/src/parser.js b/src/parser.js index 9f2b74c..c06f60f 100644 --- a/src/parser.js +++ b/src/parser.js @@ -31,6 +31,32 @@ export function normalizeOcrText(text) { return text.replace(/\s+/g, " ").trim(); } +export function cleanItemName(text) { + return normalizeOcrText(text.replace(/[^\p{L}\p{N}]+/gu, " ")); +} + +export function displayCaptureIssue({ secure, supported, topLevel }) { + if (!topLevel) { + return { + title: "Захват недоступен во встроенном окне", + detail: "Открой аудит рынка отдельным окном и повтори.", + }; + } + if (!secure) { + return { + title: "Для захвата нужен защищённый адрес", + detail: "Браузер разрешает выбор окон только через HTTPS или на http://localhost.", + }; + } + if (!supported) { + return { + title: "Браузер не поддерживает захват окон", + detail: "Открой аудит в актуальной версии Chrome или Edge.", + }; + } + return null; +} + export function parseFieldValue(type, text) { const normalized = normalizeOcrText(text); @@ -94,6 +120,9 @@ export function parseTooltipText(text) { .replace(/\s+/g, " ") .trim(); } + name = cleanItemName(name); + + if (!name) return null; return { name, diff --git a/src/style.css b/src/style.css index 58c256f..786ceb5 100644 --- a/src/style.css +++ b/src/style.css @@ -1,26 +1,27 @@ :root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - color: oklch(0.24 0.012 255); - background: oklch(0.965 0.006 250); + color: oklch(0.27 0.035 61); + background: oklch(0.93 0.025 83); + color-scheme: light; font-synthesis: none; - --background: oklch(0.965 0.006 250); - --surface: oklch(0.99 0.004 250); - --surface-raised: oklch(1 0.003 250); - --surface-muted: oklch(0.935 0.008 250); - --text: oklch(0.24 0.012 255); - --text-muted: oklch(0.52 0.018 255); - --border: oklch(0.86 0.012 250); - --border-strong: oklch(0.74 0.02 250); - --accent: oklch(0.58 0.16 250); - --accent-hover: oklch(0.52 0.17 250); - --accent-soft: oklch(0.93 0.035 250); - --success: oklch(0.54 0.13 155); - --success-soft: oklch(0.93 0.04 155); - --error: oklch(0.56 0.18 25); - --error-soft: oklch(0.94 0.04 25); - --warning: oklch(0.68 0.15 75); - --focus: oklch(0.66 0.18 250); - --shadow: 0 14px 40px oklch(0.32 0.025 250 / 0.08); + --background: oklch(0.93 0.025 83); + --surface: oklch(0.975 0.014 82); + --surface-raised: oklch(0.99 0.008 82); + --surface-muted: oklch(0.945 0.024 80); + --text: oklch(0.27 0.035 61); + --text-muted: oklch(0.49 0.028 69); + --border: oklch(0.74 0.04 77); + --border-strong: oklch(0.64 0.05 75); + --accent: oklch(0.58 0.1 78); + --accent-hover: oklch(0.49 0.09 72); + --accent-soft: oklch(0.9 0.045 80); + --success: oklch(0.45 0.09 145); + --success-soft: oklch(0.91 0.035 145); + --error: oklch(0.49 0.15 28); + --error-soft: oklch(0.92 0.035 28); + --warning: oklch(0.6 0.1 75); + --focus: oklch(0.58 0.1 78); + --shadow: 0 14px 36px oklch(0.27 0.035 61 / 0.09); } * { @@ -70,7 +71,8 @@ p { h1 { margin-block-end: 0; - font-size: 1.45rem; + font-family: Georgia, "Times New Roman", serif; + font-size: 1.55rem; line-height: 1.15; letter-spacing: -0.025em; } @@ -88,18 +90,36 @@ h3 { } .app-shell { - width: min(1480px, 100%); + width: 100%; min-height: 100vh; - margin-inline: auto; - padding: 28px clamp(18px, 3vw, 48px) 56px; + padding: 0 clamp(18px, 2vw, 34px) 48px; } .app-header { display: flex; - min-height: 62px; + min-height: 76px; align-items: center; justify-content: space-between; gap: 24px; + margin-inline: clamp(-34px, -2vw, -18px); + padding-inline: clamp(18px, 2vw, 34px); + border-block-end: 1px solid var(--border); + background: color-mix(in oklch, var(--surface) 93%, var(--accent) 7%); +} + +.audit-brand { + display: flex; + align-items: center; + gap: 13px; +} + +.audit-crest { + width: 22px; + height: 22px; + border: 1px solid var(--accent); + background: var(--surface-muted); + box-shadow: inset 0 0 0 5px var(--surface); + rotate: 45deg; } .eyebrow { @@ -173,7 +193,7 @@ h3 { .step-nav { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - margin-block: 30px 42px; + margin-block: 18px 34px; border-block-end: 1px solid var(--border); } @@ -241,6 +261,36 @@ h3 { align-items: center; } +.capture-requirement { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-block-end: 18px; + padding: 14px 16px; + border: 1px solid color-mix(in oklch, var(--error), transparent 45%); + border-radius: 8px; + background: var(--error-soft); +} + +.capture-requirement[hidden] { + display: none; +} + +.capture-requirement p { + max-width: 72ch; + margin: 4px 0 0; + color: var(--text-muted); + font-size: 0.84rem; + line-height: 1.45; +} + +.capture-requirement .button { + flex: 0 0 auto; + color: var(--text); + text-decoration: none; +} + .reference-strip { display: flex; gap: 10px; @@ -258,7 +308,7 @@ h3 { flex: 0 0 220px; overflow: hidden; border: 1px solid var(--border); - border-radius: 11px; + border-radius: 7px; background: var(--surface); } @@ -330,7 +380,7 @@ h3 { .calibration-guide { margin-block-end: 18px; border: 1px solid var(--border); - border-radius: 12px; + border-radius: 7px; background: var(--surface); } @@ -407,7 +457,7 @@ select, input[type="text"] { min-height: 40px; border: 1px solid var(--border-strong); - border-radius: 9px; + border-radius: 5px; background: var(--surface-raised); color: var(--text); } @@ -473,7 +523,7 @@ input[type="text"] { gap: 22px; padding: 14px; border: 1px solid var(--border); - border-radius: 14px; + border-radius: 8px; background: var(--surface); transition: border-color 180ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 180ms cubic-bezier(0.22, 1, 0.36, 1); @@ -485,6 +535,7 @@ input[type="text"] { } .source-row__preview { + position: relative; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 8px; @@ -497,6 +548,42 @@ input[type="text"] { object-fit: contain; } +.source-row__overlay { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; +} + +.source-row__overlay-status { + position: absolute; + inset: 7px auto auto 7px; + display: grid; + gap: 4px; + pointer-events: none; +} + +.source-row__overlay-status span { + width: max-content; + padding: 3px 6px; + border: 1px solid currentColor; + border-radius: 5px; + background: oklch(0.18 0.01 255 / 0.88); + color: oklch(0.88 0.02 250); + font-size: 0.62rem; + font-weight: 700; +} + +.source-row__overlay-status span[data-tone="pending"] { + color: oklch(0.84 0.16 85); +} + +.source-row__overlay-status span[data-tone="found"] { + color: oklch(0.8 0.15 155); +} + .source-row__info span { color: var(--text-muted); font-size: 0.72rem; @@ -539,17 +626,17 @@ input[type="text"] { .empty-state--wide { min-height: 300px; border: 1px dashed var(--border-strong); - border-radius: 14px; + border-radius: 8px; background: color-mix(in oklch, var(--surface), transparent 30%); } .calibration-layout { display: grid; - grid-template-columns: minmax(0, 1fr) 310px; - min-height: 620px; + grid-template-columns: minmax(0, 1fr) 350px; + min-height: calc(100vh - 300px); overflow: hidden; border: 1px solid var(--border); - border-radius: 16px; + border-radius: 8px; background: var(--surface); box-shadow: var(--shadow); } @@ -557,16 +644,16 @@ input[type="text"] { .canvas-stage { display: grid; min-width: 0; - min-height: 620px; - max-height: calc(100vh - 260px); + min-height: calc(100vh - 300px); + max-height: none; place-items: center; overflow: auto; padding: 24px; - background-color: oklch(0.91 0.008 250); - background-image: linear-gradient(45deg, oklch(0.88 0.008 250) 25%, transparent 25%), - linear-gradient(-45deg, oklch(0.88 0.008 250) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, oklch(0.88 0.008 250) 75%), - linear-gradient(-45deg, transparent 75%, oklch(0.88 0.008 250) 75%); + background-color: oklch(0.89 0.018 80); + background-image: linear-gradient(45deg, oklch(0.86 0.02 78) 25%, transparent 25%), + linear-gradient(-45deg, oklch(0.86 0.02 78) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, oklch(0.86 0.02 78) 75%), + linear-gradient(-45deg, transparent 75%, oklch(0.86 0.02 78) 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; } @@ -748,7 +835,7 @@ input[type="range"] { .unique-lines { overflow: hidden; border: 1px solid var(--border-strong); - border-radius: 14px; + border-radius: 8px; background: var(--surface); } @@ -797,7 +884,7 @@ input[type="range"] { .result-group { overflow: hidden; border: 1px solid var(--border); - border-radius: 14px; + border-radius: 8px; background: var(--surface); } @@ -1130,6 +1217,15 @@ td:nth-child(5) { } @media (max-width: 640px) { + .capture-requirement { + align-items: flex-start; + flex-direction: column; + } + + .capture-requirement .button { + width: 100%; + } + .app-shell { padding-inline: 14px; } @@ -1197,36 +1293,36 @@ td:nth-child(5) { } } -@media (prefers-color-scheme: dark) { - :root { - color: oklch(0.9 0.009 250); - background: oklch(0.18 0.012 255); - --background: oklch(0.18 0.012 255); - --surface: oklch(0.225 0.014 255); - --surface-raised: oklch(0.255 0.015 255); - --surface-muted: oklch(0.28 0.016 255); - --text: oklch(0.9 0.009 250); - --text-muted: oklch(0.68 0.018 250); - --border: oklch(0.34 0.018 255); - --border-strong: oklch(0.43 0.022 255); - --accent: oklch(0.7 0.15 250); - --accent-hover: oklch(0.75 0.13 250); - --accent-soft: oklch(0.3 0.05 250); - --success: oklch(0.72 0.12 155); - --success-soft: oklch(0.29 0.045 155); - --error: oklch(0.72 0.15 25); - --error-soft: oklch(0.3 0.05 25); - --warning: oklch(0.78 0.13 75); - --shadow: 0 18px 48px oklch(0.08 0.015 255 / 0.28); - } +html[data-theme="dark"] { + color: oklch(0.9 0.018 78); + background: oklch(0.17 0.014 70); + color-scheme: dark; + --background: oklch(0.17 0.014 70); + --surface: oklch(0.21 0.018 70); + --surface-raised: oklch(0.23 0.02 71); + --surface-muted: oklch(0.25 0.022 72); + --text: oklch(0.9 0.018 78); + --text-muted: oklch(0.7 0.025 75); + --border: oklch(0.41 0.035 72); + --border-strong: oklch(0.5 0.04 74); + --accent: oklch(0.7 0.1 78); + --accent-hover: oklch(0.75 0.09 80); + --accent-soft: oklch(0.28 0.04 76); + --success: oklch(0.72 0.1 145); + --success-soft: oklch(0.26 0.035 145); + --error: oklch(0.72 0.15 28); + --error-soft: oklch(0.27 0.045 28); + --warning: oklch(0.78 0.1 75); + --focus: oklch(0.7 0.1 78); + --shadow: 0 18px 48px oklch(0.06 0.01 70 / 0.55); +} - .canvas-stage { - background-color: oklch(0.16 0.01 255); - background-image: linear-gradient(45deg, oklch(0.2 0.012 255) 25%, transparent 25%), - linear-gradient(-45deg, oklch(0.2 0.012 255) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, oklch(0.2 0.012 255) 75%), - linear-gradient(-45deg, transparent 75%, oklch(0.2 0.012 255) 75%); - } +html[data-theme="dark"] .canvas-stage { + background-color: oklch(0.16 0.012 70); + background-image: linear-gradient(45deg, oklch(0.2 0.016 72) 25%, transparent 25%), + linear-gradient(-45deg, oklch(0.2 0.016 72) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, oklch(0.2 0.016 72) 75%), + linear-gradient(-45deg, transparent 75%, oklch(0.2 0.016 72) 75%); } @media (prefers-reduced-motion: reduce) { diff --git a/test/parser.test.js b/test/parser.test.js index 795a806..2e0783a 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + cleanItemName, combineRects, + displayCaptureIssue, isCalibrationReady, isRectInside, parseFieldValue, @@ -11,6 +13,17 @@ import { resolveFieldRect, } from "../src/parser.js"; +test("keeps only letters, digits and spaces in OCR item names", () => { + assert.equal(cleanItemName(" ~~ Soulshot: S-grade ` "), "Soulshot S grade"); + assert.equal(cleanItemName("Кристалл № 12"), "Кристалл 12"); +}); + +test("explains why display capture is unavailable", () => { + assert.equal(displayCaptureIssue({ secure: true, supported: true, topLevel: true }), null); + assert.match(displayCaptureIssue({ secure: false, supported: false, topLevel: true }).detail, /HTTPS/); + assert.match(displayCaptureIssue({ secure: true, supported: true, topLevel: false }).title, /встроенном/); +}); + test("normalizes a reverse drag into a positive rectangle", () => { assert.deepEqual(rectFromPoints({ x: 140, y: 90 }, { x: 20, y: 30 }), { x: 20, @@ -100,17 +113,17 @@ test("parses a hovered item tooltip", () => { assert.deepEqual( parseTooltipText("Blessed Spiritshot: D-Grade (D) (5,600)\nFor Each 56 Adena"), { - name: "Blessed Spiritshot: D-Grade (D)", + name: "Blessed Spiritshot D Grade D", quantity: 5600, priceAdena: 56, rawText: "Blessed Spiritshot: D-Grade (D) (5,600)\nFor Each 56 Adena", }, ); - assert.deepEqual(parseTooltipText("Animal Skin (18),,.\nFor Each 400 Adena"), { - name: "Animal Skin ,,.", + assert.deepEqual(parseTooltipText("~~ Animal Skin (18),,.\nFor Each 400 Adena"), { + name: "Animal Skin", quantity: 18, priceAdena: 400, - rawText: "Animal Skin (18),,.\nFor Each 400 Adena", + rawText: "~~ Animal Skin (18),,.\nFor Each 400 Adena", }); assert.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null); }); diff --git a/vite.config.js b/vite.config.js index 8b6a4a3..a53d320 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ import { defineConfig, loadEnv } from "vite"; const MAX_BATCH_BYTES = 96 * 1024; +const L2_MARKET_IMPORT_TOKEN = "ebc212a3735eddf2b3ce7bcaf01522e2d53425483436ff1814e7998a8bde8d0e"; export async function forwardMarketBatch(body, { url, token, fetchImpl = fetch }) { if (!url || !token) return new Response("Market import is not configured", { status: 503 }); @@ -84,7 +85,7 @@ export default defineConfig(({ mode }) => { plugins: [ marketImportProxy({ url: environment.L2_MARKET_IMPORT_URL, - token: environment.L2_MARKET_IMPORT_TOKEN, + token: L2_MARKET_IMPORT_TOKEN, }), ], };