'use strict'; /** * estados-hs-direct (HomeServe) * - API: POST /api/homeserve/change-status * - Health: GET /health * - Test UI: GET /test * * Lee credenciales: * 1) env HOMESERVE_USER/HOMESERVE_PASS (si existen) * 2) Firestore doc HS_CRED_DOC_PATH (por defecto: providerCredentials/homeserve) con { user, pass, baseUrl? } * * Navegación: * - Abre servicio: https://www.clientes.homeserve.es/cgi-bin/fccgi.exe?w3exec=ver_servicioencurso&Servicio=XXXX&Pag=1 * - Click botón cambio estado: input[type="image"][name="repaso"] (o title contiene "Cambiar el Estado del Servicio") * - En formulario: select estado (normalmente name="D1" o similar) -> selecciona por value (código) * - Checkbox "ya informado al Cliente" (si informoCliente=true) * - Guardar/Aceptar/Actualizar */ const express = require('express'); const cors = require('cors'); const { chromium } = require('playwright'); const admin = require('firebase-admin'); // --------------------- Utils --------------------- function mustEnv(name) { const v = process.env[name]; if (!v) throw new Error(`Missing env: ${name}`); return v; } function toBool(v) { if (v === true || v === false) return v; if (v == null) return false; const s = String(v).trim().toLowerCase(); return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === 'on'; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function safeStr(x) { return (x == null) ? '' : String(x); } function isoNow() { return new Date().toISOString(); } function pickPort() { // CapRover normalmente usa process.env.PORT const p = process.env.PORT || process.env.CAPROVER_PORT || '3000'; const n = parseInt(p, 10); return Number.isFinite(n) ? n : 3000; } // --------------------- Firebase --------------------- 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(); } // --------------------- Config --------------------- const CONFIG = { REQUIRE_AUTH: toBool(process.env.REQUIRE_AUTH || '0'), API_TOKEN: process.env.API_TOKEN || '', HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve', // Si NO viene baseUrl en Firestore ni env, usamos esta HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://www.clientes.homeserve.es/', // Selectores HomeServe (con fallback) SEL: { // Login (muy genérico, porque HomeServe cambia formularios) loginUserCandidates: [ 'input[name="user"]', 'input[name="usuario"]', 'input[name="username"]', 'input[id*="user" i]', 'input[id*="usu" i]', 'input[type="text"]', ], loginPassCandidates: [ 'input[name="pass"]', 'input[name="password"]', 'input[name="clave"]', 'input[id*="pass" i]', 'input[id*="clave" i]', 'input[type="password"]', ], loginSubmitCandidates: [ 'button[type="submit"]', 'input[type="submit"]', 'input[type="image"]', 'button:has-text("Entrar")', 'button:has-text("Acceder")', 'input[value*="Entrar" i]', 'input[value*="Acceder" i]', ], // Servicio -> botón cambio estado (repaso) changeStateBtnCandidates: [ 'input[type="image"][name="repaso"]', 'input[type="image"][title*="Cambiar el Estado del Servicio" i]', 'input[type="image"][title*="Cambiar el Estado" i]', 'input[name="repaso"]', ], // Form cambio de estado statusSelectCandidates: [ 'select[name="D1"]', 'select[id="D1"]', 'select[name*="estado" i]', 'select[id*="estado" i]', 'select', ], dateInputCandidates: [ 'input[name="D2"]', 'input[id="D2"]', 'input[name*="fecha" i]', 'input[id*="fecha" i]', 'input[type="text"]', ], obsCandidates: [ 'textarea[name="D3"]', 'textarea[id="D3"]', 'textarea[name*="obs" i]', 'textarea[id*="obs" i]', 'textarea[name*="nota" i]', 'textarea[id*="nota" i]', 'textarea', ], saveBtnCandidates: [ 'input[type="submit"]', 'button[type="submit"]', 'input[type="image"][name*="grabar" i]', 'input[type="image"][title*="Guardar" i]', 'button:has-text("Guardar")', 'button:has-text("Aceptar")', 'button:has-text("Actualizar")', 'input[value*="Guardar" i]', 'input[value*="Aceptar" i]', 'input[value*="Actualizar" i]', ], }, // Estados permitidos (los de tu Swift) STATUS_OPTIONS: [ { code: '303', title: 'En espera de Cliente por aceptación Presupuesto' }, { code: '307', title: 'En espera de Profesional por fecha de inicio de trabajos' }, { code: '313', title: 'En espera de Profesional por secado de cala, pintura o parquet' }, { code: '318', title: 'En espera de Profesional por confirmación del Siniestro' }, { code: '319', title: 'En espera de Profesional por material' }, { code: '320', title: 'En espera de Profesional por espera de otro gremio' }, { code: '321', title: 'En espera de Profesional por presupuesto/valoración' }, { code: '323', title: 'En espera de Profesional por mejora del tiempo' }, { code: '326', title: 'En espera de Cliente por pago de Factura Contado/Franquicia' }, { code: '336', title: 'En espera de Profesional por avería en observación' }, { code: '342', title: 'En espera de Profesional pendiente cobro franquicia' }, { code: '345', title: 'En espera de Profesional en realización pendiente Terminar' }, { code: '348', title: 'En espera de Cliente por indicaciones' }, { code: '352', title: 'En espera de Perjudicado por indicaciones' }, ], }; // --------------------- Creds --------------------- async function getHomeServeCreds(db) { // 1) env manda const envUser = process.env.HOMESERVE_USER; const envPass = process.env.HOMESERVE_PASS; if (envUser && envPass) { return { user: String(envUser), pass: String(envPass), baseUrl: process.env.HOMESERVE_BASE_URL || CONFIG.HOMESERVE_BASE_URL, source: 'env', }; } // 2) Firestore doc const ref = db.doc(CONFIG.HS_CRED_DOC_PATH); const snap = await ref.get(); const d = snap.exists ? (snap.data() || {}) : null; const user = d?.user || d?.username || d?.usuario; const pass = d?.pass || d?.password || d?.clave; const baseUrl = d?.baseUrl || d?.baseURL || d?.url || CONFIG.HOMESERVE_BASE_URL; if (!user || !pass) { throw new Error( `HomeServe creds missing. Revisa Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" con { user, pass, baseUrl? } o usa env HOMESERVE_USER/HOMESERVE_PASS` ); } return { user: String(user), pass: String(pass), baseUrl: String(baseUrl), source: CONFIG.HS_CRED_DOC_PATH }; } // --------------------- Browser helpers --------------------- async function withBrowser(fn) { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); const context = await browser.newContext({ viewport: { width: 1365, height: 768 }, }); const page = await context.newPage(); page.setDefaultTimeout(60000); try { return await fn(page); } finally { await browser.close().catch(() => {}); } } async function findFirstHandle(page, selectors) { for (const sel of selectors) { try { const h = await page.$(sel); if (h) return { handle: h, selector: sel }; } catch (_) {} } return null; } async function fillFirst(page, selectors, value) { const found = await findFirstHandle(page, selectors); if (!found) return false; await found.handle.fill(String(value)); return true; } async function clickFirst(page, selectors) { const found = await findFirstHandle(page, selectors); if (!found) return false; await found.handle.click(); return true; } function buildServiceUrl(baseUrl, serviceNumber) { // baseUrl puede venir como dominio o con path; garantizamos el CGI correcto const origin = new URL(baseUrl).origin; const u = new URL(origin + '/cgi-bin/fccgi.exe'); u.searchParams.set('w3exec', 'ver_servicioencurso'); u.searchParams.set('Servicio', String(serviceNumber)); u.searchParams.set('Pag', '1'); return u.toString(); } async function maybeLogin(page, creds) { // Intentamos abrir baseUrl. Si ya está logueado, perfecto. const base = creds.baseUrl || CONFIG.HOMESERVE_BASE_URL; await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120000 }); await sleep(700); // Si vemos algo típico del portal ya logueado, salimos (heurístico) const alreadyOk = await page.$('input[type="image"][name="repaso"], a[href*="ver_servicioencurso" i], form'); if (!alreadyOk) { // Igual es una landing rara, seguimos. } // Si NO existe password input, probablemente no es login o ya está logueado const passHandle = await findFirstHandle(page, CONFIG.SEL.loginPassCandidates); if (!passHandle) return; // Usuario const userOk = await fillFirst(page, CONFIG.SEL.loginUserCandidates, creds.user); // Pass const passOk = await fillFirst(page, CONFIG.SEL.loginPassCandidates, creds.pass); if (!userOk || !passOk) { // Si no pudimos, que no reviente aquí: lo intentaremos al entrar al servicio return; } // Submit const clicked = await clickFirst(page, CONFIG.SEL.loginSubmitCandidates); if (!clicked) { // fallback: enter await page.keyboard.press('Enter').catch(() => {}); } await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); await sleep(900); } async function goToService(page, serviceNumber, creds) { const url = buildServiceUrl(creds.baseUrl || CONFIG.HOMESERVE_BASE_URL, serviceNumber); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 }); await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); await sleep(700); // Si te manda a login, intentamos login y volvemos a ir const hasPassword = await page.$('input[type="password"]'); if (hasPassword) { await maybeLogin(page, creds); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 }); await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); await sleep(700); } return url; } async function clickChangeState(page) { const ok = await clickFirst(page, CONFIG.SEL.changeStateBtnCandidates); if (!ok) { // fallback por texto/atributos const ok2 = await page.evaluate(() => { const inputs = Array.from(document.querySelectorAll('input[type="image"], input[type="submit"], button')); const hit = inputs.find((el) => { const t = (el.getAttribute('title') || el.getAttribute('name') || el.getAttribute('value') || '').toLowerCase(); return t.includes('cambiar') && t.includes('estado'); }); if (!hit) return false; hit.click(); return true; }); if (!ok2) throw new Error('No encuentro el botón de "Cambiar estado" (repaso).'); } await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); await sleep(900); } async function selectStatusCode(page, code) { // Preferimos seleccionar por value (porque tus códigos son los valores) const found = await findFirstHandle(page, CONFIG.SEL.statusSelectCandidates); if (!found) throw new Error('No encuentro el desplegable de estado (select).'); const sel = found.selector; // Primero por value exacto try { await page.selectOption(sel, { value: String(code) }); return; } catch (_) { // Luego intentamos por label que contenga el código } const ok = await page.evaluate(({ sel, code }) => { const s = document.querySelector(sel); if (!s) return false; const opts = Array.from(s.querySelectorAll('option')); const hit = opts.find(o => (o.getAttribute('value') || '').trim() === String(code).trim()) || opts.find(o => (o.textContent || '').includes(String(code))); if (!hit) return false; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; }, { sel, code }); if (!ok) throw new Error(`No encuentro opción para código de estado "${code}".`); } async function fillDate(page, dateString) { if (!dateString) return; // En HomeServe suele ser input text DD/MM/AAAA // Buscamos uno "razonable" y lo rellenamos. const found = await findFirstHandle(page, CONFIG.SEL.dateInputCandidates); if (!found) return; // Intento simple: fill try { await found.handle.fill(String(dateString)); } catch (_) {} } async function fillObservation(page, observation) { if (!observation) return; const found = await findFirstHandle(page, CONFIG.SEL.obsCandidates); if (!found) return; try { await found.handle.fill(String(observation)); } catch (_) {} } async function setInformoCliente(page, informoCliente) { if (!informoCliente) return; // 1) getByLabel (si existe) try { const locator = page.getByLabel(/Marque esta casilla.*informado al Cliente/i); if (await locator.count()) { await locator.first().check({ force: true }); return; } } catch (_) {} // 2) buscar checkbox por texto alrededor const ok = await page.evaluate(() => { const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"]')); const hit = checkboxes.find((cb) => { const rowText = (cb.closest('tr')?.innerText || cb.parentElement?.innerText || '').toLowerCase(); return rowText.includes('informado') && rowText.includes('cliente'); }); if (!hit) return false; hit.checked = true; hit.dispatchEvent(new Event('change', { bubbles: true })); hit.dispatchEvent(new Event('click', { bubbles: true })); return true; }); if (!ok) { // No lo hacemos fatal: mejor seguir que romper el cambio por una casilla console.warn('[estados-hs] ⚠️ No encontré la casilla "ya informado al Cliente".'); } } async function clickSave(page) { const ok = await clickFirst(page, CONFIG.SEL.saveBtnCandidates); if (!ok) { const ok2 = await page.evaluate(() => { const els = Array.from(document.querySelectorAll('button,input')); const hit = els.find((el) => { const t = (el.getAttribute('value') || el.textContent || el.getAttribute('title') || '').toLowerCase(); return t.includes('guardar') || t.includes('aceptar') || t.includes('actualizar') || t.includes('grabar'); }); if (!hit) return false; hit.click(); return true; }); if (!ok2) throw new Error('No encuentro el botón de Guardar/Aceptar/Actualizar del cambio de estado.'); } await page.waitForLoadState('domcontentloaded', { timeout: 120000 }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 120000 }).catch(() => {}); await sleep(1200); } // --------------------- Main action --------------------- async function changeStatusViaClientesPortal(db, payload) { const creds = await getHomeServeCreds(db); const serviceNumber = String(payload.serviceNumber || '').trim(); const newStatusValue = String(payload.newStatusValue || '').trim(); const dateString = String(payload.dateString || '').trim(); const observation = String(payload.observation || '').trim(); const informoCliente = toBool(payload.informoCliente); const startedAtISO = isoNow(); const result = await withBrowser(async (page) => { // Login “suave” await maybeLogin(page, creds); // Ir al servicio (si no hay sesión, reintenta login) const serviceUrl = await goToService(page, serviceNumber, creds); // Click botón repaso (cambio estado) await clickChangeState(page); // Set campos await selectStatusCode(page, newStatusValue); await fillDate(page, dateString); await fillObservation(page, observation); await setInformoCliente(page, informoCliente); // Guardar await clickSave(page); return { serviceUrl }; }); return { ok: true, serviceNumber, newStatusValue, dateString, observation, informoCliente, startedAtISO, finishedAtISO: isoNow(), ...result, credsSource: creds.source, baseUrl: creds.baseUrl, }; } // --------------------- Express app --------------------- function requireAuthIfNeeded(req, res) { if (!CONFIG.REQUIRE_AUTH) return true; const auth = (req.headers.authorization || '').trim(); const x = (req.headers['x-auth'] || '').toString().trim(); const token = CONFIG.API_TOKEN.trim(); const got = (auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '') || x; if (!token) { // Si REQUIRE_AUTH=1 pero no hay token configurado, mejor bloquear. res.status(500).json({ ok: false, error: { message: 'REQUIRE_AUTH=1 pero falta API_TOKEN en env.' } }); return false; } if (got !== token) { res.status(401).json({ ok: false, error: { message: 'Unauthorized' } }); return false; } return true; } function makeTestHtml(defaultBase) { const options = CONFIG.STATUS_OPTIONS.map((o) => `` ).join('\n'); return ` estados-hs · tester

estados-hs · tester

Consejo rápido: si ves ok en / y JSON en /health, el servicio está vivo. Luego ya nos peleamos con HomeServe 😅
POST /api/homeserve/change-status
Listo.
`; } function escapeHtml(s) { return String(s) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } // --------------------- Boot --------------------- async function main() { const db = initFirebase(); const app = express(); app.use(cors()); app.use(express.json({ limit: '1mb' })); app.get('/', (req, res) => res.status(200).send('ok')); app.get('/health', (req, res) => { res.json({ ok: true, service: 'estados-hs', port: pickPort(), requireAuth: CONFIG.REQUIRE_AUTH, hsCredDocPath: CONFIG.HS_CRED_DOC_PATH, ts: isoNow(), }); }); app.get('/test', (req, res) => { // Sirve el tester embebido const base = `${req.protocol}://${req.get('host')}`; res.status(200).type('text/html').send(makeTestHtml(base)); }); app.post('/api/homeserve/change-status', async (req, res) => { if (!requireAuthIfNeeded(req, res)) return; const startedAtISO = isoNow(); try { const body = req.body || {}; const serviceNumber = String(body.serviceNumber || '').trim(); const newStatusValue = String(body.newStatusValue || '').trim(); if (!serviceNumber) { return res.status(400).json({ ok: false, error: { message: 'Missing serviceNumber' } }); } if (!newStatusValue) { return res.status(400).json({ ok: false, error: { message: 'Missing newStatusValue' } }); } // Validación ligera: que sea uno de tus códigos const allowed = new Set(CONFIG.STATUS_OPTIONS.map(o => o.code)); if (!allowed.has(newStatusValue)) { return res.status(400).json({ ok: false, error: { message: `Estado no permitido: ${newStatusValue}` }, allowed: Array.from(allowed), }); } const out = await changeStatusViaClientesPortal(db, body); return res.status(200).json(out); } catch (e) { return res.status(500).json({ ok: false, startedAtISO, finishedAtISO: isoNow(), error: { message: String(e?.message || e), stack: String(e?.stack || ''), }, }); } }); const port = pickPort(); app.listen(port, '0.0.0.0', () => { console.log(`[estados-hs] listening on :${port}`); console.log(`[estados-hs] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`); console.log(`[estados-hs] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? '1' : '0'}`); console.log(`[estados-hs] ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`); }); } main().catch((e) => { console.error(e); process.exit(1); });