diff --git a/README.md b/README.md
index 173410f..3701029 100644
--- a/README.md
+++ b/README.md
@@ -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)` |
Лучше калиброваться на предмете с длинным названием и большой ценой. Тогда прямоугольники не обрежут более короткие варианты.
diff --git a/src/main.js b/src/main.js
index 08f5989..5b5f6e4 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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 = "| Предмет | Цена | Последний раз | Уверенность OCR |
";
+ table.innerHTML = "| Предмет | Количество | Цена | Последний раз | Уверенность OCR |
";
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");
}
}
diff --git a/src/parser.js b/src/parser.js
index f5c8c43..80eb6b9 100644
--- a/src/parser.js
+++ b/src/parser.js
@@ -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) {
diff --git a/src/style.css b/src/style.css
index 3e8bcba..f57531e 100644
--- a/src/style.css
+++ b/src/style.css
@@ -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;
}
diff --git a/test/parser.test.js b/test/parser.test.js
index 955b7d6..4e4e964 100644
--- a/test/parser.test.js
+++ b/test/parser.test.js
@@ -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);
});