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