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, '/');
}
}