From 0e5eea0c2a3536fd5355b438ef32fac54a0d3bd0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 14 May 2026 13:34:30 +0000
Subject: [PATCH 1/6] Fix Prettier formatting issues in 21 files
Agent-Logs-Url: https://github.com/potemkin666/bluelens/sessions/c865ca1d-dd7f-49e1-93be-be3b2191e9b6
Co-authored-by: potemkin666 <183807833+potemkin666@users.noreply.github.com>
---
README.md | 5 +
app.js | 662 +++++++++++++++++++++++++-----------
bluelens-config.js | 18 +-
bluelens-helpers.js | 156 ++++++---
docs/API.md | 78 +++--
docs/ARCHITECTURE.md | 12 +
docs/DEPLOYMENT.md | 47 ++-
docs/MODULARIZATION.md | 11 +-
docs/PROJECT_CONFIG.md | 9 +-
docs/README.md | 7 +
help.html | 8 +-
index.html | 565 +++++++++++++++---------------
launchpad-core.js | 36 +-
ocr-entities-ui.js | 52 ++-
ocr-pipeline.js | 329 ++++++++++++++++--
server.js | 213 +++++++-----
styles.css | 168 +++++----
test/helpers.test.js | 47 +--
test/osint.test.js | 33 +-
test/ui-regressions.test.js | 23 +-
wait.html | 15 +-
21 files changed, 1640 insertions(+), 854 deletions(-)
diff --git a/README.md b/README.md
index b0ede17..d93025b 100644
--- a/README.md
+++ b/README.md
@@ -208,27 +208,32 @@ For detailed documentation, see the [docs/](./docs) folder:
## 🧑💻 Development
**Install development tools**:
+
```bash
npm install
```
**Run tests**:
+
```bash
npm test
```
**Run tests with coverage**:
+
```bash
npm run test:coverage
```
**Linting**:
+
```bash
npm run lint
npm run lint:fix
```
**Formatting**:
+
```bash
npm run format:check
npm run format
diff --git a/app.js b/app.js
index 6505558..bf10de0 100644
--- a/app.js
+++ b/app.js
@@ -155,7 +155,6 @@ const elements = {
cmdk: document.getElementById("cmdk"),
cmdkInput: document.getElementById("cmdkInput"),
cmdkList: document.getElementById("cmdkList"),
-
};
const appHelpers = BLUELENS_HELPERS || {};
@@ -186,24 +185,32 @@ const sortBatchItems =
sortKey === "name"
? String(item?.report?.file?.name || "")
: sortKey === "gps"
- ? (triage.gps ? 1 : 0)
+ ? triage.gps
+ ? 1
+ : 0
: sortKey === "ent"
- ? (triage.entCount || 0)
+ ? triage.entCount || 0
: sortKey === "repost"
- ? (Number.isFinite(triage.repost) ? triage.repost : -1)
+ ? Number.isFinite(triage.repost)
+ ? triage.repost
+ : -1
: sortKey === "cluster"
- ? (item?.clusterId || 0)
+ ? item?.clusterId || 0
: sortKey === "dim"
- ? (dims ? Number(dims[1]) * Number(dims[2]) : -1)
- : (triage.lead || 0);
+ ? dims
+ ? Number(dims[1]) * Number(dims[2])
+ : -1
+ : triage.lead || 0;
const va = pick(a, ta, dimsA);
const vb = pick(b, tb, dimsB);
if (typeof va === "string" || typeof vb === "string") return dir * String(va).localeCompare(String(vb));
return dir * ((va || 0) - (vb || 0));
});
});
-const buildEntityGraph = appHelpers.buildEntityGraph || (() => ({ nodes: [], edges: [], summary: { reports: 0, file_nodes: 0, entity_nodes: 0, edges: 0 } }));
-const buildInvestigationTimeline = appHelpers.buildInvestigationTimeline || (() => ({ events: [], summary: { total: 0, ambiguous: 0, categories: {} } }));
+const buildEntityGraph =
+ appHelpers.buildEntityGraph || (() => ({ nodes: [], edges: [], summary: { reports: 0, file_nodes: 0, entity_nodes: 0, edges: 0 } }));
+const buildInvestigationTimeline =
+ appHelpers.buildInvestigationTimeline || (() => ({ events: [], summary: { total: 0, ambiguous: 0, categories: {} } }));
const runtimeConfig = BLUELENS_CONFIG || {};
const CONFIG_META = runtimeConfig.meta || {};
@@ -212,7 +219,18 @@ const SERVER_CONFIG = runtimeConfig.server || {};
const FX_CONFIG = runtimeConfig.fx || {};
const STORAGE_KEYS = runtimeConfig.storageKeys || {};
-const ENGINE_ORDER = APP_CONFIG.engines?.order || ["lens", "bing", "yandex", "tineye", "pinterest", "saucenao", "iqdb", "baidu", "ascii2d", "google_images"];
+const ENGINE_ORDER = APP_CONFIG.engines?.order || [
+ "lens",
+ "bing",
+ "yandex",
+ "tineye",
+ "pinterest",
+ "saucenao",
+ "iqdb",
+ "baidu",
+ "ascii2d",
+ "google_images",
+];
const ENGINE_LABEL = APP_CONFIG.engines?.labels || {
lens: "Lens",
bing: "Bing",
@@ -255,14 +273,15 @@ const BATCH_TOP_LENS_MAX = APP_CONFIG.batch?.topLensMax || 10;
const BATCH_OCR_DEFAULT = APP_CONFIG.batch?.ocrDefault || 8;
const BATCH_OCR_MAX = APP_CONFIG.batch?.ocrMax || 20;
const OCR_DEFAULT_LANGUAGE = APP_CONFIG.ocr?.defaultLanguage || "eng";
-const OCR_LANGUAGE_OPTIONS = Array.isArray(APP_CONFIG.ocr?.languages) && APP_CONFIG.ocr.languages.length
- ? APP_CONFIG.ocr.languages
- : [
- { value: "eng", label: "English" },
- { value: "spa", label: "Spanish" },
- { value: "fra", label: "French" },
- { value: "deu", label: "German" },
- ];
+const OCR_LANGUAGE_OPTIONS =
+ Array.isArray(APP_CONFIG.ocr?.languages) && APP_CONFIG.ocr.languages.length
+ ? APP_CONFIG.ocr.languages
+ : [
+ { value: "eng", label: "English" },
+ { value: "spa", label: "Spanish" },
+ { value: "fra", label: "French" },
+ { value: "deu", label: "German" },
+ ];
const OCR_FAST_PREPROCESS_MAX_DIM = APP_CONFIG.ocr?.fastPreprocessMaxDim || 1200;
const OCR_BATCH_PREPROCESS_MAX_DIM = APP_CONFIG.ocr?.batchPreprocessMaxDim || 1400;
const DHASH_BATCH_CLUSTER_THRESHOLD = APP_CONFIG.dhash?.batchClusterThreshold || 8;
@@ -272,7 +291,9 @@ const UPLOAD_PROXY_TIMEOUT_MS = APP_CONFIG.upload?.endpointTimeoutMs || 45000;
const UPLOAD_PREFLIGHT_TIMEOUT_MS = APP_CONFIG.upload?.preflightTimeoutMs || 2500;
const WAIT_JOB_DEFAULT_TIMEOUT_MS = SERVER_CONFIG.waitJobs?.defaultTimeoutMs || 25000;
const LOCAL_SERVER_HINT_TIMEOUT_MS = APP_CONFIG.localServerHint?.timeoutMs || 900;
-const LOCAL_SERVER_HINT_MESSAGE = APP_CONFIG.localServerHint?.offlineMessage || "Local server offline — start `bluelens-start.cmd` (or `node server.js`) for Upload + Launchpad.";
+const LOCAL_SERVER_HINT_MESSAGE =
+ APP_CONFIG.localServerHint?.offlineMessage ||
+ "Local server offline — start `bluelens-start.cmd` (or `node server.js`) for Upload + Launchpad.";
const WAIT_JOB_ENDPOINT_PREFIX = "/api/wait-jobs/";
const SESSION_KEY = STORAGE_KEYS.session || "osint:session:v1";
const LAST_RUN_KEY = STORAGE_KEYS.lastRun || "osint:lastRun:v1";
@@ -340,7 +361,8 @@ const RUN_QUEUE_STATUS = {
error: "error",
};
const UNKNOWN_EXPIRY_WINDOW = "unknown";
-const TEMP_EXTERNAL_ARTIFACT_WARNING = "Temporary external artifact — third-party upload URLs may expire or disappear before a reader opens the report.";
+const TEMP_EXTERNAL_ARTIFACT_WARNING =
+ "Temporary external artifact — third-party upload URLs may expire or disappear before a reader opens the report.";
const TEMP_EXTERNAL_ARTIFACT_NOTE = "Host retention is controlled by the third-party service and is not guaranteed by BlueLens.";
const compareDiffScratchA = document.createElement("canvas");
const compareDiffScratchB = document.createElement("canvas");
@@ -362,7 +384,8 @@ const EXPORT_RUNTIME_CONFIG_SOURCE = JSON.stringify({
metadataSuspicionBands: METADATA_SUSPICION_BANDS,
},
});
-const EXPORT_RUNTIME_CONFIG_FINGERPRINT = typeof sha256 === "function" ? sha256(EXPORT_RUNTIME_CONFIG_SOURCE) : EXPORT_RUNTIME_CONFIG_SOURCE;
+const EXPORT_RUNTIME_CONFIG_FINGERPRINT =
+ typeof sha256 === "function" ? sha256(EXPORT_RUNTIME_CONFIG_SOURCE) : EXPORT_RUNTIME_CONFIG_SOURCE;
const DOCTOR_SONAR_POLL_MS = 120_000;
let doctorSonarTimer = 0;
@@ -636,11 +659,9 @@ function getInvestigationReports({ currentReport = null } = {}) {
const addReport = (report) => {
const enriched = enrichReportForInvestigation(report);
if (!enriched) return;
- const key = [
- enriched.file?.name || "image",
- enriched.hashes?.sha256 || enriched.hashes?.md5 || "",
- enriched.generated_at || "",
- ].join("|");
+ const key = [enriched.file?.name || "image", enriched.hashes?.sha256 || enriched.hashes?.md5 || "", enriched.generated_at || ""].join(
+ "|",
+ );
if (seen.has(key)) return;
seen.add(key);
reports.push(enriched);
@@ -782,7 +803,9 @@ function renderInvestigationGraph(model) {
svg.innerHTML = `${edgeMarkup}${nodeMarkup} `;
const legendTypes = Array.from(new Set(nodes.map((node) => node.type)));
legend.hidden = legendTypes.length === 0;
- legend.innerHTML = legendTypes.map((type) => `${escapeHtml(type)} `).join("");
+ legend.innerHTML = legendTypes
+ .map((type) => `${escapeHtml(type)} `)
+ .join("");
}
function renderInvestigationDetail(model) {
@@ -809,10 +832,17 @@ function renderInvestigationDetail(model) {
...(linkedNodes.length ? linkedNodes.map((entry) => `- ${entry.label} (${entry.type}) · files ${entry.file_count}`) : ["- —"]),
"",
"Provenance:",
- ...((node.provenance || []).slice(0, 10).map((entry) => `- ${entry.file_name || "report"} · ${entry.field || "field"} · ${entry.source || "source"}${entry.raw ? ` · ${entry.raw}` : ""}${entry.excerpt ? ` · ${entry.excerpt}` : ""}`) || ["- —"]),
+ ...((node.provenance || [])
+ .slice(0, 10)
+ .map(
+ (entry) =>
+ `- ${entry.file_name || "report"} · ${entry.field || "field"} · ${entry.source || "source"}${entry.raw ? ` · ${entry.raw}` : ""}${entry.excerpt ? ` · ${entry.excerpt}` : ""}`,
+ ) || ["- —"]),
"",
"Edges:",
- ...(linkedEdges.length ? linkedEdges.map((edge) => `- ${edge.type} · evidence ${edge.evidence_count} · files ${edge.file_count}`) : ["- —"]),
+ ...(linkedEdges.length
+ ? linkedEdges.map((edge) => `- ${edge.type} · evidence ${edge.evidence_count} · files ${edge.file_count}`)
+ : ["- —"]),
];
el.textContent = lines.join("\n");
}
@@ -844,32 +874,45 @@ function renderDoctorSurface(report) {
lines.push("Running sonar…");
} else {
lines.push(`App: ${report.app_version || APP_VERSION} · schema ${report.schema_version || EXPORT_SCHEMA_VERSION}`);
- lines.push(`Ping: ${report.server?.ping_ok ? "OK" : "FAIL"}${report.server?.node_version ? ` · Node ${report.server.node_version}` : ""}`);
+ lines.push(
+ `Ping: ${report.server?.ping_ok ? "OK" : "FAIL"}${report.server?.node_version ? ` · Node ${report.server.node_version}` : ""}`,
+ );
lines.push(`Popup: ${report.popup?.ok ? "OK" : "BLOCKED"} · Storage: ${report.storage?.ok ? "OK" : "FAIL"}`);
lines.push(`Libraries: ${report.libs?.summary || "unknown"}`);
if (report.server?.recommended_upload_host) lines.push(`Best upload path: ${report.server.recommended_upload_host}`);
if (Array.isArray(report.server?.upload_reachability) && report.server.upload_reachability.length) {
lines.push("Upload reachability:");
for (const row of report.server.upload_reachability) {
- lines.push(`- ${row.host}: ${row.reachable ? "OK" : "FAIL"}${row.status_code ? ` (${row.status_code})` : ""}${row.error ? ` · ${row.error}` : ""}`);
+ lines.push(
+ `- ${row.host}: ${row.reachable ? "OK" : "FAIL"}${row.status_code ? ` (${row.status_code})` : ""}${row.error ? ` · ${row.error}` : ""}`,
+ );
}
}
if (Array.isArray(report.server?.cdn_reachability) && report.server.cdn_reachability.length) {
lines.push("CDN reachability:");
- for (const row of report.server.cdn_reachability) lines.push(`- ${row.name}: ${row.reachable ? "OK" : "FAIL"}${row.status_code ? ` (${row.status_code})` : ""}${row.error ? ` · ${row.error}` : ""}`);
+ for (const row of report.server.cdn_reachability)
+ lines.push(
+ `- ${row.name}: ${row.reachable ? "OK" : "FAIL"}${row.status_code ? ` (${row.status_code})` : ""}${row.error ? ` · ${row.error}` : ""}`,
+ );
}
if (Array.isArray(report.server?.engine_availability) && report.server.engine_availability.length) {
lines.push("Engine availability:");
- for (const row of report.server.engine_availability) lines.push(`- ${row.engine}: ${row.reachable ? "OK" : "FAIL"}${row.status_code ? ` (${row.status_code})` : ""}${row.error ? ` · ${row.error}` : ""}`);
+ for (const row of report.server.engine_availability)
+ lines.push(
+ `- ${row.engine}: ${row.reachable ? "OK" : "FAIL"}${row.status_code ? ` (${row.status_code})` : ""}${row.error ? ` · ${row.error}` : ""}`,
+ );
}
if (Array.isArray(report.server?.recent_upload_attempts) && report.server.recent_upload_attempts.length) {
lines.push("Recent upload attempts:");
- for (const row of report.server.recent_upload_attempts.slice(0, 6)) lines.push(`- ${row.host}: ${row.ok ? "OK" : "FAIL"} · ${fmtMs(row.ms)}${row.err ? ` · ${row.err}` : ""}`);
+ for (const row of report.server.recent_upload_attempts.slice(0, 6))
+ lines.push(`- ${row.host}: ${row.ok ? "OK" : "FAIL"} · ${fmtMs(row.ms)}${row.err ? ` · ${row.err}` : ""}`);
}
if (report.server?.history?.upload_hosts && Object.keys(report.server.history.upload_hosts).length) {
lines.push("Host trends:");
for (const [host, stats] of Object.entries(report.server.history.upload_hosts)) {
- lines.push(`- ${host}: ok ${stats.ok || 0} · fail ${stats.fail || 0} · fail-rate ${Math.round(Number(stats.fail_rate || 0) * 100)}% · avg ${fmtMs(stats.avg_ms)}`);
+ lines.push(
+ `- ${host}: ok ${stats.ok || 0} · fail ${stats.fail || 0} · fail-rate ${Math.round(Number(stats.fail_rate || 0) * 100)}% · avg ${fmtMs(stats.avg_ms)}`,
+ );
}
}
}
@@ -891,14 +934,17 @@ function renderInvestigationSwarm(model) {
el.innerHTML = `
- ${engines.map((engine) => {
- const queue = run.queue?.[engine] || {};
- const outcome = run.outcomes?.[engine] || {};
- const disposition = outcome.disposition || "pending";
- return `
+ ${engines
+ .map((engine) => {
+ const queue = run.queue?.[engine] || {};
+ const outcome = run.outcomes?.[engine] || {};
+ const disposition = outcome.disposition || "pending";
+ return `
${escapeHtml(displayEngineLabel(engine))}
@@ -917,7 +963,8 @@ function renderInvestigationSwarm(model) {
`;
- }).join("")}
+ })
+ .join("")}
`;
}
@@ -967,7 +1014,12 @@ function createReviewEntry({
function updateSourceInfoField(field, value, { source = "manual", note = "" } = {}) {
state.sourceInfo = state.sourceInfo || {};
const normalized = typeof value === "string" ? value : value == null ? "" : String(value);
- const previous = typeof state.sourceInfo[field] === "string" ? state.sourceInfo[field] : state.sourceInfo[field] == null ? "" : String(state.sourceInfo[field]);
+ const previous =
+ typeof state.sourceInfo[field] === "string"
+ ? state.sourceInfo[field]
+ : state.sourceInfo[field] == null
+ ? ""
+ : String(state.sourceInfo[field]);
if (normalized === previous) return false;
state.sourceInfo[field] = normalized;
appendReviewEntry(
@@ -1134,7 +1186,9 @@ function formatSourceLabelKey(key) {
function extractYearCandidatesFromText(text) {
const raw = String(text || "");
- const years = Array.from(new Set((raw.match(/\b(19\d{2}|20\d{2})\b/g) || []).map((value) => Number(value)).filter((value) => value >= 1900 && value <= 2099)));
+ const years = Array.from(
+ new Set((raw.match(/\b(19\d{2}|20\d{2})\b/g) || []).map((value) => Number(value)).filter((value) => value >= 1900 && value <= 2099)),
+ );
return years.sort((a, b) => a - b);
}
@@ -1156,7 +1210,8 @@ function buildResultIntakeDerivedContext(entries = []) {
}
function classifyResultIntakeEntry(entry, context = {}) {
- const combined = `${entry?.title || ""}\n${entry?.snippet || ""}\n${entry?.canonical_url || entry?.url || ""}\n${entry?.source_line || ""}`.toLowerCase();
+ const combined =
+ `${entry?.title || ""}\n${entry?.snippet || ""}\n${entry?.canonical_url || entry?.url || ""}\n${entry?.source_line || ""}`.toLowerCase();
const years = extractYearCandidatesFromText(combined);
const earliestYear = years[0] || null;
const notes = [];
@@ -1166,10 +1221,16 @@ function classifyResultIntakeEntry(entry, context = {}) {
const domain = String(entry?.domain || "");
const repeated = Number(context.repeatedDomains?.get(domain) || 0);
const has = (re) => re.test(combined);
- const stockDomain = /(shutterstock|adobestock|stock|istockphoto|gettyimages|pixabay|pexels|unsplash)/.test(domain) || has(/\b(stock|royalty[- ]free|licensing|shutterstock|getty|adobe stock|iStock)\b/);
+ const stockDomain =
+ /(shutterstock|adobestock|stock|istockphoto|gettyimages|pixabay|pexels|unsplash)/.test(domain) ||
+ has(/\b(stock|royalty[- ]free|licensing|shutterstock|getty|adobe stock|iStock)\b/);
const memeSignal = has(/\b(meme|reaction image|caption|template|knowyourmeme|imgflip|shitpost)\b/);
- const mirrorSignal = /(pinimg|pinterest|weheartit|tumblr|reddit|9gag|danbooru|gelbooru|yande\.re|zerochan|e-shuushuu|fancaps)/.test(domain) || has(/\b(mirror|mirrored|rehost|aggregator|board|cached|scraped|proxy)\b/);
- const originalSignal = /(artstation|behance|deviantart|pixiv|flickr|instagram|twitter|x\.com|newgrounds)/.test(domain) || has(/\b(original|official|artist|author|portfolio|source post|posted by)\b/);
+ const mirrorSignal =
+ /(pinimg|pinterest|weheartit|tumblr|reddit|9gag|danbooru|gelbooru|yande\.re|zerochan|e-shuushuu|fancaps)/.test(domain) ||
+ has(/\b(mirror|mirrored|rehost|aggregator|board|cached|scraped|proxy)\b/);
+ const originalSignal =
+ /(artstation|behance|deviantart|pixiv|flickr|instagram|twitter|x\.com|newgrounds)/.test(domain) ||
+ has(/\b(original|official|artist|author|portfolio|source post|posted by)\b/);
const repostSignal = has(/\b(repost|reblog|shared by|posted on|pinned|collection|roundup|compilation)\b/);
if (stockDomain) {
@@ -1260,7 +1321,8 @@ function renderSourceContradictions() {
const conflict = buildSourceContradictionModel();
if (!conflict) {
el.classList.remove("mission-grid");
- el.textContent = "Ingest result snippets with dates and BlueLens will surface conflicts instead of flattening them into fake certainty.";
+ el.textContent =
+ "Ingest result snippets with dates and BlueLens will surface conflicts instead of flattening them into fake certainty.";
return;
}
el.classList.add("mission-grid");
@@ -1286,15 +1348,15 @@ function renderSourceContradictions() {
function pickBestResultIntakeEntry(entries = []) {
const priority = { likely_original: 5, stock_duplicate: 4, scraped_mirror: 3, likely_repost: 2, meme_derivative: 1, needs_review: 0 };
- return entries
- .slice()
- .sort((a, b) => {
+ return (
+ entries.slice().sort((a, b) => {
const pa = priority[a.source_label] || 0;
const pb = priority[b.source_label] || 0;
if (pb !== pa) return pb - pa;
if ((b.source_confidence || 0) !== (a.source_confidence || 0)) return (b.source_confidence || 0) - (a.source_confidence || 0);
return String(a.canonical_url || a.url || "").localeCompare(String(b.canonical_url || b.url || ""));
- })[0] || null;
+ })[0] || null
+ );
}
function parseCurrentDimensions() {
@@ -1319,7 +1381,8 @@ function computeNoResultAutopsy() {
const readyEngines = Object.keys(run.targets || {}).length;
const manualOnlyProviders = ["pinterest"].filter((engine) => run.targets?.[engine]);
const hasExif = Boolean(state.exif && Object.keys(state.exif).length > 0);
- const hasTextPivots = Boolean((state.ocrText || "").trim()) || Boolean(state.insights?.attribution_hints && state.insights.attribution_hints !== "—");
+ const hasTextPivots =
+ Boolean((state.ocrText || "").trim()) || Boolean(state.insights?.attribution_hints && state.insights.attribution_hints !== "—");
if (blocked > 0) {
reasons.push({
@@ -1527,11 +1590,12 @@ function renderResultIntake() {
Intake queue: ${summary.total} visible entries · merged ${summary.duplicatesMerged} duplicates · engines ${summary.engines.join(", ") || "—"} · top domains ${summary.topDomains.join(", ") || "—"}${suppressedEntries.length ? ` · suppressed ${suppressedEntries.length}` : ""}
- ${visibleEntries.length
- ? visibleEntries
- .slice(0, 12)
- .map(
- (entry) => `
+ ${
+ visibleEntries.length
+ ? visibleEntries
+ .slice(0, 12)
+ .map(
+ (entry) => `
${escapeHtml(entry.title || entry.canonical_url || entry.url)}
@@ -1549,9 +1613,10 @@ function renderResultIntake() {
`,
- )
- .join("")
- : `
All current findings are suppressed for this session.
`}
+ )
+ .join("")
+ : `
All current findings are suppressed for this session.
`
+ }
${
suppressedEntries.length
? `
@@ -1642,13 +1707,7 @@ function buildMetadataPassOutput() {
const software = elements.kfSoftware?.textContent || "—";
const gps = elements.kfGps?.textContent || "—";
const suspicion = state.insights?.metadata_suspicion_band || "—";
- const lines = [
- `Captured: ${captured}`,
- `Camera: ${camera}`,
- `Software: ${software}`,
- `GPS: ${gps}`,
- `Metadata suspicion: ${suspicion}`,
- ];
+ const lines = [`Captured: ${captured}`, `Camera: ${camera}`, `Software: ${software}`, `GPS: ${gps}`, `Metadata suspicion: ${suspicion}`];
if (Array.isArray(state.insights?.metadata_suspicion_inputs)) {
lines.push(...state.insights.metadata_suspicion_inputs.slice(0, 4).map((line) => `Cue: ${line}`));
}
@@ -1661,7 +1720,14 @@ function buildMetadataPassOutput() {
label: state.file?.name || "Loaded image",
meta: elements.metaDim?.textContent || "metadata review",
lines,
- links: state.gps ? [{ label: "Open map", url: `https://www.openstreetmap.org/?mlat=${encodeURIComponent(state.gps.lat)}&mlon=${encodeURIComponent(state.gps.lon)}#map=16/${encodeURIComponent(state.gps.lat)}/${encodeURIComponent(state.gps.lon)}` }] : [],
+ links: state.gps
+ ? [
+ {
+ label: "Open map",
+ url: `https://www.openstreetmap.org/?mlat=${encodeURIComponent(state.gps.lat)}&mlon=${encodeURIComponent(state.gps.lon)}#map=16/${encodeURIComponent(state.gps.lat)}/${encodeURIComponent(state.gps.lon)}`,
+ },
+ ]
+ : [],
},
],
};
@@ -1682,7 +1748,8 @@ function getBatchEntityClusters(items = state.batchItems) {
for (const item of items || []) {
const report = item?.report;
const fileName = report?.file?.name || item?.file?.name || "image";
- const ent = report?.key_fields?.ocr_entities || (report?.ocr_text ? OCR_PIPELINE?.extractEntities?.(report.ocr_text) : null) || { urls: [], emails: [], handles: [], phones: [] };
+ const ent = report?.key_fields?.ocr_entities ||
+ (report?.ocr_text ? OCR_PIPELINE?.extractEntities?.(report.ocr_text) : null) || { urls: [], emails: [], handles: [], phones: [] };
for (const handle of ent.handles || []) {
const normalized = OCR_PIPELINE?.normalizeHandle?.(handle);
if (normalized) addCluster("handle", `@${normalized}`, fileName);
@@ -1738,7 +1805,9 @@ async function fetchLocalJson(endpoint, scope = "api.fetch") {
}
function extractEmailDomain(email) {
- const value = String(email || "").trim().toLowerCase();
+ const value = String(email || "")
+ .trim()
+ .toLowerCase();
const at = value.lastIndexOf("@");
return at >= 0 ? value.slice(at + 1) : "";
}
@@ -1801,7 +1870,9 @@ async function runPivotStructuredTask({ entityType, entityKey, entityValue }) {
};
if (entityType === "handle") {
- const handle = String(entityValue || "").replace(/^@/, "").trim();
+ const handle = String(entityValue || "")
+ .replace(/^@/, "")
+ .trim();
const candidates = [
{ label: "Instagram", url: `https://www.instagram.com/${encodeURIComponent(handle)}/` },
{ label: "TikTok", url: `https://www.tiktok.com/@${encodeURIComponent(handle)}` },
@@ -1816,7 +1887,9 @@ async function runPivotStructuredTask({ entityType, entityKey, entityValue }) {
const live = probes.filter((probe) => probe.result.ok);
return {
status: live.length ? "ready" : "error",
- summary: live.length ? `Resolved ${live.length}/${probes.length} profile probes for @${handle}.` : `No profile metadata fetched for @${handle}.`,
+ summary: live.length
+ ? `Resolved ${live.length}/${probes.length} profile probes for @${handle}.`
+ : `No profile metadata fetched for @${handle}.`,
lines: probes.map((probe) =>
probe.result.ok
? `${probe.label}: ${(probe.result.data?.metadata?.title || probe.result.data?.final_url || probe.url).slice(0, 120)}`
@@ -1840,11 +1913,13 @@ async function runPivotStructuredTask({ entityType, entityKey, entityValue }) {
call(`/api/archive?url=${encodeURIComponent(target)}`, `pivot.${entityType}.archive`),
]);
const lines = [];
- if (page.ok) lines.push(`Fetch: ${page.data?.status || "—"} · ${(page.data?.metadata?.title || page.data?.snippet || target).slice(0, 140)}`);
+ if (page.ok)
+ lines.push(`Fetch: ${page.data?.status || "—"} · ${(page.data?.metadata?.title || page.data?.snippet || target).slice(0, 140)}`);
else lines.push(`Fetch: ${page.error}`);
if (metadata.ok) lines.push(`Canonical: ${metadata.data?.metadata?.canonical_url || metadata.data?.final_url || target}`);
else lines.push(`Metadata: ${metadata.error}`);
- if (discover.ok) lines.push(`Discovery: ${Number(discover.data?.sitemaps?.length || 0)} sitemaps · robots ${discover.data?.robots_status || "—"}`);
+ if (discover.ok)
+ lines.push(`Discovery: ${Number(discover.data?.sitemaps?.length || 0)} sitemaps · robots ${discover.data?.robots_status || "—"}`);
else lines.push(`Discovery: ${discover.error}`);
if (archive.ok) {
const snap = archive.data?.snapshot;
@@ -1970,8 +2045,8 @@ function renderOnboardingStrip() {
state.localServerOnline === null
? "Checking local upload helper…"
: state.localServerOnline === false
- ? "Uploads unavailable — start `node server.js`."
- : "Uploads available when you ask for them.",
+ ? "Uploads unavailable — start `node server.js`."
+ : "Uploads available when you ask for them.",
},
state.popupLikely ? { tone: "warn", text: "If a search tab does not open, click that engine again." } : null,
].filter(Boolean);
@@ -2252,7 +2327,12 @@ function reset() {
analyst_confidence: "unverified",
};
state.sourceReviewLog = [];
- state.insights = { metadata_suspicion_score: null, metadata_suspicion_band: null, metadata_suspicion_inputs: [], ai_image_suspicion: null };
+ state.insights = {
+ metadata_suspicion_score: null,
+ metadata_suspicion_band: null,
+ metadata_suspicion_inputs: [],
+ ai_image_suspicion: null,
+ };
state.missionOutput = null;
state.resultIntake = { raw: "", entries: [], last_ingested_at: null };
state.pivotTaskResults = {};
@@ -2278,7 +2358,8 @@ function reset() {
}
elements.previewEmpty.style.display = "grid";
if (elements.cropStatusOut) {
- elements.cropStatusOut.textContent = "Drag a box around a face, logo, object, tattoo, sign, vehicle, building, product, artwork, or text area to search just that region.";
+ elements.cropStatusOut.textContent =
+ "Drag a box around a face, logo, object, tattoo, sign, vehicle, building, product, artwork, or text area to search just that region.";
}
elements.metaName.textContent = "—";
elements.metaType.textContent = "—";
@@ -2380,7 +2461,8 @@ function reset() {
}
if (elements.noResultAutopsyOut) {
elements.noResultAutopsyOut.classList.remove("mission-grid");
- elements.noResultAutopsyOut.textContent = "Run a multi-engine launch, then ingest hits. If nothing lands, BlueLens will explain the likely failure modes here.";
+ elements.noResultAutopsyOut.textContent =
+ "Run a multi-engine launch, then ingest hits. If nothing lands, BlueLens will explain the likely failure modes here.";
}
if (elements.sourceContradictionOut) {
elements.sourceContradictionOut.classList.remove("mission-grid");
@@ -2537,7 +2619,9 @@ function setSearchQueryOutput(output) {
function parseFilenameStem(name) {
if (appHelpers.parseFilenameStem) return appHelpers.parseFilenameStem(name);
- const raw = String(name || "").trim().replace(/\.[^.]+$/, "");
+ const raw = String(name || "")
+ .trim()
+ .replace(/\.[^.]+$/, "");
return raw.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
}
@@ -2551,10 +2635,20 @@ function summarizeDocumentLayout(text) {
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
- const paragraphBreaks = String(text || "").split(/\n\s*\n/).filter((block) => block.trim()).length;
- const headingCandidates = lines.filter((line) => line.length >= 4 && line.length <= 72 && (line === line.toUpperCase() || /^[A-Z][A-Za-z0-9 '&/-]{3,}$/.test(line))).slice(0, 4);
- const kvRows = lines.filter((line) => /[:|]/.test(line) || /\b(total|date|name|address|price|receipt|invoice|menu|certificate|issued|expires)\b/i.test(line)).slice(0, 6);
- const tableRows = lines.filter((line) => /\d/.test(line) && /[$€£¥]|(?:\s{2,}|\t)|\bqty\b|\btotal\b|\bsubtotal\b/i.test(line)).slice(0, 6);
+ const paragraphBreaks = String(text || "")
+ .split(/\n\s*\n/)
+ .filter((block) => block.trim()).length;
+ const headingCandidates = lines
+ .filter((line) => line.length >= 4 && line.length <= 72 && (line === line.toUpperCase() || /^[A-Z][A-Za-z0-9 '&/-]{3,}$/.test(line)))
+ .slice(0, 4);
+ const kvRows = lines
+ .filter(
+ (line) => /[:|]/.test(line) || /\b(total|date|name|address|price|receipt|invoice|menu|certificate|issued|expires)\b/i.test(line),
+ )
+ .slice(0, 6);
+ const tableRows = lines
+ .filter((line) => /\d/.test(line) && /[$€£¥]|(?:\s{2,}|\t)|\bqty\b|\btotal\b|\bsubtotal\b/i.test(line))
+ .slice(0, 6);
return {
line_count: lines.length,
paragraph_count: paragraphBreaks,
@@ -2657,12 +2751,10 @@ function buildDocumentImageOutput(context = getCurrentReconContext()) {
`Candidates: ${logoCandidates.join(" · ") || "No strong brand/logo cue extracted"}`,
"Use logo lookups as pivots, not proof of origin.",
],
- links: logoCandidates
- .slice(0, 4)
- .flatMap((candidate) => [
- { label: `${candidate} logo`, url: buildSearchLink(`"${candidate}" logo`) },
- { label: `${candidate} images`, url: buildSearchLink(`"${candidate}"`) },
- ]),
+ links: logoCandidates.slice(0, 4).flatMap((candidate) => [
+ { label: `${candidate} logo`, url: buildSearchLink(`"${candidate}" logo`) },
+ { label: `${candidate} images`, url: buildSearchLink(`"${candidate}"`) },
+ ]),
},
{
label: "Entity extraction",
@@ -2724,26 +2816,38 @@ function renderEngineLaunchpad(run) {
const queue = r.queue && typeof r.queue === "object" ? r.queue : {};
const engines = getRunEngines(r);
- const chips = engines.map((eng) => {
- const url = r.targets?.[eng] || "";
- const on = chosen?.[eng] !== false;
- const queueState = queue?.[eng] || {};
- const st = blocked?.[eng] ? "blocked" : opened?.[eng] ? "opened" : queueState.status || "";
- const title = blocked?.[eng] ? "Popup blocked" : opened?.[eng] ? "Opened" : queueState.status ? `State: ${queueState.status}` : "Prepared";
- const disabled = url ? "" : "disabled";
- return `
+ const chips = engines
+ .map((eng) => {
+ const url = r.targets?.[eng] || "";
+ const on = chosen?.[eng] !== false;
+ const queueState = queue?.[eng] || {};
+ const st = blocked?.[eng] ? "blocked" : opened?.[eng] ? "opened" : queueState.status || "";
+ const title = blocked?.[eng]
+ ? "Popup blocked"
+ : opened?.[eng]
+ ? "Opened"
+ : queueState.status
+ ? `State: ${queueState.status}`
+ : "Prepared";
+ const disabled = url ? "" : "disabled";
+ return `
${escapeHtml(ENGINE_ICON[eng] || "•")}
${escapeHtml(ENGINE_LABEL[eng] || eng)}
`;
- }).join("");
+ })
+ .join("");
const queueCards = engines
.map((eng) => {
const q = queue?.[eng] || {};
- const status = blocked?.[eng] ? RUN_QUEUE_STATUS.blocked : opened?.[eng] ? RUN_QUEUE_STATUS.opened : q.status || (r.targets?.[eng] ? RUN_QUEUE_STATUS.prepared : "idle");
+ const status = blocked?.[eng]
+ ? RUN_QUEUE_STATUS.blocked
+ : opened?.[eng]
+ ? RUN_QUEUE_STATUS.opened
+ : q.status || (r.targets?.[eng] ? RUN_QUEUE_STATUS.prepared : "idle");
const detail = [
q.job_id ? `job ${q.job_id.slice(0, 12)}…` : "",
Number.isFinite(q.attempts) && q.attempts > 0 ? `attempts ${q.attempts}` : "",
@@ -2952,7 +3056,9 @@ function fmtMs(ms) {
function triageSignalsForReport(report) {
const gps = report?.gps && Number.isFinite(report.gps.lat) && Number.isFinite(report.gps.lon);
// Keep reading the old field from saved cases/reports until they have all been re-exported with metadata_suspicion_score.
- const repost = Number(report?.insights?.metadata_suspicion_score ?? report?.insights?.repost_heuristic ?? report?.insights?.repost_likelihood);
+ const repost = Number(
+ report?.insights?.metadata_suspicion_score ?? report?.insights?.repost_heuristic ?? report?.insights?.repost_likelihood,
+ );
const hasExif = Boolean(report?.exif && Object.keys(report.exif).length > 0);
const software = String(report?.key_fields?.software || report?.exif?.Software || "").trim();
@@ -2964,10 +3070,7 @@ function triageSignalsForReport(report) {
const lowRes = mp != null ? mp < 1.0 : false;
// "Entities" from OCR if available; otherwise from whatever text we have without forcing batch OCR.
- let ent =
- report?.key_fields?.ocr_entities && typeof report.key_fields.ocr_entities === "object"
- ? report.key_fields.ocr_entities
- : null;
+ let ent = report?.key_fields?.ocr_entities && typeof report.key_fields.ocr_entities === "object" ? report.key_fields.ocr_entities : null;
if (!ent && report?.ocr_text) {
ent = OCR_PIPELINE?.extractEntities?.(String(report.ocr_text)) || null;
@@ -3068,7 +3171,9 @@ function renderBatchDashboard() {
const entityClusters = getBatchEntityClusters(items).slice(0, 10);
const applyFilters = () => {
- const q = String(state.batchUi.query || "").toLowerCase().trim();
+ const q = String(state.batchUi.query || "")
+ .toLowerCase()
+ .trim();
const gpsOnly = Boolean(state.batchUi.gpsOnly);
const entOnly = Boolean(state.batchUi.entOnly);
const cl = state.batchUi.cluster;
@@ -3079,7 +3184,8 @@ function renderBatchDashboard() {
if (entOnly && !t.entCount) return false;
if (cl !== "all" && String(it.clusterId || "") !== String(cl)) return false;
if (q) {
- const hay = `${it.report?.file?.name || ""} ${it.triage?.software || ""} ${(t.ent?.urls || []).join(" ")} ${(t.ent?.handles || []).join(" ")} ${(t.ent?.emails || []).join(" ")}`.toLowerCase();
+ const hay =
+ `${it.report?.file?.name || ""} ${it.triage?.software || ""} ${(t.ent?.urls || []).join(" ")} ${(t.ent?.handles || []).join(" ")} ${(t.ent?.emails || []).join(" ")}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
@@ -3466,7 +3572,12 @@ async function ensureReconContext({ mode = "deep" } = {}) {
return normalizeReconEntities(state.ocrText || "");
}
-function createEngineRunRecord({ engines = ENGINE_ORDER, mode = "launchpad", url = "", artifact = state.publicUrlArtifact || "original" } = {}) {
+function createEngineRunRecord({
+ engines = ENGINE_ORDER,
+ mode = "launchpad",
+ url = "",
+ artifact = state.publicUrlArtifact || "original",
+} = {}) {
return LAUNCHPAD_CORE.createEngineRunRecord({
engines,
mode,
@@ -3621,7 +3732,9 @@ async function runMissionPreset(preset) {
const output = buildSearchQueryGeneratorOutput(context);
setSearchQueryOutput(output);
setMissionOutput(output);
- setStatusLine(output.items?.length ? `Query generator: ${output.items.length} searches prepared` : "Query generator: no stable clues yet");
+ setStatusLine(
+ output.items?.length ? `Query generator: ${output.items.length} searches prepared` : "Query generator: no stable clues yet",
+ );
setStatus("Ready");
return;
}
@@ -3647,7 +3760,9 @@ async function runMissionPreset(preset) {
if (p === "handle_recon") {
const context = await ensureReconContext({ mode: "deep" });
setMissionOutput(buildHandleReconOutput(context));
- setStatusLine(context.handles.length ? `Handle recon: ${context.handles.length} handles normalized` : "Handle recon: no stable handles found");
+ setStatusLine(
+ context.handles.length ? `Handle recon: ${context.handles.length} handles normalized` : "Handle recon: no stable handles found",
+ );
setStatus("Ready");
return;
}
@@ -3655,7 +3770,9 @@ async function runMissionPreset(preset) {
if (p === "domain_recon") {
const context = await ensureReconContext({ mode: "deep" });
setMissionOutput(buildDomainReconOutput(context));
- setStatusLine(context.domains.length ? `Domain recon: ${context.domains.length} domains normalized` : "Domain recon: no domains found");
+ setStatusLine(
+ context.domains.length ? `Domain recon: ${context.domains.length} domains normalized` : "Domain recon: no domains found",
+ );
setStatus("Ready");
return;
}
@@ -3668,17 +3785,24 @@ async function runMissionPreset(preset) {
}
if (p === "cross_engine_swarm") {
- if (!ensureMissionShareEnabled("Cross-engine swarm opens queued wait tabs for every configured engine and uses one temporary upload for the shared handoff. Allow it?")) return;
+ if (
+ !ensureMissionShareEnabled(
+ "Cross-engine swarm opens queued wait tabs for every configured engine and uses one temporary upload for the shared handoff. Allow it?",
+ )
+ )
+ return;
setStatusLine("Swarm: staging wait tabs…");
const run = await prepareEngineSwarm({ engines: ENGINE_ORDER, labelPrefix: "Swarm" });
state.session = loadSession();
state.session.engines_opened += ENGINE_ORDER.length;
saveSession();
void refreshHostStats();
- setMissionOutput(buildMissionSummaryOutput("Cross-Engine Swarm", [
- `Queued ${ENGINE_ORDER.length} engine waits with throttled handoff`,
- `Swarm cockpit now tracks per-engine state, retries, and reopen status`,
- ]));
+ setMissionOutput(
+ buildMissionSummaryOutput("Cross-Engine Swarm", [
+ `Queued ${ENGINE_ORDER.length} engine waits with throttled handoff`,
+ `Swarm cockpit now tracks per-engine state, retries, and reopen status`,
+ ]),
+ );
setStatusLine("Swarm: queued and ready");
setStatus("Ready");
}
@@ -3715,7 +3839,9 @@ async function refreshHostStats() {
}
const lines = [
`Upload stats (session-only diagnostic): engines ${session.engines_opened} · uploads ok ${session.uploads_ok} · fail ${session.uploads_fail} · last ${session.last_host || "—"} ${fmtMs(session.last_ms)}`,
- rows.length ? `Hosts: ${rows.map((r) => `${r.host} ok ${r.ok} · fail ${r.fail} · avg ${fmtMs(r.avgMs)}`).join(" · ")}` : "Hosts: no completed upload samples yet",
+ rows.length
+ ? `Hosts: ${rows.map((r) => `${r.host} ok ${r.ok} · fail ${r.fail} · avg ${fmtMs(r.avgMs)}`).join(" · ")}`
+ : "Hosts: no completed upload samples yet",
];
elements.hostStatsOut.textContent = lines.join("\n");
@@ -3882,15 +4008,8 @@ async function ensurePublicUrl({ purpose = "" } = {}) {
};
const uploadFile =
- artifactWanted === "clean"
- ? await ensureCleanCopyFile()
- : artifactWanted === "crop"
- ? await ensureCropSearchFile()
- : state.file;
- const url =
- provider === "0x0"
- ? await publicUrlForFile(uploadFile, normalizedPurpose)
- : "";
+ artifactWanted === "clean" ? await ensureCleanCopyFile() : artifactWanted === "crop" ? await ensureCropSearchFile() : state.file;
+ const url = provider === "0x0" ? await publicUrlForFile(uploadFile, normalizedPurpose) : "";
if (!url) throw new Error("Unsupported provider");
state.publicUrl = url;
state.publicUrlPurpose = normalizedPurpose;
@@ -3923,7 +4042,11 @@ async function handleQuickJump(engine) {
if (!state.file) return;
if (state.uiBusy) return;
- if (!ensureMissionShareEnabled("To open a provider in one step, BlueLens needs one temporary public upload for the handoff URL. Allow the upload?")) {
+ if (
+ !ensureMissionShareEnabled(
+ "To open a provider in one step, BlueLens needs one temporary public upload for the handoff URL. Allow the upload?",
+ )
+ ) {
openUrl(reverseSearchUploadPage(engine));
return;
}
@@ -4156,10 +4279,7 @@ function setupCropTool() {
const bounds = stage.getBoundingClientRect();
const px = event.clientX - bounds.left;
const py = event.clientY - bounds.top;
- if (
- px < metrics.left || px > metrics.left + metrics.width ||
- py < metrics.top || py > metrics.top + metrics.height
- ) return;
+ if (px < metrics.left || px > metrics.left + metrics.width || py < metrics.top || py > metrics.top + metrics.height) return;
event.preventDefault();
state.crop.dragging = true;
state.crop.pointerId = event.pointerId;
@@ -4232,7 +4352,9 @@ function setupCropTool() {
invalidateSharedSearch("Crop ready — next search uploads only the selected region");
renderCropSelection();
updateCropButtons();
- setCropStatus(`Crop locked: ${state.crop.rect.natural.width} × ${state.crop.rect.natural.height}px. SEARCH now uses only the selected region.`);
+ setCropStatus(
+ `Crop locked: ${state.crop.rect.natural.width} × ${state.crop.rect.natural.height}px. SEARCH now uses only the selected region.`,
+ );
};
stage.addEventListener("pointerdown", beginCrop);
@@ -4288,11 +4410,7 @@ async function generateMutationFiles() {
low.ctx.drawImage(img, 0, 0, iw, ih);
low.ctx.filter = "none";
- const blobs = await Promise.all([
- canvasToBlob(crop.c, outType, q),
- canvasToBlob(rot.c, outType, q),
- canvasToBlob(low.c, outType, q),
- ]);
+ const blobs = await Promise.all([canvasToBlob(crop.c, outType, q), canvasToBlob(rot.c, outType, q), canvasToBlob(low.c, outType, q)]);
const files = [
new File([blobs[0]], `${baseName}_mut_crop.jpg`, { type: outType }),
@@ -4383,10 +4501,13 @@ function renderMutationSummary(entries) {
const lens = url ? reverseSearchUrl("lens", url) : "";
const analystAnnotation = r.analyst_annotation || r.confidence || "unreviewed";
const disabled = r.status !== "ok" ? "disabled" : "";
- const score = r.engine_review && typeof r.engine_review === "object" ? r.engine_review : r.score && typeof r.score === "object" ? r.score : {};
+ const score =
+ r.engine_review && typeof r.engine_review === "object" ? r.engine_review : r.score && typeof r.score === "object" ? r.score : {};
parts.push(`
`);
- parts.push(`
${status} ${escapeHtml(r.label || "Variant")}
`);
+ parts.push(
+ `
${status} ${escapeHtml(r.label || "Variant")}
`,
+ );
parts.push(`
Δ ${escapeHtml(dist)}
`);
parts.push(
`
` +
@@ -4502,7 +4623,9 @@ function buildPivotSearchUrlsFromEntities(ent) {
for (const date of (ent?.dates || []).slice(0, 4)) add(google(`"${date}"`));
for (const alias of (ent?.aliases || []).slice(0, 4)) add(google(`"${alias}"`));
for (const hRaw of (ent?.handles || []).slice(0, 8)) {
- const h = String(hRaw || "").replace(/^@/, "").trim();
+ const h = String(hRaw || "")
+ .replace(/^@/, "")
+ .trim();
if (!h) continue;
add(google(`@${h}`));
add(`https://www.instagram.com/${encodeURIComponent(h)}/`);
@@ -4551,7 +4674,9 @@ function buildMarkdownReport(report) {
lines.push(`- Size: \`${file.size_bytes != null ? formatBytes(file.size_bytes) : "—"}\``);
lines.push(`- Dimensions: \`${r.dimensions || "—"}\``);
if (crop?.bounds_pixels) {
- lines.push(`- Search crop: \`${crop.bounds_pixels.width} × ${crop.bounds_pixels.height}px @ ${crop.bounds_pixels.x},${crop.bounds_pixels.y}\``);
+ lines.push(
+ `- Search crop: \`${crop.bounds_pixels.width} × ${crop.bounds_pixels.height}px @ ${crop.bounds_pixels.x},${crop.bounds_pixels.y}\``,
+ );
}
lines.push("");
lines.push(`## Signals`);
@@ -4601,7 +4726,8 @@ function buildMarkdownReport(report) {
lines.push(`- Expected expiry window: ${upload.expected_expiry_window ? `\`${upload.expected_expiry_window}\`` : "—"}`);
lines.push(`- Retention note: ${upload.retention_note || "—"}`);
lines.push(`- Warning: ${upload.temporary_external_artifact_warning || "—"}`);
- if (r.export_metadata?.runtime_config_fingerprint) lines.push(`- Runtime fingerprint: \`${r.export_metadata.runtime_config_fingerprint}\``);
+ if (r.export_metadata?.runtime_config_fingerprint)
+ lines.push(`- Runtime fingerprint: \`${r.export_metadata.runtime_config_fingerprint}\``);
lines.push("");
lines.push(`## OCR Pivots`);
const u = Array.isArray(entities.urls) ? entities.urls.slice(0, 8) : [];
@@ -4664,14 +4790,20 @@ function buildMarkdownReport(report) {
lines.push(`- Deduped entries: \`${Array.isArray(r.result_intake?.entries) ? r.result_intake.entries.length : 0}\``);
lines.push(`- Last ingested: ${r.result_intake?.last_ingested_at ? `\`${r.result_intake.last_ingested_at}\`` : "—"}`);
if (r.result_intake?.best_match) {
- lines.push(`- Best match: ${r.result_intake.best_match.title ? `\`${r.result_intake.best_match.title}\`` : "—"} · ${r.result_intake.best_match.label || "—"} · confidence ${r.result_intake.best_match.confidence ?? "—"}`);
+ lines.push(
+ `- Best match: ${r.result_intake.best_match.title ? `\`${r.result_intake.best_match.title}\`` : "—"} · ${r.result_intake.best_match.label || "—"} · confidence ${r.result_intake.best_match.confidence ?? "—"}`,
+ );
lines.push(`- Best match URL: ${r.result_intake.best_match.url || "—"}`);
}
if (r.result_intake?.contradictions?.summary) lines.push(`- Contradictions: ${r.result_intake.contradictions.summary}`);
lines.push("");
lines.push(`## Action Log`);
const actionLog = Array.isArray(r.session_action_log) ? r.session_action_log : [];
- lines.push(actionLog.length ? actionLog.map((row) => `- ${row.ts || "—"} · ${row.event || "event"}${row.detail ? ` · ${row.detail}` : ""}`).join("\n") : "- —");
+ lines.push(
+ actionLog.length
+ ? actionLog.map((row) => `- ${row.ts || "—"} · ${row.event || "event"}${row.detail ? ` · ${row.detail}` : ""}`).join("\n")
+ : "- —",
+ );
lines.push("");
lines.push(`---`);
lines.push("");
@@ -4770,7 +4902,8 @@ function parseExifDateValue(rawValue) {
normalized: raw.replace(/\.000Z$/, "Z"),
normalized_utc: raw,
has_timezone: false,
- timezone_note: "EXIF parser returned a Date object; the original EXIF timezone is still ambiguous unless a source offset is documented elsewhere.",
+ timezone_note:
+ "EXIF parser returned a Date object; the original EXIF timezone is still ambiguous unless a source offset is documented elsewhere.",
source_type: sourceType,
};
}
@@ -4840,7 +4973,11 @@ function normalizeCapturedAt(exifObj) {
};
}
-function buildExportMetadata({ ocrMode = state.lastOcrMode || "not_run", ocrLanguage = elements.ocrLang?.value || OCR_DEFAULT_LANGUAGE, uploadMeta = state.uploadMeta ? { ...state.uploadMeta } : null } = {}) {
+function buildExportMetadata({
+ ocrMode = state.lastOcrMode || "not_run",
+ ocrLanguage = elements.ocrLang?.value || OCR_DEFAULT_LANGUAGE,
+ uploadMeta = state.uploadMeta ? { ...state.uploadMeta } : null,
+} = {}) {
const upload = buildUploadLifecycleMeta(uploadMeta, uploadMeta?.purpose || state.publicUrlPurpose || "");
return {
schema_version: EXPORT_SCHEMA_VERSION,
@@ -5164,9 +5301,8 @@ function computeAiImageSuspicionChecklist({ exifObj, file, width, height, ocrTex
return letters > 0 && digits > 0;
}).length;
- const squareish = Number.isFinite(width) && Number.isFinite(height) && height > 0
- ? Math.abs(width / height - 1) < AI_SQUAREISH_ASPECT_DELTA
- : false;
+ const squareish =
+ Number.isFinite(width) && Number.isFinite(height) && height > 0 ? Math.abs(width / height - 1) < AI_SQUAREISH_ASPECT_DELTA : false;
const hiRes = Number.isFinite(width) && Number.isFinite(height) ? (width * height) / 1_000_000 >= AI_SYNTHETIC_TEXTURE_HIRES_MP : false;
const syntheticFriendlyFormat = /image\/(png|webp)/i.test(file?.type || "");
@@ -5176,21 +5312,76 @@ function computeAiImageSuspicionChecklist({ exifObj, file, width, height, ocrTex
aiToolName
? { key: "metadata", label: "Metadata hints", status: "flag", detail: `Creator/software tag mentions ${aiToolName}.` }
: !hasExif
- ? { key: "metadata", label: "Metadata hints", status: "review", detail: "No capture metadata present. Re-exports and synthetic images often strip EXIF." }
+ ? {
+ key: "metadata",
+ label: "Metadata hints",
+ status: "review",
+ detail: "No capture metadata present. Re-exports and synthetic images often strip EXIF.",
+ }
: hasCameraMeta
- ? { key: "metadata", label: "Metadata hints", status: "clear", detail: "Camera/capture metadata is present, so there is no direct generator tag here." }
- : { key: "metadata", label: "Metadata hints", status: "review", detail: software ? `Creator/software tag: ${software}` : "Metadata is thin; review provenance manually." },
+ ? {
+ key: "metadata",
+ label: "Metadata hints",
+ status: "clear",
+ detail: "Camera/capture metadata is present, so there is no direct generator tag here.",
+ }
+ : {
+ key: "metadata",
+ label: "Metadata hints",
+ status: "review",
+ detail: software ? `Creator/software tag: ${software}` : "Metadata is thin; review provenance manually.",
+ },
!text
- ? { key: "text", label: "Malformed text", status: "review", detail: "Run OCR or visually inspect lettering for warped glyphs, merged characters, and fake UI text." }
+ ? {
+ key: "text",
+ label: "Malformed text",
+ status: "review",
+ detail: "Run OCR or visually inspect lettering for warped glyphs, merged characters, and fake UI text.",
+ }
: weirdGlyphRatio > AI_OCR_WEIRD_GLYPH_RATIO || repeatedRuns || noisyTokens >= AI_OCR_NOISY_TOKEN_MIN
- ? { key: "text", label: "Malformed text", status: "flag", detail: "OCR output looks noisy enough to justify a closer lettering review." }
- : { key: "text", label: "Malformed text", status: "clear", detail: "OCR text does not show an obvious gibberish pattern from local extraction alone." },
+ ? {
+ key: "text",
+ label: "Malformed text",
+ status: "flag",
+ detail: "OCR output looks noisy enough to justify a closer lettering review.",
+ }
+ : {
+ key: "text",
+ label: "Malformed text",
+ status: "clear",
+ detail: "OCR text does not show an obvious gibberish pattern from local extraction alone.",
+ },
aiToolName || (!hasExif && hiRes && syntheticFriendlyFormat) || squareish
- ? { key: "texture", label: "Synthetic texture", status: "review", detail: "Zoom into skin, sky, fabric, foliage, or walls for repeated micro-patterns and plastic smoothing." }
- : { key: "texture", label: "Synthetic texture", status: "review", detail: "Still inspect repeated texture, oversmoothing, and edge halos manually." },
- { key: "reflections", label: "Weird reflections", status: "review", detail: "Check glass, water, chrome, and eyes for impossible mirrored geometry." },
- { key: "shadows", label: "Inconsistent shadows", status: "review", detail: "Check whether shadow direction, hardness, and light color stay consistent across the scene." },
- { key: "anatomy", label: "Impossible anatomy", status: "review", detail: "Check hands, teeth, ears, jewelry, straps, and limb joins for broken structure." },
+ ? {
+ key: "texture",
+ label: "Synthetic texture",
+ status: "review",
+ detail: "Zoom into skin, sky, fabric, foliage, or walls for repeated micro-patterns and plastic smoothing.",
+ }
+ : {
+ key: "texture",
+ label: "Synthetic texture",
+ status: "review",
+ detail: "Still inspect repeated texture, oversmoothing, and edge halos manually.",
+ },
+ {
+ key: "reflections",
+ label: "Weird reflections",
+ status: "review",
+ detail: "Check glass, water, chrome, and eyes for impossible mirrored geometry.",
+ },
+ {
+ key: "shadows",
+ label: "Inconsistent shadows",
+ status: "review",
+ detail: "Check whether shadow direction, hardness, and light color stay consistent across the scene.",
+ },
+ {
+ key: "anatomy",
+ label: "Impossible anatomy",
+ status: "review",
+ detail: "Check hands, teeth, ears, jewelry, straps, and limb joins for broken structure.",
+ },
];
const flagged = items.filter((item) => item.status === "flag").length;
@@ -5240,9 +5431,7 @@ function updateConsoleInsights({ exifObj, file, width, height, ocrText }) {
elements.repostReasons.innerHTML = "";
} else {
elements.repostReasons.hidden = false;
- elements.repostReasons.innerHTML = top
- .map((r) => `${escapeHtml(String(r))} `)
- .join("");
+ elements.repostReasons.innerHTML = top.map((r) => `${escapeHtml(String(r))} `).join("");
}
}
return { score, band, inputs, aiImageSuspicion };
@@ -5355,10 +5544,7 @@ async function encodeCleanCopy(img, preferredType) {
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, 0, 0, w, h);
- const type =
- preferredType && (preferredType === "image/png" || preferredType === "image/webp")
- ? preferredType
- : "image/jpeg";
+ const type = preferredType && (preferredType === "image/png" || preferredType === "image/webp") ? preferredType : "image/jpeg";
const quality = type === "image/jpeg" ? 0.92 : undefined;
const blob = await new Promise((resolve) => canvas.toBlob(resolve, type, quality));
@@ -5516,9 +5702,7 @@ function buildOsintReport({ includeInvestigation = true } = {}) {
ocr_entity_confidence: state.entityConfidence && Object.keys(state.entityConfidence).length ? { ...state.entityConfidence } : null,
ocr_entity_review_entries: buildCurrentOcrReviewEntries(),
ocr_entity_tasks:
- state.pivotTaskResults && Object.keys(state.pivotTaskResults).length
- ? JSON.parse(JSON.stringify(state.pivotTaskResults))
- : null,
+ state.pivotTaskResults && Object.keys(state.pivotTaskResults).length ? JSON.parse(JSON.stringify(state.pivotTaskResults)) : null,
},
exif: state.exif || null,
ocr_text: state.ocrText || null,
@@ -5547,12 +5731,12 @@ function buildOsintReport({ includeInvestigation = true } = {}) {
items: Array.isArray(state.missionOutput.items) ? state.missionOutput.items.slice() : null,
}
: null,
- document_image_mode: (state.documentModeOutput || state.ocrText)
- ? deepClone(state.documentModeOutput || buildDocumentImageOutput(reconContext))
- : null,
- search_query_generator: (state.searchQueryOutput || state.ocrText || state.file)
- ? deepClone(state.searchQueryOutput || buildSearchQueryGeneratorOutput(reconContext))
- : null,
+ document_image_mode:
+ state.documentModeOutput || state.ocrText ? deepClone(state.documentModeOutput || buildDocumentImageOutput(reconContext)) : null,
+ search_query_generator:
+ state.searchQueryOutput || state.ocrText || state.file
+ ? deepClone(state.searchQueryOutput || buildSearchQueryGeneratorOutput(reconContext))
+ : null,
result_intake: state.resultIntake
? {
last_ingested_at: state.resultIntake.last_ingested_at || null,
@@ -5560,7 +5744,9 @@ function buildOsintReport({ includeInvestigation = true } = {}) {
autopsy: computeNoResultAutopsy(),
contradictions: buildSourceContradictionModel(),
best_match: (() => {
- const best = pickBestResultIntakeEntry(getAnalyzedResultIntakeEntries(state.resultIntake.entries || []).filter((entry) => !entry?.suppressed));
+ const best = pickBestResultIntakeEntry(
+ getAnalyzedResultIntakeEntries(state.resultIntake.entries || []).filter((entry) => !entry?.suppressed),
+ );
return best
? {
title: best.title || null,
@@ -5603,11 +5789,7 @@ function extractKeyFieldsObj(exifObj) {
}
async function buildReportForFileHeadless(file) {
- const [hashes, exifObj, decoded] = await Promise.all([
- computeHashes(file),
- parseExif(file),
- loadImageStandalone(file),
- ]);
+ const [hashes, exifObj, decoded] = await Promise.all([computeHashes(file), parseExif(file), loadImageStandalone(file)]);
const width = decoded.img.naturalWidth || decoded.img.width;
const height = decoded.img.naturalHeight || decoded.img.height;
@@ -5678,7 +5860,9 @@ async function runBatchFiles(files) {
thumb = null;
}
state.batchItems.push({ id, file: f, report: r, triage: null, clusterId: 0, thumb });
- const suspicion = r.insights?.metadata_suspicion_band || formatMetadataSuspicionBand(Number(r.insights?.metadata_suspicion_score ?? r.insights?.repost_heuristic));
+ const suspicion =
+ r.insights?.metadata_suspicion_band ||
+ formatMetadataSuspicionBand(Number(r.insights?.metadata_suspicion_score ?? r.insights?.repost_heuristic));
lines.push(` OK · suspicion ${suspicion}`);
} catch (e) {
lines.push(` FAIL · ${e?.message || "unknown error"}`);
@@ -5733,8 +5917,10 @@ function estimateLocalSourcePosture(report) {
const dims = String(report?.dimensions || "");
const match = dims.match(/(\d+)\s*[×x]\s*(\d+)/);
const mp = match ? (Number(match[1]) * Number(match[2])) / 1_000_000 : 0;
- if (hasExif && !software && mp >= 1.5 && score <= 45) return { label: "Likely original", confidence: 0.62, notes: ["Local metadata and resolution look comparatively source-like"] };
- if (software || score >= 60) return { label: "Likely repost", confidence: 0.58, notes: ["Editing/software or repost-oriented metadata cues present"] };
+ if (hasExif && !software && mp >= 1.5 && score <= 45)
+ return { label: "Likely original", confidence: 0.62, notes: ["Local metadata and resolution look comparatively source-like"] };
+ if (software || score >= 60)
+ return { label: "Likely repost", confidence: 0.58, notes: ["Editing/software or repost-oriented metadata cues present"] };
return { label: "Needs review", confidence: 0.4, notes: ["No external result intake was attached to this batch row"] };
}
@@ -5784,7 +5970,18 @@ function buildBatchSummaryRows(items = state.batchItems) {
}
function downloadCsvRows(rows, filename) {
- const cols = ["file_name", "dimensions", "sha256", "best_match", "best_match_label", "earliest_date", "source_urls", "confidence", "duplicate_count", "notes"];
+ const cols = [
+ "file_name",
+ "dimensions",
+ "sha256",
+ "best_match",
+ "best_match_label",
+ "earliest_date",
+ "source_urls",
+ "confidence",
+ "duplicate_count",
+ "notes",
+ ];
const lines = [cols.join(",")];
for (const row of rows || []) {
lines.push(cols.map((col) => toCsvValue(row?.[col] ?? "")).join(","));
@@ -5876,7 +6073,9 @@ function getOcrLanguageLabel(code) {
function populateOcrLanguageOptions() {
if (!elements.ocrLang) return;
const current = elements.ocrLang.value || OCR_DEFAULT_LANGUAGE;
- elements.ocrLang.innerHTML = OCR_LANGUAGE_OPTIONS.map((opt) => `${escapeHtml(opt.label)} `).join("");
+ elements.ocrLang.innerHTML = OCR_LANGUAGE_OPTIONS.map(
+ (opt) => `${escapeHtml(opt.label)} `,
+ ).join("");
const fallback = OCR_LANGUAGE_OPTIONS.some((opt) => opt.value === current) ? current : OCR_DEFAULT_LANGUAGE;
elements.ocrLang.value = fallback;
}
@@ -6014,7 +6213,12 @@ async function runOcrForCurrent({ mode = "deep" } = {}) {
const m = (elements.metaDim.textContent || "").match(/(\d+)\s*×\s*(\d+)/);
return m ? Number(m[2]) : null;
})();
- const { score: s, band, inputs, aiImageSuspicion } = updateConsoleInsights({
+ const {
+ score: s,
+ band,
+ inputs,
+ aiImageSuspicion,
+ } = updateConsoleInsights({
exifObj: state.exif,
file: state.file,
width: imgW,
@@ -6173,7 +6377,12 @@ async function runOcrForCurrent({ mode = "deep" } = {}) {
const m = (elements.metaDim.textContent || "").match(/(\d+)\s*×\s*(\d+)/);
return m ? Number(m[2]) : null;
})();
- const { score: s, band, inputs, aiImageSuspicion } = updateConsoleInsights({
+ const {
+ score: s,
+ band,
+ inputs,
+ aiImageSuspicion,
+ } = updateConsoleInsights({
exifObj: state.exif,
file: state.file,
width: imgW,
@@ -6265,7 +6474,9 @@ async function analyzeFile(file) {
setShareControlsEnabled(true);
setShareStatus(state.publicUrl ? "Shared" : "Not shared");
clearCompare();
- setCropStatus("Drag a box over the preview to search only a face, logo, object, tattoo, sign, vehicle, building, product, artwork, or text area.");
+ setCropStatus(
+ "Drag a box over the preview to search only a face, logo, object, tattoo, sign, vehicle, building, product, artwork, or text area.",
+ );
renderCropSelection();
updateCropButtons();
@@ -6411,8 +6622,7 @@ async function analyzeCompareFile(file) {
elements.cmpVerdict.textContent = verdict;
if (elements.cmpExplain) {
const diffText = Number.isFinite(state.compare.diffScore) ? ` Thumbnail diff intensity: ${state.compare.diffScore}/255.` : "";
- elements.cmpExplain.textContent =
- `dHash compares tiny perceptual thumbnails; 0 means identical hashes and larger numbers mean less visual agreement.${diffText} Treat this as a screening signal, not proof that two files are the same image.`;
+ elements.cmpExplain.textContent = `dHash compares tiny perceptual thumbnails; 0 means identical hashes and larger numbers mean less visual agreement.${diffText} Treat this as a screening signal, not proof that two files are the same image.`;
}
elements.btnClearCompare.disabled = false;
setStatus("Ready");
@@ -6670,7 +6880,9 @@ function setupActions() {
autopsy: computeNoResultAutopsy(),
contradictions: buildSourceContradictionModel(),
best_match: (() => {
- const best = pickBestResultIntakeEntry(getAnalyzedResultIntakeEntries(state.resultIntake?.entries || []).filter((entry) => !entry?.suppressed));
+ const best = pickBestResultIntakeEntry(
+ getAnalyzedResultIntakeEntries(state.resultIntake?.entries || []).filter((entry) => !entry?.suppressed),
+ );
return best
? {
title: best.title || null,
@@ -6763,9 +6975,7 @@ function setupActions() {
await withUiLock("Mutating…", async () => {
if (!state.shareEnabled) {
- const ok = window.confirm(
- "Mutation Lab needs automatic uploads (uploads variants to generate public URLs). Allow it?",
- );
+ const ok = window.confirm("Mutation Lab needs automatic uploads (uploads variants to generate public URLs). Allow it?");
if (!ok) return;
state.shareEnabled = true;
elements.chkEnableShare.checked = true;
@@ -6800,7 +7010,10 @@ function setupActions() {
}
}
- const clusters = clusterByDhash(muts.filter((m) => m.dhash), DHASH_MUTATION_CLUSTER_THRESHOLD);
+ const clusters = clusterByDhash(
+ muts.filter((m) => m.dhash),
+ DHASH_MUTATION_CLUSTER_THRESHOLD,
+ );
for (let ci = 0; ci < clusters.length; ci += 1) {
for (const m of clusters[ci].items) m.cluster = ci + 1;
}
@@ -6887,7 +7100,9 @@ function setupActions() {
const output = buildSearchQueryGeneratorOutput(context);
setSearchQueryOutput(output);
setMissionOutput(output);
- setStatusLine(output.items?.length ? `Query generator: ${output.items.length} searches prepared` : "Query generator: no stable clues yet");
+ setStatusLine(
+ output.items?.length ? `Query generator: ${output.items.length} searches prepared` : "Query generator: no stable clues yet",
+ );
setStatus("Ready");
});
});
@@ -6923,7 +7138,9 @@ function setupActions() {
elements.btnOpenMap.addEventListener("click", () => {
if (!state.gps) return;
const { lat, lon } = state.gps;
- openUrl(`https://www.openstreetmap.org/?mlat=${encodeURIComponent(lat)}&mlon=${encodeURIComponent(lon)}#map=16/${encodeURIComponent(lat)}/${encodeURIComponent(lon)}`);
+ openUrl(
+ `https://www.openstreetmap.org/?mlat=${encodeURIComponent(lat)}&mlon=${encodeURIComponent(lon)}#map=16/${encodeURIComponent(lat)}/${encodeURIComponent(lon)}`,
+ );
});
elements.btnCopyCoords.addEventListener("click", async () => {
@@ -7142,9 +7359,13 @@ function setupFx() {
if (elements.chkFunMode) elements.chkFunMode.checked = funMode;
if (elements.chkOperatorMode) elements.chkOperatorMode.checked = operatorMode;
if (elements.scanlineSlider)
- elements.scanlineSlider.value = String(Math.max(0, Math.min(FX_SCANLINE_MAX, Number.isFinite(scanline) ? scanline : FX_SCANLINE_FUN_DEFAULT)));
+ elements.scanlineSlider.value = String(
+ Math.max(0, Math.min(FX_SCANLINE_MAX, Number.isFinite(scanline) ? scanline : FX_SCANLINE_FUN_DEFAULT)),
+ );
if (elements.chromaticSlider)
- elements.chromaticSlider.value = String(Math.max(0, Math.min(FX_CHROMATIC_MAX, Number.isFinite(chromatic) ? chromatic : FX_CHROMATIC_FUN_DEFAULT)));
+ elements.chromaticSlider.value = String(
+ Math.max(0, Math.min(FX_CHROMATIC_MAX, Number.isFinite(chromatic) ? chromatic : FX_CHROMATIC_FUN_DEFAULT)),
+ );
if (elements.chkHud) elements.chkHud.checked = state.hudWanted;
if (elements.chkChrome) elements.chkChrome.checked = state.chromeSkinWanted;
@@ -7447,16 +7668,41 @@ function setupCommandPalette() {
let activeIndex = 0;
const actions = [
- { name: "Upload + Prepare Links", meta: "Reverse-search launchpad", keys: ["upload", "prepare", "search", "all", "reverse", "engines", "launchpad"], run: () => void handleSearchAll() },
- { name: "Refresh Local Analysis", meta: "OCR + signals", keys: ["refresh", "pass", "ocr", "signals", "attribution"], run: () => elements.btnRunPass?.click() },
+ {
+ name: "Upload + Prepare Links",
+ meta: "Reverse-search launchpad",
+ keys: ["upload", "prepare", "search", "all", "reverse", "engines", "launchpad"],
+ run: () => void handleSearchAll(),
+ },
+ {
+ name: "Refresh Local Analysis",
+ meta: "OCR + signals",
+ keys: ["refresh", "pass", "ocr", "signals", "attribution"],
+ run: () => elements.btnRunPass?.click(),
+ },
{ name: "Copy Report", meta: "JSON to clipboard", keys: ["copy", "report", "json"], run: () => elements.btnCopyReport?.click() },
{ name: "Copy Public URL", meta: "If shared", keys: ["copy", "url", "public"], run: () => elements.btnCopyPublicUrl?.click() },
{ name: "Tab: Search", meta: "Console", keys: ["tab", "search"], run: () => window.__osintActivateTab?.("search") },
- { name: "Tab: Signals", meta: "Hashes + EXIF", keys: ["tab", "signals", "exif", "hash"], run: () => window.__osintActivateTab?.("signals") },
+ {
+ name: "Tab: Signals",
+ meta: "Hashes + EXIF",
+ keys: ["tab", "signals", "exif", "hash"],
+ run: () => window.__osintActivateTab?.("signals"),
+ },
{ name: "Tab: Text", meta: "OCR", keys: ["tab", "text", "ocr"], run: () => window.__osintActivateTab?.("text") },
{ name: "Tab: Compare", meta: "Similarity", keys: ["tab", "compare", "dhash"], run: () => window.__osintActivateTab?.("compare") },
- { name: "Toggle Blue Chrome", meta: "Y2K skin", keys: ["toggle", "chrome", "skin"], run: () => (elements.chkChrome.checked = !elements.chkChrome.checked, elements.chkChrome.dispatchEvent(new Event("change"))) },
- { name: "Toggle HUD Mode", meta: "Drag panels", keys: ["toggle", "hud", "cockpit"], run: () => (elements.chkHud.checked = !elements.chkHud.checked, elements.chkHud.dispatchEvent(new Event("change"))) },
+ {
+ name: "Toggle Blue Chrome",
+ meta: "Y2K skin",
+ keys: ["toggle", "chrome", "skin"],
+ run: () => ((elements.chkChrome.checked = !elements.chkChrome.checked), elements.chkChrome.dispatchEvent(new Event("change"))),
+ },
+ {
+ name: "Toggle HUD Mode",
+ meta: "Drag panels",
+ keys: ["toggle", "hud", "cockpit"],
+ run: () => ((elements.chkHud.checked = !elements.chkHud.checked), elements.chkHud.dispatchEvent(new Event("change"))),
+ },
{ name: "Reset", meta: "Clear current image", keys: ["reset", "clear"], run: () => elements.btnReset?.click() },
].filter((a) => typeof a.run === "function");
diff --git a/bluelens-config.js b/bluelens-config.js
index 78ce998..a4de5f4 100644
--- a/bluelens-config.js
+++ b/bluelens-config.js
@@ -148,13 +148,25 @@
{ label: "Local server port", value: "8787", detail: "Used by the built-in upload proxy and wait-job handoff." },
{ label: "Wait-job long poll", value: "25s", detail: "Wait tabs reconnect after a timed hold instead of busy polling." },
{ label: "Wait-job retention", value: "10 min", detail: "Completed handoff jobs stay available briefly for reconnects." },
- { label: "Upload host order", value: "Uguu → Catbox → 0x0 → Litterbox", detail: "Default failover order before purpose-specific weighting." },
+ {
+ label: "Upload host order",
+ value: "Uguu → Catbox → 0x0 → Litterbox",
+ detail: "Default failover order before purpose-specific weighting.",
+ },
{ label: "Lens/Google host bias", value: "Catbox → 0x0 → Litterbox → Uguu", detail: "Preferred for upload-by-URL launches." },
- { label: "Upload timeout", value: "35s upstream / 45s browser", detail: "Server host attempts and browser proxy requests time out independently." },
+ {
+ label: "Upload timeout",
+ value: "35s upstream / 45s browser",
+ detail: "Server host attempts and browser proxy requests time out independently.",
+ },
{ label: "Batch OCR default", value: "Top 8 images", detail: "Top-candidate OCR pass size before manual expansion." },
{ label: "OCR model list", value: "17 manual models", detail: "Weak script hints do not auto-switch the selected OCR model." },
{ label: "dHash cluster thresholds", value: "8 batch / 10 mutation", detail: "Near-duplicate grouping defaults used in dashboards." },
- { label: "Wait-tab recovery", value: "350ms→5s backoff", detail: "Wait tabs slow down retries and show a reopen-from-main-tab state if handoff disappears." },
+ {
+ label: "Wait-tab recovery",
+ value: "350ms→5s backoff",
+ detail: "Wait tabs slow down retries and show a reopen-from-main-tab state if handoff disappears.",
+ },
{ label: "Fun mode default", value: "Off", detail: "Operator theme ships calm; ambient FX are opt-in." },
],
},
diff --git a/bluelens-helpers.js b/bluelens-helpers.js
index 19e8ca4..ab84996 100644
--- a/bluelens-helpers.js
+++ b/bluelens-helpers.js
@@ -85,10 +85,10 @@
...ents.handles
.slice(0, 3)
.map((x) => {
- // Old reports or manual entry mistakes can leave multiple leading @ symbols; normalize them to a single @ prefix format.
- const handle = String(x || "").replace(/^@+/, "");
- return handle ? `@${handle}` : null;
- })
+ // Old reports or manual entry mistakes can leave multiple leading @ symbols; normalize them to a single @ prefix format.
+ const handle = String(x || "").replace(/^@+/, "");
+ return handle ? `@${handle}` : null;
+ })
.filter(Boolean),
);
}
@@ -107,12 +107,16 @@
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
- const paragraphBreaks = String(text || "").split(/\n\s*\n/).filter((block) => block.trim()).length;
+ const paragraphBreaks = String(text || "")
+ .split(/\n\s*\n/)
+ .filter((block) => block.trim()).length;
const headingCandidates = lines
.filter((line) => line.length >= 4 && line.length <= 72 && (line === line.toUpperCase() || /^[A-Z][A-Za-z0-9 '&/-]{3,}$/.test(line)))
.slice(0, 4);
const keyValueRows = lines
- .filter((line) => /[:|]/.test(line) || /\b(total|date|name|address|price|receipt|invoice|menu|certificate|issued|expires)\b/i.test(line))
+ .filter(
+ (line) => /[:|]/.test(line) || /\b(total|date|name|address|price|receipt|invoice|menu|certificate|issued|expires)\b/i.test(line),
+ )
.slice(0, 6);
const tabularRows = lines
.filter((line) => /\d/.test(line) && /[$€£¥]|(?:\s{2,}|\t)|\bqty\b|\btotal\b|\bsubtotal\b/i.test(line))
@@ -170,19 +174,30 @@
}
function parseFilenameStem(name) {
- const raw = String(name || "").trim().replace(/\.[^.]+$/, "");
+ const raw = String(name || "")
+ .trim()
+ .replace(/\.[^.]+$/, "");
return raw.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
}
- function buildSearchQuerySpecs({ text = "", fileName = "", dimensions = "", language = "English", ent = {}, handles = [], domains = [] } = {}) {
+ function buildSearchQuerySpecs({
+ text = "",
+ fileName = "",
+ dimensions = "",
+ language = "English",
+ ent = {},
+ handles = [],
+ domains = [],
+ } = {}) {
const kinds = inferDocumentKinds({ text, fileName, ent });
const logoCandidates = collectLogoLookupCandidates({ text, ent, domains, handles });
const fileStem = parseFilenameStem(fileName);
- const topLine = String(text || "")
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean)
- .find((line) => line.length >= 4) || "";
+ const topLine =
+ String(text || "")
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .find((line) => line.length >= 4) || "";
const queries = [];
const seen = new Set();
const add = (label, query, why) => {
@@ -191,26 +206,54 @@
seen.add(clean);
queries.push({ label, query: clean, why });
};
- if (logoCandidates[0] && ent.locations?.[0]) add("Brand + city", `"${logoCandidates[0]}" "${ent.locations[0]}"`, "Combine the strongest brand/logo clue with the strongest location clue.");
- if (topLine && logoCandidates[0]) add("OCR text + logo", `"${topLine.slice(0, 80)}" "${logoCandidates[0]}"`, "Bind the main OCR line to the strongest logo/brand candidate.");
- if (kinds[0]) add("Object + language", `"${kinds[0]}" "${language}"`, "Search the detected document/object type together with the OCR language.");
- if (fileStem || dimensions) add("File name + dimensions", `"${fileStem || "image"}" "${dimensions || "unknown dimensions"}"`, "Use file naming residue with the visible pixel dimensions.");
+ if (logoCandidates[0] && ent.locations?.[0])
+ add(
+ "Brand + city",
+ `"${logoCandidates[0]}" "${ent.locations[0]}"`,
+ "Combine the strongest brand/logo clue with the strongest location clue.",
+ );
+ if (topLine && logoCandidates[0])
+ add(
+ "OCR text + logo",
+ `"${topLine.slice(0, 80)}" "${logoCandidates[0]}"`,
+ "Bind the main OCR line to the strongest logo/brand candidate.",
+ );
+ if (kinds[0])
+ add("Object + language", `"${kinds[0]}" "${language}"`, "Search the detected document/object type together with the OCR language.");
+ if (fileStem || dimensions)
+ add(
+ "File name + dimensions",
+ `"${fileStem || "image"}" "${dimensions || "unknown dimensions"}"`,
+ "Use file naming residue with the visible pixel dimensions.",
+ );
if (handles[0]) {
- const platformHint = domains.find((domain) => /(instagram|tiktok|x\.com|twitter|facebook|linkedin|telegram)/i.test(domain)) || "instagram OR tiktok OR x";
- add("Visible username + platform", `"${handles[0]}" ${platformHint}`, "Pivot the visible handle against the likeliest platform hint.");
+ const platformHint =
+ domains.find((domain) => /(instagram|tiktok|x\.com|twitter|facebook|linkedin|telegram)/i.test(domain)) ||
+ "instagram OR tiktok OR x";
+ add(
+ "Visible username + platform",
+ `"${handles[0]}" ${platformHint}`,
+ "Pivot the visible handle against the likeliest platform hint.",
+ );
}
- if (domains[0] && logoCandidates[0]) add("Logo + domain", `"${logoCandidates[0]}" "${domains[0]}"`, "Pair a brand/logo clue with the strongest normalized domain.");
+ if (domains[0] && logoCandidates[0])
+ add("Logo + domain", `"${logoCandidates[0]}" "${domains[0]}"`, "Pair a brand/logo clue with the strongest normalized domain.");
if (topLine) add("Quoted OCR line", `"${topLine.slice(0, 96)}"`, "Quoted text search for the most stable visible line.");
return queries;
}
function normalizeHandleValue(value) {
- const clean = String(value || "").trim().replace(/^@+/, "").toLowerCase();
+ const clean = String(value || "")
+ .trim()
+ .replace(/^@+/, "")
+ .toLowerCase();
return clean ? `@${clean}` : "";
}
function normalizeDomainValue(value) {
- const raw = String(value || "").trim().toLowerCase();
+ const raw = String(value || "")
+ .trim()
+ .toLowerCase();
if (!raw) return "";
const withoutProtocol = raw.replace(/^[a-z]+:\/\//i, "");
const host = withoutProtocol.split(/[/?#]/, 1)[0].replace(/^www\./, "");
@@ -334,24 +377,32 @@
for (const value of entities?.handles || []) {
const normalized = normalizeHandleValue(value);
- const detail = Array.isArray(details.handles) ? details.handles.find((entry) => normalizeHandleValue(entry?.value) === normalized) : null;
+ const detail = Array.isArray(details.handles)
+ ? details.handles.find((entry) => normalizeHandleValue(entry?.value) === normalized)
+ : null;
addEntity("handle", normalized, detail);
}
for (const value of entities?.emails || []) {
const email = String(value || "").toLowerCase();
- const detail = Array.isArray(details.emails) ? details.emails.find((entry) => String(entry?.value || "").toLowerCase() === email) : null;
+ const detail = Array.isArray(details.emails)
+ ? details.emails.find((entry) => String(entry?.value || "").toLowerCase() === email)
+ : null;
addEntity("email", email, detail);
const domain = normalizeDomainValue(email.split("@")[1] || "");
if (domain) addEntity("domain", domain, detail, { field: "ocr_entities.email_domain", source: "email_domain" });
}
for (const value of entities?.phones || []) {
const phone = String(value || "").trim();
- const detail = Array.isArray(details.phones) ? details.phones.find((entry) => String(entry?.value || "").trim() === phone || String(entry?.raw || "").trim() === phone) : null;
+ const detail = Array.isArray(details.phones)
+ ? details.phones.find((entry) => String(entry?.value || "").trim() === phone || String(entry?.raw || "").trim() === phone)
+ : null;
addEntity("phone", phone, detail);
}
for (const value of entities?.urls || []) {
const url = String(value || "").trim();
- const detail = Array.isArray(details.urls) ? details.urls.find((entry) => String(entry?.value || "").trim() === url || String(entry?.raw || "").trim() === url) : null;
+ const detail = Array.isArray(details.urls)
+ ? details.urls.find((entry) => String(entry?.value || "").trim() === url || String(entry?.raw || "").trim() === url)
+ : null;
addEntity("url", url, detail);
const domain = normalizeDomainValue(url);
if (domain) addEntity("domain", domain, detail, { field: "ocr_entities.url_domain", source: "url_domain" });
@@ -412,25 +463,29 @@
}
});
- const nodes = Array.from(nodeMap.values()).map((node) => ({
- ...node,
- file_count: node.files.size,
- files: Array.from(node.files).sort(),
- provenance: node.provenance.slice(0, 24),
- linked_keys: Array.from(node.linked_keys),
- degree: node.linked_keys.size,
- })).sort((a, b) => {
- if (a.type === "file" && b.type !== "file") return -1;
- if (a.type !== "file" && b.type === "file") return 1;
- return b.file_count - a.file_count || b.evidence_count - a.evidence_count || a.label.localeCompare(b.label);
- });
+ const nodes = Array.from(nodeMap.values())
+ .map((node) => ({
+ ...node,
+ file_count: node.files.size,
+ files: Array.from(node.files).sort(),
+ provenance: node.provenance.slice(0, 24),
+ linked_keys: Array.from(node.linked_keys),
+ degree: node.linked_keys.size,
+ }))
+ .sort((a, b) => {
+ if (a.type === "file" && b.type !== "file") return -1;
+ if (a.type !== "file" && b.type === "file") return 1;
+ return b.file_count - a.file_count || b.evidence_count - a.evidence_count || a.label.localeCompare(b.label);
+ });
- const edges = Array.from(edgeMap.values()).map((edge) => ({
- ...edge,
- file_count: edge.files.size,
- files: Array.from(edge.files).sort(),
- provenance: edge.provenance.slice(0, 24),
- })).sort((a, b) => b.file_count - a.file_count || b.evidence_count - a.evidence_count || a.key.localeCompare(b.key));
+ const edges = Array.from(edgeMap.values())
+ .map((edge) => ({
+ ...edge,
+ file_count: edge.files.size,
+ files: Array.from(edge.files).sort(),
+ provenance: edge.provenance.slice(0, 24),
+ }))
+ .sort((a, b) => b.file_count - a.file_count || b.evidence_count - a.evidence_count || a.key.localeCompare(b.key));
return {
nodes,
@@ -499,7 +554,9 @@
});
}
- const captured = parseTimelineInstant(report?.key_fields?.captured_at, { ambiguous: report?.key_fields?.captured_at?.has_timezone === false });
+ const captured = parseTimelineInstant(report?.key_fields?.captured_at, {
+ ambiguous: report?.key_fields?.captured_at?.has_timezone === false,
+ });
if (captured) {
addEvent({
key: `${reportKey}:captured`,
@@ -660,10 +717,11 @@
const sorted = events
.slice()
- .sort((a, b) =>
- (Number(a.ts_ms || Number.MAX_SAFE_INTEGER) - Number(b.ts_ms || Number.MAX_SAFE_INTEGER)) ||
- ((categoryRank[a.category] || 99) - (categoryRank[b.category] || 99)) ||
- String(a.label || "").localeCompare(String(b.label || "")),
+ .sort(
+ (a, b) =>
+ Number(a.ts_ms || Number.MAX_SAFE_INTEGER) - Number(b.ts_ms || Number.MAX_SAFE_INTEGER) ||
+ (categoryRank[a.category] || 99) - (categoryRank[b.category] || 99) ||
+ String(a.label || "").localeCompare(String(b.label || "")),
);
return {
diff --git a/docs/API.md b/docs/API.md
index 5dc7bfe..22dd1eb 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -17,6 +17,7 @@ Base URL: `http://localhost:8787` (default port, configurable via `PORT` env var
Health check endpoint that returns server status.
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -34,17 +35,21 @@ Health check endpoint that returns server status.
Proxies an image upload to a temporary public hosting service. Implements automatic failover across multiple hosts.
**Headers**:
+
- `Content-Type`: `multipart/form-data` or `application/octet-stream`
**Request Body**:
+
- For multipart: Include `file` field with image data
- For octet-stream: Raw image bytes
**Query Parameters**:
+
- `purpose` (optional): Hint for host selection (`lens`, `google`, or `default`)
- `provider` (optional): Force specific provider (`uguu`, `catbox`, `litterbox`, `0x0`)
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -56,6 +61,7 @@ Proxies an image upload to a temporary public hosting service. Implements automa
```
**Error Response**: 500/503
+
```json
{
"ok": false,
@@ -66,12 +72,14 @@ Proxies an image upload to a temporary public hosting service. Implements automa
```
**Supported Hosts**:
+
1. **Uguu** (`uguu.se`) - 48-hour expiry
2. **Catbox** (`catbox.moe`) - Permanent
3. **Litterbox** (`litterbox.catbox.moe`) - 72-hour expiry (configurable)
4. **0x0** (`0x0.st`) - 1-year expiry
**Host Selection Logic**:
+
- Automatic failover if primary host fails
- Purpose-specific host preferences for better compatibility
- Performance-based selection using historical upload stats
@@ -83,6 +91,7 @@ Proxies an image upload to a temporary public hosting service. Implements automa
Returns upload host performance statistics.
**Response**: 200 OK
+
```json
{
"stats": {
@@ -111,6 +120,7 @@ Wait jobs enable a long-polling mechanism for search engine tabs to receive URLs
Creates a new wait job.
**Request Body**:
+
```json
{
"engine": "lens",
@@ -119,6 +129,7 @@ Creates a new wait job.
```
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -138,9 +149,11 @@ Creates a new wait job.
Long-polls for a wait job's result. Holds the connection until the job is updated or times out.
**Query Parameters**:
+
- `timeout` (optional): Max wait time in milliseconds (default: 25000, max: 30000)
**Response (pending)**: 200 OK (after timeout)
+
```json
{
"ok": true,
@@ -151,6 +164,7 @@ Long-polls for a wait job's result. Holds the connection until the job is update
```
**Response (completed)**: 200 OK
+
```json
{
"ok": true,
@@ -162,6 +176,7 @@ Long-polls for a wait job's result. Holds the connection until the job is update
```
**Response (failed)**: 200 OK
+
```json
{
"ok": false,
@@ -179,6 +194,7 @@ Long-polls for a wait job's result. Holds the connection until the job is update
Updates a wait job with a result (URL or error).
**Request Body**:
+
```json
{
"status": "completed",
@@ -196,6 +212,7 @@ or
```
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -215,9 +232,11 @@ The acquisition layer provides safe fetching of remote resources with rate limit
Fetches a remote URL's content.
**Query Parameters**:
+
- `url` (required): Target URL to fetch
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -240,6 +259,7 @@ Fetches a remote URL's content.
```
**Security**:
+
- Blocks private IP addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8)
- Rate limited: 18 requests per IP per minute
- 10-second timeout
@@ -252,9 +272,11 @@ Fetches a remote URL's content.
Fetches and parses a site's robots.txt file.
**Query Parameters**:
+
- `url` (required): Target URL (robots.txt will be fetched from the origin)
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -283,9 +305,11 @@ Fetches and parses a site's robots.txt file.
Checks for Wayback Machine snapshots of a URL.
**Query Parameters**:
+
- `url` (required): Target URL to check
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -301,6 +325,7 @@ Checks for Wayback Machine snapshots of a URL.
```
**Response (no snapshot)**: 200 OK
+
```json
{
"ok": true,
@@ -319,6 +344,7 @@ Checks for Wayback Machine snapshots of a URL.
Checks if upload hosts are reachable.
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -336,6 +362,7 @@ Checks if upload hosts are reachable.
Checks if CDN resources are reachable.
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -353,6 +380,7 @@ Checks if CDN resources are reachable.
Checks if search engine pages are reachable.
**Response**: 200 OK
+
```json
{
"ok": true,
@@ -376,14 +404,16 @@ The client-side JavaScript provides several global APIs for image analysis and s
Generates a reverse search URL for a given engine.
**Parameters**:
+
- `engine` (string): Engine name (`lens`, `bing`, `yandex`, `tineye`, `saucenao`, `iqdb`, `baidu`, `ascii2d`, `google_images`)
- `imageUrl` (string): Public URL of the image to search
**Returns**: String URL or empty string if engine not supported
**Example**:
+
```javascript
-const url = OSINT_LIB.reverseSearchUrl('lens', 'https://example.com/image.jpg');
+const url = OSINT_LIB.reverseSearchUrl("lens", "https://example.com/image.jpg");
// Returns: "https://lens.google.com/uploadbyurl?url=https%3A%2F%2Fexample.com%2Fimage.jpg"
```
@@ -394,6 +424,7 @@ const url = OSINT_LIB.reverseSearchUrl('lens', 'https://example.com/image.jpg');
Returns the manual upload page URL for an engine.
**Parameters**:
+
- `engine` (string): Engine name
**Returns**: String URL or `about:blank` if not supported
@@ -407,6 +438,7 @@ Returns the manual upload page URL for an engine.
Global configuration object containing all app settings.
**Structure**:
+
```javascript
{
meta: {
@@ -430,35 +462,35 @@ Global configuration object containing all app settings.
## Rate Limits
-| Endpoint | Limit | Window |
-|----------|-------|--------|
-| `/api/upload` | None (but individual hosts may limit) | N/A |
-| `/api/acquire` | 18 requests | 60 seconds per IP |
-| `/api/discover` | 18 requests | 60 seconds per IP |
-| `/api/archive` | 18 requests | 60 seconds per IP |
+| Endpoint | Limit | Window |
+| --------------- | ------------------------------------- | ----------------- |
+| `/api/upload` | None (but individual hosts may limit) | N/A |
+| `/api/acquire` | 18 requests | 60 seconds per IP |
+| `/api/discover` | 18 requests | 60 seconds per IP |
+| `/api/archive` | 18 requests | 60 seconds per IP |
---
## Error Codes
-| Code | HTTP Status | Description |
-|------|-------------|-------------|
-| `upload_failed` | 500 | All upload hosts failed |
-| `invalid_image` | 400 | Invalid or corrupt image data |
-| `timeout` | 504 | Request timed out |
-| `rate_limited` | 429 | Too many requests from this IP |
-| `private_ip_blocked` | 403 | Target URL resolves to private IP |
-| `invalid_url` | 400 | Malformed or invalid URL |
-| `acquire_failed` | 500 | Failed to fetch remote content |
+| Code | HTTP Status | Description |
+| -------------------- | ----------- | --------------------------------- |
+| `upload_failed` | 500 | All upload hosts failed |
+| `invalid_image` | 400 | Invalid or corrupt image data |
+| `timeout` | 504 | Request timed out |
+| `rate_limited` | 429 | Too many requests from this IP |
+| `private_ip_blocked` | 403 | Target URL resolves to private IP |
+| `invalid_url` | 400 | Malformed or invalid URL |
+| `acquire_failed` | 500 | Failed to fetch remote content |
---
## Environment Variables
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `PORT` | `8787` | Server port |
-| `BLUELENS_LOG_LEVEL` | `INFO` | Log level (DEBUG, INFO, WARN, ERROR) |
-| `BLUELENS_LOG_FILE` | None | Optional log file path |
-| `BLUELENS_ARCHIVE_API_BASE` | `https://archive.org/wayback/available` | Archive.org API base URL |
-| `BLUELENS_ALLOW_PRIVATE_FETCH` | `0` | Allow fetching private IPs (testing only) |
+| Variable | Default | Description |
+| ------------------------------ | --------------------------------------- | ----------------------------------------- |
+| `PORT` | `8787` | Server port |
+| `BLUELENS_LOG_LEVEL` | `INFO` | Log level (DEBUG, INFO, WARN, ERROR) |
+| `BLUELENS_LOG_FILE` | None | Optional log file path |
+| `BLUELENS_ARCHIVE_API_BASE` | `https://archive.org/wayback/available` | Archive.org API base URL |
+| `BLUELENS_ALLOW_PRIVATE_FETCH` | `0` | Allow fetching private IPs (testing only) |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index bea9a34..0843e8e 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -18,6 +18,7 @@ BlueLens is a local-first OSINT (Open Source Intelligence) tool for image reconn
**Files**: `app.js`, `index.html`, `wait.html`, `styles.css`
The client layer runs entirely in the browser and handles:
+
- Image upload and preview
- Local hash computation (SHA-256, MD5, dHash)
- EXIF metadata extraction using exifr library
@@ -27,6 +28,7 @@ The client layer runs entirely in the browser and handles:
- Session state management (localStorage)
**Key Components**:
+
- **Dropzone**: Drag-and-drop image upload interface
- **Preview Stage**: Image display with crop selection
- **Hash Computation**: SHA-256, MD5, and perceptual dHash generation
@@ -42,21 +44,25 @@ The client layer runs entirely in the browser and handles:
The server provides three main services:
#### a. Static File Server
+
Serves the HTML, CSS, JavaScript, and asset files for the web interface.
#### b. Upload Proxy (`/api/upload`)
+
- Proxies image uploads to public temporary hosting services (Uguu, Catbox, Litterbox, 0x0)
- Implements failover logic across multiple hosts
- Tracks upload success/failure statistics for intelligent host selection
- Returns public URLs for use in reverse search engines
#### c. Wait Job Handoff (`/api/wait-jobs/:id`)
+
- Long-polling mechanism for wait tabs
- Allows search engine tabs to receive URLs without popup blockers
- Implements job expiration and cleanup
- Stores jobs in-memory with optional disk persistence
#### d. Acquisition Layer
+
- Fetches remote content for analysis
- Robots.txt discovery
- Archive.org snapshot lookup
@@ -67,6 +73,7 @@ Serves the HTML, CSS, JavaScript, and asset files for the web interface.
**Files**: `bluelens-helpers.js`, `bluelens-config.js`, `osint-lib.js`, `launchpad-core.js`, `ocr-pipeline.js`, `ocr-entities-ui.js`
Shared utilities used by both client and server:
+
- **bluelens-config.js**: Central configuration (ports, timeouts, engine URLs)
- **bluelens-helpers.js**: Shared helper functions (Hamming distance, dimension parsing, sorting)
- **osint-lib.js**: OSINT-specific utilities (reverse search URL builders)
@@ -127,6 +134,7 @@ For engines that need post-upload processing:
## Storage
### Browser (localStorage)
+
- **osint:session:v1**: Current investigation session (images, results, suppressions)
- **osint:lastRun:v1**: Last analysis run metadata
- **ui:missionPreset**: User's mission preset selection
@@ -134,6 +142,7 @@ For engines that need post-upload processing:
- **fx:\***: Visual effects settings
### Server (In-Memory + Disk)
+
- **WAIT_JOBS Map**: Active wait jobs (in-memory)
- **UPLOAD_STATS Map**: Host performance telemetry (in-memory)
- **DOCTOR_HISTORY**: Health check history (in-memory)
@@ -174,12 +183,14 @@ For engines that need post-upload processing:
## Dependencies
### Client-Side (CDN)
+
- **exifr**: EXIF metadata parsing
- **Tesseract.js**: OCR engine
- **SparkMD5**: MD5 hashing
- **sha256**: SHA-256 hashing
### Server-Side (Built-in)
+
- **http**: HTTP server
- **fs**: File system operations
- **path**: Path manipulation
@@ -187,6 +198,7 @@ For engines that need post-upload processing:
- **net**: Network utilities
### Development
+
- **eslint**: Code linting
- **prettier**: Code formatting
- **node:test**: Built-in test runner (Node 18+)
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 60d09cb..d42bef0 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -21,17 +21,20 @@
### Setup Steps
1. **Clone the repository**:
+
```bash
git clone https://github.com/potemkin666/bluelens.git
cd bluelens
```
2. **Install dependencies** (dev tools only):
+
```bash
npm install
```
3. **Start the server**:
+
```bash
npm start
# or
@@ -46,28 +49,33 @@
### Development Workflow
**Running tests**:
+
```bash
npm test
```
**Running tests with coverage**:
+
```bash
npm run test:coverage
```
**Linting**:
+
```bash
npm run lint # Check for issues
npm run lint:fix # Auto-fix issues
```
**Formatting**:
+
```bash
npm run format:check # Check formatting
npm run format # Auto-format files
```
**Windows Desktop Shortcut**:
+
```bash
npm run desktop-icon
```
@@ -89,18 +97,21 @@ npm run desktop-icon
#### Deployment Steps
1. **Create a deployment user**:
+
```bash
sudo useradd -m -s /bin/bash bluelens
sudo su - bluelens
```
2. **Clone and setup**:
+
```bash
git clone https://github.com/potemkin666/bluelens.git
cd bluelens
```
3. **Configure environment** (optional):
+
```bash
export PORT=8787
export BLUELENS_LOG_LEVEL=INFO
@@ -108,6 +119,7 @@ npm run desktop-icon
```
4. **Start with process manager** (PM2 recommended):
+
```bash
npm install -g pm2
pm2 start server.js --name bluelens
@@ -148,6 +160,7 @@ WantedBy=multi-user.target
```
Enable and start:
+
```bash
sudo mkdir -p /var/log/bluelens
sudo chown bluelens:bluelens /var/log/bluelens
@@ -192,7 +205,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
-
+
# Long-polling support for wait jobs
proxy_read_timeout 35s;
proxy_connect_timeout 10s;
@@ -202,6 +215,7 @@ server {
```
Enable and restart:
+
```bash
sudo ln -s /etc/nginx/sites-available/bluelens /etc/nginx/sites-enabled/
sudo nginx -t
@@ -272,7 +286,7 @@ docker logs -f bluelens
Create `docker-compose.yml`:
```yaml
-version: '3.8'
+version: "3.8"
services:
bluelens:
@@ -288,13 +302,15 @@ services:
- ./logs:/var/log/bluelens
restart: unless-stopped
healthcheck:
- test: ["CMD", "node", "-e", "require('http').get('http://localhost:8787/api/ping', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
+ test:
+ ["CMD", "node", "-e", "require('http').get('http://localhost:8787/api/ping', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 3s
retries: 3
```
Run with:
+
```bash
docker-compose up -d
docker-compose logs -f
@@ -307,6 +323,7 @@ docker-compose logs -f
### Application Logs
**Console logs** (if running directly):
+
```bash
# Follow logs with PM2
pm2 logs bluelens
@@ -316,6 +333,7 @@ sudo journalctl -u bluelens -f
```
**File logs** (if configured):
+
```bash
tail -f /var/log/bluelens/app.log
```
@@ -323,6 +341,7 @@ tail -f /var/log/bluelens/app.log
### Log Format
Structured JSON logs (when using logger module):
+
```json
{
"timestamp": "2026-05-10T10:00:00.000Z",
@@ -336,11 +355,13 @@ Structured JSON logs (when using logger module):
### Health Checks
**Manual check**:
+
```bash
curl http://localhost:8787/api/ping
```
**Expected response**:
+
```json
{
"ok": true,
@@ -352,11 +373,13 @@ curl http://localhost:8787/api/ping
### Performance Monitoring
**Check upload statistics**:
+
```bash
curl http://localhost:8787/api/upload-stats
```
**Monitor system resources**:
+
```bash
# With PM2
pm2 monit
@@ -369,6 +392,7 @@ df -h
### Alerts
Set up monitoring with tools like:
+
- **Uptime Robot**: External uptime monitoring
- **Prometheus + Grafana**: Metrics and dashboards
- **Sentry**: Error tracking
@@ -381,6 +405,7 @@ Set up monitoring with tools like:
### Server Won't Start
**Check port availability**:
+
```bash
# Check if port is in use
sudo lsof -i :8787
@@ -388,6 +413,7 @@ sudo netstat -tuln | grep 8787
```
**Check logs**:
+
```bash
# PM2
pm2 logs bluelens --err
@@ -399,17 +425,20 @@ sudo journalctl -u bluelens -n 50
### Upload Failures
**Check upload host reachability**:
+
```bash
curl http://localhost:8787/api/doctor/upload-reachability
```
**Common causes**:
+
1. Network connectivity issues
2. Upload hosts down or blocking server IP
3. Image size too large (some hosts have limits)
4. Rate limiting from upload hosts
**Solutions**:
+
- Configure alternative upload hosts in `bluelens-config.js`
- Check network/firewall rules
- Reduce image size before upload
@@ -419,11 +448,13 @@ curl http://localhost:8787/api/doctor/upload-reachability
**Symptoms**: Wait tabs stuck at "Waiting for upload..."
**Check**:
+
1. Server is running and accessible
2. No CORS issues (check browser console)
3. Wait job timeout settings in config
**Debug**:
+
```bash
# Check active jobs
curl http://localhost:8787/api/wait-jobs/[job-id]
@@ -432,6 +463,7 @@ curl http://localhost:8787/api/wait-jobs/[job-id]
### High Memory Usage
**Check memory**:
+
```bash
# PM2
pm2 show bluelens
@@ -442,11 +474,13 @@ ps aux | grep node
```
**Common causes**:
+
1. Large images being processed
2. Memory leaks in long-running processes
3. Too many concurrent uploads
**Solutions**:
+
- Restart the service regularly (cron job)
- Add memory limit to PM2: `pm2 start server.js --max-memory-restart 500M`
- Enable swap if needed
@@ -454,16 +488,19 @@ ps aux | grep node
### CDN Resources Not Loading
**Check CDN reachability**:
+
```bash
curl http://localhost:8787/api/doctor/cdn-reachability
```
**Common causes**:
+
1. Network/firewall blocking CDN domains
2. Corporate proxy interfering
3. DNS issues
**Solutions**:
+
- Check DNS resolution: `nslookup cdn.jsdelivr.net`
- Configure proxy if needed
- Whitelist CDN domains in firewall
@@ -473,11 +510,13 @@ curl http://localhost:8787/api/doctor/cdn-reachability
**Symptoms**: Slow responses, timeouts
**Check**:
+
1. Server resource usage (CPU, RAM, disk)
2. Network latency
3. Upload host performance
**Optimize**:
+
```bash
# Enable compression in nginx
gzip on;
@@ -503,11 +542,13 @@ worker_processes auto;
## Backup and Recovery
**What to backup**:
+
- Application logs (if important)
- Configuration files (if customized)
- Wait job store: `/tmp/bluelens-wait-jobs-v1.json` (ephemeral, low priority)
**Backup script example**:
+
```bash
#!/bin/bash
BACKUP_DIR=/backup/bluelens/$(date +%Y%m%d)
diff --git a/docs/MODULARIZATION.md b/docs/MODULARIZATION.md
index d375e86..28517f4 100644
--- a/docs/MODULARIZATION.md
+++ b/docs/MODULARIZATION.md
@@ -10,6 +10,7 @@ The BlueLens codebase has two large files that could benefit from modularization
## Why Not Split Now?
### app.js (Browser Context)
+
- **Single Script Tag**: Currently loaded as a single `
```
@@ -124,6 +128,7 @@ These improvements make the large files more maintainable without breaking chang
## When to Revisit
Consider splitting when:
+
- app.js grows beyond 10,000 lines
- server.js grows beyond 2,000 lines
- Adding a build system becomes necessary for other reasons
diff --git a/docs/PROJECT_CONFIG.md b/docs/PROJECT_CONFIG.md
index 0dd0aeb..2b91a1f 100644
--- a/docs/PROJECT_CONFIG.md
+++ b/docs/PROJECT_CONFIG.md
@@ -15,7 +15,7 @@ All requirements from the problem statement have been successfully addressed:
2. **.gitignore**
- Status: Already existed
- - Content: Excludes node_modules/, .DS_Store, *.log, .env, IDE files, and more
+ - Content: Excludes node_modules/, .DS_Store, \*.log, .env, IDE files, and more
- Change: Removed package-lock.json from exclusions
3. **.editorconfig**
@@ -76,17 +76,20 @@ All requirements from the problem statement have been successfully addressed:
## Developer Experience Improvements
### For New Contributors
+
- Running `nvm use` or similar will automatically use Node.js 20
- Editor configurations (indent size, line endings) are automatically applied
- Code quality checks are available via npm scripts
- License terms are clearly defined
### For CI/CD
+
- Consistent dependency versions via package-lock.json
- Automated linting and formatting checks can be added to workflows
- Test coverage reporting is available
### For Maintainers
+
- All major IDEs and editors will respect .editorconfig settings
- Consistent code style is enforced via ESLint and Prettier
- Clear licensing reduces legal ambiguity
@@ -94,6 +97,7 @@ All requirements from the problem statement have been successfully addressed:
## Verification
All tests pass successfully:
+
```
✔ 56 tests passed
✔ 0 tests failed
@@ -114,6 +118,7 @@ All tests pass successfully:
For developers using this repository:
1. **Install Node.js**: Use version 20 as specified in .nvmrc
+
```bash
nvm use
# or
@@ -121,11 +126,13 @@ For developers using this repository:
```
2. **Install dependencies**:
+
```bash
npm install
```
3. **Run development tools**:
+
```bash
npm run lint # Check code quality
npm run format # Format code
diff --git a/docs/README.md b/docs/README.md
index 2236390..9c11093 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,6 +5,7 @@ This folder contains development and deployment documentation for BlueLens.
## Documentation Files
### [ARCHITECTURE.md](./ARCHITECTURE.md)
+
- System architecture overview
- Component descriptions (client, server, helpers)
- Data flow diagrams
@@ -15,6 +16,7 @@ This folder contains development and deployment documentation for BlueLens.
- Dependencies
### [API.md](./API.md)
+
- Server API endpoints documentation
- Health & status endpoints
- Upload proxy API
@@ -26,6 +28,7 @@ This folder contains development and deployment documentation for BlueLens.
- Environment variables
### [DEPLOYMENT.md](./DEPLOYMENT.md)
+
- Local development setup
- Production deployment options
- Direct Node.js deployment
@@ -41,6 +44,7 @@ This folder contains development and deployment documentation for BlueLens.
- Backup and recovery
### [MODULARIZATION.md](./MODULARIZATION.md)
+
- Code organization notes
- Rationale for not splitting large files yet
- Future modularization strategy
@@ -51,15 +55,18 @@ This folder contains development and deployment documentation for BlueLens.
## Quick Links
### For Developers
+
- Start with [ARCHITECTURE.md](./ARCHITECTURE.md) to understand the system
- See [API.md](./API.md) for endpoint and function documentation
- Review [MODULARIZATION.md](./MODULARIZATION.md) before making structural changes
### For Operators
+
- See [DEPLOYMENT.md](./DEPLOYMENT.md) for installation and configuration
- Reference [API.md](./API.md) for API endpoints and environment variables
### For Contributors
+
- Read [ARCHITECTURE.md](./ARCHITECTURE.md) to understand the codebase
- Check [MODULARIZATION.md](./MODULARIZATION.md) for code organization philosophy
- Follow the development workflow in [DEPLOYMENT.md](./DEPLOYMENT.md#local-development)
diff --git a/help.html b/help.html
index 0be74b3..36fad39 100644
--- a/help.html
+++ b/help.html
@@ -15,7 +15,9 @@
BlueLens Help
Back to app
-
Start here for startup steps, privacy expectations, troubleshooting, and the operator-visible defaults that shape launch behavior.
+
+ Start here for startup steps, privacy expectations, troubleshooting, and the operator-visible defaults that shape launch behavior.
+
@@ -25,7 +27,9 @@ BlueLens Help
Run node server.js from the repository root.
Open http://localhost:8787.
Choose an image to inspect locally, then explicitly run a launch action when you want provider links.
- Open the Doctor panel for a one-screen check of server, popup, storage, CDN, and upload-host reachability.
+
+ Open the Doctor panel for a one-screen check of server, popup, storage, CDN, and upload-host reachability.
+
diff --git a/index.html b/index.html
index edb7b4c..fe9d960 100644
--- a/index.html
+++ b/index.html
@@ -91,24 +91,10 @@ Image Search
Other actions
-
+
Lens only
-
+
Run OCR
@@ -221,237 +207,254 @@
Image Search
-
-
-
Share
-
- Not shared
- GPS present
- Editing software
-
-
+
+
+
Share
+
+ Not shared
+ GPS present
+ Editing software
+
+
-
+
-
-
- Confidence
-
- Unverified
- Likely
- Confirmed
-
-
-
+
+
+ Confidence
+
+ Unverified
+ Likely
+ Confirmed
+
+
+
-
-
- Uploads start only when you choose search.
-
+
+
+ Uploads start only when you choose search.
+
-
+
-
-
-
- Clean upload copy
-
+
+
+
+ Clean upload copy
+
-
-
- Auto host
-
-
- Copy URL
-
-
- Retry
-
-
+
+
+ Auto host
+
+ Copy URL
+
+ Retry
+
+
-
—
-
-
-
-
+
—
+
+
+
+
-
-
-
AI-image suspicion
-
checklist
-
-
Checklist only — not an oracle.
-
-
+
+
+
AI-image suspicion
+
checklist
+
+
Checklist only — not an oracle.
+
+
-
-
-
Mission Output
-
structured
-
-
- Run a workflow.
-
-
+
+
+
Mission Output
+
structured
+
+
Run a workflow.
+
-
-
-
Result Intake
-
dedupe queue
-
-
-
-
- Ingest Results
-
-
- Clear Queue
-
-
- Copy JSON
-
-
-
- No external findings ingested yet.
-
-
+
+
+
Result Intake
+
dedupe queue
+
+
+
+
+ Ingest Results
+
+
+ Clear Queue
+
+
+ Copy JSON
+
+
+
No external findings ingested yet.
+
-
-
-
No-result autopsy
-
why nothing hit
-
-
- Run a multi-engine launch, then ingest hits. If nothing lands, BlueLens will explain the likely failure modes here.
-
-
+
+
+
No-result autopsy
+
why nothing hit
+
+
+ Run a multi-engine launch, then ingest hits. If nothing lands, BlueLens will explain the likely failure modes here.
+
+
-
-
-
Source contradictions
-
date conflicts
-
-
- Ingest result snippets with dates and BlueLens will surface conflicts instead of flattening them into fake certainty.
-
-
+
+
+
Source contradictions
+
date conflicts
+
+
+ Ingest result snippets with dates and BlueLens will surface conflicts instead of flattening them into fake certainty.
+
+
-
- More tools
-
-
-
-
Mutation Lab
-
variants
-
-
-
- Generate Variants + Lens
-
- Copy JSON
-
-
—
-
+
+ More tools
+
+
+
+
Mutation Lab
+
variants
+
+
+
+ Generate Variants + Lens
+
+
+ Copy JSON
+
+
+
—
+
-
-
-
-
- Run Batch
- Download JSON
- Download CSV
-
-
—
-
+
+
+
+
+
+ Run Batch
+
+
+ Download JSON
+
+
+ Download CSV
+
+
+
—
+
-
-
-
-
- Run Doctor
-
-
-
Checking runtime…
-
+
+
+
+
+ Run Doctor
+
+
+
Checking runtime…
+
-
-
-
+
@@ -475,11 +478,12 @@ Image Search
Search Crop
-
- Clear Crop
-
+ Clear Crop
+
+
+ Drag a box around a face, logo, object, tattoo, sign, vehicle, building, product, artwork, or text area to search just that
+ region.
-
Drag a box around a face, logo, object, tattoo, sign, vehicle, building, product, artwork, or text area to search just that region.
- dHash is a 64-bit perceptual heuristic. Lower Hamming distance means the thumbnails look closer, not that the files are proven to be the same image.
+ dHash is a 64-bit perceptual heuristic. Lower Hamming distance means the thumbnails look closer, not that the files are
+ proven to be the same image.
@@ -711,7 +730,9 @@ Correlate entities, inspect chronology, monitor source health, and track swarm outcomes from one shared investigation model.
+
+ Correlate entities, inspect chronology, monitor source health, and track swarm outcomes from one shared investigation model.
+
- *HEIC support depends on your browser/OS. One-click search makes the image public via a temporary URL.
-
+ *HEIC support depends on your browser/OS. One-click search makes the image public via a temporary URL.
diff --git a/launchpad-core.js b/launchpad-core.js
index 42308a9..c8d1fb7 100644
--- a/launchpad-core.js
+++ b/launchpad-core.js
@@ -4,7 +4,13 @@
const getRunEngines = ({ run, engineOrder = [] } = {}) => {
const fromTargets = Object.keys(run?.targets || {});
const fromQueue = Object.keys(run?.queue || {});
- return Array.from(new Set([...(engineOrder || []).filter((engine) => fromTargets.includes(engine) || fromQueue.includes(engine)), ...fromTargets, ...fromQueue])).filter(Boolean);
+ return Array.from(
+ new Set([
+ ...(engineOrder || []).filter((engine) => fromTargets.includes(engine) || fromQueue.includes(engine)),
+ ...fromTargets,
+ ...fromQueue,
+ ]),
+ ).filter(Boolean);
};
const updateRunQueueStatus = ({ run, engine, patch = {}, runQueueStatus = { queued: "queued" } } = {}) => {
@@ -96,7 +102,14 @@
};
};
- const openTargetsForRun = ({ run, engines, engineOrder = [], openUrl = () => null, updateRunQueueStatus: updateStatus, runQueueStatus = {} } = {}) => {
+ const openTargetsForRun = ({
+ run,
+ engines,
+ engineOrder = [],
+ openUrl = () => null,
+ updateRunQueueStatus: updateStatus,
+ runQueueStatus = {},
+ } = {}) => {
if (!run || !run.targets) return { openedCount: 0, blockedCount: 0 };
ensureRunOutcomeState({ run, engines: getRunEngines({ run, engineOrder }) });
const openList = Array.from(new Set((engines || []).filter((engine) => run.targets?.[engine])));
@@ -124,7 +137,15 @@
return { openedCount, blockedCount };
};
- const hydrateRunTargets = ({ run, engines = [], url = "", reverseSearchUrl = () => "", updateRunQueueStatus: updateStatus, openLens = true, runQueueStatus = {} } = {}) => {
+ const hydrateRunTargets = ({
+ run,
+ engines = [],
+ url = "",
+ reverseSearchUrl = () => "",
+ updateRunQueueStatus: updateStatus,
+ openLens = true,
+ runQueueStatus = {},
+ } = {}) => {
run.url = url;
run.targets = Object.fromEntries(engines.map((engine) => [engine, reverseSearchUrl(engine, url)]));
for (const engine of engines) {
@@ -192,7 +213,9 @@
const run = createRun({ engines, mode: "swarm", reverseSearchUrl, runQueueStatus, artifact: getArtifact() });
ensureRunOutcomeState({ run, engines });
for (const engine of engines) {
- const wait = openWaitJob(engine, `${labelPrefix} · ${engineLabel[engine] || engine}`, { initialStatus: runQueueStatus.queued || "queued" });
+ const wait = openWaitJob(engine, `${labelPrefix} · ${engineLabel[engine] || engine}`, {
+ initialStatus: runQueueStatus.queued || "queued",
+ });
updateStatus?.(run, engine, {
job_id: wait.jobId,
attempts: 1,
@@ -209,7 +232,10 @@
for (let index = 0; index < engines.length; index += 1) {
const engine = engines[index];
const jobId = run.queue?.[engine]?.job_id || "";
- updateStatus?.(run, engine, { status: runQueueStatus.ready || "ready", detail: `Provider target staged (${index + 1}/${engines.length})` });
+ updateStatus?.(run, engine, {
+ status: runQueueStatus.ready || "ready",
+ detail: `Provider target staged (${index + 1}/${engines.length})`,
+ });
if (jobId && !run.blocked?.[engine]) publishWaitState(jobId, { url });
persistRun(run);
if (index < engines.length - 1 && delayMs > 0) await sleep(delayMs);
diff --git a/ocr-entities-ui.js b/ocr-entities-ui.js
index 944cbbb..4c8ddaa 100644
--- a/ocr-entities-ui.js
+++ b/ocr-entities-ui.js
@@ -1,7 +1,17 @@
/* global BLUELENS_CONFIG */
(() => {
- const emptyRecon = { urls: [], emails: [], handles: [], phones: [], people: [], organizations: [], locations: [], dates: [], aliases: [] };
+ const emptyRecon = {
+ urls: [],
+ emails: [],
+ handles: [],
+ phones: [],
+ people: [],
+ organizations: [],
+ locations: [],
+ dates: [],
+ aliases: [],
+ };
const buildDetailIndex = (entries = []) =>
new Map(
@@ -28,7 +38,13 @@
const handles = Array.from(
new Set(
[...(ent.handles || []), ...(extra.handles || [])]
- .map((value) => ocrPipeline?.normalizeHandle?.(value) || String(value || "").replace(/^@/, "").trim())
+ .map(
+ (value) =>
+ ocrPipeline?.normalizeHandle?.(value) ||
+ String(value || "")
+ .replace(/^@/, "")
+ .trim(),
+ )
.filter(Boolean)
.map((value) => `@${value}`),
),
@@ -70,14 +86,18 @@
});
return {
mission: "handle_recon",
- summary: handles?.length ? `Handle recon prepared ${handles.length} normalized handles.` : "Handle recon found no stable OCR handles.",
+ summary: handles?.length
+ ? `Handle recon prepared ${handles.length} normalized handles.`
+ : "Handle recon found no stable OCR handles.",
items,
};
};
const buildDomainReconOutput = ({ ent, domains, normalizeDomain }) => {
const items = (domains || []).slice(0, 8).map((domain) => {
- const relatedUrls = (ent?.urls || []).filter((url) => (typeof normalizeDomain === "function" ? normalizeDomain(url) : "") === domain).slice(0, 2);
+ const relatedUrls = (ent?.urls || [])
+ .filter((url) => (typeof normalizeDomain === "function" ? normalizeDomain(url) : "") === domain)
+ .slice(0, 2);
return {
type: "domain",
label: domain,
@@ -94,7 +114,9 @@
});
return {
mission: "domain_recon",
- summary: domains?.length ? `Domain recon normalized ${domains.length} domains from OCR and URL pivots.` : "Domain recon found no domains to normalize.",
+ summary: domains?.length
+ ? `Domain recon normalized ${domains.length} domains from OCR and URL pivots.`
+ : "Domain recon found no domains to normalize.",
items,
};
};
@@ -120,7 +142,10 @@
element.hidden = true;
return;
}
- const labels = hint.models.slice(0, 3).map((code) => getOcrLanguageLabel(code)).join(" / ");
+ const labels = hint.models
+ .slice(0, 3)
+ .map((code) => getOcrLanguageLabel(code))
+ .join(" / ");
element.hidden = false;
element.textContent = `Weak script hint: ${hint.label} → try ${labels}`;
};
@@ -266,9 +291,7 @@
sel.className = "select chip-select";
sel.title = "Analyst confidence (manual)";
sel.innerHTML =
- `