← Code-Übersicht

HttpWetellSourceAdapter.php

Pfad: src/Infrastructure/Rechnung/HttpWetellSourceAdapter.php

Ext: php

Größe: 9416 Bytes

Geändert: 2026-07-16T14:24:46+02:00

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

<?php
declare(strict_types=1);

namespace Demo\Infrastructure\Rechnung;

use Demo\Domain\Rechnung\InvoiceLoggerPort;
use Demo\Domain\Rechnung\SourceInvoice;
use Demo\Domain\Rechnung\WetellSourcePort;

/**
 * Source-Adapter per HTTP + DOM-Parsing.
 */
final class HttpWetellSourceAdapter implements WetellSourcePort
{
    /**
     * @param string $cookieFile Pfad zur gemeinsamen Session-Cookie-Datei.
     */
    private string $cookieFile;

    /**
     * @param string             $loginUrl        Login-URL.
     * @param string             $invoicesUrl     URL zur Rechnungsansicht.
     * @param string             $listSelector    Optionaler CSS/XPath-Selektor.
     * @param InvoiceLoggerPort  $logger          Logging.
     */
    public function __construct(
        private string $loginUrl,
        private string $invoicesUrl,
        private string $listSelector,
        private string $usernameField = 'username',
        private string $passwordField = 'password',
        private InvoiceLoggerPort $logger,
        private ?string $userAgent = null
    ) {
        if ($this->userAgent === null) {
            $this->userAgent = 'Mozilla/5.0 (compatible; Demo-KI-Bot/1.0)';
        }

        $cookie = tempnam(sys_get_temp_dir(), 'wetell_cookie_');
        if ($cookie === false) {
            throw new \RuntimeException('cURL-Session-Cookie konnte nicht initialisiert werden');
        }

        $this->cookieFile = $cookie;
    }

    /**
     * @param string $reason Optionaler Grund.
     */
    public function __destruct()
    {
        @unlink($this->cookieFile);
    }

    /**
     * @inheritDoc
     */
    public function login(string $username, string $password): void
    {
        $page = $this->request($this->loginUrl);
        $payload = $this->extractLoginPayload($page);
        if (!is_array($payload)) {
            $payload = [];
        }

        $payload = array_merge($payload, [
            $this->usernameField => $username,
            $this->passwordField => $password,
        ]);

        $this->request($this->loginUrl, 'POST', $payload);
    }

    /**
     * @inheritDoc
     */
    public function discoverInvoices(): array
    {
        $html = $this->request($this->invoicesUrl);
        $invoices = $this->extractFromHtml((string) $html);

        if (count($invoices) === 0) {
            $invoices = $this->fallbackRegexExtract((string) $html);
        }

        return $invoices;
    }

    /**
     * @inheritDoc
     */
    public function downloadInvoicePdf(string $downloadUrl): string
    {
        $content = $this->request($downloadUrl);
        if ($content === '') {
            throw new \RuntimeException('downloadInvoicePdf: leere Antwort vom Portal');
        }

        $tmp = tempnam(sys_get_temp_dir(), 'wetell_');
        if ($tmp === false) {
            throw new \RuntimeException('downloadInvoicePdf: tempnam fehlgeschlagen');
        }

        $pdfPath = $tmp . '.pdf';
        if (file_put_contents($pdfPath, $content, LOCK_EX) === false) {
            throw new \RuntimeException('downloadInvoicePdf: PDF konnte nicht gespeichert werden');
        }

        return $pdfPath;
    }

    /**
     * @param string $url      URL.
     * @param string $method   GET oder POST.
     * @param array  $postData POST-Body.
     * @return string Response.
     */
    private function request(string $url, string $method = 'GET', array $postData = []): string
    {
        $ch = curl_init();
        if ($ch === false) {
            throw new \RuntimeException('cURL-Handle konnte nicht initialisiert werden');
        }

        $isPost = strtoupper($method) === 'POST';

        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS => 4,
            CURLOPT_USERAGENT => $this->userAgent,
            CURLOPT_COOKIEJAR => $this->cookieFile,
            CURLOPT_COOKIEFILE => $this->cookieFile,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HTTPHEADER => ['Accept: text/html,application/json,*/*'],
            CURLOPT_POST => $isPost,
        ]);

        if ($isPost) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
        }

        $response = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($response === false || $httpCode >= 400) {
            throw new \RuntimeException('Portal-Anfrage fehlgeschlagen: ' . $httpCode);
        }

        return $response;
    }

    /**
     * @param string $html Login-Seiten-Inhalt.
     * @return array<string,string> Bestehende Formularfelder.
     */
    private function extractLoginPayload(string $html): array
    {
        $dom = new \DOMDocument();
        libxml_use_internal_errors(true);
        $parsed = $dom->loadHTML($html);
        libxml_clear_errors();
        if (!$parsed) {
            return [];
        }

        $xpath = new \DOMXPath($dom);
        $forms = $xpath->query('//form');
        if ($forms === false || $forms->length === 0) {
            return [];
        }

        $form = $forms->item(0);
        if (!$form instanceof \DOMElement) {
            return [];
        }

        $fields = [];
        $inputs = $xpath->query('.//input', $form);
        if ($inputs !== false) {
            foreach ($inputs as $input) {
                if (!$input instanceof \DOMElement) {
                    continue;
                }

                $name = trim((string) $input->getAttribute('name'));
                if ($name === '') {
                    continue;
                }

                $fields[$name] = (string) $input->getAttribute('value');
            }
        }

        return $fields;
    }

    /**
     * @param string $html HTML-Input.
     * @return list<SourceInvoice> Geparste Datensätze.
     */
    private function extractFromHtml(string $html): array
    {
        if ($this->listSelector === '') {
            return [];
        }

        $dom = new \DOMDocument();
        libxml_use_internal_errors(true);
        $dom->loadHTML($html);
        libxml_clear_errors();
        $xpath = new \DOMXPath($dom);
        $nodes = $xpath->query($this->listSelector);
        if ($nodes === false || $nodes->count() === 0) {
            return [];
        }

        $result = [];
        foreach ($nodes as $node) {
            if (!$node instanceof \DOMElement) {
                continue;
            }

            $href = $node->getAttribute('href');
            if ($href === '') {
                continue;
            }

            $text = trim((string) $node->textContent);
            $sourceId = sha1($href . '|' . $text);
            $date = $this->extractDateFromText($text);
            $invoice = $this->extractInvoiceNumberFromText($text);

            $result[] = new SourceInvoice(
                $sourceId,
                $this->sanitizeVendor($text),
                $this->normalizeUrl($href),
                $date,
                $invoice
            );
        }

        return $result;
    }

    /**
     * @param string $html HTML-Input.
     * @return list<SourceInvoice> Gefundene PDF-Links.
     */
    private function fallbackRegexExtract(string $html): array
    {
        $result = [];
        if (!preg_match_all('/href=["\']([^"\']+\.pdf[^"\']*)/i', $html, $matches)) {
            return [];
        }

        foreach ($matches[1] as $link) {
            $sourceId = sha1($link);
            $result[] = new SourceInvoice(
                $sourceId,
                'unbekannt',
                $this->normalizeUrl($link),
                null,
                null
            );
        }

        return $result;
    }

    /**
     * @param string $text Rohtext.
     * @return string|null Erkanntes Datum im String-Format.
     */
    private function extractDateFromText(string $text): ?string
    {
        if (preg_match('/\b(\d{4}-\d{2}-\d{2})\b/', $text, $matches) === 1) {
            return $matches[1];
        }

        if (preg_match('/\b(\d{2}\.\d{2}\.\d{4})\b/', $text, $matches) === 1) {
            return $matches[1];
        }

        return null;
    }

    /**
     * @param string $text Rohtext.
     * @return string|null Rechnungsnummer.
     */
    private function extractInvoiceNumberFromText(string $text): ?string
    {
        if (preg_match('/\b(?:Rechnungsnummer|Invoice|Nr\.?)(?:\s*[:#]\s*)([A-Za-z0-9\-\/]+)\b/i', $text, $matches) === 1) {
            return $matches[1];
        }

        return null;
    }

    /**
     * @param string $text Rohtext.
     * @return string Aufbereiteter Lieferantenname.
     */
    private function sanitizeVendor(string $text): string
    {
        $parts = preg_split('/\s+/', trim($text));
        return (string) (($parts[0] ?? '') . (($parts[1] ?? '') === '' ? '' : ' ' . $parts[1]));
    }

    /**
     * @param string $link Voll- oder relative URL.
     * @return string Absolut aufgeloeste URL.
     */
    private function normalizeUrl(string $link): string
    {
        if (str_starts_with($link, 'http')) {
            return $link;
        }

        $base = preg_replace('/\/.+$/', '', $this->invoicesUrl);
        return rtrim($base, '/') . '/' . ltrim($link, '/');
    }
}