chore: initialize repository baseline
Import the existing Electron + React + TypeScript app as the version-control baseline before the scanner rework (C# input/capture sidecar, resolution-anchored layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop). Housekeeping in this commit: - Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script). - Ignore .claude/scheduled_tasks.lock local session state. - Add .gitattributes to normalize line endings (LF in repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
import type { BooleanResult, 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 { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "./scannerCaptureQuality";
|
||||
import type { AutoScanStats, ScanSummary } from "./scannerSession";
|
||||
import { clampSkipRows, emptyAutoScanStats, resolveScanTargetCount } from "./scannerSession";
|
||||
|
||||
// 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) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => 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;
|
||||
};
|
||||
|
||||
export type AutoScanLoopResult = {
|
||||
status: ScanSummary["status"];
|
||||
stats: AutoScanStats;
|
||||
blockedReason: string;
|
||||
pageCount: number;
|
||||
gridLabel: string;
|
||||
targetCount: number;
|
||||
};
|
||||
|
||||
const CLICK_SETTLE_MS = 280;
|
||||
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 maxTargets = resolveScanTargetCount(options.scanLimit, options.detectedInventoryCount);
|
||||
const rowsToSkip = clampSkipRows(options.skipRows);
|
||||
const seen = new Set<string>();
|
||||
const seenPageFingerprints = new Set<string>();
|
||||
let page = 0;
|
||||
let blockedReason = "";
|
||||
let aborted = false;
|
||||
let consecutiveMisses = 0;
|
||||
let rowsQueued = 0;
|
||||
|
||||
function updateStats() {
|
||||
setAutoScanStats({ ...stats });
|
||||
}
|
||||
|
||||
async function saveAutomaticReviewSample(capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason: string) {
|
||||
const saved = await saveReviewSample(capture, parsed, reason);
|
||||
if (saved?.ok) {
|
||||
stats.review++;
|
||||
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;
|
||||
}
|
||||
|
||||
let currentCapture = await captureSelectedSource(0, true);
|
||||
const initialCaptureRejection = captureSourceRejectionReason(currentCapture);
|
||||
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 { status: "blocked", stats, blockedReason: reason, pageCount: 0, gridLabel: reason, targetCount: maxTargets };
|
||||
}
|
||||
|
||||
let lastDetailSignature = "";
|
||||
const initialParsed = parseArtifact(currentCapture);
|
||||
if (initialParsed) lastDetailSignature = sessionSignature(initialParsed);
|
||||
let lastDetailViewFingerprint = detailFingerprint(currentCapture);
|
||||
|
||||
try {
|
||||
while (!blockedReason && !shouldStop() && stats.clicked < 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,
|
||||
rowsQueued,
|
||||
});
|
||||
const targets = 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.clicked}/${maxTargets} geklickt.`);
|
||||
let newArtifactsOnPage = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
if (shouldStop() || stats.clicked >= 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.moved === false || clickResult.clicked === false) {
|
||||
// A structural failure (cursor could not be placed, or SendInput
|
||||
// was rejected outright) means clicks are not reaching Genshin at
|
||||
// all - almost always an elevation mismatch. Abort immediately
|
||||
// instead of clicking blindly through the rest of the inventory.
|
||||
blockedReason = clickResult.inputBlocked
|
||||
? "Windows blockiert die Eingabe (UIPI). Starte die App als Administrator (Scanner Diagnose > 'App als Administrator neu starten')."
|
||||
: `Klick kam nicht an (Ziel ${target.x},${target.y}). Starte die App als Administrator und versuche es erneut.`;
|
||||
break;
|
||||
}
|
||||
|
||||
let waitStop = await waitDuringScan(CLICK_SETTLE_MS);
|
||||
if (waitStop) {
|
||||
blockedReason = waitStop;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let previewCapture = await captureFastSelectedSource(0, true);
|
||||
let previewFingerprint = detailFingerprint(previewCapture);
|
||||
let changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
|
||||
|
||||
if (!changedDetail) {
|
||||
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;
|
||||
}
|
||||
waitStop = await waitDuringScan(CLICK_SETTLE_MS);
|
||||
if (waitStop) {
|
||||
blockedReason = waitStop;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
previewCapture = await captureFastSelectedSource(0, true);
|
||||
previewFingerprint = detailFingerprint(previewCapture);
|
||||
changedDetail = Boolean(previewFingerprint && previewFingerprint !== lastDetailViewFingerprint);
|
||||
}
|
||||
|
||||
if (!changedDetail) {
|
||||
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++;
|
||||
|
||||
const capture = await captureSelectedSource(0, true);
|
||||
if (capture?.ocrTimedOut) {
|
||||
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);
|
||||
|
||||
if (rejection) {
|
||||
await 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++;
|
||||
if (await persistParsedArtifact(capture, parsed, "auto-scan-review", true)) stats.stored++;
|
||||
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) {
|
||||
await saveAutomaticReviewSample(capture, parsed, `automatic:${reason}:p${page}:r${target.row}c${target.col}`);
|
||||
}
|
||||
if (await persistParsedArtifact(capture, parsed, "auto-scan", needsReview)) stats.stored++;
|
||||
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,
|
||||
rowsQueued,
|
||||
});
|
||||
rowsQueued = endOfPagePlan.rowsQueuedAfterPage;
|
||||
|
||||
if (aborted || stats.clicked >= 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 scrollWaitStop = await waitDuringScan(760);
|
||||
if (scrollWaitStop) {
|
||||
blockedReason = scrollWaitStop;
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const beforeScrollFingerprint = currentPageFingerprint || screenFingerprint(currentCapture);
|
||||
currentCapture = await captureFastSelectedSource(0, true);
|
||||
const afterScrollFingerprint = screenFingerprint(currentCapture);
|
||||
|
||||
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(currentCapture?.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";
|
||||
return {
|
||||
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.detailDataUrl) return fingerprintDataUrl(capture.detailDataUrl);
|
||||
if (capture.dataUrl) return fingerprintDataUrl(capture.dataUrl);
|
||||
return "";
|
||||
}
|
||||
|
||||
export function screenFingerprint(capture: CaptureResult | null) {
|
||||
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) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user