询盘表单只收字段是不够的:来源页、浏览路径与 IP 地理怎么记

September, 28th 2026 10 min read • Markdown •

引子

我们公司自己的外贸站在跑 Google Ads,询盘表单能收名字、邮箱、货物需求——然后呢?我自己想看”这条询盘是从哪个页面、哪个广告带来的”,答不上来。

给表单补归因数据,听起来像”加几个隐藏字段”的事。真做起来有两个硬约束会挡路:

  1. CDN 缓存了 HTML——主机 CDN 按 URL 把页面缓存 7 天,服务端渲染时根本不知道访客这次 URL 上挂着什么 ?gclid=。
  2. HTTP 无状态——服务器看到的是一堆独立请求,“这个访客提交前看了哪几页”它天然不知道。

注意:这两个约束跟 CMS 无关——CDN 缓存和 HTTP 无状态是所有网站的天性。所以下面这套是方法论,不是某个生态的技巧;代码用 WordPress/PHP 举例,换成 Next.js、Laravel、静态站生成器,三层结构一层不变。

这是我在我们自己公司站上刚落地的完整方案,记一篇,以后做别的站直接照抄。

要记的字段,分三类

类别字段回答的问题
来源source_page在哪一页点的提交
归因utm_source/medium/campaign、gclid、landing_page、referrer、journey哪个广告/渠道带来的,从哪页落地,逛了哪几页
环境user_agent、accept_language、ip、geo_country/city什么设备、什么语言、哪的人

三个容易混的概念先分清:

  • source_page = 提交页(表单所在的页)
  • landing_page = 这个标签页本次会话的第一个页面(广告流量的落地页)
  • journey = 从落地到提交中间经过的每一页

广告流量的典型路径是”落在方案页 → 逛几页 → 到联系页提交”,所以 landing ≠ source 是常态,必须分开记。

核心机制:三层各管一段

① 捕获层:sessionStorage 首触记录

为什么是 sessionStorage 而不是 cookie 或 localStorage:

  • 标签页级生命周期:同一标签页里跳多少页都活着,关掉就清空。“这次访问”天然等于”这个标签页的会话”,不用自己造会话超时。
  • 不出浏览器:不随请求头发送、不占带宽、不进 CDN 缓存键、平时服务器完全看不到——只有提交那一刻才被抄进表单。

每次页面加载跑这一段(放在全站都加载的 JS 里):

javascript
12345678910111213141516171819
try {
  const ss = sessionStorage, q = new URLSearchParams(location.search);
  // 落地页:写一次就锁死,首触优先
  if (!ss.getItem('eu_land')) ss.setItem('eu_land', location.origin + location.pathname);
  // 浏览路径:逐页追加,刷新同页不重复,上限 20 页
  const path = JSON.parse(ss.getItem('eu_journey') || '[]');
  if (path[path.length - 1] !== location.pathname) path.push(location.pathname);
  ss.setItem('eu_journey', JSON.stringify(path.slice(-20)));
  // 广告参数:URL 上有就存(已有不覆盖)
  ['utm_source','utm_medium','utm_campaign','gclid'].forEach(k => {
    const v = q.get(k);
    if (v && !ss.getItem('eu_' + k)) ss.setItem('eu_' + k, v.slice(0, 300));
  });
  // referrer 只存站外来源
  if (!ss.getItem('eu_ref') && document.referrer
      && new URL(document.referrer).origin !== location.origin) {
    ss.setItem('eu_ref', document.referrer);
  }
} catch (e) { /* 隐私模式读不到 storage:归因留空,表单照常 */ }

提交前把这些值抄进表单隐藏字段:

javascript
12345678910
const fill = () => {
  document.querySelectorAll('form [name=utm_source]').forEach(el => {
    const f = el.form;
    const set = (n, k) => { const i = f.querySelector('[name=' + n + ']'); if (i) i.value = ss.getItem(k) || ''; };
    set('utm_source', 'eu_utm_source'); set('utm_medium', 'eu_utm_medium');
    set('utm_campaign', 'eu_utm_campaign'); set('gclid', 'eu_gclid');
    set('landing_page', 'eu_land'); set('referrer', 'eu_ref');
    set('journey', 'eu_journey');
  });
};

② 传输层:隐藏字段,但 source_page 例外

归因字段全走隐藏字段。唯一例外是 source_page——它服务端渲染:

php
123
<input type="hidden" name="source_page" value="<?php
  echo esc_attr( is_singular() ? get_permalink() : home_url( '/' ) );
?>">

为什么它可以服务端渲染而 UTM 不行?CDN 按 URL 缓存 HTML,所以每页缓存里的值就是这页自己的地址——不会串页。而 ?gclid= 这种 query 在缓存键里丢了,服务端根本看不到。

服务端渲染还有个好处:无 JS 的提交路径(比如老爬虫、极端浏览器)也有来源页。

③ 接收层:白名单 + 落库

所有隐藏字段走和表单字段同一条白名单管线,每个都校验:

php
12345678910111213141516171819
// source_page:只收本站 URL,防伪造外链
function clean_source_page( string $url ): string {
  $host = wp_parse_url( $url, PHP_URL_HOST );
  return ( $host && $host === wp_parse_url( home_url(), PHP_URL_HOST ) ) ? $url : '';
}

// journey:JSON 数组 → "a → b → c",只收 / 开头的路径段
$journey = json_decode( $in['journey'], true );
$in['journey'] = '';
if ( is_array( $journey ) ) {
  $legs = array_filter( array_map(
    fn( $l ) => is_string( $l ) && str_starts_with( $l, '/' ) ? mb_substr( $l, 0, 120 ) : '',
    $journey
  ) );
  $in['journey'] = mb_substr( implode( '  →  ', $legs ), 0, 1200 );
}

// gclid:只留合法字符
$in['gclid'] = preg_replace( '/[^A-Za-z0-9_-]/', '', $in['gclid'] );

IP 和地理:CDN 后面怎么拿真实 IP

站点在 CDN 后面时,REMOTE_ADDR 是边缘节点的 IP,不是访客的。按代理头链读:

php
1234567
function visitor_ip( WP_REST_Request $req ): string {
  $cf = trim( (string) $req->get_header( 'cf-connecting-ip' ) );
  if ( $cf ) return $cf;
  $xff = trim( (string) $req->get_header( 'x-forwarded-for' ) );
  if ( $xff ) return trim( explode( ',', $xff )[0] );   // 第一个是访客,后面是代理
  return trim( (string) $req->get_header( 'x-real-ip' ) ) ?: ( $_SERVER['REMOTE_ADDR'] ?? '' );
}

地理解析用 ipwho.is——免费、HTTPS、免 key,询盘量级的调用完全够用。关键约束是失败必须放行:

php
1234567891011
function geo_lookup( string $ip ): array {
  if ( '' === $ip || filter_var( $ip, FILTER_VALIDATE_IP,
      FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) {
    return [ '', '' ];  // 内网/保留地址没有地理意义
  }
  $res = wp_remote_get( 'https://ipwho.is/' . rawurlencode( $ip ), [ 'timeout' => 4 ] );
  if ( is_wp_error( $res ) ) return [ '', '' ];          // 挂了就留空,绝不阻塞提交
  $data = json_decode( (string) wp_remote_retrieve_body( $res ), true );
  if ( empty( $data['success'] ) ) return [ '', '' ];
  return [ $data['country'] ?? '', $data['city'] ?? '' ];
}

验证方法:提交一单测试,把记录的 IP 和你本机 curl ifconfig.me 的结果逐位对比。我实测一致——一致才说明代理头链真的穿透了 CDN,而不是记了个节点 IP。

展示:空的行就不要显示

后台详情页我把字段分三张表:Enquiry(访客填的内容)、Attribution(来源页/着陆页/路径/广告)、Technical(时间/IP/设备/发信结果)。访客填的和系统采集的分开,不然 15 行挤一张表没法看。

归因组按值条件显示:

  • journey 只有一页 → 不显示(一页会话它等于 source page,纯重复)
  • landing = source → 不显示(同理)
  • Campaign/GCLID/Referrer 为空 → 不显示(空 = 自然流量,没有信息量)
  • Technical 组始终全显——发信失败的状态是信号,不能藏

通知邮件正文同样:归因行有值才输出。

边界条件(都是实测踩出来的)

  • 新标签页 = 新会话。sessionStorage 的天性,landing 会重置。对”这单是哪里来的”这个问题,标签页粒度恰好正确,但别指望它做跨天回访归因。
  • 隐私模式读不到 storage——归因留空,表单必须照常可用(try/catch 包住整段)。
  • IP 是个人信息。存 IP 前确认隐私政策覆盖,GDPR 口径下它算 PII。
  • 邮件通知和入库要分开。主机 PHP mail() 依赖宿主本地中继,中继会停(我在 Hostinger 上实测:前一天正常、次日拒连)。发信结果写成记录 meta(sent/failed),失败不回滚入库——记录落地了,邮件只是通知。
  • E2E 测试会被自己的时间陷阱拦。表单有”秒填秒交判机器人”的反爬,脚本测试时填充和点击之间要留够秒数,否则症状是”表单没坏但测试记录不出现”。

总结

一句话带走:浏览器 sessionStorage 做首触捕获,隐藏字段做传输,服务端白名单做落库——CDN 缓存和 HTTP 无状态这两个约束决定了它只能这么搭,跟用什么后端框架无关。

下个站直接照这套做就行,字段契约我已经固化进建站 skill 了。进一步可以做的:gclid 留着以后做 Google Ads 离线转化回传;journey 攒多了能看出哪些页面是转化路径上的常客。