From d97305553f6d968fa8c1fd47d21fd633ebaf0b77 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Tue, 11 Aug 2026 18:34:52 +0300 Subject: [PATCH] Integrate market audit with shared calibration and catalog --- README.md | 14 ++- index.html | 5 + src/main.js | 220 ++++++++++++++++++++++++++++++------------- src/market-outbox.js | 4 +- src/parser.js | 28 ++++-- src/style.css | 125 ++++++++++++++++++++++++ test/parser.test.js | 40 +++++++- vite.config.js | 1 + 8 files changed, 359 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 3701029..78446dd 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ npm run dev Открой адрес, который напечатает Vite, обычно `http://127.0.0.1:5173`. +В основном приложении этот же интерфейс доступен в `Рынок → Аудит рынка`. Там сохранённая калибровка общая: сервер загружает её при открытии вкладки и заменяет после нажатия «Сохранить». + На macOS браузеру потребуется разрешение **System Settings → Privacy & Security → Screen & System Audio Recording**. После выдачи разрешения браузер иногда нужно перезапустить. ## Как работает поиск @@ -66,7 +68,13 @@ npm run dev После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно. -Над подробными результатами выводятся уникальные строки вида `Покупка · Lui · Animal Skin · 18 шт. · 400 Adena`. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`. Повторное распознавание той же комбинации торговца, типа сделки, предмета, количества и цены не создаёт новую строку. +Над подробными результатами выводятся уникальные строки вида `Покупка · Lui · Animal Skin · 18 шт. · 400 Adena`. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`; если их нет, используется `1`. Повторное распознавание той же комбинации торговца, типа сделки, предмета, количества и цены не создаёт новую строку. + +Во вкладке приложения OCR-название проходит неточный поиск по активному каталогу. Уверенное совпадение заменяется каноническим названием и показывается с иконкой предмета; слабое совпадение остаётся с вопросительным знаком и пометкой «Не найден в каталоге». Предметы группируются по имени торговца и типу сделки. + +Количеством считается только последняя числовая скобка. В `Blessed Spiritshot: D-Grade (D) (5,600)` часть `(D)` остаётся в названии, а количество равно `5600`. + +Если рамки перекрывают соседние элементы, выключи «Показывать выбранные рамки». Сохранённые области не удалятся, а новый пунктирный прямоугольник останется виден во время выделения. ### Что именно выделять @@ -95,7 +103,7 @@ npm run dev Диагностические изображения существуют только в памяти открытой страницы. Они не попадают в JSON результата и не отправляются вместе с рыночными наблюдениями. -Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере. +В отдельном Vite-прототипе калибровка хранится в `localStorage`. Во вкладке `home-service` серверная калибровка является основной, а `localStorage` остаётся локальной резервной копией. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере. ## Ограничения прототипа @@ -110,4 +118,6 @@ npm run dev ```bash npm test npm run build +# Обновить встроенную копию в соседнем home-service: +npm run build -- --outDir ../home-service/frontend/public/l2/market-audit --emptyOutDir ``` diff --git a/index.html b/index.html index 14d299a..8371968 100644 --- a/index.html +++ b/index.html @@ -109,6 +109,11 @@

Выдели постоянный нижний фрагмент торгового окна, например подпись Adena и кнопку Confirm. Числа лучше не включать.

+ +
diff --git a/src/main.js b/src/main.js index 5b5f6e4..a385ddb 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,6 @@ import "./style.css"; import { + combineRects, isCalibrationReady, isRectInside, parseStoreHeaderText, @@ -16,6 +17,8 @@ import { import { createMarketOutbox } from "./market-outbox.js"; const STORAGE_KEY = "l2-market-parser.calibration.v4"; +const embedded = location.pathname.startsWith("/l2/market-audit/"); +if (embedded) document.documentElement.classList.add("is-embedded"); const regionNames = { saleAnchor: "Маркер магазина", storeHeader: "Заголовок магазина", @@ -55,11 +58,13 @@ const state = { selectedReferenceId: null, referenceCanvas: null, calibration: loadCalibration(), + showOverlays: true, drawing: null, results: [], collecting: false, }; -const marketOutbox = createMarketOutbox(); +const marketOutbox = createMarketOutbox(embedded ? "/api/l2/market/import" : undefined); +const catalogMatches = new Map(); const elements = { status: document.querySelector("#global-status"), @@ -76,6 +81,7 @@ const elements = { canvas: document.querySelector("#calibration-canvas"), regionType: document.querySelector("#region-type"), drawHint: document.querySelector("#draw-hint"), + showOverlays: document.querySelector("#show-overlays"), threshold: document.querySelector("#match-threshold"), thresholdValue: document.querySelector("#threshold-value"), regionCount: document.querySelector("#region-count"), @@ -573,7 +579,7 @@ function addRegion(rect) { if (type === "saleAnchor") { state.calibration.saleAnchor = { ...rect, - image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), + image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/jpeg", 0.88), }; state.references.forEach((item) => { item.saleAnchor = null; @@ -622,7 +628,7 @@ function addRegion(rect) { } else if (type === "tooltipAnchor") { state.calibration.tooltipAnchor = { ...rect, - image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), + image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/jpeg", 0.88), }; state.references.forEach((item) => { item.tooltipAnchor = null; @@ -705,33 +711,35 @@ function drawCalibration() { context.clearRect(0, 0, elements.canvas.width, elements.canvas.height); context.drawImage(state.referenceCanvas, 0, 0); - if (selectedReference()?.saleAnchor) { - drawBox(context, selectedReference().saleAnchor, "Магазин", "oklch(0.72 0.16 65)"); - } - if (referenceStoreHeaderRect()) { - drawBox(context, referenceStoreHeaderRect(), "Тип + торговец", "oklch(0.65 0.17 250)"); - } - if (referenceTooltipSearchRect()) { - drawBox(context, referenceTooltipSearchRect(), "Поиск tooltip", "oklch(0.64 0.14 155)"); - } - if (selectedReference()?.tooltipAnchor) { - drawBox(context, selectedReference().tooltipAnchor, "Price :", "oklch(0.68 0.17 25)"); - } - if (referenceTooltipFieldRect(state.calibration.itemNameRegion)) { - drawBox( - context, - referenceTooltipFieldRect(state.calibration.itemNameRegion), - "Название", - "oklch(0.66 0.14 300)", - ); - } - if (referenceTooltipFieldRect(state.calibration.itemPriceRegion)) { - drawBox( - context, - referenceTooltipFieldRect(state.calibration.itemPriceRegion), - "Цена", - "oklch(0.67 0.14 210)", - ); + if (state.showOverlays) { + if (selectedReference()?.saleAnchor) { + drawBox(context, selectedReference().saleAnchor, "Магазин", "oklch(0.72 0.16 65)"); + } + if (referenceStoreHeaderRect()) { + drawBox(context, referenceStoreHeaderRect(), "Тип + торговец", "oklch(0.65 0.17 250)"); + } + if (referenceTooltipSearchRect()) { + drawBox(context, referenceTooltipSearchRect(), "Поиск tooltip", "oklch(0.64 0.14 155)"); + } + if (selectedReference()?.tooltipAnchor) { + drawBox(context, selectedReference().tooltipAnchor, "Price :", "oklch(0.68 0.17 25)"); + } + if (referenceTooltipFieldRect(state.calibration.itemNameRegion)) { + drawBox( + context, + referenceTooltipFieldRect(state.calibration.itemNameRegion), + "Название", + "oklch(0.66 0.14 300)", + ); + } + if (referenceTooltipFieldRect(state.calibration.itemPriceRegion)) { + drawBox( + context, + referenceTooltipFieldRect(state.calibration.itemPriceRegion), + "Цена", + "oklch(0.67 0.14 210)", + ); + } } if (state.drawing) { @@ -819,7 +827,7 @@ function renderCalibration() { drawCalibration(); } -function saveCalibration() { +async function saveCalibration() { if (!isCalibrationReady(state.calibration)) { setStatus("Нужно заполнить все 6 областей калибровки", "error"); return; @@ -827,9 +835,39 @@ function saveCalibration() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.calibration)); - setStatus("Калибровка сохранена в браузере", "success"); + if (embedded) { + const response = await fetch("/api/l2/market/calibration", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ calibration: state.calibration }), + }); + if (!response.ok) throw new Error(); + setStatus("Общая калибровка сохранена на сервере", "success"); + } else { + setStatus("Калибровка сохранена в браузере", "success"); + } } catch { - setStatus("Браузер не смог сохранить калибровку", "error"); + setStatus(embedded ? "Сервер не смог сохранить калибровку" : "Браузер не смог сохранить калибровку", "error"); + } +} + +async function loadSharedCalibration() { + if (!embedded) return; + try { + const response = await fetch("/api/l2/market/calibration", { cache: "no-store" }); + if (!response.ok) throw new Error(); + const settings = await response.json(); + if (!settings.calibration) { + setStatus("Общая калибровка ещё не настроена", "neutral"); + return; + } + state.calibration = settings.calibration; + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.calibration)); + renderCalibration(); + renderReferences(); + setStatus("Общая калибровка загружена с сервера", "success"); + } catch { + setStatus("Не удалось загрузить общую калибровку; используется локальная копия", "error"); } } @@ -1003,26 +1041,51 @@ function renderResults() { error.textContent = result.error; section.append(error); } else if (result.items.length) { - const table = document.createElement("table"); - table.innerHTML = "ПредметКоличествоЦенаПоследний разУверенность OCR"; - const body = document.createElement("tbody"); + const shops = new Map(); result.items.forEach((item) => { - const row = document.createElement("tr"); - const name = document.createElement("td"); - name.textContent = item.name; - const quantity = document.createElement("td"); - quantity.textContent = item.quantity == null ? "—" : item.quantity.toLocaleString("ru-RU"); - const price = document.createElement("td"); - price.textContent = `${item.priceAdena.toLocaleString("ru-RU")} Adena`; - const seen = document.createElement("td"); - seen.textContent = new Date(item.seenAt).toLocaleTimeString("ru-RU"); - const confidence = document.createElement("td"); - confidence.textContent = `${item.confidence}%`; - row.append(name, quantity, price, seen, confidence); - body.append(row); + const shopKey = `${item.side}:${item.merchant}`; + if (!shops.has(shopKey)) shops.set(shopKey, []); + shops.get(shopKey).push(item); }); - table.append(body); - section.append(table); + const shopList = document.createElement("div"); + shopList.className = "merchant-list"; + shops.forEach((items) => { + const shop = document.createElement("section"); + shop.className = "merchant-card"; + const shopHeader = document.createElement("header"); + const shopTitle = document.createElement("h4"); + shopTitle.textContent = items[0].merchant; + const shopMeta = document.createElement("span"); + shopMeta.textContent = `${items[0].side === "buy" ? "Покупает" : "Продаёт"} · ${items.length} поз.`; + shopHeader.append(shopTitle, shopMeta); + const itemGrid = document.createElement("div"); + itemGrid.className = "market-item-grid"; + items.forEach((item) => { + const tile = document.createElement("article"); + tile.className = `market-item${item.itemId ? "" : " is-unmatched"}`; + const icon = item.iconUrl ? document.createElement("img") : document.createElement("span"); + if (item.iconUrl) { + icon.src = item.iconUrl; + icon.alt = ""; + } else { + icon.className = "market-item__missing-icon"; + icon.textContent = "?"; + } + const copy = document.createElement("div"); + const name = document.createElement("strong"); + name.textContent = item.name; + const price = document.createElement("span"); + price.textContent = `${item.priceAdena.toLocaleString("ru-RU")} Adena${item.quantity == null ? "" : ` · ${item.quantity.toLocaleString("ru-RU")} шт.`}`; + const status = document.createElement("small"); + status.textContent = item.itemId ? `ID ${item.itemId} · OCR ${item.confidence}%` : `Не найден в каталоге · OCR ${item.confidence}%`; + copy.append(name, price, status); + tile.append(icon, copy); + itemGrid.append(tile); + }); + shop.append(shopHeader, itemGrid); + shopList.append(shop); + }); + section.append(shopList); } else if (result.saleFound) { const hint = document.createElement("p"); hint.className = "result-hint"; @@ -1233,16 +1296,15 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source const nameRect = resolveFieldRect(tooltipRect, state.calibration.itemNameRegion); const priceRect = resolveFieldRect(tooltipRect, state.calibration.itemPriceRegion); - const nameBox = { rect: nameRect, label: "Название", status: "info" }; - const priceBox = { rect: priceRect, label: "Цена", status: "info" }; - diagnostics.boxes.push(nameBox, priceBox); + const itemRect = combineRects(nameRect, priceRect); + const itemBox = { rect: itemRect, label: "Название + цена", status: "info" }; + diagnostics.boxes.push(itemBox); if ( !isRectInside(nameRect, frame.width, frame.height) || !isRectInside(priceRect, frame.width, frame.height) ) { - nameBox.status = "not-found"; - priceBox.status = "not-found"; + itemBox.status = "not-found"; addSkippedStages( diagnostics, [ @@ -1255,31 +1317,31 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source return; } - const nameOcr = await recognizeText(frame, nameRect, { singleLine: true }); - const priceOcr = await recognizeText(frame, priceRect, { singleLine: true }); - const item = parseTooltipText(`${nameOcr.text}\n${priceOcr.text}`); - nameBox.status = nameOcr.text ? "found" : "not-found"; - priceBox.status = item ? "found" : "not-found"; + const itemOcr = await recognizeText(frame, itemRect); + let item = parseTooltipText(itemOcr.text); + itemBox.status = item ? "found" : "not-found"; addDiagnosticStage( diagnostics, "itemName", "Название предмета", - nameOcr.text ? "found" : "not-found", - nameOcr.text ? `OCR ${nameOcr.confidence}%: ${nameOcr.text}` : "OCR вернул пустую строку", - cropPreview(frame, nameRect), + item ? "found" : "not-found", + itemOcr.text ? `OCR ${itemOcr.confidence}%: ${itemOcr.text}` : "OCR вернул пустую строку", + cropPreview(frame, itemRect), ); addDiagnosticStage( diagnostics, "itemPrice", "Цена предмета", item ? "found" : "not-found", - priceOcr.text ? `OCR ${priceOcr.confidence}%: ${priceOcr.text}` : "OCR вернул пустую строку", - cropPreview(frame, priceRect), + itemOcr.text ? `OCR ${itemOcr.confidence}%: ${itemOcr.text}` : "OCR вернул пустую строку", + cropPreview(frame, itemRect), ); finishDiagnostics(result, frame, diagnostics); if (!item) return; - const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2); + item = await resolveCatalogItem(item); + + const confidence = itemOcr.confidence; const key = `${result.side}:${result.merchant.toLocaleLowerCase("en-US")}:${item.name.toLocaleLowerCase("en-US")}:${item.quantity ?? ""}:${item.priceAdena}`; const existing = result.items.find((entry) => entry.key === key); const seenAt = new Date().toISOString(); @@ -1296,7 +1358,9 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source storeRawText: result.storeRawText.slice(0, 4096), storeConfidence: Number.isFinite(result.storeConfidence) ? Math.max(0, Math.min(100, Math.round(result.storeConfidence))) : 0, storeScore: Number.isFinite(saleMatch.score) ? Math.max(0, Math.min(1, saleMatch.score)) : 0, + itemId: item.itemId ?? null, name: item.name.slice(0, 256), + quantity: item.quantity, priceAdena: item.priceAdena, rawText: item.rawText.slice(0, 4096), key: key.slice(0, 512), @@ -1324,6 +1388,23 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source } } +async function resolveCatalogItem(item) { + if (!embedded) return item; + if (!catalogMatches.has(item.name)) { + catalogMatches.set( + item.name, + fetch(`/api/l2/items?${new URLSearchParams({ q: item.name.slice(0, 128), limit: "1", fuzzy: "true" })}`, { cache: "no-store" }) + .then((response) => (response.ok ? response.json() : [])) + .then((items) => items[0] ?? null) + .catch(() => null), + ); + } + const match = await catalogMatches.get(item.name); + return match?.matchScore >= 0.35 + ? { ...item, itemId: match.id, name: match.name, iconUrl: match.iconUrl, matchScore: match.matchScore } + : item; +} + async function scanSource(source, saleTemplate, tooltipTemplate) { const result = resultFor(source); let frame; @@ -1503,6 +1584,10 @@ elements.referenceFile.addEventListener("change", (event) => { event.target.value = ""; }); elements.regionType.addEventListener("change", updateDrawHint); +elements.showOverlays.addEventListener("change", (event) => { + state.showOverlays = event.target.checked; + drawCalibration(); +}); elements.threshold.addEventListener("input", (event) => { state.calibration.threshold = Number(event.target.value); elements.thresholdValue.value = state.calibration.threshold.toFixed(2); @@ -1550,3 +1635,4 @@ renderSources(); renderCalibration(); renderResults(); updateDrawHint(); +void loadSharedCalibration(); diff --git a/src/market-outbox.js b/src/market-outbox.js index a79b9d8..7997fc4 100644 --- a/src/market-outbox.js +++ b/src/market-outbox.js @@ -50,9 +50,9 @@ export class MarketOutbox { } } -export function createMarketOutbox() { +export function createMarketOutbox(endpoint = "/api/market-import") { return new MarketOutbox(createIndexedDbStore(), async (batch) => { - const response = await fetch("/api/market-import", { + const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(batch), diff --git a/src/parser.js b/src/parser.js index 80eb6b9..9f2b74c 100644 --- a/src/parser.js +++ b/src/parser.js @@ -16,6 +16,17 @@ export function resolveFieldRect(anchorPosition, field) { }; } +export function combineRects(first, second) { + const x = Math.min(first.x, second.x); + const y = Math.min(first.y, second.y); + return { + x, + y, + width: Math.max(first.x + first.width, second.x + second.width) - x, + height: Math.max(first.y + first.height, second.y + second.height) - y, + }; +} + export function normalizeOcrText(text) { return text.replace(/\s+/g, " ").trim(); } @@ -54,7 +65,7 @@ export function parseTooltipText(text) { const priceIndex = lines.findIndex( (line) => /\d[\d\s,.'`]*\s*adena/i.test(line) || - /pr[i1l]ce\s*[:;]?/i.test(line) || + /pr[i1l]ce\s*[:;]?\s*\d/i.test(line) || /^\s*\d[\d\s,.'`]*\s*$/.test(line), ); @@ -67,7 +78,7 @@ export function parseTooltipText(text) { priceLine.match(/^\s*([\d][\d\s,.'`]*)\s*$/)?.[1] ?? ""; const digits = priceText.replace(/\D/g, ""); - let name = lines[priceIndex - 1] ?? ""; + let name = lines.slice(0, priceIndex).find((line) => !/^pr[i1l]ce\s*[:;]?$/i.test(line)) ?? ""; if (!name) { name = priceLine.slice(0, priceLine.search(/pr[i1l]ce/i)).trim(); @@ -75,13 +86,18 @@ export function parseTooltipText(text) { if (!name || !digits) return null; - const quantityMatch = name.match(/^(.*?)\s*\(([\d][\d\s,.]*)\)\s*$/); - const quantityText = quantityMatch?.[2].replace(/\D/g, "") ?? ""; - if (quantityMatch) name = quantityMatch[1].trim(); + const quantityMatches = [...name.matchAll(/\(([\d][\d\s,.]*)\)/g)]; + const quantityMatch = quantityMatches.at(-1); + const quantityText = quantityMatch?.[1].replace(/\D/g, "") ?? ""; + if (quantityMatch) { + name = `${name.slice(0, quantityMatch.index)} ${name.slice(quantityMatch.index + quantityMatch[0].length)}` + .replace(/\s+/g, " ") + .trim(); + } return { name, - quantity: quantityText ? Number(quantityText) : null, + quantity: quantityText ? Number(quantityText) : 1, priceAdena: Number(digits), rawText: lines.join("\n"), }; diff --git a/src/style.css b/src/style.css index f57531e..58c256f 100644 --- a/src/style.css +++ b/src/style.css @@ -614,6 +614,23 @@ input[type="text"] { line-height: 1.45; } +.overlay-toggle { + display: flex; + align-items: center; + gap: 9px; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 650; + cursor: pointer; +} + +.overlay-toggle input { + width: 17px; + height: 17px; + margin: 0; + accent-color: var(--accent); +} + .range-label { display: flex; justify-content: space-between; @@ -831,6 +848,106 @@ input[type="range"] { color: var(--error); } +.merchant-list { + display: grid; + gap: 12px; + padding: 16px; +} + +.merchant-card { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-raised); +} + +.merchant-card > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 13px; + border-block-end: 1px solid var(--border); + background: var(--surface-muted); +} + +.merchant-card h4 { + margin: 0; + font-size: 0.92rem; +} + +.merchant-card header span { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; +} + +.market-item-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 1px; + background: var(--border); +} + +.market-item { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 10px; + min-height: 68px; + align-items: center; + padding: 10px; + background: var(--surface); +} + +.market-item.is-unmatched { + background: var(--error-soft); +} + +.market-item img, +.market-item__missing-icon { + width: 44px; + height: 44px; + border: 1px solid var(--border-strong); + border-radius: 5px; + background: oklch(0.18 0.01 255); +} + +.market-item img { + object-fit: contain; + image-rendering: pixelated; +} + +.market-item__missing-icon { + display: grid; + place-items: center; + color: var(--error); + font-weight: 800; +} + +.market-item > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.market-item strong, +.market-item span, +.market-item small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.market-item strong { + font-size: 0.82rem; +} + +.market-item span, +.market-item small { + color: var(--text-muted); + font-size: 0.72rem; +} + .result-hint { margin: 0; padding: 22px 20px; @@ -1120,3 +1237,11 @@ td:nth-child(5) { transition-duration: 0.01ms !important; } } +html.is-embedded .app-header { + display: none; +} + +html.is-embedded .app-shell { + max-width: none; + padding-top: 0; +} diff --git a/test/parser.test.js b/test/parser.test.js index 4e4e964..795a806 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + combineRects, isCalibrationReady, isRectInside, parseFieldValue, @@ -29,6 +30,16 @@ test("moves calibrated fields together with the detected anchor", () => { ); }); +test("combines the calibrated name and price rows into one OCR window", () => { + assert.deepEqual( + combineRects( + { x: 100, y: 40, width: 220, height: 18 }, + { x: 120, y: 90, width: 160, height: 18 }, + ), + { x: 100, y: 40, width: 220, height: 68 }, + ); +}); + test("parses formatted prices and rejects empty numeric OCR", () => { assert.equal(parseFieldValue("price", "1 250,000 adena"), 1250000); assert.equal(parseFieldValue("quantity", "not found"), null); @@ -60,7 +71,7 @@ test("parses a hovered item tooltip", () => { parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"), { name: "Tears of Eva", - quantity: null, + quantity: 1, priceAdena: 3000000, rawText: "Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)", }, @@ -74,6 +85,33 @@ test("parses a hovered item tooltip", () => { assert.equal(parseTooltipText("Animal Skin (18)\n400")?.priceAdena, 400); assert.equal(parseTooltipText("Animal Skin (18)\nPrice : 400")?.priceAdena, 400); assert.equal(parseTooltipText("Blessed Spiritshot D (5,600)\nFor Each 56 Adena")?.quantity, 5600); + assert.deepEqual(parseTooltipText("Sword of Valhalla\nPrice :\nFor Each 56 Adena"), { + name: "Sword of Valhalla", + quantity: 1, + priceAdena: 56, + rawText: "Sword of Valhalla\nPrice :\nFor Each 56 Adena", + }); + assert.deepEqual(parseTooltipText("Animal Skin (18)\nWeight 0\nPrice :\nFor Each 400 Adena"), { + name: "Animal Skin", + quantity: 18, + priceAdena: 400, + rawText: "Animal Skin (18)\nWeight 0\nPrice :\nFor Each 400 Adena", + }); + assert.deepEqual( + parseTooltipText("Blessed Spiritshot: D-Grade (D) (5,600)\nFor Each 56 Adena"), + { + 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 ,,.", + quantity: 18, + priceAdena: 400, + 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 1ce525f..8b6a4a3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -80,6 +80,7 @@ function readBody(request) { export default defineConfig(({ mode }) => { const environment = loadEnv(mode, process.cwd(), ""); return { + base: "./", plugins: [ marketImportProxy({ url: environment.L2_MARKET_IMPORT_URL,