Add store header OCR and market observation outbox

This commit is contained in:
2026-08-11 17:29:41 +03:00
parent d6e0ba466b
commit f87a65bb04
13 changed files with 1110 additions and 182 deletions
+2
View File
@@ -0,0 +1,2 @@
L2_MARKET_IMPORT_URL=https://l2.dokops.ru/api/l2/market/import
L2_MARKET_IMPORT_TOKEN=
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/ dist/
.DS_Store .DS_Store
npm-debug.log* npm-debug.log*
.env*.local
+1 -1
View File
@@ -10,7 +10,7 @@ product
## Product Purpose ## Product Purpose
Приложение подключает одно или несколько выбранных окон, подтверждает открытое окно продажи по его нижнему элементу, читает имя торговца и накапливает названия и цены из tooltip при ручном наведении на предметы. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows. Приложение подключает одно или несколько выбранных окон, подтверждает открытое торговое окно по его нижнему элементу, читает тип сделки и имя торговца из заголовка магазина и накапливает названия и цены из tooltip при ручном наведении на предметы. Каждое распознавание сохраняется с датой и периодически отправляется в home-service. Успех прототипа означает, что весь путь от выбора окна до видимого и сохранённого результата работает локально на macOS и Windows.
## Brand Personality ## Brand Personality
+64 -4
View File
@@ -8,6 +8,8 @@
```bash ```bash
npm install npm install
cp .env.local.example .env.local
# Заполни L2_MARKET_IMPORT_TOKEN тем же значением, что HOME_SERVICE_L2_MARKET_IMPORT_TOKEN в Gitea Secrets.
npm run dev npm run dev
``` ```
@@ -15,17 +17,75 @@ npm run dev
На macOS браузеру потребуется разрешение **System Settings → Privacy & Security → Screen & System Audio Recording**. После выдачи разрешения браузер иногда нужно перезапустить. На 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. В разделе «Источники» нажми «Добавить окно» для каждого окна игры. 1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры.
2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот. 2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот.
3. Выдели постоянный нижний элемент продажи, например `Adena + Confirm`, как маркер продажи. По возможности не захватывай изменяемые числа. 3. Выдели постоянный нижний элемент торгового окна, например `Adena + Confirm`, как маркер магазина. По возможности не захватывай изменяемые числа.
4. Выдели имя торговца в верхней панели цели. 4. Выдели заголовок магазина одной строкой: от `Private Store(` до конца имени торговца. Рамку и кнопку закрытия не включай.
5. Выдели широкую область всех возможных положений tooltip, не захватывая нижний `Price` основного окна. 5. Выдели широкую область всех возможных положений tooltip, не захватывая нижний `Price` основного окна.
6. Внутри tooltip обведи только постоянную надпись `Price :`. 6. Внутри tooltip обведи только постоянную надпись `Price :`.
7. Отдельно выдели точную строку названия и точную строку цены. Для названия лучше использовать длинный предмет и оставить запас справа. 7. Отдельно выдели точную строку названия и точную строку цены. Для названия лучше использовать длинный предмет и оставить запас справа.
8. Сохрани калибровку, нажми «Начать сбор» и вернись в игру. 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 загружает английскую языковую модель и кеширует её в браузере. Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере.
@@ -35,7 +95,7 @@ npm run dev
- Одна калибровка рассчитана на один масштаб интерфейса Lineage II. - Одна калибровка рассчитана на один масштаб интерфейса Lineage II.
- Сбор работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить сбор». - Сбор работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить сбор».
- Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод. - Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод.
- Отправки на сервер пока нет. - Токен импорта хранится только в локальном Vite-процессе; в браузерный bundle он не попадает.
## Проверки ## Проверки
+22 -4
View File
@@ -48,7 +48,7 @@
<div> <div>
<p class="eyebrow">Образец интерфейса</p> <p class="eyebrow">Образец интерфейса</p>
<h2 id="calibration-title">Калибровка</h2> <h2 id="calibration-title">Калибровка</h2>
<p>Отметь окно продажи, верхнее имя торговца и точные строки всплывающего описания предмета.</p> <p>Отметь торговое окно, его заголовок и точные строки всплывающего описания предмета.</p>
</div> </div>
<div class="button-row"> <div class="button-row">
<select id="calibration-source" aria-label="Окно для снимка"></select> <select id="calibration-source" aria-label="Окно для снимка"></select>
@@ -60,6 +60,23 @@
</div> </div>
</div> </div>
<details class="calibration-guide" open>
<summary>Как связаны области калибровки</summary>
<div class="calibration-guide__content">
<ol>
<li><strong>Маркер магазина</strong><span>Ищется по всему кадру. Лучше выбрать стабильные `Adena` и `Confirm`, не включая числа.</span></li>
<li><strong>Заголовок магазина</strong><span>Строка `Private Store(Buy/Sell) - имя` даёт тип сделки и торговца. Она привязана к найденному маркеру.</span></li>
<li><strong>Зона tooltip</strong><span>Тоже привязана к маркеру, поэтому перемещается вместе с торговым окном.</span></li>
<li><strong>Якорь Price :</strong><span>Ищется внутри зоны tooltip. Он позволяет самому tooltip перемещаться при наведении на разные иконки.</span></li>
<li><strong>Название и цена</strong><span>Привязаны к найденному `Price :` и читаются из двух точных строк.</span></li>
</ol>
<div class="pipeline-map" aria-label="Последовательность распознавания">
<span>Кадр</span><b></b><span>Магазин</span><b></b><span>Тип + торговец</span>
<i>и</i><span>Tooltip</span><b></b><span>Название + цена</span>
</div>
</div>
</details>
<div class="calibration-layout"> <div class="calibration-layout">
<div class="canvas-stage"> <div class="canvas-stage">
<div id="canvas-empty" class="empty-state"> <div id="canvas-empty" class="empty-state">
@@ -73,8 +90,8 @@
<div class="field-stack"> <div class="field-stack">
<label for="region-type">Что выделяем</label> <label for="region-type">Что выделяем</label>
<select id="region-type"> <select id="region-type">
<option value="saleAnchor">Маркер продажи</option> <option value="saleAnchor">Маркер магазина</option>
<option value="merchant">Имя торговца</option> <option value="storeHeader">Заголовок магазина</option>
<option value="tooltipSearch">Зона поиска tooltip</option> <option value="tooltipSearch">Зона поиска tooltip</option>
<option value="tooltipAnchor">Якорь Price :</option> <option value="tooltipAnchor">Якорь Price :</option>
<option value="itemName">Название предмета</option> <option value="itemName">Название предмета</option>
@@ -82,7 +99,7 @@
</select> </select>
</div> </div>
<p id="draw-hint" class="hint">Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.</p> <p id="draw-hint" class="hint">Выдели постоянный нижний фрагмент торгового окна, например подпись Adena и кнопку Confirm. Числа лучше не включать.</p>
<div class="field-stack"> <div class="field-stack">
<div class="range-label"> <div class="range-label">
@@ -102,6 +119,7 @@
<div class="inspector__actions"> <div class="inspector__actions">
<button id="save-calibration" class="button button--primary" type="button">Сохранить</button> <button id="save-calibration" class="button button--primary" type="button">Сохранить</button>
<button id="test-calibration" class="button" type="button" disabled>Проверить на скриншоте</button>
<button id="clear-calibration" class="button button--quiet" type="button">Сбросить</button> <button id="clear-calibration" class="button button--quiet" type="button">Сбросить</button>
</div> </div>
</aside> </aside>
+514 -141
View File
@@ -2,11 +2,10 @@ import "./style.css";
import { import {
isCalibrationReady, isCalibrationReady,
isRectInside, isRectInside,
parseMerchantText, parseStoreHeaderText,
parseTooltipText, parseTooltipText,
rectFromPoints, rectFromPoints,
resolveFieldRect, resolveFieldRect,
resolveScreenRect,
} from "./parser.js"; } from "./parser.js";
import { import {
findAnchor, findAnchor,
@@ -14,11 +13,12 @@ import {
recognizeText, recognizeText,
terminateVision, terminateVision,
} from "./vision.js"; } 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 = { const regionNames = {
saleAnchor: "Маркер продажи", saleAnchor: "Маркер магазина",
merchant: "Имя торговца", storeHeader: "Заголовок магазина",
tooltipSearch: "Зона поиска tooltip", tooltipSearch: "Зона поиска tooltip",
tooltipAnchor: "Якорь Price :", tooltipAnchor: "Якорь Price :",
itemName: "Название предмета", itemName: "Название предмета",
@@ -28,12 +28,11 @@ const regionNames = {
function emptyCalibration() { function emptyCalibration() {
return { return {
saleAnchor: null, saleAnchor: null,
merchantRegion: null, storeHeaderRegion: null,
tooltipSearchRegion: null, tooltipSearchRegion: null,
tooltipAnchor: null, tooltipAnchor: null,
itemNameRegion: null, itemNameRegion: null,
itemPriceRegion: null, itemPriceRegion: null,
referenceSize: null,
threshold: 0.8, threshold: 0.8,
}; };
} }
@@ -58,6 +57,7 @@ const state = {
results: [], results: [],
collecting: false, collecting: false,
}; };
const marketOutbox = createMarketOutbox();
const elements = { const elements = {
status: document.querySelector("#global-status"), status: document.querySelector("#global-status"),
@@ -77,6 +77,7 @@ const elements = {
regionCount: document.querySelector("#region-count"), regionCount: document.querySelector("#region-count"),
regionList: document.querySelector("#region-list"), regionList: document.querySelector("#region-list"),
saveCalibration: document.querySelector("#save-calibration"), saveCalibration: document.querySelector("#save-calibration"),
testCalibration: document.querySelector("#test-calibration"),
clearCalibration: document.querySelector("#clear-calibration"), clearCalibration: document.querySelector("#clear-calibration"),
recognizeAll: document.querySelector("#recognize-all"), recognizeAll: document.querySelector("#recognize-all"),
clearResults: document.querySelector("#clear-results"), clearResults: document.querySelector("#clear-results"),
@@ -296,6 +297,7 @@ function setReference(canvas) {
elements.canvas.height = canvas.height; elements.canvas.height = canvas.height;
elements.canvas.hidden = false; elements.canvas.hidden = false;
elements.canvasEmpty.hidden = true; elements.canvasEmpty.hidden = true;
elements.testCalibration.disabled = false;
drawCalibration(); drawCalibration();
setStatus(`Снимок готов: ${canvas.width} × ${canvas.height}`, "success"); setStatus(`Снимок готов: ${canvas.width} × ${canvas.height}`, "success");
} }
@@ -349,33 +351,94 @@ function cropCanvas(source, rect) {
return canvas; 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) { function addRegion(rect) {
const type = elements.regionType.value; const type = elements.regionType.value;
state.calibration.referenceSize = {
width: state.referenceCanvas.width,
height: state.referenceCanvas.height,
};
if (type === "saleAnchor") { if (type === "saleAnchor") {
state.calibration.saleAnchor = { state.calibration.saleAnchor = {
...rect, ...rect,
image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"),
}; };
state.calibration.storeHeaderRegion = null;
state.calibration.tooltipSearchRegion = null; state.calibration.tooltipSearchRegion = null;
elements.regionType.value = "merchant"; elements.regionType.value = "storeHeader";
setStatus("Маркер продажи выбран. Теперь выдели имя торговца", "success"); setStatus("Маркер выбран. Теперь выдели строку Private Store(...) - имя", "success");
} else if (type === "merchant") { } else if (type === "storeHeader") {
state.calibration.merchantRegion = { if (!state.calibration.saleAnchor) {
xRatio: rect.x / state.referenceCanvas.width, setStatus("Сначала выдели маркер магазина", "error");
yRatio: rect.y / state.referenceCanvas.height, return;
widthRatio: rect.width / state.referenceCanvas.width, }
heightRatio: rect.height / state.referenceCanvas.height,
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"; elements.regionType.value = "tooltipSearch";
setStatus("Имя торговца выбрано. Теперь выдели зону появления tooltip", "success"); setStatus("Заголовок выбран. Теперь выдели зону появления tooltip", "success");
} else if (type === "tooltipSearch") { } else if (type === "tooltipSearch") {
if (!state.calibration.saleAnchor) { if (!state.calibration.saleAnchor) {
setStatus("Сначала выдели маркер продажи", "error"); setStatus("Сначала выдели маркер магазина", "error");
return; return;
} }
@@ -420,10 +483,9 @@ function addRegion(rect) {
renderCalibration(); renderCalibration();
} }
function referenceMerchantRect() { function referenceStoreHeaderRect() {
const size = state.calibration.referenceSize; return state.calibration.saleAnchor && state.calibration.storeHeaderRegion
return size && state.calibration.merchantRegion ? resolveFieldRect(state.calibration.saleAnchor, state.calibration.storeHeaderRegion)
? resolveScreenRect(state.calibration.merchantRegion, size.width, size.height)
: null; : null;
} }
@@ -467,8 +529,8 @@ function drawCalibration() {
if (state.calibration.saleAnchor) { if (state.calibration.saleAnchor) {
drawBox(context, state.calibration.saleAnchor, "Продажа", "oklch(0.72 0.16 65)"); drawBox(context, state.calibration.saleAnchor, "Продажа", "oklch(0.72 0.16 65)");
} }
if (referenceMerchantRect()) { if (referenceStoreHeaderRect()) {
drawBox(context, referenceMerchantRect(), "Торговец", "oklch(0.65 0.17 250)"); drawBox(context, referenceStoreHeaderRect(), "Тип + торговец", "oklch(0.65 0.17 250)");
} }
if (referenceTooltipSearchRect()) { if (referenceTooltipSearchRect()) {
drawBox(context, referenceTooltipSearchRect(), "Поиск tooltip", "oklch(0.64 0.14 155)"); drawBox(context, referenceTooltipSearchRect(), "Поиск tooltip", "oklch(0.64 0.14 155)");
@@ -502,10 +564,11 @@ function drawCalibration() {
function updateDrawHint() { function updateDrawHint() {
const hints = { const hints = {
saleAnchor: saleAnchor:
"Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.", "Выдели постоянный нижний фрагмент торгового окна, например подпись Adena и кнопку Confirm. Числа лучше не включать.",
merchant: "Выдели только имя персонажа в верхней панели цели, без уровня клана.", storeHeader:
"Выдели одну строку целиком: Private Store(Buy/Sell) - имя. Не включай рамку и кнопку закрытия.",
tooltipSearch: tooltipSearch:
"Выдели широкую область всех возможных положений tooltip. Нижний блок Price окна продажи не включай.", "Выдели широкую область всех возможных положений tooltip. Нижний блок Price торгового окна не включай.",
tooltipAnchor: "Обведи только постоянную надпись Price : внутри tooltip, без изменяемого числа.", tooltipAnchor: "Обведи только постоянную надпись Price : внутри tooltip, без изменяемого числа.",
itemName: itemName:
"Выдели точную верхнюю строку названия. Возьми скриншот с длинным названием и оставь запас справа.", "Выдели точную верхнюю строку названия. Возьми скриншот с длинным названием и оставь запас справа.",
@@ -518,8 +581,8 @@ function renderCalibration() {
elements.threshold.value = String(state.calibration.threshold); elements.threshold.value = String(state.calibration.threshold);
elements.thresholdValue.value = state.calibration.threshold.toFixed(2); elements.thresholdValue.value = state.calibration.threshold.toFixed(2);
const regions = [ const regions = [
["saleAnchor", "saleAnchor", "Шаблон поиска окна продажи"], ["saleAnchor", "saleAnchor", "Шаблон поиска торгового окна"],
["merchant", "merchantRegion", "OCR только после продажи"], ["storeHeader", "storeHeaderRegion", "Тип сделки и имя торговца"],
["tooltipSearch", "tooltipSearchRegion", "Ограничивает поиск всплывающего окна"], ["tooltipSearch", "tooltipSearchRegion", "Ограничивает поиск всплывающего окна"],
["tooltipAnchor", "tooltipAnchor", "Постоянная надпись Price :"], ["tooltipAnchor", "tooltipAnchor", "Постоянная надпись Price :"],
["itemName", "itemNameRegion", "Точная строка OCR"], ["itemName", "itemNameRegion", "Точная строка OCR"],
@@ -547,7 +610,10 @@ function renderCalibration() {
remove.setAttribute("aria-label", `Удалить ${regionNames[type]}`); remove.setAttribute("aria-label", `Удалить ${regionNames[type]}`);
remove.addEventListener("click", () => { remove.addEventListener("click", () => {
state.calibration[key] = null; 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") { if (key === "tooltipAnchor") {
state.calibration.itemNameRegion = null; state.calibration.itemNameRegion = null;
state.calibration.itemPriceRegion = 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() { function renderResults() {
elements.resultsList.replaceChildren(); elements.resultsList.replaceChildren();
elements.copyResults.disabled = !state.results.some((result) => result.items.length); elements.copyResults.disabled = !state.results.some((result) => result.items.length);
@@ -623,14 +751,14 @@ function renderResults() {
const title = document.createElement("h3"); const title = document.createElement("h3");
title.textContent = result.source; title.textContent = result.source;
const meta = document.createElement("p"); const meta = document.createElement("p");
meta.textContent = result.saleOpen meta.textContent = result.saleFound
? `Торговец: ${result.merchant || "распознаётся"}, маркер ${(result.score * 100).toFixed(1)}%` ? `${result.side === "buy" ? "Покупка" : result.side === "sell" ? "Продажа" : "Тип распознаётся"} · ${result.merchant || "торговец распознаётся"}, маркер ${(result.score * 100).toFixed(1)}%`
: `Окно продажи не найдено, лучшее совпадение ${(result.score * 100).toFixed(1)}%`; : `Торговое окно не найдено, лучшее совпадение ${(result.score * 100).toFixed(1)}%`;
heading.append(title, meta); heading.append(title, meta);
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = "result-badge"; badge.className = "result-badge";
badge.dataset.tone = result.saleOpen ? "success" : "error"; badge.dataset.tone = result.saleFound ? "success" : "error";
badge.textContent = result.saleOpen ? `${result.items.length} предметов` : "Нет продажи"; badge.textContent = result.saleFound ? `${result.items.length} предметов` : "Нет магазина";
header.append(heading, badge); header.append(heading, badge);
section.append(header); section.append(header);
@@ -639,7 +767,7 @@ function renderResults() {
error.className = "result-error"; error.className = "result-error";
error.textContent = result.error; error.textContent = result.error;
section.append(error); section.append(error);
} else if (result.saleOpen && result.items.length) { } else if (result.saleFound && result.items.length) {
const table = document.createElement("table"); const table = document.createElement("table");
table.innerHTML = "<thead><tr><th>Предмет</th><th>Цена</th><th>Последний раз</th><th>Уверенность OCR</th></tr></thead>"; table.innerHTML = "<thead><tr><th>Предмет</th><th>Цена</th><th>Последний раз</th><th>Уверенность OCR</th></tr></thead>";
const body = document.createElement("tbody"); const body = document.createElement("tbody");
@@ -658,13 +786,14 @@ function renderResults() {
}); });
table.append(body); table.append(body);
section.append(table); section.append(table);
} else if (result.saleOpen) { } else if (result.saleFound) {
const hint = document.createElement("p"); const hint = document.createElement("p");
hint.className = "result-hint"; hint.className = "result-hint";
hint.textContent = "Окно продажи найдено. Наводи курсор на иконки предметов и задерживай его до появления tooltip."; hint.textContent = "Торговое окно найдено. Наводи курсор на иконки предметов и задерживай его до появления tooltip.";
section.append(hint); section.append(hint);
} }
appendDiagnostics(section, result);
elements.resultsList.append(section); elements.resultsList.append(section);
}); });
} }
@@ -675,11 +804,13 @@ function resultFor(source) {
result = { result = {
sourceId: source.id, sourceId: source.id,
source: source.name, source: source.name,
saleOpen: false, saleFound: false,
score: 0, score: 0,
side: "",
merchant: "", merchant: "",
storeRawText: "",
storeConfidence: 0,
items: [], items: [],
merchantScan: 0,
misses: 0, misses: 0,
error: null, error: null,
}; };
@@ -692,103 +823,325 @@ function delay(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, 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) { async function scanSource(source, saleTemplate, tooltipTemplate) {
const result = resultFor(source); 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 { try {
const frame = await captureSource(source); state.results = state.results.filter((result) => result.sourceId !== "calibration-test");
const match = await findAnchor(frame, saleTemplate); result = resultFor({ id: "calibration-test", name: "Тестовый скриншот" });
result.score = match.score; const [saleTemplate, tooltipTemplate] = await loadCalibrationTemplates();
result.error = null; await analyzeFrame(state.referenceCanvas, result, saleTemplate, tooltipTemplate);
renderResults();
if (match.score < state.calibration.threshold) { showView("results");
result.misses += 1; setStatus("Проверка завершена. Смотри этапы и изображения ниже", "success");
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");
}
} catch (error) { } 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; state.collecting = false;
elements.recognizeAll.textContent = "Начать сбор"; elements.recognizeAll.textContent = "Начать сбор";
setStatus("Сбор остановлен", "neutral"); setStatus("Сбор остановлен", "neutral");
void syncMarketOutbox();
return; return;
} }
@@ -806,7 +1160,7 @@ async function toggleCollection() {
return; return;
} }
if (!isCalibrationReady(state.calibration)) { if (!isCalibrationReady(state.calibration)) {
setStatus("Сначала откалибруй продажу, торговца и tooltip", "error"); setStatus("Сначала откалибруй торговое окно и tooltip", "error");
return; return;
} }
@@ -822,10 +1176,7 @@ async function toggleCollection() {
try { try {
await delay(2000); await delay(2000);
const [saleTemplate, tooltipTemplate] = await Promise.all([ const [saleTemplate, tooltipTemplate] = await loadCalibrationTemplates();
imageToCanvas(state.calibration.saleAnchor.image),
imageToCanvas(state.calibration.tooltipAnchor.image),
]);
while (state.collecting) { while (state.collecting) {
const sources = state.sources.filter((source) => source.active); 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() { function clearResults() {
state.results = []; state.results = [];
renderResults(); renderResults();
@@ -853,7 +1215,14 @@ function clearResults() {
async function copyResults() { async function copyResults() {
try { 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"); setStatus("JSON скопирован", "success");
} catch { } catch {
setStatus("Не удалось скопировать JSON", "error"); setStatus("Не удалось скопировать JSON", "error");
@@ -871,6 +1240,7 @@ elements.threshold.addEventListener("input", (event) => {
elements.thresholdValue.value = state.calibration.threshold.toFixed(2); elements.thresholdValue.value = state.calibration.threshold.toFixed(2);
}); });
elements.saveCalibration.addEventListener("click", saveCalibration); elements.saveCalibration.addEventListener("click", saveCalibration);
elements.testCalibration.addEventListener("click", testCalibration);
elements.clearCalibration.addEventListener("click", clearCalibration); elements.clearCalibration.addEventListener("click", clearCalibration);
elements.recognizeAll.addEventListener("click", toggleCollection); elements.recognizeAll.addEventListener("click", toggleCollection);
elements.clearResults.addEventListener("click", clearResults); elements.clearResults.addEventListener("click", clearResults);
@@ -901,6 +1271,9 @@ window.addEventListener("beforeunload", () => {
state.sources.forEach((source) => source.stream.getTracks().forEach((track) => track.stop())); state.sources.forEach((source) => source.stream.getTracks().forEach((track) => track.stop()));
terminateVision(); terminateVision();
}); });
window.addEventListener("online", syncMarketOutbox);
window.setInterval(syncMarketOutbox, 30_000);
void syncMarketOutbox();
renderSources(); renderSources();
renderCalibration(); renderCalibration();
+134
View File
@@ -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);
});
}
+14 -15
View File
@@ -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) { export function normalizeOcrText(text) {
return text.replace(/\s+/g, " ").trim(); return text.replace(/\s+/g, " ").trim();
} }
@@ -40,11 +31,19 @@ export function parseFieldValue(type, text) {
return digits ? Number(digits) : null; return digits ? Number(digits) : null;
} }
export function parseMerchantText(text) { export function parseStoreHeaderText(text) {
return text const normalized = normalizeOcrText(text);
.split(/\r?\n/) const match = normalized.match(
.map(normalizeOcrText) /private\s*store.*?\b(buy|sell)\b.*?[-–—]\s*(.+)$/i,
.find(Boolean) ?? ""; );
if (!match?.[2]?.trim()) return null;
return {
side: match[1].toLowerCase(),
merchant: match[2].trim(),
rawText: normalized,
};
} }
export function parseTooltipText(text) { export function parseTooltipText(text) {
@@ -75,7 +74,7 @@ export function isCalibrationReady(calibration) {
calibration?.saleAnchor?.image && calibration?.saleAnchor?.image &&
calibration.saleAnchor.width >= 4 && calibration.saleAnchor.width >= 4 &&
calibration.saleAnchor.height >= 4 && calibration.saleAnchor.height >= 4 &&
calibration.merchantRegion && calibration.storeHeaderRegion &&
calibration.tooltipSearchRegion && calibration.tooltipSearchRegion &&
calibration.tooltipAnchor?.image && calibration.tooltipAnchor?.image &&
calibration.itemNameRegion && calibration.itemNameRegion &&
+197 -1
View File
@@ -216,6 +216,73 @@ h3 {
align-items: center; 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 { .button-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -540,7 +607,7 @@ input[type="range"] {
.inspector__actions { .inspector__actions {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr;
gap: 8px; gap: 8px;
padding-block-start: 2px; padding-block-start: 2px;
} }
@@ -605,6 +672,108 @@ input[type="range"] {
color: var(--text-muted); 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 { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -659,6 +828,18 @@ td:nth-child(4) {
grid-template-columns: 1fr; 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 { .inspector {
border-block-start: 1px solid var(--border); border-block-start: 1px solid var(--border);
border-inline-start: 0; border-inline-start: 0;
@@ -713,6 +894,21 @@ td:nth-child(4) {
overflow-x: auto; 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 { table {
min-width: 680px; min-width: 680px;
} }
+21
View File
@@ -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);
});
+52
View File
@@ -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;
},
};
}
+14 -16
View File
@@ -3,12 +3,11 @@ import assert from "node:assert/strict";
import { import {
isCalibrationReady, isCalibrationReady,
isRectInside, isRectInside,
parseMerchantText,
parseFieldValue, parseFieldValue,
parseStoreHeaderText,
parseTooltipText, parseTooltipText,
rectFromPoints, rectFromPoints,
resolveFieldRect, resolveFieldRect,
resolveScreenRect,
} from "../src/parser.js"; } from "../src/parser.js";
test("normalizes a reverse drag into a positive rectangle", () => { test("normalizes a reverse drag into a positive rectangle", () => {
@@ -41,7 +40,7 @@ test("requires both anchors and exact OCR regions", () => {
assert.equal( assert.equal(
isCalibrationReady({ isCalibrationReady({
saleAnchor: { image: "data:image/png;base64,x", width: 20, height: 10 }, 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 }, tooltipSearchRegion: { offsetX: -100, offsetY: -300, width: 300, height: 100 },
tooltipAnchor: { image: "data:image/png;base64,y", width: 30, height: 12 }, tooltipAnchor: { image: "data:image/png;base64,y", width: 30, height: 12 },
itemNameRegion: { offsetX: 0, offsetY: -18, width: 220, height: 18 }, 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); 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", () => { test("parses a hovered item tooltip", () => {
assert.deepEqual( assert.deepEqual(
parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"), 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); assert.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null);
}); });
test("takes the first OCR line as merchant name", () => { test("parses trade side and merchant from the store header", () => {
assert.equal(parseMerchantText(" Dwa \nVagabond\n"), "Dwa"); 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);
}); });
+74
View File
@@ -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,
}),
],
};
});