利用B站IP查询API更新访客记录IP地理信息

偶然的一次机会,在群里面聊天的时候,群友指出纯真数据库的数据并不准确,并且说有一个可以免费调用的IP地理信息库——B站的IP查询API。据说还是用的ipip.net的企业版,这下得狠狠白嫖了。

前置任务:修复VisitorLoggerPro插件BUG

如果使用过这个插件一段时间,你就应该发现了,在国家统计图表这项,非常的混乱,似乎是只要记录的字符不一致,就会被当作不同的国家处理,例如美国加利福尼亚洛杉矶和美国纽约就被当作不同的国家列在上面。
经过深挖代码斗智斗勇,我发现这个问题我无法从根本解决,因为整个代码太奇怪了,而且用的本地IP数据库也都是乱七八糟的,根本没有做好国家的细分地区分类,一股脑返回,导致所有的信息被集中在一起写进了国家字段。
于是,我放弃治疗,直接在后面的调用代码中,增加分类逻辑进行分类:
这是原来的代码,单独处理和提取了国内省份:

        foreach ($countryCountsResult as $row) {
            $countryName = $row['country'] ?: '未知';
            $count = $row['count'];

            if (!isset($countryData[$countryName])) {
                $countryData[$countryName] = 0;
                $totalCountries++;
            }
            $countryData[$countryName] += $count;

            if (strpos($countryName, '中国') !== false) {
                foreach ($provinces as $province) {
                    if (strpos($countryName, $province) !== false) {
                        if (!isset($provinceData[$province])) {
                            $provinceData[$province] = 0;
                        }
                        $provinceData[$province] += $count;
                        break;
                    }
                }
            }
        }

我决定在返回国家信息的时候,进行提取合并并返回:

        foreach ($countryCountsResult as $row) {
            $rawCountry = $row['country'] ?? '';
            $count = (int)($row['count'] ?? 0);

            if ($rawCountry === '') {
                $stdCountry = '未知';
            } elseif (strpos($rawCountry, '中国') !== false) {
                // 所有含“中国”的都归为“中国”
                $stdCountry = '中国';

                // 同时尝试提取省份
                foreach ($provinces as $province) {
                    if (strpos($rawCountry, $province) !== false) {
                        if (!isset($provinceData[$province])) {
                            $provinceData[$province] = 0;
                        }
                        $provinceData[$province] += $count;
                        break; // 匹配到第一个即停止
                    }
                }
            } else {
                // 非中国:尝试匹配标准国家名(用于归一化,如“美国加州”→“美国”)
                $stdCountry = $rawCountry; // 默认保留原值
                foreach ($countries as $country) {
                    if (strpos($rawCountry, $country) !== false) {
                        $stdCountry = $country;
                        break; // 匹配到即停止(因列表已按长优先排序)
                    }
                }
            }

            // 累计国家统计
            if (!isset($countryData[$stdCountry])) {
                $countryData[$stdCountry] = 0;
                $totalCountries++;
            }
            $countryData[$stdCountry] += $count;
        }

当然了,需要补上完整的国家地区列表:

        $countries = [
            // 长名称国家(必须放前面)
            '波斯尼亚和黑塞哥维那',
            '赤道几内亚',
            '圣文森特和格林纳丁斯',
            '特立尼达和多巴哥',
            '圣多美和普林西比',
            '阿拉伯联合酋长国',
            '沙特阿拉伯',
            '巴布亚新几内亚',
            '所罗门群岛',
            '马绍尔群岛',
            '密克罗尼西亚联邦',
            '帕劳共和国',
            '瓦努阿图共和国',
            '基里巴斯共和国',
            '瑙鲁共和国',
            '图瓦卢',
            '美属萨摩亚',
            '英属维尔京群岛',
            '美属维尔京群岛',
            '北马里亚纳群岛',
            '福克兰群岛(马尔维纳斯)',
            '法属圭亚那',
            '法属波利尼西亚',
            '法属南部领地',
            '皮特凯恩群岛',
            '托克劳',
            '库克群岛',
            '纽埃',
            '新喀里多尼亚',
            '关岛',
            '百慕大',
            '开曼群岛',
            '安圭拉',
            '蒙特塞拉特',
            '荷属圣马丁',
            '法属圣马丁',
            '圣皮埃尔和密克隆',
            '阿森松岛',
            '特里斯坦-达库尼亚',

            // 主权国家(按字母拼音排序)
            '阿富汗', '阿尔巴尼亚', '阿尔及利亚', '安道尔', '安哥拉', '安提瓜和巴布达',
            '阿根廷', '亚美尼亚', '澳大利亚', '奥地利', '阿塞拜疆',
            '巴哈马', '巴林', '孟加拉国', '巴巴多斯', '白俄罗斯', '比利时', '伯利兹', '贝宁',
            '不丹', '玻利维亚', '博茨瓦纳', '巴西', '文莱', '保加利亚', '布基纳法索', '布隆迪',
            '柬埔寨', '喀麦隆', '加拿大', '佛得角', '中非', '乍得', '智利', '哥伦比亚',
            '科摩罗', '刚果(布)', '刚果(金)', '哥斯达黎加', '科特迪瓦', '克罗地亚', '古巴',
            '塞浦路斯', '捷克', '丹麦', '吉布提', '多米尼克', '多米尼加',
            '厄瓜多尔', '埃及', '萨尔瓦多', '赤道几内亚', '厄立特里亚', '爱沙尼亚', '斯威士兰',
            '埃塞俄比亚', '斐济', '芬兰', '法国',
            '加蓬', '冈比亚', '格鲁吉亚', '德国', '加纳', '希腊', '格林纳达', '危地马拉',
            '几内亚', '几内亚比绍', '圭亚那',
            '海地', '洪都拉斯', '匈牙利',
            '冰岛', '印度', '印度尼西亚', '伊朗', '伊拉克', '爱尔兰', '以色列', '意大利',
            '牙买加', '日本', '约旦',
            '哈萨克斯坦', '肯尼亚', '基里巴斯', '朝鲜', '韩国', '科威特', '吉尔吉斯斯坦',
            '老挝', '拉脱维亚', '黎巴嫩', '莱索托', '利比里亚', '利比亚', '列支敦士登', '立陶宛',
            '卢森堡',
            '马达加斯加', '马拉维', '马来西亚', '马尔代夫', '马里', '马耳他', '马绍尔群岛',
            '毛里塔尼亚', '毛里求斯', '墨西哥', '密克罗尼西亚', '摩尔多瓦', '摩纳哥', '蒙古',
            '黑山', '摩洛哥', '莫桑比克', '缅甸',
            '纳米比亚', '瑙鲁', '尼泊尔', '荷兰', '新西兰', '尼加拉瓜', '尼日尔', '尼日利亚',
            '北马其顿', '挪威',
            '阿曼',
            '巴基斯坦', '帕劳', '巴拿马', '巴布亚新几内亚', '巴拉圭', '秘鲁', '菲律宾', '波兰',
            '葡萄牙',
            '卡塔尔',
            '罗马尼亚', '俄罗斯', '卢旺达',
            '圣基茨和尼维斯', '圣卢西亚', '圣马力诺', '圣多美和普林西比', '沙特阿拉伯',
            '塞内加尔', '塞尔维亚', '塞舌尔', '塞拉利昂', '新加坡', '斯洛伐克', '斯洛文尼亚',
            '所罗门群岛', '索马里', '南非', '南苏丹', '西班牙', '斯里兰卡', '苏丹', '苏里南',
            '瑞典', '瑞士', '叙利亚',
            '塔吉克斯坦', '坦桑尼亚', '泰国', '东帝汶', '多哥', '汤加', '特立尼达和多巴哥',
            '突尼斯', '土耳其', '土库曼斯坦', '图瓦卢',
            '乌干达', '乌克兰', '阿联酋', '英国', '美国', '乌拉圭', '乌兹别克斯坦',
            '瓦努阿图', '梵蒂冈', '委内瑞拉', '越南',
            '也门',
            '赞比亚', '津巴布韦'
        ];

虽然我感觉好像把朝鲜放进去并没有什么意义()
修复后的代码已经提交到了我的github仓库:github.com/Chocola-X/VisitorLoggerPro-Enhanced

编写更新脚本

脚本程序主要的任务就是从数据库里面获取IP,然后从查询API获取到IP的相关信息,再去更新本地记录。考虑到服务器已经是有php环境了,干脆就直接用php来写了。

更新已记录IP的IP信息脚本

#!/usr/bin/env php
<?php

// ========== 数据库配置 ==========
$DB_HOST = 'localhost';
$DB_PORT = '3306';
$DB_NAME = 'db_name';
$DB_USER = 'db_user';
$DB_PASS = 'www.nekopara.uk';
// ===============================

$API_URL_TEMPLATE = 'https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr?ip=%s';
$LOOKBACK_MINUTES = 600; //向前追溯更新的时间,单位分钟
$BATCH_SIZE = 800; //最大更新IP条数
$API_TIMEOUT = 2;
$DEBUG = true;

// 创建数据库连接
try {
    $pdo = new PDO(
        "mysql:host=$DB_HOST;port=$DB_PORT;dbname=$DB_NAME;charset=utf8mb4",
        $DB_USER,
        $DB_PASS,
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );
} catch (PDOException $e) {
    fwrite(STDERR, "❌ 数据库连接失败: " . $e->getMessage() . "\n");
    exit(1);
}

function queryIpLocation($ip, $apiTemplate, $timeout = 5)
{
    if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)) {
        return null;
    }

    $url = sprintf($apiTemplate, urlencode($ip));

    $context = stream_context_create([
        'http' => [
            'method' => 'GET',
            'timeout' => $timeout,
            'header' => "User-Agent: Neko-IP-Geo-Updater/1.0\r\nAccept: application/json\r\n"
        ]
    ]);

    $response = @file_get_contents($url, false, $context);
    if ($response === false) {
        return null;
    }

    $data = json_decode($response, true);
    if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
        return null;
    }

    if (($data['code'] ?? -1) !== 0) {
        return null;
    }

    if (!isset($data['data']) || !is_array($data['data'])) {
        return null;
    }

    $country  = trim($data['data']['country']  ?? '');
    $province = trim($data['data']['province'] ?? '');
    $city     = trim($data['data']['city']     ?? '');
    $isp      = trim($data['data']['isp']      ?? '');

    // 构建去重后的部件列表
    $parts = [];

    // 国家总是加入
    if (!empty($country) && $country !== 'Unknown' && $country !== '未知') {
        $parts[] = $country;
    }

    // 省份:仅当不等于国家时才加入
    if (!empty($province) && $province !== 'Unknown' && $province !== '未知' && $province !== $country) {
        $parts[] = $province;
    }

    // 城市:仅当不等于国家且不等于省份时才加入
    if (!empty($city) && $city !== 'Unknown' && $city !== '未知' && $city !== $country && $city !== $province) {
        $parts[] = $city;
    }

    // ISP 总是加入(只要非空)
    if (!empty($isp) && $isp !== 'Unknown' && $isp !== '未知') {
        $parts[] = $isp;
    }

    // 去除可能的重复(保险起见再做一次 array_unique,保持顺序)
    $parts = array_values(array_unique($parts));

    $location = implode(' ', $parts);
    return $location ?: 'Unknown';
}

// ======================
// 主逻辑
// ======================

$isCli = php_sapi_name() === 'cli';
$log = function($msg) use ($isCli, $DEBUG) {
    if (!$DEBUG) return;
    $line = "[" . date('Y-m-d H:i:s') . "] $msg\n";
    if ($isCli) {
        echo $line;
    } else {
        echo htmlspecialchars($line);
    }
};

$log("🔍 开始处理最近 {$LOOKBACK_MINUTES} 分钟内的访客记录(将 country 字段替换为完整地理位置)...");

$stmt = $pdo->prepare("
    SELECT id, ip, country FROM `typecho_visitor_log`
    WHERE `time` >= NOW() - INTERVAL ? MINUTE
    ORDER BY `time` ASC
    LIMIT ?
");
$stmt->bindValue(1, (int)$LOOKBACK_MINUTES, PDO::PARAM_INT);
$stmt->bindValue(2, (int)$BATCH_SIZE, PDO::PARAM_INT);
$stmt->execute();

$records = $stmt->fetchAll(PDO::FETCH_ASSOC);

if (empty($records)) {
    $log("✅ 无符合条件的记录。");
    exit(0);
}

$log("📥 找到 " . count($records) . " 条记录,开始查询 API...");

$updateStmt = $pdo->prepare("
    UPDATE `typecho_visitor_log`
    SET `country` = ?
    WHERE `id` = ?
");

$updated = 0;
$failed = 0;

foreach ($records as $record) {
    $id = $record['id'];
    $ip = $record['ip'];
    $oldCountry = $record['country'];

    $fullLocation = queryIpLocation($ip, $API_URL_TEMPLATE, $API_TIMEOUT);

    if ($fullLocation !== null) {
        $updateStmt->execute([$fullLocation, $id]);
        $updated++;
        $log("✅ ID=$id | IP=$ip | 替换为: \"$fullLocation\"");
    } else {
        $failed++;
        $log("❌ ID=$id | IP=$ip | API 查询失败(保留原值: \"$oldCountry\")");
    }
}

$log("📊 完成:更新 $updated 条,失败 $failed 条");

这个脚本用于更新已经存在的IP记录信息,已经设置了相关的参数便于操作,下面是参数解释:

  • $DB_HOST = 'localhost'; 这部分设置数据库地址,例如localhost192.168.6.6
  • $DB_PORT = '3306'; 这部分是数据库端口号,例如默认端口3306
  • $DB_NAME = 'db_name'; 这部分设置数据库名称,和网站设置的保持一致即可
  • $DB_USER = 'db_user'; 这部分设置数据库用户名,和网站设置的保持一致即可
  • $DB_PASS = 'www.nekopara.uk'; 这部分设置数据库密码,和网站设置的保持一致即可
  • $LOOKBACK_MINUTES = 600; 这部分是往前追溯更新时间设置,也就是设置更新你执行脚本的这个时间之前的多少分钟内的数据
  • $BATCH_SIZE = 800;这部分是设置最大更新IP数量大小,防止IP太多导致长时间占用运行
  • $API_TIMEOUT = 2; 这是API访问超时时间限制,设置2秒即可,如果网络不稳定可以改大一点

把以上的脚本修改好对应信息后,保存为一个php文件(例如update_ip_info.php)丢到服务器的一个目录里面(例如/data/ip_info/),然后进入目录执行:

php82 update_ip_info.php

耐心等待数据更新完毕即可,如果设置数目较大会需要等待较长时间,可以考虑用tumx来挂着运行。

自动更新记录脚本

话说回来,我们总不能每次都手动上服务器执行这个更新脚本吧?所以,我们需要一个自动化,批量更新多个网站的脚本:

<?php

// ========== 多站点数据库配置 ==========
$sites = [
    [
        'name' => 'www.nekopara.uk',
        'host' => 'localhost',
        'port' => '3306',
        'dbname' => 'typecho',
        'user' => 'typecho',
        'pass' => 'www.nekopara.uk',
    ],
    [
        'name' => 'demo.nekopara.uk',
        'host' => 'localhost',
        'port' => '3306',
        'dbname' => 'demo_typecho',
        'user' => 'demo_typecho',
        'pass' => 'demo.nekopara.uk',
    ],
    // 可继续添加更多站点...
];
// =====================================

$API_URL_TEMPLATE = 'https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr?ip=%s';
$LOOKBACK_MINUTES = 10;
$BATCH_SIZE = 500;
$API_TIMEOUT = 2;
$DEBUG = true;

$isCli = php_sapi_name() === 'cli';
$log = function($msg) use ($isCli, $DEBUG) {
    if (!$DEBUG) return;
    $line = "[" . date('Y-m-d H:i:s') . "] $msg\n";
    if ($isCli) {
        echo $line;
    } else {
        echo htmlspecialchars($line);
    }
};

function queryIpLocation($ip, $apiTemplate, $timeout = 5)
{
    if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)) {
        return null;
    }

    $url = sprintf($apiTemplate, urlencode($ip));

    $context = stream_context_create([
        'http' => [
            'method' => 'GET',
            'timeout' => $timeout,
            'header' => "User-Agent: Neko-IP-Geo-Updater/1.0\r\nAccept: application/json\r\n"
        ]
    ]);

    $response = @file_get_contents($url, false, $context);
    if ($response === false) {
        return null;
    }

    $data = json_decode($response, true);
    if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
        return null;
    }

    if (($data['code'] ?? -1) !== 0) {
        return null;
    }

    if (!isset($data['data']) || !is_array($data['data'])) {
        return null;
    }

    $country  = trim($data['data']['country']  ?? '');
    $province = trim($data['data']['province'] ?? '');
    $city     = trim($data['data']['city']     ?? '');
    $isp      = trim($data['data']['isp']      ?? '');

    // 构建去重后的部件列表
    $parts = [];

    // 国家总是加入
    if (!empty($country) && $country !== 'Unknown' && $country !== '未知') {
        $parts[] = $country;
    }

    // 省份:仅当不等于国家时才加入
    if (!empty($province) && $province !== 'Unknown' && $province !== '未知' && $province !== $country) {
        $parts[] = $province;
    }

    // 城市:仅当不等于国家且不等于省份时才加入
    if (!empty($city) && $city !== 'Unknown' && $city !== '未知' && $city !== $country && $city !== $province) {
        $parts[] = $city;
    }

    // ISP 总是加入(只要非空)
    if (!empty($isp) && $isp !== 'Unknown' && $isp !== '未知') {
        $parts[] = $isp;
    }

    // 去除可能的重复(保险起见再做一次 array_unique,保持顺序)
    $parts = array_values(array_unique($parts));

    $location = implode(' ', $parts);
    return $location ?: 'Unknown';
}

foreach ($sites as $site) {
    $log("🌐 开始处理站点: {$site['name']} (数据库: {$site['dbname']})");

    try {
        $pdo = new PDO(
            "mysql:host={$site['host']};port={$site['port']};dbname={$site['dbname']};charset=utf8mb4",
            $site['user'],
            $site['pass'],
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
        );
    } catch (PDOException $e) {
        $log("❌ 连接失败 ({$site['name']}): " . $e->getMessage());
        continue; // 跳过这个站点,继续下一个
    }

    // 查询近期记录
    $stmt = $pdo->prepare("
        SELECT id, ip, country FROM `typecho_visitor_log`
        WHERE `time` >= NOW() - INTERVAL ? MINUTE
        ORDER BY `time` ASC
        LIMIT ?
    ");
    $stmt->bindValue(1, (int)$LOOKBACK_MINUTES, PDO::PARAM_INT);
    $stmt->bindValue(2, (int)$BATCH_SIZE, PDO::PARAM_INT);
    $stmt->execute();
    $records = $stmt->fetchAll(PDO::FETCH_ASSOC);

    if (empty($records)) {
        $log("✅ {$site['name']}: 无新记录");
        continue;
    }

    $updateStmt = $pdo->prepare("
        UPDATE `typecho_visitor_log`
        SET `country` = ?
        WHERE `id` = ?
    ");

    $updated = 0;
    $failed = 0;

    foreach ($records as $record) {
        $id = $record['id'];
        $ip = $record['ip'];
        $oldCountry = $record['country'];

        $fullLocation = queryIpLocation($ip, $API_URL_TEMPLATE, $API_TIMEOUT);

        if ($fullLocation !== null) {
            $updateStmt->execute([$fullLocation, $id]);
            $updated++;
            $log("✅ {$site['name']} | ID=$id | IP=$ip => \"$fullLocation\"");
        } else {
            $failed++;
            $log("❌ {$site['name']} | ID=$id | IP=$ip | API 失败(保留: \"$oldCountry\")");
        }
    }

    $log("📊 {$site['name']} 完成:更新 $updated 条,失败 $failed 条\n");
}

$log("🏁 所有站点处理完毕!");

这里我设置了往前更新10分钟内的记录,更新IP记录数量限制在500以内,配合cron计划任务,就可以实现定时自动更新访问IP信息:

*/10 * * * * /usr/bin/php82 "/data/ip_info/cron_update_ip.php"

这样设置后,服务器就会每10分钟自动去获取IP信息并更新数据库了。