Integrate market audit with shared calibration and catalog

This commit is contained in:
2026-08-11 18:34:52 +03:00
parent e95e43bd92
commit d97305553f
8 changed files with 359 additions and 78 deletions
+12 -2
View File
@@ -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
``` ```
+5
View File
@@ -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
View File
@@ -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();
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}); });
+1
View File
@@ -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,