project/ ├── ipLocation/ │ ├── qqwry/ │ │ └── QQWry.dat # 純真 IP 資料庫檔案 │ └── IpCity.php # 查詢類別
<?php
/**
* 純真 IP 資料庫 (QQWry.dat) 解析類別
* 支援 IPv4 查詢國家/省份/城市/ISP
* 資料庫下載: https://github.com/out0fmemory/qqwry.dat
*/
class IpCity
{
private $fp;
private $firstIndexOffset;
private $lastIndexOffset;
private $totalIndexCount;
/**
* 查詢 IP 所屬地區
* @param string $ip IPv4 位址 (如 114.114.114.114)
* @param string $datPath 資料庫路徑 (預設同目錄下 qqwry/QQWry.dat)
* @return string 地區字串 (如 "北京市 海淀區 聯通") 或錯誤訊息
*/
public function getCity(string $ip, string $datPath = ''): string
{
// 1. 驗證 IP 格式
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return 'IP Address Invalid';
}
// 2. 開啟資料庫
$datPath = $datPath ?: __DIR__ . '/qqwry/QQWry.dat';
$this->fp = @fopen($datPath, 'rb');
if (!$this->fp) {
return 'IP data file not exists or access denied';
}
// 3. 讀取索引區塊範圍 (檔案開頭 8 位元組)
$this->firstIndexOffset = $this->readLong(fread($this->fp, 4));
$this->lastIndexOffset = $this->readLong(fread($this->fp, 4));
$this->totalIndexCount = (int)(($this->lastIndexOffset - $this->firstIndexOffset) / 7 + 1);
// 4. IP 轉長整數
$ipNum = $this->ipToLong($ip);
// 5. 二分搜尋索引
$locationOffset = $this->binarySearch($ipNum);
if ($locationOffset === false) {
fclose($this->fp);
return 'No Data';
}
// 6. 讀取地區資訊
$area = $this->readArea($locationOffset);
fclose($this->fp);
// 7. 清理與編碼轉換 (QQWry 內部為 GBK)
$area = preg_replace('/CZ88\.Net/is', '', $area);
$area = trim($area);
if (preg_match('/http/i', $area) || $area === '') {
return 'No Data';
}
if (!$this->isUtf8($area)) {
$area = iconv('GBK', 'UTF-8//IGNORE', $area);
}
return $area;
}
// ===== 私有輔助方法 =====
private function ipToLong(string $ip): int
{
$parts = explode('.', $ip);
return ($parts[0] << 24) | ($parts[1] << 16) | ($parts[2] << 8) | $parts[3];
}
private function readLong(string $bytes): int
{
$val = unpack('V', $bytes)[1]; // V = little-endian unsigned long
return $val < 0 ? $val + 4294967296 : $val; // PHP 整數為有號,修正無號
}
private function binarySearch(int $ipNum): int|false
{
$low = 0;
$high = $this->totalIndexCount - 1;
while ($low <= $high) {
$mid = (int)(($low + $high) / 2);
fseek($this->fp, $this->firstIndexOffset + $mid * 7);
$indexData = fread($this->fp, 7);
if (strlen($indexData) < 7) return false;
$startIp = $this->readLong(substr($indexData, 0, 4));
$offset = $this->readLong(substr($indexData, 4, 3) . "\x00"); // 3 bytes + padding
if ($ipNum < $startIp) {
$high = $mid - 1;
} else {
// 讀取下一筆索引的起始 IP 確認範圍
$nextOffset = $this->firstIndexOffset + ($mid + 1) * 7;
if ($nextOffset > $this->lastIndexOffset) {
return $offset; // 最後一筆
}
fseek($this->fp, $nextOffset);
$nextStartIp = $this->readLong(fread($this->fp, 4));
if ($ipNum < $nextStartIp) {
return $offset; // 找到範圍
}
$low = $mid + 1;
}
}
return false;
}
private function readArea(int $offset): string
{
fseek($this->fp, $offset);
$flag = fread($this->fp, 1);
$area1 = '';
$area2 = '';
if ($flag === "\x01") {
// 模式 1: 國家/區域 為重導向
$redirectOffset = $this->readLong(fread($this->fp, 3) . "\x00");
fseek($this->fp, $redirectOffset);
$flag = fread($this->fp, 1);
}
if ($flag === "\x02") {
// 模式 2: 區域為重導向
$redirectOffset = $this->readLong(fread($this->fp, 3) . "\x00");
$area2 = $this->readString($redirectOffset);
fseek($this->fp, $offset + 4); // 回到國家/區域位置
$area1 = $this->readString(ftell($this->fp));
} else {
// 直接模式
fseek($this->fp, -1, SEEK_CUR);
$area1 = $this->readString(ftell($this->fp));
$flag = fread($this->fp, 1);
if ($flag === "\x02") {
$redirectOffset = $this->readLong(fread($this->fp, 3) . "\x00");
$area2 = $this->readString($redirectOffset);
} else {
fseek($this->fp, -1, SEEK_CUR);
$area2 = $this->readString(ftell($this->fp));
}
}
return trim("$area1 $area2");
}
private function readString(int $offset): string
{
fseek($this->fp, $offset);
$str = '';
while (($char = fread($this->fp, 1)) !== "\x00") {
$str .= $char;
}
return $str;
}
private function isUtf8(string $str): bool
{
return (bool)preg_match('/^[\x00-\x7F]+$/u', $str) || // 純 ASCII
(bool)preg_match('/^[\xC2-\xDF][\x80-\xBF]+$/u', $str) ||
(bool)preg_match('/^[\xE0-\xEF][\x80-\xBF]{2}+$/u', $str) ||
(bool)preg_match('/^[\xF0-\xF4][\x80-\xBF]{3}+$/u', $str);
}
}
// ===== 使用範例 =====
require_once __DIR__ . '/ipLocation/IpCity.php';
$ipCity = new IpCity();
echo $ipCity->getCity('114.114.114.114'); // 北京市 海淀區 電信
echo $ipCity->getCity('8.8.8.8'); // 美國 加利福尼亞州 聖克拉拉郡 谷歌
echo $ipCity->getCity('172.0.0.1'); // 內網地址 / No Data
?>
$fp 開啟,避免重複開關mmap() 整個 .dat 到記憶體 (需擴充功能)| 方案 | 優點 | 缺點 |
|---|---|---|
| MaxMind GeoIP2 (MMDB) | 標準格式、支援 IPv6、精度高、官方庫 | 需授權、檔案較大 |
| IP2Location | 功能豐富、多語言 SDK | 商業收費 |
| IP-API / ipinfo.io (API) | 免維護、即時更新 | 需網路、有頻率限制、隱私考量 |
| QQWry.dat (本文) | 免費、離線、中文地區細分度高 | 僅 IPv4、需手動更新、解析複雜 |
# 現代 PHP 專案建議使用封裝良好的套件
composer require jasongrimes/qqwry-php
# 或
composer require torann/geoip (支援多種驅動)
離線 IP 查詢在無外網環境、高頻率、隱私敏感場景仍具不可替代價值,掌握 QQWry.dat 解析是 PHP 開發者的實用技能。