Skip to content

网站怎么按访客 IP 显示本地天气?PHP 对接天气接口的三个坑

本地生活站、出行工具站、区域资讯站,首页右上角挂一行「深圳 多云 26℃」是标配。用户喜欢这个细节,但站长做起来经常卡住:让用户自己选城市的,多数人根本不选;直接用浏览器定位的,一半人点「拒绝」。

用 IP 归属地拿到访客城市,再拿城市去查天气,是成本最低的做法。逻辑听着简单——「查 IP → 拿城市 → 查天气」三步——但真正写起来,坑都集中在中转那一步:IP 接口返回的城市名,和天气接口要的城市参数,通常对不上。这篇用 PHP 把完整流程写出来,把三个常见的坑逐个填掉。

一、三个坑,先认识一下

坑一:把 IP 直接丢给天气接口。 天气接口要的是城市编码、城市 ID 或者经纬度,不是 IP。IP9 返回的经纬度是城市中心点,拿它当定位坐标查天气勉强能用,但因为一个城市只有一个中心点,等于全市共用一份天气,白白丢了精度;更稳的是用城市名/城市编码去查。

坑二:城市名对不上。 IP 返回「深圳」,天气接口要「深圳市」;IP 返回「省直辖县级行政区划」,天气接口压根没这个词;港澳台和海外城市通常需要英文名或国际城市 ID。这一层映射不做,线上表现就是「有的城市能显示,有的城市空白」。

坑三:每个访客查两次接口。 IP 查一次、天气查一次,还不缓存的话,一个日活几千的站能轻松把免费额度打满。天气数据的更新频率是分钟级到小时级,缓存 30 分钟完全够用。

二、方案设计

整体的处理链路:

  1. 取访客真实 IP(有 CDN/代理时注意取对头,下面代码里处理了);
  2. https://ip9.com.cn/get?ip=<IP>prov / city(免费版就返回这两个字段,区县级别的 area 是 VIP 字段);
  3. 用「省+市」去自己的映射表里找天气接口要的城市编码,找不到就退到省会城市;
  4. 查天气,缓存 30 分钟(按城市编码缓存,同一个城市的所有访客共用一份);
  5. 全程任何一步失败,回落到站点默认城市,绝不让首页开天窗。

映射表不用一次做全,先把你访客量前 50 的城市覆盖掉,剩下的走兜底,后面按日志慢慢补。

三、PHP 实现

php
<?php
declare(strict_types=1);

const IP9_API = 'https://ip9.com.cn/get?ip=';
const CACHE_DIR = __DIR__ . '/cache';
const WEATHER_TTL = 1800;   // 天气缓存 30 分钟
const CITY_TTL = 86400;     // IP→城市 缓存 1 天,归属地变化很慢

/** 取访客真实 IP:只信自己 CDN/网关写入的头,XFF 第一段仅作最后兜底 */
function client_ip(): string
{
    foreach (['HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
        if (!empty($_SERVER[$key])) {
            $ip = trim(explode(',', (string) $_SERVER[$key])[0]);
            if (filter_var($ip, FILTER_VALIDATE_IP)) {
                return $ip;
            }
        }
    }
    return '';
}

/** 文件缓存:小站够用,量大了换成 Redis 的 get/setex 即可 */
function cache_get(string $key, int $ttl)
{
    $file = CACHE_DIR . '/' . md5($key) . '.json';
    if (!is_file($file) || time() - filemtime($file) > $ttl) {
        return null;
    }
    $raw = file_get_contents($file);
    return $raw === false ? null : json_decode($raw, true);
}

function cache_set(string $key, $value): void
{
    if (!is_dir(CACHE_DIR)) {
        mkdir(CACHE_DIR, 0775, true);
    }
    file_put_contents(CACHE_DIR . '/' . md5($key) . '.json', json_encode($value, JSON_UNESCAPED_UNICODE));
}

function http_get_json(string $url, int $timeoutMs = 1000): ?array
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT_MS     => $timeoutMs,   // 超时必须卡死,首页不能被天气拖垮
        CURLOPT_CONNECTTIMEOUT => 1,
    ]);
    $body = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($body === false || $code !== 200) {
        return null;
    }
    $json = json_decode((string) $body, true);
    return is_array($json) ? $json : null;
}

/** 查 IP 归属地,返回 ['prov' => '广东', 'city' => '深圳'] 或 null */
function ip_location(string $ip): ?array
{
    if ($ip === '') {
        return null;
    }
    $cached = cache_get('ip:' . $ip, CITY_TTL);
    if ($cached !== null) {
        return $cached;
    }
    $json = http_get_json(IP9_API . urlencode($ip));
    if (!$json || ($json['ret'] ?? 0) !== 200) {
        return null;              // 非法 IP 返回 ret=400,直接走兜底
    }
    $data = $json['data'] ?? [];
    $loc = ['prov' => $data['prov'] ?? '', 'city' => $data['city'] ?? ''];
    cache_set('ip:' . $ip, $loc);
    return $loc;
}

/** 城市名 → 天气接口的城市编码。这里用假想接口,换成你们用的服务商字段即可 */
function city_code(string $prov, string $city): string
{
    static $map = [
        '广东|深圳' => '101280601',
        '广东|广州' => '101280101',
        '浙江|杭州' => '101210101',
        '北京|北京' => '101010100',
        '上海|上海' => '101020100',
    ];
    // 直辖市:IP 库里 prov 和 city 常常都是「北京」
    if (isset($map[$prov . '|' . $city])) {
        return $map[$prov . '|' . $city];
    }
    // 兜底一:city 为空(有些 IP 只能定位到省),退到省会
    if ($city === '' && $prov !== '') {
        foreach ($map as $key => $code) {
            if (str_starts_with($key, $prov . '|')) {
                return $code;
            }
        }
    }
    return '101010100';           // 兜底二:站点默认城市(这里默认北京)
}

function weather_for(string $code): ?array
{
    $cached = cache_get('weather:' . $code, WEATHER_TTL);
    if ($cached !== null) {
        return $cached;
    }
    // 换成你选用的天气服务商接口,把城市编码拼进去
    $json = http_get_json('https://weather.example.com/api?city=' . $code, 1200);
    if (!$json || empty($json['data'])) {
        return null;
    }
    cache_set('weather:' . $code, $json['data']);
    return $json['data'];
}

// —— 页面入口 ——
$loc     = ip_location(client_ip());
$code    = $loc ? city_code($loc['prov'] ?? '', $loc['city'] ?? '') : '101010100';
$weather = weather_for($code);   // 拿不到就整块不渲染,别显示「--℃」
$cityName = ($loc['city'] ?? '') !== '' ? $loc['city'] : '北京';
?>
<?php if ($weather): ?>
<div class="local-weather">
  <span><?= htmlspecialchars($cityName, ENT_QUOTES) ?></span>
  <span><?= htmlspecialchars((string) $weather['text'], ENT_QUOTES) ?></span>
  <span><?= (int) $weather['temp'] ?></span>
</div>
<?php endif; ?>

四、上线前再确认几件事

缓存目录要可写cache/ 放在 Web 根目录之外更稳妥,否则有人直接访问 cache/xxxx.json 能读到你的缓存文件。量上去之后把 cache_get/cache_set 换成 Redis,接口和逻辑都不用动。

缓存 key 别用完整 IP 长期存。上面 ip:<完整IP> 的缓存是 1 天,属于性能需要;如果你对合规比较敏感,可以只缓存 C 段(1.2.3.0/24),命中率更高,存的粒度也更粗——统计和展示都不需要精确到单个 IP。

海外访客怎么显示。IP9 会返回 country / country_code,非中国访客默认落到你的备用城市或者隐藏这一块,别硬套国内城市编码。

让用户能改。IP 判断总有偏差(公司专线出口在总部城市、手机流量归属地可能落在运营商机房城市),天气旁边给个「切换城市」的小链接,用户改过的选择存进 cookie 并优先于 IP 判断,体验最稳。

额度心里有数。IP9 免费版 60 次/分钟/IP,配合「IP 缓存 1 天 + 天气按城市缓存 30 分钟」,日活几千的站基本不会碰到上限。真碰到限速(返回 ret=429)时不要重试,直接走兜底城市,避免把额度浪费在重试上。要判断访客是不是机房 IP(比如有人刷你的天气接口),VIP 版的 ip_type 字段能区分 ISP 家庭 / BUS 企业 / IDC 机房。

总结

按 IP 显示天气,真正的活是那张城市映射表和缓存策略,代码本身很短。落地顺序建议:先只做「IP → 城市 → 默认城市天气」,把缓存和兜底跑通,再逐步补映射表。

查城市这一步,用 IP9 的免费接口 https://ip9.com.cn/get?ip=<IP> 就够(不传参则查当前请求方 IP,支持 IPv4/IPv6,返回国家、省市、邮编、区号、运营商、城市中心经纬度等字段),免注册、无需鉴权。官网:https://www.ip9.com.cn