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