diff --git a/index.js b/index.js index b873006..55a8797 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -// worker-homeserve.js +// worker-homeserve.js (Versión Detective V2 - URL Corregida) 'use strict'; const { chromium } = require('playwright'); @@ -6,32 +6,24 @@ const admin = require('firebase-admin'); // --- CONFIGURACIÓN --- const CONFIG = { - // Firestore Collections QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado', RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log', - - // Credenciales: Busca en ENV primero, luego en Firestore HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve', - // URL Base - CLIENTES_CGI_BASE: process.env.CLIENTES_CGI_BASE || 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe', + // ¡CAMBIO IMPORTANTE! Usamos la URL completa de login + LOGIN_URL: 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=PROF_PASS', + BASE_CGI: 'https://www.clientes.homeserve.es/cgi-bin/fccgi.exe', - // Timeouts (Ms) - NAV_TIMEOUT: 120000, - SEL_TIMEOUT: 60000, - - // Worker Settings - CLAIM_TTL_MINUTES: 10, // Tiempo antes de robarle la tarea a un worker muerto - RESCAN_SECONDS: 60 // Cada cuánto mirar si se nos escapó algo + NAV_TIMEOUT: 60000, + RESCAN_SECONDS: 60 }; -// --- UTILS BÁSICOS --- +// --- UTILS --- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -function nowISO() { return new Date().toISOString(); } function toServerTimestamp() { return admin.firestore.FieldValue.serverTimestamp(); } function mustEnv(name) { const v = process.env[name]; - if (!v) throw new Error(`Falta variable de entorno: ${name}`); + if (!v) throw new Error(`Falta variable: ${name}`); return v; } function pickFirstNonEmpty(...vals) { @@ -41,9 +33,9 @@ function pickFirstNonEmpty(...vals) { return ''; } -// --- FIREBASE INIT --- +// --- FIREBASE --- function initFirebase() { - if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); + if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing FIREBASE_PRIVATE_KEY'); if (!admin.apps.length) { admin.initializeApp({ credential: admin.credential.cert({ @@ -56,330 +48,211 @@ function initFirebase() { return admin.firestore(); } -// --- CREDENCIALES HOMESERVE --- +// --- CREDENCIALES (Lectura de Firestore) --- async function getHomeServeCreds(db) { - // 1. Intentar ENV + // Prioridad 1: ENV const envUser = pickFirstNonEmpty(process.env.HOMESERVE_USER); const envPass = pickFirstNonEmpty(process.env.HOMESERVE_PASS); if (envUser && envPass) return { user: envUser, pass: envPass }; - // 2. Intentar Firestore - const path = CONFIG.HS_CRED_DOC_PATH; + // Prioridad 2: Firestore (providerCredentials/homeserve) + const path = CONFIG.HS_CRED_DOC_PATH; const parts = path.split('/'); + if (parts.length === 2) { const snap = await db.collection(parts[0]).doc(parts[1]).get(); if (snap.exists) { const d = snap.data(); const user = pickFirstNonEmpty(d.user, d.username, d.usuario); const pass = pickFirstNonEmpty(d.pass, d.password, d.clave); + + console.log(`[Creds] Leído de Firestore: Usuario termina en ...${user.slice(-3)}`); // Log seguro if (user && pass) return { user, pass }; } } - - throw new Error('No se encontraron credenciales de HomeServe (ni en ENV ni en Firestore).'); + throw new Error('No se encontraron credenciales en providerCredentials/homeserve'); } -// --- PLAYWRIGHT HELPERS (LA MAGIA DEL CÓDIGO B) --- +// --- PLAYWRIGHT HELPERS --- async function withBrowser(fn) { - const browser = await chromium.launch({ - headless: true, // Pon false si quieres ver lo que hace en local - args: ['--no-sandbox', '--disable-setuid-sandbox'], - }); + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const context = await browser.newContext(); const page = await context.newPage(); - try { - return await fn(page); - } finally { - await browser.close().catch(() => {}); - } + try { return await fn(page); } finally { await browser.close().catch(() => {}); } } -// Busca en todos los iframes (vital para HomeServe) async function findLocatorInFrames(page, selector) { for (const fr of page.frames()) { const loc = fr.locator(selector); - try { - if (await loc.count()) return { frame: fr, locator: loc }; - } catch (_) {} + try { if (await loc.count()) return { frame: fr, locator: loc }; } catch (_) {} } return null; } -// Intenta clickar el primero que encuentre de una lista -async function clickFirstThatExists(page, selectors, opts = {}) { - for (const sel of selectors) { - const hit = await findLocatorInFrames(page, sel); - if (hit) { - await hit.locator.first().click(opts); - return sel; - } - } - return null; -} - -// Intenta llenar texto en el primero que encuentre async function fillFirstThatExists(page, selectors, value) { for (const sel of selectors) { const hit = await findLocatorInFrames(page, sel); - if (hit) { - await hit.locator.first().fill(String(value)); - return sel; - } + if (hit) { await hit.locator.first().fill(String(value)); return sel; } } return null; } -// Lógica inteligente para checkboxes -async function checkInformoClienteIfNeeded(page, enabled) { - if (!enabled) return; - const labels = ['informado al cliente', 'informado al Cliente', 'Marque esta casilla']; - - // 1. Buscar por label directa - for (const txt of labels) { - const hit = await findLocatorInFrames(page, `label:has-text("${txt}") >> input[type="checkbox"]`); - if (hit && !(await hit.locator.first().isChecked())) { - await hit.locator.first().check(); - return; - } +async function clickFirstThatExists(page, selectors) { + for (const sel of selectors) { + const hit = await findLocatorInFrames(page, sel); + if (hit) { await hit.locator.first().click(); return sel; } } + return null; } -// Lógica inteligente para Dropdowns (Selects) -async function selectStatusByCode(page, code) { - const ok = await page.evaluate((codeStr) => { - const selects = Array.from(document.querySelectorAll('select')); - // 1. Buscar por value exacto - for (const s of selects) { - const opt = Array.from(s.options).find(o => o.value.trim() === codeStr); - if (opt) { - s.value = opt.value; - s.dispatchEvent(new Event('change', { bubbles: true })); - return true; - } +// --- DIAGNÓSTICO --- +async function getDebugInfo(page) { + try { + const title = await page.title(); + const url = page.url(); + // Coge los primeros 500 caracteres de texto visible para ver si hay mensajes de error + const bodyText = await page.evaluate(() => document.body.innerText.substring(0, 500).replace(/\n/g, ' ')); + return `URL: ${url} || TITULO: "${title}" || TEXTO: "${bodyText}..."`; + } catch (e) { + return "No se pudo obtener info de debug."; } - // 2. Buscar por texto - for (const s of selects) { - const opt = Array.from(s.options).find(o => o.textContent.includes(codeStr)); - if (opt) { - s.value = opt.value; - s.dispatchEvent(new Event('change', { bubbles: true })); - return true; - } - } - return false; - }, String(code)); - - if (!ok) throw new Error(`No se encontró la opción de estado: ${code} en ningún desplegable.`); } -// --- ACCIONES DE NEGOCIO --- - +// --- LÓGICA PRINCIPAL --- async function loginAndProcess(page, creds, jobData) { - const { serviceNumber, newStatusValue, dateString, observation, informoCliente } = jobData; + console.log('>>> 1. Iniciando navegación a LOGIN...'); + + // 1. IR A LA PÁGINA DE LOGIN CORRECTA + await page.goto(CONFIG.LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT }); + await page.waitForTimeout(1000); - // 1. LOGIN - await page.goto(CONFIG.CLIENTES_CGI_BASE, { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT }); - - const u = await fillFirstThatExists(page, ['input[name*="user" i]', 'input[type="text"]'], creds.user); - const p = await fillFirstThatExists(page, ['input[name*="pass" i]', 'input[type="password"]'], creds.pass); - - if (u && p) { - const clicked = await clickFirstThatExists(page, ['button[type="submit"]', 'input[type="submit"]', 'input[type="image"]']); - if (!clicked) await page.keyboard.press('Enter'); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + // 2. RELLENAR LOGIN + const userFilled = await fillFirstThatExists(page, ['input[name="w3user"]', 'input[name*="user" i]', 'input[type="text"]'], creds.user); + const passFilled = await fillFirstThatExists(page, ['input[name="w3clau"]', 'input[name*="pass" i]', 'input[type="password"]'], creds.pass); + + if (!userFilled || !passFilled) { + const debug = await getDebugInfo(page); + throw new Error(`No encontré los campos de usuario/contraseña. ¿La página cargó bien? DEBUG: ${debug}`); } - // 2. IR AL SERVICIO DIRECTAMENTE - const serviceUrl = new URL(CONFIG.CLIENTES_CGI_BASE); + // 3. CLICK ENTRAR + await page.keyboard.press('Enter'); + // Opcional: click en botón si Enter no va + // await clickFirstThatExists(page, ['input[type="image"]', 'button[type="submit"]']); + + await page.waitForTimeout(3000); // Esperar redirección + + // VERIFICACIÓN DE LOGIN: Si seguimos viendo el campo de password, falló. + const stillLogin = await findLocatorInFrames(page, 'input[type="password"]'); + if (stillLogin) { + const debug = await getDebugInfo(page); + throw new Error(`Parece que el login falló (sigo viendo el input password). DEBUG: ${debug}`); + } + + console.log('>>> 2. Login parece correcto. Buscando servicio...'); + + // 4. IR AL SERVICIO DIRECTAMENTE + const serviceUrl = new URL(CONFIG.BASE_CGI); serviceUrl.searchParams.set('w3exec', 'ver_servicioencurso'); - serviceUrl.searchParams.set('Servicio', String(serviceNumber)); + serviceUrl.searchParams.set('Servicio', String(jobData.serviceNumber)); serviceUrl.searchParams.set('Pag', '1'); await page.goto(serviceUrl.toString(), { waitUntil: 'domcontentloaded', timeout: CONFIG.NAV_TIMEOUT }); - await sleep(1000); + await page.waitForTimeout(2000); - // 3. CLICK EN "CAMBIAR ESTADO" (REPASO) + // 5. BUSCAR BOTÓN "CAMBIAR ESTADO" (REPASO) const changeBtn = await clickFirstThatExists(page, [ 'input[name="repaso"]', 'input[title*="Cambiar el Estado" i]', 'input[src*="estado1.gif" i]' ]); - if (!changeBtn) throw new Error('No se encontró el botón de cambiar estado (repaso). ¿Login fallido?'); + if (!changeBtn) { + const debug = await getDebugInfo(page); + throw new Error(`Login OK, pero NO veo el botón 'repaso' en el servicio ${jobData.serviceNumber}. ¿El parte está cerrado? DEBUG: ${debug}`); + } + + // 6. CAMBIAR ESTADO await page.waitForLoadState('domcontentloaded'); - await sleep(1000); + await page.waitForTimeout(1000); - // 4. RELLENAR FORMULARIO - // Estado - await selectStatusByCode(page, newStatusValue); + // Seleccionar estado + const statusOk = await page.evaluate((code) => { + const s = document.querySelector('select'); // Asumimos que es el primer select principal + if (!s) return false; + for (const opt of s.options) { + if (opt.value == code || opt.text.includes(code)) { + s.value = opt.value; + return true; + } + } + return false; + }, jobData.newStatusValue); - // Fecha (si aplica) - if (dateString) { - await fillFirstThatExists(page, ['input[name*="fecha" i]', 'input[id*="fecha" i]', 'input[size="10"]'], dateString); + if (!statusOk) throw new Error(`No encontré el estado ${jobData.newStatusValue} en el desplegable.`); + + if (jobData.observation) { + await fillFirstThatExists(page, ['textarea'], jobData.observation); } - // Nota - if (observation) { - await fillFirstThatExists(page, ['textarea[name*="obs" i]', 'textarea[name*="nota" i]', 'textarea'], observation); - } - - // Checkbox cliente - await checkInformoClienteIfNeeded(page, informoCliente); - - // 5. GUARDAR - const saveBtn = await clickFirstThatExists(page, [ - 'input[type="submit"][value*="Enviar" i]', - 'input[type="submit"][value*="Guardar" i]', - 'button:has-text("Enviar")', - 'button:has-text("Guardar")' - ]); + // 7. GUARDAR + const saveBtn = await clickFirstThatExists(page, ['input[value*="Enviar" i]', 'input[value*="Guardar" i]']); + if (!saveBtn) throw new Error('No encuentro botón Guardar.'); - if (!saveBtn) throw new Error('No se encontró el botón de Guardar.'); - - await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {}); - - return { success: true, serviceUrl: serviceUrl.toString() }; + await page.waitForTimeout(3000); + return { success: true }; } -// --- GESTIÓN DE COLAS (EL CEREBRO DEL CÓDIGO A) --- - +// --- GESTIÓN DE COLAS --- async function claimJobById(db, jobId) { - const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); - return await db.runTransaction(async (tx) => { - const snap = await tx.get(ref); - if (!snap.exists) return null; - - const d = snap.data(); - const st = d.status || 'PENDING'; - const claimedAt = d.claimedAt ? d.claimedAt.toMillis() : 0; - const now = Date.now(); - const isStale = (st === 'RUNNING') && ((now - claimedAt) > (CONFIG.CLAIM_TTL_MINUTES * 60 * 1000)); - - // Solo cogemos PENDING o RUNNING caducados - if (st !== 'PENDING' && !isStale) return null; - - tx.set(ref, { - status: 'RUNNING', - claimedAt: toServerTimestamp(), - lastSeenAt: toServerTimestamp(), - workerId: process.env.HOSTNAME || 'worker-local' - }, { merge: true }); - - return { id: jobId, ...d }; - }); + const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); + return await db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + if (!snap.exists || snap.data().status !== 'PENDING') return null; + tx.set(ref, { status: 'RUNNING', claimedAt: toServerTimestamp() }, { merge: true }); + return { id: jobId, ...snap.data() }; + }); } async function markJobDone(db, jobId, result) { - await db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId).set({ - status: 'DONE', - finishedAt: toServerTimestamp(), - result: result - }, { merge: true }); - - await db.collection(CONFIG.RESULT_COLLECTION).add({ - jobId, - ok: true, - ...result, - createdAt: toServerTimestamp() - }); + await db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId).set({ status: 'DONE', result }, { merge: true }); + await db.collection(CONFIG.RESULT_COLLECTION).add({ jobId, ok: true, ...result, createdAt: toServerTimestamp() }); } -async function markJobFailed(db, jobId, error) { - const errData = { - message: String(error?.message || error), - stack: String(error?.stack || '') - }; - - await db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId).set({ - status: 'FAILED', - finishedAt: toServerTimestamp(), - error: errData - }, { merge: true }); - - await db.collection(CONFIG.RESULT_COLLECTION).add({ - jobId, - ok: false, - error: errData, - createdAt: toServerTimestamp() - }); +async function markJobFailed(db, jobId, err) { + const errData = { message: String(err?.message || err) }; + await db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId).set({ status: 'FAILED', error: errData }, { merge: true }); + await db.collection(CONFIG.RESULT_COLLECTION).add({ jobId, ok: false, error: errData, createdAt: toServerTimestamp() }); } -// --- BUCLE PRINCIPAL --- - async function processJob(db, job) { - console.log(`>>> Procesando Job: ${job.id}`); - - // Mapeo de campos flexibles (para que acepte inputs variados) - const jobData = { - serviceNumber: job.parteId || job.serviceNumber || job.codigo, - newStatusValue: job.nuevoEstado || job.newStatusValue || job.statusCode, - dateString: job.fecha || job.dateString || '', - observation: job.nota || job.observation || '', - informoCliente: job.informoCliente || false - }; - - if (!jobData.serviceNumber || !jobData.newStatusValue) { - await markJobFailed(db, job.id, new Error('Faltan datos obligatorios: serviceNumber o newStatusValue')); - return; - } - + console.log(`>>> Procesando: ${job.id}`); try { const creds = await getHomeServeCreds(db); - await withBrowser(async (page) => { - const res = await loginAndProcess(page, creds, jobData); + const res = await loginAndProcess(page, creds, { + serviceNumber: job.parteId || job.serviceNumber, + newStatusValue: job.nuevoEstado || job.newStatusValue, + observation: job.nota || '' + }); await markJobDone(db, job.id, res); - console.log(`✅ Job ${job.id} completado.`); + console.log(`✅ OK: ${job.id}`); }); } catch (err) { - console.error(`❌ Job ${job.id} falló:`, err.message); + console.error(`❌ ERROR: ${err.message}`); await markJobFailed(db, job.id, err); } } -// Listener Reactivo function startWorker(db) { const queue = []; - let isProcessing = false; - - const processQueue = async () => { - if (isProcessing) return; - isProcessing = true; - while (queue.length > 0) { - const jobId = queue.shift(); - const job = await claimJobById(db, jobId); - if (job) await processJob(db, job); - } - isProcessing = false; + const run = async () => { + while(queue.length) { await processJob(db, await claimJobById(db, queue.shift())); } }; - - const enqueue = (id) => { - if (!queue.includes(id)) { - queue.push(id); - processQueue(); - } - }; - - // 1. Escuchar nuevos - db.collection(CONFIG.QUEUE_COLLECTION) - .where('status', '==', 'PENDING') - .onSnapshot(snap => { - snap.docChanges().forEach(change => { - if (change.type === 'added') enqueue(change.doc.id); - }); - }); - - // 2. Rescaneo de seguridad (polling) - setInterval(async () => { - const snap = await db.collection(CONFIG.QUEUE_COLLECTION) - .where('status', '==', 'PENDING') - .limit(10).get(); - snap.forEach(doc => enqueue(doc.id)); - }, CONFIG.RESCAN_SECONDS * 1000); - - console.log('🚀 Worker HomeServe iniciado. Esperando trabajos en Firestore...'); + db.collection(CONFIG.QUEUE_COLLECTION).where('status', '==', 'PENDING').onSnapshot(s => { + s.docChanges().forEach(c => { if(c.type==='added') { queue.push(c.doc.id); run(); } }); + }); + console.log('🚀 Worker V2 LISTO'); } -// Start -const db = initFirebase(); -startWorker(db); \ No newline at end of file +startWorker(initFirebase()); \ No newline at end of file