← Code-Übersicht

server.js

Pfad: wetell-headless/server.js

Ext: js

Größe: 14386 Bytes

Geändert: 2026-07-16T14:29:34+02:00

Frühere Version vom 2026-07-16T14:29:34+02:00 · zur aktuellen Fassung

#!/usr/bin/env node
'use strict';

const http = require('node:http');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium } = require('playwright');

const host = process.env.WETELL_HEADLESS_HOST || '127.0.0.1';
const port = Number(process.env.WETELL_HEADLESS_PORT || 3001);
const defaultLoginUrl = process.env.WETELL_LOGIN_URL || 'https://mein.wetell.de/anmelden';
const defaultInvoicesUrl = process.env.WETELL_INVOICES_URL || 'https://mein.wetell.de/deine-rechnungen';
const requestTimeoutMs = Number(process.env.WETELL_HEADLESS_TIMEOUT_MS || 45000);
const secret = process.env.WETELL_HEADLESS_SECRET || '';
const userAgent = process.env.WETELL_USER_AGENT || 'Mozilla/5.0 (DemoHeadless/1.0)';
const tempDir = process.env.WETELL_TEMP_DIR || os.tmpdir();

function toJsonPayload(responseCode, payload) {
    return JSON.stringify({
        ok: responseCode === 200,
        ...payload,
    });
}

function sendJson(response, responseCode, payload) {
    const body = toJsonPayload(responseCode, payload);
    response.writeHead(responseCode, {
        'Content-Type': 'application/json; charset=utf-8',
        'Content-Length': Buffer.byteLength(body, 'utf8'),
    });
    response.end(body);
}

function parseJsonBody(request) {
    return new Promise((resolve, reject) => {
        let data = '';
        request.on('data', (chunk) => {
            data += chunk;
        });
        request.on('end', () => {
            if (data === '') {
                resolve(null);
                return;
            }
            try {
                resolve(JSON.parse(data));
            } catch (error) {
                reject(error);
            }
        });
        request.on('error', () => {
            reject(new Error('request_body_error'));
        });
    });
}

async function withBrowser(callback) {
    const browser = await chromium.launch({
        headless: process.env.WETELL_HEADLESS !== '0',
        args: ['--no-sandbox', '--disable-dev-shm-usage'],
    });

    const context = await browser.newContext({
        userAgent,
        ignoreHTTPSErrors: true,
    });

    try {
        return await callback(context);
    } finally {
        await context.close().catch(() => null);
        await browser.close().catch(() => null);
    }
}

async function loginAndGetPage(context, params) {
    const loginUrl = params.loginUrl || defaultLoginUrl;
    const username = String(params.username || '');
    const password = String(params.password || '');
    const usernameField = String(params.usernameField || 'username');
    const passwordField = String(params.passwordField || 'password');

    if (username === '' || password === '') {
        throw new Error('credentials_required');
    }

    const page = await context.newPage();
    await page.goto(loginUrl, {
        waitUntil: 'domcontentloaded',
        timeout: requestTimeoutMs,
    });

    const formAction = await page.evaluate(
        (params) => {
            const usernameFieldName = String(params.usernameFieldName || 'username');
            const passwordFieldName = String(params.passwordFieldName || 'password');
            const usernameValue = String(params.usernameValue || '');
            const passwordValue = String(params.passwordValue || '');

            const form = document.querySelector('form');
            if (!form) {
                return { formFound: false };
            }

            const formData = {};
            form.querySelectorAll('input[name]').forEach((input) => {
                if (!input.name) {
                    return;
                }
                formData[input.name] = input.value || '';
            });

            if (typeof formData[usernameFieldName] === 'undefined') {
                formData[usernameFieldName] = '';
            }
            if (typeof formData[passwordFieldName] === 'undefined') {
                formData[passwordFieldName] = '';
            }

            formData[usernameFieldName] = usernameValue;
            formData[passwordFieldName] = passwordValue;

            for (const [name, value] of Object.entries(formData)) {
                let element = form.querySelector(`[name="${CSS.escape(name)}"]`);
                if (!element) {
                    element = document.createElement('input');
                    element.type = 'hidden';
                    element.name = name;
                    form.appendChild(element);
                }
                element.value = String(value ?? '');
            }

            let submitted = false;
            const submitters = form.querySelectorAll('button[type="submit"],input[type="submit"]');
            if (submitters.length > 0) {
                submitters[0].click();
                submitted = true;
            } else {
                form.requestSubmit();
                submitted = true;
            }

            return {
                formFound: true,
                submitted,
            };
        },
        {
            usernameFieldName: usernameField,
            passwordFieldName: passwordField,
            usernameValue: username,
            passwordValue: password,
        }
    );

    if (!formAction.formFound || !formAction.submitted) {
        throw new Error('login_form_not_found');
    }

    await Promise.all([
        page.waitForLoadState('domcontentloaded', { timeout: requestTimeoutMs }).catch(() => null),
        page.waitForLoadState('networkidle', { timeout: requestTimeoutMs }).catch(() => null),
    ]);

    const loggedIn = await page.evaluate(() => {
        const href = window.location.href.toLowerCase();
        const text = (document.body && document.body.innerText ? document.body.innerText : '').toLowerCase();
        const looksLikeLogin = href.includes('anmelden') || text.includes('anmelden');
        return !looksLikeLogin;
    });

    if (!loggedIn) {
        throw new Error('login_failed');
    }

    return { page, loginUrl, invoicesUrl: params.invoicesUrl || defaultInvoicesUrl };
}

async function doDiscover(context, params) {
    const { page, invoicesUrl } = await loginAndGetPage(context, params);
    await page.goto(invoicesUrl, {
        waitUntil: 'domcontentloaded',
        timeout: requestTimeoutMs,
    });

    const html = await page.content();
    const invoices = await page.evaluate(() => {
        const normalizeVendor = (value) => {
            let text = String(value || '')
                .replace(/\s+/g, ' ')
                .trim()
                .replace(/\b(rechnung|rechnungsnummer|invoice|beleg|belegnummer|ref)\b[^a-zA-Z0-9ÄÖÜäöüß._/-]*/gi, '')
                .replace(/\b\d{1,2}[.\-\/]\d{1,2}[.\-\/]\d{2,4}\b/g, '')
                .trim();
            return (text || 'unbekannt').trim();
        };

        const readDate = (value) => {
            if (!value) {
                return null;
            }
            const match = String(value).match(/\b(\d{1,2}[.\-\/]\d{1,2}[.\-\/]\d{2,4})\b/);
            if (!match) {
                return null;
            }
            const parts = match[1].replace(/\//g, '.').split('.');
            if (parts.length !== 3) {
                return null;
            }
            const [dayRaw, monthRaw, yearRaw] = parts;
            const day = Number(dayRaw);
            const month = Number(monthRaw);
            const year = Number(yearRaw.length === 2 ? `20${yearRaw}` : yearRaw);
            if (!Number.isFinite(day) || !Number.isFinite(month) || !Number.isFinite(year)) {
                return null;
            }
            if (day < 1 || day > 31 || month < 1 || month > 12 || year < 2000 || year > 2100) {
                return null;
            }
            return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
        };

        const readNumber = (value) => {
            const match = String(value).match(/\b(?:rechnung|rechnung|beleg|belegnummer|invoice|ref|rechnungsnummer)\s*[:#]?\s*([A-ZÄÖÜa-zäöüß0-9][A-ZÄÖÜa-zäöüß0-9._/-]{2,})/i);
            if (match && match[1]) {
                return String(match[1]).trim();
            }
            return null;
        };

        const links = Array.from(document.querySelectorAll('a[href]'));
        const out = [];
        const seen = new Set();

        for (const link of links) {
            const href = (link.getAttribute('href') || '').trim();
            if (href === '') {
                continue;
            }
            const lower = href.toLowerCase();
            if (
                !lower.includes('.pdf') &&
                !lower.includes('download') &&
                !link.textContent
            ) {
                continue;
            }

            const absolute = new URL(href, window.location.href).toString();
            const text = (link.textContent || '').replace(/\s+/g, ' ').trim();
            const key = `${absolute}|${text}`;
            if (seen.has(key)) {
                continue;
            }
            seen.add(key);

            const vendorName = normalizeVendor(text || (link.getAttribute('aria-label') || ''));
            out.push({
                vendorName,
                downloadUrl: absolute,
                invoiceDate: readDate(text),
                invoiceNumber: readNumber(text),
                linkText: text,
            });
        }

        return out;
    });

    const normalized = invoices.map((item) => {
        const invoice = {
            vendorName: item.vendorName || 'unbekannt',
            downloadUrl: String(item.downloadUrl || ''),
            invoiceDate: item.invoiceDate || null,
            invoiceNumber: item.invoiceNumber || null,
            sourceId: cryptoHash(item.downloadUrl || item.linkText || ''),
        };
        return invoice;
    });

    return {
        ok: true,
        invoices: normalized,
        invoiceCount: normalized.length,
        html,
    };
}

function cryptoHash(value) {
    return crypto.createHash('sha1').update(String(value)).digest('hex');
}

async function doLoginOnly(context, params) {
    await loginAndGetPage(context, params);
    return {
        ok: true,
        action: 'login',
    };
}

async function doDownload(context, params) {
    const { page } = await loginAndGetPage(context, params);
    const sourceUrl = String(params.url || params.downloadUrl || '').trim();
    if (!sourceUrl) {
        throw new Error('download_url_missing');
    }

    const resolvedUrl = new URL(sourceUrl, params.invoicesUrl || defaultInvoicesUrl).toString();
    const response = await page.request.get(resolvedUrl, {
        timeout: requestTimeoutMs,
        maxRedirects: 5,
    });
    if (!response.ok()) {
        throw new Error(`download_failed_${String(response.status())}`);
    }

    const body = await response.body();
    const contentType = String(response.headers()['content-type'] || '').toLowerCase();
    const prefix = body.slice(0, 64).toString('latin1').toLowerCase();
    if (contentType.includes('text/html') || contentType.includes('text/plain') || prefix.includes('<html')) {
        throw new Error('download_not_pdf');
    }

    const fileName = `wetell_headless_${Date.now()}_${crypto.randomBytes(8).toString('hex')}.pdf`;
    const filePath = path.join(tempDir, fileName);
    await fs.writeFile(filePath, body);

    return {
        ok: true,
        action: 'download',
        filePath,
    };
}

const handlers = {
    login: (params) => withBrowser((context) => doLoginOnly(context, params)),
    discover: (params) => withBrowser((context) => doDiscover(context, params)),
    download: (params) => withBrowser((context) => doDownload(context, params)),
};

const server = http.createServer(async (request, response) => {
    if (request.url === '/health') {
        if (request.method !== 'GET') {
            sendJson(response, 405, {
                ok: false,
                error: 'method_not_allowed',
                message: 'GET expected',
            });
            return;
        }
        sendJson(response, 200, {
            ok: true,
            service: 'wetell-headless-playwright',
            version: '1.0.0',
        });
        return;
    }

    if (request.method !== 'POST') {
        sendJson(response, 405, {
            ok: false,
            error: 'method_not_allowed',
            message: 'POST expected',
        });
        return;
    }

    if (request.headers['content-type'] && !String(request.headers['content-type']).includes('application/json')) {
        sendJson(response, 415, {
            ok: false,
            error: 'unsupported_content_type',
            message: 'application/json required',
        });
        return;
    }

    let body;
    try {
        body = await parseJsonBody(request);
    } catch (error) {
        sendJson(response, 400, {
            ok: false,
            error: 'invalid_json',
            message: 'request body must be valid json',
        });
        return;
    }

    if (!body || typeof body !== 'object') {
        sendJson(response, 400, {
            ok: false,
            error: 'invalid_payload',
            message: 'payload must be an object',
        });
        return;
    }

    if (secret !== '' && String(body.secret || '') !== secret) {
        sendJson(response, 403, {
            ok: false,
            error: 'forbidden',
            message: 'secret mismatch',
        });
        return;
    }

    const action = String(body.action || '');
    if (!action || typeof handlers[action] === 'undefined') {
        sendJson(response, 400, {
            ok: false,
            error: 'invalid_action',
            message: `supported actions: ${Object.keys(handlers).join(',')}`,
        });
        return;
    }

    try {
        const result = await handlers[action](body);
        sendJson(response, 200, {
            ...result,
            handledAt: new Date().toISOString(),
        });
    } catch (error) {
        sendJson(response, 500, {
            ok: false,
            error: String(error && error.message ? error.message : 'runtime_error'),
            action,
            message: 'headless_action_failed',
        });
    }
});

server.listen(port, host, () => {
    console.log(`wetell-headless listening on http://${host}:${port}`);
});