feat(sdk): business.ts——咨询点击/CF7状态机/产品页自动监听
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import type { Transport } from "./transport";
|
||||
import { pageContext } from "./context";
|
||||
import { uuid } from "./util";
|
||||
import { getPageSession, onSegmentChange } from "./page";
|
||||
|
||||
// 业务事件自动监听(spec §1.1-§1.3 / §1.5,阶段B):咨询点击(contact_click)、
|
||||
// CF7 表单全过程(form_start / form_submit / form_error / form_submit_result +
|
||||
// _ctk_sid 隐藏字段)、产品页推导(product_view)。DOM 监听全部 document 级
|
||||
// capture 委托——submit capture 先于 CF7 在 form 冒泡阶段的拦截执行,_ctk_sid
|
||||
// 写入先于 FormData 构造(§1.5 时序确定性)。
|
||||
// 事件结构 = events 形态 + 当前段 attribution() 顶层展开(同 stay.ts 模式);
|
||||
// 归属在 emit 时读取 getPageSession()——段切换(onSegmentChange 在新段创建后
|
||||
// 触发)后的事件自然归新段。红线:任何回调异常 try/catch 静默(spec §4.5)。
|
||||
|
||||
/** 产品页段匹配(spec §1.3,非锚定):路径段数组含 "product" 且其后一段存在且
|
||||
* 非空 → 该段为 slug(截断 ≤100)。非锚定覆盖 /{lang}/product/{slug}/ 多语言
|
||||
* 前缀(实测 9 语言;锚定会静默丢失约半数产品页)。slug 须过服务端 _PROD_RE
|
||||
* 同构格式(首字符字母数字、[A-Za-z0-9._-]≤99)——百分号编码等非约定 slug
|
||||
* 服务端必拒,客户端即不发(省无谓 reject 噪声)。 */
|
||||
const PROD_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
function matchProduct(path: string): string | null {
|
||||
const segs = path.split("/").filter(Boolean);
|
||||
const i = segs.indexOf("product");
|
||||
if (i >= 0 && i + 1 < segs.length && segs[i + 1]) {
|
||||
const slug = segs[i + 1].slice(0, 100);
|
||||
return PROD_SLUG_RE.test(slug) ? slug : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** CF7 unit-tag → form_id(spec §1.2 规整):action/id 内
|
||||
* /(?:^|[-#])wpcf7-f(\d{1,10})(?!\d)/ → cf7-{ID},不暴露完整内部串;
|
||||
* (?!\d) 防 11+ 位被截断匹配(服务端 cf7-N 上限 10 位,超限按解析失败归 cf7-0)。 */
|
||||
function cf7FormId(form: HTMLFormElement): string {
|
||||
try {
|
||||
const sources = [form.getAttribute("action") || "", form.id || ""];
|
||||
for (const src of sources) {
|
||||
const m = /(?:^|[-#])wpcf7-f(\d{1,10})(?!\d)/.exec(src);
|
||||
if (m) return `cf7-${m[1]}`;
|
||||
}
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
return "cf7-0";
|
||||
}
|
||||
|
||||
export function startBusinessTracking(t: Transport): void {
|
||||
// 事件入队:emit 时读取当前段(未启动 PageSession 的异常接线顺序无从归属,不发);
|
||||
// pageContext() 单次取用复用于 path/title(同一事件内字段一致)
|
||||
const emit = (event_name: string, properties: Record<string, unknown>): void => {
|
||||
try {
|
||||
const ps = getPageSession();
|
||||
if (!ps) return;
|
||||
const pc = pageContext();
|
||||
const a = ps.attribution();
|
||||
t.push({
|
||||
event_id: uuid(),
|
||||
event_name,
|
||||
occurred_at: new Date().toISOString(),
|
||||
path: pc.path,
|
||||
title: pc.title,
|
||||
properties,
|
||||
site_id: a.site_id,
|
||||
visitor_id: a.visitor_id,
|
||||
session_id: a.session_id,
|
||||
page_id: a.page_id,
|
||||
seq: a.seq,
|
||||
// V1.1 预留字段从快照展开(非字面量)——将来 attribution() 填真实值时自动携带
|
||||
purpose: a.purpose,
|
||||
epoch: a.epoch,
|
||||
grant_ref: a.grant_ref,
|
||||
});
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 1) contact_click(spec §1.1):链接协议前缀判定,命中即 beacon flush ----
|
||||
const WHATSAPP_HTTP = /^https?:\/\/(?:wa\.me|api\.whatsapp\.com)\//i;
|
||||
const WHATSAPP_SCHEME = /^whatsapp:\/\//i;
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
try {
|
||||
const link = (e.target as Element | null)?.closest?.("a[href]");
|
||||
if (!link) return;
|
||||
const href = link.getAttribute("href") || "";
|
||||
let channel: string | null = null;
|
||||
if (WHATSAPP_HTTP.test(href) || WHATSAPP_SCHEME.test(href)) channel = "whatsapp";
|
||||
else if (/^tel:/i.test(href)) channel = "phone";
|
||||
else if (/^mailto:/i.test(href)) channel = "email";
|
||||
if (!channel) return;
|
||||
emit("contact_click", { channel });
|
||||
t.flush(true); // 跳转窗口内保证发出(沿 v1 卸载 beacon 路径)
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
// ---- 2) form_*(spec §1.2 / §1.5):CF7 全过程状态机 ----
|
||||
const started = new WeakSet<HTMLFormElement>(); // form_start 去重(每表单实例一次)
|
||||
const lastSubmission = new WeakMap<HTMLFormElement, string>(); // error/result 引用的最近 submission_id
|
||||
|
||||
/** e.target 所在的 CF7 表单(class 含 wpcf7-form),非 CF7 返回 null */
|
||||
const cf7Form = (target: EventTarget | null): HTMLFormElement | null => {
|
||||
try {
|
||||
const f = (target as Element | null)?.closest?.("form") as HTMLFormElement | null;
|
||||
if (f && f.classList && f.classList.contains("wpcf7-form")) return f;
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** _ctk_sid 隐藏字段:找已有(宿主/先前注入)否则新建空占位(§1.5:提交前
|
||||
* 字段恒存在,值由 submit 捕获填充)。DOM API 构造——不碰 innerHTML。 */
|
||||
const ensureCtkSid = (form: HTMLFormElement): HTMLInputElement => {
|
||||
const existing = form.querySelector<HTMLInputElement>('input[name="_ctk_sid"]');
|
||||
if (existing) return existing;
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "hidden";
|
||||
inp.name = "_ctk_sid";
|
||||
form.appendChild(inp);
|
||||
return inp;
|
||||
};
|
||||
|
||||
// form_start:.wpcf7-form 内首次 isTrusted focusin/input(合成事件不构成真实
|
||||
// "用户开始");WeakSet 保证每表单实例只发一次
|
||||
const onStart = (e: Event) => {
|
||||
try {
|
||||
if (!e.isTrusted) return;
|
||||
const form = cf7Form(e.target);
|
||||
if (!form || started.has(form)) return;
|
||||
started.add(form);
|
||||
ensureCtkSid(form);
|
||||
emit("form_start", { form_id: cf7FormId(form) });
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
};
|
||||
document.addEventListener("focusin", onStart, { capture: true });
|
||||
document.addEventListener("input", onStart, { capture: true });
|
||||
|
||||
// form_submit:原生 submit 的 document capture 委托——先于 CF7 冒泡阶段拦截
|
||||
// (preventDefault + FormData 构造),_ctk_sid 写入确定性(§1.5);每次尝试
|
||||
// 独立 submission_id
|
||||
document.addEventListener(
|
||||
"submit",
|
||||
(e) => {
|
||||
try {
|
||||
const form = cf7Form(e.target);
|
||||
if (!form) return;
|
||||
const sid = uuid();
|
||||
lastSubmission.set(form, sid);
|
||||
ensureCtkSid(form).value = sid;
|
||||
emit("form_submit", { form_id: cf7FormId(form), submission_id: sid });
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
// wpcf7statuschanged(CF7 6.x 状态机,bubbles:true):终态映射——
|
||||
// invalid/unaccepted → form_error("invalid");spam → form_error("spam");
|
||||
// mail_sent/mail_failed → form_submit_result。init/validating/sending/resetting
|
||||
// 等中间态不产生事件。submission_id 引用本表单最近一次 submit(无则空串,
|
||||
// 服务端 _SUBMIT_RE 允许空)
|
||||
document.addEventListener("wpcf7statuschanged", (e) => {
|
||||
try {
|
||||
const form = cf7Form(e.target);
|
||||
if (!form) return;
|
||||
const status = String((e as CustomEvent).detail?.status ?? "");
|
||||
const form_id = cf7FormId(form);
|
||||
const submission_id = lastSubmission.get(form) ?? "";
|
||||
if (status === "invalid" || status === "unaccepted" || status === "spam") {
|
||||
emit("form_error", {
|
||||
form_id,
|
||||
submission_id,
|
||||
error_code: status === "spam" ? "spam" : "invalid",
|
||||
// 上限 99:服务端 field_count 契约 0-99,病态多字段表单钳制而非必拒
|
||||
field_count: Math.min(99, form.querySelectorAll(".wpcf7-not-valid-tip").length),
|
||||
});
|
||||
} else if (status === "mail_sent" || status === "mail_failed") {
|
||||
emit("form_submit_result", { form_id, submission_id, result_code: status });
|
||||
}
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
}, { capture: true });
|
||||
|
||||
// ---- 3) product_view(spec §1.3):init 同步判定 + SPA 切段回调 ----
|
||||
const emitProduct = () => {
|
||||
try {
|
||||
const slug = matchProduct(location.pathname);
|
||||
if (slug) emit("product_view", { product_id: slug, inferred: true, rule_version: "v1" });
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
};
|
||||
emitProduct(); // init 时对当前段同步判定
|
||||
onSegmentChange(() => emitProduct()); // SPA 切段后新段判定(触发点在 onRouteChange 新段创建后)
|
||||
}
|
||||
@@ -192,6 +192,22 @@ export function getPageSession(): PageSession | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
// ---- 段切换监听(spec §1.3):触发点钉死在 onRouteChange 新段创建之后——
|
||||
// 不在 close() 内触发(close 还覆盖 pagehide/重复 init,放在那里会在卸载时
|
||||
// 重复发 product_view)。模块级注册表:跨 startPageSession 存留;单个回调
|
||||
// 异常静默且不阻断后续回调(红线 spec §4.5)。 ----
|
||||
const segmentListeners = new Set<(ps: PageSession) => void>();
|
||||
|
||||
/** 注册 SPA 段切换回调(business.ts T4 消费:新段 path 判定 product_view 等)。
|
||||
* 初始段不触发——init 时的同步判定由消费方自理(startPageSession 不回调)。 */
|
||||
export function onSegmentChange(fn: (ps: PageSession) => void): void {
|
||||
try {
|
||||
segmentListeners.add(fn);
|
||||
} catch {
|
||||
/* 静默(spec §4.5):fn 不可哈希等异常环境——降级无该监听 */
|
||||
}
|
||||
}
|
||||
|
||||
export function startPageSession(t: Transport): PageSession {
|
||||
activeTransport = t;
|
||||
current?.close(); // 重复 init:先收掉旧段(幂等)
|
||||
@@ -212,6 +228,16 @@ function onRouteChange(): void {
|
||||
current?.close();
|
||||
current = new PageSession(activeTransport);
|
||||
current.emitPageView();
|
||||
// 段切换回调(spec §1.3):新段创建 + PV 之后触发;本地引用防回调内重入切段
|
||||
// 拿错段;逐个 try/catch——单个回调异常不冒泡不阻断后续(红线 spec §4.5)。
|
||||
const ps = current;
|
||||
for (const fn of segmentListeners) {
|
||||
try {
|
||||
fn(ps);
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user