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