From fedbdbf2da4d424ca2cd90f353a92c4aef83d811 Mon Sep 17 00:00:00 2001 From: marsalva Date: Fri, 26 Dec 2025 08:41:21 +0000 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20index.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.js | 314 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 index.js diff --git a/index.js b/index.js new file mode 100644 index 0000000..2115230 --- /dev/null +++ b/index.js @@ -0,0 +1,314 @@ +// 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); +}); \ No newline at end of file