diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..63557a1 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,2 @@ +L2_MARKET_IMPORT_URL=https://l2.dokops.ru/api/l2/market/import +L2_MARKET_IMPORT_TOKEN= diff --git a/.gitignore b/.gitignore index 54653aa..869575d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .DS_Store npm-debug.log* +.env*.local diff --git a/PRODUCT.md b/PRODUCT.md index 40d0301..8c3ae02 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -10,7 +10,7 @@ product ## Product Purpose -Приложение подключает одно или несколько выбранных окон, подтверждает открытое окно продажи по его нижнему элементу, читает имя торговца и накапливает названия и цены из tooltip при ручном наведении на предметы. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows. +Приложение подключает одно или несколько выбранных окон, подтверждает открытое торговое окно по его нижнему элементу, читает тип сделки и имя торговца из заголовка магазина и накапливает названия и цены из tooltip при ручном наведении на предметы. Каждое распознавание сохраняется с датой и периодически отправляется в home-service. Успех прототипа означает, что весь путь от выбора окна до видимого и сохранённого результата работает локально на macOS и Windows. ## Brand Personality diff --git a/README.md b/README.md index fdd6ec2..2ba1680 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ ```bash npm install +cp .env.local.example .env.local +# Заполни L2_MARKET_IMPORT_TOKEN тем же значением, что HOME_SERVICE_L2_MARKET_IMPORT_TOKEN в Gitea Secrets. npm run dev ``` @@ -15,17 +17,75 @@ npm run dev На macOS браузеру потребуется разрешение **System Settings → Privacy & Security → Screen & System Audio Recording**. После выдачи разрешения браузер иногда нужно перезапустить. +## Как работает поиск + +Основное торговое окно считается единым объектом. Пользователь отмечает его нижний постоянный фрагмент, а приложение ищет этот фрагмент по всему кадру. Когда фрагмент найден, его координаты становятся точкой отсчёта для заголовка магазина и зоны tooltip. + +```text +Кадр +└─ Маркер магазина найден? + ├─ нет → заголовок и tooltip не проверяются + └─ да + ├─ OCR `Private Store(Buy/Sell) - имя` + │ └─ тип сделки + имя торговца + └─ поиск Price : внутри зоны tooltip + ├─ нет → название и цена не читаются + └─ да → OCR точной строки названия и точной строки цены +``` + +Заголовок и зона tooltip хранятся относительно маркера магазина. Поэтому всё торговое окно можно перемещать. Внутри зоны tooltip отдельно ищется `Price :`, поэтому сам tooltip тоже может двигаться при наведении на разные предметы. + +Из заголовка `Private Store(Buy) - Hikvision` получится `side: "buy"`, `merchant: "Hikvision"`; из `Private Store(Sell) - Dwa` — `side: "sell"`, `merchant: "Dwa"`. + ## Сценарий 1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры. 2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот. -3. Выдели постоянный нижний элемент продажи, например `Adena + Confirm`, как маркер продажи. По возможности не захватывай изменяемые числа. -4. Выдели имя торговца в верхней панели цели. +3. Выдели постоянный нижний элемент торгового окна, например `Adena + Confirm`, как маркер магазина. По возможности не захватывай изменяемые числа. +4. Выдели заголовок магазина одной строкой: от `Private Store(` до конца имени торговца. Рамку и кнопку закрытия не включай. 5. Выдели широкую область всех возможных положений tooltip, не захватывая нижний `Price` основного окна. 6. Внутри tooltip обведи только постоянную надпись `Price :`. 7. Отдельно выдели точную строку названия и точную строку цены. Для названия лучше использовать длинный предмет и оставить запас справа. 8. Сохрани калибровку, нажми «Начать сбор» и вернись в игру. -9. Наводи курсор на каждый предмет. Уникальные пары `название + цена` будут накапливаться в таблице. +9. Наводи курсор на каждый предмет. Уникальные пары `название + цена` будут накапливаться в таблице, а каждое распознавание — сохраняться с датой и отправляться на сервер. + +Наблюдения сначала попадают в IndexedDB браузера, затем отправляются пакетами раз в 30 секунд. При недоступном сервере пакет остаётся локально и повторяется с тем же ID после восстановления связи или перезапуска страницы; сервер не создаёт дубликат. + +Для калибровки нужен скриншот, на котором одновременно видны: + +- нижняя часть торгового окна; +- заголовок `Private Store(Buy/Sell) - имя`; +- открытый tooltip предмета; +- полная строка названия и полная строка `Price`. + +После выбора всех шести областей нажми «Проверить на скриншоте». Подключать игровое окно для этого не нужно. + +### Что именно выделять + +| Область | Правильный выбор | Частая ошибка | +| --- | --- | --- | +| Маркер магазина | Небольшой постоянный фрагмент внизу, например `Adena` и `Confirm` | Захватить баланс Adena или другое изменяемое число | +| Заголовок магазина | Вся строка `Private Store(Sell) - Dwa` или `Private Store(Buy) - Hikvision` | Обрезать `Buy/Sell` или имя; захватить рамку и кнопку закрытия | +| Зона tooltip | Вся полоса возможных положений всплывающего окна над сеткой предметов | Включить нижнюю строку `Price` основного окна | +| Якорь Price | Только постоянные символы `Price :` внутри tooltip | Добавить цену, которая меняется у каждого предмета | +| Название предмета | Одна верхняя строка tooltip с запасом справа | Захватить иконки, фон игры или строку цены | +| Строка цены | Одна строка `Price : 3,000,000 Adena` | Захватить дополнительную строку `(3 Million Adena)` | + +Лучше калиброваться на предмете с длинным названием и большой ценой. Тогда прямоугольники не обрежут более короткие варианты. + +## Диагностика + +В результате показывается последний обработанный кадр с рамками и каждый этап отдельно: + +- зелёная рамка означает, что шаблон прошёл заданный порог; +- красная показывает лучшее совпадение, которое не прошло порог; +- синяя показывает вычисленную область следующего шага; +- «Пропущено» означает, что предыдущий обязательный этап не прошёл; +- рядом с OCR показываются фактический crop, текст и уверенность. + +Если не находится магазин, смотри crop «Маркер магазина» и процент совпадения. Если магазин найден, но тип или имя пустые, смотри crop и фактический OCR этапа «Заголовок магазина». Если магазин найден, но предмет не появляется, смотри «Якорь Price :». Если оба якоря найдены, проблема уже в точных crop названия или цены. + +Диагностические изображения существуют только в памяти открытой страницы. Они не попадают в JSON результата и не отправляются вместе с рыночными наблюдениями. Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере. @@ -35,7 +95,7 @@ npm run dev - Одна калибровка рассчитана на один масштаб интерфейса Lineage II. - Сбор работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить сбор». - Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод. -- Отправки на сервер пока нет. +- Токен импорта хранится только в локальном Vite-процессе; в браузерный bundle он не попадает. ## Проверки diff --git a/index.html b/index.html index ba152d7..d9bbabc 100644 --- a/index.html +++ b/index.html @@ -48,7 +48,7 @@

Образец интерфейса

Калибровка

-

Отметь окно продажи, верхнее имя торговца и точные строки всплывающего описания предмета.

+

Отметь торговое окно, его заголовок и точные строки всплывающего описания предмета.

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

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

+

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

@@ -102,6 +119,7 @@
+
diff --git a/src/main.js b/src/main.js index 8b8a714..604e1f9 100644 --- a/src/main.js +++ b/src/main.js @@ -2,11 +2,10 @@ import "./style.css"; import { isCalibrationReady, isRectInside, - parseMerchantText, + parseStoreHeaderText, parseTooltipText, rectFromPoints, resolveFieldRect, - resolveScreenRect, } from "./parser.js"; import { findAnchor, @@ -14,11 +13,12 @@ import { recognizeText, terminateVision, } from "./vision.js"; +import { createMarketOutbox } from "./market-outbox.js"; -const STORAGE_KEY = "l2-market-parser.calibration.v3"; +const STORAGE_KEY = "l2-market-parser.calibration.v4"; const regionNames = { - saleAnchor: "Маркер продажи", - merchant: "Имя торговца", + saleAnchor: "Маркер магазина", + storeHeader: "Заголовок магазина", tooltipSearch: "Зона поиска tooltip", tooltipAnchor: "Якорь Price :", itemName: "Название предмета", @@ -28,12 +28,11 @@ const regionNames = { function emptyCalibration() { return { saleAnchor: null, - merchantRegion: null, + storeHeaderRegion: null, tooltipSearchRegion: null, tooltipAnchor: null, itemNameRegion: null, itemPriceRegion: null, - referenceSize: null, threshold: 0.8, }; } @@ -58,6 +57,7 @@ const state = { results: [], collecting: false, }; +const marketOutbox = createMarketOutbox(); const elements = { status: document.querySelector("#global-status"), @@ -77,6 +77,7 @@ const elements = { regionCount: document.querySelector("#region-count"), regionList: document.querySelector("#region-list"), saveCalibration: document.querySelector("#save-calibration"), + testCalibration: document.querySelector("#test-calibration"), clearCalibration: document.querySelector("#clear-calibration"), recognizeAll: document.querySelector("#recognize-all"), clearResults: document.querySelector("#clear-results"), @@ -296,6 +297,7 @@ function setReference(canvas) { elements.canvas.height = canvas.height; elements.canvas.hidden = false; elements.canvasEmpty.hidden = true; + elements.testCalibration.disabled = false; drawCalibration(); setStatus(`Снимок готов: ${canvas.width} × ${canvas.height}`, "success"); } @@ -349,33 +351,94 @@ function cropCanvas(source, rect) { return canvas; } +function canvasPreview(canvas, maxWidth = 760) { + const scale = Math.min(1, maxWidth / canvas.width); + const preview = document.createElement("canvas"); + preview.width = Math.max(1, Math.round(canvas.width * scale)); + preview.height = Math.max(1, Math.round(canvas.height * scale)); + preview.getContext("2d").drawImage(canvas, 0, 0, preview.width, preview.height); + return preview.toDataURL("image/jpeg", 0.78); +} + +function cropPreview(frame, rect) { + return isRectInside(rect, frame.width, frame.height) + ? cropCanvas(frame, rect).toDataURL("image/png") + : null; +} + +function annotatedPreview(frame, boxes) { + const scale = Math.min(1, 760 / frame.width); + const preview = document.createElement("canvas"); + preview.width = Math.max(1, Math.round(frame.width * scale)); + preview.height = Math.max(1, Math.round(frame.height * scale)); + const context = preview.getContext("2d"); + context.drawImage(frame, 0, 0, preview.width, preview.height); + context.font = "12px system-ui"; + + boxes.forEach(({ rect, label, status }) => { + const color = status === "found" ? "#42c884" : status === "not-found" ? "#ef6a67" : "#6d9ff2"; + const x = rect.x * scale; + const y = rect.y * scale; + const width = rect.width * scale; + const height = rect.height * scale; + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = 2; + context.strokeRect(x, y, width, height); + const labelWidth = context.measureText(label).width + 10; + const labelY = Math.max(0, y - 20); + context.fillRect(x, labelY, labelWidth, 20); + context.fillStyle = "#f7f9fc"; + context.fillText(label, x + 5, labelY + 14); + }); + + return preview.toDataURL("image/jpeg", 0.82); +} + +function addDiagnosticStage(diagnostics, key, label, status, detail, image = null) { + diagnostics.stages.push({ key, label, status, detail, image }); +} + +function addSkippedStages(diagnostics, stages, reason) { + stages.forEach(([key, label]) => { + addDiagnosticStage(diagnostics, key, label, "skipped", `Не запускалось: ${reason}`); + }); +} + +function finishDiagnostics(result, frame, diagnostics) { + diagnostics.preview = annotatedPreview(frame, diagnostics.boxes); + result.diagnostics = diagnostics; +} + function addRegion(rect) { const type = elements.regionType.value; - state.calibration.referenceSize = { - width: state.referenceCanvas.width, - height: state.referenceCanvas.height, - }; if (type === "saleAnchor") { state.calibration.saleAnchor = { ...rect, image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), }; + state.calibration.storeHeaderRegion = null; state.calibration.tooltipSearchRegion = null; - elements.regionType.value = "merchant"; - setStatus("Маркер продажи выбран. Теперь выдели имя торговца", "success"); - } else if (type === "merchant") { - state.calibration.merchantRegion = { - xRatio: rect.x / state.referenceCanvas.width, - yRatio: rect.y / state.referenceCanvas.height, - widthRatio: rect.width / state.referenceCanvas.width, - heightRatio: rect.height / state.referenceCanvas.height, + elements.regionType.value = "storeHeader"; + setStatus("Маркер выбран. Теперь выдели строку Private Store(...) - имя", "success"); + } else if (type === "storeHeader") { + if (!state.calibration.saleAnchor) { + setStatus("Сначала выдели маркер магазина", "error"); + return; + } + + state.calibration.storeHeaderRegion = { + offsetX: rect.x - state.calibration.saleAnchor.x, + offsetY: rect.y - state.calibration.saleAnchor.y, + width: rect.width, + height: rect.height, }; elements.regionType.value = "tooltipSearch"; - setStatus("Имя торговца выбрано. Теперь выдели зону появления tooltip", "success"); + setStatus("Заголовок выбран. Теперь выдели зону появления tooltip", "success"); } else if (type === "tooltipSearch") { if (!state.calibration.saleAnchor) { - setStatus("Сначала выдели маркер продажи", "error"); + setStatus("Сначала выдели маркер магазина", "error"); return; } @@ -420,10 +483,9 @@ function addRegion(rect) { renderCalibration(); } -function referenceMerchantRect() { - const size = state.calibration.referenceSize; - return size && state.calibration.merchantRegion - ? resolveScreenRect(state.calibration.merchantRegion, size.width, size.height) +function referenceStoreHeaderRect() { + return state.calibration.saleAnchor && state.calibration.storeHeaderRegion + ? resolveFieldRect(state.calibration.saleAnchor, state.calibration.storeHeaderRegion) : null; } @@ -467,8 +529,8 @@ function drawCalibration() { if (state.calibration.saleAnchor) { drawBox(context, state.calibration.saleAnchor, "Продажа", "oklch(0.72 0.16 65)"); } - if (referenceMerchantRect()) { - drawBox(context, referenceMerchantRect(), "Торговец", "oklch(0.65 0.17 250)"); + if (referenceStoreHeaderRect()) { + drawBox(context, referenceStoreHeaderRect(), "Тип + торговец", "oklch(0.65 0.17 250)"); } if (referenceTooltipSearchRect()) { drawBox(context, referenceTooltipSearchRect(), "Поиск tooltip", "oklch(0.64 0.14 155)"); @@ -502,10 +564,11 @@ function drawCalibration() { function updateDrawHint() { const hints = { saleAnchor: - "Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.", - merchant: "Выдели только имя персонажа в верхней панели цели, без уровня клана.", + "Выдели постоянный нижний фрагмент торгового окна, например подпись Adena и кнопку Confirm. Числа лучше не включать.", + storeHeader: + "Выдели одну строку целиком: Private Store(Buy/Sell) - имя. Не включай рамку и кнопку закрытия.", tooltipSearch: - "Выдели широкую область всех возможных положений tooltip. Нижний блок Price окна продажи не включай.", + "Выдели широкую область всех возможных положений tooltip. Нижний блок Price торгового окна не включай.", tooltipAnchor: "Обведи только постоянную надпись Price : внутри tooltip, без изменяемого числа.", itemName: "Выдели точную верхнюю строку названия. Возьми скриншот с длинным названием и оставь запас справа.", @@ -518,8 +581,8 @@ function renderCalibration() { elements.threshold.value = String(state.calibration.threshold); elements.thresholdValue.value = state.calibration.threshold.toFixed(2); const regions = [ - ["saleAnchor", "saleAnchor", "Шаблон поиска окна продажи"], - ["merchant", "merchantRegion", "OCR только после продажи"], + ["saleAnchor", "saleAnchor", "Шаблон поиска торгового окна"], + ["storeHeader", "storeHeaderRegion", "Тип сделки и имя торговца"], ["tooltipSearch", "tooltipSearchRegion", "Ограничивает поиск всплывающего окна"], ["tooltipAnchor", "tooltipAnchor", "Постоянная надпись Price :"], ["itemName", "itemNameRegion", "Точная строка OCR"], @@ -547,7 +610,10 @@ function renderCalibration() { remove.setAttribute("aria-label", `Удалить ${regionNames[type]}`); remove.addEventListener("click", () => { state.calibration[key] = null; - if (key === "saleAnchor") state.calibration.tooltipSearchRegion = null; + if (key === "saleAnchor") { + state.calibration.storeHeaderRegion = null; + state.calibration.tooltipSearchRegion = null; + } if (key === "tooltipAnchor") { state.calibration.itemNameRegion = null; state.calibration.itemPriceRegion = null; @@ -599,6 +665,68 @@ function imageToCanvas(dataUrl) { }); } +function appendDiagnostics(section, result) { + if (!result.diagnostics) return; + + const details = document.createElement("details"); + details.className = "diagnostics"; + details.open = true; + const successful = result.diagnostics.stages.filter((stage) => stage.status === "found").length; + const summary = document.createElement("summary"); + summary.textContent = `Диагностика последнего кадра: ${successful}/${result.diagnostics.stages.length} этапов выполнено`; + const content = document.createElement("div"); + content.className = "diagnostics__content"; + + const preview = document.createElement("div"); + preview.className = "diagnostics__preview"; + if (result.diagnostics.preview) { + const image = document.createElement("img"); + image.src = result.diagnostics.preview; + image.alt = `Последний кадр ${result.source} с найденными областями`; + preview.append(image); + } + const caption = document.createElement("p"); + caption.textContent = "Зелёная рамка найдена, красная не прошла порог, синяя показывает область следующего шага."; + preview.append(caption); + + const stages = document.createElement("div"); + stages.className = "stage-list"; + const statusNames = { + found: "Найдено", + "not-found": "Не найдено", + skipped: "Пропущено", + error: "Ошибка", + }; + result.diagnostics.stages.forEach((stage) => { + const row = document.createElement("div"); + row.className = "stage-row"; + row.dataset.status = stage.status; + const dot = document.createElement("span"); + dot.className = "stage-row__dot"; + dot.title = statusNames[stage.status]; + const label = document.createElement("strong"); + label.textContent = stage.label; + const detail = document.createElement("p"); + detail.textContent = `${statusNames[stage.status]}. ${stage.detail}`; + if (stage.image) { + const image = document.createElement("img"); + image.src = stage.image; + image.alt = `Фрагмент этапа «${stage.label}»`; + row.append(dot, label, detail, image); + } else { + const empty = document.createElement("span"); + empty.className = "stage-row__empty"; + empty.textContent = "Нет изображения"; + row.append(dot, label, detail, empty); + } + stages.append(row); + }); + + content.append(preview, stages); + details.append(summary, content); + section.append(details); +} + function renderResults() { elements.resultsList.replaceChildren(); elements.copyResults.disabled = !state.results.some((result) => result.items.length); @@ -623,14 +751,14 @@ function renderResults() { const title = document.createElement("h3"); title.textContent = result.source; const meta = document.createElement("p"); - meta.textContent = result.saleOpen - ? `Торговец: ${result.merchant || "распознаётся"}, маркер ${(result.score * 100).toFixed(1)}%` - : `Окно продажи не найдено, лучшее совпадение ${(result.score * 100).toFixed(1)}%`; + meta.textContent = result.saleFound + ? `${result.side === "buy" ? "Покупка" : result.side === "sell" ? "Продажа" : "Тип распознаётся"} · ${result.merchant || "торговец распознаётся"}, маркер ${(result.score * 100).toFixed(1)}%` + : `Торговое окно не найдено, лучшее совпадение ${(result.score * 100).toFixed(1)}%`; heading.append(title, meta); const badge = document.createElement("span"); badge.className = "result-badge"; - badge.dataset.tone = result.saleOpen ? "success" : "error"; - badge.textContent = result.saleOpen ? `${result.items.length} предметов` : "Нет продажи"; + badge.dataset.tone = result.saleFound ? "success" : "error"; + badge.textContent = result.saleFound ? `${result.items.length} предметов` : "Нет магазина"; header.append(heading, badge); section.append(header); @@ -639,7 +767,7 @@ function renderResults() { error.className = "result-error"; error.textContent = result.error; section.append(error); - } else if (result.saleOpen && result.items.length) { + } else if (result.saleFound && result.items.length) { const table = document.createElement("table"); table.innerHTML = "ПредметЦенаПоследний разУверенность OCR"; const body = document.createElement("tbody"); @@ -658,13 +786,14 @@ function renderResults() { }); table.append(body); section.append(table); - } else if (result.saleOpen) { + } else if (result.saleFound) { const hint = document.createElement("p"); hint.className = "result-hint"; - hint.textContent = "Окно продажи найдено. Наводи курсор на иконки предметов и задерживай его до появления tooltip."; + hint.textContent = "Торговое окно найдено. Наводи курсор на иконки предметов и задерживай его до появления tooltip."; section.append(hint); } + appendDiagnostics(section, result); elements.resultsList.append(section); }); } @@ -675,11 +804,13 @@ function resultFor(source) { result = { sourceId: source.id, source: source.name, - saleOpen: false, + saleFound: false, score: 0, + side: "", merchant: "", + storeRawText: "", + storeConfidence: 0, items: [], - merchantScan: 0, misses: 0, error: null, }; @@ -692,103 +823,325 @@ function delay(milliseconds) { return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); } +async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source = null) { + const diagnostics = { boxes: [], stages: [], preview: null }; + addDiagnosticStage( + diagnostics, + "capture", + "Захват кадра", + "found", + `${frame.width} × ${frame.height}`, + canvasPreview(frame, 240), + ); + result.error = null; + + const saleMatch = await findAnchor(frame, saleTemplate); + const saleRect = { + x: saleMatch.x, + y: saleMatch.y, + width: saleTemplate.width, + height: saleTemplate.height, + }; + const saleFound = saleMatch.score >= state.calibration.threshold; + result.score = saleMatch.score; + result.saleFound = saleFound; + diagnostics.boxes.push({ rect: saleRect, label: "Маркер магазина", status: saleFound ? "found" : "not-found" }); + addDiagnosticStage( + diagnostics, + "sale", + "Маркер магазина", + saleFound ? "found" : "not-found", + `${(saleMatch.score * 100).toFixed(1)}%, нужно ${(state.calibration.threshold * 100).toFixed(0)}%`, + cropPreview(frame, saleRect), + ); + + if (!saleFound) { + result.misses += 1; + if (result.misses >= 3) { + result.side = ""; + result.merchant = ""; + result.items = []; + } + addSkippedStages( + diagnostics, + [ + ["storeHeader", "Заголовок магазина"], + ["tooltip", "Якорь Price :"], + ["itemName", "Название предмета"], + ["itemPrice", "Цена предмета"], + ], + "маркер магазина не прошёл порог", + ); + finishDiagnostics(result, frame, diagnostics); + return; + } + + result.misses = 0; + const storeHeaderRect = resolveFieldRect(saleMatch, state.calibration.storeHeaderRegion); + const tooltipSearchRect = resolveFieldRect( + saleMatch, + state.calibration.tooltipSearchRegion, + ); + const storeHeaderBox = { rect: storeHeaderRect, label: "Заголовок", status: "info" }; + diagnostics.boxes.push(storeHeaderBox); + diagnostics.boxes.push({ rect: tooltipSearchRect, label: "Зона tooltip", status: "info" }); + + 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"; + 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; + storeHeaderBox.label = `${header.side === "buy" ? "Buy" : "Sell"} · ${header.merchant}`; + } + addDiagnosticStage( + diagnostics, + "storeHeader", + "Заголовок магазина", + header ? "found" : "not-found", + header + ? `OCR ${headerOcr.confidence}%: ${header.side === "buy" ? "Покупка" : "Продажа"}, ${header.merchant}` + : `OCR ${headerOcr.confidence}%: «${headerOcr.text || "пусто"}» — не удалось разобрать тип и имя`, + cropPreview(frame, storeHeaderRect), + ); + } else { + storeHeaderBox.status = "not-found"; + addDiagnosticStage( + diagnostics, + "storeHeader", + "Заголовок магазина", + "error", + "Область вышла за границы кадра", + ); + } + + if (!isRectInside(tooltipSearchRect, frame.width, frame.height)) { + addDiagnosticStage( + diagnostics, + "tooltip", + "Якорь Price :", + "error", + "Зона поиска tooltip вышла за границы кадра", + ); + addSkippedStages( + diagnostics, + [ + ["itemName", "Название предмета"], + ["itemPrice", "Цена предмета"], + ], + "зона поиска tooltip недоступна", + ); + finishDiagnostics(result, frame, diagnostics); + return; + } + + const tooltipSearch = cropCanvas(frame, tooltipSearchRect); + const tooltipMatch = await findAnchor(tooltipSearch, tooltipTemplate); + const tooltipRect = { + x: tooltipSearchRect.x + tooltipMatch.x, + y: tooltipSearchRect.y + tooltipMatch.y, + width: tooltipTemplate.width, + height: tooltipTemplate.height, + }; + const tooltipFound = tooltipMatch.score >= state.calibration.threshold; + diagnostics.boxes.push({ rect: tooltipRect, label: "Price :", status: tooltipFound ? "found" : "not-found" }); + addDiagnosticStage( + diagnostics, + "tooltip", + "Якорь Price :", + tooltipFound ? "found" : "not-found", + `${(tooltipMatch.score * 100).toFixed(1)}%, нужно ${(state.calibration.threshold * 100).toFixed(0)}%`, + cropPreview(frame, tooltipRect), + ); + + if (!tooltipFound) { + addSkippedStages( + diagnostics, + [ + ["itemName", "Название предмета"], + ["itemPrice", "Цена предмета"], + ], + "якорь Price : не найден", + ); + 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" }; + const priceBox = { rect: priceRect, label: "Цена", status: "info" }; + diagnostics.boxes.push(nameBox, priceBox); + + if ( + !isRectInside(nameRect, frame.width, frame.height) || + !isRectInside(priceRect, frame.width, frame.height) + ) { + nameBox.status = "not-found"; + priceBox.status = "not-found"; + addSkippedStages( + diagnostics, + [ + ["itemName", "Название предмета"], + ["itemPrice", "Цена предмета"], + ], + "точные строки вышли за границы кадра", + ); + finishDiagnostics(result, frame, diagnostics); + 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"; + addDiagnosticStage( + diagnostics, + "itemName", + "Название предмета", + nameOcr.text ? "found" : "not-found", + nameOcr.text ? `OCR ${nameOcr.confidence}%: ${nameOcr.text}` : "OCR вернул пустую строку", + cropPreview(frame, nameRect), + ); + addDiagnosticStage( + diagnostics, + "itemPrice", + "Цена предмета", + item ? "found" : "not-found", + priceOcr.text ? `OCR ${priceOcr.confidence}%: ${priceOcr.text}` : "OCR вернул пустую строку", + cropPreview(frame, priceRect), + ); + finishDiagnostics(result, frame, diagnostics); + if (!item) return; + + const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2); + const key = `${item.name.toLocaleLowerCase("en-US")}:${item.priceAdena}`; + const existing = result.items.find((entry) => entry.key === key); + const seenAt = new Date().toISOString(); + + if (source) { + try { + await marketOutbox.enqueue({ + observationId: crypto.randomUUID(), + seenAt, + sourceId: source.id, + source: source.name.slice(0, 256), + side: result.side || null, + 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, + name: item.name.slice(0, 256), + priceAdena: item.priceAdena, + rawText: item.rawText.slice(0, 4096), + key: key.slice(0, 512), + confidence: Number.isFinite(confidence) ? Math.max(0, Math.min(100, confidence)) : 0, + }); + } catch { + state.collecting = false; + throw new Error("Не удалось сохранить наблюдение рынка локально"); + } + } + + if (existing) { + existing.seenAt = seenAt; + existing.confidence = confidence; + } else { + result.items.push({ ...item, key, confidence, seenAt }); + setStatus(`Добавлен предмет: ${item.name}`, "success"); + } +} + async function scanSource(source, saleTemplate, tooltipTemplate) { const result = resultFor(source); + let frame; + try { + frame = await captureSource(source); + await analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source); + } catch (error) { + result.saleFound = false; + result.error = error.message || "Ошибка распознавания"; + result.diagnostics = { + preview: frame ? canvasPreview(frame) : null, + boxes: [], + stages: [ + { + key: frame ? "pipeline" : "capture", + label: frame ? "Обработка кадра" : "Захват кадра", + status: "error", + detail: result.error, + image: frame ? canvasPreview(frame, 240) : null, + }, + ], + }; + } +} + +function loadCalibrationTemplates() { + return Promise.all([ + imageToCanvas(state.calibration.saleAnchor.image), + imageToCanvas(state.calibration.tooltipAnchor.image), + ]); +} + +async function testCalibration() { + if (!state.referenceCanvas) { + setStatus("Сначала загрузи скриншот или сделай снимок окна", "error"); + return; + } + if (!isCalibrationReady(state.calibration)) { + setStatus("Для проверки нужно заполнить все 6 областей", "error"); + return; + } + + state.collecting = false; + elements.recognizeAll.textContent = "Начать сбор"; + elements.testCalibration.disabled = true; + setStatus("Проверяю калибровку на текущем скриншоте", "working"); + let result; try { - const frame = await captureSource(source); - const match = await findAnchor(frame, saleTemplate); - result.score = match.score; - result.error = null; - - if (match.score < state.calibration.threshold) { - result.misses += 1; - if (result.misses >= 3) { - result.saleOpen = false; - result.merchant = ""; - result.items = []; - } - return; - } - - result.misses = 0; - result.saleOpen = true; - - const merchantRect = resolveScreenRect( - state.calibration.merchantRegion, - frame.width, - frame.height, - ); - const tooltipSearchRect = resolveFieldRect( - match, - state.calibration.tooltipSearchRegion, - ); - - if ( - !isRectInside(merchantRect, frame.width, frame.height) || - !isRectInside(tooltipSearchRect, frame.width, frame.height) - ) { - result.error = "Одна из областей вышла за границы кадра. Сделай калибровку заново."; - return; - } - - result.merchantScan += 1; - if (!result.merchant || result.merchantScan % 10 === 0) { - const merchantOcr = await recognizeText(frame, merchantRect, { singleLine: true }); - const merchant = parseMerchantText(merchantOcr.text); - if (merchant && result.merchant && merchant !== result.merchant) result.items = []; - if (merchant) result.merchant = merchant; - } - - const tooltipSearch = cropCanvas(frame, tooltipSearchRect); - const tooltipMatch = await findAnchor(tooltipSearch, tooltipTemplate); - if (tooltipMatch.score < state.calibration.threshold) return; - - const tooltipPosition = { - x: tooltipSearchRect.x + tooltipMatch.x, - y: tooltipSearchRect.y + tooltipMatch.y, - }; - const nameRect = resolveFieldRect( - tooltipPosition, - state.calibration.itemNameRegion, - ); - const priceRect = resolveFieldRect( - tooltipPosition, - state.calibration.itemPriceRegion, - ); - - if ( - !isRectInside(nameRect, frame.width, frame.height) || - !isRectInside(priceRect, frame.width, frame.height) - ) { - result.error = "Строки tooltip вышли за границы кадра. Проверь их калибровку."; - 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}`); - if (!item) return; - const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2); - - const key = `${item.name.toLocaleLowerCase("en-US")}:${item.priceAdena}`; - const existing = result.items.find((entry) => entry.key === key); - const seenAt = new Date().toISOString(); - - if (existing) { - existing.seenAt = seenAt; - existing.confidence = confidence; - } else { - result.items.push({ - ...item, - key, - confidence, - seenAt, - }); - setStatus(`Добавлен предмет: ${item.name}`, "success"); - } + state.results = state.results.filter((result) => result.sourceId !== "calibration-test"); + result = resultFor({ id: "calibration-test", name: "Тестовый скриншот" }); + const [saleTemplate, tooltipTemplate] = await loadCalibrationTemplates(); + await analyzeFrame(state.referenceCanvas, result, saleTemplate, tooltipTemplate); + renderResults(); + showView("results"); + setStatus("Проверка завершена. Смотри этапы и изображения ниже", "success"); } catch (error) { - result.error = error.message || "Ошибка распознавания"; + if (result) { + result.saleFound = false; + result.error = error.message || "Ошибка проверки"; + result.diagnostics = { + preview: canvasPreview(state.referenceCanvas), + boxes: [], + stages: [ + { + key: "pipeline", + label: "Обработка скриншота", + status: "error", + detail: result.error, + image: canvasPreview(state.referenceCanvas, 240), + }, + ], + }; + renderResults(); + showView("results"); + } + setStatus(error.message || "Не удалось проверить калибровку", "error"); + } finally { + elements.testCalibration.disabled = false; } } @@ -797,6 +1150,7 @@ async function toggleCollection() { state.collecting = false; elements.recognizeAll.textContent = "Начать сбор"; setStatus("Сбор остановлен", "neutral"); + void syncMarketOutbox(); return; } @@ -806,7 +1160,7 @@ async function toggleCollection() { return; } if (!isCalibrationReady(state.calibration)) { - setStatus("Сначала откалибруй продажу, торговца и tooltip", "error"); + setStatus("Сначала откалибруй торговое окно и tooltip", "error"); return; } @@ -822,10 +1176,7 @@ async function toggleCollection() { try { await delay(2000); - const [saleTemplate, tooltipTemplate] = await Promise.all([ - imageToCanvas(state.calibration.saleAnchor.image), - imageToCanvas(state.calibration.tooltipAnchor.image), - ]); + const [saleTemplate, tooltipTemplate] = await loadCalibrationTemplates(); while (state.collecting) { const sources = state.sources.filter((source) => source.active); @@ -845,6 +1196,17 @@ async function toggleCollection() { } } +async function syncMarketOutbox() { + try { + const result = await marketOutbox.flush(); + if (result.sent && !state.collecting) { + setStatus(`Отправлено наблюдений: ${result.count}`, "success"); + } + } catch (error) { + if (!state.collecting) setStatus(error.message || "Не удалось обновить данные рынка", "error"); + } +} + function clearResults() { state.results = []; renderResults(); @@ -853,7 +1215,14 @@ function clearResults() { async function copyResults() { try { - await navigator.clipboard.writeText(JSON.stringify(state.results, null, 2)); + const exportable = state.results.map((result) => ({ + source: result.source, + side: result.saleFound ? result.side || null : null, + merchant: result.saleFound ? result.merchant || null : null, + saleFound: result.saleFound, + items: result.items.map(({ key, ...item }) => item), + })); + await navigator.clipboard.writeText(JSON.stringify(exportable, null, 2)); setStatus("JSON скопирован", "success"); } catch { setStatus("Не удалось скопировать JSON", "error"); @@ -871,6 +1240,7 @@ elements.threshold.addEventListener("input", (event) => { elements.thresholdValue.value = state.calibration.threshold.toFixed(2); }); elements.saveCalibration.addEventListener("click", saveCalibration); +elements.testCalibration.addEventListener("click", testCalibration); elements.clearCalibration.addEventListener("click", clearCalibration); elements.recognizeAll.addEventListener("click", toggleCollection); elements.clearResults.addEventListener("click", clearResults); @@ -901,6 +1271,9 @@ window.addEventListener("beforeunload", () => { state.sources.forEach((source) => source.stream.getTracks().forEach((track) => track.stop())); terminateVision(); }); +window.addEventListener("online", syncMarketOutbox); +window.setInterval(syncMarketOutbox, 30_000); +void syncMarketOutbox(); renderSources(); renderCalibration(); diff --git a/src/market-outbox.js b/src/market-outbox.js new file mode 100644 index 0000000..702628c --- /dev/null +++ b/src/market-outbox.js @@ -0,0 +1,134 @@ +const DATABASE_NAME = "l2-market-parser"; +const OBSERVATIONS = "market-observations"; +const META = "meta"; +const PENDING_BATCH = "pending-market-batch"; + +export class MarketOutbox { + constructor(store, send, { now = () => new Date().toISOString(), uuid = () => crypto.randomUUID() } = {}) { + this.store = store; + this.send = send; + this.now = now; + this.uuid = uuid; + this.flushing = null; + } + + enqueue(observation) { + return this.store.add(observation); + } + + flush() { + if (!this.flushing) this.flushing = this.flushOnce().finally(() => (this.flushing = null)); + return this.flushing; + } + + async flushOnce() { + let batch = await this.store.getBatch(); + if (!batch) { + const observations = await this.store.list(200); + if (!observations.length) return { sent: false, count: 0 }; + batch = { + batchId: this.uuid(), + capturedAt: this.now(), + observationIds: observations.map(({ observationId }) => observationId), + }; + await this.store.setBatch(batch); + } + + const observations = await this.store.getMany(batch.observationIds); + if (!observations.length) { + await this.store.ack(batch); + return { sent: false, count: 0 }; + } + + await this.send({ batchId: batch.batchId, capturedAt: batch.capturedAt, observations }); + await this.store.ack(batch); + return { sent: true, count: observations.length }; + } +} + +export function createMarketOutbox() { + return new MarketOutbox(createIndexedDbStore(), async (batch) => { + const response = await fetch("/api/market-import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(batch), + }); + if (!response.ok) throw new Error(`Импорт рынка отклонён (${response.status})`); + }); +} + +function createIndexedDbStore() { + const database = openDatabase(); + return { + async add(observation) { + const db = await database; + await requestInTransaction(db, OBSERVATIONS, "readwrite", (store) => store.add(observation)); + }, + async list(limit) { + const db = await database; + return requestInTransaction(db, OBSERVATIONS, "readonly", (store) => store.getAll(undefined, limit)); + }, + async getMany(ids) { + const db = await database; + const transaction = db.transaction(OBSERVATIONS, "readonly"); + const store = transaction.objectStore(OBSERVATIONS); + const values = await Promise.all(ids.map((id) => request(store.get(id)))); + await transactionDone(transaction); + return values.filter(Boolean); + }, + async getBatch() { + const db = await database; + const value = await requestInTransaction(db, META, "readonly", (store) => store.get(PENDING_BATCH)); + return value?.batch ?? null; + }, + async setBatch(batch) { + const db = await database; + // ponytail: one local collector tab is assumed; add a cross-tab lease if that becomes a real workflow. + await requestInTransaction(db, META, "readwrite", (store) => store.put({ key: PENDING_BATCH, batch })); + }, + async ack(batch) { + const db = await database; + const transaction = db.transaction([OBSERVATIONS, META], "readwrite"); + const meta = transaction.objectStore(META); + const current = await request(meta.get(PENDING_BATCH)); + if (current?.batch?.batchId === batch.batchId) { + batch.observationIds.forEach((id) => transaction.objectStore(OBSERVATIONS).delete(id)); + meta.delete(PENDING_BATCH); + } + await transactionDone(transaction); + }, + }; +} + +function openDatabase() { + return new Promise((resolve, reject) => { + const open = indexedDB.open(DATABASE_NAME, 1); + open.onupgradeneeded = () => { + open.result.createObjectStore(OBSERVATIONS, { keyPath: "observationId" }); + open.result.createObjectStore(META, { keyPath: "key" }); + }; + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error); + }); +} + +function requestInTransaction(database, storeName, mode, operation) { + const transaction = database.transaction(storeName, mode); + const result = request(operation(transaction.objectStore(storeName))); + return Promise.all([result, transactionDone(transaction)]).then(([value]) => value); +} + +function request(value) { + return new Promise((resolve, reject) => { + value.onsuccess = () => resolve(value.result); + value.onerror = () => reject(value.error); + }); +} + +function transactionDone(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); +} diff --git a/src/parser.js b/src/parser.js index f5ac74f..f5c8c43 100644 --- a/src/parser.js +++ b/src/parser.js @@ -16,15 +16,6 @@ export function resolveFieldRect(anchorPosition, field) { }; } -export function resolveScreenRect(region, width, height) { - return { - x: Math.round(region.xRatio * width), - y: Math.round(region.yRatio * height), - width: Math.round(region.widthRatio * width), - height: Math.round(region.heightRatio * height), - }; -} - export function normalizeOcrText(text) { return text.replace(/\s+/g, " ").trim(); } @@ -40,11 +31,19 @@ export function parseFieldValue(type, text) { return digits ? Number(digits) : null; } -export function parseMerchantText(text) { - return text - .split(/\r?\n/) - .map(normalizeOcrText) - .find(Boolean) ?? ""; +export function parseStoreHeaderText(text) { + const normalized = normalizeOcrText(text); + const match = normalized.match( + /private\s*store.*?\b(buy|sell)\b.*?[-–—]\s*(.+)$/i, + ); + + if (!match?.[2]?.trim()) return null; + + return { + side: match[1].toLowerCase(), + merchant: match[2].trim(), + rawText: normalized, + }; } export function parseTooltipText(text) { @@ -75,7 +74,7 @@ export function isCalibrationReady(calibration) { calibration?.saleAnchor?.image && calibration.saleAnchor.width >= 4 && calibration.saleAnchor.height >= 4 && - calibration.merchantRegion && + calibration.storeHeaderRegion && calibration.tooltipSearchRegion && calibration.tooltipAnchor?.image && calibration.itemNameRegion && diff --git a/src/style.css b/src/style.css index ca4b677..868ed46 100644 --- a/src/style.css +++ b/src/style.css @@ -216,6 +216,73 @@ h3 { align-items: center; } +.calibration-guide { + margin-block-end: 18px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); +} + +.calibration-guide summary { + padding: 13px 16px; + cursor: pointer; + font-weight: 700; +} + +.calibration-guide__content { + display: grid; + gap: 16px; + padding: 0 16px 16px; + border-block-start: 1px solid var(--border); +} + +.calibration-guide ol { + display: grid; + gap: 9px; + margin: 14px 0 0; + padding-inline-start: 24px; +} + +.calibration-guide li { + padding-inline-start: 5px; +} + +.calibration-guide li strong, +.calibration-guide li span { + display: block; +} + +.calibration-guide li span { + margin-block-start: 2px; + color: var(--text-muted); + font-size: 0.84rem; + line-height: 1.45; +} + +.pipeline-map { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px; + color: var(--text-muted); + font-size: 0.8rem; +} + +.pipeline-map span { + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface-muted); + color: var(--text); + font-weight: 650; +} + +.pipeline-map b, +.pipeline-map i { + font-style: normal; + font-weight: 500; +} + .button-row { display: flex; flex-wrap: wrap; @@ -540,7 +607,7 @@ input[type="range"] { .inspector__actions { display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: 1fr; gap: 8px; padding-block-start: 2px; } @@ -605,6 +672,108 @@ input[type="range"] { color: var(--text-muted); } +.diagnostics { + border-block-start: 1px solid var(--border); +} + +.diagnostics summary { + padding: 13px 20px; + cursor: pointer; + color: var(--text-muted); + font-size: 0.82rem; + font-weight: 700; +} + +.diagnostics__content { + display: grid; + grid-template-columns: minmax(260px, 0.9fr) minmax(420px, 1.4fr); + gap: 18px; + padding: 0 20px 20px; +} + +.diagnostics__preview { + align-self: start; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-muted); +} + +.diagnostics__preview img { + display: block; + width: 100%; + height: auto; +} + +.diagnostics__preview p { + margin: 0; + padding: 9px 11px; + color: var(--text-muted); + font-size: 0.74rem; +} + +.stage-list { + display: grid; + gap: 7px; +} + +.stage-row { + display: grid; + grid-template-columns: 11px minmax(130px, 0.45fr) minmax(180px, 1fr) 120px; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: 9px; +} + +.stage-row__dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--border-strong); +} + +.stage-row[data-status="found"] .stage-row__dot { + background: var(--success); +} + +.stage-row[data-status="not-found"] .stage-row__dot, +.stage-row[data-status="error"] .stage-row__dot { + background: var(--error); +} + +.stage-row[data-status="skipped"] { + opacity: 0.68; +} + +.stage-row strong { + font-size: 0.8rem; +} + +.stage-row p { + margin: 0; + color: var(--text-muted); + font-size: 0.74rem; + line-height: 1.35; +} + +.stage-row img { + display: block; + width: 120px; + height: 42px; + object-fit: contain; + border-radius: 5px; + background: oklch(0.18 0.01 255); +} + +.stage-row__empty { + color: var(--text-muted); + font-size: 0.72rem; + text-align: center; +} + table { width: 100%; border-collapse: collapse; @@ -659,6 +828,18 @@ td:nth-child(4) { grid-template-columns: 1fr; } + .diagnostics__content { + grid-template-columns: 1fr; + } + + .stage-row { + grid-template-columns: 11px minmax(110px, 0.45fr) minmax(160px, 1fr) 100px; + } + + .stage-row img { + width: 100px; + } + .inspector { border-block-start: 1px solid var(--border); border-inline-start: 0; @@ -713,6 +894,21 @@ td:nth-child(4) { overflow-x: auto; } + .stage-row { + grid-template-columns: 11px 1fr; + } + + .stage-row p, + .stage-row img, + .stage-row__empty { + grid-column: 2; + } + + .stage-row img { + width: 100%; + height: 68px; + } + table { min-width: 680px; } diff --git a/test/market-import-proxy.test.js b/test/market-import-proxy.test.js new file mode 100644 index 0000000..0ca3f78 --- /dev/null +++ b/test/market-import-proxy.test.js @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { forwardMarketBatch } from "../vite.config.js"; + +test("keeps the import token in the local server-side forwarder", async () => { + let forwarded; + const response = await forwardMarketBatch('{"batchId":"one"}', { + url: "https://l2.example/api/l2/market/import", + token: "secret", + fetchImpl: async (url, init) => { + forwarded = { url, init }; + return Response.json({ imported: true }); + }, + }); + + assert.equal(response.status, 200); + assert.equal(forwarded.url, "https://l2.example/api/l2/market/import"); + assert.equal(forwarded.init.headers.Authorization, "Bearer secret"); + assert.equal(forwarded.init.body, '{"batchId":"one"}'); + assert.equal((await forwardMarketBatch("{}", {})).status, 503); +}); diff --git a/test/market-outbox.test.js b/test/market-outbox.test.js new file mode 100644 index 0000000..4836be2 --- /dev/null +++ b/test/market-outbox.test.js @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { MarketOutbox } from "../src/market-outbox.js"; + +test("keeps the same batch across retries and deletes only after acknowledgement", async () => { + const store = memoryStore(); + await store.add({ observationId: "one", name: "Stem" }); + await store.add({ observationId: "two", name: "Stem" }); + const sent = []; + let fail = true; + const send = async (batch) => { + sent.push(batch); + if (fail) throw new Error("offline"); + }; + const options = { now: () => "2026-08-11T12:00:00.000Z", uuid: () => "batch-one" }; + + await assert.rejects(new MarketOutbox(store, send, options).flush(), /offline/); + fail = false; + const result = await new MarketOutbox(store, send, options).flush(); + + assert.equal(result.count, 2); + assert.equal(sent[0].batchId, "batch-one"); + assert.deepEqual(sent[1], sent[0]); + assert.deepEqual(await store.list(200), []); +}); + +function memoryStore() { + const observations = new Map(); + let batch = null; + return { + async add(value) { + observations.set(value.observationId, value); + }, + async list(limit) { + return [...observations.values()].slice(0, limit); + }, + async getMany(ids) { + return ids.map((id) => observations.get(id)).filter(Boolean); + }, + async getBatch() { + return batch; + }, + async setBatch(value) { + batch = value; + }, + async ack(value) { + if (batch?.batchId !== value.batchId) return; + value.observationIds.forEach((id) => observations.delete(id)); + batch = null; + }, + }; +} diff --git a/test/parser.test.js b/test/parser.test.js index 743e35f..955b7d6 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -3,12 +3,11 @@ import assert from "node:assert/strict"; import { isCalibrationReady, isRectInside, - parseMerchantText, parseFieldValue, + parseStoreHeaderText, parseTooltipText, rectFromPoints, resolveFieldRect, - resolveScreenRect, } from "../src/parser.js"; test("normalizes a reverse drag into a positive rectangle", () => { @@ -41,7 +40,7 @@ test("requires both anchors and exact OCR regions", () => { assert.equal( isCalibrationReady({ saleAnchor: { image: "data:image/png;base64,x", width: 20, height: 10 }, - merchantRegion: { xRatio: 0.1, yRatio: 0.1, widthRatio: 0.2, heightRatio: 0.1 }, + storeHeaderRegion: { offsetX: -50, offsetY: -300, width: 250, height: 24 }, tooltipSearchRegion: { offsetX: -100, offsetY: -300, width: 300, height: 100 }, tooltipAnchor: { image: "data:image/png;base64,y", width: 30, height: 12 }, itemNameRegion: { offsetX: 0, offsetY: -18, width: 220, height: 18 }, @@ -56,17 +55,6 @@ test("detects OCR rectangles outside the captured frame", () => { assert.equal(isRectInside({ x: 90, y: 10, width: 20, height: 20 }, 100, 100), false); }); -test("resolves the merchant region against the current window size", () => { - assert.deepEqual( - resolveScreenRect( - { xRatio: 0.25, yRatio: 0.1, widthRatio: 0.5, heightRatio: 0.08 }, - 1200, - 800, - ), - { x: 300, y: 80, width: 600, height: 64 }, - ); -}); - test("parses a hovered item tooltip", () => { assert.deepEqual( parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"), @@ -79,6 +67,16 @@ test("parses a hovered item tooltip", () => { assert.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null); }); -test("takes the first OCR line as merchant name", () => { - assert.equal(parseMerchantText(" Dwa \nVagabond\n"), "Dwa"); +test("parses trade side and merchant from the store header", () => { + assert.deepEqual(parseStoreHeaderText("Private Store(Buy) - Hikvision"), { + side: "buy", + merchant: "Hikvision", + rawText: "Private Store(Buy) - Hikvision", + }); + assert.deepEqual(parseStoreHeaderText(" Private Store({Sell) - Dwa \n"), { + side: "sell", + merchant: "Dwa", + rawText: "Private Store({Sell) - Dwa", + }); + assert.equal(parseStoreHeaderText("Items on Sale"), null); }); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..e1e5071 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,74 @@ +import { defineConfig, loadEnv } from "vite"; + +const MAX_BATCH_BYTES = 512 * 1024; + +export async function forwardMarketBatch(body, { url, token, fetchImpl = fetch }) { + if (!url || !token) return new Response("Market import is not configured", { status: 503 }); + return fetchImpl(url, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body, + }); +} + +function marketImportProxy(options) { + return { + name: "market-import-proxy", + configureServer(server) { + server.middlewares.use("/api/market-import", async (request, response) => { + if (request.method !== "POST") { + response.statusCode = 405; + response.end(); + return; + } + + try { + const body = await readBody(request); + const upstream = await forwardMarketBatch(body, options); + response.statusCode = upstream.status; + response.setHeader("Content-Type", upstream.headers.get("content-type") || "application/json"); + response.end(await upstream.text()); + } catch (error) { + response.statusCode = error.code === "BATCH_TOO_LARGE" ? 413 : 502; + response.end(error.code === "BATCH_TOO_LARGE" ? "Batch too large" : "Market import failed"); + } + }); + }, + }; +} + +function readBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + let tooLarge = false; + request.on("data", (chunk) => { + if (tooLarge) return; + size += chunk.length; + if (size > MAX_BATCH_BYTES) { + tooLarge = true; + const error = new Error("Batch too large"); + error.code = "BATCH_TOO_LARGE"; + reject(error); + return; + } + chunks.push(chunk); + }); + request.on("end", () => { + if (!tooLarge) resolve(Buffer.concat(chunks).toString("utf8")); + }); + request.on("error", reject); + }); +} + +export default defineConfig(({ mode }) => { + const environment = loadEnv(mode, process.cwd(), ""); + return { + plugins: [ + marketImportProxy({ + url: environment.L2_MARKET_IMPORT_URL, + token: environment.L2_MARKET_IMPORT_TOKEN, + }), + ], + }; +});