#!/usr/bin/env node import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; const SOURCES = [ { language: "da", file: "data/raw/smk_all_da.json" }, { language: "en", file: "data/raw/smk_all_en.json" }, ]; const VISUAL_FIELDS = ["brightness", "contrast", "saturation", "colortemp", "entropy"]; const COMPARE_FIELDS = [ "id", "created", "modified", "acquisition_date", "acquisition_date_precision", "object_number", "object_url", "frontend_url", "iiif_manifest", "enrichment_url", "similar_images_url", "public_domain", "rights", "on_display", "image_mime_type", "image_iiif_id", "image_iiif_info", "image_width", "image_height", "image_size", "image_thumbnail", "image_native", "image_cropped", "image_orientation", "image_hq", "has_3d_file", "has_image", "colors", "suggested_bg_color", ...VISUAL_FIELDS, ]; const RELEVANT_KEYS = new Set([ "object_number", "public_domain", "titles", "title", "object_names", "name", "classification", "classifications", "dimensions", "part", "type", "unit", "value", "image_width", "image_height", "has_image", "colors", "colors_palette", "colors_palette_weighted", "colors_palette_weighted_s", "suggested_bg_color", ...VISUAL_FIELDS, ]); function valueType(value) { if (value === null) return "null"; if (Array.isArray(value)) return "array"; return typeof value; } function stable(value) { if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; if (value && typeof value === "object") { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`; } return JSON.stringify(value); } function digestValue(value) { return createHash("sha256").update(stable(value)).digest("hex").slice(0, 16); } function increment(map, key) { map.set(key, (map.get(key) ?? 0) + 1); } function inspectRelevant(value, currentPath, stats) { if (Array.isArray(value)) { for (const item of value) inspectRelevant(item, `${currentPath}[]`, stats); return; } if (!value || typeof value !== "object") return; for (const [key, child] of Object.entries(value)) { const childPath = currentPath ? `${currentPath}.${key}` : key; if (RELEVANT_KEYS.has(key) || /palette|color|brightness|contrast|saturation|entropy|dimension/i.test(key)) { const statKey = `${childPath}\u0000${valueType(child)}`; increment(stats, statKey); } inspectRelevant(child, childPath, stats); } } function sampleRecord(record) { const keys = [ "id", "object_number", "public_domain", "has_image", "image_width", "image_height", "dimensions", "titles", "object_names", "colors", "suggested_bg_color", ...VISUAL_FIELDS, ]; return Object.fromEntries(keys.filter((key) => Object.hasOwn(record, key)).map((key) => [key, record[key]])); } async function inspectSource(source) { const hash = createHash("sha256"); const ids = new Map(); const comparison = new Map(); const topLevelFields = new Map(); const relevantPaths = new Map(); const counts = { records: 0, missing_object_number: 0, blank_object_number: 0, public_domain_true: 0, has_image_true: 0, dimensions_present: 0, colors_present: 0, all_visual_fields_present: 0, }; const visualFieldPresence = Object.fromEntries(VISUAL_FIELDS.map((key) => [key, 0])); const samples = { first: null, last: null, first_without_image: null, first_without_object_number: null }; let bytes = 0; let rootStarted = false; let rootEnded = false; let collecting = false; let depth = 0; let inString = false; let escaped = false; let recordText = ""; function accept(record) { counts.records += 1; const sampled = sampleRecord(record); samples.first ??= sampled; samples.last = sampled; if (record.has_image !== true) samples.first_without_image ??= sampled; const rawId = record.object_number; const validId = typeof rawId === "string" && rawId.trim() !== ""; if (!Object.hasOwn(record, "object_number")) counts.missing_object_number += 1; else if (!validId) counts.blank_object_number += 1; if (!validId) samples.first_without_object_number ??= sampled; if (validId) { const id = rawId.trim(); increment(ids, id); const fingerprints = {}; for (const key of COMPARE_FIELDS) { fingerprints[key] = Object.hasOwn(record, key) ? digestValue(record[key]) : null; } if (!comparison.has(id)) comparison.set(id, fingerprints); } if (record.public_domain === true) counts.public_domain_true += 1; if (record.has_image === true) counts.has_image_true += 1; if (Array.isArray(record.dimensions) && record.dimensions.length > 0) counts.dimensions_present += 1; if (Array.isArray(record.colors) && record.colors.length > 0) counts.colors_present += 1; if (VISUAL_FIELDS.every((key) => record[key] !== null && record[key] !== undefined)) { counts.all_visual_fields_present += 1; } for (const key of VISUAL_FIELDS) { if (record[key] !== null && record[key] !== undefined) visualFieldPresence[key] += 1; } for (const [key, value] of Object.entries(record)) { increment(topLevelFields, `${key}\u0000${valueType(value)}`); } inspectRelevant(record, "", relevantPaths); } const stream = createReadStream(source.file, { highWaterMark: 1024 * 1024 }); for await (const chunk of stream) { bytes += chunk.length; hash.update(chunk); const text = chunk.toString("utf8"); for (const char of text) { if (!rootStarted) { if (/\s/.test(char)) continue; if (char !== "[") throw new Error(`${source.file}: expected top-level array`); rootStarted = true; continue; } if (!collecting) { if (/\s/.test(char) || char === ",") continue; if (char === "]") { rootEnded = true; continue; } if (char !== "{") throw new Error(`${source.file}: expected object array element`); collecting = true; depth = 1; inString = false; escaped = false; recordText = "{"; continue; } recordText += char; if (inString) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === "\"") inString = false; continue; } if (char === "\"") inString = true; else if (char === "{" || char === "[") depth += 1; else if (char === "}" || char === "]") depth -= 1; if (depth === 0) { accept(JSON.parse(recordText)); collecting = false; recordText = ""; } } } if (!rootStarted || !rootEnded || collecting) throw new Error(`${source.file}: incomplete JSON array`); const duplicates = [...ids.entries()].filter(([, count]) => count > 1).sort((a, b) => a[0].localeCompare(b[0])); return { language: source.language, path: source.file.replaceAll("\\", "/"), bytes, sha256: hash.digest("hex"), observed_structure: { top_level_type: "array", record_path: "$[]", element_type: "object", envelope: null, }, counts: { ...counts, unique_object_numbers: ids.size, duplicate_object_numbers: duplicates.length, duplicate_rows_beyond_first: duplicates.reduce((sum, [, count]) => sum + count - 1, 0), visual_field_presence: visualFieldPresence, }, duplicate_details: duplicates.map(([object_number, count]) => ({ object_number, count })), top_level_fields: [...topLevelFields.entries()].map(([key, count]) => { const [field, type] = key.split("\u0000"); return { field, type, count }; }).sort((a, b) => a.field.localeCompare(b.field) || a.type.localeCompare(b.type)), relevant_field_locations: [...relevantPaths.entries()].map(([key, count]) => { const [field_path, type] = key.split("\u0000"); return { field_path, type, count }; }).sort((a, b) => a.field_path.localeCompare(b.field_path) || a.type.localeCompare(b.type)), representative_records: samples, _ids: ids, _comparison: comparison, }; } function compareSources(da, en) { const daIds = new Set(da._ids.keys()); const enIds = new Set(en._ids.keys()); const daOnly = [...daIds].filter((id) => !enIds.has(id)).sort(); const enOnly = [...enIds].filter((id) => !daIds.has(id)).sort(); const overlap = [...daIds].filter((id) => enIds.has(id)); const fieldDifferences = Object.fromEntries(COMPARE_FIELDS.map((field) => [field, 0])); const differenceExamples = Object.fromEntries(COMPARE_FIELDS.map((field) => [field, []])); for (const id of overlap) { const left = da._comparison.get(id); const right = en._comparison.get(id); for (const field of COMPARE_FIELDS) { if (left[field] !== right[field]) { fieldDifferences[field] += 1; if (differenceExamples[field].length < 5) differenceExamples[field].push(id); } } } return { overlap_unique_object_numbers: overlap.length, da_only_count: daOnly.length, en_only_count: enOnly.length, da_only_object_numbers: daOnly, en_only_object_numbers: enOnly, selected_field_difference_counts_on_overlap: fieldDifferences, selected_field_difference_examples: Object.fromEntries( Object.entries(differenceExamples).filter(([, ids]) => ids.length > 0), ), }; } const startedAt = new Date().toISOString(); const results = []; for (const source of SOURCES) { process.stderr.write(`Inspecting ${source.file}...\n`); results.push(await inspectSource(source)); } const comparison = compareSources(results[0], results[1]); for (const result of results) { delete result._ids; delete result._comparison; } const output = { reconnaissance_version: "1.0.0", tool: { name: "tools/source-reconnaissance.mjs", version: "1.0.0", runtime: process.version }, started_at_utc: startedAt, generated_at_utc: new Date().toISOString(), sources: results, identity_comparison: comparison, }; await mkdir("data/reports", { recursive: true }); await writeFile("data/reports/source-reconnaissance.json", `${JSON.stringify(output, null, 2)}\n`); process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);