From e95e43bd92c3a2f03c7379727190c7b5b4c069eb Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Tue, 11 Aug 2026 18:06:29 +0300 Subject: [PATCH] Support item quantities and unique result lines --- README.md | 4 ++- src/main.js | 67 +++++++++++++++++++++++++++++++++++---------- src/parser.js | 26 +++++++++++++++--- src/style.css | 57 +++++++++++++++++++++++++++++++++++++- test/parser.test.js | 10 +++++++ 5 files changed, 143 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 173410f..3701029 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ npm run dev После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно. +Над подробными результатами выводятся уникальные строки вида `Покупка · Lui · Animal Skin · 18 шт. · 400 Adena`. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`. Повторное распознавание той же комбинации торговца, типа сделки, предмета, количества и цены не создаёт новую строку. + ### Что именно выделять | Область | Правильный выбор | Частая ошибка | @@ -75,7 +77,7 @@ npm run dev | Зона tooltip | Вся полоса возможных положений всплывающего окна над сеткой предметов | Включить нижнюю строку `Price` основного окна | | Якорь Price | Только постоянные символы `Price :` внутри tooltip | Добавить цену, которая меняется у каждого предмета | | Название предмета | Одна верхняя строка tooltip с запасом справа | Захватить иконки, фон игры или строку цены | -| Строка цены | Одна строка `Price : 3,000,000 Adena` | Захватить дополнительную строку `(3 Million Adena)` | +| Строка цены | `Price : 3,000,000 Adena`, `For Each 400 Adena` или только число | Захватить дополнительную строку `(3 Million Adena)` | Лучше калиброваться на предмете с длинным названием и большой ценой. Тогда прямоугольники не обрежут более короткие варианты. diff --git a/src/main.js b/src/main.js index 08f5989..5b5f6e4 100644 --- a/src/main.js +++ b/src/main.js @@ -942,6 +942,38 @@ function renderResults() { return; } + const uniqueLines = [ + ...new Set( + state.results.flatMap((result) => + result.items.map((item) => { + const side = (item.side || result.side) === "buy" ? "Покупка" : "Продажа"; + const merchant = item.merchant || result.merchant; + const quantity = item.quantity == null ? "количество не указано" : `${item.quantity.toLocaleString("ru-RU")} шт.`; + return `${side} · ${merchant} · ${item.name} · ${quantity} · ${item.priceAdena.toLocaleString("ru-RU")} Adena`; + }), + ), + ), + ]; + + if (uniqueLines.length) { + const output = document.createElement("section"); + output.className = "unique-lines"; + const header = document.createElement("header"); + const title = document.createElement("h3"); + title.textContent = "Уникальные строки"; + const count = document.createElement("span"); + count.textContent = String(uniqueLines.length); + header.append(title, count); + const list = document.createElement("ol"); + uniqueLines.forEach((line) => { + const item = document.createElement("li"); + item.textContent = line; + list.append(item); + }); + output.append(header, list); + elements.resultsList.append(output); + } + state.results.forEach((result) => { const section = document.createElement("section"); section.className = "result-group"; @@ -956,8 +988,12 @@ function renderResults() { heading.append(title, meta); const badge = document.createElement("span"); badge.className = "result-badge"; - badge.dataset.tone = result.saleFound ? "success" : "error"; - badge.textContent = result.saleFound ? `${result.items.length} предметов` : "Нет магазина"; + badge.dataset.tone = result.saleFound ? "success" : result.items.length ? "neutral" : "error"; + badge.textContent = result.saleFound + ? `${result.items.length} предметов` + : result.items.length + ? `${result.items.length} сохранено` + : "Нет магазина"; header.append(heading, badge); section.append(header); @@ -966,21 +1002,23 @@ function renderResults() { error.className = "result-error"; error.textContent = result.error; section.append(error); - } else if (result.saleFound && result.items.length) { + } else if (result.items.length) { const table = document.createElement("table"); - table.innerHTML = "ПредметЦенаПоследний разУверенность OCR"; + table.innerHTML = "ПредметКоличествоЦенаПоследний разУверенность OCR"; const body = document.createElement("tbody"); 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, price, seen, confidence); + row.append(name, quantity, price, seen, confidence); body.append(row); }); table.append(body); @@ -1063,7 +1101,6 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source result.merchant = ""; result.storeRawText = ""; result.storeConfidence = 0; - result.items = []; } addSkippedStages( diagnostics, @@ -1094,13 +1131,6 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source const header = parseStoreHeaderText(headerOcr.text); const rememberedStore = !header && result.side && result.merchant; storeHeaderBox.status = header ? "found" : rememberedStore ? "info" : "not-found"; - if ( - header && - ((result.merchant && header.merchant !== result.merchant) || - (result.side && header.side !== result.side)) - ) { - result.items = []; - } if (header) { result.side = header.side; result.merchant = header.merchant; @@ -1250,7 +1280,7 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source if (!item) return; const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2); - const key = `${item.name.toLocaleLowerCase("en-US")}:${item.priceAdena}`; + 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(); @@ -1282,7 +1312,14 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source existing.seenAt = seenAt; existing.confidence = confidence; } else { - result.items.push({ ...item, key, confidence, seenAt }); + result.items.push({ + ...item, + side: result.side, + merchant: result.merchant, + key, + confidence, + seenAt, + }); setStatus(`Добавлен предмет: ${item.name}`, "success"); } } diff --git a/src/parser.js b/src/parser.js index f5c8c43..80eb6b9 100644 --- a/src/parser.js +++ b/src/parser.js @@ -51,13 +51,22 @@ export function parseTooltipText(text) { .split(/\r?\n/) .map(normalizeOcrText) .filter(Boolean); - const priceIndex = lines.findIndex((line) => /pr[i1l]ce\s*[:;]?/i.test(line)); + const priceIndex = lines.findIndex( + (line) => + /\d[\d\s,.'`]*\s*adena/i.test(line) || + /pr[i1l]ce\s*[:;]?/i.test(line) || + /^\s*\d[\d\s,.'`]*\s*$/.test(line), + ); if (priceIndex < 0) return null; const priceLine = lines[priceIndex]; - const priceMatch = priceLine.match(/pr[i1l]ce\s*[:;]?\s*([\d\s,.'`]+)\s*adena/i); - const digits = priceMatch?.[1].replace(/\D/g, "") ?? ""; + const priceText = + priceLine.match(/([\d][\d\s,.'`]*)\s*adena/i)?.[1] ?? + priceLine.match(/pr[i1l]ce\s*[:;]?\s*([\d][\d\s,.'`]*)/i)?.[1] ?? + priceLine.match(/^\s*([\d][\d\s,.'`]*)\s*$/)?.[1] ?? + ""; + const digits = priceText.replace(/\D/g, ""); let name = lines[priceIndex - 1] ?? ""; if (!name) { @@ -66,7 +75,16 @@ export function parseTooltipText(text) { if (!name || !digits) return null; - return { name, priceAdena: Number(digits), rawText: lines.join("\n") }; + const quantityMatch = name.match(/^(.*?)\s*\(([\d][\d\s,.]*)\)\s*$/); + const quantityText = quantityMatch?.[2].replace(/\D/g, "") ?? ""; + if (quantityMatch) name = quantityMatch[1].trim(); + + return { + name, + quantity: quantityText ? Number(quantityText) : null, + priceAdena: Number(digits), + rawText: lines.join("\n"), + }; } export function isCalibrationReady(calibration) { diff --git a/src/style.css b/src/style.css index 3e8bcba..f57531e 100644 --- a/src/style.css +++ b/src/style.css @@ -728,6 +728,55 @@ input[type="range"] { gap: 18px; } +.unique-lines { + overflow: hidden; + border: 1px solid var(--border-strong); + border-radius: 14px; + background: var(--surface); +} + +.unique-lines header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 20px; + border-block-end: 1px solid var(--border); +} + +.unique-lines h3 { + margin: 0; +} + +.unique-lines header span { + min-width: 28px; + padding: 4px 8px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.75rem; + font-weight: 750; + text-align: center; +} + +.unique-lines ol { + margin: 0; + padding: 0; + list-style-position: inside; +} + +.unique-lines li { + padding: 11px 20px; + border-block-end: 1px solid var(--border); + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; + font-size: 0.82rem; + line-height: 1.5; +} + +.unique-lines li:last-child { + border-block-end: 0; +} + .result-group { overflow: hidden; border: 1px solid var(--border); @@ -771,6 +820,11 @@ input[type="range"] { color: var(--error); } +.result-badge[data-tone="neutral"] { + background: var(--surface-muted); + color: var(--text-muted); +} + .result-error { margin: 0; padding: 22px 20px; @@ -911,7 +965,8 @@ tbody tr:last-child td { } td:nth-child(2), -td:nth-child(4) { +td:nth-child(3), +td:nth-child(5) { font-variant-numeric: tabular-nums; font-weight: 650; } diff --git a/test/parser.test.js b/test/parser.test.js index 955b7d6..4e4e964 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -60,10 +60,20 @@ 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, priceAdena: 3000000, rawText: "Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million 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("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.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null); });