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}`);
});