diff --git a/README.md b/README.md
index 3701029..78446dd 100644
--- a/README.md
+++ b/README.md
@@ -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
```
diff --git a/index.html b/index.html
index 14d299a..8371968 100644
--- a/index.html
+++ b/index.html
@@ -109,6 +109,11 @@
Выдели постоянный нижний фрагмент торгового окна, например подпись Adena и кнопку Confirm. Числа лучше не включать.
+
Порог совпадения
diff --git a/src/main.js b/src/main.js
index 5b5f6e4..a385ddb 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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 = "Предмет Количество Цена Последний раз Уверенность OCR ";
- 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();
diff --git a/src/market-outbox.js b/src/market-outbox.js
index a79b9d8..7997fc4 100644
--- a/src/market-outbox.js
+++ b/src/market-outbox.js
@@ -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),
diff --git a/src/parser.js b/src/parser.js
index 80eb6b9..9f2b74c 100644
--- a/src/parser.js
+++ b/src/parser.js
@@ -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"),
};
diff --git a/src/style.css b/src/style.css
index f57531e..58c256f 100644
--- a/src/style.css
+++ b/src/style.css
@@ -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;
+}
diff --git a/test/parser.test.js b/test/parser.test.js
index 4e4e964..795a806 100644
--- a/test/parser.test.js
+++ b/test/parser.test.js
@@ -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);
});
diff --git a/vite.config.js b/vite.config.js
index 1ce525f..8b6a4a3 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -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,