Files
genshin-assistant/src/lib/autoScanLoop.ts
T
2026-07-07 22:02:24 +02:00

766 lines
30 KiB
TypeScript

import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, AutomationGuard, ScrollResult } from "../types/global";
import type { ParsedArtifactCandidate } from "./artifactOcrParser";
import { sessionSignature } from "./artifactStore";
import { buildGridModel, buildInventoryPagePlan, type GridTarget } from "./automationPlanner";
import { classifyAutoScanCapture, shouldAbortAfterConsecutiveMisses } from "./autoScanController";
import { waitForCardReady } from "./cardReadyGate";
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
import type { AutoScanStats, ScanSummary } from "./scannerSession";
import { addCaptureTiming, addCardReadyTiming, addScrollReadyTiming, clampSkipRows, emptyAutoScanStats, resolveScanTargetCount, updateScanTiming } from "./scannerSession";
import { validateAutoScanEntryPreflight } from "./autoScanEntry";
// Simplified to match Inventory Kamera's proven approach (see docs/DECISIONS.md
// ADR-007): one click per tile, a fixed settle delay, one retry if the detail
// view did not change, then move on. No click-profile matrix, no offset
// retries, no double-read conflict resolution - those never fixed a single
// click and only made failures harder to diagnose.
type AutoScanApi = {
clickScreen: (x: number, y: number) => Promise<ClickResult>;
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
getAutomationGuard?: () => Promise<AutomationGuard>;
};
export type AutoScanLoopDependencies = {
api: AutoScanApi;
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
persistParsedArtifact: (
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate,
source: string,
needsReview: boolean,
) => Promise<boolean>;
saveReviewSample: (
capture: CaptureResult | null,
parsed: ParsedArtifactCandidate | null,
reason: string,
) => Promise<BooleanResult | null>;
getAutoReviewReason: (capture: CaptureResult, parsed: ParsedArtifactCandidate) => string;
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
appendAutomationLog: (line: string) => void;
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
setReviewStatus: (value: string) => void;
setAutoScanStats: (stats: AutoScanStats) => void;
shouldStop: () => boolean;
};
export type AutoScanLoopOptions = {
scanLimit: number;
skipRows: number;
detectedInventoryCount?: number | null;
processInitialSelection?: boolean;
skipInitialGridTarget?: boolean;
ocrEngine?: CaptureOptions["ocrEngine"];
};
export type AutoScanLoopResult = {
status: ScanSummary["status"];
stats: AutoScanStats;
blockedReason: string;
pageCount: number;
gridLabel: string;
targetCount: number;
};
// Card-ready gating replaces a fixed settle delay: poll the detail fingerprint
// until it has changed and stabilized (or the budget is spent). See cardReadyGate.
const CARD_READY_MAX_MS = 420;
const CARD_READY_POLL_MS = 60;
const CARD_READY_STABLE_SAMPLES = 2;
const CARD_READY_ACCEPT_CHANGED_AFTER_MS = 200;
const SCROLL_READY_MAX_MS = 760;
const SCROLL_READY_POLL_MS = 80;
const SCROLL_READY_STABLE_SAMPLES = 2;
const SCROLL_READY_ACCEPT_CHANGED_AFTER_MS = 100;
const MISS_ABORT_THRESHOLD = 3;
const UNREADABLE_ABORT_THRESHOLD = 5;
export async function runAutoScanLoop(
deps: AutoScanLoopDependencies,
options: AutoScanLoopOptions,
): Promise<AutoScanLoopResult> {
const {
api,
captureSelectedSource,
captureFastSelectedSource,
parseArtifact,
persistParsedArtifact,
saveReviewSample,
getAutoReviewReason,
shouldFlagArtifactForReview,
appendAutomationLog,
appendClickDiagnostics,
setReviewStatus,
setAutoScanStats,
shouldStop,
} = deps;
const stats: AutoScanStats = { ...emptyAutoScanStats };
const startedAt = Date.now();
const maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount);
const rowsToSkip = clampSkipRows(options.skipRows);
const seen = new Set<string>();
const seenDetailFingerprints = new Set<string>();
const seenPageFingerprints = new Set<string>();
let page = 0;
let blockedReason = "";
let aborted = false;
let consecutiveMisses = 0;
let rowsQueued = 0;
let writeQueue: Promise<void> = Promise.resolve();
function updateStats(preserveActiveScanMs = false) {
updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs });
setAutoScanStats({ ...stats });
}
function enqueueWrite(label: string, task: () => Promise<void>) {
writeQueue = writeQueue
.catch(() => undefined)
.then(async () => {
try {
await task();
} catch (error) {
appendAutomationLog(`write failed ${label}: ${error instanceof Error ? error.message : String(error)}`);
}
});
}
async function flushWrites() {
await writeQueue.catch(() => undefined);
updateStats(true);
}
async function finish(result: AutoScanLoopResult) {
const flushStartedAt = Date.now();
stats.activeScanMs = Math.max(0, flushStartedAt - startedAt);
await flushWrites();
stats.writeFlushMs += Math.max(0, Date.now() - flushStartedAt);
updateStats(true);
return { ...result, stats: { ...stats } };
}
function saveAutomaticReviewSample(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason: string) {
enqueueWrite(`review:${reason}`, async () => {
const saved = await saveReviewSample(capture, parsed, reason);
if (saved?.ok) {
stats.review++;
updateStats();
}
});
}
function persistArtifactLater(capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) {
enqueueWrite(`persist:${source}:${parsed.name}`, async () => {
if (await persistParsedArtifact(capture, parsed, source, needsReview)) {
stats.stored++;
updateStats();
}
});
}
async function checkGuard() {
if (shouldStop()) return "Stop-Button gedrueckt.";
if (!api.getAutomationGuard) return "";
try {
const guard = await api.getAutomationGuard();
if (guard.escapePressed) return "ESC wird gehalten - Scan sofort gestoppt.";
if (guard.enterPressed) return "ENTER wird gehalten - Scan sofort gestoppt.";
if (guard.f9Pressed) return "F9 wird gehalten - Scan sofort gestoppt.";
return "";
} catch {
return "";
}
}
function inputStopReason(result: ClickResult) {
if (result.escapePressed) return "ESC wird gehalten - Scan sofort gestoppt.";
if (result.enterPressed) return "ENTER wird gehalten - Scan sofort gestoppt.";
if (result.f9Pressed) return "F9 wird gehalten - Scan sofort gestoppt.";
return "";
}
async function waitDuringScan(ms: number) {
const startedAt = Date.now();
while (Date.now() - startedAt < ms) {
const guardReason = await checkGuard();
if (guardReason) return guardReason;
await wait(Math.min(120, ms - (Date.now() - startedAt)));
}
return "";
}
async function clickTarget(target: GridTarget, label: string) {
appendAutomationLog(`${label} r${target.row} c${target.col} -> ${target.x},${target.y}`);
const clickResult = await api.clickScreen(target.x, target.y);
appendClickDiagnostics(clickResult, `${label} r${target.row} c${target.col}`);
stats.clicked++;
stats.attempted = stats.clicked;
updateStats();
return clickResult;
}
function reportedClickDeliveryFailure(result: ClickResult) {
return result.moved === false || result.clicked === false;
}
function clickDeliveryFailureReason(target: GridTarget, clickResult: ClickResult) {
if (clickResult.inputBlocked) {
return "Windows blockiert die Eingabe (UIPI). Starte die App als Administrator (Scanner Diagnose > 'App als Administrator neu starten').";
}
if (clickResult.isElevated === false) {
return `Klick kam nicht an (Ziel ${target.x},${target.y}). Starte die App als Administrator und versuche es erneut.`;
}
const cursor = `${clickResult.cursorX ?? "?"},${clickResult.cursorY ?? "?"}`;
return `Cursor kam nicht am Klick-Ziel ${target.x},${target.y} an (Cursor ${cursor}). Genshin im Vordergrund lassen und erneut versuchen.`;
}
let currentCapture = await captureFastSelectedSource(0, false, {
omitFullFrame: true,
omitDetailPreview: true,
omitInventoryPreview: true,
omitCrops: true,
omitCropImages: true,
omitLockState: true,
});
const initialSurfaceRejection = validateAutoScanEntryPreflight(currentCapture);
const initialCaptureRejection = initialSurfaceRejection.ok ? captureSourceRejectionReason(currentCapture) : initialSurfaceRejection.reason;
let gridModel = buildGridModel(currentCapture?.inventoryGrid);
if (initialCaptureRejection || !gridModel || gridModel.targets.length === 0) {
const reason = initialCaptureRejection || "Kein verlaessliches Kachel-Grid erkannt. Artifact-Inventar sichtbar lassen und Smart Capture einmal ausfuehren.";
setReviewStatus(reason);
return finish({ status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets });
}
let lastDetailSignature = "";
let lastDetailViewFingerprint = detailFingerprint(currentCapture);
if (lastDetailViewFingerprint) seenDetailFingerprints.add(lastDetailViewFingerprint);
const shouldSkipInitialGridTarget = Boolean(options.processInitialSelection && options.skipInitialGridTarget);
let initialProcessedOffset = 0;
let initialSelectionDuplicateSkipped = false;
if (options.processInitialSelection) {
const initialCapture = await captureSelectedSource(0, false, {
ocrMode: "artifact",
ocrProfile: "fast",
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
omitFullFrame: true,
omitInventoryPreview: true,
omitCropImages: true,
omitEquippedOcr: true,
skipOcrUnlessArtifactDetail: true,
});
const initialSurfaceRejection = validateAutoScanEntryPreflight(initialCapture);
if (!initialSurfaceRejection.ok) {
return finish({
status: "blocked",
stats,
blockedReason: initialSurfaceRejection.reason,
pageCount: 0,
gridLabel: initialSurfaceRejection.reason,
targetCount: maxTargets,
});
}
if (!initialCapture) {
return finish({
status: "blocked",
stats,
blockedReason: "Keine Capture-Daten fuer die initiale Artifact-Auswahl.",
pageCount: 0,
gridLabel: "Keine Capture-Daten fuer die initiale Artifact-Auswahl.",
targetCount: maxTargets,
});
}
const parsed = parseArtifact(initialCapture);
const rejection = captureRejectionReason(initialCapture, parsed);
if (rejection || !parsed) {
saveAutomaticReviewSample(initialCapture, parsed, `automatic:initial-selection-rejected`);
stats.verified++;
stats.misses++;
addCaptureTiming(stats, initialCapture.timings);
initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0;
lastDetailViewFingerprint = detailFingerprint(initialCapture);
updateStats();
appendAutomationLog(`initial selection review: ${rejection || "kein Artifact lesbar"}; scan continues with next tile`);
} else {
stats.verified++;
stats.parsed++;
addCaptureTiming(stats, initialCapture.timings);
initialProcessedOffset = shouldSkipInitialGridTarget ? 1 : 0;
const signature = sessionSignature(parsed);
seen.add(signature);
lastDetailSignature = signature;
lastDetailViewFingerprint = detailFingerprint(initialCapture);
const reason = getAutoReviewReason(initialCapture, parsed);
const needsReview = reason ? true : shouldFlagArtifactForReview(parsed);
if (reason) saveAutomaticReviewSample(initialCapture, parsed, `automatic:${reason}:initial-selection`);
persistArtifactLater(initialCapture, parsed, "auto-scan-initial", needsReview);
updateStats();
appendAutomationLog(`initial selection parsed: ${parsed.name}`);
if (stats.parsed >= maxTargets) {
return finish({
status: "done",
stats,
blockedReason: "",
pageCount: 1,
gridLabel: `Initial ausgewaehltes Artifact verarbeitet, Ziel ${maxTargets} Artifacts`,
targetCount: maxTargets,
});
}
}
}
async function awaitCardReady() {
const startedAt = Date.now();
const result = await waitForCardReady(
{
sampleFingerprint: async () => detailFingerprint(await captureFastSelectedSource(0, false, {
omitFullFrame: true,
omitDetailPreview: true,
omitInventoryPreview: true,
omitCrops: true,
omitCropImages: true,
omitLockState: true,
})),
wait,
now: () => Date.now(),
checkAbort: checkGuard,
},
lastDetailViewFingerprint,
{
minStableSamples: CARD_READY_STABLE_SAMPLES,
maxWaitMs: CARD_READY_MAX_MS,
pollIntervalMs: CARD_READY_POLL_MS,
acceptChangedAfterMs: CARD_READY_ACCEPT_CHANGED_AFTER_MS,
},
);
addCardReadyTiming(stats, Date.now() - startedAt);
return result;
}
async function awaitInventoryPageReady(previousFingerprint: string): Promise<{
ready: Awaited<ReturnType<typeof waitForCardReady>>;
capture: CaptureResult | null;
}> {
let latestCapture: CaptureResult | null = null;
const startedAt = Date.now();
const ready = await waitForCardReady(
{
sampleFingerprint: async () => {
latestCapture = await captureFastSelectedSource(0, false, {
omitFullFrame: true,
omitDetailPreview: true,
omitInventoryPreview: true,
omitCrops: true,
omitCropImages: true,
omitLockState: true,
});
return screenFingerprint(latestCapture);
},
wait,
now: () => Date.now(),
checkAbort: checkGuard,
},
previousFingerprint,
{
minStableSamples: SCROLL_READY_STABLE_SAMPLES,
maxWaitMs: SCROLL_READY_MAX_MS,
pollIntervalMs: SCROLL_READY_POLL_MS,
acceptChangedAfterMs: SCROLL_READY_ACCEPT_CHANGED_AFTER_MS,
},
);
addScrollReadyTiming(stats, Date.now() - startedAt);
return { ready, capture: latestCapture };
}
try {
while (!blockedReason && !shouldStop() && stats.parsed < maxTargets) {
page++;
stats.pages = page;
updateStats();
const currentPageFingerprint = screenFingerprint(currentCapture);
if (isRepeatedProcessedPageFingerprint(currentPageFingerprint, seenPageFingerprints, page)) {
blockedReason = `Inventarseite ${page} wurde bereits zuvor gesehen. Scrollen hat wahrscheinlich keine neue Seite geliefert; Scan gestoppt, um keine Duplikat-Schleife zu erzeugen.`;
break;
}
if (currentPageFingerprint) seenPageFingerprints.add(currentPageFingerprint);
const pageSkipRows = page === 1 ? Math.min(rowsToSkip, Math.max(0, gridModel.rows - 1)) : 0;
const baseTargets = gridModel.targets.filter((target) => target.row >= pageSkipRows);
const pagePlan = buildInventoryPagePlan({
targets: baseTargets,
cols: gridModel.cols,
rows: Math.max(1, gridModel.rows - pageSkipRows),
totalTargetCount: maxTargets,
processedTargets: stats.clicked + initialProcessedOffset,
rowsQueued,
});
const targets = shouldSkipInitialGridTarget && page === 1
? baseTargets.slice(initialProcessedOffset)
: pagePlan.pageTargets;
if (targets.length === 0) {
blockedReason = `Keine Klick-Ziele nach dem Skippen von ${pageSkipRows} Zeile(n) auf Seite ${page}.`;
break;
}
setReviewStatus(`Automatischer Scan Seite ${page}: ${gridModel.cols} x ${gridModel.rows} Raster (${gridModel.source}, ${gridModel.confidence}%), ${targets.length} Klick-Ziele, ${stats.parsed}/${maxTargets} gelesen.`);
let newArtifactsOnPage = 0;
for (const target of targets) {
if (shouldStop() || stats.parsed >= maxTargets) break;
const guardReason = await checkGuard();
if (guardReason) {
blockedReason = guardReason;
aborted = true;
break;
}
let clickResult = await clickTarget(target, "click");
let stopReason = inputStopReason(clickResult);
if (stopReason) {
blockedReason = stopReason;
aborted = true;
break;
}
if (clickResult.inputBlocked) {
blockedReason = clickDeliveryFailureReason(target, clickResult);
break;
}
if (reportedClickDeliveryFailure(clickResult)) {
appendAutomationLog(`warn r${target.row} c${target.col}: helper reported cursor/click miss; verifying detail change`);
}
let ready = await awaitCardReady();
if (ready.abortReason) {
blockedReason = ready.abortReason;
aborted = true;
break;
}
let changedDetail = ready.changed;
if (!changedDetail) {
if (options.processInitialSelection && !initialSelectionDuplicateSkipped && !reportedClickDeliveryFailure(clickResult)) {
initialSelectionDuplicateSkipped = true;
consecutiveMisses = 0;
stats.duplicates++;
updateStats();
appendAutomationLog(`duplicate selected tile r${target.row} c${target.col}: already processed initial detail`);
continue;
}
appendAutomationLog(`retry r${target.row} c${target.col}: Detailansicht unveraendert`);
clickResult = await clickTarget(target, "retry");
stopReason = inputStopReason(clickResult);
if (stopReason) {
blockedReason = stopReason;
aborted = true;
break;
}
if (clickResult.inputBlocked) {
blockedReason = clickDeliveryFailureReason(target, clickResult);
break;
}
if (reportedClickDeliveryFailure(clickResult)) {
appendAutomationLog(`warn r${target.row} c${target.col}: retry helper reported cursor/click miss; verifying detail change`);
}
ready = await awaitCardReady();
if (ready.abortReason) {
blockedReason = ready.abortReason;
aborted = true;
break;
}
changedDetail = ready.changed;
}
if (!changedDetail) {
if (reportedClickDeliveryFailure(clickResult)) {
blockedReason = clickDeliveryFailureReason(target, clickResult);
break;
}
if (options.processInitialSelection && !initialSelectionDuplicateSkipped) {
initialSelectionDuplicateSkipped = true;
consecutiveMisses = 0;
stats.duplicates++;
updateStats();
appendAutomationLog(`duplicate selected tile r${target.row} c${target.col}: already processed initial detail`);
continue;
}
stats.misses++;
consecutiveMisses++;
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: Detailansicht unveraendert`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, MISS_ABORT_THRESHOLD)) {
blockedReason = "Mehrere Klicks hintereinander haben die Detailansicht nicht veraendert. Auto-Scan gestoppt: Klicks landen wahrscheinlich nicht auf neuen Artifacts.";
break;
}
continue;
}
stats.verified++;
if (ready.fingerprint) {
if (seenDetailFingerprints.has(ready.fingerprint)) {
consecutiveMisses = 0;
stats.duplicates++;
lastDetailViewFingerprint = ready.fingerprint;
updateStats();
appendAutomationLog(`duplicate visual r${target.row} c${target.col}: OCR uebersprungen`);
continue;
}
seenDetailFingerprints.add(ready.fingerprint);
}
const capture = await captureSelectedSource(0, false, {
ocrMode: "artifact",
ocrProfile: "fast",
...(options.ocrEngine ? { ocrEngine: options.ocrEngine } : {}),
omitFullFrame: true,
omitInventoryPreview: true,
omitCropImages: true,
omitEquippedOcr: true,
skipOcrUnlessArtifactDetail: true,
});
const captureSurfaceRejection = validateAutoScanEntryPreflight(capture);
if (!captureSurfaceRejection.ok) {
blockedReason = captureSurfaceRejection.reason;
appendAutomationLog(`blocked r${target.row} c${target.col}: ${blockedReason}`);
break;
}
if (capture?.ocrTimedOut) {
addCaptureTiming(stats, capture.timings);
stats.misses++;
consecutiveMisses++;
lastDetailViewFingerprint = detailFingerprint(capture);
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: OCR timeout`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, UNREADABLE_ABORT_THRESHOLD)) {
blockedReason = "Mehrere OCR-Timeouts hintereinander. Auto-Scan gestoppt, damit die Session nicht haengen bleibt.";
break;
}
continue;
}
const parsed = parseArtifact(capture);
const rejection = captureRejectionReason(capture, parsed);
addCaptureTiming(stats, capture?.timings);
if (rejection) {
saveAutomaticReviewSample(capture, parsed, `automatic:capture-rejected:p${page}:r${target.row}c${target.col}`);
if (parsed && shouldPersistParsedArtifact(parsed, true)) {
consecutiveMisses = 0;
const signature = sessionSignature(parsed);
stats.parsed++;
lastDetailSignature = signature;
lastDetailViewFingerprint = detailFingerprint(capture);
seen.add(signature);
newArtifactsOnPage++;
persistArtifactLater(capture, parsed, "auto-scan-review", true);
updateStats();
continue;
}
stats.misses++;
consecutiveMisses++;
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: ${rejection}`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, UNREADABLE_ABORT_THRESHOLD)) {
blockedReason = "Mehrere unlesbare Artifact-Captures hintereinander. Auto-Scan gestoppt, damit nicht blind weitergeklickt wird.";
break;
}
continue;
}
const signature = parsed ? sessionSignature(parsed) : "";
const decision = classifyAutoScanCapture({ signature, lastDetailSignature, seen });
if (!capture || !parsed || decision.kind === "unreadable") {
stats.misses++;
consecutiveMisses++;
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: kein Artifact lesbar`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, UNREADABLE_ABORT_THRESHOLD)) {
blockedReason = "Mehrere unlesbare Artifacts hintereinander. Auto-Scan gestoppt: Klicks treffen wahrscheinlich nicht das Artifact-Raster.";
break;
}
continue;
}
if (decision.kind === "stuck") {
stats.misses++;
consecutiveMisses++;
updateStats();
appendAutomationLog(`miss r${target.row} c${target.col}: Detail zeigt weiterhin "${parsed.name}"`);
if (shouldAbortAfterConsecutiveMisses(consecutiveMisses, MISS_ABORT_THRESHOLD)) {
blockedReason = `Mehrere Klicks blieben auf "${parsed.name}". Auto-Scan gestoppt.`;
break;
}
continue;
}
consecutiveMisses = 0;
stats.parsed++;
lastDetailSignature = signature;
lastDetailViewFingerprint = detailFingerprint(capture);
if (decision.kind === "duplicate") {
stats.duplicates++;
updateStats();
continue;
}
seen.add(signature);
newArtifactsOnPage++;
const reason = getAutoReviewReason(capture, parsed);
const needsReview = reason ? true : shouldFlagArtifactForReview(parsed);
if (reason) {
saveAutomaticReviewSample(capture, parsed, `automatic:${reason}:p${page}:r${target.row}c${target.col}`);
}
persistArtifactLater(capture, parsed, "auto-scan", needsReview);
updateStats();
}
const endOfPagePlan = buildInventoryPagePlan({
targets: gridModel.targets.filter((target) => target.row >= pageSkipRows),
cols: gridModel.cols,
rows: Math.max(1, gridModel.rows - pageSkipRows),
totalTargetCount: maxTargets,
processedTargets: stats.clicked + initialProcessedOffset,
rowsQueued,
});
rowsQueued = endOfPagePlan.rowsQueuedAfterPage;
if (aborted || stats.parsed >= maxTargets || shouldStop() || blockedReason) break;
if (newArtifactsOnPage === 0 && page > 1) {
blockedReason = `Seite ${page} hat keine neuen Artifacts geliefert; gestoppt, um nicht dieselbe Seite zu loopen.`;
break;
}
const guardReason = await checkGuard();
if (guardReason) {
blockedReason = guardReason;
aborted = true;
break;
}
const rowsToScroll = endOfPagePlan.scrollRowsAfterPage;
if (rowsToScroll <= 0) break;
const scrollNotches = Math.min(60, Math.max(1, rowsToScroll * 10 - 1));
setReviewStatus(`Automatischer Scan Seite ${page} fertig. Scrolle zur naechsten Inventory-Seite...`);
appendAutomationLog(`scroll ${scrollNotches} (${rowsToScroll} row(s)) @ ${gridModel.anchorX},${gridModel.anchorY}`);
const scrollResult = await api.scrollScreen(-scrollNotches, gridModel.anchorX, gridModel.anchorY);
if (scrollResult.inputBlocked) {
blockedReason = "Scroll-Input wurde von Windows blockiert. Starte die App als Administrator.";
break;
}
if (page % 12 === 0) {
appendAutomationLog(`scroll correction p${page}: +1 notch @ ${gridModel.anchorX},${gridModel.anchorY}`);
const correctionResult = await api.scrollScreen(1, gridModel.anchorX, gridModel.anchorY);
if (correctionResult.inputBlocked) {
blockedReason = "Scroll-Korrektur wurde von Windows blockiert.";
break;
}
}
const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture);
const scrollReady = await awaitInventoryPageReady(beforeScrollFingerprint);
if (scrollReady.ready.abortReason) {
blockedReason = scrollReady.ready.abortReason;
aborted = true;
break;
}
const scrolledCapture = scrollReady.capture;
if (!scrolledCapture) {
blockedReason = "Keine Capture-Daten nach dem Scrollen.";
break;
}
currentCapture = scrolledCapture;
const scrolledSurfaceRejection = validateAutoScanEntryPreflight(scrolledCapture);
if (!scrolledSurfaceRejection.ok) {
blockedReason = scrolledSurfaceRejection.reason;
break;
}
const afterScrollFingerprint = screenFingerprint(scrolledCapture);
if (beforeScrollFingerprint && afterScrollFingerprint && beforeScrollFingerprint === afterScrollFingerprint) {
blockedReason = "Scrollen hat die sichtbare Inventarseite nicht veraendert.";
break;
}
if (afterScrollFingerprint && seenPageFingerprints.has(afterScrollFingerprint)) {
blockedReason = "Scrollen hat erneut eine bereits verarbeitete Inventarseite gezeigt.";
break;
}
const refreshedModel = buildGridModel(scrolledCapture.inventoryGrid);
if (!refreshedModel) {
blockedReason = "Kachel-Grid nach dem Scrollen verloren.";
break;
}
if (refreshedModel.source === "detected" && refreshedModel.confidence >= 72) {
gridModel = refreshedModel;
}
}
} catch (error) {
blockedReason = `Fehler waehrend des Scans: ${error instanceof Error ? error.message : String(error)}`;
appendAutomationLog(blockedReason);
}
const status: ScanSummary["status"] = aborted || shouldStop() ? "stopped" : blockedReason ? "blocked" : "done";
updateScanTiming(stats, startedAt);
return finish({
status,
stats,
blockedReason,
pageCount: page,
gridLabel: blockedReason || `${page} Seite(n) verarbeitet, Ziel ${maxTargets} Artifacts, ${rowsToSkip} Zeile(n) auf der ersten Seite uebersprungen`,
targetCount: maxTargets,
});
}
export function detailFingerprint(capture: CaptureResult | null) {
if (!capture) return "";
if (capture.detailFingerprint) return capture.detailFingerprint;
if (capture.detailDataUrl) return fingerprintDataUrl(capture.detailDataUrl);
if (capture.dataUrl) return fingerprintDataUrl(capture.dataUrl);
return "";
}
export function screenFingerprint(capture: CaptureResult | null) {
if (capture?.inventoryFingerprint) return capture.inventoryFingerprint;
if (capture?.inventoryDataUrl) return fingerprintDataUrl(capture.inventoryDataUrl);
if (capture?.dataUrl) return fingerprintDataUrl(capture.dataUrl);
return "";
}
export function isRepeatedProcessedPageFingerprint(
fingerprint: string,
seenPageFingerprints: ReadonlySet<string>,
page: number,
) {
return page > 1 && Boolean(fingerprint) && seenPageFingerprints.has(fingerprint);
}
export function fingerprintDataUrl(dataUrl: string) {
let hash = 2166136261;
const stride = Math.max(1, Math.floor(dataUrl.length / 4096));
for (let index = 0; index < dataUrl.length; index += stride) {
hash ^= dataUrl.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
}
function wait(ms: number): Promise<void> {
const schedule = typeof window !== "undefined" && window.setTimeout ? window.setTimeout : setTimeout;
return new Promise((resolve) => schedule(() => resolve(), ms));
}