From eb08cd6b83dcfb9288017b69b17de5fdd8edcff0 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 13 Aug 2026 15:08:27 +0200 Subject: [PATCH] Kursabruf: Yahoo Finanzen als dritter Anbieter, FMP-Adressen berichtigt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Financial Modeling Prep hat die /api/v3/-Adressen zum 31.08.2025 abgeschaltet; sie antworten mit HTTP 403. Der Abruf nutzt jetzt /stable/quote und /stable/search-isin und meldet den Klartext des Dienstes, statt jeden 403 als abgelehnten Schlüssel auszugeben. HTTP 402 ist ein eigener Fall – im Gratistarif fehlen dort ISIN-Suche und deutsche Börsenplätze. Neu ist Yahoo Finanzen: ohne Schlüssel, ohne Kontingent, dafür muss das Kürzel samt Handelsplatz in den Stammdaten stehen. Eine offene Schnittstelle gibt es nicht mehr (query1.finance.yahoo.com antwortet ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb), gelesen wird deshalb die Kursseite selbst. yahooKurs() liest beide Ausprägungen der Seite – und den angezeigten Kurs in deutscher Schreibweise – und prüft am Seitentitel, dass die Seite zum Kürzel gehört. Das eingebettete JSON bleibt ungenutzt: Es gehört zu einem Fremd-Widget und liefert auf jeder Seite denselben papierfremden Wert. Eine Sekunde Abstand zwischen zwei Seiten, bis zu drei Versuche bei HTTP 503. Co-Authored-By: Claude Opus 5 --- Aktienkurse.md | 38 ++++- Changes.md | 39 +++++ docs/wertpapiere.md | 34 ++++- src/Services/Kursabruf.php | 244 +++++++++++++++++++++++++++--- src/Views/einstellungen/index.php | 11 +- tests/27_KursabrufTest.php | 154 +++++++++++++++++++ 6 files changed, 486 insertions(+), 34 deletions(-) create mode 100644 tests/27_KursabrufTest.php diff --git a/Aktienkurse.md b/Aktienkurse.md index 5c07656..a2967c9 100644 --- a/Aktienkurse.md +++ b/Aktienkurse.md @@ -14,6 +14,11 @@ Für den kostenlosen, tagesaktuellen Abruf von Aktienkursen mit ISIN-Codes (ca. ### 2. **Financial Modeling Prep (FMP)** - **Angebot:** 250 kostenlose Anfragen pro Tag (mehr als ausreichend für 20 Positionen). - **ISIN-Unterstützung:** Ja, ISIN kann für die Suche verwendet werden. +- **Stand August 2026 – Einschränkungen im kostenlosen Tarif:** Die alten `/api/v3/`-Adressen + sind seit dem 31.08.2025 abgeschaltet (HTTP 403), gültig ist nur noch `/stable/`. Die + ISIN-Suche (`/stable/search-isin`) und deutsche Börsenplätze (`EUNL.DE` und dergleichen) + sind kostenpflichtig und antworten mit HTTP 402. Für ein Depot mit deutschen ISINs bleibt + im Gratistarif also nichts übrig – dafür Leeway verwenden. - **Daten:** Echtzeit- und historische Kurse, Fundamentaldaten, Dividenden. - **Registrierung:** Kostenlos, API-Key nach Anmeldung. - **Dokumentation:** [fmpcloud.io](https://site.financialmodelingprep.com/de/developer/docs) @@ -27,11 +32,34 @@ Für den kostenlosen, tagesaktuellen Abruf von Aktienkursen mit ISIN-Codes (ca. - **Dokumentation:** [www.alphavantage.co](https://www.alphavantage.co) --- -### 4. **Yahoo Finance (unoffiziell)** -- **Angebot:** Kein offizieller API-Key nötig, aber Nutzung der öffentlichen URL-Struktur (z. B. `https://query1.finance.yahoo.com/v8/finance/chart/ISIN?interval=1d`). -- **ISIN-Unterstützung:** Teilweise, aber nicht alle ISINs werden direkt unterstützt. Oft muss die ISIN auf ein Börsenkürzel gemappt werden. -- **Daten:** Aktuelle Kurse, aber mit Verzögerung und ohne Garantie auf Stabilität. -- **Hinweis:** Keine offizielle API, daher kann die Nutzung eingeschränkt oder blockiert werden . +### 4. **Yahoo Finance (unoffiziell)** – im Finanzmanager umgesetzt +- **Angebot:** Kein API-Key, kein Kontingent. +- **ISIN-Unterstützung:** Keine mehr. `/lookup` und `/search` antworten mit HTTP 404, die + Suchschnittstelle `v1/finance/search` mit HTTP 429. Das Börsenkürzel muss deshalb in den + Stammdaten des Wertpapiers stehen. +- **Stand August 2026:** Auch `query1.finance.yahoo.com` (chart, quote, getcrumb) antwortet + ohne Sitzung durchgängig mit HTTP 429 – Cookie und Crumb helfen nicht. Erreichbar ist nur + die Kursseite `https://de.finance.yahoo.com/quote/ALV.DE/`, aus der der Kurs gelesen wird. +- **Fallstricke beim Lesen der Seite:** + - Die Seite wird in zwei Ausprägungen ausgeliefert – mit `` + (Punkt als Dezimaltrenner) oder nur mit dem angezeigten Kurs (`data-testid="qsp-price"`, + deutsche Schreibweise). Beides kommt am selben Tag vor. + - Das eingebettete JSON (`"regularMarketPrice":{"raw":…}`) gehört zu einem Fremd-Widget + und führt in die Irre: Es liefert auf jeder Seite denselben, papierfremden Wert. + - Die Kopfleiste führt DAX, Dow und andere als `` mit – nur der Treffer mit + passendem `data-symbol` zählt. + - Ohne `Accept: text/html` gibt das Auslieferungsnetz gelegentlich eine veraltete Fassung + heraus. Der angezeigte Kursstand landet im Debug-Log. + - Schnelle Serienabrufe beantwortet Yahoo mit HTTP 503, deshalb eine Sekunde Abstand. + Der 503 hält auch mal eine Viertelstunde an und trifft dann einzelne Kürzel, die kurz + darauf wieder ausgeliefert werden – im Browser funktionieren sie zur selben Zeit. Der + Abruf wiederholt deshalb bis zu dreimal. + - Ein **Browser-User-Agent schadet**: Mit Chrome-Kennung antwortete Yahoo mit HTTP 404, + mit `Finanzmanager Kursabruf` mit 200. Getarnt wird hier nichts. +- **Hinweis:** Keine offizielle Schnittstelle. Die Nutzungsbedingungen von Yahoo sehen + automatisierte Abrufe nicht vor; für ein privates Depot mit einer Handvoll Papieren am + Tag ist das vertretbar, als Grundlage für mehr aber nicht geeignet. Die `robots.txt` + sperrt `/quote/` nicht. --- ### 5. **Marketstack** diff --git a/Changes.md b/Changes.md index 74aa6cf..2e23dc7 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,44 @@ # Änderungen +## Kurse von Yahoo Finanzen (13.08.2026) + +Dritter Anbieter im Kursabruf: **Yahoo Finanzen**, ohne Schlüssel und ohne Tageskontingent. +Eine offene Schnittstelle gibt es dort nicht mehr – `query1.finance.yahoo.com` antwortet +ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb –, gelesen wird deshalb die Kursseite +selbst. + +- Auswahl *Yahoo Finanzen* im Einstellungsdialog unter *Wertpapiere*; das Feld für den + API-Schlüssel bleibt dort leer. +- Das **Kürzel muss in den Stammdaten stehen** und den Handelsplatz nennen (`ALV.DE`, + `PTX.F`). Yahoo hat keine ISIN-Suche mehr, `/lookup` und `/search` antworten mit 404. +- `Kursabruf::yahooKurs()` liest beide Ausprägungen der Seite (`` und den + angezeigten Kurs in deutscher Schreibweise) und prüft am Seitentitel, dass die Seite zum + Kürzel gehört – ein Tippfehler bringt so keinen fremden Kurs in den Bestand. +- Eine Sekunde Abstand zwischen zwei Seiten; auf schnelle Serienabrufe antwortet Yahoo mit + HTTP 503. Weil dieser 503 vorübergehend ist und Kürzel trifft, die im Browser zur selben + Zeit funktionieren, wird bis zu dreimal angefragt. Der angezeigte Kursstand geht ins + Debug-Log, weil Yahoo gelegentlich eine ältere Fassung der Seite ausliefert. + +Der Weg über fremdes Markup ist der empfindlichste der drei Anbieter: Baut Yahoo die Seite +um, meldet der Abruf einen Fehler, statt einen falschen Kurs zu speichern. + +## Kursabruf: Financial Modeling Prep antwortete mit HTTP 403 (13.08.2026) + +Financial Modeling Prep hat die alten `/api/v3/`-Adressen zum 31. August 2025 abgeschaltet; +sie beantworten seither jede Anfrage mit HTTP 403. Der Kursabruf sprach genau diese Adressen +an und meldete daraufhin *„Der API-Schlüssel wurde abgelehnt“* – eine falsche Fährte, denn +der Schlüssel war in Ordnung. + +- `Kursabruf` nutzt für Financial Modeling Prep jetzt `/stable/quote` und `/stable/search-isin`. +- Fehlermeldungen nennen den Klartext des Dienstes. HTTP 403 heißt nicht mehr pauschal + „Schlüssel abgelehnt“, und HTTP 402 („im gebuchten Tarif nicht enthalten“) wird als + eigener Fall gemeldet statt als nacktes „antwortet mit HTTP 402“. + +**Hinweis zum kostenlosen Tarif von Financial Modeling Prep:** Er umfasst weder die +ISIN-Suche noch deutsche Börsenplätze (`…​.DE`) – beides beantwortet der Dienst mit HTTP 402. +Für ein Depot mit deutschen ISINs ist dort auch nach dieser Korrektur nichts zu holen; +dafür ist Leeway.tech vorgesehen (Einstellungen → Wertpapiere). + ## Detailseite eines Wertpapiers (13.08.2026) Bisher endete der Weg zu einem Wertpapier in einer Tabellenzeile: Stückzahl, Kurs, Wert – diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index 0cfaf2a..7e1ca45 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -151,15 +151,37 @@ Ersatzwert wäre bequem, verstellte aber den Blick. ### Kursabruf -`Services\Kursabruf` kennt zwei Anbieter (siehe `Aktienkurse.md`): +`Services\Kursabruf` kennt drei Anbieter (siehe `Aktienkurse.md`): * **Leeway.tech** – 50 Abrufe/Tag, kennt ISINs direkt -* **Financial Modeling Prep** – 250 Abrufe/Tag +* **Financial Modeling Prep** – 250 Abrufe/Tag, im Gratistarif ohne ISIN-Suche und ohne + deutsche Börsenplätze (beides antwortet mit HTTP 402) +* **Yahoo Finanzen** – kein Schlüssel, kein Kontingent, dafür muss das Kürzel in den + Stammdaten stehen -Beide arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene Kürzel wird -am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das halbiert den -Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für heute noch -kein Kurs vorliegt. +Die ersten beiden arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene +Kürzel wird am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das +halbiert den Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für +heute noch kein Kurs vorliegt. + +**Yahoo Finanzen** hat keine offene Schnittstelle mehr: `query1.finance.yahoo.com` +antwortet ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb, und `/lookup` wie `/search` +sind abgeschaltet. Gelesen wird deshalb die Kursseite `de.finance.yahoo.com/quote//`. + +* Das **Kürzel** muss den Handelsplatz nennen – `ALV.DE` für Xetra, `PTX.F` für Frankfurt. + Ohne Kürzel meldet der Abruf das ausdrücklich, denn raten lässt sich hier nichts. +* Yahoo liefert die Seite in zwei Ausprägungen aus: mal mit `` samt + `data-value`, mal nur mit dem angezeigten, deutsch formatierten Kurs. `yahooKurs()` liest + beides und prüft am Seitentitel, dass die Seite zum Kürzel gehört – sonst käme bei einem + Tippfehler der Kurs eines fremden Papiers in den Bestand. +* Zwischen zwei Seiten liegt **eine Sekunde**; auf schnelle Serienabrufe antwortet Yahoo + mit HTTP 503. Dieser 503 ist vorübergehend – er trifft auch Kürzel, die im Browser zur + selben Zeit funktionieren –, deshalb bis zu drei Versuche je Papier. +* Der angezeigte Kursstand („Markt geöffnet“, „Bei Börsenschluss“) geht ins Debug-Log: + Über sein Auslieferungsnetz gibt Yahoo gelegentlich eine ältere Fassung der Seite heraus. + +Weil hier fremdes Markup gelesen wird, ist dieser Weg der empfindlichste der drei – ein +Umbau bei Yahoo fällt als Fehler auf, nicht als falscher Kurs. Anbieter und Schlüssel stehen im Einstellungsdialog unter *Wertpapiere*. Der tägliche Lauf gehört in einen Cron-Eintrag: diff --git a/src/Services/Kursabruf.php b/src/Services/Kursabruf.php index 4c55ef0..5dce4c9 100644 --- a/src/Services/Kursabruf.php +++ b/src/Services/Kursabruf.php @@ -10,9 +10,10 @@ use Finanzmanager\Models\Wertpapier; /** * Holt Tageskurse von einem Kursdienst und legt sie in `kurse` ab. * - * Zwei Anbieter, beide mit kostenlosem Kontingent (siehe `Aktienkurse.md`): - * **Leeway.tech** (50 Abrufe/Tag, kennt ISINs direkt) und **Financial Modeling - * Prep** (250/Tag). Bei rund zwanzig Positionen reicht beides. + * Drei Anbieter (siehe `Aktienkurse.md`): **Leeway.tech** (50 Abrufe/Tag, kennt + * ISINs direkt), **Financial Modeling Prep** (250/Tag, im Gratistarif ohne + * ISIN-Suche und ohne deutsche Börsenplätze) und **Yahoo Finanzen**, das ohne + * Schlüssel auskommt, dafür aber das Kürzel in den Stammdaten braucht. * * Der Anbieter ist austauschbar: Jeder liefert zu einem Wertpapier einen Kurs * oder null, alles andere – Reihenfolge, Ablage, Protokoll – ist gemeinsam. @@ -26,11 +27,30 @@ class Kursabruf public const ANBIETER = [ 'leeway' => 'Leeway.tech (50 Abrufe/Tag)', 'fmp' => 'Financial Modeling Prep (250 Abrufe/Tag)', + 'yahoo' => 'Yahoo Finanzen (ohne Schlüssel, Kürzel nötig)', ]; + /** Anbieter, die ohne API-Schlüssel auskommen. */ + private const OHNE_SCHLUESSEL = ['yahoo']; + /** Wie lange auf den Kursdienst gewartet wird, in Sekunden. */ private const ZEITGRENZE = 15; + /** Mindestabstand zwischen zwei Kursseiten, in Sekunden. */ + private const SEITENPAUSE = 1.0; + + /** Wie oft eine Kursseite geholt wird, wenn der Anbieter kurz streikt. */ + private const SEITENVERSUCHE = 3; + + /** Wartezeit vor dem nächsten Versuch, in Sekunden. */ + private const WIEDERHOLPAUSE = 2; + + /** Zeitpunkt der zuletzt geholten Kursseite. */ + private static float $letzteSeite = 0.0; + + /** Statuscode der letzten Antwort – für die Frage, ob ein zweiter Versuch lohnt. */ + private static int $letzterStatus = 0; + /** * Holt Kurse für alle Wertpapiere mit Bestand. * @@ -50,7 +70,7 @@ class Kursabruf if (!array_key_exists($anbieter, self::ANBIETER)) { return self::ergebnis(0, 1, 0, ['Unbekannter Kursdienst: ' . $anbieter]); } - if ($key === '') { + if ($key === '' && !in_array($anbieter, self::OHNE_SCHLUESSEL, true)) { return self::ergebnis(0, 1, 0, [ 'Kein API-Schlüssel hinterlegt (Einstellungen → Wertpapiere).', ]); @@ -127,6 +147,7 @@ class Kursabruf { return match ($anbieter) { 'fmp' => self::fmp($key, $wertpapier), + 'yahoo' => self::yahoo($wertpapier), default => self::leeway($key, $wertpapier), }; } @@ -174,6 +195,9 @@ class Kursabruf * Financial Modeling Prep sucht die ISIN erst zu einem Kürzel, dann kommt * der Kurs über die Kurzabfrage. * + * Seit dem 31. August 2025 beantwortet der Dienst die alten `/api/v3/`- + * Adressen nur noch mit HTTP 403; gültig ist allein `/stable/`. + * * @param array $wertpapier */ private static function fmp(string $key, array $wertpapier): ?float @@ -187,8 +211,8 @@ class Kursabruf } $treffer = self::hole( - 'https://financialmodelingprep.com/api/v3/search?query=' . rawurlencode($isin) - . '&limit=1&apikey=' . rawurlencode($key) + 'https://financialmodelingprep.com/stable/search-isin?isin=' . rawurlencode($isin) + . '&apikey=' . rawurlencode($key) ); $symbol = (string)self::ersterWert($treffer[0] ?? [], ['symbol', 'Symbol']); @@ -200,19 +224,160 @@ class Kursabruf } $antwort = self::hole( - 'https://financialmodelingprep.com/api/v3/quote-short/' . rawurlencode($symbol) - . '?apikey=' . rawurlencode($key) + 'https://financialmodelingprep.com/stable/quote?symbol=' . rawurlencode($symbol) + . '&apikey=' . rawurlencode($key) ); return self::zahl(self::ersterWert($antwort[0] ?? $antwort, ['price', 'Price'])); } + /** + * Yahoo Finanzen hat keine offene Schnittstelle mehr: `query1.finance.yahoo.com` + * antwortet auf Anfragen ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb. + * Übrig bleibt die Kursseite selbst, aus der der Kurs gelesen wird. + * + * Der Preis dafür ist Abhängigkeit von fremdem Markup – deshalb zwei Muster + * und ein Abgleich mit dem Seitentitel, damit ein Umbau bei Yahoo als Fehler + * auffällt und nicht als falscher Kurs in den Bestand wandert. + * + * Eine ISIN-Suche gibt es dort nicht mehr (`/lookup` und `/search` antworten + * mit 404), das Kürzel muss deshalb in den Stammdaten stehen. + * + * @param array $wertpapier + */ + private static function yahoo(array $wertpapier): ?float + { + $symbol = trim((string)$wertpapier['symbol']); + + if ($symbol === '') { + throw new \RuntimeException( + 'Yahoo Finanzen kennt keine ISIN-Suche – bitte das Kürzel in den ' + . 'Stammdaten hinterlegen (Xetra-Werte enden auf .DE, etwa ALV.DE).' + ); + } + + $html = self::holeSeite('https://de.finance.yahoo.com/quote/' . rawurlencode($symbol) . '/'); + + // Yahoo liefert über sein Auslieferungsnetz gelegentlich eine ältere + // Fassung der Seite. Der angezeigte Stand („Markt geöffnet“, „Bei + // Börsenschluss“) landet deshalb im Protokoll – daran lässt sich ein + // alter Kurs erkennen, wenn einer auffällt. + if (preg_match('/marketTimeNotice[^>]*>(.*?)<\/div>/s', $html, $stand) === 1) { + DebugLog::schreibe( + 'kursabruf', + $symbol . ': ' . trim((string)preg_replace('/\s+/', ' ', strip_tags($stand[1]))) + ); + } + + return self::yahooKurs($html, $symbol); + } + + /** + * Der Kurs aus einer Yahoo-Kursseite. Getrennt vom Abruf, damit die + * Auswertung ohne Netz geprüft werden kann. + * + * Yahoo liefert die Seite in zwei Ausprägungen aus – mal mit dem + * maschinenlesbaren `` (Punkt als Dezimaltrenner), mal nur mit + * dem angezeigten Kurs (deutsche Schreibweise). Beides kommt vor, teils am + * selben Tag für verschiedene Papiere. + */ + public static function yahooKurs(string $html, string $symbol): ?float + { + // Der Titel lautet „Allianz SE (ALV.DE) Aktienkurs …“. Steht dort ein + // anderes Kürzel, zeigt die Seite ein anderes Papier – dann lieber kein + // Kurs als der falsche. + if (preg_match('#(.*?)#is', $html, $titel) === 1 + && !str_contains($titel[1], '(' . $symbol . ')') + ) { + throw new \RuntimeException( + 'Yahoo zeigt unter dem Kürzel ' . $symbol . ' eine andere Seite an.' + ); + } + + $streamer = '/]*data-symbol="' . preg_quote($symbol, '/') . '"' + . '[^>]*data-field="regularMarketPrice"[^>]*data-value="([0-9.]+)"/i'; + + if (preg_match($streamer, $html, $treffer) === 1) { + return self::zahl($treffer[1]); + } + + if (preg_match('/data-testid="qsp-price"[^>]*>([^<]+) */ private static function hole(string $url): array + { + $antwort = self::abruf($url, 'application/json', false); + + $daten = json_decode($antwort, true); + if (!is_array($daten)) { + throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.'); + } + + return $daten; + } + + /** + * Eine Kursseite als HTML. Yahoo leitet von `/quote/X/` auf die Länderseite + * weiter und liefert komprimiert – beides muss curl mitmachen. + * + * Zwischen zwei Seiten liegt eine Sekunde: Auf schnelle Serienabrufe + * antwortet Yahoo mit HTTP 503. Bei einer Handvoll Papiere am Tag fällt die + * Wartezeit nicht ins Gewicht. + * + * Ein 503 ist bei Yahoo vorübergehend und trifft auch Kürzel, die kurz + * darauf wieder ausgeliefert werden – deshalb bis zu drei Versuche, statt + * das Papier für den Tag verloren zu geben. + */ + private static function holeSeite(string $url): string + { + for ($versuch = 1; ; $versuch++) { + $wartezeit = self::SEITENPAUSE - (microtime(true) - self::$letzteSeite); + if (self::$letzteSeite > 0.0 && $wartezeit > 0.0) { + usleep((int)($wartezeit * 1_000_000)); + } + self::$letzteSeite = microtime(true); + + try { + return self::abruf($url, 'text/html', true); + } catch (\RuntimeException $fehler) { + if (self::$letzterStatus !== 503 || $versuch >= self::SEITENVERSUCHE) { + throw $fehler; + } + + sleep(self::WIEDERHOLPAUSE); + } + } + } + + /** + * Ein GET gegen den Kursdienst, Rohtext der Antwort. + */ + private static function abruf(string $url, string $accept, bool $folgen): string { $kanal = curl_init($url); if ($kanal === false) { @@ -223,9 +388,11 @@ class Kursabruf CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => self::ZEITGRENZE, CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_FOLLOWLOCATION => false, + CURLOPT_FOLLOWLOCATION => $folgen, + CURLOPT_MAXREDIRS => 3, + CURLOPT_ENCODING => '', CURLOPT_USERAGENT => 'Finanzmanager Kursabruf', - CURLOPT_HTTPHEADER => ['Accept: application/json'], + CURLOPT_HTTPHEADER => ['Accept: ' . $accept], ]); $antwort = curl_exec($kanal); @@ -233,6 +400,8 @@ class Kursabruf $fehler = curl_error($kanal); curl_close($kanal); + self::$letzterStatus = $status; + // Der Schlüssel steht in der Adresse – er darf nirgends im Klartext // auftauchen, auch nicht in einer Fehlermeldung. $sichtbar = preg_replace('/(apitoken|apikey)=[^&]*/', '$1=…', $url) ?? $url; @@ -241,22 +410,30 @@ class Kursabruf if ($antwort === false) { throw new \RuntimeException('Kursdienst nicht erreichbar: ' . $fehler); } - if ($status === 401 || $status === 403) { - throw new \RuntimeException('Der API-Schlüssel wurde abgelehnt (HTTP ' . $status . ').'); + + // Der Klartext des Dienstes wird mitgegeben: Ein 403 heißt bei Financial + // Modeling Prep längst nicht mehr nur „Schlüssel falsch“, sondern meist + // „diese Adresse gibt es nicht mehr“ – ohne den Text sucht man am + // falschen Ende. + $grund = self::meldung((string)$antwort); + + if ($status === 401) { + throw new \RuntimeException('Der API-Schlüssel wurde abgelehnt (HTTP 401).' . $grund); + } + if ($status === 402) { + throw new \RuntimeException('Im gebuchten Tarif nicht enthalten (HTTP 402).' . $grund); + } + if ($status === 403) { + throw new \RuntimeException('Zugriff verweigert (HTTP 403).' . $grund); } if ($status === 429) { throw new \RuntimeException('Das Tageskontingent des Kursdienstes ist aufgebraucht.'); } if ($status < 200 || $status >= 300) { - throw new \RuntimeException('Kursdienst antwortet mit HTTP ' . $status . '.'); + throw new \RuntimeException('Kursdienst antwortet mit HTTP ' . $status . '.' . $grund); } - $daten = json_decode((string)$antwort, true); - if (!is_array($daten)) { - throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.'); - } - - return $daten; + return (string)$antwort; } /** @@ -280,6 +457,35 @@ class Kursabruf return null; } + /** + * Die Begründung des Dienstes, gekürzt und einzeilig – als Anhang an die + * eigene Fehlermeldung. Die Anbieter antworten mal mit JSON, mal mit + * schlichtem Text, deshalb beides. + */ + private static function meldung(string $antwort): string + { + // Fehlerseiten der Kursseiten-Anbieter sind ganze HTML-Dokumente – davon + // gehört nichts in eine Fehlermeldung. + if (str_starts_with(ltrim($antwort), '<')) { + return ''; + } + + $daten = json_decode($antwort, true); + $text = is_array($daten) + ? (string)(self::ersterWert($daten, ['Error Message', 'error', 'message', 'Message']) ?? '') + : $antwort; + + $text = trim(preg_replace('/\s+/', ' ', $text) ?? ''); + if ($text === '') { + return ''; + } + if (mb_strlen($text) > 200) { + $text = mb_substr($text, 0, 200) . '…'; + } + + return ' ' . $text; + } + private static function zahl(mixed $wert): ?float { if ($wert === null || !is_numeric($wert)) { diff --git a/src/Views/einstellungen/index.php b/src/Views/einstellungen/index.php index bb0f08d..2b6be9d 100644 --- a/src/Views/einstellungen/index.php +++ b/src/Views/einstellungen/index.php @@ -157,16 +157,19 @@ include __DIR__ . '/../layout/kopf.php';

- Beide kennen ISINs und haben ein kostenloses Tageskontingent, das für rund zwanzig - Positionen reicht. + Leeway kennt ISINs und hat ein kostenloses Tageskontingent, das für rund zwanzig + Positionen reicht. Financial Modeling Prep führt ISIN-Suche und deutsche + Börsenplätze nur im Bezahltarif. Yahoo Finanzen braucht keinen Schlüssel, dafür + muss bei jedem Wertpapier das Kürzel samt Handelsplatz stehen (etwa + ALV.DE für Xetra).

- Nach kostenloser Anmeldung beim gewählten Dienst. Ohne Schlüssel lassen sich Kurse - nur von Hand eintragen. + Nach kostenloser Anmeldung beim gewählten Dienst. Bei Yahoo Finanzen bleibt das Feld + leer; ohne Schlüssel lassen sich Kurse sonst nur von Hand eintragen.