← Files VeraARCHIVED FILE
modules/browser-automation/scripts/agenzia_acquisition.mjs
23.2 KB · Oct 2, 2026 · 00:29 UTC
/** Resumable Agenzia invoice acquisition for explicit, model-reviewed category plans. */
import { createHash } from "node:crypto";
import { chmod, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import {
AgenziaArtifactError,
archiveInvoiceOriginal,
archiveNativePdf,
verifyArchivedArtifact
} from "./agenzia_artifacts.mjs";
import { DownloadDirectoryError, observeDownloadDirectory } from "./download_directory.mjs";
import { browserFailureCode } from "./browser_session.mjs";
const ALLOWED_ORIGIN = "https://ivaservizi.agenziaentrate.gov.it";
const FORMAT_STATES = new Set(["available", "native_gap", "unavailable"]);
const EXECUTION_MODES = new Set(["unverified", "simulated", "live_connected_chrome"]);
export class AgenziaAcquisitionError extends Error {
constructor(code, cause) {
super(code, cause ? { cause } : undefined);
this.name = "AgenziaAcquisitionError";
this.code = code;
}
}
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === "object") {
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
}
return value;
}
function formatItalianDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? "")) {
throw new AgenziaAcquisitionError("invalid-date-format");
}
const [year, month, day] = value.split("-").map(Number);
const parsed = new Date(Date.UTC(year, month - 1, day));
if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
throw new AgenziaAcquisitionError("invalid-date-value");
}
return `${String(day).padStart(2, "0")}/${String(month).padStart(2, "0")}/${year}`;
}
function normalizePlan(plan) {
if (!Array.isArray(plan) || plan.length < 1 || plan.length > 100) {
throw new AgenziaAcquisitionError("category-plan-required");
}
const keys = new Set();
return plan.map((item) => {
if (!item || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.category ?? "")) {
throw new AgenziaAcquisitionError("category-id-invalid");
}
if (typeof item.accessibleName !== "string" || item.accessibleName.trim().length < 3) {
throw new AgenziaAcquisitionError("category-accessible-name-required");
}
if (!Number.isInteger(item.expectedCount) || item.expectedCount < 0) {
throw new AgenziaAcquisitionError("expected-invoice-count-required");
}
const dateFrom = item.dateFrom;
const dateTo = item.dateTo;
const from = formatItalianDate(dateFrom);
const to = formatItalianDate(dateTo);
if (dateFrom > dateTo || dateFrom.slice(0, 4) !== dateTo.slice(0, 4)) {
throw new AgenziaAcquisitionError("single-year-date-range-required");
}
const year = dateFrom.slice(0, 4);
const key = `${year}-${item.category}`;
if (keys.has(key)) throw new AgenziaAcquisitionError("category-period-duplicate");
keys.add(key);
const formats = {
original: item.formats?.original ?? "available",
pdf: item.formats?.pdf ?? "unavailable"
};
if (!FORMAT_STATES.has(formats.original) || !FORMAT_STATES.has(formats.pdf)) {
throw new AgenziaAcquisitionError("format-availability-invalid");
}
if (formats.original === "native_gap" || formats.pdf === "available") {
throw new AgenziaAcquisitionError("format-route-invalid");
}
return {
key,
category: item.category,
accessibleName: item.accessibleName.trim(),
dateFrom,
dateTo,
from,
to,
year,
expectedCount: item.expectedCount,
formats,
labels: {
from: item.labels?.from?.trim() || "Dal",
to: item.labels?.to?.trim() || "Al"
}
};
});
}
async function assertAllowedOrigin(tab) {
const current = new URL(await tab.url());
if (current.origin !== ALLOWED_ORIGIN) {
throw new AgenziaAcquisitionError("origin-outside-authorized-boundary");
}
}
async function waitUntil(tab, predicate, code, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) return;
await tab.playwright.waitForTimeout(200);
}
throw new AgenziaAcquisitionError(code);
}
async function unique(locator, code) {
if ((await locator.count()) !== 1) throw new AgenziaAcquisitionError(code);
return locator;
}
function partialName(value) {
return new RegExp(escapeRegex(value), "i");
}
async function labeledTextbox(tab, label, code) {
const accessibleLabel = new RegExp(`^\\s*${escapeRegex(label)}(?:\\s*:)?\\s*$`, "i");
if (typeof tab.playwright.getByLabel === "function") {
const labeled = tab.playwright.getByLabel(accessibleLabel);
if ((await labeled.count()) === 1) return labeled;
}
return unique(tab.playwright.getByRole("textbox", { name: accessibleLabel }), code);
}
async function uniqueRoleChoice(tab, roles, name, code) {
const matches = [];
for (const role of roles) {
const locator = tab.playwright.getByRole(role, { name: partialName(name) });
if ((await locator.count()) === 1) matches.push(locator);
else if ((await locator.count()) > 1) throw new AgenziaAcquisitionError(code);
}
if (matches.length !== 1) throw new AgenziaAcquisitionError(code);
return matches[0];
}
async function persistRevision(context, state) {
const sequence = context.nextRevision;
const snapshot = {
...state,
revision: sequence,
previous_revision_sha256: context.previousHash
};
const bytes = Buffer.from(`${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
await writeFile(join(context.runDirectory, `run-state-${String(sequence).padStart(6, "0")}.json`), bytes, {
flag: "wx",
mode: 0o600
});
context.previousHash = sha256(bytes);
context.nextRevision += 1;
state.revision = sequence;
state.previous_revision_sha256 = snapshot.previous_revision_sha256;
}
async function loadState(runDirectory) {
const names = (await readdir(runDirectory))
.filter((name) => /^run-state-\d{6}\.json$/.test(name))
.sort();
if (names.length < 1) throw new AgenziaAcquisitionError("resume-state-missing");
let previousHash = null;
let state = null;
for (let index = 0; index < names.length; index += 1) {
if (names[index] !== `run-state-${String(index).padStart(6, "0")}.json`) {
throw new AgenziaAcquisitionError("resume-revision-gap");
}
const bytes = await readFile(join(runDirectory, names[index]));
let payload;
try {
payload = JSON.parse(bytes.toString("utf8"));
} catch (error) {
throw new AgenziaAcquisitionError("resume-state-invalid", error);
}
if (payload.revision !== index || payload.previous_revision_sha256 !== previousHash) {
throw new AgenziaAcquisitionError("resume-revision-chain-invalid");
}
previousHash = sha256(bytes);
state = payload;
}
return {
state,
context: { runDirectory, nextRevision: names.length, previousHash }
};
}
async function verifyRetainedArtifacts(state, archiveRoot) {
for (const document of state.documents) {
if (document.original) {
await verifyArchivedArtifact(document.original.original, { allowedRoot: archiveRoot });
if (document.original.extracted_xml) {
await verifyArchivedArtifact(document.original.extracted_xml, { allowedRoot: archiveRoot });
}
}
if (document.pdf) await verifyArchivedArtifact(document.pdf, { allowedRoot: archiveRoot });
}
}
async function observeBrowserDownload({ tab, control, downloadDirectory, timeoutMs }) {
if (typeof tab.playwright.waitForEvent !== "function") {
throw new AgenziaAcquisitionError("download-event-api-unavailable");
}
const observation = await observeDownloadDirectory(downloadDirectory);
try {
const eventPromise = Promise.resolve(tab.playwright.waitForEvent("download", { timeoutMs })).then(
(download) => ({ download, error: null }),
(error) => ({ download: null, error })
);
await control.click({ timeoutMs });
const event = await eventPromise;
if (event.error || !event.download) {
throw new AgenziaAcquisitionError("download-event-not-observed", event.error);
}
return await observation.wait({ timeoutMs });
} finally {
await observation.close();
}
}
async function acquireOriginal({ tab, plan, documentKey, downloadDirectory, archiveRoot, timeoutMs }) {
const control = await uniqueRoleChoice(
tab,
["button", "link"],
"download file fattura",
"original-download-control-not-unique"
);
const evidence = await observeBrowserDownload({ tab, control, downloadDirectory, timeoutMs });
return archiveInvoiceOriginal({
evidence,
archiveRoot,
year: plan.year,
category: plan.category,
documentKey
});
}
async function acquireNativePdf({
tab,
plan,
documentKey,
page,
row,
nativePdfDirectory,
archiveRoot,
timeoutMs,
onNativePdf
}) {
if (typeof onNativePdf !== "function") {
throw new AgenziaAcquisitionError("native-pdf-handoff-required");
}
const viewControl = await uniqueRoleChoice(
tab,
["button", "link"],
"visualizza file fattura",
"invoice-view-control-not-unique"
);
await viewControl.click({ timeoutMs });
await waitUntil(tab, async () =>
(await tab.playwright.getByRole("button", { name: /Stampa/i }).count()) +
(await tab.playwright.getByRole("link", { name: /Stampa/i }).count()) > 0,
"print-control-not-ready", timeoutMs);
await assertAllowedOrigin(tab);
const printControl = await uniqueRoleChoice(tab, ["button", "link"], "Stampa", "print-control-not-unique");
const observation = await observeDownloadDirectory(nativePdfDirectory);
try {
let printClicked = false;
const trackedPrintControl = {
click: async (options) => {
await printControl.click(options);
printClicked = true;
}
};
const handoff = await onNativePdf({
tab,
printControl: trackedPrintControl,
category: plan.category,
year: plan.year,
page,
row
});
if (!printClicked) throw new AgenziaAcquisitionError("native-pdf-print-not-invoked");
if (handoff?.completed !== true) throw new AgenziaAcquisitionError("native-pdf-not-completed");
const evidence = await observation.wait({ timeoutMs });
return archiveNativePdf({
evidence,
archiveRoot,
year: plan.year,
category: plan.category,
documentKey
});
} finally {
await observation.close();
}
}
async function openSearch(tab, plan, timeoutMs) {
await assertAllowedOrigin(tab);
const home = await uniqueRoleChoice(tab, ["link", "button"], "Home consultazione", "home-control-not-unique");
await home.click({ timeoutMs });
await assertAllowedOrigin(tab);
const category = await unique(
tab.playwright.getByRole("link", { name: partialName(plan.accessibleName) }),
"category-control-not-unique"
);
await category.click({ timeoutMs });
await assertAllowedOrigin(tab);
const from = await labeledTextbox(tab, plan.labels.from, "date-from-control-not-unique");
const to = await labeledTextbox(tab, plan.labels.to, "date-to-control-not-unique");
await from.fill(plan.from, { timeoutMs });
await to.fill(plan.to, { timeoutMs });
const search = await uniqueRoleChoice(tab, ["button", "link"], "Cerca", "search-control-not-unique");
await search.click({ timeoutMs });
await assertAllowedOrigin(tab);
}
function detailLinks(tab) {
return tab.playwright.getByRole("link", { name: /Dettaglio fattura/i });
}
async function detailReady(tab, plan) {
if (plan.formats.original === "available") {
return (await tab.playwright.getByRole("button", { name: /download file fattura/i }).count()) +
(await tab.playwright.getByRole("link", { name: /download file fattura/i }).count()) > 0;
}
if (plan.formats.pdf === "native_gap") {
return (await tab.playwright.getByRole("button", { name: /visualizza file fattura/i }).count()) +
(await tab.playwright.getByRole("link", { name: /visualizza file fattura/i }).count()) > 0;
}
return (await tab.playwright.getByRole("link", { name: /Torna alla pagina precedente/i }).count()) +
(await tab.playwright.getByRole("button", { name: /Torna alla pagina precedente/i }).count()) > 0;
}
async function resultState(tab, timeoutMs) {
const details = detailLinks(tab);
const empty = tab.playwright.getByRole("heading", { name: /Fatture individuate\s*\(0\)/i });
await waitUntil(tab, async () => (await details.count()) > 0 || (await empty.count()) > 0, "invoice-results-not-ready", timeoutMs);
return (await details.count()) === 0 ? "empty" : "results";
}
async function nextDisabled(locator) {
return locator.evaluate((element) => {
const classes = String(element.parentElement?.className ?? "").split(/\s+/).filter(Boolean);
return classes.includes("disabled") || element.getAttribute("aria-disabled") === "true";
});
}
function documentReady(document, plan) {
return (plan.formats.original !== "available" || document.original) &&
(plan.formats.pdf !== "native_gap" || document.pdf);
}
async function processPlan({
tab,
plan,
state,
context,
downloadDirectory,
nativePdfDirectory,
archiveRoot,
timeoutMs,
maxPages,
maxInvoices,
onNativePdf,
onProgress
}) {
const request = state.requests[plan.key];
await openSearch(tab, plan, timeoutMs);
const currentResult = await resultState(tab, timeoutMs);
if (currentResult === "empty") {
request.pages_visited = 1;
request.observed_count = 0;
if (plan.expectedCount !== 0) throw new AgenziaAcquisitionError("invoice-population-incomplete");
request.status = "completed";
await persistRevision(context, state);
return;
}
const existing = new Map(
state.documents.filter((item) => item.request_key === plan.key).map((item) => [item.detail_sha256, item])
);
const seen = new Set();
let page = 0;
let paginationComplete = false;
while (page < maxPages) {
page += 1;
request.pages_visited = page;
await assertAllowedOrigin(tab);
const links = detailLinks(tab);
const count = await links.count();
if (count < 1) throw new AgenziaAcquisitionError("invoice-list-empty-unexpectedly");
const firstHref = await links.nth(0).getAttribute("href");
for (let row = 0; row < count; row += 1) {
if (seen.size >= maxInvoices) throw new AgenziaAcquisitionError("invoice-limit-exceeded");
await assertAllowedOrigin(tab);
await detailLinks(tab).nth(row).click({ timeoutMs });
await waitUntil(tab, () => detailReady(tab, plan), "invoice-detail-not-ready", timeoutMs);
const detailKey = sha256(await tab.url());
if (seen.has(detailKey)) throw new AgenziaAcquisitionError("invoice-identity-not-unique");
seen.add(detailKey);
let document = existing.get(detailKey);
if (!document) {
document = {
request_key: plan.key,
category: plan.category,
year: plan.year,
detail_sha256: detailKey,
first_seen: { page, row: row + 1 },
original: null,
pdf: null,
format_status: {
original: plan.formats.original === "unavailable" ? "unavailable" : "pending",
pdf: plan.formats.pdf === "unavailable" ? "unavailable" : "pending"
}
};
state.documents.push(document);
existing.set(detailKey, document);
await persistRevision(context, state);
}
if (plan.formats.original === "available" && !document.original) {
document.original = await acquireOriginal({
tab,
plan,
documentKey: detailKey,
downloadDirectory,
archiveRoot,
timeoutMs
});
document.format_status.original = "verified";
await persistRevision(context, state);
}
if (plan.formats.pdf === "native_gap" && !document.pdf) {
if (!state.native_gaps.includes("operator-save-as-pdf")) {
state.native_gaps.push("operator-save-as-pdf");
}
document.pdf = await acquireNativePdf({
tab,
plan,
documentKey: detailKey,
page,
row: row + 1,
nativePdfDirectory,
archiveRoot,
timeoutMs,
onNativePdf
});
document.format_status.pdf = "verified_native_gap";
await persistRevision(context, state);
}
onProgress({ event: "invoice-processed", category: plan.category, year: plan.year, completed: seen.size });
await assertAllowedOrigin(tab);
const back = await uniqueRoleChoice(
tab,
["link", "button"],
"Torna alla pagina precedente",
"back-to-list-control-not-unique"
);
await back.click({ timeoutMs });
await waitUntil(tab, async () => (await detailLinks(tab).count()) > 0, "invoice-list-not-restored", timeoutMs);
}
const next = tab.playwright.getByRole("link", { name: partialName("Pagina successiva") });
const nextCount = await next.count();
if (nextCount === 0 || (nextCount === 1 && (await nextDisabled(next)))) {
paginationComplete = true;
break;
}
if (nextCount !== 1) throw new AgenziaAcquisitionError("next-page-control-not-unique");
await next.click({ timeoutMs });
await waitUntil(tab, async () => {
const current = detailLinks(tab);
return (await current.count()) > 0 && (await current.nth(0).getAttribute("href")) !== firstHref;
}, "next-page-not-loaded", timeoutMs);
}
if (!paginationComplete) throw new AgenziaAcquisitionError("page-limit-exceeded");
request.observed_count = seen.size;
if (seen.size !== plan.expectedCount) throw new AgenziaAcquisitionError("invoice-population-incomplete");
const documents = state.documents.filter((item) => item.request_key === plan.key && seen.has(item.detail_sha256));
if (documents.length !== plan.expectedCount || documents.some((item) => !documentReady(item, plan))) {
throw new AgenziaAcquisitionError("invoice-artifacts-incomplete");
}
request.status = "completed";
await persistRevision(context, state);
}
function failureCode(error) {
return browserFailureCode(error) ??
(error instanceof DownloadDirectoryError ? error.evidenceCode : null) ??
(error instanceof AgenziaArtifactError ? error.code : null) ??
(error instanceof AgenziaAcquisitionError ? error.code : "invoice-acquisition-failed");
}
function summaries(state) {
return Object.values(state.requests).map((request) => {
const documents = state.documents.filter((item) => item.request_key === request.key);
return {
category: request.category,
year: request.year,
status: request.status,
expected_count: request.expected_count,
observed_count: request.observed_count,
completed_count: documents.filter((item) => item.format_status.original !== "pending" && item.format_status.pdf !== "pending").length,
missing_count: Math.max(0, request.expected_count - documents.filter((item) => item.format_status.original !== "pending" && item.format_status.pdf !== "pending").length),
pages_visited: request.pages_visited,
formats: request.formats
};
});
}
/** Acquire exact category/year plans, retaining verified artifacts across a bounded resume. */
export async function acquireAgenziaInvoices({
tab,
categoryPlan,
downloadDirectory,
nativePdfDirectory = downloadDirectory,
runDirectory,
resume = false,
timeoutMs = 30_000,
maxPages = 100,
maxInvoices = 10_000,
executionMode = "unverified",
onNativePdf = null,
onProgress = () => {}
}) {
if (!downloadDirectory || !runDirectory) throw new AgenziaAcquisitionError("local-directories-required");
if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 1000) {
throw new AgenziaAcquisitionError("invalid-page-limit");
}
if (!Number.isInteger(maxInvoices) || maxInvoices < 1 || maxInvoices > 100_000) {
throw new AgenziaAcquisitionError("invalid-invoice-limit");
}
if (!EXECUTION_MODES.has(executionMode)) throw new AgenziaAcquisitionError("invalid-execution-mode");
const plans = normalizePlan(categoryPlan);
if (plans.reduce((total, plan) => total + plan.expectedCount, 0) > maxInvoices) {
throw new AgenziaAcquisitionError("invoice-limit-exceeded");
}
const planHash = sha256(Buffer.from(JSON.stringify(canonical(plans)), "utf8"));
const privateRunDirectory = resolve(runDirectory);
let state;
let context;
if (resume) {
({ state, context } = await loadState(privateRunDirectory));
if (state.plan_sha256 !== planHash || state.execution_mode !== executionMode) {
throw new AgenziaAcquisitionError("resume-input-mismatch");
}
await verifyRetainedArtifacts(state, join(privateRunDirectory, "archive"));
state.attempt += 1;
state.status = "running";
state.error = null;
} else {
await mkdir(dirname(privateRunDirectory), { recursive: true, mode: 0o700 });
await mkdir(privateRunDirectory, { mode: 0o700 });
await chmod(privateRunDirectory, 0o700);
state = {
schema_version: "agenzia-invoice-acquisition-output/v1",
execution_mode: executionMode,
validation_status: "prototype",
browser_validation_eligible: true,
status: "running",
attempt: 1,
revision: -1,
previous_revision_sha256: null,
plan_sha256: planHash,
requests: Object.fromEntries(plans.map((plan) => [plan.key, {
key: plan.key,
category: plan.category,
year: plan.year,
expected_count: plan.expectedCount,
observed_count: 0,
pages_visited: 0,
formats: plan.formats,
status: "pending"
}])),
documents: [],
native_gaps: [],
error: null
};
context = { runDirectory: privateRunDirectory, nextRevision: 0, previousHash: null };
}
await persistRevision(context, state);
try {
if (!tab?.playwright || typeof tab.url !== "function") {
throw new AgenziaAcquisitionError("connected-tab-required");
}
const archiveRoot = join(privateRunDirectory, "archive");
for (const plan of plans) {
await processPlan({
tab,
plan,
state,
context,
downloadDirectory: resolve(downloadDirectory),
nativePdfDirectory: resolve(nativePdfDirectory),
archiveRoot,
timeoutMs,
maxPages,
maxInvoices,
onNativePdf,
onProgress
});
}
state.status = "completed";
} catch (error) {
state.status = "failed";
state.error = {
code: failureCode(error),
detail_sha256: sha256(Buffer.from(error instanceof Error ? error.message : String(error), "utf8"))
};
}
state.browser_validation_eligible = state.native_gaps.length === 0;
await persistRevision(context, state);
const reportPath = join(privateRunDirectory, `attempt-${String(state.attempt).padStart(6, "0")}.outputs.json`);
await writeFile(reportPath, `${JSON.stringify(state, null, 2)}\n`, { flag: "wx", mode: 0o600 });
const requestSummaries = summaries(state);
return {
status: state.status,
execution_mode: executionMode,
validation_status: "prototype",
browser_validation_eligible: state.browser_validation_eligible,
error: state.error,
report_path: reportPath,
request_summaries: requestSummaries,
total_documents: state.documents.length,
missing_documents: requestSummaries.reduce((total, item) => total + item.missing_count, 0),
native_gaps: [...state.native_gaps]
};
}
SHA-256: 685220a935fb728194d3cb3c1a676c1e50d1eace5351efe97b4c38a29ca22135