码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/app/Support/IpLocator.php
IpLocator.php24.8 KB
<?php
/**
 * IP 地理解析器(驱动可插拔:none | http | xdb)
 *
 * - xdb :ip2region xdb 离线库纯 PHP 检索,**IPv4 / IPv6 双库**:
 *           · IPv4 → `collect.geo_xdb` (默认 server/data/ip2region.xdb)
 *           · IPv6 → `collect.geo_xdb6`(默认 server/data/ip2region_v6.xdb)
 *         实现参考 ip2region 官方 v3 绑定 Searcher(Apache-2.0),改为二分检索,零依赖。
 *
 *         两库结构相同(256B 头 + 512KiB 向量索引 + 数据区 + 二分索引),差异只有两处:
 *           ① 地址宽度:IPv4 = 4 字节,IPv6 = 16 字节;
 *           ② 索引项大小:IPv4 = 14 = 4+4+2+4,IPv6 = 38 = 16+16+2+4。
 *
 *         **字节序不同,这是最容易踩的坑**:IPv4 库的 sip/eip 是**小端**(ip2region 早期实现的
 *         历史包袱),IPv6 库是**大端原样 16 字节**。所以比对方式也不同 —— IPv4 用小端整型比较,
 *         IPv6 用 16 字节二进制串 `strcmp`(memcmp 语义,无符号逐字节)比较。二者都等价于
 *         「按地址数值比大小」,但绝不能互换。
 *
 *         向量索引定位公式两族一致:header(256B) + 地址前两字节 → b1*2048 + b2*8。
 *
 * - http:接口型驱动。config collect.geo_http_url 形如 https://host/path?ip={ip},
 *         geo_http_key 非空时自动拼 key 参数(或替换 {key} 占位)。
 *         兼容 ad_info.{nation,province,city}(腾讯)/ data.{nation,province,city}(百度)
 *         / 顶层 {nation,province,city} / ip-api {country,regionName,city} 等常见 JSON 结构。
 *         IPv6 传入 {ip} 时会先 rawurlencode(裸 `:` 出现在 URL 路径段里是非法的)。
 * - none :不解析(采集字段保持空串)。
 *
 * 两族互相独立降级:v6 库缺失只影响 v6 访客,v4 照常解析(反之亦然)。
 * 只有一个库都没有时才整体降级 none。
 *
 * `::ffff:a.b.c.d`(IPv4-mapped,双栈套接字常见)会**改判为 IPv4** 并走 v4 库 ——
 * 它本质就是 IPv4 地址,而 v4 库的数据覆盖远好于在 v6 库里查一个 v6 段。
 *
 * 解析结果进程内按 IP 缓存(worker 常驻收益最大;FPM 每次请求内复用)。
 */
declare(strict_types=1);

namespace Wstat\Support;

class IpLocator
{
    /** 解析结果进程内缓存上限 */
    private const CACHE_MAX = 2048;

    /** 地址族(4 / 6)→ 库文件句柄 */
    private static array $fp = [4 => null, 6 => null];

    /** 地址族 → 库文件路径(首次读配置后缓存) */
    private static array $paths = [];

    /** 地址族 → 头部信息:byte_num / seg_size / file_version / ip_version */
    private static array $heads = [];

    /** 地址族 → 已确认不可用(打开或头部校验失败;不再重试,避免每次查询都白跑一次 fopen) */
    private static array $dead = [4 => false, 6 => false];

    /** 配置里的驱动(none|http|xdb),null = 尚未读取配置 */
    private static ?string $driver = null;

    /** @var array<string,array{country:string,province:string,city:string}> */
    private static array $cache = [];

    /**
     * IPv6 内网 / 保留段:直接跳过检索。
     * IPv4 侧不走这张表,用位运算快判(见 isPrivate),避免每次解析都做 CIDR 匹配。
     */
    private const V6_INTERNAL = [
        '::/128',          // 未指定地址
        '::1/128',         // 回环
        'fc00::/7',        // ULA 唯一本地地址
        'fe80::/10',       // 链路本地
        'ff00::/8',        // 组播
        '2001:db8::/32',   // 文档示例段
        '2002::/16',       // 6to4:内嵌 IPv4,地域归属不确定,按「不可解析」处理
    ];

    /* ================= 配置与可用性 ================= */

    /** 配置里的驱动(none|http|xdb),不含任何可用性判断 */
    private static function configured(): string
    {
        if (self::$driver === null) {
            $d = (string) (wstat_config('collect.geo_driver') ?? 'none');
            self::$driver = in_array($d, ['none', 'http', 'xdb'], true) ? $d : 'none';
        }
        return self::$driver;
    }

    /** 指定地址族的库文件路径 */
    private static function xdbPath(int $fam): string
    {
        if (!isset(self::$paths[$fam])) {
            if ($fam === 6) {
                $p = (string) (wstat_config('collect.geo_xdb6') ?? '');
                self::$paths[6] = $p !== '' ? $p : WSTAT_ROOT . '/data/ip2region_v6.xdb';
            } else {
                $p = (string) (wstat_config('collect.geo_xdb') ?? '');
                self::$paths[4] = $p !== '' ? $p : WSTAT_ROOT . '/data/ip2region.xdb';
            }
        }
        return self::$paths[$fam];
    }

    /**
     * 指定地址族当前能否真正解析。
     *
     * xdb 驱动下不只看「文件在不在」,还要**能打开并通过头部校验**(声明的地址族与槽位一致、
     * 数据指针宽度受支持)。否则「文件存在但放错族」时诊断会给出一片绿灯,而解析必然返回空 ——
     * 那种假绿灯比直接报错更难排查。
     */
    private static function usable(int $fam): bool
    {
        $c = self::configured();
        if ($c === 'http') {
            return true;
        }
        if ($c !== 'xdb') {
            return false;
        }
        return self::openXdb($fam) !== null;
    }

    /**
     * 生效驱动(用于诊断展示)。
     *
     * xdb 驱动下**按族独立降级**:只有两族库都不可用时才整体降级 none;
     * 单族缺失不影响另一族(这是相对早期单库实现的语义变化)。
     */
    private static function driver(): string
    {
        $c = self::configured();
        if ($c === 'xdb' && !self::usable(4) && !self::usable(6)) {
            return 'none';
        }
        return $c;
    }

    /**
     * 诊断信息:配置驱动 / 生效驱动(整体 + 按族)/ 两个库文件状态 / 目录下候选 xdb。
     * 用于 doctor / geo-doctor 脚本与后台页面的「为什么没有地域数据」提示。
     *
     * 历史键名(`xdb_path` / `xdb_exists` / `xdb_size` / `xdb_readable` / `xdb_dir`)继续指向
     * **IPv4 库**,避免老前端与老诊断页取不到值;IPv6 库用 `xdb6_*` 前缀的平行键。
     */
    public static function status(): array
    {
        $p4 = self::xdbPath(4);
        $p6 = self::xdbPath(6);
        $e4 = is_file($p4);
        $e6 = is_file($p6);

        // 目录下所有 .xdb 候选(含各自头部声明的 IP 版本,便于识别「放错文件/改错名」)
        $cands = [];
        foreach (array_values(array_unique([dirname($p4), dirname($p6)])) as $dir) {
            $list = @scandir($dir);
            if (!is_array($list)) {
                continue;
            }
            foreach ($list as $f) {
                if (!is_string($f) || substr(strtolower($f), -4) !== '.xdb') {
                    continue;
                }
                $full = $dir . DIRECTORY_SEPARATOR . $f;
                $cands[] = [
                    'file'       => $f,
                    'size'       => (int) @filesize($full),
                    'ip_version' => self::peekIpVersion($full),
                ];
            }
        }

        return [
            'configured'   => (string) (wstat_config('collect.geo_driver') ?? 'none'),
            'effective'    => self::driver(),
            'effective_v4' => self::usable(4) ? self::configured() : 'none',
            'effective_v6' => self::usable(6) ? self::configured() : 'none',
            // 历史键名 → IPv4 库
            'xdb_path'     => $p4,
            'xdb_exists'   => $e4,
            'xdb_size'     => $e4 ? (int) @filesize($p4) : 0,
            'xdb_readable' => $e4 && is_readable($p4),
            'xdb_dir'      => dirname($p4),
            // IPv6 库
            'xdb6_path'     => $p6,
            'xdb6_exists'   => $e6,
            'xdb6_size'     => $e6 ? (int) @filesize($p6) : 0,
            'xdb6_readable' => $e6 && is_readable($p6),
            'xdb_candidates' => $cands,
        ];
    }

    /** 只读前 20 字节判断库文件声明的 IP 版本;读不出返回 0 */
    private static function peekIpVersion(string $file): int
    {
        $fp = @fopen($file, 'rb');
        if ($fp === false) {
            return 0;
        }
        $h = fread($fp, 20);
        fclose($fp);
        if (!is_string($h) || strlen($h) !== 20) {
            return 0;
        }
        $hd = unpack('vversion/vpolicy/Vcreated/Vsiptr/Veiptr/vipver/vptrbytes', $h);
        $ver = (int) ($hd['version'] ?? 0);
        if ($ver === 2) {
            return 4;                 // v2 结构只有 IPv4
        }
        return $ver === 3 ? (int) ($hd['ipver'] ?? 0) : 0;
    }

    /* ================= 地址族判定 ================= */

    /** 地址族:4 / 6;非法地址返回 0 */
    public static function family(string $ip): int
    {
        $ip = trim($ip);
        if ($ip === '') {
            return 0;
        }
        if (strpos($ip, ':') !== false) {
            return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? 6 : 0;
        }
        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false ? 4 : 0;
    }

    /**
     * `::ffff:a.b.c.d`(IPv4-mapped)→ 点分 IPv4 串;非该形式返回空串。
     *
     * 只认 `::ffff:0:0/96` 这一段:它才是双栈套接字实际吐出来的形式。
     * IPv4-compatible(`::a.b.c.d`,RFC 4291 已废弃)不处理 —— 它会和 `::` / `::1` 撞车,
     * 硬拆反而把「未指定地址」变成 `0.0.0.0` 这类噪音。
     */
    private static function mappedV4(string $ip): string
    {
        $b = @inet_pton($ip);
        if ($b === false || strlen($b) !== 16) {
            return '';
        }
        if (substr($b, 0, 12) !== str_repeat("\x00", 10) . "\xff\xff") {
            return '';
        }
        $n = unpack('N', substr($b, 12, 4));
        return long2ip((int) ($n[1] ?? 0));
    }

    /** 探活:清空缓存后按当前驱动解析固定公网 IP(诊断用) */
    public static function probe(string $ip = '114.114.114.114'): array
    {
        self::$fp = [4 => null, 6 => null];
        self::$heads = [];
        self::$dead = [4 => false, 6 => false];
        self::$driver = null;
        self::$paths = [];
        self::$cache = [];
        return self::resolve($ip);
    }

    /** 空结果 */
    private static function emptyGeo(): array
    {
        return ['country' => '', 'province' => '', 'city' => ''];
    }

    /** 解析 IP,返回 ['country'=>,'province'=>,'city'=>](无法识别时为空串) */
    public static function resolve(string $ip): array
    {
        $ip = trim($ip);
        $fam = self::family($ip);
        if ($fam === 0) {
            return self::emptyGeo();
        }
        // IPv4-mapped → 改判 IPv4 走 v4 库(本质是 v4 地址,v4 库覆盖更好)
        if ($fam === 6) {
            $v4 = self::mappedV4($ip);
            if ($v4 !== '') {
                $ip = $v4;
                $fam = 4;
            }
        }
        // 内网 / 保留地址直接跳过,避免无效检索
        if (self::isPrivate($ip, $fam)) {
            return self::emptyGeo();
        }
        if (self::driver() === 'none') {
            return self::emptyGeo();
        }
        if (isset(self::$cache[$ip])) {
            return self::$cache[$ip];
        }
        $geo = self::configured() === 'http' ? self::httpLookup($ip) : self::xdbLookup($ip, $fam);
        if (count(self::$cache) >= self::CACHE_MAX) {
            self::$cache = [];
        }
        self::$cache[$ip] = $geo;
        return $geo;
    }

    /** 给事件数组补齐地理字段(仅在为空时解析;返回新数组) */
    public static function enrich(array $evt): array
    {
        $ip = (string) ($evt['ip'] ?? '');
        $need = ($evt['country'] ?? '') === '' && ($evt['province'] ?? '') === '' && ($evt['city'] ?? '') === '';
        if ($need && $ip !== '') {
            $geo = self::resolve($ip);
            if (($geo['country'] ?? '') !== '' || ($geo['province'] ?? '') !== '' || ($geo['city'] ?? '') !== '') {
                $evt['country'] = (string) $geo['country'];
                $evt['province'] = (string) $geo['province'];
                $evt['city'] = (string) $geo['city'];
            }
        }
        return $evt;
    }

    /* ================= xdb 离线库 ================= */

    /**
     * 按地址族检索。
     *
     * 两族共用同一套流程(向量索引 → 二分索引 → 数据区),只有「地址键」与「索引项布局」不同,
     * 故把差异收敛到 addrKey() / recKey() / keyCmp() 三个函数里,检索逻辑只写一份。
     */
    private static function xdbLookup(string $ip, int $fam): array
    {
        $fp = self::openXdb($fam);
        if ($fp === null) {
            return self::emptyGeo();
        }
        $head = self::$heads[$fam] ?? null;
        if ($head === null) {
            return self::emptyGeo();
        }
        $byteNum = (int) $head['byte_num'];      // 4 / 16
        $segSize = (int) $head['seg_size'];      // 14 / 38

        $probe = self::addrKey($ip, $fam);
        if ($probe === null) {
            return self::emptyGeo();
        }

        // 1) 向量索引定位段索引区间:header 256B + b1*256*8 + b2*8
        //    两族都用地址的**前两个字节**(v4 = 最高两字节;v6 = 头两字节),公式一致
        $b1 = self::addrByte($ip, $fam, 0);
        $b2 = self::addrByte($ip, $fam, 1);
        if (fseek($fp, 256 + $b1 * 2048 + $b2 * 8) !== 0) {
            return self::emptyGeo();
        }
        $vec = fread($fp, 8);
        if ($vec === false || strlen($vec) !== 8) {
            return self::emptyGeo();
        }
        $u = unpack('Vsptr/Veptr', $vec);
        $sPtr = (int) ($u['sptr'] ?? 0);
        $ePtr = (int) ($u['eptr'] ?? 0);
        if ($sPtr === 0 || $ePtr === 0 || $ePtr <= $sPtr) {
            return self::emptyGeo();
        }

        // 2) 段索引二分
        $lo = 0;
        $hi = (int) (($ePtr - $sPtr) / $segSize);
        while ($lo <= $hi) {
            $mid = ($lo + $hi) >> 1;
            if (fseek($fp, $sPtr + $mid * $segSize) !== 0) {
                break;
            }
            $rec = fread($fp, $segSize);
            if ($rec === false || strlen($rec) !== $segSize) {
                break;
            }
            $sip = self::recKey($rec, 0, $byteNum, $fam);
            $eip = self::recKey($rec, $byteNum, $byteNum, $fam);
            if (self::keyCmp($fam, $probe, $sip) < 0) {
                $hi = $mid - 1;
            } elseif (self::keyCmp($fam, $probe, $eip) > 0) {
                $lo = $mid + 1;
            } else {
                // 3) 命中:读数据区(数据长度 2B + 数据指针 4B,均小端)
                $r = unpack('vlen/Vptr', substr($rec, $byteNum * 2, 6));
                $len = (int) ($r['len'] ?? 0);
                $ptr = (int) ($r['ptr'] ?? 0);
                if ($len <= 0 || $ptr <= 0) {
                    return self::emptyGeo();
                }
                if (fseek($fp, $ptr) !== 0) {
                    return self::emptyGeo();
                }
                $region = fread($fp, $len);
                if ($region === false || strlen($region) !== $len) {
                    return self::emptyGeo();
                }
                return self::regionToGeo($region);
            }
        }
        return self::emptyGeo();
    }

    /**
     * 查询地址的「可比较键」:v4 = 无符号 32 位整数;v6 = 16 字节大端二进制串。
     * 非法地址返回 null。
     */
    private static function addrKey(string $ip, int $fam)
    {
        if ($fam === 4) {
            $n = ip2long($ip);
            return $n === false ? null : (int) (sprintf('%u', $n) & 0xFFFFFFFF);
        }
        $b = @inet_pton($ip);
        return ($b === false || strlen($b) !== 16) ? null : $b;
    }

    /** 取地址第 $i 个字节(0 起,大端语义) */
    private static function addrByte(string $ip, int $fam, int $i): int
    {
        if ($fam === 4) {
            $n = (int) (sprintf('%u', (int) ip2long($ip)) & 0xFFFFFFFF);
            return ($n >> (24 - $i * 8)) & 0xFF;
        }
        $b = (string) @inet_pton($ip);
        return ord($b[$i] ?? "\x00");
    }

    /**
     * 从索引记录里取地址键。
     *
     * **字节序是两族的根本差异**:IPv4 库的 sip/eip 以小端存储(ip2region 早期实现的历史包袱,
     * 官方 v4 比较器还要把字节倒着读一轮),IPv6 库是大端原样 16 字节。这里各自解成
     * 「数值可比」的形式 —— v4 用小端 int,v6 用原样字节串。
     */
    private static function recKey(string $rec, int $at, int $bytes, int $fam)
    {
        if ($fam === 4) {
            $u = unpack('V', substr($rec, $at, 4));
            return (int) ($u[1] ?? 0);
        }
        return substr($rec, $at, $bytes);
    }

    /** 两个地址键比较:<0 / 0 / >0。IPv4 比整数,IPv6 比字节串 */
    private static function keyCmp(int $fam, $a, $b): int
    {
        if ($fam === 4) {
            return $a === $b ? 0 : ((int) $a < (int) $b ? -1 : 1);
        }
        // strcmp 是 memcmp 语义(无符号逐字节),正好等价于 128 位地址的数值比较;
        // 注意不能用 PHP 的 `<` 比二进制串 —— 若串恰好是「数字形式」会退化成数值比较
        $c = strcmp((string) $a, (string) $b);
        return $c === 0 ? 0 : ($c < 0 ? -1 : 1);
    }

    /**
     * 打开并校验指定族的库文件。
     *
     * 头部校验会**核对库文件声明的 IP 版本与所在槽位是否一致** —— 有人把 v6 库改名成
     * ip2region.xdb(或两个配置指到同一个文件)时,会在这里被挡下并标记该族不可用,
     * 而不是拿着 4 字节的地址去解析 38 字节的索引项(那样只会读到垃圾数据)。
     */
    private static function openXdb(int $fam)
    {
        if (self::$fp[$fam] !== null && is_resource(self::$fp[$fam])) {
            return self::$fp[$fam];
        }
        if (!empty(self::$dead[$fam])) {
            return null;
        }
        $path = self::xdbPath($fam);
        $fp = @fopen($path, 'rb');
        if ($fp === false) {
            self::$dead[$fam] = true;
            return null;
        }
        $h = fread($fp, 20);
        if ($h === false || strlen($h) !== 20) {
            fclose($fp);
            self::$dead[$fam] = true;
            return null;
        }
        $hd = unpack('vversion/vpolicy/Vcreated/Vsiptr/Veiptr/vipver/vptrbytes', $h);
        $ver = (int) ($hd['version'] ?? 0);
        $ipVer = (int) ($hd['ipver'] ?? 0);
        $ptrBytes = (int) ($hd['ptrbytes'] ?? 0);
        $fileFam = $ver === 2 ? 4 : ($ver === 3 ? $ipVer : 0);
        if ($fileFam !== $fam) {
            fclose($fp);
            self::$dead[$fam] = true;
            return null;
        }
        // 数据指针宽度:当前实现按 4 字节解析索引项与数据区(现有库都是 4)。
        // 若将来官方把指针扩到 8 字节,索引项布局会整体改变 —— 那时必须**拒绝**而不是
        // 硬着头皮按 4 字节读,否则读到的是错位数据、且返回的是看似正常的错误地域。
        // v2 老结构没有该字段,按 4 处理。
        if ($ver === 3 && $ptrBytes !== 4) {
            fclose($fp);
            self::$dead[$fam] = true;
            return null;
        }
        self::$heads[$fam] = [
            'file_version' => $ver,
            'ip_version'   => $ipVer,
            'ptr_bytes'    => $ptrBytes,
            'byte_num'     => $fam === 4 ? 4 : 16,
            'seg_size'     => $fam === 4 ? 14 : 38,
        ];
        self::$fp[$fam] = $fp;
        return $fp;
    }

    /** "国家|省份|城市|运营商" → 三字段('0'/'内网IP' 等占位置空) */
    private static function regionToGeo(string $region): array
    {
        $p = explode('|', $region);
        return [
            'country' => self::clean($p[0] ?? ''),
            'province' => self::clean($p[1] ?? ''),
            'city' => self::clean($p[2] ?? ''),
        ];
    }

    private static function clean(string $s): string
    {
        $s = trim($s);
        if ($s === '0' || $s === '' || $s === '内网IP' || $s === '局域网' || strcasecmp($s, 'N/A') === 0) {
            return '';
        }
        return self::cut($s, 64);
    }

    private static function cut(string $s, int $len): string
    {
        return mb_strlen($s) > $len ? mb_substr($s, 0, $len) : $s;
    }

    /**
     * 内网 / 保留地址判定(跳过检索)。
     *
     * IPv4 走位运算快判;IPv6 复用 `Util::ipInList()` 的 CIDR 匹配
     * (同一套二进制前缀比较,与 Cloudflare 段判定共用实现,避免另写一份易错的 v6 前缀逻辑)。
     */
    private static function isPrivate(string $ip, int $fam): bool
    {
        if ($fam === 6) {
            return Util::ipInList($ip, self::V6_INTERNAL);
        }
        $n = ip2long($ip);
        if ($n === false) {
            return true;
        }
        $n = (int) sprintf('%u', $n) & 0xFFFFFFFF;
        if (($n >> 24) === 10 || ($n >> 24) === 127) {
            return true;
        }
        if ((($n >> 16) & 0xFFF0) === 0xAC10) {       // 172.16.0.0/12
            return true;
        }
        if (($n >> 16) === 0xC0A8) {                  // 192.168.0.0/16
            return true;
        }
        if (($n >> 16) === 0xA9FE) {                  // 169.254.0.0/16 link-local
            return true;
        }
        return $n === 0 || $n === 0xFFFFFFFF;
    }

    /* ================= http 驱动 ================= */

    private static function httpLookup(string $ip): array
    {
        $empty = self::emptyGeo();
        $url = (string) (wstat_config('collect.geo_http_url') ?? '');
        if ($url === '') {
            return $empty;
        }
        // IPv6 里裸 `:` 出现在 URL 路径段是非法的(会被解析成端口/协议分隔),先编码;
        // 对 IPv4 是恒等变换,不改变既有行为
        $slot = strpos($ip, ':') !== false ? rawurlencode($ip) : $ip;
        $url = str_replace('{ip}', $slot, $url);
        $key = (string) (wstat_config('collect.geo_http_key') ?? '');
        if ($key !== '') {
            if (strpos($url, '{key}') !== false) {
                $url = str_replace('{key}', rawurlencode($key), $url);
            } else {
                $url .= (strpos($url, '?') === false ? '?' : '&') . 'key=' . rawurlencode($key);
            }
        }

        // Redis 缓存 30 天(worker 常驻进程场景可跨进程命中;失败静默)
        $r = null;
        try {
            $r = Rds::get();
            if ($r !== null) {
                $cached = $r->get('geo:http:' . $ip);
                if (is_string($cached) && $cached !== '') {
                    $j = json_decode($cached, true);
                    if (is_array($j)) {
                        return $j + $empty;
                    }
                }
            }
        } catch (\Throwable $e) {
            $r = null;
        }

        $raw = @file_get_contents($url, false, stream_context_create([
            'http' => ['timeout' => 1.2, 'ignore_errors' => true, 'method' => 'GET'],
            'ssl' => ['verify_peer' => false, 'verify_peer_name' => false],
        ]));
        $geo = $empty;
        if (is_string($raw)) {
            $json = json_decode($raw, true);
            if (is_array($json)) {
                $geo = self::extractHttpGeo($json);
            }
        }
        try {
            if ($r !== null) {
                $r->set('geo:http:' . $ip, json_encode($geo, JSON_UNESCAPED_UNICODE), 30 * 86400);
            }
        } catch (\Throwable $e) {
            /* ignore */
        }
        return $geo;
    }

    /** 兼容常见地理 JSON 结构 */
    private static function extractHttpGeo(array $json): array
    {
        $nodes = [];
        if (isset($json['data']) && is_array($json['data'])) {
            $nodes[] = $json['data'];
        }
        if (isset($json['ad_info']) && is_array($json['ad_info'])) {
            $nodes[] = $json['ad_info'];
        }
        $nodes[] = $json;

        foreach ($nodes as $node) {
            $c = (string) ($node['nation'] ?? $node['country'] ?? $node['countryName'] ?? '');
            $p = (string) ($node['province'] ?? $node['regionName'] ?? '');
            $ci = (string) ($node['city'] ?? '');
            if (isset($node['status']) && (string) $node['status'] !== 'success') {
                continue;   // ip-api 失败体
            }
            if ($c !== '' || $p !== '' || $ci !== '') {
                return [
                    'country' => self::clean($c),
                    'province' => self::clean($p),
                    'city' => self::clean($ci),
                ];
            }
        }
        return self::emptyGeo();
    }
}