docs: actualiza CAMBIOS-2026-05-04.md y commitea fixes pendientes
- Monitor de sincronización SAT (sat-sync-monitor.job + alerta por correo). - Scraper de CSF más robusto (iframes, blobs, popups, validación PDF). - Reactivación de contribuyentes desactivados y limpieza al desactivar. - Timeout de constancia aumentado a 5 min. - Variables de entorno SAT en .env.example y env.ts.
This commit is contained in:
@@ -1,40 +1,113 @@
|
||||
import type { Page, Locator, Frame, Response } from 'playwright';
|
||||
import type { Page, Locator, Frame, Response, BrowserContext } from 'playwright';
|
||||
import type { CsfLoginSession } from './sat-csf-login.js';
|
||||
|
||||
async function tryFetchPdfFromUrl(page: Page, url: string): Promise<Buffer | null> {
|
||||
async function tryFetchPdfFromUrl(frame: Frame, url: string): Promise<Buffer | null> {
|
||||
if (!url || url === 'about:blank') return null;
|
||||
|
||||
// Blob / data URI → fetchear dentro del navegador para respetar cookies/sesión
|
||||
if (url.startsWith('blob:') || url.startsWith('data:')) {
|
||||
const arr = await page.evaluate(async (u) => {
|
||||
const r = await fetch(u);
|
||||
const buf = await r.arrayBuffer();
|
||||
return Array.from(new Uint8Array(buf));
|
||||
}, url);
|
||||
return Buffer.from(arr);
|
||||
try {
|
||||
const page = frame.page();
|
||||
const arr = await page.evaluate(async (u) => {
|
||||
const r = await fetch(u);
|
||||
const buf = await r.arrayBuffer();
|
||||
return Array.from(new Uint8Array(buf));
|
||||
}, url);
|
||||
const buf = Buffer.from(arr);
|
||||
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// URL http(s) relativa o absoluta → fetchear desde el frame para mantener sesión
|
||||
if (url.startsWith('http')) {
|
||||
const response = await page.context().request.get(url);
|
||||
if (!response.ok()) return null;
|
||||
return Buffer.from(await response.body());
|
||||
try {
|
||||
const response = await frame.page().context().request.get(url);
|
||||
if (!response.ok()) return null;
|
||||
const ct = response.headers()['content-type'] ?? '';
|
||||
if (!ct.includes('application/pdf') && !url.toLowerCase().includes('.pdf')) return null;
|
||||
const buf = Buffer.from(await response.body());
|
||||
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca "Generar Constancia" en cualquiera de los frames del appPage (vive
|
||||
* típicamente en un iframe JSF legacy: rfcampc.siat.sat.gob.mx/PTSC/...).
|
||||
* Intenta 3 rutas: download event, popup con viewer, response interception.
|
||||
*/
|
||||
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
|
||||
const { context, appPage } = session;
|
||||
async function findPdfInFrames(appPage: Page, deadlineMs: number): Promise<Buffer | null> {
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
|
||||
let interceptedPdf: Buffer | null = null;
|
||||
const responseListener = async (response: Response) => {
|
||||
const ct = response.headers()['content-type'] ?? '';
|
||||
if (ct.includes('application/pdf')) {
|
||||
try { interceptedPdf = Buffer.from(await response.body()); } catch { /* ok */ }
|
||||
while (Date.now() < deadline) {
|
||||
const frames = appPage.frames();
|
||||
|
||||
for (const frame of frames) {
|
||||
try {
|
||||
const frameUrl = frame.url();
|
||||
|
||||
// 1. Frame cuya URL sea directamente un PDF
|
||||
if (
|
||||
frameUrl.toLowerCase().includes('.pdf') ||
|
||||
frameUrl.includes('application/pdf')
|
||||
) {
|
||||
const body = await frame.content().catch(() => null);
|
||||
if (!body) continue;
|
||||
// content() de un PDF no es util; intentar fetch por URL
|
||||
const pdf = await tryFetchPdfFromUrl(frame, frameUrl);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
|
||||
// 2. <embed type="application/pdf">
|
||||
const embed = frame.locator('embed[type="application/pdf"]').first();
|
||||
if ((await embed.count()) > 0) {
|
||||
const src = await embed.getAttribute('src');
|
||||
if (src) {
|
||||
const pdf = await tryFetchPdfFromUrl(frame, src);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. <iframe> cuyo src apunte al visor/constancia (IdcSiat, .pdf, etc.)
|
||||
const iframes = await frame.locator('iframe').all();
|
||||
for (const iframe of iframes) {
|
||||
const src = await iframe.getAttribute('src');
|
||||
if (src) {
|
||||
const lower = src.toLowerCase();
|
||||
if (
|
||||
lower.includes('.pdf') ||
|
||||
lower.includes('idcsiat') ||
|
||||
lower.includes('reimpresion') ||
|
||||
lower.includes('consultatramite')
|
||||
) {
|
||||
const pdf = await tryFetchPdfFromUrl(frame, src);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. <a download> o link con data/blob generado por jsPDF
|
||||
const downloadLinks = await frame.locator('a[download], a[href*="data:"], a[href*="blob:"]').all();
|
||||
for (const link of downloadLinks) {
|
||||
const href = await link.getAttribute('href');
|
||||
if (href) {
|
||||
const pdf = await tryFetchPdfFromUrl(frame, href);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Frame puede estar navegando; ignorar y continuar
|
||||
}
|
||||
}
|
||||
};
|
||||
context.on('response', responseListener);
|
||||
|
||||
await appPage.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findGenerarButton(appPage: Page, timeoutMs: number): Promise<Locator | null> {
|
||||
const GENERAR_SELECTORS = [
|
||||
'button:has-text("Generar Constancia")',
|
||||
'button:has-text("Generar constancia")',
|
||||
@@ -44,77 +117,109 @@ export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
|
||||
'a:has-text("Generar constancia")',
|
||||
].join(', ');
|
||||
|
||||
let generarLocator: Locator | null = null;
|
||||
let foundFrame: Frame | null = null;
|
||||
const deadline = Date.now() + 90_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
for (const frame of appPage.frames()) {
|
||||
const loc = frame.locator(GENERAR_SELECTORS).first();
|
||||
const count = await loc.count().catch(() => 0);
|
||||
if (count > 0 && await loc.isVisible().catch(() => false)) {
|
||||
generarLocator = loc;
|
||||
foundFrame = frame;
|
||||
break;
|
||||
if (count > 0 && (await loc.isVisible().catch(() => false))) {
|
||||
return loc;
|
||||
}
|
||||
}
|
||||
if (generarLocator) break;
|
||||
await appPage.waitForTimeout(1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!generarLocator || !foundFrame) {
|
||||
context.off('response', responseListener);
|
||||
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
|
||||
}
|
||||
/**
|
||||
* Busca "Generar Constancia" en cualquiera de los frames del appPage,
|
||||
* hace click, y extrae el PDF desde el visor/iframe que genera el SAT.
|
||||
*
|
||||
* El portal SAT actual (2025-2026) genera la CSF dentro de un iframe JSF
|
||||
* legacy (rfcampc.siat.sat.gob.mx/PTSC/.../ConsultaTramite.jsf). El PDF
|
||||
* no siempre se descarga como evento de download ni abre popup; a veces
|
||||
* se renderiza en un <embed> o en un iframe cuyo src devuelve el PDF.
|
||||
*/
|
||||
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
|
||||
const { context, appPage } = session;
|
||||
|
||||
await generarLocator.scrollIntoViewIfNeeded();
|
||||
await appPage.waitForTimeout(500);
|
||||
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 15_000 }).catch(() => null);
|
||||
const downloadPromise = appPage.waitForEvent('download', { timeout: 15_000 }).catch(() => null);
|
||||
await generarLocator.click();
|
||||
|
||||
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
|
||||
let interceptedPdf: Buffer | null = null;
|
||||
const responseListener = async (response: Response) => {
|
||||
const ct = response.headers()['content-type'] ?? '';
|
||||
if (ct.includes('application/pdf')) {
|
||||
try {
|
||||
interceptedPdf = Buffer.from(await response.body());
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
};
|
||||
context.on('response', responseListener);
|
||||
|
||||
try {
|
||||
// Path 1: download event
|
||||
const generarLocator = await findGenerarButton(appPage, 90_000);
|
||||
if (!generarLocator) {
|
||||
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
|
||||
}
|
||||
|
||||
await generarLocator.scrollIntoViewIfNeeded();
|
||||
await appPage.waitForTimeout(500);
|
||||
|
||||
// Algunos botones del SAT usan JSF/ajax; un click simple a veces no basta.
|
||||
// Hacemos click normal y, como fallback, dispatchEvent si no hay reacción.
|
||||
await generarLocator.click();
|
||||
await appPage.waitForTimeout(2000);
|
||||
|
||||
// Intentar extraer el PDF del iframe/visor
|
||||
let pdf = await findPdfInFrames(appPage, 60_000);
|
||||
if (pdf) return pdf;
|
||||
|
||||
// Si aún no hay PDF, algunos flujos abren popup clásico
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 10_000 }).catch(() => null);
|
||||
const downloadPromise = appPage.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
|
||||
|
||||
// Reintentar click por si el primero no disparó el handler
|
||||
const stillVisible = await generarLocator.isVisible().catch(() => false);
|
||||
if (stillVisible) {
|
||||
await generarLocator.dispatchEvent('click');
|
||||
}
|
||||
|
||||
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
|
||||
|
||||
if (download) {
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
||||
const pdf = Buffer.concat(chunks);
|
||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) {
|
||||
throw new Error('El archivo descargado no es un PDF válido');
|
||||
}
|
||||
return pdf;
|
||||
const downloaded = Buffer.concat(chunks);
|
||||
if (downloaded.subarray(0, 5).toString().startsWith('%PDF-')) return downloaded;
|
||||
}
|
||||
|
||||
// Path 2: viewer popup
|
||||
if (popup) {
|
||||
await popup.waitForLoadState('domcontentloaded').catch(() => undefined);
|
||||
await popup.waitForTimeout(2000);
|
||||
|
||||
let pdf = await tryFetchPdfFromUrl(popup, popup.url()).catch(() => null);
|
||||
if (!pdf) {
|
||||
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
|
||||
if (embedSrc) {
|
||||
const absolute = new URL(embedSrc, popup.url()).toString();
|
||||
pdf = await tryFetchPdfFromUrl(popup, absolute).catch(() => null);
|
||||
}
|
||||
pdf = await findPdfInFrames(popup, 20_000);
|
||||
if (pdf) return pdf;
|
||||
|
||||
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
|
||||
if (embedSrc) {
|
||||
const absolute = new URL(embedSrc, popup.url()).toString();
|
||||
pdf = await tryFetchPdfFromUrl(popup.mainFrame(), absolute).catch(() => null);
|
||||
if (pdf) return pdf;
|
||||
}
|
||||
if (!pdf && interceptedPdf) pdf = interceptedPdf;
|
||||
if (!pdf || pdf.length === 0) throw new Error('El visor abrió pero no se pudo extraer el PDF');
|
||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer extraído no es un PDF válido');
|
||||
return pdf;
|
||||
|
||||
if (interceptedPdf) return interceptedPdf;
|
||||
throw new Error('El visor abrió pero no se pudo extraer el PDF');
|
||||
}
|
||||
|
||||
// Path 3: inline response (no popup, no download)
|
||||
await appPage.waitForTimeout(3000);
|
||||
if (interceptedPdf) {
|
||||
const pdf = interceptedPdf as Buffer;
|
||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer interceptado no es un PDF válido');
|
||||
return pdf;
|
||||
}
|
||||
throw new Error('Click en "Generar Constancia" no produjo descarga, popup ni respuesta PDF');
|
||||
// Último intento: esperar un poco más a que el iframe termine de cargar
|
||||
await appPage.waitForTimeout(5000);
|
||||
pdf = await findPdfInFrames(appPage, 20_000);
|
||||
if (pdf) return pdf;
|
||||
|
||||
if (interceptedPdf) return interceptedPdf;
|
||||
|
||||
throw new Error('Click en "Generar Constancia" no produjo un PDF descargable ni visible');
|
||||
} finally {
|
||||
context.off('response', responseListener);
|
||||
}
|
||||
|
||||
@@ -820,9 +820,12 @@ async function processMetadataRange(
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina el tamaño de bloque óptimo consultando metadata del rango completo.
|
||||
* Determina el tamaño de bloque óptimo consultando metadata en chunks de 1 año.
|
||||
* <= 15,000 CFDIs → bloques de 6 meses
|
||||
* > 15,000 CFDIs → bloques de 2 meses
|
||||
* > 15,000 CFDIs → bloques de 3 meses
|
||||
*
|
||||
* El SAT no genera paquetes de metadata para rangos muy grandes (p. ej. 6 años),
|
||||
* así que el sondeo se divide en bloques anuales.
|
||||
*/
|
||||
async function determineChunkMonths(
|
||||
ctx: SyncContext,
|
||||
@@ -851,17 +854,20 @@ async function determineChunkMonths(
|
||||
}
|
||||
|
||||
const THRESHOLD = 15_000;
|
||||
const probeChunks = generateChunks(fechaInicio, fechaFin, 12);
|
||||
let totalCfdis = 0;
|
||||
|
||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
||||
try {
|
||||
const { totalCfdis: count } = await requestAndDownload(
|
||||
ctx, jobId, fechaInicio, fechaFin, tipo, 'metadata'
|
||||
);
|
||||
totalCfdis += count;
|
||||
console.log(`[SAT] Sondeo metadata ${tipo}: ${count} CFDIs en rango completo`);
|
||||
} catch (error: any) {
|
||||
console.log(`[SAT] No se pudo sondear metadata ${tipo}: ${error.message}`);
|
||||
for (const { start, end } of probeChunks) {
|
||||
try {
|
||||
const { totalCfdis: count } = await requestAndDownload(
|
||||
ctx, jobId, start, end, tipo, 'metadata'
|
||||
);
|
||||
totalCfdis += count;
|
||||
console.log(`[SAT] Sondeo metadata ${tipo} ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}: ${count} CFDIs`);
|
||||
} catch (error: any) {
|
||||
console.log(`[SAT] No se pudo sondear metadata ${tipo} ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,7 +913,7 @@ async function processInitialSync(
|
||||
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
||||
const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin);
|
||||
const xmlChunks = generateChunks(inicioHistorico, fechaFin, chunkMonths);
|
||||
const metaChunks = generateChunks(inicioHistorico, fechaFin, 36); // bloques de 3 años
|
||||
const metaChunks = generateChunks(inicioHistorico, fechaFin, 12); // bloques de 1 año
|
||||
|
||||
console.log(`[SAT] Sincronización: ${xmlChunks.length} bloques XML (${chunkMonths}m) + ${metaChunks.length} bloques metadata (36m)`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user