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; scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise; getAutomationGuard?: () => Promise; }; export type AutoScanLoopDependencies = { api: AutoScanApi; captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise; parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null; persistParsedArtifact: ( capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean, ) => Promise; saveReviewSample: ( capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason: string, ) => Promise; 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 { 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(); const seenDetailFingerprints = new Set(); const seenPageFingerprints = new Set(); let page = 0; let blockedReason = ""; let aborted = false; let consecutiveMisses = 0; let rowsQueued = 0; let writeQueue: Promise = Promise.resolve(); function updateStats(preserveActiveScanMs = false) { updateScanTiming(stats, startedAt, Date.now(), { preserveActiveScanMs }); setAutoScanStats({ ...stats }); } function enqueueWrite(label: string, task: () => Promise) { 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>; 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, 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 { const schedule = typeof window !== "undefined" && window.setTimeout ? window.setTimeout : setTimeout; return new Promise((resolve) => schedule(() => resolve(), ms)); }