Appearance
WordPress 怎么显示访客 IP 归属地?不装插件,从评论到后台一次打通(PHP 实现)
后台一打开,评论列表里排着十几条「来自北京」「来自广州」的小字,运营一眼就能分辨哪些评论像是本地客户留的。这个功能市面上的插件不少,但真正踩过坑的人最后大多会自己写:插件往往顺手把一堆统计、SEO、CDN 逻辑一起塞进来,而老教程给的代码是直接在评论模板里同步请求接口——评论一多,首字节时间肉眼可见地涨。
这篇文章讲一套自建做法,三件事一起解决:拿对真实访客 IP、把归属地落成评论的永久属性、把接口调用控制在不触发限速的范围内。全部代码只依赖 WordPress 自身的函数,塞一个文件进 wp-content/mu-plugins/ 就能跑。
一、WordPress 站点上做这件事的三个真问题
同步查接口,会把评论列表拖慢。 一个热门文章有 200 条评论,如果在 comments.php 的循环里逐条调接口,页面渲染就变成 200 次串行外部请求。就算加了缓存,第一次生成缓存的那次渲染也可能超时。正确顺序是先落库、后渲染:评论提交的瞬间查一次,结果存进评论的 meta,列表页只读数据库。
反代和 CDN 后面,REMOTE_ADDR 不是访客。 国内的 WordPress 站基本都会套一层 CDN 或者宝塔的 Nginx 反向代理。这时 $_SERVER['REMOTE_ADDR'] 拿到的是回源 IP,查出来经常是一堆「来自美国」或者机房地址。要按请求头里的转发链取第一个非内网地址,并且把自家 CDN 回源段列成白名单——不设白名单的话,任何人伪造一个 X-Forwarded-For 都能把自己的属地改成任意城市。
免费接口有频次上限,评论高峰会撞墙。 IP9 免费版是 60 次/分钟,一个正常日更的博客平时够用,但文章被推荐、评论集中涌入的时候就不一定。所以要有两层缓存:IP → 归属地的结果缓存(同一个 IP 反复出现只查一次),以及评论 → 归属地的 meta(渲染阶段零请求)。
二、方案设计
整条链路分成「提交时查」和「定时补」两条:
访客提交评论
→ preprocess_comment 钩子拿真实 IP(反代白名单 + 转发链)
→ 查属地:transient 缓存命中就返回,未命中才请求 ip9.com.cn/get
→ 结果写成评论 meta(ip9_geo_*),查不到就什么都不写
→ comment_text 过滤器在列表页读 meta,拼一行小字
历史评论 / 接口当时失败的评论
→ WP-Cron 每 5 分钟跑一次,每次最多 20 条,逐条补查(sleep 隔开,避免限速)几个关键决定:
- 归属地存 meta 而不是临时算。 属地是评论的属性,存下来之后无论页面缓存、对象缓存怎么折腾,渲染都不再依赖外部接口。
- 只存需要的几个字段。 省、市、运营商三样足够展示,存 JSON 比存五列更好扩展。
- 渲染阶段绝不发请求。
comment_text过滤器里只做get_comment_meta+ 字符串拼接。 - 失败静默。 接口超时、返回
ret=400(IP 非法)或触发限速,都不影响评论入库,只是这条评论没有属地小字。
三、PHP 实现
下面这份可以直接存成 wp-content/mu-plugins/ip9-comment-geo.php。mu-plugins 目录下的文件会被自动加载,不受主题更换影响,也不会被误停用。
php
<?php
/**
* Plugin Name: 评论 IP 属地(自建版)
* Description: 评论提交时查一次归属地存进 meta,列表页零请求展示
*/
if (!defined('ABSPATH')) { exit; }
const IP9_GEO_API = 'https://ip9.com.cn/get';
const IP9_GEO_TTL = 604800; // IP → 属地结果缓存 7 天
const IP9_GEO_TIMEOUT = 2; // 单次请求最多等 2 秒
const IP9_CRON_PER_RUN = 20; // 每次定时任务最多补查多少条评论
// 可信代理:CDN / Nginx 回源 IP(换成自己服务器的)
function ip9_trusted_proxies(): array {
return ['127.0.0.1', '172.17.0.0/16'];
}
function ip9_ip_in_net(string $ip, string $net): bool {
if (strpos($net, '/') === false) { return $ip === $net; }
[$subnet, $bits] = explode('/', $net);
$a = ip2long($ip); $b = ip2long($subnet);
if ($a === false || $b === false) { return false; } // IPv6 走精确比较
$mask = -1 << (32 - (int) $bits);
return ($a & $mask) === ($b & $mask);
}
/**
* 取真实访客 IP:只有直连来源在可信代理白名单里,才解析转发链
*/
function ip9_client_ip(): string {
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
$trusted = false;
foreach (ip9_trusted_proxies() as $net) {
if ($remote !== '' && ip9_ip_in_net($remote, $net)) { $trusted = true; break; }
}
if (!$trusted) { return $remote; }
// Cloudflare 会在 CF-Connecting-IP 里放客户端地址,优先用它
$cf = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
if ($cf !== '' && filter_var($cf, FILTER_VALIDATE_IP)) { return $cf; }
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
foreach (array_reverse(array_map('trim', explode(',', $xff))) as $cand) {
if ($cand === '' || !filter_var($cand, FILTER_VALIDATE_IP)) { continue; }
$isPrivate = ip9_ip_in_net($cand, '10.0.0.0/8')
|| ip9_ip_in_net($cand, '172.16.0.0/12')
|| ip9_ip_in_net($cand, '192.168.0.0/16');
if (!$isPrivate) { return $cand; }
}
return $remote;
}
/**
* 查归属地:transient 缓存优先,失败返回 null(调用方不需要处理异常)
*/
function ip9_geo_lookup(string $ip): ?array {
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) { return null; }
$key = 'ip9geo_' . md5($ip);
$cached = get_transient($key);
if (is_array($cached)) { return $cached; }
$resp = wp_remote_get(
IP9_GEO_API . '?ip=' . rawurlencode($ip),
['timeout' => IP9_GEO_TIMEOUT, 'user-agent' => 'wp-comment-geo/1.0']
);
if (is_wp_error($resp) || wp_remote_retrieve_response_code($resp) !== 200) {
return null; // 超时 / 5xx:当次没有属地,不落缓存
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!is_array($body) || ($body['ret'] ?? 0) !== 200 || empty($body['data'])) {
// ret=400 是 IP 非法,ret=429 是限速。429 只缓存 60 秒,避免继续撞墙
if (($body['ret'] ?? 0) === 429) { set_transient($key, [], 60); }
return null;
}
$d = $body['data'];
$geo = [
'ip' => $ip,
'country' => $d['country'] ?? '',
'prov' => $d['prov'] ?? '',
'city' => $d['city'] ?? '',
'isp' => $d['isp'] ?? '',
'bigarea' => $d['big_area'] ?? '',
];
set_transient($key, $geo, IP9_GEO_TTL);
return $geo;
}
/**
* 把属地和 IP 一起写进评论 meta
*/
function ip9_save_comment_geo(int $comment_id): void {
$comment = get_comment($comment_id);
if (!$comment) { return; }
// 直接取 WordPress 记录下来的评论者 IP,比重新解析请求头更可靠
$ip = (string) $comment->comment_author_IP;
$geo = ip9_geo_lookup($ip);
if ($geo === null) { return; }
update_comment_meta($comment_id, 'ip9_geo', wp_json_encode($geo, JSON_UNESCAPED_UNICODE));
}
add_action('comment_post', 'ip9_save_comment_geo', 20);
/**
* 渲染:只读 meta,不发任何外部请求
*/
function ip9_append_geo_to_comment(string $text): string {
if (is_admin()) { return $text; }
$comment_id = get_comment_ID();
if (!$comment_id) { return $text; }
$raw = get_comment_meta((int) $comment_id, 'ip9_geo', true);
if (!$raw) { return $text; }
$geo = json_decode($raw, true);
if (!is_array($geo)) { return $text; }
$place = $geo['country'] === '中国' && $geo['prov'] !== ''
? ($geo['prov'] === $geo['city'] ? $geo['city'] : $geo['prov'] . '·' . $geo['city'])
: trim($geo['country'] . ' ' . $geo['prov']);
return $text . '<span class="ip9-comment-geo" style="display:block;font-size:12px;color:#8a8a8a;margin-top:4px;">'
. esc_html('来自 ' . $place . ($geo['isp'] ? '(' . $geo['isp'] . ')' : '')) . '</span>';
}
add_filter('comment_text', 'ip9_append_geo_to_comment', 20);
/**
* 定时补查:历史评论、提交时接口失败的评论,分批发
*/
add_action('ip9_geo_backfill', function () {
$rows = get_comments([
'status' => 'approve',
'number' => IP9_CRON_PER_RUN,
'meta_query' => [['key' => 'ip9_geo', 'compare' => 'NOT EXISTS']],
]);
foreach ($rows as $c) {
ip9_save_comment_geo((int) $c->comment_ID);
usleep(1200000); // 每条之间隔 1.2 秒,稳稳压在 60 次/分钟以内
}
});
add_action('init', function () {
if (!wp_next_scheduled('ip9_geo_backfill')) {
wp_schedule_event(time() + 300, 'hourly', 'ip9_geo_backfill');
}
});装好之后回后台看评论列表,新评论出现几秒内就会多出一行小字。历史评论第一次跑 Cron 会补一批,几十条的量级一个下午就补齐了。
如果想在后台评论列表也直接看到属地(编辑审评论时挺有用),加一列就行:
php
add_filter('manage_edit-comments_columns', function ($cols) {
$cols['ip9_geo'] = 'IP 属地';
return $cols;
});
add_action('manage_comments_custom_column', function ($col, $comment_id) {
if ($col !== 'ip9_geo') { return; }
$raw = get_comment_meta((int) $comment_id, 'ip9_geo', true);
$geo = $raw ? json_decode($raw, true) : null;
echo $geo ? esc_html($geo['prov'] . ' ' . $geo['city']) : '—';
}, 10, 2);四、上线前要处理的边界
页面缓存插件不会破坏这个方案。 属地写在评论 meta 里,是数据库属性,跟整页静态缓存没关系。真正会出问题的反而是反过来做:如果在模板里 file_get_contents 接口,缓存插件把「查询失败时的空白」一起缓存了,之后所有访客看到的都是空的。
转发链只信任自家的代理。 上面 ip9_client_ip() 的白名单必须换成真实回源地址(宝塔一般是 127.0.0.1,套 CDN 时是 CDN 的固定回源段)。放开解析又没白名单,等于把「属地」这个字段交给评论者自己填。
批量补查的节奏要捏住。 免费版 60 次/分钟是按出口 IP 算的,上面每条之间隔 1.2 秒就是专门留的安全余量。帖子多、评论过万的站点,把每次补查条数调小、执行频率调高比一次跑完更稳妥。评论量再大(日均新增几千条),站点讨论区的属性就更接近小型社区,这时按站内《高并发下怎么调 IP 接口才不被打爆》那篇的做法,把 IP → 属地的缓存换成 Redis 共享更合适。
展示到省市就够,别把完整 IP 打到页面上。 评论区公开展示 IP 本身在很多国家属于个人信息处理,展示「来自江苏·南京」这种聚合字段风险低得多。IP 地址作为个人信息的口径,站内《IP 地址是个人信息吗》那篇讲得比较细,站点隐私政策里补一句「为展示归属地,评论时会记录 IP 并查询其归属地,结果保存 N 天」就可以。
区县和机房判断要看版本。 免费接口返回 country、prov、city、city_code、post_code、area_code、isp、城市中心经纬度、long_ip、big_area 这些字段,area(区县)在免费版是空的;IP 类型(ip_type:IDC 机房 / ISP 家宽 / BUS 企业)和 AS 号要 VIP 版才有。如果只是评论区展示,免费版的省市级信息完全够用;想拿「这条评论来自机房 IP」做垃圾评论识别,那是另一个话题了,得先有 ip_type。
移动网络的「假异地」很常见。 4G/5G 出口跨省市是常态,同一条评论的属地在小范围里跳来跳去不必当异常。真要做风控判断(比如识别同一批 IP 的刷评论),应该按大区聚合看趋势,而不是盯着单条评论的城市。
总结
WordPress 上做评论 IP 属地,工作量其实很小:一个 mu-plugin 文件、一次提交时查询、一张评论 meta、一个定时补查任务。真正的门道在三个细节——反代下把真实 IP 拿对、渲染阶段不发外部请求、批量阶段控住调用频率。
接口用 IP9 免费版就够:https://ip9.com.cn/get?ip=<IP> 查指定 IP,不带参数则返回请求方自己的归属地,IPv4 和 IPv6 都支持,免注册、无需鉴权,60 次/分钟。官网上还有字段说明和 VIP 版(区县、IP 类型、AS 号)的信息:https://www.ip9.com.cn 。上线前记得套一层缓存,vps 再小也扛得住。