Add store header OCR and market observation outbox
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
L2_MARKET_IMPORT_URL=https://l2.dokops.ru/api/l2/market/import
|
||||||
|
L2_MARKET_IMPORT_TOKEN=
|
||||||
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
.env*.local
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ product
|
|||||||
|
|
||||||
## Product Purpose
|
## Product Purpose
|
||||||
|
|
||||||
Приложение подключает одно или несколько выбранных окон, подтверждает открытое окно продажи по его нижнему элементу, читает имя торговца и накапливает названия и цены из tooltip при ручном наведении на предметы. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows.
|
Приложение подключает одно или несколько выбранных окон, подтверждает открытое торговое окно по его нижнему элементу, читает тип сделки и имя торговца из заголовка магазина и накапливает названия и цены из tooltip при ручном наведении на предметы. Каждое распознавание сохраняется с датой и периодически отправляется в home-service. Успех прототипа означает, что весь путь от выбора окна до видимого и сохранённого результата работает локально на macOS и Windows.
|
||||||
|
|
||||||
## Brand Personality
|
## Brand Personality
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+466
-93
@@ -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 scanSource(source, saleTemplate, tooltipTemplate) {
|
async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source = null) {
|
||||||
const result = resultFor(source);
|
const diagnostics = { boxes: [], stages: [], preview: null };
|
||||||
|
addDiagnosticStage(
|
||||||
try {
|
diagnostics,
|
||||||
const frame = await captureSource(source);
|
"capture",
|
||||||
const match = await findAnchor(frame, saleTemplate);
|
"Захват кадра",
|
||||||
result.score = match.score;
|
"found",
|
||||||
|
`${frame.width} × ${frame.height}`,
|
||||||
|
canvasPreview(frame, 240),
|
||||||
|
);
|
||||||
result.error = null;
|
result.error = null;
|
||||||
|
|
||||||
if (match.score < state.calibration.threshold) {
|
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;
|
result.misses += 1;
|
||||||
if (result.misses >= 3) {
|
if (result.misses >= 3) {
|
||||||
result.saleOpen = false;
|
result.side = "";
|
||||||
result.merchant = "";
|
result.merchant = "";
|
||||||
result.items = [];
|
result.items = [];
|
||||||
}
|
}
|
||||||
|
addSkippedStages(
|
||||||
|
diagnostics,
|
||||||
|
[
|
||||||
|
["storeHeader", "Заголовок магазина"],
|
||||||
|
["tooltip", "Якорь Price :"],
|
||||||
|
["itemName", "Название предмета"],
|
||||||
|
["itemPrice", "Цена предмета"],
|
||||||
|
],
|
||||||
|
"маркер магазина не прошёл порог",
|
||||||
|
);
|
||||||
|
finishDiagnostics(result, frame, diagnostics);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.misses = 0;
|
result.misses = 0;
|
||||||
result.saleOpen = true;
|
const storeHeaderRect = resolveFieldRect(saleMatch, state.calibration.storeHeaderRegion);
|
||||||
|
|
||||||
const merchantRect = resolveScreenRect(
|
|
||||||
state.calibration.merchantRegion,
|
|
||||||
frame.width,
|
|
||||||
frame.height,
|
|
||||||
);
|
|
||||||
const tooltipSearchRect = resolveFieldRect(
|
const tooltipSearchRect = resolveFieldRect(
|
||||||
match,
|
saleMatch,
|
||||||
state.calibration.tooltipSearchRegion,
|
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 (
|
if (
|
||||||
!isRectInside(merchantRect, frame.width, frame.height) ||
|
header &&
|
||||||
!isRectInside(tooltipSearchRect, frame.width, frame.height)
|
((result.merchant && header.merchant !== result.merchant) ||
|
||||||
|
(result.side && header.side !== result.side))
|
||||||
) {
|
) {
|
||||||
result.error = "Одна из областей вышла за границы кадра. Сделай калибровку заново.";
|
result.items = [];
|
||||||
return;
|
}
|
||||||
|
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",
|
||||||
|
"Область вышла за границы кадра",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.merchantScan += 1;
|
if (!isRectInside(tooltipSearchRect, frame.width, frame.height)) {
|
||||||
if (!result.merchant || result.merchantScan % 10 === 0) {
|
addDiagnosticStage(
|
||||||
const merchantOcr = await recognizeText(frame, merchantRect, { singleLine: true });
|
diagnostics,
|
||||||
const merchant = parseMerchantText(merchantOcr.text);
|
"tooltip",
|
||||||
if (merchant && result.merchant && merchant !== result.merchant) result.items = [];
|
"Якорь Price :",
|
||||||
if (merchant) result.merchant = merchant;
|
"error",
|
||||||
|
"Зона поиска tooltip вышла за границы кадра",
|
||||||
|
);
|
||||||
|
addSkippedStages(
|
||||||
|
diagnostics,
|
||||||
|
[
|
||||||
|
["itemName", "Название предмета"],
|
||||||
|
["itemPrice", "Цена предмета"],
|
||||||
|
],
|
||||||
|
"зона поиска tooltip недоступна",
|
||||||
|
);
|
||||||
|
finishDiagnostics(result, frame, diagnostics);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipSearch = cropCanvas(frame, tooltipSearchRect);
|
const tooltipSearch = cropCanvas(frame, tooltipSearchRect);
|
||||||
const tooltipMatch = await findAnchor(tooltipSearch, tooltipTemplate);
|
const tooltipMatch = await findAnchor(tooltipSearch, tooltipTemplate);
|
||||||
if (tooltipMatch.score < state.calibration.threshold) return;
|
const tooltipRect = {
|
||||||
|
|
||||||
const tooltipPosition = {
|
|
||||||
x: tooltipSearchRect.x + tooltipMatch.x,
|
x: tooltipSearchRect.x + tooltipMatch.x,
|
||||||
y: tooltipSearchRect.y + tooltipMatch.y,
|
y: tooltipSearchRect.y + tooltipMatch.y,
|
||||||
|
width: tooltipTemplate.width,
|
||||||
|
height: tooltipTemplate.height,
|
||||||
};
|
};
|
||||||
const nameRect = resolveFieldRect(
|
const tooltipFound = tooltipMatch.score >= state.calibration.threshold;
|
||||||
tooltipPosition,
|
diagnostics.boxes.push({ rect: tooltipRect, label: "Price :", status: tooltipFound ? "found" : "not-found" });
|
||||||
state.calibration.itemNameRegion,
|
addDiagnosticStage(
|
||||||
|
diagnostics,
|
||||||
|
"tooltip",
|
||||||
|
"Якорь Price :",
|
||||||
|
tooltipFound ? "found" : "not-found",
|
||||||
|
`${(tooltipMatch.score * 100).toFixed(1)}%, нужно ${(state.calibration.threshold * 100).toFixed(0)}%`,
|
||||||
|
cropPreview(frame, tooltipRect),
|
||||||
);
|
);
|
||||||
const priceRect = resolveFieldRect(
|
|
||||||
tooltipPosition,
|
if (!tooltipFound) {
|
||||||
state.calibration.itemPriceRegion,
|
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 (
|
if (
|
||||||
!isRectInside(nameRect, frame.width, frame.height) ||
|
!isRectInside(nameRect, frame.width, frame.height) ||
|
||||||
!isRectInside(priceRect, frame.width, frame.height)
|
!isRectInside(priceRect, frame.width, frame.height)
|
||||||
) {
|
) {
|
||||||
result.error = "Строки tooltip вышли за границы кадра. Проверь их калибровку.";
|
nameBox.status = "not-found";
|
||||||
|
priceBox.status = "not-found";
|
||||||
|
addSkippedStages(
|
||||||
|
diagnostics,
|
||||||
|
[
|
||||||
|
["itemName", "Название предмета"],
|
||||||
|
["itemPrice", "Цена предмета"],
|
||||||
|
],
|
||||||
|
"точные строки вышли за границы кадра",
|
||||||
|
);
|
||||||
|
finishDiagnostics(result, frame, diagnostics);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameOcr = await recognizeText(frame, nameRect, { singleLine: true });
|
const nameOcr = await recognizeText(frame, nameRect, { singleLine: true });
|
||||||
const priceOcr = await recognizeText(frame, priceRect, { singleLine: true });
|
const priceOcr = await recognizeText(frame, priceRect, { singleLine: true });
|
||||||
const item = parseTooltipText(`${nameOcr.text}\n${priceOcr.text}`);
|
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;
|
if (!item) return;
|
||||||
const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2);
|
|
||||||
|
|
||||||
|
const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2);
|
||||||
const key = `${item.name.toLocaleLowerCase("en-US")}:${item.priceAdena}`;
|
const key = `${item.name.toLocaleLowerCase("en-US")}:${item.priceAdena}`;
|
||||||
const existing = result.items.find((entry) => entry.key === key);
|
const existing = result.items.find((entry) => entry.key === key);
|
||||||
const seenAt = new Date().toISOString();
|
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) {
|
if (existing) {
|
||||||
existing.seenAt = seenAt;
|
existing.seenAt = seenAt;
|
||||||
existing.confidence = confidence;
|
existing.confidence = confidence;
|
||||||
} else {
|
} else {
|
||||||
result.items.push({
|
result.items.push({ ...item, key, confidence, seenAt });
|
||||||
...item,
|
|
||||||
key,
|
|
||||||
confidence,
|
|
||||||
seenAt,
|
|
||||||
});
|
|
||||||
setStatus(`Добавлен предмет: ${item.name}`, "success");
|
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) {
|
} catch (error) {
|
||||||
|
result.saleFound = false;
|
||||||
result.error = error.message || "Ошибка распознавания";
|
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 {
|
||||||
|
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) {
|
||||||
|
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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user