Appearance
网站怎么按访客城市显示本地门店、区域活动?用 IP 归属地自动分发(PHP 实现)
连锁品牌、区域加盟、本地生活类的官网有个共同需求:广州来的访客想看广州门店和广州活动,成都来的想看成都的。给所有人看同一个全国页,等于让一半访客自己再找一遍「离我最近的门店」。让访客手填城市?多一步就多一批流失。服务端用 IP 归属地判断访客所在城市,直接把对应的本地内容给他,访客无感知,这是目前最省事的做法。这篇用 PHP 写一套完整的自动分发:查归属地 → 匹配城市 → 渲染本地门店页,缓存、兜底、SEO 注意事项都带上。
一、为什么是「服务端查 IP」,而不是前端定位
先排除两个想当然的方案:
- 让浏览器弹定位授权:现在用户对授权弹窗极度警惕,拒绝率很高,而且 HTTPS 下才生效。为了看个门店信息让用户授权位置,转化率直接崩。
- 纯前端 JS 调查询接口:省了后端,但 IP 归属地接口大多没开浏览器跨域(CORS),直调会被浏览器拦;就算能调,前端每次进站都裸奔一个查询接口,限流和缓存都不好控制。
服务端查 IP 的好处:不用用户授权、不依赖浏览器环境、接口调用次数可控、结果可以缓存,还能顺便给日志打上访客地域标签。IP 归属地是城市级精度,判断「访客在哪个城市」这个需求正好够用——它不是 GPS,别指望精确到街道,那是另一码事。
数据源用 IP9 免费接口 https://ip9.com.cn/get?ip=<ip>,返回国家、省份、城市、运营商等字段,免注册、60 次/分钟/IP。PHP 里一次 file_get_contents 就能拿到 JSON,不需要装任何扩展。
二、方案设计
分发逻辑放在入口文件最前面,三步:
- 取访客 IP:直连场景取
REMOTE_ADDR。网站在 Nginx/CDN 反代后面的话,先配好real_ip模块或让反代覆盖可信头,再取REMOTE_ADDR(怎么在反代后面拿真实 IP 是个独立话题,这里不展开,只提醒别直接信客户端能伪造的X-Forwarded-For)。 - 查归属地并匹配城市:调接口拿
prov/city,和事先配好的「城市 → 本地页」映射表比对。查不到城市(海外 IP、区县级数据缺失、接口超时)一律走默认页。 - 渲染:命中就渲染本地门店/区域活动页;没命中就渲染全国默认页。
两个工程细节提前说:归属地结果按 IP 缓存(文件或 Redis,缓存 6 小时够用,IP 属地一天内基本不变),别让每个访客都打一次上游接口;给搜索引擎放行默认页——让百度爬虫看到的是全国页而不是随机某个城市页,避免收录混乱(代码里用 UA 判断,示例只写了最简单的一种)。
三、PHP 实现
一个入口文件跑通,映射表按需扩充:
php
<?php
// index.php —— 按访客城市分发本地门店/区域活动页
// 城市 -> 本地页 映射表(city 字段与接口返回一致;更稳的做法是 prov+city 联合)
$regionMap = [
'广州' => '/pages/gz-stores.php', // 广州门店页
'深圳' => '/pages/sz-stores.php',
'成都' => '/pages/cd-stores.php',
'杭州' => '/pages/hz-stores.php',
];
function clientIp(): string {
// 直连取 REMOTE_ADDR;Nginx/CDN 反代请先配 real_ip 模块,仍取 REMOTE_ADDR
return $_SERVER['REMOTE_ADDR'] ?? '';
}
function ipGeo(string $ip): array {
// 6 小时文件缓存,避免每个访客都打上游接口(免费版 60 次/分钟/IP)
$cacheFile = sys_get_temp_dir() . '/geo_' . md5($ip) . '.json';
if (is_file($cacheFile) && (time() - filemtime($cacheFile) < 21600)) {
$cached = json_decode((string)file_get_contents($cacheFile), true);
if (is_array($cached)) return $cached;
}
$ctx = stream_context_create(['http' => [
'timeout' => 3,
'header' => "User-Agent: Mozilla/5.0 (compatible; RegionPage/1.0)\r\n", // 显式带UA,规避默认请求特征被拦
]]);
$raw = @file_get_contents('https://ip9.com.cn/get?ip=' . urlencode($ip), false, $ctx);
$geo = [];
if ($raw !== false) {
$resp = json_decode($raw, true);
if (is_array($resp) && ($resp['ret'] ?? 0) === 200) {
$geo = is_array($resp['data'] ?? null) ? $resp['data'] : [];
}
}
@file_put_contents($cacheFile, json_encode($geo)); // 写失败不影响主流程
return $geo;
}
function isSearchEngine(): bool {
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
return preg_match('/Baiduspider|Googlebot|bingbot|Bytespider|Sogou/', $ua) === 1;
}
$geo = ipGeo(clientIp());
$city = (string)($geo['city'] ?? '');
$local = $regionMap[$city] ?? '';
if ($local !== '' && !isSearchEngine()) {
include __DIR__ . $local; // 命中:渲染本地门店/活动页
} else {
include __DIR__ . '/pages/default.php'; // 未命中/爬虫:全国默认页
}本地页示例(pages/gz-stores.php),把门店列表写进模板就行:
php
<?php
// pages/gz-stores.php —— 广州本地页
$stores = [
['name' => '广州天河店', 'addr' => '天河区体育西路 xx 号', 'tel' => '020-8888xxxx'],
['name' => '广州白云店', 'addr' => '白云区云城东路 xx 号', 'tel' => '020-6666xxxx'],
];
?>
<h1>广州门店</h1>
<ul>
<?php foreach ($stores as $s): ?>
<li><?= htmlspecialchars($s['name']) ?>:<?= htmlspecialchars($s['addr']) ?>(<?= htmlspecialchars($s['tel']) ?>)</li>
<?php endforeach; ?>
</ul>想改成「不同城市跳不同子域名/落地页」也简单,把 include 换成:
php
// header('Location: https://gz.example.com/', true, 302);
// exit;两种形态的取舍见下节。代码就这么多,核心是「查一次归属地 + 查一次映射表」,没有第二种语言的实现;页面本身是什么框架(原生 PHP、模板引擎、WordPress)都不影响这套分发逻辑。
四、落地注意事项
- 跳转 vs 软渲染,优先软渲染。302 跳转到不同 URL,会让「同一个页面」在不同城市访客眼里是不同网址,搜索引擎收录、分享出去的链接都会乱。同一域名下 include 不同内容块最稳;真要跳,只在有独立子站(gz.example.com)时用。
- 爬虫必须放行默认页。上面代码用 UA 判断了主流搜索引擎。不做这步,百度收录的可能全是「广州店」页,其他城市的关键词全废。
- 兜底顺序:查归属地失败、返回空城市、海外 IP,全部走默认页。海外访客你甚至可以再叠一层「按国家展示英文页」,那是另一个话题。
- 别把经纬度当门店定位依据。接口返回的
lng/lat是城市中心点,用来画地图中心可以,用来算「最近门店」会出笑话。最近门店要么用门店列表让用户自己选,要么上真正的定位。 - 免费版没有
ip_type/区县字段(VIP 版提供)。做城市级分发用不到,不用纠结。 - 缓存文件目录要可写;多台服务器部署时把缓存换 Redis/Memcached,避免每台机器各查一遍。
总结
官网按城市分发本地内容,服务端查一次 IP 归属地就解决了:https://ip9.com.cn/get?ip=<ip> 免注册返回省市,PHP 里 file_get_contents 加个浏览器 UA 就能用,匹配到城市就 include 对应的本地门店页。记住三条:软渲染优先于 302、爬虫放行默认页、查不到就走兜底页。接口字段和限流说明以官网 https://www.ip9.com.cn 为准,把映射表扩充到你的每个门店城市,这套逻辑能直接上线。