// estados-homeserve/index.js 'use strict'; const { chromium } = require('playwright'); const admin = require('firebase-admin'); function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } function initFirebase() { if (!process.env.FIREBASE_PRIVATE_KEY) throw new Error('Missing env: FIREBASE_PRIVATE_KEY'); if (!admin.apps.length) { admin.initializeApp({ credential: admin.credential.cert({ projectId: mustEnv('FIREBASE_PROJECT_ID'), clientEmail: mustEnv('FIREBASE_CLIENT_EMAIL'), privateKey: mustEnv('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'), }), }); } return admin.firestore(); } const CONFIG = { HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', HOMESERVE_USER: mustEnv('HOMESERVE_USER'), HOMESERVE_PASS: mustEnv('HOMESERVE_PASS'), QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado', RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log', // TTL de claim (si un worker muere, otro puede reintentar) CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10), // rescaneo por si un listener se pierde un evento (seguridad) RESCAN_SECONDS: parseInt(process.env.RESCAN_SECONDS || '60', 10), SEL: { user: process.env.SEL_USER || 'input[type="text"]', pass: process.env.SEL_PASS || 'input[type="password"]', submit: process.env.SEL_SUBMIT || 'button[type="submit"]', searchBox: process.env.SEL_SEARCH_BOX || 'input[placeholder*="Buscar"], input[type="search"]', searchBtn: process.env.SEL_SEARCH_BTN || 'button:has-text("Buscar"), button:has-text("Search")', openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child', statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)', noteTextarea: process.env.SEL_NOTE_TEXTAREA || 'textarea[name*="nota"], textarea[id*="nota"], textarea', saveBtn: process.env.SEL_SAVE_BTN || 'button:has-text("Guardar"), button:has-text("Save"), button:has-text("Actualizar")', }, }; const STATE_MAP = { EN_RUTA: ['De camino', 'En ruta', 'En camino'], EN_CURSO: ['En curso', 'Trabajando', 'En intervención'], FINALIZADO: ['Finalizado', 'Finalizada', 'Terminado'], NO_LOCALIZADO: ['No localizado', 'No localizable', 'Ausente'], CERRADO: ['Cerrado', 'Cierre', 'Cerrada'], ANULADO: ['Anulado', 'Cancelado', 'Cancelada'], }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function nowMs() { return Date.now(); } function toServerTimestamp() { return admin.firestore.FieldValue.serverTimestamp(); } function isPendingStatus(st) { return st === undefined || st === null || st === 'PENDING'; } async function withBrowser(fn) { 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(() => {}); } } async function login(page) { await page.goto(CONFIG.HOMESERVE_BASE_URL, { waitUntil: 'domcontentloaded', timeout: 120000 }); await page.waitForSelector(CONFIG.SEL.user, { timeout: 60000 }); await page.fill(CONFIG.SEL.user, CONFIG.HOMESERVE_USER); await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 }); await page.fill(CONFIG.SEL.pass, CONFIG.HOMESERVE_PASS); const btn = await page.$(CONFIG.SEL.submit); if (btn) await btn.click(); else await page.keyboard.press('Enter'); await page.waitForLoadState('networkidle', { timeout: 120000 }); } async function openParte(page, parteId) { const hasSearch = await page.$(CONFIG.SEL.searchBox); if (hasSearch) { await page.fill(CONFIG.SEL.searchBox, String(parteId)); const btn = await page.$(CONFIG.SEL.searchBtn); if (btn) await btn.click(); else await page.keyboard.press('Enter'); await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1500); } await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); await page.click(CONFIG.SEL.openRow); await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1200); } async function setEstado(page, nuevoEstado, nota) { const candidates = STATE_MAP[nuevoEstado] || [nuevoEstado]; await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); let selected = false; for (const label of candidates) { try { await page.selectOption(CONFIG.SEL.statusDropdown, { label }); selected = true; break; } catch (_) {} } if (!selected) { const ok = await page.evaluate(({ sel, candidates }) => { const s = document.querySelector(sel); if (!s) return false; const opts = Array.from(s.querySelectorAll('option')); const hit = opts.find(o => candidates.some(c => (o.textContent || '').trim().toLowerCase() === c.trim().toLowerCase()) ); if (!hit) return false; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; }, { sel: CONFIG.SEL.statusDropdown, candidates }); if (!ok) throw new Error(`No matching status option for "${nuevoEstado}"`); } if (nota) { const ta = await page.$(CONFIG.SEL.noteTextarea); if (ta) await page.fill(CONFIG.SEL.noteTextarea, String(nota)); } const save = await page.$(CONFIG.SEL.saveBtn); if (!save) throw new Error('Save button not found'); await save.click(); await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(1500); } // Claim por ID (mantiene la “seguridad” si hay 2 robots) async function claimJobById(db, jobId) { const now = nowMs(); const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000; const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); const res = 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?.toMillis ? d.claimedAt.toMillis() : null; const isStale = claimedAt && (now - claimedAt > ttlMs); if (st === 'DONE') return null; if (st === 'RUNNING' && !isStale) return null; if (!isPendingStatus(st) && st !== 'FAILED' && !(st === 'RUNNING' && isStale)) { // Si alguien mete estados raros, lo ignoramos return null; } tx.set(ref, { status: 'RUNNING', claimedAt: toServerTimestamp(), claimedBy: process.env.HOSTNAME || 'estados-homeserve', lastSeenAt: toServerTimestamp(), }, { merge: true }); return { id: jobId, ...d }; }); return res; } async function markDone(db, jobId, result) { const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); await ref.set({ status: 'DONE', finishedAt: toServerTimestamp(), result, lastSeenAt: toServerTimestamp(), }, { merge: true }); await db.collection(CONFIG.RESULT_COLLECTION).add({ jobId, ...result, createdAt: toServerTimestamp(), }); } async function markFailed(db, jobId, err) { const ref = db.collection(CONFIG.QUEUE_COLLECTION).doc(jobId); await ref.set({ status: 'FAILED', finishedAt: toServerTimestamp(), error: { message: String(err?.message || err), stack: String(err?.stack || ''), }, lastSeenAt: toServerTimestamp(), }, { merge: true }); await db.collection(CONFIG.RESULT_COLLECTION).add({ jobId, ok: false, error: { message: String(err?.message || err), stack: String(err?.stack || ''), }, createdAt: toServerTimestamp(), }); } async function processJob(db, job) { const jobId = job.id; const parteId = job.parteId || job.parte || job.codigo || job.serviceId; const nuevoEstado = job.nuevoEstado || job.estado || job.statusTo; const nota = job.nota || job.note || ''; if (!parteId || !nuevoEstado) { await markFailed(db, jobId, new Error('Job missing parteId or nuevoEstado')); return; } try { const started = new Date().toISOString(); await withBrowser(async (page) => { await login(page); await openParte(page, parteId); await setEstado(page, nuevoEstado, nota); }); await markDone(db, jobId, { ok: true, startedAtISO: started, parteId: String(parteId), nuevoEstado: String(nuevoEstado), nota: String(nota || ''), }); } catch (err) { await markFailed(db, jobId, err); } } /** * Modo reactivo: * - Listener Firestore detecta jobs PENDING y los mete en cola interna * - Procesamos de uno en uno (Playwright mejor así) */ function createWorker(db) { const queue = []; const queued = new Set(); let running = false; async function drain() { if (running) return; running = true; try { while (queue.length) { const id = queue.shift(); queued.delete(id); const claimed = await claimJobById(db, id); if (!claimed) continue; // otro worker lo pilló o ya no aplica await processJob(db, claimed); } } finally { running = false; } } function enqueue(id) { if (queued.has(id)) return; queued.add(id); queue.push(id); // Arranca al momento, sin esperar drain().catch(console.error); } async function rescanPending() { try { const snap = await db.collection(CONFIG.QUEUE_COLLECTION) .orderBy('createdAt', 'asc') .limit(50) .get(); snap.forEach((doc) => { const d = doc.data() || {}; if (isPendingStatus(d.status)) enqueue(doc.id); }); } catch (e) { console.error('Rescan error:', e); } } function startListener() { // Listener general (evita problemas de query con null en "in") return db.collection(CONFIG.QUEUE_COLLECTION) .orderBy('createdAt', 'asc') .limit(50) .onSnapshot((snap) => { for (const ch of snap.docChanges()) { if (ch.type !== 'added' && ch.type !== 'modified') continue; const d = ch.doc.data() || {}; if (isPendingStatus(d.status)) enqueue(ch.doc.id); } }, (err) => { console.error('onSnapshot error:', err); }); } return { startListener, rescanPending }; } async function main() { const db = initFirebase(); const worker = createWorker(db); const unsubscribe = worker.startListener(); // rescaneo “por si acaso” await worker.rescanPending(); setInterval(() => worker.rescanPending(), CONFIG.RESCAN_SECONDS * 1000); // no salimos nunca (servicio) process.on('SIGINT', () => { try { unsubscribe && unsubscribe(); } catch (_) {} process.exit(0); }); process.on('SIGTERM', () => { try { unsubscribe && unsubscribe(); } catch (_) {} process.exit(0); }); console.log('✅ estados-homeserve listo (modo reactivo, sin polling).'); } main().catch((e) => { console.error(e); process.exit(1); });