Support multi-screenshot calibration and cached shop context

This commit is contained in:
2026-08-11 17:43:32 +03:00
parent f87a65bb04
commit cf1cb41f04
8 changed files with 507 additions and 84 deletions
+16 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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%;
} }
+10 -1
View File
@@ -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);
});
+22
View File
@@ -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
View File
@@ -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);