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`.
|
||||
|
||||
В основном приложении этот же интерфейс доступен в `Рынок → Аудит рынка`. Там сохранённая калибровка общая: сервер загружает её при открытии вкладки и заменяет после нажатия «Сохранить».
|
||||
|
||||
На macOS браузеру потребуется разрешение **System Settings → Privacy & Security → Screen & System Audio Recording**. После выдачи разрешения браузер иногда нужно перезапустить.
|
||||
|
||||
## Как работает поиск
|
||||
@@ -66,7 +68,13 @@ npm run dev
|
||||
|
||||
После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с 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 результата и не отправляются вместе с рыночными наблюдениями.
|
||||
|
||||
Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере.
|
||||
В отдельном Vite-прототипе калибровка хранится в `localStorage`. Во вкладке `home-service` серверная калибровка является основной, а `localStorage` остаётся локальной резервной копией. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере.
|
||||
|
||||
## Ограничения прототипа
|
||||
|
||||
@@ -110,4 +118,6 @@ npm run dev
|
||||
```bash
|
||||
npm test
|
||||
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>
|
||||
|
||||
<label class="overlay-toggle" for="show-overlays">
|
||||
<input id="show-overlays" type="checkbox" checked />
|
||||
<span>Показывать выбранные рамки</span>
|
||||
</label>
|
||||
|
||||
<div class="field-stack">
|
||||
<div class="range-label">
|
||||
<label for="match-threshold">Порог совпадения</label>
|
||||
|
||||
+153
-67
@@ -1,5 +1,6 @@
|
||||
import "./style.css";
|
||||
import {
|
||||
combineRects,
|
||||
isCalibrationReady,
|
||||
isRectInside,
|
||||
parseStoreHeaderText,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
import { createMarketOutbox } from "./market-outbox.js";
|
||||
|
||||
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 = {
|
||||
saleAnchor: "Маркер магазина",
|
||||
storeHeader: "Заголовок магазина",
|
||||
@@ -55,11 +58,13 @@ const state = {
|
||||
selectedReferenceId: null,
|
||||
referenceCanvas: null,
|
||||
calibration: loadCalibration(),
|
||||
showOverlays: true,
|
||||
drawing: null,
|
||||
results: [],
|
||||
collecting: false,
|
||||
};
|
||||
const marketOutbox = createMarketOutbox();
|
||||
const marketOutbox = createMarketOutbox(embedded ? "/api/l2/market/import" : undefined);
|
||||
const catalogMatches = new Map();
|
||||
|
||||
const elements = {
|
||||
status: document.querySelector("#global-status"),
|
||||
@@ -76,6 +81,7 @@ const elements = {
|
||||
canvas: document.querySelector("#calibration-canvas"),
|
||||
regionType: document.querySelector("#region-type"),
|
||||
drawHint: document.querySelector("#draw-hint"),
|
||||
showOverlays: document.querySelector("#show-overlays"),
|
||||
threshold: document.querySelector("#match-threshold"),
|
||||
thresholdValue: document.querySelector("#threshold-value"),
|
||||
regionCount: document.querySelector("#region-count"),
|
||||
@@ -573,7 +579,7 @@ function addRegion(rect) {
|
||||
if (type === "saleAnchor") {
|
||||
state.calibration.saleAnchor = {
|
||||
...rect,
|
||||
image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"),
|
||||
image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/jpeg", 0.88),
|
||||
};
|
||||
state.references.forEach((item) => {
|
||||
item.saleAnchor = null;
|
||||
@@ -622,7 +628,7 @@ function addRegion(rect) {
|
||||
} else if (type === "tooltipAnchor") {
|
||||
state.calibration.tooltipAnchor = {
|
||||
...rect,
|
||||
image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"),
|
||||
image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/jpeg", 0.88),
|
||||
};
|
||||
state.references.forEach((item) => {
|
||||
item.tooltipAnchor = null;
|
||||
@@ -705,33 +711,35 @@ function drawCalibration() {
|
||||
context.clearRect(0, 0, elements.canvas.width, elements.canvas.height);
|
||||
context.drawImage(state.referenceCanvas, 0, 0);
|
||||
|
||||
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 (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 (referenceTooltipFieldRect(state.calibration.itemNameRegion)) {
|
||||
drawBox(
|
||||
context,
|
||||
referenceTooltipFieldRect(state.calibration.itemNameRegion),
|
||||
"Название",
|
||||
"oklch(0.66 0.14 300)",
|
||||
);
|
||||
}
|
||||
if (referenceTooltipFieldRect(state.calibration.itemPriceRegion)) {
|
||||
drawBox(
|
||||
context,
|
||||
referenceTooltipFieldRect(state.calibration.itemPriceRegion),
|
||||
"Цена",
|
||||
"oklch(0.67 0.14 210)",
|
||||
);
|
||||
if (state.showOverlays) {
|
||||
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 (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 (referenceTooltipFieldRect(state.calibration.itemNameRegion)) {
|
||||
drawBox(
|
||||
context,
|
||||
referenceTooltipFieldRect(state.calibration.itemNameRegion),
|
||||
"Название",
|
||||
"oklch(0.66 0.14 300)",
|
||||
);
|
||||
}
|
||||
if (referenceTooltipFieldRect(state.calibration.itemPriceRegion)) {
|
||||
drawBox(
|
||||
context,
|
||||
referenceTooltipFieldRect(state.calibration.itemPriceRegion),
|
||||
"Цена",
|
||||
"oklch(0.67 0.14 210)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.drawing) {
|
||||
@@ -819,7 +827,7 @@ function renderCalibration() {
|
||||
drawCalibration();
|
||||
}
|
||||
|
||||
function saveCalibration() {
|
||||
async function saveCalibration() {
|
||||
if (!isCalibrationReady(state.calibration)) {
|
||||
setStatus("Нужно заполнить все 6 областей калибровки", "error");
|
||||
return;
|
||||
@@ -827,9 +835,39 @@ function saveCalibration() {
|
||||
|
||||
try {
|
||||
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 {
|
||||
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;
|
||||
section.append(error);
|
||||
} else if (result.items.length) {
|
||||
const table = document.createElement("table");
|
||||
table.innerHTML = "<thead><tr><th>Предмет</th><th>Количество</th><th>Цена</th><th>Последний раз</th><th>Уверенность OCR</th></tr></thead>";
|
||||
const body = document.createElement("tbody");
|
||||
const shops = new Map();
|
||||
result.items.forEach((item) => {
|
||||
const row = document.createElement("tr");
|
||||
const name = document.createElement("td");
|
||||
name.textContent = item.name;
|
||||
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);
|
||||
const shopKey = `${item.side}:${item.merchant}`;
|
||||
if (!shops.has(shopKey)) shops.set(shopKey, []);
|
||||
shops.get(shopKey).push(item);
|
||||
});
|
||||
table.append(body);
|
||||
section.append(table);
|
||||
const shopList = document.createElement("div");
|
||||
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) {
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "result-hint";
|
||||
@@ -1233,16 +1296,15 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
|
||||
|
||||
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);
|
||||
const itemRect = combineRects(nameRect, priceRect);
|
||||
const itemBox = { rect: itemRect, label: "Название + цена", status: "info" };
|
||||
diagnostics.boxes.push(itemBox);
|
||||
|
||||
if (
|
||||
!isRectInside(nameRect, frame.width, frame.height) ||
|
||||
!isRectInside(priceRect, frame.width, frame.height)
|
||||
) {
|
||||
nameBox.status = "not-found";
|
||||
priceBox.status = "not-found";
|
||||
itemBox.status = "not-found";
|
||||
addSkippedStages(
|
||||
diagnostics,
|
||||
[
|
||||
@@ -1255,31 +1317,31 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
|
||||
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";
|
||||
const itemOcr = await recognizeText(frame, itemRect);
|
||||
let item = parseTooltipText(itemOcr.text);
|
||||
itemBox.status = item ? "found" : "not-found";
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"itemName",
|
||||
"Название предмета",
|
||||
nameOcr.text ? "found" : "not-found",
|
||||
nameOcr.text ? `OCR ${nameOcr.confidence}%: ${nameOcr.text}` : "OCR вернул пустую строку",
|
||||
cropPreview(frame, nameRect),
|
||||
item ? "found" : "not-found",
|
||||
itemOcr.text ? `OCR ${itemOcr.confidence}%: ${itemOcr.text}` : "OCR вернул пустую строку",
|
||||
cropPreview(frame, itemRect),
|
||||
);
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"itemPrice",
|
||||
"Цена предмета",
|
||||
item ? "found" : "not-found",
|
||||
priceOcr.text ? `OCR ${priceOcr.confidence}%: ${priceOcr.text}` : "OCR вернул пустую строку",
|
||||
cropPreview(frame, priceRect),
|
||||
itemOcr.text ? `OCR ${itemOcr.confidence}%: ${itemOcr.text}` : "OCR вернул пустую строку",
|
||||
cropPreview(frame, itemRect),
|
||||
);
|
||||
finishDiagnostics(result, frame, diagnostics);
|
||||
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 existing = result.items.find((entry) => entry.key === key);
|
||||
const seenAt = new Date().toISOString();
|
||||
@@ -1296,7 +1358,9 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
|
||||
storeRawText: result.storeRawText.slice(0, 4096),
|
||||
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,
|
||||
itemId: item.itemId ?? null,
|
||||
name: item.name.slice(0, 256),
|
||||
quantity: item.quantity,
|
||||
priceAdena: item.priceAdena,
|
||||
rawText: item.rawText.slice(0, 4096),
|
||||
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) {
|
||||
const result = resultFor(source);
|
||||
let frame;
|
||||
@@ -1503,6 +1584,10 @@ elements.referenceFile.addEventListener("change", (event) => {
|
||||
event.target.value = "";
|
||||
});
|
||||
elements.regionType.addEventListener("change", updateDrawHint);
|
||||
elements.showOverlays.addEventListener("change", (event) => {
|
||||
state.showOverlays = event.target.checked;
|
||||
drawCalibration();
|
||||
});
|
||||
elements.threshold.addEventListener("input", (event) => {
|
||||
state.calibration.threshold = Number(event.target.value);
|
||||
elements.thresholdValue.value = state.calibration.threshold.toFixed(2);
|
||||
@@ -1550,3 +1635,4 @@ renderSources();
|
||||
renderCalibration();
|
||||
renderResults();
|
||||
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) => {
|
||||
const response = await fetch("/api/market-import", {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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) {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
@@ -54,7 +65,7 @@ export function parseTooltipText(text) {
|
||||
const priceIndex = lines.findIndex(
|
||||
(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),
|
||||
);
|
||||
|
||||
@@ -67,7 +78,7 @@ export function parseTooltipText(text) {
|
||||
priceLine.match(/^\s*([\d][\d\s,.'`]*)\s*$/)?.[1] ??
|
||||
"";
|
||||
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) {
|
||||
name = priceLine.slice(0, priceLine.search(/pr[i1l]ce/i)).trim();
|
||||
@@ -75,13 +86,18 @@ export function parseTooltipText(text) {
|
||||
|
||||
if (!name || !digits) return null;
|
||||
|
||||
const quantityMatch = name.match(/^(.*?)\s*\(([\d][\d\s,.]*)\)\s*$/);
|
||||
const quantityText = quantityMatch?.[2].replace(/\D/g, "") ?? "";
|
||||
if (quantityMatch) name = quantityMatch[1].trim();
|
||||
const quantityMatches = [...name.matchAll(/\(([\d][\d\s,.]*)\)/g)];
|
||||
const quantityMatch = quantityMatches.at(-1);
|
||||
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 {
|
||||
name,
|
||||
quantity: quantityText ? Number(quantityText) : null,
|
||||
quantity: quantityText ? Number(quantityText) : 1,
|
||||
priceAdena: Number(digits),
|
||||
rawText: lines.join("\n"),
|
||||
};
|
||||
|
||||
+125
@@ -614,6 +614,23 @@ input[type="text"] {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -831,6 +848,106 @@ input[type="range"] {
|
||||
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 {
|
||||
margin: 0;
|
||||
padding: 22px 20px;
|
||||
@@ -1120,3 +1237,11 @@ td:nth-child(5) {
|
||||
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 assert from "node:assert/strict";
|
||||
import {
|
||||
combineRects,
|
||||
isCalibrationReady,
|
||||
isRectInside,
|
||||
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", () => {
|
||||
assert.equal(parseFieldValue("price", "1 250,000 adena"), 1250000);
|
||||
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)"),
|
||||
{
|
||||
name: "Tears of Eva",
|
||||
quantity: null,
|
||||
quantity: 1,
|
||||
priceAdena: 3000000,
|
||||
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)\nPrice : 400")?.priceAdena, 400);
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function readBody(request) {
|
||||
export default defineConfig(({ mode }) => {
|
||||
const environment = loadEnv(mode, process.cwd(), "");
|
||||
return {
|
||||
base: "./",
|
||||
plugins: [
|
||||
marketImportProxy({
|
||||
url: environment.L2_MARKET_IMPORT_URL,
|
||||
|
||||
Reference in New Issue
Block a user