SDK Node.js per la conversione file#

@enconvert/node-sdk è il client JavaScript e TypeScript ufficiale per l'API EnConvert. Tredici metodi di conversione tipizzati corrispondono 1:1 agli endpoint REST come POST /v1/convert/url-to-pdf, e un secondo namespace, client.v2, aggiunge la web intelligence: percepire un URL in artefatti pronti per gli agenti, scoprire gli URL di un sito, eseguire una ricerca web, distillare dati strutturati, ingerire un sito in JSONL pronto per RAG e sorvegliare le pagine alla ricerca di cambiamenti. Richiede Node.js 18+ con zero dipendenze runtime, è costruito su fetch, FormData e node:stream nativi e recupera in modo trasparente i timeout del reverse-proxy facendo polling sullo stato del job. Viene distribuito con build ESM e CJS duali e dichiarazioni TypeScript complete.

npm: @enconvert/node-sdk · Sorgente: enconvert/node-sdk · Node: 18+

Installazione#

npm install @enconvert/node-sdk
pnpm add @enconvert/node-sdk
yarn add @enconvert/node-sdk

Guida rapida#

import { Enconvert } from "@enconvert/node-sdk";

const client = new Enconvert({ apiKey: process.env.ENCONVERT_API_KEY! });

// V1: converte un URL in PDF e lo scrive su disco in streaming.
const result = await client.convertUrlToPdf("https://example.com", {
    saveTo: "page.pdf",
});
console.log(result.presignedUrl);

// V2: legge una pagina come dovrebbe farlo il tuo agente, con un punteggio di qualità allegato.
const op = await client.v2.perceive("https://example.com", {
    outputs: ["markdown", "structured"],
});
console.log(op.outputs.markdown.url, op.renderQuality); // ad es. 0.93

L'SDK funziona su ogni runtime Node moderno (Node 18+, Bun, Deno tramite lo specificatore npm). È solo lato server, quindi non includere la tua chiave API privata nel bundle di un'applicazione browser.


Cosa espone il client#

Un solo client, due superfici. Entrambe si raggiungono dalla stessa istanza Enconvert e condividono un'unica chiave API.

Superficie Come si raggiunge Cosa copre
Conversione file client.convertUrlToPdf(...), client.convertImage(...) e così via Tredici metodi tipizzati per il rendering di URL, la conversione di immagini, la compressione di immagini, la conversione di documenti, più il polling dei job e dei batch su interi siti. Vedi Conversione file.
Web intelligence (V2) client.v2.perceive(...), client.v2.distill(...) e così via Ventitré metodi su sei capacità: perceive, discover, lookup, distill, ingest, watch. Vedi Web intelligence (V2).

Gli endpoint V2 richiedono una chiave API privata (sk_...); le chiavi pubbliche vengono rifiutate. Consulta Autenticazione per capire in cosa differiscono i due tipi di chiave, e la V1 e V2 per la superficie REST che sta dietro a client.v2.


Conversione file#

La superficie di conversione espone tredici metodi che corrispondono 1:1 all'API REST:

Metodo Endpoint Restituisce
convertUrlToPdf(url, options?) POST /v1/convert/url-to-pdf ConversionResult
convertUrlToScreenshot(url, options?) POST /v1/convert/url-to-screenshot ConversionResult
convertUrlToMarkdown(url, options?) POST /v1/convert/url-to-markdown ConversionResult
convertImage(file, options) POST /v1/convert/{from}-to-{to} ConversionResult
convertDocument(file, options?) POST /v1/convert/{from}-to-{to} ConversionResult
compressImage(file, options?) POST /v1/convert/compress-image ConversionResult
convertToMarkdown(file, options?) POST /v1/convert/anything-to-markdown ConversionResult
convertToPdf(file, options?) POST /v1/convert/anything-to-pdf ConversionResult
getJobStatus(jobId) GET /v1/convert/status/{jobId} JobStatus
convertWebsiteToPdf(url, options?) POST /v1/convert/website-to-pdf BatchSubmission
convertWebsiteToScreenshot(url, options?) POST /v1/convert/website-to-screenshot BatchSubmission
getBatchStatus(batchId) GET /v1/convert/batch/{batchId} BatchStatus
waitForBatch(batchId, options?) GET /v1/convert/batch/{batchId} (con polling) BatchStatus

Ogni metodo restituisce una promise tipizzata. Tutti i campi delle opzioni sono facoltativi se non indicato diversamente. Gli ultimi quattro sono helper batch per interi siti: inviano e interrogano job asincroni, quindi restituiscono un BatchSubmission o un BatchStatus invece di un ConversionResult.


convertUrlToPdf#

Esegue il rendering in PDF di qualsiasi URL pubblico.

const result = await client.convertUrlToPdf("https://example.com", {
    pdfOptions: { pageSize: "A4", orientation: "landscape" },
    singlePage: false,
    viewportWidth: 1440,
    saveTo: "report.pdf",
});
Opzione Tipo Default Descrizione
saveTo string -- Percorso locale su cui scrivere il PDF in streaming. Le directory superiori vengono create automaticamente.
singlePage boolean true true produce una sola pagina continua. false impagina usando pdfOptions.pageSize.
pdfOptions PdfOptions -- Dimensione pagina, orientamento, margini, scala, scala di grigi, intestazione e piè di pagina. Vedi Opzioni PDF.
viewportWidth number 1920 Larghezza del viewport del browser in pixel.
viewportHeight number 1080 Altezza del viewport del browser in pixel.
loadMedia boolean true Attende immagini e video prima della cattura.
enableScroll boolean true Scorre dall'alto in basso per far scattare i lazy loader.
outputFilename string automatico Sovrascrive il nome file generato. .pdf viene aggiunto se manca.

convertUrlToScreenshot#

Cattura un PNG a pagina intera di qualsiasi URL.

const result = await client.convertUrlToScreenshot("https://example.com", {
    viewportWidth: 1440,
    saveTo: "screenshot.png",
});

Accetta le stesse opzioni di viewport, media, scroll e nome file di convertUrlToPdf (esclusi singlePage e pdfOptions).


convertUrlToMarkdown#

Estrae Markdown GitHub-Flavored pulito da qualsiasi URL. Il convertitore rimuove navigazione, piè di pagina, pubblicità e script, mantiene il corpo principale dell'articolo e antepone un frontmatter YAML (titolo, descrizione, url, link, immagini).

const result = await client.convertUrlToMarkdown("https://example.com/article", {
    saveTo: "article.md",
});

Utile per costruire pipeline RAG, importare contenuti di terze parti in un CMS o generare dati di addestramento. Se vuoi un punteggio di qualità del render insieme al Markdown, usa invece client.v2.perceive.


convertImage#

Converte tra jpeg, png, svg, heic e webp.

// Da un percorso
await client.convertImage("photo.heic", {
    outputFormat: "webp",
    saveTo: "photo.webp",
});

// Da byte
import { readFile } from "node:fs/promises";
const buf = await readFile("photo.heic");

await client.convertImage(
    { data: buf, filename: "photo.heic" },
    { outputFormat: "webp", saveTo: "photo.webp" },
);

// Rasterizza un SVG a una larghezza fissa
await client.convertImage("logo.svg", { outputFormat: "png", width: 512, saveTo: "logo.png" });

Il formato di input viene rilevato dall'estensione del percorso o del nome file. Il formato di output è obbligatorio.

Opzione Tipo Obbligatoria Descrizione
outputFormat string Formato di destinazione: jpeg, png, svg, heic o webp (e jpeg per un input .pdf). Gli alias jpg, yml, htm e md vengono normalizzati. Le coppie non supportate sollevano un errore prima che la richiesta parta.
saveTo string -- Percorso locale su cui scrivere il risultato in streaming.
outputFilename string -- Sovrascrive il nome file generato.
width number -- Solo per input SVG (svg-to-png, svg-to-jpeg, svg-to-webp), da 1 a 10000. Da sola scala in modo proporzionale, ricavando l'altezza dalle proporzioni dell'SVG.
height number -- Solo per input SVG (svg-to-png, svg-to-jpeg, svg-to-webp), da 1 a 10000. Da sola scala in modo proporzionale, ricavando la larghezza dalle proporzioni dell'SVG.

Imposta sia width sia height per fissare un canvas esatto, cosa che può cambiare le proporzioni. Ometti entrambe e l'output conserva la larghezza, l'altezza o il viewBox intrinseci dell'SVG. Il totale dei pixel in uscita è limitato a 25.000.000. Nessuna delle due opzioni è accettata da svg-to-heic, e l'SDK solleva un errore prima di inviare la richiesta se le passi a qualsiasi altra conversione.


convertDocument#

Converte documenti e formati dati. Il valore predefinito di outputFormat è "pdf".

// docx in pdf
await client.convertDocument("report.docx", { saveTo: "report.pdf" });

// json in yaml
await client.convertDocument("data.json", {
    outputFormat: "yaml",
    saveTo: "data.yaml",
});

// markdown in pdf con impostazioni di pagina personalizzate
await client.convertDocument("README.md", {
    outputFormat: "pdf",
    pdfOptions: { pageSize: "A4", margins: { top: 20, bottom: 20, left: 25, right: 25 } },
    saveTo: "readme.pdf",
});

Input supportati: doc, docx, xls, xlsx, ppt, pptx, html, htm, odt, ods, odp, ots, pages, numbers, markdown (.md, .markdown), csv, json, xml, yaml (.yaml, .yml), toml.

EPUB non ha una coppia di conversione documenti dedicata. Passa i file .epub attraverso convertToPdf o convertToMarkdown.

Opzione Tipo Default Descrizione
outputFormat string "pdf" Formato di destinazione.
saveTo string -- Percorso locale su cui scrivere il risultato in streaming.
outputFilename string -- Sovrascrive il nome file generato.
pdfOptions PdfOptions -- Impostazioni di pagina. Considerate solo quando l'output è PDF.

compressImage#

Riduce un PNG, un JPEG o un WebP senza cambiarne il formato.

// Solo passaggio lossless
const result = await client.compressImage("photo.jpg", { saveTo: "photo-small.jpg" });

// Punta a un budget di 200 KB
const capped = await client.compressImage("photo.jpg", {
    targetSizeKb: 200,
    saveTo: "photo-capped.jpg",
});

console.log(capped.fileSize);

Input supportati: .png, .jpg, .jpeg, .webp.

L'output conserva il formato e l'estensione dell'input, quindi non c'è un formato di destinazione da scegliere. Il primo stadio è lossless: i metadati vengono rimossi, il profilo ICC e l'orientamento EXIF vengono preservati, e il risultato non è mai più grande dell'input. Impostare targetSizeKb aggiunge un secondo stadio che riduce le dimensioni mantenendo bloccate le proporzioni finché il budget non è rispettato. Quel target è "best effort": un budget irraggiungibile restituisce il file più piccolo ottenuto invece di un errore, quindi controlla result.fileSize. APNG animate e WebP animate vengono rifiutate con 400, e il canvas decodificato è limitato a 40.000.000 di pixel.

Opzione Tipo Obbligatoria Descrizione
targetSizeKb number -- Budget di dimensione in KB, intero, minimo 1. Omettilo per eseguire solo il passaggio lossless.
saveTo string -- Percorso locale su cui scrivere il risultato in streaming.
outputFilename string -- Sovrascrive il nome file generato. L'estensione dell'input viene mantenuta.

convertToMarkdown#

Converte in Markdown qualsiasi documento, foglio di calcolo, presentazione, ebook, file web o file di testo semplice supportato.

await client.convertToMarkdown("handbook.docx", {
    saveTo: "handbook.md",
});

Input supportati (22): .csv, .doc, .docx, .epub, .htm, .html, .markdown, .md, .mdown, .mkd, .odp, .ods, .odt, .pdf, .ppt, .pptx, .rtf, .text, .txt, .xhtml, .xls, .xlsx.

L'output è un unico file .md consapevole delle intestazioni e pensato per il chunking RAG: la gerarchia di intestazioni del documento sopravvive alla conversione, quindi un chunker semantico può dividere sulle intestazioni invece che su conteggi arbitrari di caratteri. Su questo endpoint non ci sono opzioni PDF. Qualsiasi altra estensione solleva un errore prima che venga fatta una richiesta.

Opzione Tipo Obbligatoria Descrizione
saveTo string -- Percorso locale su cui scrivere il Markdown in streaming.
outputFilename string -- Sovrascrive il nome file generato.

Se vuoi che anche il chunking sia fatto per te, passa gli stessi file a client.v2.ingestFiles.


convertToPdf#

Converte in PDF qualsiasi documento, immagine, ebook, file web o file di testo semplice supportato.

// docx in pdf
await client.convertToPdf("contract.docx", { saveTo: "contract.pdf" });

// html in pdf con geometria di pagina completa
await client.convertToPdf("invoice.html", {
    pdfOptions: { pageSize: "A4", margins: { top: 15, bottom: 15, left: 15, right: 15 } },
    saveTo: "invoice.pdf",
});

// pdf in pdf in scala di grigi (passthrough)
await client.convertToPdf("scan.pdf", {
    pdfOptions: { grayscale: true },
    saveTo: "scan-gray.pdf",
});

Input supportati (36): .bmp, .csv, .doc, .docx, .epub, .gif, .heic, .heif, .htm, .html, .jpeg, .jpg, .markdown, .md, .mdown, .mkd, .numbers, .odp, .ods, .odt, .ots, .pages, .pdf, .png, .ppt, .pptx, .rtf, .svg, .text, .tif, .tiff, .txt, .webp, .xhtml, .xls, .xlsx.

Un input .pdf viene accettato e restituito così com'è, quindi con pdfOptions: { grayscale: true } questo metodo funziona anche come percorso di normalizzazione PDF. Anche EPUB viene gestito qui, dato che non ha una coppia di conversione documenti dedicata. Qualsiasi altra estensione solleva un errore prima che venga fatta una richiesta.

La geometria dipende dall'input. La geometria di pagina completa (dimensione pagina, larghezza e altezza pagina, orientamento, margini, scala, intestazione, piè di pagina) viene rispettata per input HTML (.html, .htm, .xhtml), Markdown, testo semplice, EPUB, immagini e SVG. Gli input Office, ODF, iWork, RTF e CSV, oltre al passthrough PDF, supportano solo grayscale e restituiscono 400 se viene impostata un'opzione di geometria esplicita. grayscale è invece rispettato per ogni tipo di input.
Opzione Tipo Obbligatoria Descrizione
saveTo string -- Percorso locale su cui scrivere il PDF in streaming.
outputFilename string -- Sovrascrive il nome file generato. .pdf viene aggiunto se manca.
pdfOptions PdfOptions -- Impostazioni di pagina. Vedi l'avvertenza qui sopra su quali input rispettano la geometria.

getJobStatus#

Interroga lo stato di un job asincrono o recuperato.

const status = await client.getJobStatus("job_abc123");

if (status.status === "success") {
    console.log(status.presignedUrl);
} else if (status.status === "failed") {
    console.error(status.error);
}

Restituisce { status: "processing" | "success" | "failed", presignedUrl?, objectKey?, error? }.

Di solito non serve chiamarlo direttamente. L'SDK esegue il polling in automatico quando una richiesta sincrona restituisce 5xx. Vedi Recupero dei timeout più sotto.

Helper batch per interi siti#

convertWebsiteToPdf e convertWebsiteToScreenshot individuano le pagine di un sito, le accodano tutte e raccolgono gli output in un unico ZIP. Entrambi restituiscono subito un BatchSubmission; interroga con getBatchStatus oppure bloccati con waitForBatch. Le opzioni condivise sono crawlMode ("auto", "sitemap" o "full"), includePatterns, excludePatterns, notificationEmail e callbackUrl; convertWebsiteToPdf aggiunge singlePage e pdfOptions.

const batch = await client.convertWebsiteToPdf("https://example.com", {
    crawlMode: "sitemap",
    excludePatterns: ["/tag/"],
});

const done = await client.waitForBatch(batch.batchId, { saveTo: "site.zip" });
console.log(done.status, done.completed, done.failed, done.zipDownloadUrl);

waitForBatch accetta intervalMs (default 5_000), timeoutMs (default 1_800_000, trenta minuti) e saveTo. Solleva APIError(504, ...) se la scadenza viene superata. Vedi la panoramica degli endpoint per la superficie REST.


Web intelligence (V2)#

Tutto ciò che sta sotto client.v2 restituisce dati di cui un agente può fidarsi, perché ogni render V2 porta con sé un punteggio renderQuality da 0.0 a 1.0. Una pagina bloccata, una sfida anti-bot, un muro di login, una pagina di errore HTTP, un soft 404 o uno shell SPA vuoto tornano con un punteggio basso più deductions e warnings con un nome, così vengono segnalati invece di essere scambiati per contenuto reale. Il contenuto viene comunque restituito; sta a te decidere cosa farne. Punteggi sotto circa 0.40 significano che il render non è riuscito in alcun senso utile.

Ventitré metodi su sei capacità:

Metodo Endpoint Restituisce
v2.perceive(url, options?) POST /v2/perceive PerceiveResult
v2.perceiveDirect(url, options?) POST /v2/perceive PerceiveDirectResult
v2.getPerceiveOperation(operationId) GET /v2/perceive/{operationId} PerceiveResult
v2.downloadPerceiveArtifact(operationId, output?) GET /v2/perceive/{operationId} PerceiveDirectResult
v2.perceiveBatch(urls, options?) POST /v2/perceive/batch PerceiveBatchResult
v2.getPerceiveBatch(jobId) GET /v2/perceive/batch/{jobId} PerceiveBatchResult
v2.discover(url, options?) POST /v2/discover DiscoverResult
v2.lookup(query, options?) POST /v2/lookup LookupResult
v2.distill(options) POST /v2/distill DistillResult
v2.ingest(options) POST /v2/ingest IngestJob
v2.ingestFiles(files, options?) POST /v2/ingest/files IngestJob
v2.listIngestJobs(options?) GET /v2/ingest IngestJobList
v2.getIngestJob(jobId) GET /v2/ingest/{jobId} IngestJob
v2.cancelIngestJob(jobId) DELETE /v2/ingest/{jobId} IngestJob
v2.retryIngestWebhook(jobId) POST /v2/ingest/{jobId}/retry-webhook WebhookRetryResult
v2.getWebhookSecret() GET /v2/ingest/webhook-secret WebhookSecret
v2.rotateWebhookSecret() POST /v2/ingest/webhook-secret/rotate WebhookSecret
v2.createWatcher(url, options?) POST /v2/watch Watcher
v2.listWatchers(options?) GET /v2/watch WatcherList
v2.getWatcher(watcherId) GET /v2/watch/{watcherId} Watcher
v2.getWatcherSnapshots(watcherId, options?) GET /v2/watch/{watcherId}/snapshots WatcherSnapshotList
v2.updateWatcher(watcherId, updates) PATCH /v2/watch/{watcherId} Watcher
v2.deleteWatcher(watcherId) DELETE /v2/watch/{watcherId} Watcher

Le opzioni sono in camelCase sulla superficie dell'SDK e vengono serializzate nel formato wire snake_case dell'API; le risposte vengono rimappate in camelCase. I tuoi payload (schemi di estrazione, dati estratti, campi tracciati, voci di diff) passano invariati.


Perceive#

Esegue il rendering di un URL negli artefatti che richiedi: Markdown, HTML pulito o grezzo, uno screenshot del viewport o a pagina intera, un PDF, un elenco di link, un elenco di immagini o dati strutturati. perceive è sincrono e restituisce l'operazione completata con URL firmati per gli artefatti validi 15 minuti. Riferimento completo: Perceive.

const op = await client.v2.perceive("https://example.com", {
    outputs: ["markdown", "screenshot", "structured"],
    extract: ["tables", "metadata"],
    onlyMainContent: true,
    waitFor: "css:.article-body",
    viewport: { width: 1440, height: 900 },
});

console.log(op.renderQuality);        // da 0.0 a 1.0
console.log(op.deductions);           // ad es. { http_error: 0.7 }
console.log(op.outputs.markdown.url); // URL firmato, 15 minuti
console.log(op.structured);

if ((op.renderQuality ?? 0) < 0.4) {
    console.warn("Bad read, do not feed this to the model:", op.warnings);
}

// Rifirma gli URL degli artefatti più tardi senza rieseguire il rendering:
const again = await client.v2.getPerceiveOperation(op.operationId);
Opzione Tipo Default Descrizione
outputs PerceiveOutputName[] ["markdown", "structured"] Uno o più tra markdown, html_cleaned, html_raw, screenshot, screenshot_full_page, pdf, links, images, structured.
extract PerceiveExtractName[] -- Target euristici: tables, prices, contacts, metadata, main_content, headings, structured_data, technologies, all.
onlyMainContent boolean true Rimuove navigazione, header, footer e banner dei cookie dall'output Markdown, protetto da una verifica di fedeltà. false restituisce la pagina intera intatta.
schema Record<string, unknown> -- Schema JSON per l'estrazione strutturata sul tier LLM.
waitFor string -- Selettore CSS (facoltativamente "css:...") o "js:<expr>" da attendere prima della cattura.
waitTimeoutMs number 30000 Da 0 a 60000.
jsCode string -- JavaScript eseguito dopo la navigazione. Massimo 20000 caratteri.
viewport { width?, height? } 1920 x 1080 Larghezza da 320 a 3840, altezza da 240 a 2160.
headers Record<string, string> -- Header di richiesta aggiuntivi.
cookies BrowserCookie[] -- Cookie iniettati prima del rendering. Ognuno richiede name, value e o domain o url.
auth { username, password } -- HTTP Basic Auth.
cacheMode "enabled" \| "bypass" \| "refresh" "enabled" Cache di un'ora. bypass la salta, refresh forza un nuovo rendering.
pdfOptions PdfOptions -- Ha senso solo quando outputs include "pdf". Vedi Opzioni PDF.
blockResources PerceiveResourceType[] -- Tipi di risorsa che il browser non deve caricare, per esempio ["image", "font", "media"].
respectRobots boolean -- Rispetta le regole robots del sito.
mobile boolean -- Esegue il rendering con un profilo mobile.
directDownload boolean -- Solo per perceive. Preferisci perceiveDirect, che la imposta al posto tuo.
Tre opzioni sono dichiarate ma non ancora attive. proxyUrl, geolocation e actionChain sono tipizzate su PerceiveOptions ma al momento vengono rifiutate lato server con 422. Sono riservate, non utilizzabili.

Download diretto. perceiveDirect salta il giro di andata e ritorno con l'URL firmato: il corpo della risposta HTTP contiene i byte dell'artefatto e i metadati viaggiano negli header. Richiede esattamente un output che produca un artefatto, e l'SDK solleva un errore in locale prima di inviare se non è così ("structured" può viaggiare insieme, ma resta inline lato server e non viene restituito).

import { writeFile } from "node:fs/promises";

const direct = await client.v2.perceiveDirect("https://example.com", { outputs: ["markdown"] });
console.log(direct.contentType, direct.renderQuality, direct.sourceStatusCode);
await writeFile(direct.filename ?? "page.md", direct.content);

// Riscarica come byte grezzi un artefatto salvato da un'operazione precedente.
// `output` si può omettere quando l'operazione ha prodotto esattamente un artefatto.
const raw = await client.v2.downloadPerceiveArtifact(op.operationId, "markdown");

downloadPerceiveArtifact restituisce 410 quando l'artefatto salvato è scaduto, e 400 (elencando gli output disponibili) se l'operazione ha prodotto più di un artefatto e hai omesso output.

Batch. perceiveBatch accetta fino a 1000 URL con un unico blocco di opzioni condivise. I batch piccoli girano inline e tornano già completati; quelli più grandi restituiscono lo stato "queued", quindi interroga getPerceiveBatch con il jobId restituito.

const batch = await client.v2.perceiveBatch(["https://example.com/a", "https://example.com/b"], {
    outputs: ["markdown"],
    outputMode: "zip",
});

let job = await client.v2.getPerceiveBatch(batch.jobId);
while (job.status === "queued" || job.status === "processing") {
    await new Promise((r) => setTimeout(r, 3000));
    job = await client.v2.getPerceiveBatch(batch.jobId);
}
console.log(job.completed, job.failed, job.zip?.url);

outputMode è "manifest" (predefinito, una voce per URL in items) oppure "zip" (tutti gli artefatti riusciti raccolti insieme una volta finito il job). L'endpoint batch rifiuta directDownload; usa invece outputMode: "zip".


Discover#

Elenca gli URL di un sito senza eseguire alcun rendering. Non entra in gioco nessun browser, quindi è veloce ed economico rispetto al percepire ogni singola pagina. Riferimento completo: Discover.

const found = await client.v2.discover("https://example.com", {
    mode: "hybrid",
    maxUrls: 200,
    maxDepth: 3,
    excludePatterns: ["/tag/", "/author/"],
    sameDomainOnly: true,
});

console.log(found.total, found.truncated, found.sources); // ad es. { sitemap: 42, crawl: 30 }
for (const url of found.urls) console.log(url);
Opzione Tipo Default Descrizione
mode "sitemap" \| "crawl" \| "hybrid" "hybrid" Solo sitemap, solo crawl HTTP, o entrambi.
maxUrls number 100 Da 1 a 1000. truncated è true quando esistevano più URL di quanti questo limite ne permettesse.
maxDepth number 2 Da 1 a 5. Profondità del crawl a partire dall'URL di partenza.
includePatterns string[] -- Allowlist di espressioni regolari, massimo 50 voci.
excludePatterns string[] -- Denylist di espressioni regolari applicata dopo includePatterns, massimo 50 voci.
sameDomainOnly boolean true Mantiene il crawl sul dominio di partenza.
respectRobots boolean -- Rispetta le regole robots del sito. robotsRespected nel risultato riporta cosa è successo.

Lookup#

Esegue una ricerca web categorizzata e, facoltativamente, percepisce in automatico i primi risultati, così ogni hit porta con sé il proprio PerceiveResult completo inline. Riferimento completo: Lookup.

const search = await client.v2.lookup("best static site generators", {
    category: "web",
    numResults: 10,
    country: "us",
    locale: "en",
    timeFilter: "month",
    perceiveTop: 3,
});

for (const hit of search.results) {
    console.log(hit.position, hit.title, hit.url);
    if (hit.perceive) {
        console.log("  quality:", hit.perceive.renderQuality);
        console.log("  markdown:", hit.perceive.outputs.markdown?.url);
    }
}
console.log(search.answerBox, search.knowledgeGraph);
Opzione Tipo Default Descrizione
category "web" \| "news" \| "images" \| "scholar" \| "patents" \| "maps" "web" Verticale di ricerca.
country string -- Codice paese gl di Google, per esempio "us" o "in".
locale string -- Lingua dell'interfaccia hl di Google, per esempio "en".
timeFilter "hour" \| "day" \| "week" \| "month" \| "year" -- Finestra temporale di recency.
numResults number 10 Da 1 a 100.
page number 1 Da 1 a 10.
location string -- Località in testo libero, per esempio "Austin, Texas".
autocorrect boolean true Lascia che il provider corregga i refusi evidenti.
perceiveTop number 0 Da 0 a 10. Esegue il rendering automatico dei primi N URL dei risultati; ognuno è un render completo del browser.

perceiveTop nel risultato riporta quanti risultati sono stati effettivamente percepiti, che possono essere meno di quanti ne avevi chiesti, e perceiveOperationIds ti dà gli id delle operazioni per rifirmare gli URL più tardi.


Distill#

Estrazione strutturata guidata da uno schema. Dagli una forma e un insieme di URL (o un sito da scoprire prima) e restituisce record conformi a quella forma. Un cssSchema facoltativo risponde a tutto ciò che può a partire dai selettori, prima che qualcosa venga escalato al tier LLM. Riferimento completo: Distill.

const extraction = await client.v2.distill({
    urls: ["https://example.com/pricing"],
    schema: { plans: "list of plan names with monthly prices" },
    cssSchema: {
        baseSelector: ".plan-card",
        fields: [
            { name: "name", type: "text", selector: "h3" },
            { name: "price", type: "text", selector: ".price" },
            { name: "url", type: "attribute", selector: "a", attribute: "href" },
        ],
    },
});

const first = extraction.results[0];
console.log(first.data);
console.log(first.extractionTier);  // "css" | "llm" | "mixed" | "none"
console.log(first.fieldsFromCss, first.fieldsFromLlm, first.renderQuality);

Passa esattamente uno tra urls e discoverFrom; l'SDK solleva un errore in locale se li passi entrambi o nessuno dei due, e lo solleva anche se schema manca o non è un oggetto.

// Scopri prima un sito, poi distilla ogni pagina trovata.
await client.v2.distill({
    discoverFrom: { url: "https://example.com", mode: "sitemap", maxPages: 10 },
    schema: { title: "page title", summary: "one-line summary" },
});
Opzione Tipo Default Descrizione
urls string[] -- URL espliciti da distillare, massimo 50. Mutuamente esclusivo con discoverFrom.
discoverFrom { url, mode?, maxPages? } -- Scopre prima, poi distilla. maxPages va da 1 a 50, default 10, e limita sia la scoperta sia la distillazione.
schema Record<string, unknown> obbligatorio Un oggetto JSON-Schema ({ type: "object", properties: {...} }) oppure una mappa piatta { campo: descrizione }.
cssSchema CssSchema -- Passaggio gratuito sui selettori eseguito prima di qualsiasi escalation all'LLM.
waitFor string -- Selettore CSS o "js:<expr>" da attendere.
waitTimeoutMs number 30000 Da 0 a 60000.
headers Record<string, string> -- Header di richiesta aggiuntivi.
cookies BrowserCookie[] -- Cookie iniettati prima del rendering.
respectRobots boolean -- Rispetta le regole robots del sito.

Un CssSchema ha un baseSelector (il contenitore ripetuto, un record per ogni corrispondenza), un elenco fields, un name facoltativo e un targetField facoltativo che indica la proprietà dello schema di output che i record vanno a riempire. Ogni campo è { name, type, selector?, attribute?, pattern?, default?, transform?, fields? } dove type è uno tra text, attribute, html, regex, nested, list, nested_list. attribute è obbligatorio per i campi attribute, pattern per i campi regex, e un array fields non vuoto per i tipi annidati (profondità massima 5).


Ingest#

Trasforma un sito, o una pila di documenti caricati, in JSONL suddiviso in chunk e pronto per RAG. Ingest è sempre asincrono: entrambi i punti di ingresso restituiscono un IngestJob in coda, e tu lo interroghi oppure configuri un webhook. Riferimento completo: Ingest.

// Da un sito.
const job = await client.v2.ingest({
    mode: "sitemap",
    url: "https://docs.example.com",
    maxPages: 100,
    chunk: { maxWords: 512, sentenceOverlap: 1 },
    webhookUrl: "https://my.app/hooks/enconvert",
});

// Da file caricati: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT/MD,
// e documenti office legacy o ODF.
const fileJob = await client.v2.ingestFiles(["handbook.pdf", "notes.docx"], {
    chunk: { maxWords: 512, sentenceOverlap: 1 },
});

// Interroga entrambi allo stesso modo. Stati non terminali: queued, discovering, processing.
let status = await client.v2.getIngestJob(job.jobId);
while (!["completed", "failed", "canceled"].includes(status.status)) {
    await new Promise((r) => setTimeout(r, 5000));
    status = await client.v2.getIngestJob(job.jobId);
}
console.log(status.totalChunks, status.outputUrl, status.errorMessage);

const page = await client.v2.listIngestJobs({ limit: 20, skip: 0 });
console.log(page.jobs.length, page.hasMore);

await client.v2.cancelIngestJob(job.jobId); // idempotente
Opzione Tipo Default Descrizione
mode "urls" \| "sitemap" \| "crawl" "urls" "urls" richiede urls e rifiuta url. "sitemap" e "crawl" richiedono un url di partenza e rifiutano urls. Entrambe le regole vengono applicate in locale prima della richiesta. L'unione IngestMode include anche "files", che è ciò che ingestFiles riporta sul suo job; non passarlo qui.
url string -- URL di partenza per sitemap e crawl.
urls string[] -- URL espliciti per la modalità "urls", massimo 1000.
maxPages number 50 Limite di scoperta per sitemap e crawl, da 1 a 1000.
maxDepth number 2 Da 1 a 5.
sameDomainOnly boolean true Mantiene il crawl sul dominio di partenza.
includePatterns / excludePatterns string[] -- Allowlist e denylist di espressioni regolari.
respectRobots boolean -- Rispetta le regole robots del sito.
waitFor / waitTimeoutMs string / number -- / 30000 Attesa di rendering per singola pagina.
chunk { maxWords?, sentenceOverlap? } 512 / 1 maxWords va da 32 a 4000, sentenceOverlap da 0 a 10.
webhookUrl string -- Webhook di completamento, firmato in HMAC.

ingestFiles accetta un FileInput[], ossia stringhe di percorso, Uint8Array / Buffer oppure oggetti { data, filename, contentType? }, in qualsiasi combinazione. Prende solo chunk e webhookUrl, e solleva un errore in locale su un elenco vuoto.

Firma dei webhook. I webhook di completamento sono firmati in HMAC. Recupera il segreto (viene creato alla prima chiamata) per verificare le consegne, ruotalo quando serve e riconsegna un webhook che il tuo endpoint ha perso.

const secret = await client.v2.getWebhookSecret();
console.log(secret.signatureHeader, secret.timestampHeader);
console.log(secret.signatureScheme, secret.replayToleranceSeconds);

// La rotazione invalida immediatamente le firme fatte con il segreto precedente.
const rotated = await client.v2.rotateWebhookSecret();

// Riconsegna il webhook di un job completato.
const retry = await client.v2.retryIngestWebhook(job.jobId);
console.log(retry.delivered, retry.attempts, retry.statusCode, retry.detail);

retryIngestWebhook restituisce 409 quando il job non è completato e 400 quando il job non ha alcun webhook configurato.


Watch#

Crea un watcher che riesegue il rendering di un URL a cadenza fissa e ti avvisa quando la pagina cambia, via email, via webhook o entrambi. Riferimento completo: Watch.

const watcher = await client.v2.createWatcher("https://example.com/pricing", {
    frequencyMinutes: 60,
    diffMode: "auto",
    webhookUrl: "https://my.app/hooks/changes",
    notifyEmail: true,
});
console.log(watcher.watcherId, watcher.nextCheckAt);

const list = await client.v2.listWatchers({ limit: 20 });
const one = await client.v2.getWatcher(watcher.watcherId);

const history = await client.v2.getWatcherSnapshots(watcher.watcherId, { limit: 10 });
for (const snap of history.snapshots) {
    console.log(snap.checkedAt, snap.hasChanges, snap.similarity, snap.changeCount);
}

await client.v2.updateWatcher(watcher.watcherId, { status: "paused" });
await client.v2.updateWatcher(watcher.watcherId, { webhookUrl: "" }); // azzera il webhook
await client.v2.deleteWatcher(watcher.watcherId);                     // soft delete, idempotente
Opzione Tipo Default Descrizione
frequencyMinutes number 60 Minuti tra un controllo e l'altro, da 60 a 43200. Il limite minimo di un'ora è rigido.
diffMode "auto" \| "text" \| "structured" \| "tables" \| "metadata" "auto" "auto" lascia scegliere al motore di diff in base al tipo di contenuto.
trackFields Record<string, unknown> -- Sottoinsieme di campi o selettori per restringere il diff.
webhookUrl string -- Webhook di notifica dei cambiamenti, firmato in HMAC.
notifyEmail boolean true Invia un'email al proprietario del progetto quando ci sono cambiamenti.

updateWatcher accetta gli stessi campi più status ("active" o "paused") e ne richiede almeno uno; l'SDK solleva un errore in locale su un aggiornamento vuoto. Passare webhookUrl: "" in modo esplicito azzera il webhook. L'eliminazione è una soft delete: deleteWatcher restituisce il watcher marcato come eliminato con stato "deleted", e un watcher eliminato risulta 404 da getWatcher.

Ogni snapshot porta con sé checkedAt, hasChanges, similarity (da 0.0 a 1.0 rispetto alla cattura precedente), renderQuality, changeCount e un array changes.

I diff degli snapshot contengono contenuto di pagina non attendibile. Le voci in snapshot.changes arrivano direttamente dalla pagina sorvegliata. Applica l'escape prima di renderizzarle in HTML o di scriverle in un visualizzatore di log.

Opzioni PDF#

Si passano tramite il campo pdfOptions su convertUrlToPdf, convertDocument, convertToPdf, convertWebsiteToPdf e client.v2.perceive (quando outputs include "pdf").

const result = await client.convertUrlToPdf("https://example.com", {
    pdfOptions: {
        pageSize: "A4",
        orientation: "landscape",
        margins: { top: 10, bottom: 10, left: 15, right: 15 },
        scale: 0.9,
        grayscale: false,
    },
    saveTo: "report.pdf",
});
Campo Tipo Descrizione
pageSize string "A4", "A3", "Letter", "Legal", ecc.
pageWidth / pageHeight number Geometria di pagina esplicita, in alternativa a pageSize.
orientation "portrait" \| "landscape" Il valore predefinito è verticale.
margins { top, bottom, left, right } (mm) Tutti e quattro sono facoltativi.
scale number Scala di rendering, ad es. 0.9 per il 90%.
grayscale boolean Post-elabora il PDF con Ghostscript convertendolo in scala di grigi.
header PdfHeaderFooter { content?, height? }. content è limitato a 2000 caratteri.
footer PdfHeaderFooter Stessa forma di header.

Ogni parametro è descritto per esteso in Job sync e async.

Gestione degli errori#

Gli errori sono classi di eccezione tipizzate che puoi riconoscere con instanceof. La stessa gerarchia copre sia i metodi di conversione sia client.v2.

import {
    Enconvert,
    APIError,
    AuthenticationError,
    QuotaError,
    RateLimitError,
} from "@enconvert/node-sdk";

try {
    await client.v2.perceive("https://example.com", { outputs: ["markdown"] });
} catch (e) {
    if (e instanceof AuthenticationError) {
        console.error("Invalid API key. Check ENCONVERT_API_KEY.");
    } else if (e instanceof QuotaError) {
        console.error("Request rejected with 402.");
    } else if (e instanceof RateLimitError) {
        console.error("Too many requests. Back off and retry.");
    } else if (e instanceof APIError) {
        console.error(`API error [${e.statusCode}]: ${e.message}`);
    } else {
        throw e;
    }
}
Classe Sollevata su Codice di stato
AuthenticationError Chiave non valida, mancante o revocata 401, 403 (entrambi riportano statusCode 401)
QuotaError Sollevata su HTTP 402 402
RateLimitError Troppe richieste 429
APIError Qualsiasi altro 4xx / 5xx il codice effettivo
EnconvertError Classe base di tutte le precedenti --

QuotaError e RateLimitError estendono entrambe APIError, che estende EnconvertError, quindi ordina i tuoi controlli instanceof dal più specifico al meno specifico. Ogni APIError porta con sé un campo statusCode.

Alcuni errori non raggiungono mai la rete: un'estensione di file non supportata, una chiamata distill con sia urls sia discoverFrom, una chiamata ingest in cui modalità e argomenti non concordano, una chiamata perceiveDirect con più di un output di artefatto, o una chiamata updateWatcher senza campi. Questi casi sollevano un semplice Error in locale, così trovi lo sbaglio in fase di sviluppo.

La mappa completa dei messaggi di errore è nel riferimento Codici di errore.


Recupero dei timeout#

Le conversioni URL-to-PDF lunghe o quelle di documenti di grandi dimensioni possono superare il limite di timeout del reverse-proxy di 60-120 secondi, anche quando la conversione alla fine riesce sul server. L'SDK gestisce la cosa in modo trasparente sui metodi di conversione V1:

  1. Prima di ogni richiesta, l'SDK genera un UUID e lo invia come job_id nel corpo della richiesta.
  2. Se la richiesta originale restituisce 5xx, l'SDK passa silenziosamente al polling di GET /v1/convert/status/{job_id} ogni 3 secondi.
  3. Appena il job risulta registrato come success, l'SDK restituisce il risultato. Appena risulta registrato come failed, l'SDK solleva APIError.
  4. La scadenza del polling è di 5 minuti. Se viene superata, l'SDK solleva APIError(504, "Conversion timed out").

Non devi scrivere codice per tutto questo, funziona e basta. Imposta timeout sul costruttore se vuoi limitare la richiesta iniziale.

V2 usa oggetti job espliciti invece del recupero implicito: perceiveBatch e ingest restituiscono un id che interroghi con getPerceiveBatch e getIngestJob, e ingest può chiamare un webhook al posto tuo.


Configurazione#

const client = new Enconvert({
    apiKey: process.env.ENCONVERT_API_KEY!,
    timeout: 300_000, // ms, default 5 minuti
    baseUrl: "https://api.enconvert.com", // override per gateway self-hosted
});
Opzione Tipo Default Descrizione
apiKey string -- (obbligatoria) Chiave API privata (sk_...). Il costruttore solleva subito un errore se manca.
timeout number 300_000 Timeout della richiesta in ms. Interrompe la fetch sottostante tramite AbortController.
baseUrl string https://api.enconvert.com URL base dell'API. Le barre finali vengono rimosse.

La chiave viaggia come header X-API-Key su ogni richiesta, sia V1 sia V2. client.v2 viene costruito per te e condivide chiave, URL base e timeout del client, quindi non c'è nulla di ulteriore da configurare.

Non scrivere mai la chiave API hardcoded. Leggila da una variabile d'ambiente o dal tuo secret manager. Chiunque ottenga la tua chiave privata può eseguire conversioni e operazioni V2 sul tuo account. Ruota le chiavi dalla dashboard.

Forma del risultato#

Ogni metodo di conversione restituisce un ConversionResult:

interface ConversionResult {
    presignedUrl: string;          // URL firmato per scaricare l'output (1 ora)
    objectKey: string;             // chiave dell'oggetto nello storage
    filename: string;              // nome file lato server
    fileSize?: number;             // byte
    conversionTimeSeconds?: number;
    jobId?: string;                // presente quando il recupero timeout ha fatto polling
}

L'URL pre-firmato è valido per un'ora. Se ti serve un accesso permanente, scarica il file (usa saveTo, oppure recupera tu stesso l'URL) e archivialo nel tuo bucket.

I risultati V2 hanno una forma diversa. Un PerceiveResult porta con sé operationId, status, url, urlFinal, contentHash, renderQuality, statusCode, deductions, cacheHit, una mappa outputs indicizzata per nome di output, structured, extractionTier, tokens, costCents, durationMs, optionsEcho, error e warnings. Ogni voce in outputs è un V2OutputArtifact fatto di { url?, objectKey, sizeBytes, contentType, expiresIn }, dove expiresIn è espresso in secondi e vale 900 per impostazione predefinita. Gli URL degli artefatti V2 durano quindi 15 minuti invece di un'ora, e vengono rifirmati a ogni lettura, quindi chiamare di nuovo getPerceiveOperation(operationId) ti dà link freschi senza rieseguire il rendering della pagina.


TypeScript#

Le definizioni di tipo sono incluse nel pacchetto, quindi non serve installare alcun @types/.... Il pacchetto è pubblicato in doppia forma (ESM + CJS) con exports, types e .d.ts / .d.cts corretti, così funziona con qualsiasi modalità di risoluzione dei moduli di Node.

import type {
    ClientOptions,
    CompressImageOptions,
    ConversionResult,
    ConvertDocumentOptions,
    ConvertImageOptions,
    ConvertToMarkdownOptions,
    ConvertToPdfOptions,
    FileInput,
    JobStatus,
    PdfOptions,
    UrlToMarkdownOptions,
    UrlToPdfOptions,
    UrlToScreenshotOptions,
    // I tipi V2 arrivano dallo stesso entry point.
    DiscoverOptions, DiscoverResult,
    DistillOptions, DistillResult,
    IngestJob, IngestOptions,
    LookupOptions, LookupResult,
    PerceiveOptions, PerceiveResult, PerceiveOutputName,
    PerceiveBatchResult, PerceiveDirectResult,
    Watcher, WatcherSnapshotList,
} from "@enconvert/node-sdk";

Anche la classe EnconvertV2 è esportata, se vuoi tipizzare il parametro di una funzione come namespace V2.


Aggiornamento#

Il pacchetto include una piccola CLI, enconvert-sdk, per mantenersi aggiornato.

npx enconvert-sdk upgrade
npx enconvert-sdk upgrade --dry-run
npx enconvert-sdk version

upgrade rileva npm, pnpm, yarn o bun dal package manager in uso e stampa sempre il comando di installazione esatto prima di eseguirlo, così non succede nulla al tuo lockfile senza che tu lo veda. --dry-run stampa quel comando e si ferma. version riporta la versione dell'SDK installata.


Sorgente e segnalazioni#


Domande frequenti#

Come converto file in Node.js con un pacchetto npm?#

Installa @enconvert/node-sdk, crea un client con la tua chiave API (new Enconvert({ apiKey: process.env.ENCONVERT_API_KEY! })) e chiama un metodo tipizzato come convertUrlToPdf, convertImage o convertDocument. Passa saveTo per scrivere il risultato direttamente su disco in streaming.

Come estraggo una pagina web in Markdown pulito con Node.js?#

Chiama client.v2.perceive(url, { outputs: ["markdown"] }). Ottieni un URL firmato valido 15 minuti verso il Markdown in op.outputs.markdown.url, più un punteggio renderQuality per la lettura. Se vuoi i byte direttamente invece di un URL, chiama client.v2.perceiveDirect(url, { outputs: ["markdown"] }) e leggi result.content.

Cos'è renderQuality e perché è importante?#

renderQuality è un punteggio da 0.0 a 1.0 allegato a ogni render V2. Una sfida anti-bot, un muro di login, una pagina di errore HTTP, un soft 404 o uno shell SPA vuoto ottengono tutti un punteggio basso e tornano con deductions e warnings con un nome, così una lettura sbagliata viene segnalata invece di entrare in silenzio nel contesto del tuo agente come se fosse la pagina vera. Punteggi sotto circa 0.40 significano che il render è fallito nei fatti, anche se la richiesta ha restituito 200.

Come converto HEIC in WebP con Node.js?#

Chiama convertImage con il file HEIC (un percorso o un oggetto buffer { data, filename }) e outputFormat: "webp". L'SDK converte tra jpeg, png, svg, heic e webp; il formato di input viene rilevato dall'estensione del nome file.

Come comprimo un'immagine in Node.js senza cambiarne il formato?#

Chiama compressImage con un file .png, .jpg, .jpeg o .webp. L'output conserva formato ed estensione dell'input, rimuove i metadati preservando il profilo ICC e l'orientamento EXIF, e non è mai più grande dell'input. Aggiungi targetSizeKb per ridurre le dimensioni verso un budget; il target è "best effort", quindi leggi result.fileSize per vedere cosa è stato effettivamente ottenuto.

Come converto qualsiasi documento in Markdown per una pipeline RAG?#

Chiama convertToMarkdown con il file e passa saveTo per scrivere il .md direttamente su disco. Accetta 22 estensioni tra Office, OpenDocument, PDF, EPUB, HTML, CSV e testo semplice, e restituisce un unico file Markdown consapevole delle intestazioni, così il tuo chunker può dividere sulle intestazioni del documento invece che su conteggi arbitrari di caratteri.

Come trasformo un intero sito web in chunk pronti per RAG con Node.js?#

Chiama client.v2.ingest({ mode: "sitemap", url, maxPages, chunk: { maxWords: 512, sentenceOverlap: 1 } }). Ingest è sempre asincrono, quindi interroga client.v2.getIngestJob(job.jobId) finché status non è "completed" e leggi outputUrl per il JSONL firmato, oppure imposta webhookUrl e lascia che sia il webhook di completamento ad avvisarti. Per documenti locali invece che per un sito, client.v2.ingestFiles([...]) esegue la stessa pipeline.

Come estraggo JSON strutturato da una pagina con Node.js?#

Chiama client.v2.distill({ urls, schema }) dove schema è un oggetto JSON-Schema oppure una mappa piatta { campo: descrizione }. Aggiungi un cssSchema e il passaggio sui selettori risponde a tutto ciò che può prima che qualcosa venga escalato al tier LLM; result.extractionTier, fieldsFromCss e fieldsFromLlm ti dicono quale tier ha fatto il lavoro.

Come monitoro i cambiamenti di una pagina web con Node.js?#

Chiama client.v2.createWatcher(url, { frequencyMinutes: 60, diffMode: "auto", webhookUrl }). Il limite minimo di un'ora è rigido, quindi 60 è la cadenza minima. Leggi lo storico con getWatcherSnapshots, metti in pausa con updateWatcher(id, { status: "paused" }) ed elimina con deleteWatcher, che è una soft delete ed è idempotente.

Come gestisce l'SDK le conversioni lunghe che superano il timeout del reverse-proxy?#

Prima di ogni richiesta di conversione V1 l'SDK genera un UUID e lo invia come job_id; se la richiesta restituisce 5xx, interroga silenziosamente GET /v1/convert/status/{job_id} ogni 3 secondi finché il job non è success o failed. La scadenza del polling è di 5 minuti, dopodiché solleva APIError(504, "Conversion timed out"). V2 usa invece id di job espliciti, interrogati con getPerceiveBatch o getIngestJob.

Posso usare l'SDK Node.js in un'applicazione browser?#

No, l'SDK è solo lato server, perché si autentica con una chiave API privata (sk_...) che non deve mai finire nel bundle del codice lato client. Funziona su Node 18+, Bun e Deno tramite lo specificatore npm.

Per quanto tempo è valido l'URL di download pre-firmato?#

Il presignedUrl presente in ogni ConversionResult è valido per un'ora. Gli URL degli artefatti V2 sono validi 15 minuti e vengono rifirmati a ogni lettura, quindi getPerceiveOperation(operationId) ti consegna link freschi. Per un accesso permanente, scarica il file e archivialo nel tuo bucket.