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 $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 $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; } }