谷歌分析太复杂?我自建了一个访客监控系统,能看清每个客户的来源和浏览路径

Meiko

技术工程师 - Meiko

2026-05-25

谷歌分析太复杂?我自建了一个访客监控系统,能看清每个客户的来源和浏览路径

文章目录

去年年底,一位做五金工具出口的客户在电话里跟我抱怨:“Meiko,Google Analytics 我看了半年,除了知道每天有多少人访问,其他一概看不懂。什么‘跳出率’、‘事件追踪’、‘目标转化’……我明明只想搞清楚三件事:客户是哪个国家的?从哪个广告点进来的?在网站逛了哪些页面?

他这番话,让我想起自己刚做外贸网站时的迷茫。市面上成熟的统计工具功能强大,但对非技术出身的企业主来说,信息过载严重,而且数据存在第三方平台,总有一种“把命交给别人”的不安全感。

于是我做了一个决定:自己动手,从零开发一套专为外贸B2B网站设计的访客监控系统。

这套系统不追求花哨的报表,只解决三个核心问题:

更重要的是,它能区分谷歌付费搜索 vs 自然搜索必应广告 vs 自然流量,甚至连来自ChatGPT、Claude等AI助手的流量都能单独标记。

今天,我把整个开发过程、踩过的坑、以及最终的解决方案完整记录下来。如果你也受够了复杂的数据工具,想要一个轻量、自主、看得懂的访客监控系统,这篇文章会给你一套可以直接落地的方案。

先给大家看看最终的效果。(图一是访客记录列表内置筛选功能方便查找对应时段的客户具体来源,图二是客户的访问记录旅程 客户访问了哪些页面以及访问的时间记录。)


01 为什么要自建?——三大痛点逼我动手

在开始写代码之前,我先梳理了外贸企业使用第三方统计工具的普遍痛点:

痛点一:数据分散,看不懂

Google Analytics 里面有“用户数”、“会话数”、“页面浏览量”、“事件数”……对于只想看“哪个国家的客户看了哪个产品”的外贸业务员来说,这些术语像天书。

痛点二:付费流量和自然流量分不清

很多外贸企业同时投谷歌广告、必应广告、领英广告。但在GA4里,想准确区分“来自谷歌广告的点击”和“来自谷歌自然搜索的点击”,需要手动配置UTM参数和归因模型,大多数人不会。

痛点三:不知道客户在网站里具体看了什么

GA的“行为流”虽然能显示路径,但操作繁琐,且数据有采样。我想知道的是:一个客户从首页进,看了产品A,又看了产品B,最后在联系页面退出——这种完整的故事线,GA给不了。

所以,我决定用原生PHP + MySQL,在PbootCMS环境下自建一套系统。目标很简单:每一条访问记录都清晰可见,每一段客户旅程都能完整回放。

在做这套系统时,我原本有一个更“野心勃勃”的计划:不仅要区分客户来自哪个搜索引擎,还要知道他是搜索什么词找到我的网站。

比如:“德国客户通过搜索‘stainless steel tools’进入产品页”——这种信息对SEO优化和内容策略的价值巨大。我甚至已经在数据库里预留了一个 search_keyword 字段,准备大干一场。然而,现实给了我狠狠一巴掌。

我花了整整三天时间,写了几十行代码来解析HTTP Referer 中的搜索词。逻辑很简单:如果来源是 google.com,就从参数 q 里取值;如果是 bing.com,就从 q 里取;如果是百度,就从 wd 或 word 里取……测试的时候,我用自己电脑手动搜索,代码完美运行——q=stainless+steel+tools 被成功解码为 “stainless steel tools”。我信心满满地把代码部署到服务器上,然后等了一周数据。一周后,我打开数据库,发现 search_keyword 字段 99% 都是空的。偶尔有几条记录,还是来自 Yahoo 或 DuckDuckGo。来自 Google、Bing、百度的流量,这一个字段全是空白。

我立刻查资料,才发现自己已经落后于时代。嘿嘿(尴尬)原来: 

Google 从 2011 年起就开始在 HTTPS 加密中隐藏搜索词,在 Referer 中只保留域名(如 google.com),不传递 q 参数。这叫 “not provided”。

百度在 2020 年左右也跟进,通过页面跳转等方式,彻底屏蔽了搜索词的传递。360、搜狗、Yandex 等 也早已实施类似策略。

这些搜索引擎的理由是“保护用户隐私”。但对于我们网站主来说,结果就是:你再也无法通过代码直接获知访客是通过什么关键词找到你的。这种现象在业内被称为 “黑暗搜索” (Dark Search)——流量明明来自自然搜索,你却不知道它是怎么来的。

虽然有点遗憾,但这也让我明白了一个道理:技术不是万能的,隐私保护是大势所趋。作为网站主,我们应该把精力放在更可控的事情上——比如优化内容、提升用户体验、获取高质量外链——而不是死磕那些已经被封死的路径。

02 数据库设计:一张表搞定所有需求

首先创建数据表 ay_visit_stats(前缀可根据需要修改)。我设计了以下字段:

sql

CREATE TABLE `ay_visit_stats` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `visit_time` datetime NOT NULL COMMENT '访问时间',
  `visitor_info` varchar(255) NOT NULL DEFAULT '' COMMENT '访客来源信息摘要',
  `country` varchar(100) NOT NULL DEFAULT '' COMMENT '国家',
  `country_code` varchar(5) NOT NULL DEFAULT '' COMMENT '国家代码(小写)',
  `region` varchar(100) NOT NULL DEFAULT '' COMMENT '省份/州',
  `city` varchar(100) NOT NULL DEFAULT '' COMMENT '城市',
  `device` varchar(20) NOT NULL DEFAULT '' COMMENT '设备类型',
  `os` varchar(50) NOT NULL DEFAULT '' COMMENT '操作系统',
  `browser` varchar(50) NOT NULL DEFAULT '' COMMENT '浏览器',
  `user_agent` text NOT NULL COMMENT '原始 User-Agent',
  `referrer_type` varchar(100) NOT NULL DEFAULT '' COMMENT '推荐人类别(Google自然搜索/付费/社媒等)',
  `entry_page` varchar(500) NOT NULL DEFAULT '' COMMENT '入口页面',
  `exit_page` varchar(500) NOT NULL DEFAULT '' COMMENT '退出页面',
  `pageviews` int(11) NOT NULL DEFAULT '1' COMMENT '本次会话总浏览量',
  `visit_path` text NOT NULL COMMENT '访问路径(JSON数组)',
  `session_id` varchar(32) NOT NULL DEFAULT '' COMMENT '会话标识',
  `ip` varchar(45) NOT NULL DEFAULT '' COMMENT '访客IP',
  PRIMARY KEY (`id`),
  KEY `session_id` (`session_id`),
  KEY `visit_time` (`visit_time`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计思路:


03 核心统计脚本 stats.php——系统的“心脏”

整个系统的引擎是一个独立的PHP文件,通过前端 <img> 标签无感调用,不侵入业务代码。

3.1 接收参数与会话处理

php

<?php
$current_url = $_GET@['url'] ?? '';
$referer     = $_GET@['ref'] ?? '';
$ua          = $_SERVER['HTTP_USER_AGENT'] ?? '';
$ip          = getClientIp();

$today = date('Y-m-d');
$session_key = md5($ip . $ua . $today);

// 查询当前会话是否存在
$sql = "SELECT * FROM ay_visit_stats WHERE session_id = '$session_key' ORDER BY visit_time DESC LIMIT 1";
$result = $conn->query($sql);
$exists = $result && $row = $result->fetch_assoc();

关键点:使用 IP + UA + 日期 生成会话ID,避免同一用户在短时间内创建无数个新会话。

3.2 获取真实IP(兼容CDN和代理)

php

function getClientIp() {
    $ip = '0.0.0.0';
    if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
    } elseif (isset($_SERVER['HTTP_CLIENT_IP'])) {
        $ip = $_SERVER['HTTP_CLIENT_IP'];
    } elseif (isset($_SERVER['REMOTE_ADDR'])) {
        $ip = $_SERVER['REMOTE_ADDR'];
    }
    if (strpos($ip, ',') !== false) {
        $ips = explode(',', $ip);
        $ip = trim($ips[0]);
    }
    return $ip;}

很多外贸网站使用Cloudflare等CDN,REMOTE_ADDR 是CDN节点IP,必须从 HTTP_X_FORWARDED_FOR 中提取真实访客IP。

3.3 设备、操作系统、浏览器解析(踩坑最多的一环)

最初我手写正则表达式,结果发现手机上的Chrome经常被误判为Safari,因为UA字符串里同时包含“Chrome”和“Safari”,而我的检测顺序先匹配了Safari。

修正后的检测顺序(将Chrome提前):

php

function getBrowser($ua) {
    if (stripos($ua, 'MicroMessenger') !== false) return 'WeChat';
    if (stripos($ua, 'Edg') !== false) return 'Edge';
    if (stripos($ua, 'OPR') !== false) return 'Opera';
    if (stripos($ua, 'Brave') !== false) return 'Brave';
    if (stripos($ua, 'Chrome') !== false) return 'Chrome';
    if (stripos($ua, 'Firefox') !== false) return 'Firefox';
    if (stripos($ua, 'Safari') !== false) return 'Safari';
    // ... 其他
    return 'Unknown';}

经过多次迭代,这套手写规则已经能覆盖全球主流浏览器。操作系统解析同理,通过检测 Windows NTMac OS XAndroidiPhone 等关键字完成。

3.4 流量来源解析(核心亮点:区分付费与自然搜索)

这是整个系统最实用的功能。外贸企业投放谷歌广告、必应广告时,需要准确知道哪些客户来自付费点击,哪些来自免费搜索。

判断逻辑:

  1. 检查当前页面URL中的广告参数

    • gclid → 谷歌广告

    • msclkid → 必应广告

    • utm_medium=cpcppc → 通用付费标记

  2. 如果没有上述参数,再判断 referer

    • 如果 referer 域名为 google. 开头 → 谷歌自然搜索

    • 如果 referer 域名为 bing.com → 必应自然搜索

    • 如果 referer 域名为 facebook.comlinkedin.com 等 → 社交媒体

    • 如果 referer 为空且无广告参数 → 直接访问

  3. 特殊处理:AI助手流量

    • 检测 referer 中的 chat.openai.comclaude.aigemini.google.comdeepseek.com 等,单独归类为 “ChatGPT AI助手” 或 “Claude AI” 等。

php

function parseReferrerTypeAdvanced($referer, $current_url) {
    // 1. 检查当前URL中的广告参数
    $has_ad_param = false;
    if (!empty($current_url)) {
        $parts = parse_url($current_url);
        if (isset($parts['query'])) {
            parse_str($parts['query'], $query);
            if (!empty($query['gclid'])) $has_ad_param = true;
            if (!empty($query['msclkid'])) $has_ad_param = true;
            if (!empty($query['utm_medium']) && in_array($query['utm_medium'], ['cpc','paid','ppc'])) $has_ad_param = true;
        }
    }
    
    // 2. 如果没有referer且无广告参数 => 直接访问
    if (empty($referer) && !$has_ad_param) return '直接访问';
    
    // 3. 解析referer域名
    $host = parse_url($referer, PHP_URL_HOST);
    if (!$host && !$has_ad_param) return '直接访问';
    
    // 4. AI助手检测(放在搜索引擎前面,因为某些AI的referer也包含google字样)
    $ai_domains = [
        'chat.openai.com' => 'ChatGPT AI助手',
        'claude.ai' => 'Claude AI助手',
        'gemini.google.com' => 'Gemini AI助手',
        'deepseek.com' => 'DeepSeek AI助手',
    ];
    foreach ($ai_domains as $domain => $label) {
        if (strpos($host, $domain) !== false) return $label;
    }
    
    // 5. 搜索引擎判断
    if (strpos($host, 'google.') !== false) {
        return $has_ad_param ? 'Google 付费搜索' : 'Google 自然搜索';
    }
    if (strpos($host, 'bing.com') !== false) {
        return $has_ad_param ? 'Bing 付费搜索' : 'Bing 自然搜索';
    }
    if (strpos($host, 'yahoo.com') !== false) return 'Yahoo 搜索';
    if (strpos($host, 'yandex.') !== false) return 'Yandex 搜索';
    if (strpos($host, 'duckduckgo.com') !== false) return 'DuckDuckGo 搜索';
    
    // 6. 社交媒体
    $social = ['facebook.com','twitter.com','linkedin.com','youtube.com','instagram.com','tiktok.com','pinterest.com','reddit.com'];
    foreach ($social as $domain) {
        if (strpos($host, $domain) !== false) return ucfirst(strtok($domain, '.'));
    }
    
    return $host ?: '其他';}

3.5 IP地理位置查询(多API容灾方案)

这是开发过程中踩坑最多的部分。一开始只用 ip-api.com,结果经常因为限流或网络抖动导致国家字段为“未知”。后来设计了多API轮询 + 本地缓存的方案:

每个API请求超时3秒,失败自动切换下一个。同时对IPv6做了过滤:只屏蔽本地回环、链路本地地址,公网IPv6正常查询。

php

function getIpLocation($ip) {
    static $cache = [];
    if (isset($cache[$ip])) return $cache[$ip];
    
    // 私有IP过滤(保留公网IPv6)
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
        if (preg_match('/^(::1|fe80|fc00|fec0)/i', $ip)) {
            return ['country' => '私有网络', 'country_code' => '', 'region' => '', 'city' => ''];
        }
    } else {
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE)) {
            return ['country' => '私有网络', 'country_code' => '', 'region' => '', 'city' => ''];
        }
    }
    
    $apis = [
        'ipapi.co' => [
            'url' => "https://ipapi.co/{$ip}/json/",
            'parse' => function($data) {
                return [
                    'country' => $data['country_name'],
                    'country_code' => strtolower($data['country_code']),
                    'region' => $data['region'],
                    'city' => $data['city']
                ];
            }
        ],
        'ip-api.com' => [
            'url' => "http://ip-api.com/json/{$ip}?fields=status,country,countryCode,regionName,city",
            'parse' => function($data) {
                if ($data['status'] == 'success') {
                    return [
                        'country' => $data['country'],
                        'country_code' => strtolower($data['countryCode']),
                        'region' => $data['regionName'],
                        'city' => $data['city']
                    ];
                }
                return null;
            }
        ],
        'ipinfo.io' => [
            'url' => "https://ipinfo.io/{$ip}/json",
            'headers' => ['Authorization: Bearer YOUR_TOKEN'],
            'parse' => function($data) {
                return [
                    'country' => countryCodeToName($data['country']), // 需要国家代码转名称的映射函数
                    'country_code' => strtolower($data['country']),
                    'region' => $data['region'],
                    'city' => $data['city']
                ];
            }
        ]
    ];
    
    foreach ($apis as $api) {
        $ctx = stream_context_create(['http' => ['timeout' => 3, 'header' => implode("
", $api['headers']??[])]]);
        $resp = @file_get_contents($api['url'], false, $ctx);
        if ($resp) {
            $data = json_decode($resp, true);
            $parsed = $api['parse']($data);
            if ($parsed && !empty($parsed['country'])) {
                $cache[$ip] = $parsed;
                return $parsed;
            }
        }
    }
    return ['country' => '未知', 'country_code' => '', 'region' => '未知', 'city' => '未知'];}

3.6 会话更新与路径记录

每次访问时,如果会话已存在,就更新 exit_page、增加 pageviews,并将当前页面追加到 visit_path 数组;否则插入新记录。

php

if ($exists) {
    $visit_path = json_decode($row['visit_path'], true);
    $visit_path[] = ['url' => $current_url, 'time' => date('Y-m-d H:i:s')];
    $visit_path_json = json_encode($visit_path);
    $new_views = $row['pageviews'] + 1;
    $update_sql = "UPDATE ay_visit_stats SET 
        visit_time = NOW(),
        exit_page = '$current_url',
        pageviews = $new_views,
        visit_path = '$visit_path_json',
        country = '$country', country_code = '$code', region = '$region', city = '$city',
        ip = '$ip'
        WHERE id = {$row['id']}";
    $conn->query($update_sql);} else {
    $visit_path_json = json_encode([['url' => $current_url, 'time' => date('Y-m-d H:i:s')]]);
    $insert_sql = "INSERT INTO ay_visit_stats (...) VALUES (...)";
    $conn->query($insert_sql);}

最后,脚本输出一个透明的1x1像素GIF图片,供前端调用:

php

header('Content-Type: image/gif');echo base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');

04 后台管理界面(PbootCMS + Layui)

为了方便查看数据,我在PbootCMS中创建了控制器和模板。

4.1 控制器:获取数据并准备图标

php

public function index() {
    $date = get('date', 'var');
    $list = (new TonjiModel())->getList($date);
    foreach ($list as &$item) {
        $item->country_code = strtolower($item->country_code);
        $item->device_icon = $item->device == '手机' ? 'fa-mobile-alt' : ($item->device == '平板' ? 'fa-tablet-alt' : 'fa-desktop');
        // 操作系统图标
        if (strpos($item->os, 'Windows') !== false) $item->os_icon = 'fa-windows';
        elseif (strpos($item->os, 'Mac') !== false) $item->os_icon = 'fa-apple';
        elseif (strpos($item->os, 'Android') !== false) $item->os_icon = 'fa-android';
        else $item->os_icon = 'fa-linux';
        // 浏览器图标
        if (strpos($item->browser, 'Chrome') !== false) $item->browser_icon = 'fa-chrome';
        elseif (strpos($item->browser, 'Edge') !== false) $item->browser_icon = 'fa-edge';
        // ... 其他
    }
    $this->assign('syslogs', $list);
    $this->display('system/tonji.html');}

4.2 模板:国旗+图标+IP链接+路径弹窗

html

<table class="layui-table">
  <thead>...</thead>
  <tbody>
    {volist name="syslogs" id="vo"}    <tr>
      <td>
        <span class="flag-icon flag-icon-{$vo.country_code}" title="{$vo.country} {$vo.region} {$vo.city}"></span>
        <i class="fas {$vo.device_icon}"></i>
        <i class="fab {$vo.os_icon}"></i>
        <i class="fab {$vo.browser_icon}"></i>
      </td>
      <td>{$vo.referrer_type}<br>
        <a href="https://iplark.com/{$vo.ip}" target="_blank">{$vo.ip}</a>
      </td>
      <td>
        <button class="layui-btn layui-btn-xs" onclick="showPath({$vo.id})">查看路径</button>
      </td>
    </tr>
    {/volist}  </tbody></table>

路径弹窗使用Layui的layer组件,将存储的JSON解码后逐条显示:

javascript

function showPath(id) {
    $.get('/index.php/tonji/getPath?id=' + id, function(res) {
        let html = '<ul>';
        res.data.forEach(item => {
            html += `<li>${item.time} → ${item.url}</li>`;
        });
        html += '</ul>';
        layer.open({ type: 1, title: '访问路径', content: html, area: ['600px', '400px'] });
    });}

同时我给旗帜添加了悬浮显示具体位置功能如下图1,鼠标悬浮旗帜上显示国家、省份(州)、城市。后面显示出对应的IP地址,管理员也可以通过点击IP来手动查询确认客户的国家位置如下图2。


05 踩坑实录(献给同样自建的开发者)

在整个开发过程中,我犯了不下20个错误。以下是印象最深的几个:

坑1:手机Chrome浏览器被识别为Safari

原因:UA字符串同时包含“Chrome”和“Safari”,检测顺序先匹配了Safari。
解决:把Chrome的检测放在Safari前面。

坑2:IPv6地址全部显示“未知”

原因:过滤逻辑把所有IPv6都拦截了。
解决:只过滤本地回滚(::1)、链路本地(fe80::)等私有地址,其他IPv6放行并交给API查询。

坑3:国家旗帜不显示

原因:数据库中 country_code 字段为空,因为IP API返回的代码是大写,而控制器里错误地尝试从 country 字段按空格分割取末段。
解决:直接在 stats.php 中保存小写的 country_code,控制器直接使用。

坑4:谷歌广告点击被归为“自然搜索”

原因:只判断了 referer,忽略了URL中的 gclid 参数。
解决:解析当前页面URL,优先检测 gclidmsclkid 等付费参数。

坑5:IP定位经常失败,显示“未知”

原因:单一API不可靠。
解决:多API容灾 + 本地缓存,3秒超时自动切换。

坑6:数据库表不断膨胀

原因:没有定期清理机制。
解决:创建MySQL事件,每天凌晨删除7天前的记录。

坑7:本地开发环境测试时无数据

原因getClientIp 返回 127.0.0.1,被私有IP过滤拦截。
解决:开发环境下临时注释过滤逻辑,生产环境保留。


06 最终成果与数据准确度

经过三个月的实际运行,这套系统在客户的五金外贸网站上收集了超过5万条访问记录。对比Google Analytics的数据:

指标自建系统GA4差异原因
国家定位准确率95%98%IP库本身精度限制,但可接受
付费/自然流量区分100%需手动配置自建系统直接读取gclid参数,无误
访问路径完整性100%GA采样自建系统记录每一个页面
设备/浏览器识别99%99%手写正则已覆盖主流
AI助手流量识别新增不支持自建系统独家功能

客户反馈最有用的是两件事:

  1. “我看到一个来自德国的IP,先看了产品A,又看了产品B,最后在联系页面退出,虽然他给我发送了询盘建立了沟通但是始终没有敲定订单。后面的一段时间我注意到这个IP,他不断在我们网站浏览不同的产品页、公司介绍页。我决定打电话过去,对方说正在对比产品,经我的不断主动“骚扰”,三天后成交了。”

  2. “原来我们投的必应广告,一直以为是浪费钱。现在看到有客户通过必应广告进来,并且看了好几个产品,我就加大了必应预算。”


07 后续优化方向

虽然系统已经稳定运行,但我还在持续改进:


结语:数据自主,才能心中有数

自建访客统计系统,初期开发确实比直接装个Google Analytics要多花一两周时间。但对于长期运营的外贸独立站来说,数据自主权带来的价值远超那点开发成本

你可以随时添加自己需要的字段(比如“客户是否发送了询盘”),可以自由调整分类规则(比如把某个冷门社媒单独标记),不用担心数据被采样或被屏蔽。更重要的是,每一行代码你都知道它在做什么当然最主要的原因还是 “我对于这项工作的热爱,我想不断的提升自己。从最初的想法到落实的实际功能实现,这个过程我很满足(哈哈)。

我是Meiko,meikoseo.com的创始人。 我专注于为外贸企业搭建高性能、数据可控的独立站。如果你也被复杂的分析工具困扰,或者想为自己的网站定制一套这样的监控系统,欢迎通过我的网站联系我。

让数据为你所用,而不是被数据淹没。

原创文章归Meikoseo版权所有,转载请注明出处,商用请联系本站获取版权。

想要马上开始定制开发您的网站建设?

up icon