Add store header OCR and market observation outbox
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
L2_MARKET_IMPORT_URL=https://l2.dokops.ru/api/l2/market/import
|
||||
L2_MARKET_IMPORT_TOKEN=
|
||||
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
.env*.local
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ product
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Приложение подключает одно или несколько выбранных окон, подтверждает открытое окно продажи по его нижнему элементу, читает имя торговца и накапливает названия и цены из tooltip при ручном наведении на предметы. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows.
|
||||
Приложение подключает одно или несколько выбранных окон, подтверждает открытое торговое окно по его нижнему элементу, читает тип сделки и имя торговца из заголовка магазина и накапливает названия и цены из tooltip при ручном наведении на предметы. Каждое распознавание сохраняется с датой и периодически отправляется в home-service. Успех прототипа означает, что весь путь от выбора окна до видимого и сохранённого результата работает локально на macOS и Windows.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.local.example .env.local
|
||||
# Заполни L2_MARKET_IMPORT_TOKEN тем же значением, что HOME_SERVICE_L2_MARKET_IMPORT_TOKEN в Gitea Secrets.
|
||||
npm run dev
|
||||
```
|
||||
|
||||
@@ -15,17 +17,75 @@ npm run dev
|
||||
|
||||
На macOS браузеру потребуется разрешение **System Settings → Privacy & Security → Screen & System Audio Recording**. После выдачи разрешения браузер иногда нужно перезапустить.
|
||||
|
||||
## Как работает поиск
|
||||
|
||||
Основное торговое окно считается единым объектом. Пользователь отмечает его нижний постоянный фрагмент, а приложение ищет этот фрагмент по всему кадру. Когда фрагмент найден, его координаты становятся точкой отсчёта для заголовка магазина и зоны tooltip.
|
||||
|
||||
```text
|
||||
Кадр
|
||||
└─ Маркер магазина найден?
|
||||
├─ нет → заголовок и tooltip не проверяются
|
||||
└─ да
|
||||
├─ OCR `Private Store(Buy/Sell) - имя`
|
||||
│ └─ тип сделки + имя торговца
|
||||
└─ поиск Price : внутри зоны tooltip
|
||||
├─ нет → название и цена не читаются
|
||||
└─ да → OCR точной строки названия и точной строки цены
|
||||
```
|
||||
|
||||
Заголовок и зона tooltip хранятся относительно маркера магазина. Поэтому всё торговое окно можно перемещать. Внутри зоны tooltip отдельно ищется `Price :`, поэтому сам tooltip тоже может двигаться при наведении на разные предметы.
|
||||
|
||||
Из заголовка `Private Store(Buy) - Hikvision` получится `side: "buy"`, `merchant: "Hikvision"`; из `Private Store(Sell) - Dwa` — `side: "sell"`, `merchant: "Dwa"`.
|
||||
|
||||
## Сценарий
|
||||
|
||||
1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры.
|
||||
2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот.
|
||||
3. Выдели постоянный нижний элемент продажи, например `Adena + Confirm`, как маркер продажи. По возможности не захватывай изменяемые числа.
|
||||
4. Выдели имя торговца в верхней панели цели.
|
||||
3. Выдели постоянный нижний элемент торгового окна, например `Adena + Confirm`, как маркер магазина. По возможности не захватывай изменяемые числа.
|
||||
4. Выдели заголовок магазина одной строкой: от `Private Store(` до конца имени торговца. Рамку и кнопку закрытия не включай.
|
||||
5. Выдели широкую область всех возможных положений tooltip, не захватывая нижний `Price` основного окна.
|
||||
6. Внутри tooltip обведи только постоянную надпись `Price :`.
|
||||
7. Отдельно выдели точную строку названия и точную строку цены. Для названия лучше использовать длинный предмет и оставить запас справа.
|
||||
8. Сохрани калибровку, нажми «Начать сбор» и вернись в игру.
|
||||
9. Наводи курсор на каждый предмет. Уникальные пары `название + цена` будут накапливаться в таблице.
|
||||
9. Наводи курсор на каждый предмет. Уникальные пары `название + цена` будут накапливаться в таблице, а каждое распознавание — сохраняться с датой и отправляться на сервер.
|
||||
|
||||
Наблюдения сначала попадают в IndexedDB браузера, затем отправляются пакетами раз в 30 секунд. При недоступном сервере пакет остаётся локально и повторяется с тем же ID после восстановления связи или перезапуска страницы; сервер не создаёт дубликат.
|
||||
|
||||
Для калибровки нужен скриншот, на котором одновременно видны:
|
||||
|
||||
- нижняя часть торгового окна;
|
||||
- заголовок `Private Store(Buy/Sell) - имя`;
|
||||
- открытый tooltip предмета;
|
||||
- полная строка названия и полная строка `Price`.
|
||||
|
||||
После выбора всех шести областей нажми «Проверить на скриншоте». Подключать игровое окно для этого не нужно.
|
||||
|
||||
### Что именно выделять
|
||||
|
||||
| Область | Правильный выбор | Частая ошибка |
|
||||
| --- | --- | --- |
|
||||
| Маркер магазина | Небольшой постоянный фрагмент внизу, например `Adena` и `Confirm` | Захватить баланс Adena или другое изменяемое число |
|
||||
| Заголовок магазина | Вся строка `Private Store(Sell) - Dwa` или `Private Store(Buy) - Hikvision` | Обрезать `Buy/Sell` или имя; захватить рамку и кнопку закрытия |
|
||||
| Зона tooltip | Вся полоса возможных положений всплывающего окна над сеткой предметов | Включить нижнюю строку `Price` основного окна |
|
||||
| Якорь Price | Только постоянные символы `Price :` внутри tooltip | Добавить цену, которая меняется у каждого предмета |
|
||||
| Название предмета | Одна верхняя строка tooltip с запасом справа | Захватить иконки, фон игры или строку цены |
|
||||
| Строка цены | Одна строка `Price : 3,000,000 Adena` | Захватить дополнительную строку `(3 Million Adena)` |
|
||||
|
||||
Лучше калиброваться на предмете с длинным названием и большой ценой. Тогда прямоугольники не обрежут более короткие варианты.
|
||||
|
||||
## Диагностика
|
||||
|
||||
В результате показывается последний обработанный кадр с рамками и каждый этап отдельно:
|
||||
|
||||
- зелёная рамка означает, что шаблон прошёл заданный порог;
|
||||
- красная показывает лучшее совпадение, которое не прошло порог;
|
||||
- синяя показывает вычисленную область следующего шага;
|
||||
- «Пропущено» означает, что предыдущий обязательный этап не прошёл;
|
||||
- рядом с OCR показываются фактический crop, текст и уверенность.
|
||||
|
||||
Если не находится магазин, смотри crop «Маркер магазина» и процент совпадения. Если магазин найден, но тип или имя пустые, смотри crop и фактический OCR этапа «Заголовок магазина». Если магазин найден, но предмет не появляется, смотри «Якорь Price :». Если оба якоря найдены, проблема уже в точных crop названия или цены.
|
||||
|
||||
Диагностические изображения существуют только в памяти открытой страницы. Они не попадают в JSON результата и не отправляются вместе с рыночными наблюдениями.
|
||||
|
||||
Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере.
|
||||
|
||||
@@ -35,7 +95,7 @@ npm run dev
|
||||
- Одна калибровка рассчитана на один масштаб интерфейса Lineage II.
|
||||
- Сбор работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить сбор».
|
||||
- Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод.
|
||||
- Отправки на сервер пока нет.
|
||||
- Токен импорта хранится только в локальном Vite-процессе; в браузерный bundle он не попадает.
|
||||
|
||||
## Проверки
|
||||
|
||||
|
||||
+22
-4
@@ -48,7 +48,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Образец интерфейса</p>
|
||||
<h2 id="calibration-title">Калибровка</h2>
|
||||
<p>Отметь окно продажи, верхнее имя торговца и точные строки всплывающего описания предмета.</p>
|
||||
<p>Отметь торговое окно, его заголовок и точные строки всплывающего описания предмета.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<select id="calibration-source" aria-label="Окно для снимка"></select>
|
||||
@@ -60,6 +60,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="calibration-guide" open>
|
||||
<summary>Как связаны области калибровки</summary>
|
||||
<div class="calibration-guide__content">
|
||||
<ol>
|
||||
<li><strong>Маркер магазина</strong><span>Ищется по всему кадру. Лучше выбрать стабильные `Adena` и `Confirm`, не включая числа.</span></li>
|
||||
<li><strong>Заголовок магазина</strong><span>Строка `Private Store(Buy/Sell) - имя` даёт тип сделки и торговца. Она привязана к найденному маркеру.</span></li>
|
||||
<li><strong>Зона tooltip</strong><span>Тоже привязана к маркеру, поэтому перемещается вместе с торговым окном.</span></li>
|
||||
<li><strong>Якорь Price :</strong><span>Ищется внутри зоны tooltip. Он позволяет самому tooltip перемещаться при наведении на разные иконки.</span></li>
|
||||
<li><strong>Название и цена</strong><span>Привязаны к найденному `Price :` и читаются из двух точных строк.</span></li>
|
||||
</ol>
|
||||
<div class="pipeline-map" aria-label="Последовательность распознавания">
|
||||
<span>Кадр</span><b>→</b><span>Магазин</span><b>→</b><span>Тип + торговец</span>
|
||||
<i>и</i><span>Tooltip</span><b>→</b><span>Название + цена</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="calibration-layout">
|
||||
<div class="canvas-stage">
|
||||
<div id="canvas-empty" class="empty-state">
|
||||
@@ -73,8 +90,8 @@
|
||||
<div class="field-stack">
|
||||
<label for="region-type">Что выделяем</label>
|
||||
<select id="region-type">
|
||||
<option value="saleAnchor">Маркер продажи</option>
|
||||
<option value="merchant">Имя торговца</option>
|
||||
<option value="saleAnchor">Маркер магазина</option>
|
||||
<option value="storeHeader">Заголовок магазина</option>
|
||||
<option value="tooltipSearch">Зона поиска tooltip</option>
|
||||
<option value="tooltipAnchor">Якорь Price :</option>
|
||||
<option value="itemName">Название предмета</option>
|
||||
@@ -82,7 +99,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p id="draw-hint" class="hint">Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.</p>
|
||||
<p id="draw-hint" class="hint">Выдели постоянный нижний фрагмент торгового окна, например подпись Adena и кнопку Confirm. Числа лучше не включать.</p>
|
||||
|
||||
<div class="field-stack">
|
||||
<div class="range-label">
|
||||
@@ -102,6 +119,7 @@
|
||||
|
||||
<div class="inspector__actions">
|
||||
<button id="save-calibration" class="button button--primary" type="button">Сохранить</button>
|
||||
<button id="test-calibration" class="button" type="button" disabled>Проверить на скриншоте</button>
|
||||
<button id="clear-calibration" class="button button--quiet" type="button">Сбросить</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
+466
-93
@@ -2,11 +2,10 @@ import "./style.css";
|
||||
import {
|
||||
isCalibrationReady,
|
||||
isRectInside,
|
||||
parseMerchantText,
|
||||
parseStoreHeaderText,
|
||||
parseTooltipText,
|
||||
rectFromPoints,
|
||||
resolveFieldRect,
|
||||
resolveScreenRect,
|
||||
} from "./parser.js";
|
||||
import {
|
||||
findAnchor,
|
||||
@@ -14,11 +13,12 @@ import {
|
||||
recognizeText,
|
||||
terminateVision,
|
||||
} from "./vision.js";
|
||||
import { createMarketOutbox } from "./market-outbox.js";
|
||||
|
||||
const STORAGE_KEY = "l2-market-parser.calibration.v3";
|
||||
const STORAGE_KEY = "l2-market-parser.calibration.v4";
|
||||
const regionNames = {
|
||||
saleAnchor: "Маркер продажи",
|
||||
merchant: "Имя торговца",
|
||||
saleAnchor: "Маркер магазина",
|
||||
storeHeader: "Заголовок магазина",
|
||||
tooltipSearch: "Зона поиска tooltip",
|
||||
tooltipAnchor: "Якорь Price :",
|
||||
itemName: "Название предмета",
|
||||
@@ -28,12 +28,11 @@ const regionNames = {
|
||||
function emptyCalibration() {
|
||||
return {
|
||||
saleAnchor: null,
|
||||
merchantRegion: null,
|
||||
storeHeaderRegion: null,
|
||||
tooltipSearchRegion: null,
|
||||
tooltipAnchor: null,
|
||||
itemNameRegion: null,
|
||||
itemPriceRegion: null,
|
||||
referenceSize: null,
|
||||
threshold: 0.8,
|
||||
};
|
||||
}
|
||||
@@ -58,6 +57,7 @@ const state = {
|
||||
results: [],
|
||||
collecting: false,
|
||||
};
|
||||
const marketOutbox = createMarketOutbox();
|
||||
|
||||
const elements = {
|
||||
status: document.querySelector("#global-status"),
|
||||
@@ -77,6 +77,7 @@ const elements = {
|
||||
regionCount: document.querySelector("#region-count"),
|
||||
regionList: document.querySelector("#region-list"),
|
||||
saveCalibration: document.querySelector("#save-calibration"),
|
||||
testCalibration: document.querySelector("#test-calibration"),
|
||||
clearCalibration: document.querySelector("#clear-calibration"),
|
||||
recognizeAll: document.querySelector("#recognize-all"),
|
||||
clearResults: document.querySelector("#clear-results"),
|
||||
@@ -296,6 +297,7 @@ function setReference(canvas) {
|
||||
elements.canvas.height = canvas.height;
|
||||
elements.canvas.hidden = false;
|
||||
elements.canvasEmpty.hidden = true;
|
||||
elements.testCalibration.disabled = false;
|
||||
drawCalibration();
|
||||
setStatus(`Снимок готов: ${canvas.width} × ${canvas.height}`, "success");
|
||||
}
|
||||
@@ -349,33 +351,94 @@ function cropCanvas(source, rect) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function canvasPreview(canvas, maxWidth = 760) {
|
||||
const scale = Math.min(1, maxWidth / canvas.width);
|
||||
const preview = document.createElement("canvas");
|
||||
preview.width = Math.max(1, Math.round(canvas.width * scale));
|
||||
preview.height = Math.max(1, Math.round(canvas.height * scale));
|
||||
preview.getContext("2d").drawImage(canvas, 0, 0, preview.width, preview.height);
|
||||
return preview.toDataURL("image/jpeg", 0.78);
|
||||
}
|
||||
|
||||
function cropPreview(frame, rect) {
|
||||
return isRectInside(rect, frame.width, frame.height)
|
||||
? cropCanvas(frame, rect).toDataURL("image/png")
|
||||
: null;
|
||||
}
|
||||
|
||||
function annotatedPreview(frame, boxes) {
|
||||
const scale = Math.min(1, 760 / frame.width);
|
||||
const preview = document.createElement("canvas");
|
||||
preview.width = Math.max(1, Math.round(frame.width * scale));
|
||||
preview.height = Math.max(1, Math.round(frame.height * scale));
|
||||
const context = preview.getContext("2d");
|
||||
context.drawImage(frame, 0, 0, preview.width, preview.height);
|
||||
context.font = "12px system-ui";
|
||||
|
||||
boxes.forEach(({ rect, label, status }) => {
|
||||
const color = status === "found" ? "#42c884" : status === "not-found" ? "#ef6a67" : "#6d9ff2";
|
||||
const x = rect.x * scale;
|
||||
const y = rect.y * scale;
|
||||
const width = rect.width * scale;
|
||||
const height = rect.height * scale;
|
||||
context.strokeStyle = color;
|
||||
context.fillStyle = color;
|
||||
context.lineWidth = 2;
|
||||
context.strokeRect(x, y, width, height);
|
||||
const labelWidth = context.measureText(label).width + 10;
|
||||
const labelY = Math.max(0, y - 20);
|
||||
context.fillRect(x, labelY, labelWidth, 20);
|
||||
context.fillStyle = "#f7f9fc";
|
||||
context.fillText(label, x + 5, labelY + 14);
|
||||
});
|
||||
|
||||
return preview.toDataURL("image/jpeg", 0.82);
|
||||
}
|
||||
|
||||
function addDiagnosticStage(diagnostics, key, label, status, detail, image = null) {
|
||||
diagnostics.stages.push({ key, label, status, detail, image });
|
||||
}
|
||||
|
||||
function addSkippedStages(diagnostics, stages, reason) {
|
||||
stages.forEach(([key, label]) => {
|
||||
addDiagnosticStage(diagnostics, key, label, "skipped", `Не запускалось: ${reason}`);
|
||||
});
|
||||
}
|
||||
|
||||
function finishDiagnostics(result, frame, diagnostics) {
|
||||
diagnostics.preview = annotatedPreview(frame, diagnostics.boxes);
|
||||
result.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
function addRegion(rect) {
|
||||
const type = elements.regionType.value;
|
||||
state.calibration.referenceSize = {
|
||||
width: state.referenceCanvas.width,
|
||||
height: state.referenceCanvas.height,
|
||||
};
|
||||
|
||||
if (type === "saleAnchor") {
|
||||
state.calibration.saleAnchor = {
|
||||
...rect,
|
||||
image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"),
|
||||
};
|
||||
state.calibration.storeHeaderRegion = null;
|
||||
state.calibration.tooltipSearchRegion = null;
|
||||
elements.regionType.value = "merchant";
|
||||
setStatus("Маркер продажи выбран. Теперь выдели имя торговца", "success");
|
||||
} else if (type === "merchant") {
|
||||
state.calibration.merchantRegion = {
|
||||
xRatio: rect.x / state.referenceCanvas.width,
|
||||
yRatio: rect.y / state.referenceCanvas.height,
|
||||
widthRatio: rect.width / state.referenceCanvas.width,
|
||||
heightRatio: rect.height / state.referenceCanvas.height,
|
||||
elements.regionType.value = "storeHeader";
|
||||
setStatus("Маркер выбран. Теперь выдели строку Private Store(...) - имя", "success");
|
||||
} else if (type === "storeHeader") {
|
||||
if (!state.calibration.saleAnchor) {
|
||||
setStatus("Сначала выдели маркер магазина", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
state.calibration.storeHeaderRegion = {
|
||||
offsetX: rect.x - state.calibration.saleAnchor.x,
|
||||
offsetY: rect.y - state.calibration.saleAnchor.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
elements.regionType.value = "tooltipSearch";
|
||||
setStatus("Имя торговца выбрано. Теперь выдели зону появления tooltip", "success");
|
||||
setStatus("Заголовок выбран. Теперь выдели зону появления tooltip", "success");
|
||||
} else if (type === "tooltipSearch") {
|
||||
if (!state.calibration.saleAnchor) {
|
||||
setStatus("Сначала выдели маркер продажи", "error");
|
||||
setStatus("Сначала выдели маркер магазина", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -420,10 +483,9 @@ function addRegion(rect) {
|
||||
renderCalibration();
|
||||
}
|
||||
|
||||
function referenceMerchantRect() {
|
||||
const size = state.calibration.referenceSize;
|
||||
return size && state.calibration.merchantRegion
|
||||
? resolveScreenRect(state.calibration.merchantRegion, size.width, size.height)
|
||||
function referenceStoreHeaderRect() {
|
||||
return state.calibration.saleAnchor && state.calibration.storeHeaderRegion
|
||||
? resolveFieldRect(state.calibration.saleAnchor, state.calibration.storeHeaderRegion)
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -467,8 +529,8 @@ function drawCalibration() {
|
||||
if (state.calibration.saleAnchor) {
|
||||
drawBox(context, state.calibration.saleAnchor, "Продажа", "oklch(0.72 0.16 65)");
|
||||
}
|
||||
if (referenceMerchantRect()) {
|
||||
drawBox(context, referenceMerchantRect(), "Торговец", "oklch(0.65 0.17 250)");
|
||||
if (referenceStoreHeaderRect()) {
|
||||
drawBox(context, referenceStoreHeaderRect(), "Тип + торговец", "oklch(0.65 0.17 250)");
|
||||
}
|
||||
if (referenceTooltipSearchRect()) {
|
||||
drawBox(context, referenceTooltipSearchRect(), "Поиск tooltip", "oklch(0.64 0.14 155)");
|
||||
@@ -502,10 +564,11 @@ function drawCalibration() {
|
||||
function updateDrawHint() {
|
||||
const hints = {
|
||||
saleAnchor:
|
||||
"Выдели постоянный нижний фрагмент окна продажи, например подпись Adena и кнопку Confirm. Числа лучше не включать.",
|
||||
merchant: "Выдели только имя персонажа в верхней панели цели, без уровня клана.",
|
||||
"Выдели постоянный нижний фрагмент торгового окна, например подпись Adena и кнопку Confirm. Числа лучше не включать.",
|
||||
storeHeader:
|
||||
"Выдели одну строку целиком: Private Store(Buy/Sell) - имя. Не включай рамку и кнопку закрытия.",
|
||||
tooltipSearch:
|
||||
"Выдели широкую область всех возможных положений tooltip. Нижний блок Price окна продажи не включай.",
|
||||
"Выдели широкую область всех возможных положений tooltip. Нижний блок Price торгового окна не включай.",
|
||||
tooltipAnchor: "Обведи только постоянную надпись Price : внутри tooltip, без изменяемого числа.",
|
||||
itemName:
|
||||
"Выдели точную верхнюю строку названия. Возьми скриншот с длинным названием и оставь запас справа.",
|
||||
@@ -518,8 +581,8 @@ function renderCalibration() {
|
||||
elements.threshold.value = String(state.calibration.threshold);
|
||||
elements.thresholdValue.value = state.calibration.threshold.toFixed(2);
|
||||
const regions = [
|
||||
["saleAnchor", "saleAnchor", "Шаблон поиска окна продажи"],
|
||||
["merchant", "merchantRegion", "OCR только после продажи"],
|
||||
["saleAnchor", "saleAnchor", "Шаблон поиска торгового окна"],
|
||||
["storeHeader", "storeHeaderRegion", "Тип сделки и имя торговца"],
|
||||
["tooltipSearch", "tooltipSearchRegion", "Ограничивает поиск всплывающего окна"],
|
||||
["tooltipAnchor", "tooltipAnchor", "Постоянная надпись Price :"],
|
||||
["itemName", "itemNameRegion", "Точная строка OCR"],
|
||||
@@ -547,7 +610,10 @@ function renderCalibration() {
|
||||
remove.setAttribute("aria-label", `Удалить ${regionNames[type]}`);
|
||||
remove.addEventListener("click", () => {
|
||||
state.calibration[key] = null;
|
||||
if (key === "saleAnchor") state.calibration.tooltipSearchRegion = null;
|
||||
if (key === "saleAnchor") {
|
||||
state.calibration.storeHeaderRegion = null;
|
||||
state.calibration.tooltipSearchRegion = null;
|
||||
}
|
||||
if (key === "tooltipAnchor") {
|
||||
state.calibration.itemNameRegion = null;
|
||||
state.calibration.itemPriceRegion = null;
|
||||
@@ -599,6 +665,68 @@ function imageToCanvas(dataUrl) {
|
||||
});
|
||||
}
|
||||
|
||||
function appendDiagnostics(section, result) {
|
||||
if (!result.diagnostics) return;
|
||||
|
||||
const details = document.createElement("details");
|
||||
details.className = "diagnostics";
|
||||
details.open = true;
|
||||
const successful = result.diagnostics.stages.filter((stage) => stage.status === "found").length;
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = `Диагностика последнего кадра: ${successful}/${result.diagnostics.stages.length} этапов выполнено`;
|
||||
const content = document.createElement("div");
|
||||
content.className = "diagnostics__content";
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "diagnostics__preview";
|
||||
if (result.diagnostics.preview) {
|
||||
const image = document.createElement("img");
|
||||
image.src = result.diagnostics.preview;
|
||||
image.alt = `Последний кадр ${result.source} с найденными областями`;
|
||||
preview.append(image);
|
||||
}
|
||||
const caption = document.createElement("p");
|
||||
caption.textContent = "Зелёная рамка найдена, красная не прошла порог, синяя показывает область следующего шага.";
|
||||
preview.append(caption);
|
||||
|
||||
const stages = document.createElement("div");
|
||||
stages.className = "stage-list";
|
||||
const statusNames = {
|
||||
found: "Найдено",
|
||||
"not-found": "Не найдено",
|
||||
skipped: "Пропущено",
|
||||
error: "Ошибка",
|
||||
};
|
||||
result.diagnostics.stages.forEach((stage) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "stage-row";
|
||||
row.dataset.status = stage.status;
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "stage-row__dot";
|
||||
dot.title = statusNames[stage.status];
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = stage.label;
|
||||
const detail = document.createElement("p");
|
||||
detail.textContent = `${statusNames[stage.status]}. ${stage.detail}`;
|
||||
if (stage.image) {
|
||||
const image = document.createElement("img");
|
||||
image.src = stage.image;
|
||||
image.alt = `Фрагмент этапа «${stage.label}»`;
|
||||
row.append(dot, label, detail, image);
|
||||
} else {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "stage-row__empty";
|
||||
empty.textContent = "Нет изображения";
|
||||
row.append(dot, label, detail, empty);
|
||||
}
|
||||
stages.append(row);
|
||||
});
|
||||
|
||||
content.append(preview, stages);
|
||||
details.append(summary, content);
|
||||
section.append(details);
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
elements.resultsList.replaceChildren();
|
||||
elements.copyResults.disabled = !state.results.some((result) => result.items.length);
|
||||
@@ -623,14 +751,14 @@ function renderResults() {
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = result.source;
|
||||
const meta = document.createElement("p");
|
||||
meta.textContent = result.saleOpen
|
||||
? `Торговец: ${result.merchant || "распознаётся"}, маркер ${(result.score * 100).toFixed(1)}%`
|
||||
: `Окно продажи не найдено, лучшее совпадение ${(result.score * 100).toFixed(1)}%`;
|
||||
meta.textContent = result.saleFound
|
||||
? `${result.side === "buy" ? "Покупка" : result.side === "sell" ? "Продажа" : "Тип распознаётся"} · ${result.merchant || "торговец распознаётся"}, маркер ${(result.score * 100).toFixed(1)}%`
|
||||
: `Торговое окно не найдено, лучшее совпадение ${(result.score * 100).toFixed(1)}%`;
|
||||
heading.append(title, meta);
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "result-badge";
|
||||
badge.dataset.tone = result.saleOpen ? "success" : "error";
|
||||
badge.textContent = result.saleOpen ? `${result.items.length} предметов` : "Нет продажи";
|
||||
badge.dataset.tone = result.saleFound ? "success" : "error";
|
||||
badge.textContent = result.saleFound ? `${result.items.length} предметов` : "Нет магазина";
|
||||
header.append(heading, badge);
|
||||
section.append(header);
|
||||
|
||||
@@ -639,7 +767,7 @@ function renderResults() {
|
||||
error.className = "result-error";
|
||||
error.textContent = result.error;
|
||||
section.append(error);
|
||||
} else if (result.saleOpen && result.items.length) {
|
||||
} else if (result.saleFound && result.items.length) {
|
||||
const table = document.createElement("table");
|
||||
table.innerHTML = "<thead><tr><th>Предмет</th><th>Цена</th><th>Последний раз</th><th>Уверенность OCR</th></tr></thead>";
|
||||
const body = document.createElement("tbody");
|
||||
@@ -658,13 +786,14 @@ function renderResults() {
|
||||
});
|
||||
table.append(body);
|
||||
section.append(table);
|
||||
} else if (result.saleOpen) {
|
||||
} else if (result.saleFound) {
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "result-hint";
|
||||
hint.textContent = "Окно продажи найдено. Наводи курсор на иконки предметов и задерживай его до появления tooltip.";
|
||||
hint.textContent = "Торговое окно найдено. Наводи курсор на иконки предметов и задерживай его до появления tooltip.";
|
||||
section.append(hint);
|
||||
}
|
||||
|
||||
appendDiagnostics(section, result);
|
||||
elements.resultsList.append(section);
|
||||
});
|
||||
}
|
||||
@@ -675,11 +804,13 @@ function resultFor(source) {
|
||||
result = {
|
||||
sourceId: source.id,
|
||||
source: source.name,
|
||||
saleOpen: false,
|
||||
saleFound: false,
|
||||
score: 0,
|
||||
side: "",
|
||||
merchant: "",
|
||||
storeRawText: "",
|
||||
storeConfidence: 0,
|
||||
items: [],
|
||||
merchantScan: 0,
|
||||
misses: 0,
|
||||
error: null,
|
||||
};
|
||||
@@ -692,103 +823,325 @@ function delay(milliseconds) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function scanSource(source, saleTemplate, tooltipTemplate) {
|
||||
const result = resultFor(source);
|
||||
|
||||
try {
|
||||
const frame = await captureSource(source);
|
||||
const match = await findAnchor(frame, saleTemplate);
|
||||
result.score = match.score;
|
||||
async function analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source = null) {
|
||||
const diagnostics = { boxes: [], stages: [], preview: null };
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"capture",
|
||||
"Захват кадра",
|
||||
"found",
|
||||
`${frame.width} × ${frame.height}`,
|
||||
canvasPreview(frame, 240),
|
||||
);
|
||||
result.error = null;
|
||||
|
||||
if (match.score < state.calibration.threshold) {
|
||||
const saleMatch = await findAnchor(frame, saleTemplate);
|
||||
const saleRect = {
|
||||
x: saleMatch.x,
|
||||
y: saleMatch.y,
|
||||
width: saleTemplate.width,
|
||||
height: saleTemplate.height,
|
||||
};
|
||||
const saleFound = saleMatch.score >= state.calibration.threshold;
|
||||
result.score = saleMatch.score;
|
||||
result.saleFound = saleFound;
|
||||
diagnostics.boxes.push({ rect: saleRect, label: "Маркер магазина", status: saleFound ? "found" : "not-found" });
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"sale",
|
||||
"Маркер магазина",
|
||||
saleFound ? "found" : "not-found",
|
||||
`${(saleMatch.score * 100).toFixed(1)}%, нужно ${(state.calibration.threshold * 100).toFixed(0)}%`,
|
||||
cropPreview(frame, saleRect),
|
||||
);
|
||||
|
||||
if (!saleFound) {
|
||||
result.misses += 1;
|
||||
if (result.misses >= 3) {
|
||||
result.saleOpen = false;
|
||||
result.side = "";
|
||||
result.merchant = "";
|
||||
result.items = [];
|
||||
}
|
||||
addSkippedStages(
|
||||
diagnostics,
|
||||
[
|
||||
["storeHeader", "Заголовок магазина"],
|
||||
["tooltip", "Якорь Price :"],
|
||||
["itemName", "Название предмета"],
|
||||
["itemPrice", "Цена предмета"],
|
||||
],
|
||||
"маркер магазина не прошёл порог",
|
||||
);
|
||||
finishDiagnostics(result, frame, diagnostics);
|
||||
return;
|
||||
}
|
||||
|
||||
result.misses = 0;
|
||||
result.saleOpen = true;
|
||||
|
||||
const merchantRect = resolveScreenRect(
|
||||
state.calibration.merchantRegion,
|
||||
frame.width,
|
||||
frame.height,
|
||||
);
|
||||
const storeHeaderRect = resolveFieldRect(saleMatch, state.calibration.storeHeaderRegion);
|
||||
const tooltipSearchRect = resolveFieldRect(
|
||||
match,
|
||||
saleMatch,
|
||||
state.calibration.tooltipSearchRegion,
|
||||
);
|
||||
const storeHeaderBox = { rect: storeHeaderRect, label: "Заголовок", status: "info" };
|
||||
diagnostics.boxes.push(storeHeaderBox);
|
||||
diagnostics.boxes.push({ rect: tooltipSearchRect, label: "Зона tooltip", status: "info" });
|
||||
|
||||
if (isRectInside(storeHeaderRect, frame.width, frame.height)) {
|
||||
const headerOcr = await recognizeText(frame, storeHeaderRect, { singleLine: true });
|
||||
const header = parseStoreHeaderText(headerOcr.text);
|
||||
result.storeRawText = headerOcr.text;
|
||||
result.storeConfidence = headerOcr.confidence;
|
||||
storeHeaderBox.status = header ? "found" : "not-found";
|
||||
if (
|
||||
!isRectInside(merchantRect, frame.width, frame.height) ||
|
||||
!isRectInside(tooltipSearchRect, frame.width, frame.height)
|
||||
header &&
|
||||
((result.merchant && header.merchant !== result.merchant) ||
|
||||
(result.side && header.side !== result.side))
|
||||
) {
|
||||
result.error = "Одна из областей вышла за границы кадра. Сделай калибровку заново.";
|
||||
return;
|
||||
result.items = [];
|
||||
}
|
||||
if (header) {
|
||||
result.side = header.side;
|
||||
result.merchant = header.merchant;
|
||||
storeHeaderBox.label = `${header.side === "buy" ? "Buy" : "Sell"} · ${header.merchant}`;
|
||||
}
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"storeHeader",
|
||||
"Заголовок магазина",
|
||||
header ? "found" : "not-found",
|
||||
header
|
||||
? `OCR ${headerOcr.confidence}%: ${header.side === "buy" ? "Покупка" : "Продажа"}, ${header.merchant}`
|
||||
: `OCR ${headerOcr.confidence}%: «${headerOcr.text || "пусто"}» — не удалось разобрать тип и имя`,
|
||||
cropPreview(frame, storeHeaderRect),
|
||||
);
|
||||
} else {
|
||||
storeHeaderBox.status = "not-found";
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"storeHeader",
|
||||
"Заголовок магазина",
|
||||
"error",
|
||||
"Область вышла за границы кадра",
|
||||
);
|
||||
}
|
||||
|
||||
result.merchantScan += 1;
|
||||
if (!result.merchant || result.merchantScan % 10 === 0) {
|
||||
const merchantOcr = await recognizeText(frame, merchantRect, { singleLine: true });
|
||||
const merchant = parseMerchantText(merchantOcr.text);
|
||||
if (merchant && result.merchant && merchant !== result.merchant) result.items = [];
|
||||
if (merchant) result.merchant = merchant;
|
||||
if (!isRectInside(tooltipSearchRect, frame.width, frame.height)) {
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"tooltip",
|
||||
"Якорь Price :",
|
||||
"error",
|
||||
"Зона поиска tooltip вышла за границы кадра",
|
||||
);
|
||||
addSkippedStages(
|
||||
diagnostics,
|
||||
[
|
||||
["itemName", "Название предмета"],
|
||||
["itemPrice", "Цена предмета"],
|
||||
],
|
||||
"зона поиска tooltip недоступна",
|
||||
);
|
||||
finishDiagnostics(result, frame, diagnostics);
|
||||
return;
|
||||
}
|
||||
|
||||
const tooltipSearch = cropCanvas(frame, tooltipSearchRect);
|
||||
const tooltipMatch = await findAnchor(tooltipSearch, tooltipTemplate);
|
||||
if (tooltipMatch.score < state.calibration.threshold) return;
|
||||
|
||||
const tooltipPosition = {
|
||||
const tooltipRect = {
|
||||
x: tooltipSearchRect.x + tooltipMatch.x,
|
||||
y: tooltipSearchRect.y + tooltipMatch.y,
|
||||
width: tooltipTemplate.width,
|
||||
height: tooltipTemplate.height,
|
||||
};
|
||||
const nameRect = resolveFieldRect(
|
||||
tooltipPosition,
|
||||
state.calibration.itemNameRegion,
|
||||
const tooltipFound = tooltipMatch.score >= state.calibration.threshold;
|
||||
diagnostics.boxes.push({ rect: tooltipRect, label: "Price :", status: tooltipFound ? "found" : "not-found" });
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"tooltip",
|
||||
"Якорь Price :",
|
||||
tooltipFound ? "found" : "not-found",
|
||||
`${(tooltipMatch.score * 100).toFixed(1)}%, нужно ${(state.calibration.threshold * 100).toFixed(0)}%`,
|
||||
cropPreview(frame, tooltipRect),
|
||||
);
|
||||
const priceRect = resolveFieldRect(
|
||||
tooltipPosition,
|
||||
state.calibration.itemPriceRegion,
|
||||
|
||||
if (!tooltipFound) {
|
||||
addSkippedStages(
|
||||
diagnostics,
|
||||
[
|
||||
["itemName", "Название предмета"],
|
||||
["itemPrice", "Цена предмета"],
|
||||
],
|
||||
"якорь Price : не найден",
|
||||
);
|
||||
finishDiagnostics(result, frame, diagnostics);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (
|
||||
!isRectInside(nameRect, frame.width, frame.height) ||
|
||||
!isRectInside(priceRect, frame.width, frame.height)
|
||||
) {
|
||||
result.error = "Строки tooltip вышли за границы кадра. Проверь их калибровку.";
|
||||
nameBox.status = "not-found";
|
||||
priceBox.status = "not-found";
|
||||
addSkippedStages(
|
||||
diagnostics,
|
||||
[
|
||||
["itemName", "Название предмета"],
|
||||
["itemPrice", "Цена предмета"],
|
||||
],
|
||||
"точные строки вышли за границы кадра",
|
||||
);
|
||||
finishDiagnostics(result, frame, diagnostics);
|
||||
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";
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"itemName",
|
||||
"Название предмета",
|
||||
nameOcr.text ? "found" : "not-found",
|
||||
nameOcr.text ? `OCR ${nameOcr.confidence}%: ${nameOcr.text}` : "OCR вернул пустую строку",
|
||||
cropPreview(frame, nameRect),
|
||||
);
|
||||
addDiagnosticStage(
|
||||
diagnostics,
|
||||
"itemPrice",
|
||||
"Цена предмета",
|
||||
item ? "found" : "not-found",
|
||||
priceOcr.text ? `OCR ${priceOcr.confidence}%: ${priceOcr.text}` : "OCR вернул пустую строку",
|
||||
cropPreview(frame, priceRect),
|
||||
);
|
||||
finishDiagnostics(result, frame, diagnostics);
|
||||
if (!item) return;
|
||||
const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2);
|
||||
|
||||
const confidence = Math.round((nameOcr.confidence + priceOcr.confidence) / 2);
|
||||
const key = `${item.name.toLocaleLowerCase("en-US")}:${item.priceAdena}`;
|
||||
const existing = result.items.find((entry) => entry.key === key);
|
||||
const seenAt = new Date().toISOString();
|
||||
|
||||
if (source) {
|
||||
try {
|
||||
await marketOutbox.enqueue({
|
||||
observationId: crypto.randomUUID(),
|
||||
seenAt,
|
||||
sourceId: source.id,
|
||||
source: source.name.slice(0, 256),
|
||||
side: result.side || null,
|
||||
merchant: result.merchant.slice(0, 128),
|
||||
storeRawText: result.storeRawText.slice(0, 4096),
|
||||
storeConfidence: Number.isFinite(result.storeConfidence) ? Math.max(0, Math.min(100, Math.round(result.storeConfidence))) : 0,
|
||||
saleScore: Number.isFinite(saleMatch.score) ? Math.max(0, Math.min(1, saleMatch.score)) : 0,
|
||||
name: item.name.slice(0, 256),
|
||||
priceAdena: item.priceAdena,
|
||||
rawText: item.rawText.slice(0, 4096),
|
||||
key: key.slice(0, 512),
|
||||
confidence: Number.isFinite(confidence) ? Math.max(0, Math.min(100, confidence)) : 0,
|
||||
});
|
||||
} catch {
|
||||
state.collecting = false;
|
||||
throw new Error("Не удалось сохранить наблюдение рынка локально");
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
existing.seenAt = seenAt;
|
||||
existing.confidence = confidence;
|
||||
} else {
|
||||
result.items.push({
|
||||
...item,
|
||||
key,
|
||||
confidence,
|
||||
seenAt,
|
||||
});
|
||||
result.items.push({ ...item, key, confidence, seenAt });
|
||||
setStatus(`Добавлен предмет: ${item.name}`, "success");
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSource(source, saleTemplate, tooltipTemplate) {
|
||||
const result = resultFor(source);
|
||||
let frame;
|
||||
try {
|
||||
frame = await captureSource(source);
|
||||
await analyzeFrame(frame, result, saleTemplate, tooltipTemplate, source);
|
||||
} catch (error) {
|
||||
result.saleFound = false;
|
||||
result.error = error.message || "Ошибка распознавания";
|
||||
result.diagnostics = {
|
||||
preview: frame ? canvasPreview(frame) : null,
|
||||
boxes: [],
|
||||
stages: [
|
||||
{
|
||||
key: frame ? "pipeline" : "capture",
|
||||
label: frame ? "Обработка кадра" : "Захват кадра",
|
||||
status: "error",
|
||||
detail: result.error,
|
||||
image: frame ? canvasPreview(frame, 240) : null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function loadCalibrationTemplates() {
|
||||
return Promise.all([
|
||||
imageToCanvas(state.calibration.saleAnchor.image),
|
||||
imageToCanvas(state.calibration.tooltipAnchor.image),
|
||||
]);
|
||||
}
|
||||
|
||||
async function testCalibration() {
|
||||
if (!state.referenceCanvas) {
|
||||
setStatus("Сначала загрузи скриншот или сделай снимок окна", "error");
|
||||
return;
|
||||
}
|
||||
if (!isCalibrationReady(state.calibration)) {
|
||||
setStatus("Для проверки нужно заполнить все 6 областей", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
state.collecting = false;
|
||||
elements.recognizeAll.textContent = "Начать сбор";
|
||||
elements.testCalibration.disabled = true;
|
||||
setStatus("Проверяю калибровку на текущем скриншоте", "working");
|
||||
let result;
|
||||
|
||||
try {
|
||||
state.results = state.results.filter((result) => result.sourceId !== "calibration-test");
|
||||
result = resultFor({ id: "calibration-test", name: "Тестовый скриншот" });
|
||||
const [saleTemplate, tooltipTemplate] = await loadCalibrationTemplates();
|
||||
await analyzeFrame(state.referenceCanvas, result, saleTemplate, tooltipTemplate);
|
||||
renderResults();
|
||||
showView("results");
|
||||
setStatus("Проверка завершена. Смотри этапы и изображения ниже", "success");
|
||||
} catch (error) {
|
||||
if (result) {
|
||||
result.saleFound = false;
|
||||
result.error = error.message || "Ошибка проверки";
|
||||
result.diagnostics = {
|
||||
preview: canvasPreview(state.referenceCanvas),
|
||||
boxes: [],
|
||||
stages: [
|
||||
{
|
||||
key: "pipeline",
|
||||
label: "Обработка скриншота",
|
||||
status: "error",
|
||||
detail: result.error,
|
||||
image: canvasPreview(state.referenceCanvas, 240),
|
||||
},
|
||||
],
|
||||
};
|
||||
renderResults();
|
||||
showView("results");
|
||||
}
|
||||
setStatus(error.message || "Не удалось проверить калибровку", "error");
|
||||
} finally {
|
||||
elements.testCalibration.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,6 +1150,7 @@ async function toggleCollection() {
|
||||
state.collecting = false;
|
||||
elements.recognizeAll.textContent = "Начать сбор";
|
||||
setStatus("Сбор остановлен", "neutral");
|
||||
void syncMarketOutbox();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -806,7 +1160,7 @@ async function toggleCollection() {
|
||||
return;
|
||||
}
|
||||
if (!isCalibrationReady(state.calibration)) {
|
||||
setStatus("Сначала откалибруй продажу, торговца и tooltip", "error");
|
||||
setStatus("Сначала откалибруй торговое окно и tooltip", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -822,10 +1176,7 @@ async function toggleCollection() {
|
||||
|
||||
try {
|
||||
await delay(2000);
|
||||
const [saleTemplate, tooltipTemplate] = await Promise.all([
|
||||
imageToCanvas(state.calibration.saleAnchor.image),
|
||||
imageToCanvas(state.calibration.tooltipAnchor.image),
|
||||
]);
|
||||
const [saleTemplate, tooltipTemplate] = await loadCalibrationTemplates();
|
||||
|
||||
while (state.collecting) {
|
||||
const sources = state.sources.filter((source) => source.active);
|
||||
@@ -845,6 +1196,17 @@ async function toggleCollection() {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncMarketOutbox() {
|
||||
try {
|
||||
const result = await marketOutbox.flush();
|
||||
if (result.sent && !state.collecting) {
|
||||
setStatus(`Отправлено наблюдений: ${result.count}`, "success");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!state.collecting) setStatus(error.message || "Не удалось обновить данные рынка", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
state.results = [];
|
||||
renderResults();
|
||||
@@ -853,7 +1215,14 @@ function clearResults() {
|
||||
|
||||
async function copyResults() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(state.results, null, 2));
|
||||
const exportable = state.results.map((result) => ({
|
||||
source: result.source,
|
||||
side: result.saleFound ? result.side || null : null,
|
||||
merchant: result.saleFound ? result.merchant || null : null,
|
||||
saleFound: result.saleFound,
|
||||
items: result.items.map(({ key, ...item }) => item),
|
||||
}));
|
||||
await navigator.clipboard.writeText(JSON.stringify(exportable, null, 2));
|
||||
setStatus("JSON скопирован", "success");
|
||||
} catch {
|
||||
setStatus("Не удалось скопировать JSON", "error");
|
||||
@@ -871,6 +1240,7 @@ elements.threshold.addEventListener("input", (event) => {
|
||||
elements.thresholdValue.value = state.calibration.threshold.toFixed(2);
|
||||
});
|
||||
elements.saveCalibration.addEventListener("click", saveCalibration);
|
||||
elements.testCalibration.addEventListener("click", testCalibration);
|
||||
elements.clearCalibration.addEventListener("click", clearCalibration);
|
||||
elements.recognizeAll.addEventListener("click", toggleCollection);
|
||||
elements.clearResults.addEventListener("click", clearResults);
|
||||
@@ -901,6 +1271,9 @@ window.addEventListener("beforeunload", () => {
|
||||
state.sources.forEach((source) => source.stream.getTracks().forEach((track) => track.stop()));
|
||||
terminateVision();
|
||||
});
|
||||
window.addEventListener("online", syncMarketOutbox);
|
||||
window.setInterval(syncMarketOutbox, 30_000);
|
||||
void syncMarketOutbox();
|
||||
|
||||
renderSources();
|
||||
renderCalibration();
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
const DATABASE_NAME = "l2-market-parser";
|
||||
const OBSERVATIONS = "market-observations";
|
||||
const META = "meta";
|
||||
const PENDING_BATCH = "pending-market-batch";
|
||||
|
||||
export class MarketOutbox {
|
||||
constructor(store, send, { now = () => new Date().toISOString(), uuid = () => crypto.randomUUID() } = {}) {
|
||||
this.store = store;
|
||||
this.send = send;
|
||||
this.now = now;
|
||||
this.uuid = uuid;
|
||||
this.flushing = null;
|
||||
}
|
||||
|
||||
enqueue(observation) {
|
||||
return this.store.add(observation);
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (!this.flushing) this.flushing = this.flushOnce().finally(() => (this.flushing = null));
|
||||
return this.flushing;
|
||||
}
|
||||
|
||||
async flushOnce() {
|
||||
let batch = await this.store.getBatch();
|
||||
if (!batch) {
|
||||
const observations = await this.store.list(200);
|
||||
if (!observations.length) return { sent: false, count: 0 };
|
||||
batch = {
|
||||
batchId: this.uuid(),
|
||||
capturedAt: this.now(),
|
||||
observationIds: observations.map(({ observationId }) => observationId),
|
||||
};
|
||||
await this.store.setBatch(batch);
|
||||
}
|
||||
|
||||
const observations = await this.store.getMany(batch.observationIds);
|
||||
if (!observations.length) {
|
||||
await this.store.ack(batch);
|
||||
return { sent: false, count: 0 };
|
||||
}
|
||||
|
||||
await this.send({ batchId: batch.batchId, capturedAt: batch.capturedAt, observations });
|
||||
await this.store.ack(batch);
|
||||
return { sent: true, count: observations.length };
|
||||
}
|
||||
}
|
||||
|
||||
export function createMarketOutbox() {
|
||||
return new MarketOutbox(createIndexedDbStore(), async (batch) => {
|
||||
const response = await fetch("/api/market-import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(batch),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Импорт рынка отклонён (${response.status})`);
|
||||
});
|
||||
}
|
||||
|
||||
function createIndexedDbStore() {
|
||||
const database = openDatabase();
|
||||
return {
|
||||
async add(observation) {
|
||||
const db = await database;
|
||||
await requestInTransaction(db, OBSERVATIONS, "readwrite", (store) => store.add(observation));
|
||||
},
|
||||
async list(limit) {
|
||||
const db = await database;
|
||||
return requestInTransaction(db, OBSERVATIONS, "readonly", (store) => store.getAll(undefined, limit));
|
||||
},
|
||||
async getMany(ids) {
|
||||
const db = await database;
|
||||
const transaction = db.transaction(OBSERVATIONS, "readonly");
|
||||
const store = transaction.objectStore(OBSERVATIONS);
|
||||
const values = await Promise.all(ids.map((id) => request(store.get(id))));
|
||||
await transactionDone(transaction);
|
||||
return values.filter(Boolean);
|
||||
},
|
||||
async getBatch() {
|
||||
const db = await database;
|
||||
const value = await requestInTransaction(db, META, "readonly", (store) => store.get(PENDING_BATCH));
|
||||
return value?.batch ?? null;
|
||||
},
|
||||
async setBatch(batch) {
|
||||
const db = await database;
|
||||
// ponytail: one local collector tab is assumed; add a cross-tab lease if that becomes a real workflow.
|
||||
await requestInTransaction(db, META, "readwrite", (store) => store.put({ key: PENDING_BATCH, batch }));
|
||||
},
|
||||
async ack(batch) {
|
||||
const db = await database;
|
||||
const transaction = db.transaction([OBSERVATIONS, META], "readwrite");
|
||||
const meta = transaction.objectStore(META);
|
||||
const current = await request(meta.get(PENDING_BATCH));
|
||||
if (current?.batch?.batchId === batch.batchId) {
|
||||
batch.observationIds.forEach((id) => transaction.objectStore(OBSERVATIONS).delete(id));
|
||||
meta.delete(PENDING_BATCH);
|
||||
}
|
||||
await transactionDone(transaction);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const open = indexedDB.open(DATABASE_NAME, 1);
|
||||
open.onupgradeneeded = () => {
|
||||
open.result.createObjectStore(OBSERVATIONS, { keyPath: "observationId" });
|
||||
open.result.createObjectStore(META, { keyPath: "key" });
|
||||
};
|
||||
open.onsuccess = () => resolve(open.result);
|
||||
open.onerror = () => reject(open.error);
|
||||
});
|
||||
}
|
||||
|
||||
function requestInTransaction(database, storeName, mode, operation) {
|
||||
const transaction = database.transaction(storeName, mode);
|
||||
const result = request(operation(transaction.objectStore(storeName)));
|
||||
return Promise.all([result, transactionDone(transaction)]).then(([value]) => value);
|
||||
}
|
||||
|
||||
function request(value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
value.onsuccess = () => resolve(value.result);
|
||||
value.onerror = () => reject(value.error);
|
||||
});
|
||||
}
|
||||
|
||||
function transactionDone(transaction) {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
+14
-15
@@ -16,15 +16,6 @@ export function resolveFieldRect(anchorPosition, field) {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveScreenRect(region, width, height) {
|
||||
return {
|
||||
x: Math.round(region.xRatio * width),
|
||||
y: Math.round(region.yRatio * height),
|
||||
width: Math.round(region.widthRatio * width),
|
||||
height: Math.round(region.heightRatio * height),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOcrText(text) {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
@@ -40,11 +31,19 @@ export function parseFieldValue(type, text) {
|
||||
return digits ? Number(digits) : null;
|
||||
}
|
||||
|
||||
export function parseMerchantText(text) {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeOcrText)
|
||||
.find(Boolean) ?? "";
|
||||
export function parseStoreHeaderText(text) {
|
||||
const normalized = normalizeOcrText(text);
|
||||
const match = normalized.match(
|
||||
/private\s*store.*?\b(buy|sell)\b.*?[-–—]\s*(.+)$/i,
|
||||
);
|
||||
|
||||
if (!match?.[2]?.trim()) return null;
|
||||
|
||||
return {
|
||||
side: match[1].toLowerCase(),
|
||||
merchant: match[2].trim(),
|
||||
rawText: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTooltipText(text) {
|
||||
@@ -75,7 +74,7 @@ export function isCalibrationReady(calibration) {
|
||||
calibration?.saleAnchor?.image &&
|
||||
calibration.saleAnchor.width >= 4 &&
|
||||
calibration.saleAnchor.height >= 4 &&
|
||||
calibration.merchantRegion &&
|
||||
calibration.storeHeaderRegion &&
|
||||
calibration.tooltipSearchRegion &&
|
||||
calibration.tooltipAnchor?.image &&
|
||||
calibration.itemNameRegion &&
|
||||
|
||||
+197
-1
@@ -216,6 +216,73 @@ h3 {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calibration-guide {
|
||||
margin-block-end: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.calibration-guide summary {
|
||||
padding: 13px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calibration-guide__content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 0 16px 16px;
|
||||
border-block-start: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.calibration-guide ol {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 14px 0 0;
|
||||
padding-inline-start: 24px;
|
||||
}
|
||||
|
||||
.calibration-guide li {
|
||||
padding-inline-start: 5px;
|
||||
}
|
||||
|
||||
.calibration-guide li strong,
|
||||
.calibration-guide li span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calibration-guide li span {
|
||||
margin-block-start: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pipeline-map {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pipeline-map span {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.pipeline-map b,
|
||||
.pipeline-map i {
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -540,7 +607,7 @@ input[type="range"] {
|
||||
|
||||
.inspector__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
padding-block-start: 2px;
|
||||
}
|
||||
@@ -605,6 +672,108 @@ input[type="range"] {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
border-block-start: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.diagnostics summary {
|
||||
padding: 13px 20px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.diagnostics__content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.9fr) minmax(420px, 1.4fr);
|
||||
gap: 18px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.diagnostics__preview {
|
||||
align-self: start;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.diagnostics__preview img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.diagnostics__preview p {
|
||||
margin: 0;
|
||||
padding: 9px 11px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.stage-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.stage-row {
|
||||
display: grid;
|
||||
grid-template-columns: 11px minmax(130px, 0.45fr) minmax(180px, 1fr) 120px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 58px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.stage-row__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
.stage-row[data-status="found"] .stage-row__dot {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.stage-row[data-status="not-found"] .stage-row__dot,
|
||||
.stage-row[data-status="error"] .stage-row__dot {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.stage-row[data-status="skipped"] {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.stage-row strong {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stage-row p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.stage-row img {
|
||||
display: block;
|
||||
width: 120px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
border-radius: 5px;
|
||||
background: oklch(0.18 0.01 255);
|
||||
}
|
||||
|
||||
.stage-row__empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -659,6 +828,18 @@ td:nth-child(4) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.diagnostics__content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stage-row {
|
||||
grid-template-columns: 11px minmax(110px, 0.45fr) minmax(160px, 1fr) 100px;
|
||||
}
|
||||
|
||||
.stage-row img {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.inspector {
|
||||
border-block-start: 1px solid var(--border);
|
||||
border-inline-start: 0;
|
||||
@@ -713,6 +894,21 @@ td:nth-child(4) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stage-row {
|
||||
grid-template-columns: 11px 1fr;
|
||||
}
|
||||
|
||||
.stage-row p,
|
||||
.stage-row img,
|
||||
.stage-row__empty {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.stage-row img {
|
||||
width: 100%;
|
||||
height: 68px;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 680px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { forwardMarketBatch } from "../vite.config.js";
|
||||
|
||||
test("keeps the import token in the local server-side forwarder", async () => {
|
||||
let forwarded;
|
||||
const response = await forwardMarketBatch('{"batchId":"one"}', {
|
||||
url: "https://l2.example/api/l2/market/import",
|
||||
token: "secret",
|
||||
fetchImpl: async (url, init) => {
|
||||
forwarded = { url, init };
|
||||
return Response.json({ imported: true });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(forwarded.url, "https://l2.example/api/l2/market/import");
|
||||
assert.equal(forwarded.init.headers.Authorization, "Bearer secret");
|
||||
assert.equal(forwarded.init.body, '{"batchId":"one"}');
|
||||
assert.equal((await forwardMarketBatch("{}", {})).status, 503);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { MarketOutbox } from "../src/market-outbox.js";
|
||||
|
||||
test("keeps the same batch across retries and deletes only after acknowledgement", async () => {
|
||||
const store = memoryStore();
|
||||
await store.add({ observationId: "one", name: "Stem" });
|
||||
await store.add({ observationId: "two", name: "Stem" });
|
||||
const sent = [];
|
||||
let fail = true;
|
||||
const send = async (batch) => {
|
||||
sent.push(batch);
|
||||
if (fail) throw new Error("offline");
|
||||
};
|
||||
const options = { now: () => "2026-08-11T12:00:00.000Z", uuid: () => "batch-one" };
|
||||
|
||||
await assert.rejects(new MarketOutbox(store, send, options).flush(), /offline/);
|
||||
fail = false;
|
||||
const result = await new MarketOutbox(store, send, options).flush();
|
||||
|
||||
assert.equal(result.count, 2);
|
||||
assert.equal(sent[0].batchId, "batch-one");
|
||||
assert.deepEqual(sent[1], sent[0]);
|
||||
assert.deepEqual(await store.list(200), []);
|
||||
});
|
||||
|
||||
function memoryStore() {
|
||||
const observations = new Map();
|
||||
let batch = null;
|
||||
return {
|
||||
async add(value) {
|
||||
observations.set(value.observationId, value);
|
||||
},
|
||||
async list(limit) {
|
||||
return [...observations.values()].slice(0, limit);
|
||||
},
|
||||
async getMany(ids) {
|
||||
return ids.map((id) => observations.get(id)).filter(Boolean);
|
||||
},
|
||||
async getBatch() {
|
||||
return batch;
|
||||
},
|
||||
async setBatch(value) {
|
||||
batch = value;
|
||||
},
|
||||
async ack(value) {
|
||||
if (batch?.batchId !== value.batchId) return;
|
||||
value.observationIds.forEach((id) => observations.delete(id));
|
||||
batch = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
+14
-16
@@ -3,12 +3,11 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
isCalibrationReady,
|
||||
isRectInside,
|
||||
parseMerchantText,
|
||||
parseFieldValue,
|
||||
parseStoreHeaderText,
|
||||
parseTooltipText,
|
||||
rectFromPoints,
|
||||
resolveFieldRect,
|
||||
resolveScreenRect,
|
||||
} from "../src/parser.js";
|
||||
|
||||
test("normalizes a reverse drag into a positive rectangle", () => {
|
||||
@@ -41,7 +40,7 @@ test("requires both anchors and exact OCR regions", () => {
|
||||
assert.equal(
|
||||
isCalibrationReady({
|
||||
saleAnchor: { image: "data:image/png;base64,x", width: 20, height: 10 },
|
||||
merchantRegion: { xRatio: 0.1, yRatio: 0.1, widthRatio: 0.2, heightRatio: 0.1 },
|
||||
storeHeaderRegion: { offsetX: -50, offsetY: -300, width: 250, height: 24 },
|
||||
tooltipSearchRegion: { offsetX: -100, offsetY: -300, width: 300, height: 100 },
|
||||
tooltipAnchor: { image: "data:image/png;base64,y", width: 30, height: 12 },
|
||||
itemNameRegion: { offsetX: 0, offsetY: -18, width: 220, height: 18 },
|
||||
@@ -56,17 +55,6 @@ test("detects OCR rectangles outside the captured frame", () => {
|
||||
assert.equal(isRectInside({ x: 90, y: 10, width: 20, height: 20 }, 100, 100), false);
|
||||
});
|
||||
|
||||
test("resolves the merchant region against the current window size", () => {
|
||||
assert.deepEqual(
|
||||
resolveScreenRect(
|
||||
{ xRatio: 0.25, yRatio: 0.1, widthRatio: 0.5, heightRatio: 0.08 },
|
||||
1200,
|
||||
800,
|
||||
),
|
||||
{ x: 300, y: 80, width: 600, height: 64 },
|
||||
);
|
||||
});
|
||||
|
||||
test("parses a hovered item tooltip", () => {
|
||||
assert.deepEqual(
|
||||
parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"),
|
||||
@@ -79,6 +67,16 @@ test("parses a hovered item tooltip", () => {
|
||||
assert.equal(parseTooltipText("Purchase List\nAdena 5,403,160"), null);
|
||||
});
|
||||
|
||||
test("takes the first OCR line as merchant name", () => {
|
||||
assert.equal(parseMerchantText(" Dwa \nVagabond\n"), "Dwa");
|
||||
test("parses trade side and merchant from the store header", () => {
|
||||
assert.deepEqual(parseStoreHeaderText("Private Store(Buy) - Hikvision"), {
|
||||
side: "buy",
|
||||
merchant: "Hikvision",
|
||||
rawText: "Private Store(Buy) - Hikvision",
|
||||
});
|
||||
assert.deepEqual(parseStoreHeaderText(" Private Store({Sell) - Dwa \n"), {
|
||||
side: "sell",
|
||||
merchant: "Dwa",
|
||||
rawText: "Private Store({Sell) - Dwa",
|
||||
});
|
||||
assert.equal(parseStoreHeaderText("Items on Sale"), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
const MAX_BATCH_BYTES = 512 * 1024;
|
||||
|
||||
export async function forwardMarketBatch(body, { url, token, fetchImpl = fetch }) {
|
||||
if (!url || !token) return new Response("Market import is not configured", { status: 503 });
|
||||
return fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
function marketImportProxy(options) {
|
||||
return {
|
||||
name: "market-import-proxy",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/api/market-import", async (request, response) => {
|
||||
if (request.method !== "POST") {
|
||||
response.statusCode = 405;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(request);
|
||||
const upstream = await forwardMarketBatch(body, options);
|
||||
response.statusCode = upstream.status;
|
||||
response.setHeader("Content-Type", upstream.headers.get("content-type") || "application/json");
|
||||
response.end(await upstream.text());
|
||||
} catch (error) {
|
||||
response.statusCode = error.code === "BATCH_TOO_LARGE" ? 413 : 502;
|
||||
response.end(error.code === "BATCH_TOO_LARGE" ? "Batch too large" : "Market import failed");
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readBody(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
let tooLarge = false;
|
||||
request.on("data", (chunk) => {
|
||||
if (tooLarge) return;
|
||||
size += chunk.length;
|
||||
if (size > MAX_BATCH_BYTES) {
|
||||
tooLarge = true;
|
||||
const error = new Error("Batch too large");
|
||||
error.code = "BATCH_TOO_LARGE";
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on("end", () => {
|
||||
if (!tooLarge) resolve(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const environment = loadEnv(mode, process.cwd(), "");
|
||||
return {
|
||||
plugins: [
|
||||
marketImportProxy({
|
||||
url: environment.L2_MARKET_IMPORT_URL,
|
||||
token: environment.L2_MARKET_IMPORT_TOKEN,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user