---
title: 询盘表单只收字段是不够的：来源页、浏览路径与 IP 地理怎么记（任何网站适用）
slug: form-attribution-tracking
date: 2026-09-28
author: Frankie 徐
category: fullstack
tags: ['forms', 'attribution', 'javascript', 'php', 'analytics']
description: 表单只收名字邮箱不够回答"这单是哪个广告带来的"。一套与 CMS 无关的询盘归因方法论：sessionStorage 首触捕获 + 隐藏字段传输 + 服务端白名单落库，附 WordPress/PHP 完整实现。
permalink: https://www.210k.cc/form-attribution-tracking
---

## 引子

我们公司自己的外贸站在跑 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
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
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
<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
// 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
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://ipwho.is)——免费、HTTPS、免 key，询盘量级的调用完全够用。**关键约束是失败必须放行**：

```php
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 攒多了能看出哪些页面是转化路径上的常客。