// 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'); 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'), // Colección donde tu app mete solicitudes de cambio de estado // Docs recomendados: // { // parteId: "12345678" | codigoParte, // nuevoEstado: "EN_RUTA" | "EN_CURSO" | "FINALIZADO" | "NO_LOCALIZADO" | "CERRADO" | "ANULADO", // nota: "texto opcional", // requestedBy: "marsalva-app", // createdAt: serverTimestamp // } QUEUE_COLLECTION: process.env.QUEUE_COLLECTION || 'homeserve_cambios_estado', RESULT_COLLECTION: process.env.RESULT_COLLECTION || 'homeserve_cambios_estado_log', // Control de loop POLL_SECONDS: parseInt(process.env.POLL_SECONDS || '20', 10), CLAIM_TTL_MINUTES: parseInt(process.env.CLAIM_TTL_MINUTES || '10', 10), // Selectores (ajústalos a tu portal real si difieren) 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"]', // búsqueda de parte/servicio 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")', // entrar al detalle del parte openRow: process.env.SEL_OPEN_ROW || 'table tbody tr:first-child', // cambio de estado statusDropdown: process.env.SEL_STATUS_DROPDOWN || 'select[name*="estado"], select[id*="estado"], select:has(option)', statusOptionByText: process.env.SEL_STATUS_OPTION_BY_TEXT || null, // si quieres forzar otro método 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(); } 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) { // intenta buscar por el buscador 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); } // abre primera fila (ajusta si tu portal tiene un link directo) 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 }); // selecciona por label visible (texto) let selected = false; for (const label of candidates) { try { await page.selectOption(CONFIG.SEL.statusDropdown, { label }); selected = true; break; } catch (_) {} } if (!selected) { // fallback: intenta elegir por contenido del DOM 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); } async function claimNextJob(db) { const now = nowMs(); const ttlMs = CONFIG.CLAIM_TTL_MINUTES * 60 * 1000; const snap = await db.collection(CONFIG.QUEUE_COLLECTION) .where('status', 'in', ['PENDING', null]) .orderBy('createdAt', 'asc') .limit(10) .get(); if (snap.empty) return null; for (const doc of snap.docs) { const ref = doc.ref; const data = doc.data() || {}; const claimedAt = data.claimedAt?.toMillis ? data.claimedAt.toMillis() : null; const isStale = claimedAt && (now - claimedAt > ttlMs); try { const res = await db.runTransaction(async (tx) => { const fresh = await tx.get(ref); const d = fresh.data() || {}; const st = d.status ?? 'PENDING'; const cAt = d.claimedAt?.toMillis ? d.claimedAt.toMillis() : null; const stale = cAt && (now - cAt > ttlMs); if (st === 'DONE' || st === 'RUNNING') { if (!stale) return null; } tx.update(ref, { status: 'RUNNING', claimedAt: toServerTimestamp(), claimedBy: process.env.HOSTNAME || 'estados-homeserve', lastSeenAt: toServerTimestamp(), }); return { id: ref.id, ...d }; }); if (res) return { id: doc.id, ...data }; } catch (_) { // otro worker lo pilló } } return null; } 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 processOne(db) { const job = await claimNextJob(db); if (!job) return false; 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 true; } 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); } return true; } async function main() { const db = initFirebase(); while (true) { const did = await processOne(db); if (!did) await sleep(CONFIG.POLL_SECONDS * 1000); } } main().catch((e) => { console.error(e); process.exit(1); });