Support item quantities and unique result lines

This commit is contained in:
2026-08-11 18:06:29 +03:00
parent cf1cb41f04
commit e95e43bd92
5 changed files with 143 additions and 21 deletions
+3 -1
View File
@@ -66,6 +66,8 @@ npm run dev
После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно.
Над подробными результатами выводятся уникальные строки вида `Покупка · Lui · Animal Skin · 18 шт. · 400 Adena`. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`. Повторное распознавание той же комбинации торговца, типа сделки, предмета, количества и цены не создаёт новую строку.
### Что именно выделять
| Область | Правильный выбор | Частая ошибка |
@@ -75,7 +77,7 @@ npm run dev
| Зона tooltip | Вся полоса возможных положений всплывающего окна над сеткой предметов | Включить нижнюю строку `Price` основного окна |
| Якорь Price | Только постоянные символы `Price :` внутри tooltip | Добавить цену, которая меняется у каждого предмета |
| Название предмета | Одна верхняя строка tooltip с запасом справа | Захватить иконки, фон игры или строку цены |
| Строка цены | Одна строка `Price : 3,000,000 Adena` | Захватить дополнительную строку `(3 Million Adena)` |
| Строка цены | `Price : 3,000,000 Adena`, `For Each 400 Adena` или только число | Захватить дополнительную строку `(3 Million Adena)` |
Лучше калиброваться на предмете с длинным названием и большой ценой. Тогда прямоугольники не обрежут более короткие варианты.
+52 -15
View File
@@ -942,6 +942,38 @@ function renderResults() {
return;
}
const uniqueLines = [
...new Set(
state.results.flatMap((result) =>
result.items.map((item) => {
const side = (item.side || result.side) === "buy" ? "Покупка" : "Продажа";
const merchant = item.merchant || result.merchant;
const quantity = item.quantity == null ? "количество не указано" : `${item.quantity.toLocaleString("ru-RU")} шт.`;
return `${side} · ${merchant} · ${item.name} · ${quantity} · ${item.priceAdena.toLocaleString("ru-RU")} Adena`;
}),
),
),
];
if (uniqueLines.length) {
const output = document.createElement("section");
output.className = "unique-lines";
const header = document.createElement("header");
const title = document.createElement("h3");
title.textContent = "Уникальные строки";
const count = document.createElement("span");
count.textContent = String(uniqueLines.length);
header.append(title, count);
const list = document.createElement("ol");
uniqueLines.forEach((line) => {
const item = document.createElement("li");
item.textContent = line;
list.append(item);
});
output.append(header, list);
elements.resultsList.append(output);
}
state.results.forEach((result) => {
const section = document.createElement("section");
section.className = "result-group";
@@ -956,8 +988,12 @@ function renderResults() {
heading.append(title, meta);
const badge = document.createElement("span");
badge.className = "result-badge";
badge.dataset.tone = result.saleFound ? "success" : "error";
badge.textContent = result.saleFound ? `${result.items.length} предметов` : "Нет магазина";
badge.dataset.tone = result.saleFound ? "success" : result.items.length ? "neutral" : "error";
badge.textContent = result.saleFound
? `${result.items.length} предметов`
: result.items.length
? `${result.items.length} сохранено`
: "Нет магазина";
header.append(heading, badge);
section.append(header);
@@ -966,21 +1002,23 @@ function renderResults() {
error.className = "result-error";
error.textContent = result.error;
section.append(error);
} else if (result.saleFound && result.items.length) {
} else if (result.items.length) {
const table = document.createElement("table");
table.innerHTML = "<thead><tr><th>Предмет</th><th>Цена</th><th>Последний раз</th><th>Уверенность OCR</th></tr></thead>";
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) => {
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, price, seen, confidence);
row.append(name, quantity, price, seen, confidence);
body.append(row);
});
table.append(body);
@@ -1063,7 +1101,6 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
result.merchant = "";
result.storeRawText = "";
result.storeConfidence = 0;
result.items = [];
}
addSkippedStages(
diagnostics,
@@ -1094,13 +1131,6 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
const header = parseStoreHeaderText(headerOcr.text);
const rememberedStore = !header && result.side && result.merchant;
storeHeaderBox.status = header ? "found" : rememberedStore ? "info" : "not-found";
if (
header &&
((result.merchant && header.merchant !== result.merchant) ||
(result.side && header.side !== result.side))
) {
result.items = [];
}
if (header) {
result.side = header.side;
result.merchant = header.merchant;
@@ -1250,7 +1280,7 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
if (!item) return;
const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2);
const key = `${item.name.toLocaleLowerCase("en-US")}:${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 seenAt = new Date().toISOString();
@@ -1282,7 +1312,14 @@ async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source
existing.seenAt = seenAt;
existing.confidence = confidence;
} else {
result.items.push({ ...item, key, confidence, seenAt });
result.items.push({
...item,
side: result.side,
merchant: result.merchant,
key,
confidence,
seenAt,
});
setStatus(`Добавлен предмет: ${item.name}`, "success");
}
}
+22 -4
View File
@@ -51,13 +51,22 @@ export function parseTooltipText(text) {
.split(/\r?\n/)
.map(normalizeOcrText)
.filter(Boolean);
const priceIndex = lines.findIndex((line) => /pr[i1l]ce\s*[:;]?/i.test(line));
const priceIndex = lines.findIndex(
(line) =>
/\d[\d\s,.'`]*\s*adena/i.test(line) ||
/pr[i1l]ce\s*[:;]?/i.test(line) ||
/^\s*\d[\d\s,.'`]*\s*$/.test(line),
);
if (priceIndex < 0) return null;
const priceLine = lines[priceIndex];
const priceMatch = priceLine.match(/pr[i1l]ce\s*[:;]?\s*([\d\s,.'`]+)\s*adena/i);
const digits = priceMatch?.[1].replace(/\D/g, "") ?? "";
const priceText =
priceLine.match(/([\d][\d\s,.'`]*)\s*adena/i)?.[1] ??
priceLine.match(/pr[i1l]ce\s*[:;]?\s*([\d][\d\s,.'`]*)/i)?.[1] ??
priceLine.match(/^\s*([\d][\d\s,.'`]*)\s*$/)?.[1] ??
"";
const digits = priceText.replace(/\D/g, "");
let name = lines[priceIndex - 1] ?? "";
if (!name) {
@@ -66,7 +75,16 @@ export function parseTooltipText(text) {
if (!name || !digits) return null;
return { name, priceAdena: Number(digits), rawText: lines.join("\n") };
const quantityMatch = name.match(/^(.*?)\s*\(([\d][\d\s,.]*)\)\s*$/);
const quantityText = quantityMatch?.[2].replace(/\D/g, "") ?? "";
if (quantityMatch) name = quantityMatch[1].trim();
return {
name,
quantity: quantityText ? Number(quantityText) : null,
priceAdena: Number(digits),
rawText: lines.join("\n"),
};
}
export function isCalibrationReady(calibration) {
+56 -1
View File
@@ -728,6 +728,55 @@ input[type="range"] {
gap: 18px;
}
.unique-lines {
overflow: hidden;
border: 1px solid var(--border-strong);
border-radius: 14px;
background: var(--surface);
}
.unique-lines header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 20px;
border-block-end: 1px solid var(--border);
}
.unique-lines h3 {
margin: 0;
}
.unique-lines header span {
min-width: 28px;
padding: 4px 8px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.75rem;
font-weight: 750;
text-align: center;
}
.unique-lines ol {
margin: 0;
padding: 0;
list-style-position: inside;
}
.unique-lines li {
padding: 11px 20px;
border-block-end: 1px solid var(--border);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.82rem;
line-height: 1.5;
}
.unique-lines li:last-child {
border-block-end: 0;
}
.result-group {
overflow: hidden;
border: 1px solid var(--border);
@@ -771,6 +820,11 @@ input[type="range"] {
color: var(--error);
}
.result-badge[data-tone="neutral"] {
background: var(--surface-muted);
color: var(--text-muted);
}
.result-error {
margin: 0;
padding: 22px 20px;
@@ -911,7 +965,8 @@ tbody tr:last-child td {
}
td:nth-child(2),
td:nth-child(4) {
td:nth-child(3),
td:nth-child(5) {
font-variant-numeric: tabular-nums;
font-weight: 650;
}
+10
View File
@@ -60,10 +60,20 @@ 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,
priceAdena: 3000000,
rawText: "Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million 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("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.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null);
});