Files
wordpress-theme-aksrt/src/AKSRT/Extensions/IPGeo.php
T

520 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* IP 归属地查询(腾讯 / 高德 / 太平洋 / 轮询)。
*
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
class IPGeo {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* 查询 IP 归属地。
*
* @param string $ip IPv4 / IPv6
* @param bool $force 管理端测试:跳过启用开关与缓存;仍拒绝私有 IP
* @return array{country:string,province:string,city:string,raw:array,reason?:string,provider?:string,http_code?:int,detail?:string}
*/
public function lookup(string $ip, bool $force = false): array {
$config = $this->get_config();
$ip = trim($ip);
if (!$force && empty($config['ip_geo_enabled'])) {
return $this->empty_result('disabled');
}
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
return $this->empty_result('invalid_ip');
}
if ($this->is_private_ip($ip)) {
return $this->empty_result('private_ip');
}
$cache_key = 'aksrt_ip_geo_' . md5($ip);
$ttl = max(3600, (int) ($config['ip_geo_cache_ttl'] ?? 86400));
if (!$force) {
$cached = get_transient($cache_key);
if ($cached !== false && is_array($cached)) {
return $cached;
}
}
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
$result = $this->query_provider($provider, $ip, $config);
if ($this->has_location($result)) {
if (!$force) {
set_transient($cache_key, $result, $ttl);
}
return $result;
}
if (empty($result['reason'])) {
$result['reason'] = 'provider_empty';
}
return $result;
}
public function get_status(): array {
$config = $this->get_config();
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
$enabled = !empty($config['ip_geo_enabled']);
$missing = [];
if ($enabled && $provider === 'qq') {
if (empty($config['ip_addr_sdk_qq_appkey'])) {
$missing[] = 'ip_addr_sdk_qq_appkey';
}
}
if ($enabled && $provider === 'amap') {
if (empty($config['ip_addr_sdk_amap_appkey'])) {
$missing[] = 'ip_addr_sdk_amap_appkey';
}
}
return [
'enabled' => $enabled,
'provider' => $provider,
'configured' => $enabled && empty($missing),
'available' => $enabled,
'verified' => !empty($config['ip_geo_verified']),
'transport' => 'http_api',
'missing_fields' => $missing,
'cache_ttl' => (int) ($config['ip_geo_cache_ttl'] ?? 86400),
'display' => (array) ($config['ip_geo_display'] ?? ['comment', 'user_home', 'user_center', 'post']),
'format' => (string) ($config['ip_geo_format'] ?? 'province_city'),
];
}
public function format(array $geo, string $format = ''): string {
if ($format === '') {
$config = $this->get_config();
$format = (string) ($config['ip_geo_format'] ?? 'province_city');
}
$country = $this->string_field($geo['country'] ?? '');
$province = $this->string_field($geo['province'] ?? '');
$city = $this->string_field($geo['city'] ?? '');
switch ($format) {
case 'province':
return $province !== '' ? $province : $city;
case 'country_province':
return trim($country . ' ' . $province);
case 'province_city':
default:
$parts = array_filter([$province, $city]);
return $parts ? implode(' ', $parts) : $country;
}
}
private function get_config(): array {
if (class_exists('\AKSRT\Settings\Settings')) {
return \AKSRT\Settings\Settings::get_ip_geo_settings();
}
return ['ip_geo_enabled' => false];
}
private function query_provider(string $provider, string $ip, array $config): array {
switch ($provider) {
case 'qq':
return $this->query_qq($ip, $config);
case 'amap':
$amap = $this->query_amap($ip, $config);
if ($this->has_location($amap)) {
return $amap;
}
// Credential / platform mismatch: free path so front still works.
if ($this->should_fallback_free($amap)) {
$free = $this->query_pconline($ip);
if ($this->has_location($free)) {
$free['detail'] = 'amap_failed_fallback_free:' . (string) ($amap['detail'] ?? $amap['reason'] ?? '');
return $free;
}
}
return $amap;
case 'polling':
$last = $this->empty_result('provider_empty');
foreach (['qq', 'amap', 'null'] as $fallback) {
if ($fallback === 'qq' && empty($config['ip_addr_sdk_qq_appkey'])) {
continue;
}
if ($fallback === 'amap' && empty($config['ip_addr_sdk_amap_appkey'])) {
continue;
}
// Avoid re-entering amap→free→… recursion: call leaf methods.
if ($fallback === 'amap') {
$result = $this->query_amap($ip, $config);
} elseif ($fallback === 'qq') {
$result = $this->query_qq($ip, $config);
} else {
$result = $this->query_pconline($ip);
}
if ($this->has_location($result)) {
return $result;
}
$last = $result;
}
// Last resort free path.
$free = $this->query_pconline($ip);
return $this->has_location($free) ? $free : $last;
case 'null':
default:
// Admin label「太平洋公共接口」: pconline, with ip-api if blocked.
return $this->query_pconline($ip);
}
}
/**
* Whether a paid-provider failure should fall back to free IP lookup.
*/
private function should_fallback_free(array $result): bool {
$reason = (string) ($result['reason'] ?? '');
if (in_array($reason, ['missing_key', 'http_error'], true)) {
return true;
}
if ($reason !== 'provider_error') {
return false;
}
$detail = strtoupper((string) ($result['detail'] ?? ''));
foreach (['USERKEY_PLAT_NOMATCH', 'INVALID_USER_KEY', 'INVALID_USER_SCODE', 'DAILY_QUERY_OVER_LIMIT', 'ACCESS_TOO_FREQUENT', 'CUQPS_HAS_EXCEEDED_THE_LIMIT'] as $code) {
if (str_contains($detail, $code)) {
return true;
}
}
return false;
}
private function query_qq(string $ip, array $config): array {
$appkey = trim((string) ($config['ip_addr_sdk_qq_appkey'] ?? ''));
$secretkey = trim((string) ($config['ip_addr_sdk_qq_secretkey'] ?? ''));
if ($appkey === '') {
return $this->empty_result('missing_key', ['provider' => 'qq', 'detail' => 'missing appkey']);
}
// Path used for Tencent signature (zibll parity).
$path = '/ws/location/v1/ip';
$query = 'ip=' . rawurlencode($ip) . '&key=' . rawurlencode($appkey);
$url = 'https://apis.map.qq.com' . $path . '?' . $query;
if ($secretkey !== '') {
$url .= '&sig=' . md5($path . '?' . $query . $secretkey);
}
$http = $this->http_get($url, 8);
if (!$http['ok']) {
return $this->empty_result('http_error', [
'provider' => 'qq',
'http_code' => $http['code'],
'detail' => $http['error'],
'raw' => $http['body_raw'] !== '' ? ['body' => $http['body_raw']] : [],
]);
}
$data = $http['json'];
if (!is_array($data) || (int) ($data['status'] ?? 1) !== 0) {
$msg = is_array($data) ? (string) ($data['message'] ?? 'status_error') : 'invalid_json';
return $this->empty_result('provider_error', [
'provider' => 'qq',
'http_code' => $http['code'],
'detail' => $msg,
'raw' => is_array($data) ? $data : [],
]);
}
$info = is_array($data['result']['ad_info'] ?? null) ? $data['result']['ad_info'] : [];
return [
'country' => $this->string_field($info['nation'] ?? '中国'),
'province' => $this->string_field($info['province'] ?? ''),
'city' => $this->string_field($info['city'] ?? ''),
'raw' => $data,
'provider' => 'qq',
];
}
private function query_amap(string $ip, array $config): array {
$appkey = trim((string) ($config['ip_addr_sdk_amap_appkey'] ?? ''));
$secretkey = trim((string) ($config['ip_addr_sdk_amap_secretkey'] ?? ''));
if ($appkey === '') {
return $this->empty_result('missing_key', [
'provider' => 'amap',
'detail' => 'missing appkey',
]);
}
// Amap sign: md5(sorted query without sig + secret) — zibll uses ip=&key= order.
$base_query = 'ip=' . $ip . '&key=' . $appkey;
$url = 'https://restapi.amap.com/v3/ip?' . $base_query;
if ($secretkey !== '') {
$url .= '&sig=' . md5($base_query . $secretkey);
}
$http = $this->http_get($url, 8);
if (!$http['ok']) {
return $this->empty_result('http_error', [
'provider' => 'amap',
'http_code' => $http['code'],
'detail' => $http['error'],
'raw' => $http['body_raw'] !== '' ? ['body' => $http['body_raw']] : [],
]);
}
$data = $http['json'];
if (!is_array($data) || (string) ($data['status'] ?? '0') !== '1') {
$info = is_array($data) ? (string) ($data['info'] ?? 'status_error') : 'invalid_json';
// Common: INVALID_USER_KEY / DAILY_QUERY_OVER_LIMIT / USERKEY_PLAT_NOMATCH
return $this->empty_result('provider_error', [
'provider' => 'amap',
'http_code' => $http['code'],
'detail' => $info,
'raw' => is_array($data) ? $data : [],
]);
}
// Amap returns empty arrays for province/city on some IPs / overseas.
$province = $this->string_field($data['province'] ?? '');
$city = $this->string_field($data['city'] ?? '');
$country = '中国';
if ($province === '' && $city === '') {
return $this->empty_result('provider_empty', [
'provider' => 'amap',
'detail' => 'empty province/city',
'raw' => $data,
]);
}
return [
'country' => $country,
'province' => $province,
'city' => $city,
'raw' => $data,
'provider' => 'amap',
];
}
/**
* 太平洋网络 whois(GB2312 正文)。
*/
private function query_pconline(string $ip): array {
$url = 'http://whois.pconline.com.cn/ipJson.jsp?json=true&ip=' . rawurlencode($ip);
$http = $this->http_get($url, 8, [
'Accept' => '*/*',
'Accept-Language' => 'zh-CN,zh;q=0.9',
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer' => 'https://www.pconline.com.cn/',
]);
if (!$http['ok']) {
// Fallback: ip-api only when pconline network fails (not dual storage — runtime failover).
$fallback = $this->query_ip_api($ip);
if ($this->has_location($fallback)) {
$fallback['provider'] = 'ip-api';
$fallback['detail'] = 'pconline_failed_fallback_ip_api';
return $fallback;
}
return $this->empty_result('http_error', [
'provider' => 'pconline',
'http_code' => $http['code'],
'detail' => $http['error'] !== '' ? $http['error'] : 'pconline blocked or unreachable',
'raw' => $http['body_raw'] !== '' ? ['body' => mb_substr($http['body_raw'], 0, 500)] : [],
]);
}
$body = $http['body_raw'];
// pconline often serves GBK/GB2312.
if (function_exists('mb_convert_encoding')) {
$converted = @mb_convert_encoding($body, 'UTF-8', 'GBK,GB2312,UTF-8');
if (is_string($converted) && $converted !== '') {
$body = $converted;
}
}
$json = json_decode($body, true);
if (is_array($json)) {
$province = $this->string_field($json['pro'] ?? $json['province'] ?? '');
$city = $this->string_field($json['city'] ?? '');
$region = $this->string_field($json['region'] ?? '');
$addr = $this->string_field($json['addr'] ?? '');
$country = '';
if ($addr !== '' && preg_match('/^(\S+)/u', $addr, $m)) {
$country = $m[1];
}
if ($province !== '' || $city !== '') {
return [
'country' => $country !== '' ? $country : '中国',
'province' => $province,
'city' => $city !== '' ? $city : $region,
'raw' => $json,
'provider' => 'pconline',
];
}
}
// Regex fallback (zibll).
preg_match('/"pro":"(.*?)"/u', $body, $pro);
preg_match('/"city":"(.*?)"/u', $body, $city);
preg_match('/"region":"(.*?)"/u', $body, $region);
preg_match('/"addr":"(.*?)(\s)(.*?)"/u', $body, $addr);
$province = $this->string_field($pro[1] ?? '');
$city_s = $this->string_field($city[1] ?? '');
$country = $this->string_field($addr[1] ?? '');
if ($province === '' && $city_s === '') {
return $this->empty_result('provider_empty', [
'provider' => 'pconline',
'detail' => 'parse_failed',
'raw' => ['body' => mb_substr($body, 0, 500)],
]);
}
return [
'country' => $country !== '' ? $country : '中国',
'province' => $province,
'city' => $city_s !== '' ? $city_s : $this->string_field($region[1] ?? ''),
'raw' => ['body' => $body],
'provider' => 'pconline',
];
}
private function query_ip_api(string $ip): array {
$url = 'http://ip-api.com/json/' . rawurlencode($ip) . '?lang=zh-CN&fields=status,message,country,regionName,city,query';
$http = $this->http_get($url, 6);
if (!$http['ok']) {
return $this->empty_result('http_error', [
'provider' => 'ip-api',
'http_code' => $http['code'],
'detail' => $http['error'],
]);
}
$data = $http['json'];
if (!is_array($data) || ($data['status'] ?? '') !== 'success') {
return $this->empty_result('provider_error', [
'provider' => 'ip-api',
'detail' => is_array($data) ? (string) ($data['message'] ?? 'fail') : 'invalid_json',
'raw' => is_array($data) ? $data : [],
]);
}
return [
'country' => $this->string_field($data['country'] ?? ''),
'province' => $this->string_field($data['regionName'] ?? ''),
'city' => $this->string_field($data['city'] ?? ''),
'raw' => $data,
'provider' => 'ip-api',
];
}
/**
* @param array<string,string> $extra_headers
* @return array{ok:bool,code:int,error:string,json:?array,body_raw:string}
*/
private function http_get(string $url, int $timeout = 8, array $extra_headers = []): array {
$headers = array_merge([
'Accept' => 'application/json,text/plain,*/*',
'User-Agent' => 'AKSRT-IPGeo/3.4 (+WordPress)',
], $extra_headers);
$response = wp_remote_get($url, [
'timeout' => $timeout,
'redirection' => 3,
'sslverify' => true,
'headers' => $headers,
]);
if (is_wp_error($response)) {
return [
'ok' => false,
'code' => 0,
'error' => $response->get_error_message(),
'json' => null,
'body_raw' => '',
];
}
$code = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
if ($code < 200 || $code >= 300) {
return [
'ok' => false,
'code' => $code,
'error' => 'HTTP ' . $code,
'json' => null,
'body_raw' => $body,
];
}
$json = json_decode($body, true);
return [
'ok' => true,
'code' => $code,
'error' => '',
'json' => is_array($json) ? $json : null,
'body_raw' => $body,
];
}
private function is_private_ip(string $ip): bool {
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
private function has_location(array $result): bool {
return $this->string_field($result['province'] ?? '') !== ''
|| $this->string_field($result['city'] ?? '') !== ''
|| $this->string_field($result['country'] ?? '') !== '';
}
/**
* Amap may return [] for empty province/city.
*
* @param mixed $value Raw field.
*/
private function string_field($value): string {
if (is_array($value)) {
if ($value === []) {
return '';
}
$value = reset($value);
}
if (!is_scalar($value) && $value !== null) {
return '';
}
return trim((string) $value);
}
/**
* @param array<string,mixed> $extra Extra fields.
* @return array{country:string,province:string,city:string,raw:array,reason?:string}
*/
private function empty_result(string $reason = '', array $extra = []): array {
$out = [
'country' => '',
'province' => '',
'city' => '',
'raw' => is_array($extra['raw'] ?? null) ? $extra['raw'] : [],
];
if ($reason !== '') {
$out['reason'] = $reason;
}
foreach (['provider', 'http_code', 'detail'] as $k) {
if (array_key_exists($k, $extra)) {
$out[$k] = $extra[$k];
}
}
return $out;
}
}