Integrate market audit with shared calibration and catalog
This commit is contained in:
@@ -16,6 +16,8 @@ npm run dev
|
|||||||
|
|
||||||
Открой адрес, который напечатает Vite, обычно `http://127.0.0.1:5173`.
|
Открой адрес, который напечатает Vite, обычно `http://127.0.0.1:5173`.
|
||||||
|
|
||||||
|
В основном приложении этот же интерфейс доступен в `Рынок → Аудит рынка`. Там сохранённая калибровка общая: сервер загружает её при открытии вкладки и заменяет после нажатия «Сохранить».
|
||||||
|
|
||||||
На macOS браузеру потребуется разрешение **System Settings → Privacy & Security → Screen & System Audio Recording**. После выдачи разрешения браузер иногда нужно перезапустить.
|
На macOS браузеру потребуется разрешение **System Settings → Privacy & Security → Screen & System Audio Recording**. После выдачи разрешения браузер иногда нужно перезапустить.
|
||||||
|
|
||||||
## Как работает поиск
|
## Как работает поиск
|
||||||
@@ -66,7 +68,13 @@ npm run dev
|
|||||||
|
|
||||||
После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно.
|
После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно.
|
||||||
|
|
||||||
Над подробными результатами выводятся уникальные строки вида `Покупка · Lui · Animal Skin · 18 шт. · 400 Adena`. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`. Повторное распознавание той же комбинации торговца, типа сделки, предмета, количества и цены не создаёт новую строку.
|
Над подробными результатами выводятся уникальные строки вида `Покупка · Lui · Animal Skin · 18 шт. · 400 Adena`. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`; если их нет, используется `1`. Повторное распознавание той же комбинации торговца, типа сделки, предмета, количества и цены не создаёт новую строку.
|
||||||
|
|
||||||
|
Во вкладке приложения OCR-название проходит неточный поиск по активному каталогу. Уверенное совпадение заменяется каноническим названием и показывается с иконкой предмета; слабое совпадение остаётся с вопросительным знаком и пометкой «Не найден в каталоге». Предметы группируются по имени торговца и типу сделки.
|
||||||
|
|
||||||
|
Количеством считается только последняя числовая скобка. В `Blessed Spiritshot: D-Grade (D) (5,600)` часть `(D)` остаётся в названии, а количество равно `5600`.
|
||||||
|
|
||||||
|
Если рамки перекрывают соседние элементы, выключи «Показывать выбранные рамки». Сохранённые области не удалятся, а новый пунктирный прямоугольник останется виден во время выделения.
|
||||||
|
|
||||||
### Что именно выделять
|
### Что именно выделять
|
||||||
|
|
||||||
@@ -95,7 +103,7 @@ npm run dev
|
|||||||
|
|
||||||
Диагностические изображения существуют только в памяти открытой страницы. Они не попадают в JSON результата и не отправляются вместе с рыночными наблюдениями.
|
Диагностические изображения существуют только в памяти открытой страницы. Они не попадают в JSON результата и не отправляются вместе с рыночными наблюдениями.
|
||||||
|
|
||||||
Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере.
|
В отдельном Vite-прототипе калибровка хранится в `localStorage`. Во вкладке `home-service` серверная калибровка является основной, а `localStorage` остаётся локальной резервной копией. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере.
|
||||||
|
|
||||||
## Ограничения прототипа
|
## Ограничения прототипа
|
||||||
|
|
||||||
@@ -110,4 +118,6 @@ npm run dev
|
|||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
npm run build
|
npm run build
|
||||||
|
# Обновить встроенную копию в соседнем home-service:
|
||||||
|
npm run build -- --outDir ../home-service/frontend/public/l2/market-audit --emptyOutDir
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -109,6 +109,11 @@
|
|||||||
|
|
||||||
<p id="draw-hint" class="hint">Выдели постоянный нижний фрагмент торгового окна, например подпись Adena и кнопку Confirm. Числа лучше не включать.</p>
|
<p id="draw-hint" class="hint">Выдели постоянный нижний фрагмент торгового окна, например подпись Adena и кнопку Confirm. Числа лучше не включать.</p>
|
||||||
|
|
||||||
|
<label class="overlay-toggle" for="show-overlays">
|
||||||
|
<input id="show-overlays" type="checkbox" checked />
|
||||||
|
<span>Показывать выбранные рамки</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
<div class="range-label">
|
<div class="range-label">
|
||||||
<label for="match-threshold">Порог совпадения</label>
|
<label for="match-threshold">Порог совпадения</label>
|
||||||
|
|||||||
+153
-67
@@ -1,5 +1,6 @@
|
|||||||
import "./style.css";
|
import "./style.css";
|
||||||
import {
|
import {
|
||||||
|
combineRects,
|
||||||
isCalibrationReady,
|
isCalibrationReady,
|
||||||
isRectInside,
|
isRectInside,
|
||||||
parseStoreHeaderText,
|
parseStoreHeaderText,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
import { createMarketOutbox } from "./market-outbox.js";
|
import { createMarketOutbox } from "./market-outbox.js";
|
||||||
|
|
||||||
const STORAGE_KEY = "l2-market-parser.calibration.v4";
|
const STORAGE_KEY = "l2-market-parser.calibration.v4";
|
||||||
|
const embedded = location.pathname.startsWith("/l2/market-audit/");
|
||||||
|
if (embedded) document.documentElement.classList.add("is-embedded");
|
||||||
const regionNames = {
|
const regionNames = {
|
||||||
saleAnchor: "Маркер магазина",
|
saleAnchor: "Маркер магазина",
|
||||||
storeHeader: "Заголовок магазина",
|
storeHeader: "Заголовок магазина",
|
||||||
@@ -55,11 +58,13 @@ const state = {
|
|||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
referenceCanvas: null,
|
referenceCanvas: null,
|
||||||
calibration: loadCalibration(),
|
calibration: loadCalibration(),
|
||||||
|
showOverlays: true,
|
||||||
drawing: null,
|
drawing: null,
|
||||||
results: [],
|
results: [],
|
||||||
collecting: false,
|
collecting: false,
|
||||||
};
|
};
|
||||||
const marketOutbox = createMarketOutbox();
|
const marketOutbox = createMarketOutbox(embedded ? "/api/l2/market/import" : undefined);
|
||||||
|
const catalogMatches = new Map();
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
status: document.querySelector("#global-status"),
|
status: document.querySelector("#global-status"),
|
||||||
@@ -76,6 +81,7 @@ const elements = {
|
|||||||
canvas: document.querySelector("#calibration-canvas"),
|
canvas: document.querySelector("#calibration-canvas"),
|
||||||
regionType: document.querySelector("#region-type"),
|
regionType: document.querySelector("#region-type"),
|
||||||
drawHint: document.querySelector("#draw-hint"),
|
drawHint: document.querySelector("#draw-hint"),
|
||||||
|
showOverlays: document.querySelector("#show-overlays"),
|
||||||
threshold: document.querySelector("#match-threshold"),
|
threshold: document.querySelector("#match-threshold"),
|
||||||
thresholdValue: document.querySelector("#threshold-value"),
|
thresholdValue: document.querySelector("#threshold-value"),
|
||||||
regionCount: document.querySelector("#region-count"),
|
regionCount: document.querySelector("#region-count"),
|
||||||
@@ -573,7 +579,7 @@ function addRegion(rect) {
|
|||||||
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/jpeg", 0.88),
|
||||||
};
|
};
|
||||||
state.references.forEach((item) => {
|
state.references.forEach((item) => {
|
||||||
item.saleAnchor = null;
|
item.saleAnchor = null;
|
||||||
@@ -622,7 +628,7 @@ function addRegion(rect) {
|
|||||||
} else if (type === "tooltipAnchor") {
|
} else if (type === "tooltipAnchor") {
|
||||||
state.calibration.tooltipAnchor = {
|
state.calibration.tooltipAnchor = {
|
||||||
...rect,
|
...rect,
|
||||||
image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"),
|
image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/jpeg", 0.88),
|
||||||
};
|
};
|
||||||
state.references.forEach((item) => {
|
state.references.forEach((item) => {
|
||||||
item.tooltipAnchor = null;
|
item.tooltipAnchor = null;
|
||||||
@@ -705,33 +711,35 @@ 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 (selectedReference()?.saleAnchor) {
|
if (state.showOverlays) {
|
||||||
drawBox(context, selectedReference().saleAnchor, "Магазин", "oklch(0.72 0.16 65)");
|
if (selectedReference()?.saleAnchor) {
|
||||||
}
|
drawBox(context, selectedReference().saleAnchor, "Магазин", "oklch(0.72 0.16 65)");
|
||||||
if (referenceStoreHeaderRect()) {
|
}
|
||||||
drawBox(context, referenceStoreHeaderRect(), "Тип + торговец", "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)");
|
if (referenceTooltipSearchRect()) {
|
||||||
}
|
drawBox(context, referenceTooltipSearchRect(), "Поиск tooltip", "oklch(0.64 0.14 155)");
|
||||||
if (selectedReference()?.tooltipAnchor) {
|
}
|
||||||
drawBox(context, selectedReference().tooltipAnchor, "Price :", "oklch(0.68 0.17 25)");
|
if (selectedReference()?.tooltipAnchor) {
|
||||||
}
|
drawBox(context, selectedReference().tooltipAnchor, "Price :", "oklch(0.68 0.17 25)");
|
||||||
if (referenceTooltipFieldRect(state.calibration.itemNameRegion)) {
|
}
|
||||||
drawBox(
|
if (referenceTooltipFieldRect(state.calibration.itemNameRegion)) {
|
||||||
context,
|
drawBox(
|
||||||
referenceTooltipFieldRect(state.calibration.itemNameRegion),
|
context,
|
||||||
"Название",
|
referenceTooltipFieldRect(state.calibration.itemNameRegion),
|
||||||
"oklch(0.66 0.14 300)",
|
"Название",
|
||||||
);
|
"oklch(0.66 0.14 300)",
|
||||||
}
|
);
|
||||||
if (referenceTooltipFieldRect(state.calibration.itemPriceRegion)) {
|
}
|
||||||
drawBox(
|
if (referenceTooltipFieldRect(state.calibration.itemPriceRegion)) {
|
||||||
context,
|
drawBox(
|
||||||
referenceTooltipFieldRect(state.calibration.itemPriceRegion),
|
context,
|
||||||
"Цена",
|
referenceTooltipFieldRect(state.calibration.itemPriceRegion),
|
||||||
"oklch(0.67 0.14 210)",
|
"Цена",
|
||||||
);
|
"oklch(0.67 0.14 210)",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.drawing) {
|
if (state.drawing) {
|
||||||
@@ -819,7 +827,7 @@ function renderCalibration() {
|
|||||||
drawCalibration();
|
drawCalibration();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCalibration() {
|
async function saveCalibration() {
|
||||||
if (!isCalibrationReady(state.calibration)) {
|
if (!isCalibrationReady(state.calibration)) {
|
||||||
setStatus("Нужно заполнить все 6 областей калибровки", "error");
|
setStatus("Нужно заполнить все 6 областей калибровки", "error");
|
||||||
return;
|
return;
|
||||||
@@ -827,9 +835,39 @@ function saveCalibration() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.calibration));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.calibration));
|
||||||
setStatus("Калибровка сохранена в браузере", "success");
|
if (embedded) {
|
||||||
|
const response = await fetch("/api/l2/market/calibration", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ calibration: state.calibration }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error();
|
||||||
|
setStatus("Общая калибровка сохранена на сервере", "success");
|
||||||
|
} else {
|
||||||
|
setStatus("Калибровка сохранена в браузере", "success");
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("Браузер не смог сохранить калибровку", "error");
|
setStatus(embedded ? "Сервер не смог сохранить калибровку" : "Браузер не смог сохранить калибровку", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSharedCalibration() {
|
||||||
|
if (!embedded) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/l2/market/calibration", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error();
|
||||||
|
const settings = await response.json();
|
||||||
|
if (!settings.calibration) {
|
||||||
|
setStatus("Общая калибровка ещё не настроена", "neutral");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.calibration = settings.calibration;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.calibration));
|
||||||
|
renderCalibration();
|
||||||
|
renderReferences();
|
||||||
|
setStatus("Общая калибровка загружена с сервера", "success");
|
||||||
|
} catch {
|
||||||
|
setStatus("Не удалось загрузить общую калибровку; используется локальная копия", "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,26 +1041,51 @@ function renderResults() {
|
|||||||
error.textContent = result.error;
|
error.textContent = result.error;
|
||||||
section.append(error);
|
section.append(error);
|
||||||
} else if (result.items.length) {
|
} else if (result.items.length) {
|
||||||
const table = document.createElement("table");
|
const shops = new Map();
|
||||||
table.innerHTML = "<thead><tr><th>Предмет</th><th>Количество</th><th>Цена</th><th>Последний раз</th><th>Уверенность OCR</th></tr></thead>";
|
|
||||||
const body = document.createElement("tbody");
|
|
||||||
result.items.forEach((item) => {
|
result.items.forEach((item) => {
|
||||||
const row = document.createElement("tr");
|
const shopKey = `${item.side}:${item.merchant}`;
|
||||||
const name = document.createElement("td");
|
if (!shops.has(shopKey)) shops.set(shopKey, []);
|
||||||
name.textContent = item.name;
|
shops.get(shopKey).push(item);
|
||||||
const quantity = document.createElement("td");
|
|
||||||
quantity.textContent = item.quantity == null ? "—" : item.quantity.toLocaleString("ru-RU");
|
|
||||||
const price = document.createElement("td");
|
|
||||||
price.textContent = `${item.priceAdena.toLocaleString("ru-RU")} Adena`;
|
|
||||||
const seen = document.createElement("td");
|
|
||||||
seen.textContent = new Date(item.seenAt).toLocaleTimeString("ru-RU");
|
|
||||||
const confidence = document.createElement("td");
|
|
||||||
confidence.textContent = `${item.confidence}%`;
|
|
||||||
row.append(name, quantity, price, seen, confidence);
|
|
||||||
body.append(row);
|
|
||||||
});
|
});
|
||||||
table.append(body);
|
const shopList = document.createElement("div");
|
||||||
section.append(table);
|
shopList.className = "merchant-list";
|
||||||
|
shops.forEach((items) => {
|
||||||
|
const shop = document.createElement("section");
|
||||||
|
shop.className = "merchant-card";
|
||||||
|
const shopHeader = document.createElement("header");
|
||||||
|
const shopTitle = document.createElement("h4");
|
||||||
|
shopTitle.textContent = items[0].merchant;
|
||||||
|
const shopMeta = document.createElement("span");
|
||||||
|
shopMeta.textContent = `${items[0].side === "buy" ? "Покупает" : "Продаёт"} · ${items.length} поз.`;
|
||||||
|
shopHeader.append(shopTitle, shopMeta);
|
||||||
|
const itemGrid = document.createElement("div");
|
||||||
|
itemGrid.className = "market-item-grid";
|
||||||
|
items.forEach((item) => {
|
||||||
|
const tile = document.createElement("article");
|
||||||
|
tile.className = `market-item${item.itemId ? "" : " is-unmatched"}`;
|
||||||
|
const icon = item.iconUrl ? document.createElement("img") : document.createElement("span");
|
||||||
|
if (item.iconUrl) {
|
||||||
|
icon.src = item.iconUrl;
|
||||||
|
icon.alt = "";
|
||||||
|
} else {
|
||||||
|
icon.className = "market-item__missing-icon";
|
||||||
|
icon.textContent = "?";
|
||||||
|
}
|
||||||
|
const copy = document.createElement("div");
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
name.textContent = item.name;
|
||||||
|
const price = document.createElement("span");
|
||||||
|
price.textContent = `${item.priceAdena.toLocaleString("ru-RU")} Adena${item.quantity == null ? "" : ` · ${item.quantity.toLocaleString("ru-RU")} шт.`}`;
|
||||||
|
const status = document.createElement("small");
|
||||||
|
status.textContent = item.itemId ? `ID ${item.itemId} · OCR ${item.confidence}%` : `Не найден в каталоге · OCR ${item.confidence}%`;
|
||||||
|
copy.append(name, price, status);
|
||||||
|
tile.append(icon, copy);
|
||||||
|
itemGrid.append(tile);
|
||||||
|
});
|
||||||
|
shop.append(shopHeader, itemGrid);
|
||||||
|
shopList.append(shop);
|
||||||
|
});
|
||||||
|
section.append(shopList);
|
||||||
} 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";
|
||||||
@@ -1233,16 +1296,15 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
|
|||||||
|
|
||||||
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 itemRect = combineRects(nameRect, priceRect);
|
||||||
const priceBox = { rect: priceRect, label: "Цена", status: "info" };
|
const itemBox = { rect: itemRect, label: "Название + цена", status: "info" };
|
||||||
diagnostics.boxes.push(nameBox, priceBox);
|
diagnostics.boxes.push(itemBox);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isRectInside(nameRect, frame.width, frame.height) ||
|
!isRectInside(nameRect, frame.width, frame.height) ||
|
||||||
!isRectInside(priceRect, frame.width, frame.height)
|
!isRectInside(priceRect, frame.width, frame.height)
|
||||||
) {
|
) {
|
||||||
nameBox.status = "not-found";
|
itemBox.status = "not-found";
|
||||||
priceBox.status = "not-found";
|
|
||||||
addSkippedStages(
|
addSkippedStages(
|
||||||
diagnostics,
|
diagnostics,
|
||||||
[
|
[
|
||||||
@@ -1255,31 +1317,31 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameOcr = await recognizeText(frame, nameRect, { singleLine: true });
|
const itemOcr = await recognizeText(frame, itemRect);
|
||||||
const priceOcr = await recognizeText(frame, priceRect, { singleLine: true });
|
let item = parseTooltipText(itemOcr.text);
|
||||||
const item = parseTooltipText(`${nameOcr.text}\n${priceOcr.text}`);
|
itemBox.status = item ? "found" : "not-found";
|
||||||
nameBox.status = nameOcr.text ? "found" : "not-found";
|
|
||||||
priceBox.status = item ? "found" : "not-found";
|
|
||||||
addDiagnosticStage(
|
addDiagnosticStage(
|
||||||
diagnostics,
|
diagnostics,
|
||||||
"itemName",
|
"itemName",
|
||||||
"Название предмета",
|
"Название предмета",
|
||||||
nameOcr.text ? "found" : "not-found",
|
item ? "found" : "not-found",
|
||||||
nameOcr.text ? `OCR ${nameOcr.confidence}%: ${nameOcr.text}` : "OCR вернул пустую строку",
|
itemOcr.text ? `OCR ${itemOcr.confidence}%: ${itemOcr.text}` : "OCR вернул пустую строку",
|
||||||
cropPreview(frame, nameRect),
|
cropPreview(frame, itemRect),
|
||||||
);
|
);
|
||||||
addDiagnosticStage(
|
addDiagnosticStage(
|
||||||
diagnostics,
|
diagnostics,
|
||||||
"itemPrice",
|
"itemPrice",
|
||||||
"Цена предмета",
|
"Цена предмета",
|
||||||
item ? "found" : "not-found",
|
item ? "found" : "not-found",
|
||||||
priceOcr.text ? `OCR ${priceOcr.confidence}%: ${priceOcr.text}` : "OCR вернул пустую строку",
|
itemOcr.text ? `OCR ${itemOcr.confidence}%: ${itemOcr.text}` : "OCR вернул пустую строку",
|
||||||
cropPreview(frame, priceRect),
|
cropPreview(frame, itemRect),
|
||||||
);
|
);
|
||||||
finishDiagnostics(result, frame, diagnostics);
|
finishDiagnostics(result, frame, diagnostics);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2);
|
item = await resolveCatalogItem(item);
|
||||||
|
|
||||||
|
const confidence = itemOcr.confidence;
|
||||||
const key = `${result.side}:${result.merchant.toLocaleLowerCase("en-US")}:${item.name.toLocaleLowerCase("en-US")}:${item.quantity ?? ""}:${item.priceAdena}`;
|
const key = `${result.side}:${result.merchant.toLocaleLowerCase("en-US")}:${item.name.toLocaleLowerCase("en-US")}:${item.quantity ?? ""}:${item.priceAdena}`;
|
||||||
const existing = result.items.find((entry) => entry.key === key);
|
const existing = result.items.find((entry) => entry.key === key);
|
||||||
const seenAt = new Date().toISOString();
|
const seenAt = new Date().toISOString();
|
||||||
@@ -1296,7 +1358,9 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
|
|||||||
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,
|
||||||
storeScore: 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,
|
||||||
|
itemId: item.itemId ?? null,
|
||||||
name: item.name.slice(0, 256),
|
name: item.name.slice(0, 256),
|
||||||
|
quantity: item.quantity,
|
||||||
priceAdena: item.priceAdena,
|
priceAdena: item.priceAdena,
|
||||||
rawText: item.rawText.slice(0, 4096),
|
rawText: item.rawText.slice(0, 4096),
|
||||||
key: key.slice(0, 512),
|
key: key.slice(0, 512),
|
||||||
@@ -1324,6 +1388,23 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveCatalogItem(item) {
|
||||||
|
if (!embedded) return item;
|
||||||
|
if (!catalogMatches.has(item.name)) {
|
||||||
|
catalogMatches.set(
|
||||||
|
item.name,
|
||||||
|
fetch(`/api/l2/items?${new URLSearchParams({ q: item.name.slice(0, 128), limit: "1", fuzzy: "true" })}`, { cache: "no-store" })
|
||||||
|
.then((response) => (response.ok ? response.json() : []))
|
||||||
|
.then((items) => items[0] ?? null)
|
||||||
|
.catch(() => null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const match = await catalogMatches.get(item.name);
|
||||||
|
return match?.matchScore >= 0.35
|
||||||
|
? { ...item, itemId: match.id, name: match.name, iconUrl: match.iconUrl, matchScore: match.matchScore }
|
||||||
|
: item;
|
||||||
|
}
|
||||||
|
|
||||||
async function scanSource(source, saleTemplate, tooltipTemplate) {
|
async function scanSource(source, saleTemplate, tooltipTemplate) {
|
||||||
const result = resultFor(source);
|
const result = resultFor(source);
|
||||||
let frame;
|
let frame;
|
||||||
@@ -1503,6 +1584,10 @@ elements.referenceFile.addEventListener("change", (event) => {
|
|||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
});
|
});
|
||||||
elements.regionType.addEventListener("change", updateDrawHint);
|
elements.regionType.addEventListener("change", updateDrawHint);
|
||||||
|
elements.showOverlays.addEventListener("change", (event) => {
|
||||||
|
state.showOverlays = event.target.checked;
|
||||||
|
drawCalibration();
|
||||||
|
});
|
||||||
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);
|
||||||
@@ -1550,3 +1635,4 @@ renderSources();
|
|||||||
renderCalibration();
|
renderCalibration();
|
||||||
renderResults();
|
renderResults();
|
||||||
updateDrawHint();
|
updateDrawHint();
|
||||||
|
void loadSharedCalibration();
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ export class MarketOutbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMarketOutbox() {
|
export function createMarketOutbox(endpoint = "/api/market-import") {
|
||||||
return new MarketOutbox(createIndexedDbStore(), async (batch) => {
|
return new MarketOutbox(createIndexedDbStore(), async (batch) => {
|
||||||
const response = await fetch("/api/market-import", {
|
const response = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(batch),
|
body: JSON.stringify(batch),
|
||||||
|
|||||||
+22
-6
@@ -16,6 +16,17 @@ export function resolveFieldRect(anchorPosition, field) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function combineRects(first, second) {
|
||||||
|
const x = Math.min(first.x, second.x);
|
||||||
|
const y = Math.min(first.y, second.y);
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: Math.max(first.x + first.width, second.x + second.width) - x,
|
||||||
|
height: Math.max(first.y + first.height, second.y + second.height) - y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeOcrText(text) {
|
export function normalizeOcrText(text) {
|
||||||
return text.replace(/\s+/g, " ").trim();
|
return text.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
@@ -54,7 +65,7 @@ export function parseTooltipText(text) {
|
|||||||
const priceIndex = lines.findIndex(
|
const priceIndex = lines.findIndex(
|
||||||
(line) =>
|
(line) =>
|
||||||
/\d[\d\s,.'`]*\s*adena/i.test(line) ||
|
/\d[\d\s,.'`]*\s*adena/i.test(line) ||
|
||||||
/pr[i1l]ce\s*[:;]?/i.test(line) ||
|
/pr[i1l]ce\s*[:;]?\s*\d/i.test(line) ||
|
||||||
/^\s*\d[\d\s,.'`]*\s*$/.test(line),
|
/^\s*\d[\d\s,.'`]*\s*$/.test(line),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,7 +78,7 @@ export function parseTooltipText(text) {
|
|||||||
priceLine.match(/^\s*([\d][\d\s,.'`]*)\s*$/)?.[1] ??
|
priceLine.match(/^\s*([\d][\d\s,.'`]*)\s*$/)?.[1] ??
|
||||||
"";
|
"";
|
||||||
const digits = priceText.replace(/\D/g, "");
|
const digits = priceText.replace(/\D/g, "");
|
||||||
let name = lines[priceIndex - 1] ?? "";
|
let name = lines.slice(0, priceIndex).find((line) => !/^pr[i1l]ce\s*[:;]?$/i.test(line)) ?? "";
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
name = priceLine.slice(0, priceLine.search(/pr[i1l]ce/i)).trim();
|
name = priceLine.slice(0, priceLine.search(/pr[i1l]ce/i)).trim();
|
||||||
@@ -75,13 +86,18 @@ export function parseTooltipText(text) {
|
|||||||
|
|
||||||
if (!name || !digits) return null;
|
if (!name || !digits) return null;
|
||||||
|
|
||||||
const quantityMatch = name.match(/^(.*?)\s*\(([\d][\d\s,.]*)\)\s*$/);
|
const quantityMatches = [...name.matchAll(/\(([\d][\d\s,.]*)\)/g)];
|
||||||
const quantityText = quantityMatch?.[2].replace(/\D/g, "") ?? "";
|
const quantityMatch = quantityMatches.at(-1);
|
||||||
if (quantityMatch) name = quantityMatch[1].trim();
|
const quantityText = quantityMatch?.[1].replace(/\D/g, "") ?? "";
|
||||||
|
if (quantityMatch) {
|
||||||
|
name = `${name.slice(0, quantityMatch.index)} ${name.slice(quantityMatch.index + quantityMatch[0].length)}`
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
quantity: quantityText ? Number(quantityText) : null,
|
quantity: quantityText ? Number(quantityText) : 1,
|
||||||
priceAdena: Number(digits),
|
priceAdena: Number(digits),
|
||||||
rawText: lines.join("\n"),
|
rawText: lines.join("\n"),
|
||||||
};
|
};
|
||||||
|
|||||||
+125
@@ -614,6 +614,23 @@ input[type="text"] {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-toggle input {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.range-label {
|
.range-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -831,6 +848,106 @@ input[type="range"] {
|
|||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.merchant-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-block-end: 1px solid var(--border);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merchant-card header span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 68px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item.is-unmatched {
|
||||||
|
background: var(--error-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item img,
|
||||||
|
.market-item__missing-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: oklch(0.18 0.01 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item img {
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item__missing-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--error);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item strong,
|
||||||
|
.market-item span,
|
||||||
|
.market-item small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item strong {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-item span,
|
||||||
|
.market-item small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
.result-hint {
|
.result-hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 22px 20px;
|
padding: 22px 20px;
|
||||||
@@ -1120,3 +1237,11 @@ td:nth-child(5) {
|
|||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
html.is-embedded .app-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.is-embedded .app-shell {
|
||||||
|
max-width: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|||||||
+39
-1
@@ -1,6 +1,7 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {
|
import {
|
||||||
|
combineRects,
|
||||||
isCalibrationReady,
|
isCalibrationReady,
|
||||||
isRectInside,
|
isRectInside,
|
||||||
parseFieldValue,
|
parseFieldValue,
|
||||||
@@ -29,6 +30,16 @@ test("moves calibrated fields together with the detected anchor", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("combines the calibrated name and price rows into one OCR window", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
combineRects(
|
||||||
|
{ x: 100, y: 40, width: 220, height: 18 },
|
||||||
|
{ x: 120, y: 90, width: 160, height: 18 },
|
||||||
|
),
|
||||||
|
{ x: 100, y: 40, width: 220, height: 68 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("parses formatted prices and rejects empty numeric OCR", () => {
|
test("parses formatted prices and rejects empty numeric OCR", () => {
|
||||||
assert.equal(parseFieldValue("price", "1 250,000 adena"), 1250000);
|
assert.equal(parseFieldValue("price", "1 250,000 adena"), 1250000);
|
||||||
assert.equal(parseFieldValue("quantity", "not found"), null);
|
assert.equal(parseFieldValue("quantity", "not found"), null);
|
||||||
@@ -60,7 +71,7 @@ test("parses a hovered item tooltip", () => {
|
|||||||
parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"),
|
parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"),
|
||||||
{
|
{
|
||||||
name: "Tears of Eva",
|
name: "Tears of Eva",
|
||||||
quantity: null,
|
quantity: 1,
|
||||||
priceAdena: 3000000,
|
priceAdena: 3000000,
|
||||||
rawText: "Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)",
|
rawText: "Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)",
|
||||||
},
|
},
|
||||||
@@ -74,6 +85,33 @@ test("parses a hovered item tooltip", () => {
|
|||||||
assert.equal(parseTooltipText("Animal Skin (18)\n400")?.priceAdena, 400);
|
assert.equal(parseTooltipText("Animal Skin (18)\n400")?.priceAdena, 400);
|
||||||
assert.equal(parseTooltipText("Animal Skin (18)\nPrice : 400")?.priceAdena, 400);
|
assert.equal(parseTooltipText("Animal Skin (18)\nPrice : 400")?.priceAdena, 400);
|
||||||
assert.equal(parseTooltipText("Blessed Spiritshot D (5,600)\nFor Each 56 Adena")?.quantity, 5600);
|
assert.equal(parseTooltipText("Blessed Spiritshot D (5,600)\nFor Each 56 Adena")?.quantity, 5600);
|
||||||
|
assert.deepEqual(parseTooltipText("Sword of Valhalla\nPrice :\nFor Each 56 Adena"), {
|
||||||
|
name: "Sword of Valhalla",
|
||||||
|
quantity: 1,
|
||||||
|
priceAdena: 56,
|
||||||
|
rawText: "Sword of Valhalla\nPrice :\nFor Each 56 Adena",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseTooltipText("Animal Skin (18)\nWeight 0\nPrice :\nFor Each 400 Adena"), {
|
||||||
|
name: "Animal Skin",
|
||||||
|
quantity: 18,
|
||||||
|
priceAdena: 400,
|
||||||
|
rawText: "Animal Skin (18)\nWeight 0\nPrice :\nFor Each 400 Adena",
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
parseTooltipText("Blessed Spiritshot: D-Grade (D) (5,600)\nFor Each 56 Adena"),
|
||||||
|
{
|
||||||
|
name: "Blessed Spiritshot: D-Grade (D)",
|
||||||
|
quantity: 5600,
|
||||||
|
priceAdena: 56,
|
||||||
|
rawText: "Blessed Spiritshot: D-Grade (D) (5,600)\nFor Each 56 Adena",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(parseTooltipText("Animal Skin (18),,.\nFor Each 400 Adena"), {
|
||||||
|
name: "Animal Skin ,,.",
|
||||||
|
quantity: 18,
|
||||||
|
priceAdena: 400,
|
||||||
|
rawText: "Animal Skin (18),,.\nFor Each 400 Adena",
|
||||||
|
});
|
||||||
assert.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null);
|
assert.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ function readBody(request) {
|
|||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const environment = loadEnv(mode, process.cwd(), "");
|
const environment = loadEnv(mode, process.cwd(), "");
|
||||||
return {
|
return {
|
||||||
|
base: "./",
|
||||||
plugins: [
|
plugins: [
|
||||||
marketImportProxy({
|
marketImportProxy({
|
||||||
url: environment.L2_MARKET_IMPORT_URL,
|
url: environment.L2_MARKET_IMPORT_URL,
|
||||||
|
|||||||
Reference in New Issue
Block a user