Skip to content

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 再小也扛得住。