diff --git a/README.md b/README.md index 2ba1680..173410f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ ```bash npm install cp .env.local.example .env.local -# Заполни L2_MARKET_IMPORT_TOKEN тем же значением, что HOME_SERVICE_L2_MARKET_IMPORT_TOKEN в Gitea Secrets. +# Сгенерируй один случайный токен, сразу сохрани его и в .env.local, +# и как новый Gitea Secret HOME_SERVICE_L2_MARKET_IMPORT_TOKEN. npm run dev ``` @@ -27,7 +28,8 @@ npm run dev ├─ нет → заголовок и tooltip не проверяются └─ да ├─ OCR `Private Store(Buy/Sell) - имя` - │ └─ тип сделки + имя торговца + │ ├─ виден → запомнить тип сделки + имя торговца + │ └─ закрыт tooltip → использовать последний запомненный магазин └─ поиск Price : внутри зоны tooltip ├─ нет → название и цена не читаются └─ да → OCR точной строки названия и точной строки цены @@ -37,6 +39,8 @@ npm run dev Из заголовка `Private Store(Buy) - Hikvision` получится `side: "buy"`, `merchant: "Hikvision"`; из `Private Store(Sell) - Dwa` — `side: "sell"`, `merchant: "Dwa"`. +Контекст хранится отдельно для каждого захватываемого окна. Пока нижний маркер магазина остаётся виден, tooltip относится к последнему успешно распознанному заголовку этого окна. После трёх кадров без маркера контекст сбрасывается, чтобы предметы следующего магазина не попали в предыдущий. Если заголовок ещё ни разу не распознан, предмет из tooltip не сохраняется. + ## Сценарий 1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры. @@ -47,18 +51,20 @@ npm run dev 6. Внутри tooltip обведи только постоянную надпись `Price :`. 7. Отдельно выдели точную строку названия и точную строку цены. Для названия лучше использовать длинный предмет и оставить запас справа. 8. Сохрани калибровку, нажми «Начать сбор» и вернись в игру. -9. Наводи курсор на каждый предмет. Уникальные пары `название + цена` будут накапливаться в таблице, а каждое распознавание — сохраняться с датой и отправляться на сервер. +9. Сначала оставь курсор вне предметов, пока в результате не появятся тип сделки и имя торговца. +10. Наводи курсор на каждый предмет. Даже если tooltip закроет заголовок, предмет будет записан в последний найденный магазин. Наблюдения сначала попадают в IndexedDB браузера, затем отправляются пакетами раз в 30 секунд. При недоступном сервере пакет остаётся локально и повторяется с тем же ID после восстановления связи или перезапуска страницы; сервер не создаёт дубликат. -Для калибровки нужен скриншот, на котором одновременно видны: +Калибровку можно сделать по снимкам разного размера и с торговым окном в разных местах: -- нижняя часть торгового окна; -- заголовок `Private Store(Buy/Sell) - имя`; -- открытый tooltip предмета; -- полная строка названия и полная строка `Price`. +1. Без tooltip выдели нижний маркер и заголовок `Private Store(Buy/Sell) - имя`. +2. Добавь второй снимок с tooltip. Калибратор сам найдёт нижний маркер по всему новому кадру и перенесёт относительно него остальные области. +3. На втором снимке выдели зону tooltip, `Price :`, название и цену. Уже выбранные области сохраняются. -После выбора всех шести областей нажми «Проверить на скриншоте». Подключать игровое окно для этого не нужно. +Размер игрового окна и положение торгового окна могут отличаться. Масштаб интерфейса Lineage II должен оставаться тем же: шаблоны ищутся в исходном пиксельном размере. + +После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно. ### Что именно выделять @@ -83,7 +89,7 @@ npm run dev - «Пропущено» означает, что предыдущий обязательный этап не прошёл; - рядом с OCR показываются фактический crop, текст и уверенность. -Если не находится магазин, смотри crop «Маркер магазина» и процент совпадения. Если магазин найден, но тип или имя пустые, смотри crop и фактический OCR этапа «Заголовок магазина». Если магазин найден, но предмет не появляется, смотри «Якорь Price :». Если оба якоря найдены, проблема уже в точных crop названия или цены. +Если не находится магазин, смотри crop «Маркер магазина» и процент совпадения. Если тип или имя ещё не определены, убери курсор с предмета и смотри фактический OCR этапа «Заголовок магазина». Надпись «Используется последний магазин» означает, что tooltip закрыл заголовок, но привязка предмета сохранена. Если магазин найден, но предмет не появляется, смотри «Якорь Price :». Если оба якоря найдены, проблема уже в точных crop названия или цены. Диагностические изображения существуют только в памяти открытой страницы. Они не попадают в JSON результата и не отправляются вместе с рыночными наблюдениями. diff --git a/index.html b/index.html index d9bbabc..14d299a 100644 --- a/index.html +++ b/index.html @@ -13,8 +13,13 @@

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

L2 Market Parser

-
- Готов к работе +
+
+ Готов к работе +
+
+ Синхронизация: проверяется +
@@ -54,18 +59,20 @@
+ +
Как связаны области калибровки
  1. Маркер магазинаИщется по всему кадру. Лучше выбрать стабильные `Adena` и `Confirm`, не включая числа.
  2. -
  3. Заголовок магазинаСтрока `Private Store(Buy/Sell) - имя` даёт тип сделки и торговца. Она привязана к найденному маркеру.
  4. +
  5. Заголовок магазинаСтрока `Private Store(Buy/Sell) - имя` запоминается до закрытия торгового окна. Если tooltip её перекрыл, используется последний распознанный магазин.
  6. Зона tooltipТоже привязана к маркеру, поэтому перемещается вместе с торговым окном.
  7. Якорь Price :Ищется внутри зоны tooltip. Он позволяет самому tooltip перемещаться при наведении на разные иконки.
  8. Название и ценаПривязаны к найденному `Price :` и читаются из двух точных строк.
  9. @@ -74,6 +81,7 @@ КадрМагазинТип + торговец иTooltipНазвание + цена
+

Снимки могут иметь разный размер, а торговое окно может находиться в разных местах. На первом выдели маркер и видимый заголовок. На втором калибратор найдёт маркер по всему кадру, после чего выдели tooltip и его строки.

@@ -119,7 +127,7 @@
- +
diff --git a/src/main.js b/src/main.js index 604e1f9..08f5989 100644 --- a/src/main.js +++ b/src/main.js @@ -51,6 +51,8 @@ function loadCalibration() { const state = { sources: [], selectedSourceId: null, + references: [], + selectedReferenceId: null, referenceCanvas: null, calibration: loadCalibration(), drawing: null, @@ -61,6 +63,7 @@ const marketOutbox = createMarketOutbox(); const elements = { status: document.querySelector("#global-status"), + syncStatus: document.querySelector("#sync-status"), nav: [...document.querySelectorAll("[data-view]")], views: [...document.querySelectorAll(".view")], addSource: document.querySelector("#add-source"), @@ -68,6 +71,7 @@ const elements = { calibrationSource: document.querySelector("#calibration-source"), captureReference: document.querySelector("#capture-reference"), referenceFile: document.querySelector("#reference-file"), + referenceList: document.querySelector("#reference-list"), canvasEmpty: document.querySelector("#canvas-empty"), canvas: document.querySelector("#calibration-canvas"), regionType: document.querySelector("#region-type"), @@ -90,6 +94,11 @@ function setStatus(message, tone = "neutral") { elements.status.dataset.tone = tone; } +function setSyncStatus(message, tone = "neutral") { + elements.syncStatus.textContent = message; + elements.syncStatus.dataset.tone = tone; +} + function showView(name) { elements.nav.forEach((button) => { const active = button.dataset.view === name; @@ -291,39 +300,186 @@ async function captureSource(source) { return canvas; } -function setReference(canvas) { - state.referenceCanvas = canvas; +function selectedReference() { + return state.references.find((item) => item.id === state.selectedReferenceId); +} + +async function alignReference(reference) { + reference.saleAnchor = null; + reference.saleScore = null; + reference.tooltipAnchor = null; + reference.tooltipScore = null; + if (!state.calibration.saleAnchor?.image) return; + + const saleTemplate = await imageToCanvas(state.calibration.saleAnchor.image); + const saleMatch = await findAnchor(reference.canvas, saleTemplate); + reference.saleScore = saleMatch.score; + if (saleMatch.score < state.calibration.threshold) return; + + reference.saleAnchor = { + x: saleMatch.x, + y: saleMatch.y, + width: saleTemplate.width, + height: saleTemplate.height, + }; + if (!state.calibration.tooltipAnchor?.image || !state.calibration.tooltipSearchRegion) return; + + const searchRect = resolveFieldRect(reference.saleAnchor, state.calibration.tooltipSearchRegion); + if (!isRectInside(searchRect, reference.canvas.width, reference.canvas.height)) return; + const tooltipTemplate = await imageToCanvas(state.calibration.tooltipAnchor.image); + const tooltipMatch = await findAnchor(cropCanvas(reference.canvas, searchRect), tooltipTemplate); + reference.tooltipScore = tooltipMatch.score; + if (tooltipMatch.score < state.calibration.threshold) return; + reference.tooltipAnchor = { + x: searchRect.x + tooltipMatch.x, + y: searchRect.y + tooltipMatch.y, + width: tooltipTemplate.width, + height: tooltipTemplate.height, + }; +} + +async function selectReference(referenceId) { + const reference = state.references.find((item) => item.id === referenceId); + if (!reference) return; + + state.selectedReferenceId = reference.id; + state.referenceCanvas = reference.canvas; + const canvas = reference.canvas; elements.canvas.width = canvas.width; elements.canvas.height = canvas.height; elements.canvas.hidden = false; elements.canvasEmpty.hidden = true; elements.testCalibration.disabled = false; + setStatus(`Ищу торговое окно на снимке ${state.references.indexOf(reference) + 1}`, "working"); + await alignReference(reference); + if (state.selectedReferenceId !== reference.id) return; drawCalibration(); - setStatus(`Снимок готов: ${canvas.width} × ${canvas.height}`, "success"); + renderReferences(); + setStatus( + reference.saleAnchor || !state.calibration.saleAnchor + ? `Активен снимок ${state.references.indexOf(reference) + 1}: ${canvas.width} × ${canvas.height}` + : `Маркер магазина не найден: ${((reference.saleScore || 0) * 100).toFixed(1)}%`, + reference.saleAnchor || !state.calibration.saleAnchor ? "success" : "error", + ); +} + +async function addReference(canvas, name) { + const reference = { + id: crypto.randomUUID(), + name, + canvas, + preview: canvasPreview(canvas, 160), + saleAnchor: null, + saleScore: null, + tooltipAnchor: null, + tooltipScore: null, + }; + state.references.push(reference); + await selectReference(reference.id); + return reference; +} + +function removeReference(referenceId) { + const index = state.references.findIndex((item) => item.id === referenceId); + if (index < 0) return; + + state.references.splice(index, 1); + if (state.selectedReferenceId === referenceId) { + const next = state.references[Math.min(index, state.references.length - 1)]; + state.selectedReferenceId = null; + state.referenceCanvas = null; + if (next) { + void selectReference(next.id); + return; + } + elements.canvas.hidden = true; + elements.canvasEmpty.hidden = false; + elements.testCalibration.disabled = true; + } + renderReferences(); +} + +function renderReferences() { + elements.referenceList.replaceChildren(); + elements.referenceList.hidden = !state.references.length; + + state.references.forEach((reference, index) => { + const item = document.createElement("div"); + item.className = "reference-item"; + item.classList.toggle("is-selected", reference.id === state.selectedReferenceId); + + const select = document.createElement("button"); + select.className = "reference-item__select"; + select.type = "button"; + select.setAttribute("aria-pressed", String(reference.id === state.selectedReferenceId)); + select.addEventListener("click", () => void selectReference(reference.id)); + const image = document.createElement("img"); + image.src = reference.preview; + image.alt = ""; + const copy = document.createElement("span"); + const title = document.createElement("strong"); + title.textContent = `Снимок ${index + 1}`; + const detail = document.createElement("small"); + let match = reference.saleScore === null + ? "маркер не проверен" + : reference.saleAnchor + ? `магазин ${(reference.saleScore * 100).toFixed(1)}%` + : `магазин не найден ${(reference.saleScore * 100).toFixed(1)}%`; + if (reference.saleAnchor && state.calibration.tooltipAnchor) { + match += reference.tooltipAnchor + ? ` · tooltip ${(reference.tooltipScore * 100).toFixed(1)}%` + : ` · tooltip не найден ${((reference.tooltipScore || 0) * 100).toFixed(1)}%`; + } + detail.textContent = `${reference.name} · ${reference.canvas.width} × ${reference.canvas.height} · ${match}`; + copy.append(title, detail); + select.append(image, copy); + + const remove = document.createElement("button"); + remove.className = "reference-item__remove"; + remove.type = "button"; + remove.textContent = "×"; + remove.setAttribute("aria-label", `Удалить снимок ${index + 1}`); + remove.addEventListener("click", () => removeReference(reference.id)); + item.append(select, remove); + elements.referenceList.append(item); + }); } async function captureReference() { try { setStatus("Делаю снимок выбранного окна", "working"); - setReference(await captureSource(selectedSource())); + const source = selectedSource(); + const reference = await addReference(await captureSource(source), source.name); + if (!state.calibration.saleAnchor || reference.saleAnchor) { + setStatus(`Добавлен снимок ${state.references.length}`, "success"); + } } catch (error) { setStatus(error.message, "error"); } } -async function loadReferenceFile(file) { - if (!file) return; +async function loadReferenceFiles(fileList) { + const files = [...fileList]; + if (!files.length) return; + let added = 0; + let lastReference; try { - const bitmap = await createImageBitmap(file); - const canvas = document.createElement("canvas"); - canvas.width = bitmap.width; - canvas.height = bitmap.height; - canvas.getContext("2d", { willReadFrequently: true }).drawImage(bitmap, 0, 0); - bitmap.close(); - setReference(canvas); - } catch { - setStatus("Не удалось открыть изображение", "error"); + for (const file of files) { + const bitmap = await createImageBitmap(file); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext("2d", { willReadFrequently: true }).drawImage(bitmap, 0, 0); + bitmap.close(); + lastReference = await addReference(canvas, file.name); + added += 1; + } + if (!state.calibration.saleAnchor || lastReference?.saleAnchor) { + setStatus(`Добавлено снимков: ${added}`, "success"); + } + } catch (error) { + setStatus(error.message || "Не удалось открыть изображение", "error"); } } @@ -412,42 +568,55 @@ function finishDiagnostics(result, frame, diagnostics) { function addRegion(rect) { const type = elements.regionType.value; + const reference = selectedReference(); if (type === "saleAnchor") { state.calibration.saleAnchor = { ...rect, image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), }; + state.references.forEach((item) => { + item.saleAnchor = null; + item.saleScore = null; + item.tooltipAnchor = null; + item.tooltipScore = null; + }); + reference.saleAnchor = rect; + reference.saleScore = 1; state.calibration.storeHeaderRegion = null; state.calibration.tooltipSearchRegion = null; elements.regionType.value = "storeHeader"; setStatus("Маркер выбран. Теперь выдели строку Private Store(...) - имя", "success"); } else if (type === "storeHeader") { - if (!state.calibration.saleAnchor) { - setStatus("Сначала выдели маркер магазина", "error"); + if (!reference?.saleAnchor) { + setStatus("Маркер магазина не найден на активном снимке", "error"); return; } state.calibration.storeHeaderRegion = { - offsetX: rect.x - state.calibration.saleAnchor.x, - offsetY: rect.y - state.calibration.saleAnchor.y, + offsetX: rect.x - reference.saleAnchor.x, + offsetY: rect.y - reference.saleAnchor.y, width: rect.width, height: rect.height, }; elements.regionType.value = "tooltipSearch"; setStatus("Заголовок выбран. Теперь выдели зону появления tooltip", "success"); } else if (type === "tooltipSearch") { - if (!state.calibration.saleAnchor) { - setStatus("Сначала выдели маркер магазина", "error"); + if (!reference?.saleAnchor) { + setStatus("Маркер магазина не найден на активном снимке", "error"); return; } state.calibration.tooltipSearchRegion = { - offsetX: rect.x - state.calibration.saleAnchor.x, - offsetY: rect.y - state.calibration.saleAnchor.y, + offsetX: rect.x - reference.saleAnchor.x, + offsetY: rect.y - reference.saleAnchor.y, width: rect.width, height: rect.height, }; + state.references.forEach((item) => { + item.tooltipAnchor = null; + item.tooltipScore = null; + }); elements.regionType.value = "tooltipAnchor"; setStatus("Зона выбрана. Теперь обведи только постоянную надпись Price :", "success"); } else if (type === "tooltipAnchor") { @@ -455,19 +624,25 @@ function addRegion(rect) { ...rect, image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), }; + state.references.forEach((item) => { + item.tooltipAnchor = null; + item.tooltipScore = null; + }); + reference.tooltipAnchor = rect; + reference.tooltipScore = 1; state.calibration.itemNameRegion = null; state.calibration.itemPriceRegion = null; elements.regionType.value = "itemName"; setStatus("Якорь Price выбран. Теперь выдели точную строку названия", "success"); } else if (type === "itemName" || type === "itemPrice") { - if (!state.calibration.tooltipAnchor) { - setStatus("Сначала выдели якорь Price :", "error"); + if (!reference?.tooltipAnchor) { + setStatus("Якорь Price : не найден на активном снимке", "error"); return; } state.calibration[type === "itemName" ? "itemNameRegion" : "itemPriceRegion"] = { - offsetX: rect.x - state.calibration.tooltipAnchor.x, - offsetY: rect.y - state.calibration.tooltipAnchor.y, + offsetX: rect.x - reference.tooltipAnchor.x, + offsetY: rect.y - reference.tooltipAnchor.y, width: rect.width, height: rect.height, }; @@ -481,23 +656,27 @@ function addRegion(rect) { updateDrawHint(); renderCalibration(); + renderReferences(); } function referenceStoreHeaderRect() { - return state.calibration.saleAnchor && state.calibration.storeHeaderRegion - ? resolveFieldRect(state.calibration.saleAnchor, state.calibration.storeHeaderRegion) + const anchor = selectedReference()?.saleAnchor; + return anchor && state.calibration.storeHeaderRegion + ? resolveFieldRect(anchor, state.calibration.storeHeaderRegion) : null; } function referenceTooltipSearchRect() { - return state.calibration.saleAnchor && state.calibration.tooltipSearchRegion - ? resolveFieldRect(state.calibration.saleAnchor, state.calibration.tooltipSearchRegion) + const anchor = selectedReference()?.saleAnchor; + return anchor && state.calibration.tooltipSearchRegion + ? resolveFieldRect(anchor, state.calibration.tooltipSearchRegion) : null; } function referenceTooltipFieldRect(region) { - return state.calibration.tooltipAnchor && region - ? resolveFieldRect(state.calibration.tooltipAnchor, region) + const anchor = selectedReference()?.tooltipAnchor; + return anchor && region + ? resolveFieldRect(anchor, region) : null; } @@ -526,8 +705,8 @@ function drawCalibration() { context.clearRect(0, 0, elements.canvas.width, elements.canvas.height); context.drawImage(state.referenceCanvas, 0, 0); - if (state.calibration.saleAnchor) { - drawBox(context, state.calibration.saleAnchor, "Продажа", "oklch(0.72 0.16 65)"); + if (selectedReference()?.saleAnchor) { + drawBox(context, selectedReference().saleAnchor, "Магазин", "oklch(0.72 0.16 65)"); } if (referenceStoreHeaderRect()) { drawBox(context, referenceStoreHeaderRect(), "Тип + торговец", "oklch(0.65 0.17 250)"); @@ -535,8 +714,8 @@ function drawCalibration() { if (referenceTooltipSearchRect()) { drawBox(context, referenceTooltipSearchRect(), "Поиск tooltip", "oklch(0.64 0.14 155)"); } - if (state.calibration.tooltipAnchor) { - drawBox(context, state.calibration.tooltipAnchor, "Price :", "oklch(0.68 0.17 25)"); + if (selectedReference()?.tooltipAnchor) { + drawBox(context, selectedReference().tooltipAnchor, "Price :", "oklch(0.68 0.17 25)"); } if (referenceTooltipFieldRect(state.calibration.itemNameRegion)) { drawBox( @@ -613,12 +792,25 @@ function renderCalibration() { if (key === "saleAnchor") { state.calibration.storeHeaderRegion = null; state.calibration.tooltipSearchRegion = null; + state.references.forEach((item) => { + item.saleAnchor = null; + item.saleScore = null; + item.tooltipAnchor = null; + item.tooltipScore = null; + }); + } + if (key === "tooltipSearchRegion" || key === "tooltipAnchor") { + state.references.forEach((item) => { + item.tooltipAnchor = null; + item.tooltipScore = null; + }); } if (key === "tooltipAnchor") { state.calibration.itemNameRegion = null; state.calibration.itemPriceRegion = null; } renderCalibration(); + renderReferences(); }); row.append(copy, remove); elements.regionList.append(row); @@ -645,8 +837,15 @@ function clearCalibration() { state.collecting = false; elements.recognizeAll.textContent = "Начать сбор"; state.calibration = emptyCalibration(); + state.references.forEach((reference) => { + reference.saleAnchor = null; + reference.saleScore = null; + reference.tooltipAnchor = null; + reference.tooltipScore = null; + }); localStorage.removeItem(STORAGE_KEY); renderCalibration(); + renderReferences(); setStatus("Калибровка сброшена", "neutral"); } @@ -789,7 +988,9 @@ function renderResults() { } else if (result.saleFound) { const hint = document.createElement("p"); hint.className = "result-hint"; - hint.textContent = "Торговое окно найдено. Наводи курсор на иконки предметов и задерживай его до появления tooltip."; + hint.textContent = result.side && result.merchant + ? "Магазин запомнен. Наводи курсор на иконки предметов и задерживай его до появления tooltip." + : "Сначала убери курсор с предмета, чтобы приложение увидело заголовок магазина. Затем наводи на предметы."; section.append(hint); } @@ -860,6 +1061,8 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source if (result.misses >= 3) { result.side = ""; result.merchant = ""; + result.storeRawText = ""; + result.storeConfidence = 0; result.items = []; } addSkippedStages( @@ -889,9 +1092,8 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source if (isRectInside(storeHeaderRect, frame.width, frame.height)) { const headerOcr = await recognizeText(frame, storeHeaderRect, { singleLine: true }); const header = parseStoreHeaderText(headerOcr.text); - result.storeRawText = headerOcr.text; - result.storeConfidence = headerOcr.confidence; - storeHeaderBox.status = header ? "found" : "not-found"; + const rememberedStore = !header && result.side && result.merchant; + storeHeaderBox.status = header ? "found" : rememberedStore ? "info" : "not-found"; if ( header && ((result.merchant && header.merchant !== result.merchant) || @@ -902,26 +1104,35 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source if (header) { result.side = header.side; result.merchant = header.merchant; + result.storeRawText = header.rawText; + result.storeConfidence = headerOcr.confidence; storeHeaderBox.label = `${header.side === "buy" ? "Buy" : "Sell"} · ${header.merchant}`; + } else if (rememberedStore) { + storeHeaderBox.label = `Последний: ${result.side === "buy" ? "Buy" : "Sell"} · ${result.merchant}`; } addDiagnosticStage( diagnostics, "storeHeader", "Заголовок магазина", - header ? "found" : "not-found", + header ? "found" : rememberedStore ? "skipped" : "not-found", header ? `OCR ${headerOcr.confidence}%: ${header.side === "buy" ? "Покупка" : "Продажа"}, ${header.merchant}` - : `OCR ${headerOcr.confidence}%: «${headerOcr.text || "пусто"}» — не удалось разобрать тип и имя`, + : rememberedStore + ? `Заголовок закрыт или не читается. Используется последний магазин: ${result.side === "buy" ? "Покупка" : "Продажа"}, ${result.merchant}` + : `OCR ${headerOcr.confidence}%: «${headerOcr.text || "пусто"}» — сначала нужно распознать тип и имя`, cropPreview(frame, storeHeaderRect), ); } else { - storeHeaderBox.status = "not-found"; + const rememberedStore = result.side && result.merchant; + storeHeaderBox.status = rememberedStore ? "info" : "not-found"; addDiagnosticStage( diagnostics, "storeHeader", "Заголовок магазина", - "error", - "Область вышла за границы кадра", + rememberedStore ? "skipped" : "error", + rememberedStore + ? `Область вне кадра. Используется последний магазин: ${result.side === "buy" ? "Покупка" : "Продажа"}, ${result.merchant}` + : "Область вышла за границы кадра, а ранее распознанного магазина нет", ); } @@ -977,6 +1188,19 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source return; } + if (!result.side || !result.merchant) { + addSkippedStages( + diagnostics, + [ + ["itemName", "Название предмета"], + ["itemPrice", "Цена предмета"], + ], + "сначала нужно увидеть заголовок магазина без tooltip", + ); + finishDiagnostics(result, frame, diagnostics); + return; + } + const nameRect = resolveFieldRect(tooltipRect, state.calibration.itemNameRegion); const priceRect = resolveFieldRect(tooltipRect, state.calibration.itemPriceRegion); const nameBox = { rect: nameRect, label: "Название", status: "info" }; @@ -1041,7 +1265,7 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source merchant: result.merchant.slice(0, 128), storeRawText: result.storeRawText.slice(0, 4096), storeConfidence: Number.isFinite(result.storeConfidence) ? Math.max(0, Math.min(100, Math.round(result.storeConfidence))) : 0, - saleScore: Number.isFinite(saleMatch.score) ? Math.max(0, Math.min(1, saleMatch.score)) : 0, + storeScore: Number.isFinite(saleMatch.score) ? Math.max(0, Math.min(1, saleMatch.score)) : 0, name: item.name.slice(0, 256), priceAdena: item.priceAdena, rawText: item.rawText.slice(0, 4096), @@ -1096,8 +1320,8 @@ function loadCalibrationTemplates() { } async function testCalibration() { - if (!state.referenceCanvas) { - setStatus("Сначала загрузи скриншот или сделай снимок окна", "error"); + if (!state.references.length) { + setStatus("Сначала загрузи снимки или сделай их из окна", "error"); return; } if (!isCalibrationReady(state.calibration)) { @@ -1108,23 +1332,28 @@ async function testCalibration() { state.collecting = false; elements.recognizeAll.textContent = "Начать сбор"; elements.testCalibration.disabled = true; - setStatus("Проверяю калибровку на текущем скриншоте", "working"); + setStatus(`Проверяю снимки: 0/${state.references.length}`, "working"); let result; + let frame = state.referenceCanvas; try { state.results = state.results.filter((result) => result.sourceId !== "calibration-test"); - result = resultFor({ id: "calibration-test", name: "Тестовый скриншот" }); + result = resultFor({ id: "calibration-test", name: "Проверка снимков" }); const [saleTemplate, tooltipTemplate] = await loadCalibrationTemplates(); - await analyzeFrame(state.referenceCanvas, result, saleTemplate, tooltipTemplate); + for (const [index, reference] of state.references.entries()) { + frame = reference.canvas; + setStatus(`Проверяю снимки: ${index + 1}/${state.references.length}`, "working"); + await analyzeFrame(frame, result, saleTemplate, tooltipTemplate); + } renderResults(); showView("results"); - setStatus("Проверка завершена. Смотри этапы и изображения ниже", "success"); + setStatus(`Проверено снимков: ${state.references.length}. Смотри результат ниже`, "success"); } catch (error) { if (result) { result.saleFound = false; result.error = error.message || "Ошибка проверки"; result.diagnostics = { - preview: canvasPreview(state.referenceCanvas), + preview: canvasPreview(frame), boxes: [], stages: [ { @@ -1132,7 +1361,7 @@ async function testCalibration() { label: "Обработка скриншота", status: "error", detail: result.error, - image: canvasPreview(state.referenceCanvas, 240), + image: canvasPreview(frame, 240), }, ], }; @@ -1197,13 +1426,12 @@ async function toggleCollection() { } async function syncMarketOutbox() { + setSyncStatus("Синхронизация: отправка…", "working"); try { const result = await marketOutbox.flush(); - if (result.sent && !state.collecting) { - setStatus(`Отправлено наблюдений: ${result.count}`, "success"); - } + setSyncStatus(result.sent ? `Синхронизировано: ${result.count}` : "Синхронизация: очередь пуста", result.sent ? "success" : "neutral"); } catch (error) { - if (!state.collecting) setStatus(error.message || "Не удалось обновить данные рынка", "error"); + setSyncStatus(error.message || "Синхронизация недоступна", "error"); } } @@ -1233,12 +1461,18 @@ elements.nav.forEach((button) => button.addEventListener("click", () => showView elements.addSource.addEventListener("click", addSource); elements.calibrationSource.addEventListener("change", (event) => chooseSource(event.target.value)); elements.captureReference.addEventListener("click", captureReference); -elements.referenceFile.addEventListener("change", (event) => loadReferenceFile(event.target.files[0])); +elements.referenceFile.addEventListener("change", (event) => { + void loadReferenceFiles(event.target.files); + event.target.value = ""; +}); elements.regionType.addEventListener("change", updateDrawHint); elements.threshold.addEventListener("input", (event) => { state.calibration.threshold = Number(event.target.value); elements.thresholdValue.value = state.calibration.threshold.toFixed(2); }); +elements.threshold.addEventListener("change", () => { + if (state.selectedReferenceId) void selectReference(state.selectedReferenceId); +}); elements.saveCalibration.addEventListener("click", saveCalibration); elements.testCalibration.addEventListener("click", testCalibration); elements.clearCalibration.addEventListener("click", clearCalibration); diff --git a/src/market-outbox.js b/src/market-outbox.js index 702628c..a79b9d8 100644 --- a/src/market-outbox.js +++ b/src/market-outbox.js @@ -2,6 +2,7 @@ const DATABASE_NAME = "l2-market-parser"; const OBSERVATIONS = "market-observations"; const META = "meta"; const PENDING_BATCH = "pending-market-batch"; +const MAX_BATCH_BYTES = 80 * 1024; export class MarketOutbox { constructor(store, send, { now = () => new Date().toISOString(), uuid = () => crypto.randomUUID() } = {}) { @@ -26,10 +27,12 @@ export class MarketOutbox { if (!batch) { const observations = await this.store.list(200); if (!observations.length) return { sent: false, count: 0 }; + const batchId = this.uuid(); + const capturedAt = this.now(); batch = { - batchId: this.uuid(), - capturedAt: this.now(), - observationIds: observations.map(({ observationId }) => observationId), + batchId, + capturedAt, + observationIds: selectBatchObservations(observations, batchId, capturedAt).map(({ observationId }) => observationId), }; await this.store.setBatch(batch); } @@ -40,7 +43,8 @@ export class MarketOutbox { return { sent: false, count: 0 }; } - await this.send({ batchId: batch.batchId, capturedAt: batch.capturedAt, observations }); + const acknowledgement = await this.send({ batchId: batch.batchId, capturedAt: batch.capturedAt, observations }); + if (acknowledgement?.batchId !== batch.batchId) throw new Error("Сервер подтвердил другой пакет рынка"); await this.store.ack(batch); return { sent: true, count: observations.length }; } @@ -54,9 +58,22 @@ export function createMarketOutbox() { body: JSON.stringify(batch), }); if (!response.ok) throw new Error(`Импорт рынка отклонён (${response.status})`); + return response.json(); }); } +function selectBatchObservations(observations, batchId, capturedAt) { + const selected = []; + for (const observation of observations) { + const candidate = [...selected, observation]; + const payload = JSON.stringify({ batchId, capturedAt, observations: candidate }); + if (new TextEncoder().encode(payload).byteLength > MAX_BATCH_BYTES) break; + selected.push(observation); + } + if (!selected.length) throw new Error("Наблюдение рынка превышает допустимый размер пакета"); + return selected; +} + function createIndexedDbStore() { const database = openDatabase(); return { diff --git a/src/style.css b/src/style.css index 868ed46..3e8bcba 100644 --- a/src/style.css +++ b/src/style.css @@ -111,9 +111,15 @@ h3 { text-transform: uppercase; } -.status { +.status-stack { display: grid; width: 270px; + gap: 5px; +} + +.status { + display: grid; + width: 100%; min-height: 38px; place-items: center; padding: 8px 14px; @@ -127,6 +133,25 @@ h3 { border-color 180ms cubic-bezier(0.22, 1, 0.36, 1); } +.sync-status { + min-height: 18px; + color: var(--text-muted); + font-size: 0.75rem; + text-align: center; +} + +.sync-status[data-tone="working"] { + color: var(--accent-hover); +} + +.sync-status[data-tone="success"] { + color: var(--success); +} + +.sync-status[data-tone="error"] { + color: var(--error); +} + .status[data-tone="working"] { border-color: color-mix(in oklch, var(--accent), transparent 55%); background: var(--accent-soft); @@ -216,6 +241,92 @@ h3 { align-items: center; } +.reference-strip { + display: flex; + gap: 10px; + margin-block-end: 18px; + padding: 3px; + overflow-x: auto; +} + +.reference-strip[hidden] { + display: none; +} + +.reference-item { + position: relative; + flex: 0 0 220px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 11px; + background: var(--surface); +} + +.reference-item.is-selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} + +.reference-item__select { + display: grid; + width: 100%; + min-height: 68px; + grid-template-columns: 88px minmax(0, 1fr); + align-items: center; + gap: 10px; + padding: 6px 30px 6px 6px; + border: 0; + background: transparent; + color: var(--text); + text-align: start; +} + +.reference-item__select:hover { + background: var(--surface-muted); +} + +.reference-item__select img { + width: 88px; + height: 52px; + border-radius: 6px; + object-fit: cover; + background: oklch(0.18 0.01 255); +} + +.reference-item__select span, +.reference-item__select strong, +.reference-item__select small { + display: block; + min-width: 0; +} + +.reference-item__select small { + margin-block-start: 3px; + overflow: hidden; + color: var(--text-muted); + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reference-item__remove { + position: absolute; + inset-block-start: 5px; + inset-inline-end: 5px; + width: 26px; + height: 26px; + border: 0; + border-radius: 6px; + background: color-mix(in oklch, var(--surface), transparent 8%); + color: var(--text-muted); + line-height: 1; +} + +.reference-item__remove:hover { + background: var(--error-soft); + color: var(--error); +} + .calibration-guide { margin-block-end: 18px; border: 1px solid var(--border); @@ -856,7 +967,7 @@ td:nth-child(4) { flex-direction: column; } - .status { + .status-stack { width: 100%; } diff --git a/test/market-import-proxy.test.js b/test/market-import-proxy.test.js index 0ca3f78..4132bb6 100644 --- a/test/market-import-proxy.test.js +++ b/test/market-import-proxy.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { forwardMarketBatch } from "../vite.config.js"; +import { forwardMarketBatch, rejectNonLocalRequest } from "../vite.config.js"; test("keeps the import token in the local server-side forwarder", async () => { let forwarded; @@ -19,3 +19,12 @@ test("keeps the import token in the local server-side forwarder", async () => { assert.equal(forwarded.init.body, '{"batchId":"one"}'); assert.equal((await forwardMarketBatch("{}", {})).status, 503); }); + +test("rejects cross-site and non-JSON calls before using the import token", () => { + assert.equal( + rejectNonLocalRequest({ headers: { host: "127.0.0.1:5173", origin: "http://127.0.0.1:5173", "content-type": "application/json" } }), + null, + ); + assert.equal(rejectNonLocalRequest({ headers: { host: "127.0.0.1:5173", origin: "https://evil.example", "content-type": "application/json" } }), 403); + assert.equal(rejectNonLocalRequest({ headers: { host: "127.0.0.1:5173", origin: "http://127.0.0.1:5173", "content-type": "text/plain" } }), 415); +}); diff --git a/test/market-outbox.test.js b/test/market-outbox.test.js index 4836be2..3929d98 100644 --- a/test/market-outbox.test.js +++ b/test/market-outbox.test.js @@ -11,6 +11,7 @@ test("keeps the same batch across retries and deletes only after acknowledgement const send = async (batch) => { sent.push(batch); if (fail) throw new Error("offline"); + return { batchId: batch.batchId }; }; const options = { now: () => "2026-08-11T12:00:00.000Z", uuid: () => "batch-one" }; @@ -24,6 +25,27 @@ test("keeps the same batch across retries and deletes only after acknowledgement assert.deepEqual(await store.list(200), []); }); +test("keeps rows pending for a wrong acknowledgement and caps encoded batch size", async () => { + const store = memoryStore(); + for (let index = 0; index < 80; index += 1) { + await store.add({ observationId: `observation-${index}`, rawText: "x".repeat(4096), storeRawText: "y".repeat(4096) }); + } + let sent; + const outbox = new MarketOutbox( + store, + async (batch) => { + sent = batch; + return { batchId: "wrong" }; + }, + { now: () => "2026-08-11T12:00:00.000Z", uuid: () => "batch-two" }, + ); + + await assert.rejects(outbox.flush(), /другой пакет/); + assert.ok(sent.observations.length < 80); + assert.ok(new TextEncoder().encode(JSON.stringify(sent)).byteLength <= 80 * 1024); + assert.equal((await store.list(200)).length, 80); +}); + function memoryStore() { const observations = new Map(); let batch = null; diff --git a/vite.config.js b/vite.config.js index e1e5071..1ce525f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,6 @@ import { defineConfig, loadEnv } from "vite"; -const MAX_BATCH_BYTES = 512 * 1024; +const MAX_BATCH_BYTES = 96 * 1024; export async function forwardMarketBatch(body, { url, token, fetchImpl = fetch }) { if (!url || !token) return new Response("Market import is not configured", { status: 503 }); @@ -8,9 +8,19 @@ export async function forwardMarketBatch(body, { url, token, fetchImpl = fetch } method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body, + redirect: "error", + signal: AbortSignal.timeout(15_000), }); } +export function rejectNonLocalRequest(request) { + const rawContentType = request.headers["content-type"]; + const contentType = typeof rawContentType === "string" ? rawContentType.split(";", 1)[0].trim().toLowerCase() : ""; + if (contentType !== "application/json") return 415; + const host = typeof request.headers.host === "string" ? request.headers.host : ""; + return request.headers.origin === `http://${host}` ? null : 403; +} + function marketImportProxy(options) { return { name: "market-import-proxy", @@ -21,6 +31,12 @@ function marketImportProxy(options) { response.end(); return; } + const rejection = rejectNonLocalRequest(request); + if (rejection) { + response.statusCode = rejection; + response.end(); + return; + } try { const body = await readBody(request);