import { chromium } from "playwright-core"; import type { WebImportSource } from "../drizzle/schema"; export type ManualWebImportResult = { imported: number; duplicates: number; total: number; }; type InvoicePushResult = ManualWebImportResult; type InvoicePusher = (fileName: string, pdfBuffer: Buffer) => Promise; const SFR_INVOICE_LINK_SELECTOR = 'a[href*=".pdf"], a[href*="facture"], a[href*="invoice"], a[download]'; /** Types de connecteurs dont l’automate est réellement livré dans l’image applicative. */ export function getManualWebImportAvailability(connectorType: string): | { available: true } | { available: false; reason: string } { if (connectorType === "sfr") return { available: true }; return { available: false, reason: `L’import manuel n’est pas encore disponible pour le connecteur « ${connectorType} ».`, }; } export function summarizeManualWebImport(results: InvoicePushResult[]): ManualWebImportResult { return results.reduce( (summary, result) => ({ imported: summary.imported + result.imported, duplicates: summary.duplicates + result.duplicates, total: summary.total + result.total, }), { imported: 0, duplicates: 0, total: 0 }, ); } function getApplicationBaseUrl(): string { return process.env.APP_BASE_URL || `http://127.0.0.1:${process.env.PORT || 3000}`; } async function pushInvoiceToApplication(source: WebImportSource, fileName: string, pdfBuffer: Buffer) { const response = await fetch(`${getApplicationBaseUrl()}/api/web-import/push-invoice`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiToken: source.apiToken, fileName, fileBase64: pdfBuffer.toString("base64"), }), }); if (!response.ok) { throw new Error(`Le dépôt sécurisé du PDF a échoué (HTTP ${response.status}).`); } const result = (await response.json()) as Partial; return { imported: Number(result.imported || 0), duplicates: Number(result.duplicates || 0), total: Number(result.total || 0), }; } /** * Télécharge les nouvelles factures SFR puis les soumet au même endpoint * de contrôle, dédoublonnage, stockage et analyse IA que l’import automatique. * Les identifiants restent confinés au processus serveur et ne sont jamais loggés. */ export async function runManualWebImport( source: WebImportSource, pushInvoice: InvoicePusher = (fileName, pdfBuffer) => pushInvoiceToApplication(source, fileName, pdfBuffer), ): Promise { const availability = getManualWebImportAvailability(source.connectorType); if (!availability.available) throw new Error(availability.reason); const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || "/usr/bin/chromium", args: ["--no-sandbox", "--disable-dev-shm-usage"], }); try { const context = await browser.newContext({ acceptDownloads: true }); const page = await context.newPage(); await page.goto(source.portalUrl, { waitUntil: "networkidle", timeout: 30_000 }); await page .click('[id*="accept"], [class*="accept-cookie"], #didomi-notice-agree-button', { timeout: 3_000 }) .catch(() => undefined); await page.fill('input[type="email"], input[name="login"], input[id*="login"], input[id*="email"]', source.loginEmail); await page.fill('input[type="password"], input[name="password"], input[id*="password"]', source.loginPassword); await page.click('button[type="submit"], input[type="submit"], button:has-text("Connexion"), button:has-text("Se connecter")'); await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); const invoicePage = new URL("factures", source.portalUrl).toString(); await page.goto(invoicePage, { waitUntil: "networkidle", timeout: 20_000 }).catch(() => undefined); const invoiceLinks = await page.$$eval(SFR_INVOICE_LINK_SELECTOR, (links) => links.map((link) => ({ href: (link as HTMLAnchorElement).href, label: link.textContent?.trim() || "facture.pdf", })), ); const uniqueLinks = Array.from( new Map(invoiceLinks.filter((link) => link.href).map((link) => [link.href, link])).values(), ); const results: InvoicePushResult[] = []; for (const link of uniqueLinks) { // BrowserContext.request partage la session authentifiée du navigateur. Cela évite // de dépendre du comportement du visualiseur PDF ou de l’événement download. const response = await context.request.get(link.href, { timeout: 20_000 }); if (!response.ok()) { throw new Error(`Téléchargement de facture indisponible (HTTP ${response.status()}).`); } const fileName = link.label || `sfr-facture-${Date.now()}.pdf`; const pdfBuffer = Buffer.from(await response.body()); results.push(await pushInvoice(fileName, pdfBuffer)); } return summarizeManualWebImport(results); } finally { await browser.close(); } }