Support item quantities and unique result lines
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user