// index.js 'use strict'; const express = require('express'); const cors = require('cors'); 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 (admin.apps?.length) return admin.firestore(); // Requiere estas 3 env (como ya usas en tus robots) 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 db = initFirebase(); // ===== Config ===== const CONFIG = { SERVICE_NAME: 'estados-hs', HOMESERVE_BASE_URL: process.env.HOMESERVE_BASE_URL || 'https://gestor.homeserve.es/', // 馃敟 Preferimos este doc (tu captura) HS_CRED_DOC_PATH: process.env.HS_CRED_DOC_PATH || 'providerCredentials/homeserve', HS_CRED_DOC_FALLBACK: process.env.HS_CRED_DOC_FALLBACK || 'secrets/homeserve', // Auth opcional al endpoint (si lo quieres) REQUIRE_AUTH: String(process.env.REQUIRE_AUTH || '0') === '1', AUTH_TOKEN: process.env.AUTH_TOKEN || '', // Playwright HEADLESS: String(process.env.HEADLESS || 'true') !== 'false', // Selectores (aj煤stalos 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)', // (opcionales) campos de fecha / observaci贸n si existen en HomeServe dateInput: process.env.SEL_DATE_INPUT || 'input[name*="fecha"], input[id*="fecha"], input[placeholder*="dd"], input[placeholder*="DD"]', 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")', }, }; // ===== Estados (los del switch de tu app) ===== const STATUS = [ { 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' }, ]; const CODE_TO_LABEL = Object.fromEntries(STATUS.map(s => [s.code, s.title])); // ===== Utils ===== const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const nowISO = () => new Date().toISOString(); function parseDocPath(path) { // "collection/doc/subcollection/doc" -> solo soportamos colecci贸n/doc o colecci贸n/doc/... (FireStore permite) const parts = String(path || '').split('/').filter(Boolean); if (parts.length < 2 || parts.length % 2 !== 0) { throw new Error(`Invalid Firestore doc path: "${path}" (must be collection/doc[/collection/doc...])`); } let ref = db.collection(parts[0]).doc(parts[1]); for (let i = 2; i < parts.length; i += 2) { ref = ref.collection(parts[i]).doc(parts[i + 1]); } return ref; } async function getHomeServeCreds() { // 1) Firestore doc principal (providerCredentials/homeserve) const tryDoc = async (path) => { try { const ref = parseDocPath(path); const snap = await ref.get(); if (!snap.exists) return null; const d = snap.data() || {}; const user = (d.user || d.username || '').toString().trim(); const pass = (d.pass || d.password || '').toString().trim(); if (!user || !pass) return null; return { user, pass, source: `firestore:${path}` }; } catch (_) { return null; } }; const a = await tryDoc(CONFIG.HS_CRED_DOC_PATH); if (a) return a; const b = await tryDoc(CONFIG.HS_CRED_DOC_FALLBACK); if (b) return b; // 2) Env fallback const envUser = (process.env.HOMESERVE_USER || '').trim(); const envPass = (process.env.HOMESERVE_PASS || '').trim(); if (envUser && envPass) return { user: envUser, pass: envPass, source: 'env:HOMESERVE_USER/HOMESERVE_PASS' }; throw new Error( `HomeServe creds missing. Set them in Firestore doc "${CONFIG.HS_CRED_DOC_PATH}" (user/pass) ` + `or "${CONFIG.HS_CRED_DOC_FALLBACK}" (user/pass) or env HOMESERVE_USER/HOMESERVE_PASS` ); } async function withBrowser(fn) { const browser = await chromium.launch({ headless: CONFIG.HEADLESS, 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, creds) { 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, creds.user); await page.waitForSelector(CONFIG.SEL.pass, { timeout: 60000 }); await page.fill(CONFIG.SEL.pass, creds.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, serviceNumber) { const hasSearch = await page.$(CONFIG.SEL.searchBox); if (hasSearch) { await page.fill(CONFIG.SEL.searchBox, String(serviceNumber)); 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(1200); } await page.waitForSelector(CONFIG.SEL.openRow, { timeout: 60000 }); await page.click(CONFIG.SEL.openRow); await page.waitForLoadState('networkidle', { timeout: 120000 }); await sleep(900); } async function setEstado(page, code, dateString, observation) { await page.waitForSelector(CONFIG.SEL.statusDropdown, { timeout: 60000 }); // 1) intenta por value (= c贸digo) let selected = false; try { await page.selectOption(CONFIG.SEL.statusDropdown, { value: String(code) }); selected = true; } catch (_) {} // 2) si no, intenta por label (t铆tulo del estado) if (!selected) { const label = CODE_TO_LABEL[String(code)] || String(code); try { await page.selectOption(CONFIG.SEL.statusDropdown, { label }); selected = true; } catch (_) {} } // 3) si no, intenta encontrar option cuyo text contenga el c贸digo o el t铆tulo if (!selected) { const label = CODE_TO_LABEL[String(code)] || ''; const ok = await page.evaluate(({ sel, code, label }) => { const s = document.querySelector(sel); if (!s) return false; const opts = Array.from(s.querySelectorAll('option')); const lc = String(code).trim().toLowerCase(); const ll = String(label).trim().toLowerCase(); const hit = opts.find(o => { const t = (o.textContent || '').trim().toLowerCase(); return t.includes(lc) || (!!ll && t.includes(ll)); }); if (!hit) return false; s.value = hit.value; s.dispatchEvent(new Event('change', { bubbles: true })); return true; }, { sel: CONFIG.SEL.statusDropdown, code: String(code), label }); if (!ok) throw new Error(`No matching status option for code "${code}"`); } // Fecha (si existe el campo) if (dateString) { const el = await page.$(CONFIG.SEL.dateInput); if (el) { await page.fill(CONFIG.SEL.dateInput, String(dateString)); } } // Nota / observaci贸n (si existe) if (observation) { const ta = await page.$(CONFIG.SEL.noteTextarea); if (ta) { await page.fill(CONFIG.SEL.noteTextarea, String(observation)); } } 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(1200); } // ===== Express ===== const app = express(); app.use(cors()); app.use(express.json({ limit: '1mb' })); // Root OK (lo que pediste) app.get('/', (req, res) => { res.status(200).send('ok'); }); app.get('/health', (req, res) => { res.json({ ok: true, service: CONFIG.SERVICE_NAME, requireAuth: CONFIG.REQUIRE_AUTH, hsCredDocPath: CONFIG.HS_CRED_DOC_PATH, hsCredDocFallback: CONFIG.HS_CRED_DOC_FALLBACK, ts: nowISO(), }); }); // HTML integrado para pruebas (puedes dejarlo as铆 o servir un archivo) app.get('/test', (req, res) => { const optionsHtml = STATUS.map(s => { const sel = s.code === '348' ? ' selected' : ''; return ``; }).join('\n'); res.status(200).send(` estados-hs 路 test

estados-hs 路 prueba

Servidor:
Listo.
`); }); // Endpoint principal: cambio directo app.post('/api/homeserve/change-status', async (req, res) => { try { if (CONFIG.REQUIRE_AUTH) { const auth = req.headers.authorization || ''; const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; if (!CONFIG.AUTH_TOKEN || token !== CONFIG.AUTH_TOKEN) { return res.status(401).json({ ok: false, error: { message: 'Unauthorized' } }); } } const serviceNumber = String(req.body?.serviceNumber || '').trim(); const newStatusValue = String(req.body?.newStatusValue || '').trim(); // c贸digo const dateString = String(req.body?.dateString || '').trim(); const observation = String(req.body?.observation || '').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' } }); const startedAtISO = nowISO(); const creds = await getHomeServeCreds(); console.log(`[${CONFIG.SERVICE_NAME}] change-status service=${serviceNumber} status=${newStatusValue} credsSource=${creds.source}`); await withBrowser(async (page) => { await login(page, creds); await openParte(page, serviceNumber); await setEstado(page, newStatusValue, dateString || null, observation || null); }); const finishedAtISO = nowISO(); res.json({ ok: true, serviceNumber, newStatusValue, startedAtISO, finishedAtISO, }); } catch (err) { const finishedAtISO = nowISO(); res.status(500).json({ ok: false, finishedAtISO, error: { message: String(err?.message || err), stack: String(err?.stack || ''), }, }); } }); // Helpers function escapeHtml(s) { return String(s || '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } // ===== Listen (compatible CapRover) ===== const portA = parseInt(process.env.CAPROVER_PORT || process.env.PORT || '3000', 10); const portB = 80; // por si CapRover est谩 esperando 80 y no configuraste el "Container HTTP Port" console.log(`[${CONFIG.SERVICE_NAME}] HS_CRED_DOC_PATH=${CONFIG.HS_CRED_DOC_PATH}`); console.log(`[${CONFIG.SERVICE_NAME}] HS_CRED_DOC_FALLBACK=${CONFIG.HS_CRED_DOC_FALLBACK}`); console.log(`[${CONFIG.SERVICE_NAME}] REQUIRE_AUTH=${CONFIG.REQUIRE_AUTH ? 1 : 0}`); console.log(`[${CONFIG.SERVICE_NAME}] ENV PORT=${process.env.PORT || '(unset)'} CAPROVER_PORT=${process.env.CAPROVER_PORT || '(unset)'}`); app.listen(portA, () => console.log(`[${CONFIG.SERVICE_NAME}] listening on :${portA}`)); // Si el puerto principal no es 80, abrimos 80 tambi茅n para evitar 502 (si CapRover est谩 esperando 80) if (portA !== portB) { app.listen(portB, () => console.log(`[${CONFIG.SERVICE_NAME}] listening on :${portB}`)); }