Appearance
网站怎么按访客 IP 显示本地天气?PHP 对接天气接口的三个坑
本地生活站、出行工具站、区域资讯站,首页右上角挂一行「深圳 多云 26℃」是标配。用户喜欢这个细节,但站长做起来经常卡住:让用户自己选城市的,多数人根本不选;直接用浏览器定位的,一半人点「拒绝」。
用 IP 归属地拿到访客城市,再拿城市去查天气,是成本最低的做法。逻辑听着简单——「查 IP → 拿城市 → 查天气」三步——但真正写起来,坑都集中在中转那一步:IP 接口返回的城市名,和天气接口要的城市参数,通常对不上。这篇用 PHP 把完整流程写出来,把三个常见的坑逐个填掉。
一、三个坑,先认识一下
坑一:把 IP 直接丢给天气接口。 天气接口要的是城市编码、城市 ID 或者经纬度,不是 IP。IP9 返回的经纬度是城市中心点,拿它当定位坐标查天气勉强能用,但因为一个城市只有一个中心点,等于全市共用一份天气,白白丢了精度;更稳的是用城市名/城市编码去查。
坑二:城市名对不上。 IP 返回「深圳」,天气接口要「深圳市」;IP 返回「省直辖县级行政区划」,天气接口压根没这个词;港澳台和海外城市通常需要英文名或国际城市 ID。这一层映射不做,线上表现就是「有的城市能显示,有的城市空白」。
坑三:每个访客查两次接口。 IP 查一次、天气查一次,还不缓存的话,一个日活几千的站能轻松把免费额度打满。天气数据的更新频率是分钟级到小时级,缓存 30 分钟完全够用。
二、方案设计
整体的处理链路:
- 取访客真实 IP(有 CDN/代理时注意取对头,下面代码里处理了);
- 调
https://ip9.com.cn/get?ip=<IP>拿prov/city(免费版就返回这两个字段,区县级别的area是 VIP 字段); - 用「省+市」去自己的映射表里找天气接口要的城市编码,找不到就退到省会城市;
- 查天气,缓存 30 分钟(按城市编码缓存,同一个城市的所有访客共用一份);
- 全程任何一步失败,回落到站点默认城市,绝不让首页开天窗。
映射表不用一次做全,先把你访客量前 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