XunL A/B 实验:可视化改页面、按访客稳定分组、成功询盘由服务器确认,报告给出置信区间和是否可以下结论。
选择接入方式
两种方式用同一个站点连接、同一套实验和报告,区别只在安装和询盘确认。
| JS SDK | WordPress 插件 | |
|---|---|---|
| 适用网站 | 任何网站:Shopify、Webflow、Wix、Squarespace、跟踪代码管理器、自建站 | WordPress 6.2 及以上 |
| 安装 | 把一行脚本放进公共 head | 上传插件 ZIP,填入连接信息 |
| 页面、点击、自定义事件目标 | 支持 | 支持 |
| 成功询盘目标 | 你的服务器签名确认(附 Node.js、Python、PHP 示例) | 自动确认:Contact Form 7、Elementor Pro、WPForms、Gravity Forms |
任何网站:安装 JS SDK
- 登录迅龙后台,在「站点管理」添加网站,再到「A/B 实验 → 站点与安装」创建连接。
- 复制后台给出的脚本,放进所有页面共用的 head 里,越靠前越好。下面的示例中 SITE_PUBLIC_KEY 要换成你自己站点的公钥。
- 接入访客同意(见下文),然后在后台点「检查接入」。
<script defer src="https://www.xunl.net/js/xunl-experiments-sdk.js" data-xunl-key="SITE_PUBLIC_KEY" data-xunl-origin="https://www.xunl.net" data-xunl-consent="pending"></script>脚本只读取公开的实验配置,连接密钥永远不要放进网页。单页应用用 history.pushState 切换路由后,调用一次 XunLExperiments.refresh()。
各建站平台放在哪里
- Shopify
- 在线商店 → 模板 → 编辑代码 → layout/theme.liquid,粘贴到 head 结束标签之前。结账页无法加脚本(Shopify Plus 除外),实验目标请放在商品页和购物车之前的页面。
- Webflow
- Site settings → Custom code → Head code,粘贴后发布网站。需要付费的网站套餐。
- Wix
- 设置 → 高级 → 自定义代码 → 添加自定义代码,选择“所有页面”和“Head”。需要付费套餐并绑定域名。
- Squarespace
- 设置 → 高级 → 代码注入 → 页眉。需要商务版或更高套餐。
- Google 跟踪代码管理器
- 新建「自定义 HTML」代码,粘贴脚本,触发条件选“所有网页”或“初始化 - 所有网页”。通过跟踪代码管理器加载会比直接写在 head 里晚一些,可视化改动更容易闪一下。
- 自建站、React、Next.js
- 放进 HTML 模板的 head,例如 Next.js 的根布局或 _document,Vue 和 React 项目的 index.html。
接入访客同意
默认 data-xunl-consent="pending":同意之前 SDK 不读写浏览器存储、不生成访客身份、不发送事件。在你的 Cookie 同意弹窗回调里调用下面任一种写法。
// After the visitor accepts analytics cookies in your consent banner
XunLExperiments.consent(true);
// If they decline or withdraw consent
XunLExperiments.consent(false);
// Or, without touching the SDK directly (also works before it has started)
window.dispatchEvent(new CustomEvent('xunl:consent', { detail: { granted: true } }));如果网站在你的地区不需要征求同意,可以把脚本里的 data-xunl-consent 改成 granted。是否需要征求同意请按网站所在地和访客所在地的法规判断。
设置实验目标
每个实验设一个主要目标,开测前定好,报告的结论只看主要目标;其他目标作为参考。
| 目标 | 什么时候算转化 | 示例 |
|---|---|---|
| 页面 | 访客到达某个网址,例如感谢页 | https://www.ssevening.com/thank-you/ |
| 点击 | 点击某个元素,用 CSS 选择器指定 | #quote-button |
| 自定义事件 | 网页代码里调用 track 上报 | XunLExperiments.track('quote_started') |
| 成功询盘 | 服务器确认询盘已保存或发出,见下一节 | — |
统计成功询盘:服务端确认
非 WordPress 网站按下面三步统计成功询盘。只在询盘真正保存或发出后发送,按钮点击、校验失败和垃圾提交都不要发。
第一步:表单带上实验上下文
给脚本加上 data-xunl-forms="all"(也可以填 CSS 选择器,只处理询盘表单)。提交时表单会多一个隐藏字段 xunl_context。
<script defer src="https://www.xunl.net/js/xunl-experiments-sdk.js" data-xunl-key="SITE_PUBLIC_KEY" data-xunl-origin="https://www.xunl.net" data-xunl-consent="pending" data-xunl-forms="all"></script>用 fetch 或 AJAX 提交的表单,自己把上下文放进请求:
const body = new FormData(form);
const ctx = window.XunLExperiments && XunLExperiments.inquiryContext();
if (ctx) body.set('xunl_context', JSON.stringify(ctx));
await fetch('/api/inquiry', { method: 'POST', body });第二步:服务器保存询盘后签名发送
连接密钥在后台创建连接时只显示一次,保存到服务器环境变量 XUNL_CONNECTOR_SECRET。submissionId 用你自己的询盘编号,同一个询盘重发不会重复计数。
// Node 18+. Call after your server has saved or sent the inquiry.
const crypto = require('node:crypto');
const XUNL = 'https://www.xunl.net', KEY = 'SITE_PUBLIC_KEY', SECRET = process.env.XUNL_CONNECTOR_SECRET;
async function confirmInquiry(xunlContext, submissionId) {
let ctx; try { ctx = JSON.parse(xunlContext || 'null'); } catch { return false; }
if (!ctx || ctx.preview || !ctx.visitorId || !(ctx.assignments || []).length) return false;
const body = JSON.stringify({ eventId: crypto.randomUUID(), submissionId, visitorId: ctx.visitorId,
acceptedAt: new Date().toISOString(), assignments: ctx.assignments, status: 'accepted' });
const path = '/x/ab/v1/sites/' + KEY + '/conversions';
for (let attempt = 0; attempt < 3; attempt++) { // retries keep the body, sign again
const ts = String(Math.floor(Date.now() / 1000)), nonce = crypto.randomUUID();
const digest = crypto.createHash('sha256').update(body).digest('hex');
const sig = crypto.createHmac('sha256', SECRET).update(`v1\nPOST\n${path}\n${ts}\n${nonce}\n${digest}`).digest('hex');
const res = await fetch(XUNL + path, { method: 'POST', body, redirect: 'error', headers: { 'Content-Type': 'application/json',
'X-XunL-Timestamp': ts, 'X-XunL-Nonce': nonce, 'X-XunL-Signature': 'v1=' + sig } }).catch(() => null);
if (res && res.ok) return true;
if (res && res.status !== 409 && res.status < 500) return false;
await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
}
return false;
}
module.exports = { confirmInquiry };# Python 3.8+. Call after your server has saved or sent the inquiry.
import hashlib, hmac, json, os, time, uuid, urllib.error, urllib.request
from datetime import datetime, timezone
XUNL, KEY, SECRET = 'https://www.xunl.net', 'SITE_PUBLIC_KEY', os.environ['XUNL_CONNECTOR_SECRET']
def confirm_inquiry(xunl_context, submission_id):
try:
ctx = json.loads(xunl_context or 'null')
except ValueError:
return False
if not ctx or ctx.get('preview') or not ctx.get('visitorId') or not ctx.get('assignments'):
return False
body = json.dumps({'eventId': str(uuid.uuid4()), 'submissionId': submission_id, 'visitorId': ctx['visitorId'],
'acceptedAt': datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
'assignments': ctx['assignments'], 'status': 'accepted'}).encode()
path = f'/x/ab/v1/sites/{KEY}/conversions'
for attempt in range(3): # retries keep the body, sign again
ts, nonce = str(int(time.time())), str(uuid.uuid4())
canonical = f'v1\nPOST\n{path}\n{ts}\n{nonce}\n{hashlib.sha256(body).hexdigest()}'
sig = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
req = urllib.request.Request(XUNL + path, data=body, method='POST', headers={'Content-Type': 'application/json',
'X-XunL-Timestamp': ts, 'X-XunL-Nonce': nonce, 'X-XunL-Signature': 'v1=' + sig})
try:
with urllib.request.urlopen(req, timeout=10):
return True
except urllib.error.HTTPError as e:
if e.code != 409 and e.code < 500:
return False
except OSError:
pass
time.sleep(2 * (attempt + 1))
return False<?php // PHP 7.4+ with cURL. Call after your server has saved or sent the inquiry.
function xunl_uuid(): string { $b = random_bytes(16); $b[6] = chr(ord($b[6]) & 0x0f | 0x40); $b[8] = chr(ord($b[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); }
function xunl_confirm_inquiry(string $xunlContext, string $submissionId): bool {
$xunl = 'https://www.xunl.net'; $key = 'SITE_PUBLIC_KEY'; $secret = getenv('XUNL_CONNECTOR_SECRET');
$ctx = json_decode($xunlContext, true);
if (!is_array($ctx) || !empty($ctx['preview']) || empty($ctx['visitorId']) || empty($ctx['assignments'])) return false;
$body = json_encode(['eventId' => xunl_uuid(), 'submissionId' => $submissionId, 'visitorId' => $ctx['visitorId'],
'acceptedAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z'),
'assignments' => $ctx['assignments'], 'status' => 'accepted'], JSON_UNESCAPED_SLASHES);
$path = '/x/ab/v1/sites/' . $key . '/conversions';
for ($attempt = 0; $attempt < 3; $attempt++) { // retries keep the body, sign again
$ts = (string) time(); $nonce = xunl_uuid();
$sig = hash_hmac('sha256', "v1\nPOST\n$path\n$ts\n$nonce\n" . hash('sha256', $body), $secret);
$ch = curl_init($xunl . $path);
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', "X-XunL-Timestamp: $ts", "X-XunL-Nonce: $nonce", "X-XunL-Signature: v1=$sig"]]);
curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
if ($code >= 200 && $code < 300) return true;
if ($code > 0 && $code !== 409 && $code < 500) return false;
sleep(2 * ($attempt + 1));
}
return false;
}第三步:接口约定
- 地址
- POST https://www.xunl.net/x/ab/v1/sites/{SITE_PUBLIC_KEY}/conversions
- 请求头
- Content-Type: application/json · X-XunL-Timestamp · X-XunL-Nonce · X-XunL-Signature: v1=<hex>
- 签名原文
- v1\nPOST\n/x/ab/v1/sites/{key}/conversions\n{timestamp}\n{nonce}\n{sha256(body)}
- 签名算法
- HMAC-SHA256(connector secret, string to sign) → lowercase hex
- 请求体
- {eventId, submissionId, visitorId, acceptedAt, assignments, status: "accepted"}
- 时间与 nonce
- 时间戳为 Unix 秒,误差不超过 300 秒;nonce 是小写 UUID,每次请求都换新的。
- 返回
- 200 已接收或重复;401 签名或时间不对;409 浏览器分组尚未到达,稍后用同一个请求体重新签名发送;其他 4xx 不要重试。
WordPress 插件
- 在后台「A/B 实验 → 站点与安装」下载插件 ZIP,在 WordPress → 插件 → 安装插件 → 上传插件,然后启用。
- 在 WordPress → 设置 → XunL Experiments 填入 XunL 地址、站点公钥和连接密钥。
- 按“表单类型:表单 ID”勾选要统计的询盘表单,例如 cf7:123。没选的表单不会采集。
- 接入访客同意,然后在后台检查接入。插件已经包含 SDK,不要再手动加一份脚本。
检查接入是否成功
- 在网站上同意 Cookie 后,打开浏览器开发者工具的控制台,运行下面的命令,consentStatus 应为 granted。
- 回到后台点「检查接入」,看接收事件和访客分配是否增加。
- 用无痕窗口多打开几次实验页面,确认原版和新版本都能正常显示和提交。
XunLExperiments.diagnostics()
// { version, consentStatus, visitorId, assignments, pendingEvents, lastError, ... }常见问题
不是 WordPress 网站能用吗?
能。把 JS SDK 脚本放进网站公共 head 即可运行可视化实验和页面分流,统计页面、点击和自定义事件目标。成功询盘由你自己的服务器签名确认后发给 XunL,WordPress 插件只是把这一步做成了现成的。
为什么询盘转化要服务器确认?
浏览器能看到的只是“点了提交”,表单校验失败、垃圾提交和重复提交都会混进来。由服务器在询盘真正保存或发出后签名确认,报告里的询盘数才和销售收到的一致。
会不会让页面变慢或闪一下?
脚本异步加载,读取实验配置失败或超时会保留原页面。可视化改动在配置返回后才应用,所以脚本放得越靠前、改动越小,闪烁越少;整页改版建议用页面分流,直接跳到新页面。
访客没同意 Cookie 时会记录吗?
不会。默认等待同意:在调用 XunLExperiments.consent(true) 或触发 xunl:consent 事件之前,SDK 不读写浏览器存储、不生成访客身份、不发送任何实验事件。访客撤回同意时会清除本地记录。
单页应用(React、Vue)路由切换后怎么办?
浏览器前进后退会自动重新匹配。用 history.pushState 切换路由后,调用一次 XunLExperiments.refresh(),SDK 会按新网址重新判断哪些实验生效。