← Code-Übersicht

browser-service.js

Pfad: wetell-headless/browser-service.js

Ext: js

Größe: 3873 Bytes

Geändert: 2026-07-16 17:51:21+02

'use strict';

const crypto = require('node:crypto');
const { chromium } = require('playwright');
const { allowedUrl } = require('./policy');

class BrowserService {
    constructor(timeoutMs, launcher = chromium) {
        this.timeoutMs = timeoutMs;
        this.launcher = launcher;
        this.browser = null;
    }

    async start() {
        if (!this.browser) this.browser = await this.launcher.launch({ headless: true });
    }

    async stop() {
        if (this.browser) await this.browser.close();
        this.browser = null;
    }

    async execute(action, params) {
        if (!this.browser) throw new Error('browser_not_ready');
        if (!['login', 'discover', 'download'].includes(action)) throw new Error('invalid_action');
        const context = await this.browser.newContext();
        try {
            const page = await context.newPage();
            await this.login(page, params);
            if (action === 'login') return { ok: true, action };
            if (action === 'discover') return this.discover(page, params);
            if (action === 'download') return this.download(page, params);
            throw new Error('invalid_action');
        } finally {
            await context.close();
        }
    }

    async login(page, params) {
        const loginUrl = allowedUrl(params.loginUrl || 'https://mein.wetell.de/anmelden');
        if (!params.username || !params.password) throw new Error('credentials_missing');
        await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: this.timeoutMs });
        const user = params.usernameField || 'email';
        const pass = params.passwordField || 'password';
        if (!/^[A-Za-z0-9_-]{1,64}$/.test(user) || !/^[A-Za-z0-9_-]{1,64}$/.test(pass)) throw new Error('field_name_invalid');
        await page.locator(`[name="${user}"]`).fill(String(params.username || ''));
        await page.locator(`[name="${pass}"]`).fill(String(params.password || ''));
        await Promise.all([
            page.waitForLoadState('domcontentloaded', { timeout: this.timeoutMs }),
            page.locator('button[type="submit"],input[type="submit"]').first().click(),
        ]);
        if (await page.locator('input[type="password"]').count()) throw new Error('login_not_confirmed');
    }

    async discover(page, params) {
        const invoicesUrl = allowedUrl(params.invoicesUrl || 'https://mein.wetell.de/deine-rechnungen');
        await page.goto(invoicesUrl, { waitUntil: 'domcontentloaded', timeout: this.timeoutMs });
        const rows = await page.locator('a[href]').evaluateAll((links) => links.map((link) => ({ href: link.href, text: link.textContent || '' })));
        const invoices = rows.filter((row) => /pdf/i.test(row.href)).map((row) => {
            const url = allowedUrl(row.href, invoicesUrl);
            const number = row.text.match(/\b(RF\d{6,})\b/i);
            const date = row.text.match(/\b(\d{2}\.\d{2}\.\d{4})\b/);
            return { sourceId: crypto.createHash('sha256').update(url).digest('hex'), vendorName: 'WEtell GmbH', downloadUrl: url, invoiceNumber: number ? number[1].toUpperCase() : null, invoiceDate: date ? date[1] : null };
        });
        return { ok: true, action: 'discover', invoices };
    }

    async download(page, params) {
        const url = allowedUrl(params.downloadUrl);
        const response = await page.request.get(url, { timeout: this.timeoutMs, maxRedirects: 0 });
        if (!response.ok()) throw new Error(`download_http_${response.status()}`);
        const body = await response.body();
        if (body.length > 5_000_000) throw new Error('download_too_large');
        if (!body.subarray(0, 5).equals(Buffer.from('%PDF-'))) throw new Error('download_not_pdf');
        return { ok: true, action: 'download', pdfBase64: body.toString('base64') };
    }
}

module.exports = { BrowserService };