search('Münch', limit: 5) as $ort) { * echo $ort['name'] . ' (' . $ort['country_code'] . ")\n"; * } * * // Reverse-Geocoding mit Städte-Präferenz * $stadt = $api->lookupCity(48.137, 11.575); * echo $stadt['name'] . ' / ' . $stadt['timezone']; * * // Mit Datei-Cache (15 Min TTL) * $api = new GeoApiClient(cacheDir: __DIR__ . '/cache', cacheTtlSeconds: 900); * * Erfordert PHP 8.1+ (readonly properties, named arguments). */ final class GeoApiException extends \RuntimeException {} final class GeoApiClient { public function __construct( public readonly string $baseUrl = 'https://geoapi.world', public readonly int $timeoutSeconds = 5, public readonly string $userAgent = 'GeoApiClient/1.0', public readonly ?string $cacheDir = null, public readonly int $cacheTtlSeconds = 0, ) { if ($this->cacheDir !== null && !is_dir($this->cacheDir)) { @mkdir($this->cacheDir, 0775, true); } } /** * Ortssuche / Autosuggest-Treffer. * * @param string $query Suchbegriff (mind. 2 Zeichen) * @param int $limit max. Treffer (1–30) * @return list> */ public function search(string $query, int $limit = 10): array { if (mb_strlen($query) < 2) { return []; } $limit = max(1, min(30, $limit)); $data = $this->get('/api/search.php', ['q' => $query, 'limit' => $limit]); return $data['results'] ?? []; } /** * Reverse-Geocoding mit Städte-Präferenz (PPLC/PPLA bevorzugt). * Geeignet für Geburtsort/Wohnort, wo "die Stadt" gemeint ist. * * @return array|null Treffer oder null */ public function lookupCity(float $lat, float $lon): ?array { return $this->lookup($lat, $lon, 'city'); } /** * Reverse-Geocoding ohne Präferenz — der geographisch nächste Ort. * Geeignet für Kartenklicks, wenn der konkrete Ortsteil gewünscht ist. * * @return array|null */ public function lookupExact(float $lat, float $lon): ?array { return $this->lookup($lat, $lon, 'exact'); } /** * @return array|null */ public function lookup(float $lat, float $lon, string $mode = 'city'): ?array { if ($mode !== 'city' && $mode !== 'exact') { throw new GeoApiException("Ungültiger Modus: $mode (erlaubt: 'city', 'exact')"); } $data = $this->get('/api/lookup.php', ['lat' => $lat, 'lon' => $lon, 'mode' => $mode]); return $data['hit'] ?? null; } // ---------------------------------------------------------------- intern /** * @param array $params * @return array */ private function get(string $path, array $params): array { $url = $this->baseUrl . $path . '?' . http_build_query($params); if ($this->cacheTtlSeconds > 0 && $this->cacheDir !== null) { $cacheFile = $this->cacheDir . '/' . sha1($url) . '.json'; if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $this->cacheTtlSeconds) { $cached = file_get_contents($cacheFile); if (is_string($cached)) { $data = json_decode($cached, true); if (is_array($data)) { return $data; } } } } $body = $this->fetch($url); $data = json_decode($body, true); if (!is_array($data)) { throw new GeoApiException("Ungültige JSON-Antwort von $url"); } if (isset($data['error'])) { throw new GeoApiException("geoAPI: " . (string) $data['error']); } if (isset($cacheFile)) { @file_put_contents($cacheFile, $body, LOCK_EX); } return $data; } private function fetch(string $url): string { if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->timeoutSeconds, CURLOPT_USERAGENT => $this->userAgent, CURLOPT_FAILONERROR => false, CURLOPT_FOLLOWLOCATION => true, ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($body === false || $code >= 400) { throw new GeoApiException("HTTP $code für $url" . ($err !== '' ? ": $err" : '')); } return (string) $body; } // Fallback: file_get_contents (benötigt allow_url_fopen=On) $ctx = stream_context_create(['http' => [ 'timeout' => $this->timeoutSeconds, 'header' => "User-Agent: {$this->userAgent}\r\n", ]]); $body = @file_get_contents($url, false, $ctx); if ($body === false) { throw new GeoApiException("Abruf fehlgeschlagen für $url (cURL fehlt + allow_url_fopen=Off?)"); } return $body; } }