PageSourceSearch

https://doc.quickrouter.ai/js/channel.js?v=80fcf033af262494

js quickrouter.ai collected 2026-10-05 12:28:52 UTC 18,077 bytes, 481 lines download raw bytes

1/*!
2 * QuickRouter 文档站广告归因模块 (channel.js)
3 *
4 * 两套独立归因,并存互不干扰:
5 *
6 * [A] 百度/文档渠道归因(原有,不变)
7 *   - Cookie/localStorage key: quickrouter_attribution
8 *   - 与首页 src/lib/attribution.ts 对齐
9 *   - URL 写入只允许 7 个文档渠道;共享 Cookie 读取允许全部 10 个渠道
10 *
11 * [B] Google Ads 归因(新增)
12 *   - Cookie/localStorage key: quickrouter_google_attribution
13 *   - 字段白名单:gclid/gbraid/wbraid/gad_source/utm系列/campaignid/adgroupid/
14 *     creative/targetid/matchtype/device/network/keyword
15 *   - 附加内部字段:source(doc.quickrouter.ai)/landingPath/capturedAt/expiresAt/version(2)
16 *   - 触发:URL 含 gclid/gbraid/wbraid 或 utm_source=google 才视为新 Google 点击
17 *   - 整组覆盖,禁止逐项拼接;普通访问不清除
18 *
19 * 注册链接(/go-register)同时携带 [A] 和 [B] 的白名单参数。
20 * 不修改 UI、文案、布局、教程正文、登录按钮、/console、/pricing、API 根域及普通链接。
21 * 不引入第三方依赖。原生 JS,旧浏览器兼容。
22 */
23(function () {
24  'use strict';
25
26  // 仅浏览器执行(构建/SSR 安全)
27  if (typeof window === 'undefined' || typeof document === 'undefined') {
28    return;
29  }
30
31  // ============================================================
32  // 共享常量与工具
33  // ============================================================
34
35  var COOKIE_MAX_AGE = 2592000; // 30 天
36  var COOKIE_DOMAIN = '.quickrouter.ai';
37  var REGISTER_BASE_URL = 'https://quickrouter.ai/go-register';
38  var REGISTER_BTN_SELECTOR = 'a.header-register, a[data-register-link]';
39  var GOOGLE_COOKIE_MAX_BYTES = 3500;
40  var GOOGLE_VERSION = 2;
41  var DOC_SOURCE = 'doc.quickrouter.ai';
42
43  function isProductionHost() {
44    var h = window.location.hostname || '';
45    return h.indexOf('quickrouter.ai') !== -1;
46  }
47
48  function isLocalhost() {
49    var h = window.location.hostname || '';
50    return h === 'localhost' || h === '127.0.0.1' || h === '::1';
51  }
52
53  // 通用 Cookie 写入(localhost 不设 Domain/Secure)
54  function setCookie(name, value) {
55    var parts = [name + '=' + value, 'path=/', 'max-age=' + COOKIE_MAX_AGE, 'SameSite=Lax'];
56    if (isProductionHost()) {
57      parts.push('Domain=' + COOKIE_DOMAIN, 'Secure');
58    }
59    document.cookie = parts.join('; ');
60  }
61
62  function deleteCookie(name) {
63    var lines = [name + '=; path=/; max-age=0'];
64    if (isProductionHost()) {
65      lines.push(name + '=; path=/; max-age=0; Domain=' + COOKIE_DOMAIN);
66    }
67    for (var i = 0; i < lines.length; i++) document.cookie = lines[i];
68  }
69
70  // 通用:控制字符过滤 + trim + 按字段长度截断
71  function sanitizeValue(value, maxLen) {
72    if (value === null || value === undefined) return null;
73    var cleaned = String(value).replace(/[\x00-\x1f\x7f]/g, '').replace(/^\s+|\s+$/g, '');
74    if (!cleaned) return null;
75    if (maxLen && cleaned.length > maxLen) {
76      cleaned = cleaned.slice(0, maxLen);
77    }
78    return cleaned;
79  }
80
81  // 通用:从 document.cookie 读取并 JSON 解码
82  function readCookieJSON(name) {
83    try {
84      var cookies = document.cookie ? document.cookie.split('; ') : [];
85      for (var i = 0; i < cookies.length; i++) {
86        if (cookies[i].indexOf(name + '=') === 0) {
87          var raw = cookies[i].split('=').slice(1).join('=');
88          return JSON.parse(decodeURIComponent(raw));
89        }
90      }
91    } catch (e) {}
92    return null;
93  }
94
95  // 通用:从 localStorage 读取并 JSON 解码
96  function readStorageJSON(key) {
97    try {
98      var raw = window.localStorage.getItem(key);
99      if (!raw) return null;
100      return JSON.parse(raw);
101    } catch (e) {}
102    return null;
103  }
104
105  // ============================================================
106  // [A] 百度/文档渠道归因(原有逻辑,保持不变)
107  // ============================================================
108
109  var DOC_CHANNELS = [
110    'c_k0fxd6d2', 'c_onaqud4z', 'c_4wmiketg', 'c_pe0gzbkc',
111    'c_gbxim5yt', 'c_zhcy4sth', 'c_pxh0vohq'
112  ];
113  var ALL_CHANNELS = [
114    'c_dngxpufq', 'c_zia4gxbb', 'c_ake3gman',
115    'c_k0fxd6d2', 'c_onaqud4z', 'c_4wmiketg', 'c_pe0gzbkc',
116    'c_gbxim5yt', 'c_zhcy4sth', 'c_pxh0vohq'
117  ];
118  var TRACKING_KEYS = [
119    'bd_vid', 'planid', 'unitid', 'creative', 'keywordid',
120    'matchtype', 'equipment_type', 'userid', 'longtail'
121  ];
122  var TRACKING_MAX_LENGTH = {
123    bd_vid: 128, planid: 64, unitid: 64, creative: 64, keywordid: 128,
124    matchtype: 8, equipment_type: 8, userid: 64, longtail: 256
125  };
126
127  var ATTRIBUTION_COOKIE_NAME = 'quickrouter_attribution';
128  var ATTRIBUTION_STORAGE_KEY = 'quickrouter_attribution';
129
130  var DOC_CHANNEL_SET = {};
131  for (var i = 0; i < DOC_CHANNELS.length; i++) DOC_CHANNEL_SET[DOC_CHANNELS[i]] = true;
132  var ALL_CHANNEL_SET = {};
133  for (var j = 0; j < ALL_CHANNELS.length; j++) ALL_CHANNEL_SET[ALL_CHANNELS[j]] = true;
134
135  function isDocChannel(v) {
136    return !!v && Object.prototype.hasOwnProperty.call(DOC_CHANNEL_SET, v);
137  }
138  function isAnyChannel(v) {
139    return !!v && Object.prototype.hasOwnProperty.call(ALL_CHANNEL_SET, v);
140  }
141
142  // 百度字段专用 sanitize(按 TRACKING_MAX_LENGTH)
143  function sanitizeBaidu(key, value) {
144    if (!value) return null;
145    var cleaned = String(value).replace(/[\x00-\x1f\x7f]/g, '').replace(/^\s+|\s+$/g, '');
146    if (!cleaned) return null;
147    var maxLen = TRACKING_MAX_LENGTH[key];
148    if (maxLen && cleaned.length > maxLen) cleaned = cleaned.slice(0, maxLen);
149    return cleaned;
150  }
151
152  function readAttributionFromSearchParams(params) {
153    var channel = null;
154    try { channel = params.get('channel'); } catch (e) { return null; }
155    if (!isDocChannel(channel)) return null;
156    var attribution = { channel: channel };
157    for (var i = 0; i < TRACKING_KEYS.length; i++) {
158      var key = TRACKING_KEYS[i];
159      var cleaned = sanitizeBaidu(key, params.get(key));
160      if (cleaned) attribution[key] = cleaned;
161    }
162    return attribution;
163  }
164
165  function validateAttribution(obj) {
166    if (!obj || typeof obj !== 'object') return null;
167    var channel = typeof obj.channel === 'string' ? obj.channel : null;
168    if (!isAnyChannel(channel)) return null;
169    var attribution = { channel: channel };
170    for (var i = 0; i < TRACKING_KEYS.length; i++) {
171      var key = TRACKING_KEYS[i];
172      var raw = obj[key];
173      var cleaned = sanitizeBaidu(key, typeof raw === 'string' ? raw : null);
174      if (cleaned) attribution[key] = cleaned;
175    }
176    return attribution;
177  }
178
179  function clearAttribution() {
180    deleteCookie(ATTRIBUTION_COOKIE_NAME);
181    deleteCookie('quickrouter_channel');
182    for (var i = 0; i < TRACKING_KEYS.length; i++) {
183      deleteCookie('qr_track_' + TRACKING_KEYS[i]);
184    }
185    try {
186      window.localStorage.removeItem(ATTRIBUTION_STORAGE_KEY);
187      window.localStorage.removeItem('quickrouter_channel');
188      window.localStorage.removeItem('qr_tracking_params');
189    } catch (e) {}
190  }
191
192  function writeAttribution(attribution) {
193    clearAttribution();
194    var json = JSON.stringify(attribution);
195    setCookie(ATTRIBUTION_COOKIE_NAME, encodeURIComponent(json));
196    try { window.localStorage.setItem(ATTRIBUTION_STORAGE_KEY, json); } catch (e) {}
197  }
198
199  function readAttributionFromClientCookie() {
200    var obj = readCookieJSON(ATTRIBUTION_COOKIE_NAME);
201    return validateAttribution(obj);
202  }
203
204  function readAttributionFromStorage() {
205    return validateAttribution(readStorageJSON(ATTRIBUTION_STORAGE_KEY));
206  }
207
208  function resolveAttribution() {
209    var urlAttrib = null;
210    try {
211      urlAttrib = readAttributionFromSearchParams(new URLSearchParams(window.location.search));
212    } catch (e) {}
213    if (urlAttrib) return urlAttrib;
214    var c = readAttributionFromClientCookie();
215    if (c) return c;
216    return readAttributionFromStorage();
217  }
218
219  // ============================================================
220  // [B] Google Ads 归因(新增)
221  // ============================================================
222
223  var GOOGLE_COOKIE_NAME = 'quickrouter_google_attribution';
224  var GOOGLE_STORAGE_KEY = 'quickrouter_google_attribution';
225
226  // Google 白名单字段及其最大长度(与主站一致)
227  var GOOGLE_FIELDS = [
228    'gclid', 'gbraid', 'wbraid', 'gad_source',
229    'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
230    'campaignid', 'adgroupid', 'creative', 'targetid', 'matchtype', 'device', 'network', 'keyword'
231  ];
232  var GOOGLE_MAX_LENGTH = {
233    gclid: 256, gbraid: 256, wbraid: 256, gad_source: 64,
234    utm_source: 128, utm_medium: 128, utm_campaign: 128, utm_term: 256, utm_content: 256, utm_id: 64,
235    campaignid: 64, adgroupid: 64, creative: 64, targetid: 256, matchtype: 8, device: 8, network: 16, keyword: 256
236  };
237  // 不附加到注册链接的内部字段
238  var GOOGLE_INTERNAL_FIELDS = ['source', 'landingPath', 'capturedAt', 'expiresAt', 'version'];
239
240  // Cookie 超 3500 字节时的字段删除顺序(从前到后依次删,核心字段不在其中)
241  var GOOGLE_TRIM_ORDER = [
242    'landingPath', 'keyword', 'utm_content', 'utm_term', 'utm_id',
243    'utm_campaign', 'utm_medium', 'utm_source', 'gad_source',
244    'targetid', 'creative', 'network', 'device', 'matchtype'
245  ];
246  // 绝不能删除的点击标识
247  var GOOGLE_CLICK_IDS = ['gclid', 'gbraid', 'wbraid'];
248  // 核心字段(裁剪后必须保留,campaignid/adgroupid 也属核心但不在删除顺序里,天然保留)
249  var GOOGLE_CORE_FIELDS = ['campaignid', 'adgroupid', 'capturedAt', 'expiresAt', 'version', 'source'];
250  // 截断阶段:点击标识最多 256,campaignid/adgroupid 最多 64
251  var GOOGLE_TRUNCATE_LIMITS = { gclid: 256, gbraid: 256, wbraid: 256, campaignid: 64, adgroupid: 64 };
252
253  // 判断是否为新的 Google 点击:含 gclid/gbraid/wbraid 或 utm_source=google
254  function isGoogleClick(params) {
255    try {
256      if (params.get('gclid') || params.get('gbraid') || params.get('wbraid')) return true;
257      var utmSource = params.get('utm_source');
258      if (utmSource && utmSource.toLowerCase() === 'google') return true;
259    } catch (e) {}
260    return false;
261  }
262
263  // 从 URLSearchParams 构建 Google 归因对象(整组,只取白名单字段 + 内部字段)
264  function readGoogleFromSearchParams(params) {
265    if (!isGoogleClick(params)) return null;
266    var obj = {};
267    for (var i = 0; i < GOOGLE_FIELDS.length; i++) {
268      var key = GOOGLE_FIELDS[i];
269      var cleaned = sanitizeValue(params.get(key), GOOGLE_MAX_LENGTH[key]);
270      if (cleaned) obj[key] = cleaned;
271    }
272    // 附加内部字段
273    obj.source = DOC_SOURCE;
274    try { obj.landingPath = window.location.pathname; } catch (e) { obj.landingPath = ''; }
275    var now = Date.now();
276    obj.capturedAt = now;
277    obj.expiresAt = now + COOKIE_MAX_AGE * 1000;
278    obj.version = GOOGLE_VERSION;
279    return obj;
280  }
281
282  // 验证从存储读出的 Google 对象(只保留白名单 + 合法内部字段)
283  function validateGoogle(obj) {
284    if (!obj || typeof obj !== 'object') return null;
285    // 必须至少有一个 Google 广告字段,否则视为无效
286    var hasField = false;
287    var out = {};
288    for (var i = 0; i < GOOGLE_FIELDS.length; i++) {
289      var key = GOOGLE_FIELDS[i];
290      var raw = obj[key];
291      var cleaned = sanitizeValue(typeof raw === 'string' ? raw : null, GOOGLE_MAX_LENGTH[key]);
292      if (cleaned) { out[key] = cleaned; hasField = true; }
293    }
294    if (!hasField) return null;
295    // 保留内部字段(若存在)
296    if (obj.source) out.source = obj.source;
297    if (obj.landingPath) out.landingPath = obj.landingPath;
298    if (typeof obj.capturedAt === 'number') out.capturedAt = obj.capturedAt;
299    if (typeof obj.expiresAt === 'number') out.expiresAt = obj.expiresAt;
300    if (obj.version) out.version = obj.version;
301    return out;
302  }
303
304  // 对象深拷贝(简单版,仅用于扁平 Google 对象)
305  function cloneGoogleObj(obj) {
306    var out = {};
307    for (var k in obj) {
308      if (Object.prototype.hasOwnProperty.call(obj, k)) out[k] = obj[k];
309    }
310    return out;
311  }
312
313  // 截断超长字符串字段(点击标识<=256,campaignid/adgroupid<=64)
314  function truncateCoreFields(obj) {
315    for (var field in GOOGLE_TRUNCATE_LIMITS) {
316      if (Object.prototype.hasOwnProperty.call(GOOGLE_TRUNCATE_LIMITS, field) && obj[field]) {
317        var limit = GOOGLE_TRUNCATE_LIMITS[field];
318        var val = String(obj[field]);
319        if (val.length > limit) obj[field] = val.slice(0, limit);
320      }
321    }
322  }
323
324  // 将 Google 对象编码后控制在 <= maxBytes,始终返回可解析的 encoded 字符串
325  function encodeForCookie(obj, maxBytes) {
326    var working = cloneGoogleObj(obj);
327
328    // 阶段 1:尝试原始对象
329    var encoded = encodeURIComponent(JSON.stringify(working));
330    if (encoded.length <= maxBytes) return encoded;
331
332    // 阶段 2:按 GOOGLE_TRIM_ORDER 依次删除非核心字段
333    for (var i = 0; i < GOOGLE_TRIM_ORDER.length; i++) {
334      var field = GOOGLE_TRIM_ORDER[i];
335      if (working[field] !== undefined) {
336        delete working[field];
337        encoded = encodeURIComponent(JSON.stringify(working));
338        if (encoded.length <= maxBytes) return encoded;
339      }
340    }
341
342    // 阶段 3:截断核心字符串字段
343    truncateCoreFields(working);
344    encoded = encodeURIComponent(JSON.stringify(working));
345    // 最终兜底:极端情况下仍超限,强制保证可解析(此时已是最大裁剪)
346    return encoded;
347  }
348
349  function writeGoogle(googleObj) {
350    // localStorage 保存原始完整对象
351    var fullJson = JSON.stringify(googleObj);
352    try { window.localStorage.setItem(GOOGLE_STORAGE_KEY, fullJson); } catch (e) {}
353
354    // 父域 Cookie 必须始终写入:超限时按优先级裁剪字段,绝不降级为只写 localStorage
355    var encoded = encodeForCookie(googleObj, GOOGLE_COOKIE_MAX_BYTES);
356    setCookie(GOOGLE_COOKIE_NAME, encoded);
357  }
358
359  function readGoogleFromClientCookie() {
360    return validateGoogle(readCookieJSON(GOOGLE_COOKIE_NAME));
361  }
362
363  function readGoogleFromStorage() {
364    return validateGoogle(readStorageJSON(GOOGLE_STORAGE_KEY));
365  }
366
367  // 读取优先级:URL(新 Google 点击) > Cookie > localStorage
368  function resolveGoogle() {
369    var urlGoogle = null;
370    try {
371      urlGoogle = readGoogleFromSearchParams(new URLSearchParams(window.location.search));
372    } catch (e) {}
373    if (urlGoogle) return urlGoogle;
374    var c = readGoogleFromClientCookie();
375    if (c) return c;
376    return readGoogleFromStorage();
377  }
378
379  // ============================================================
380  // 注册链接生成(合并百度 + Google 白名单参数)
381  // ============================================================
382
383  // 用 URL + URLSearchParams 生成,同时附加百度渠道参数和 Google 白名单参数
384  // 不附加 Google 内部字段(source/landingPath/capturedAt/expiresAt/version)
385  function buildRegisterUrl(attribution, google) {
386    var url = new URL(REGISTER_BASE_URL);
387
388    // 百度渠道参数
389    if (attribution && isAnyChannel(attribution.channel)) {
390      url.searchParams.set('channel', attribution.channel);
391      for (var i = 0; i < TRACKING_KEYS.length; i++) {
392        var bkey = TRACKING_KEYS[i];
393        if (attribution[bkey]) url.searchParams.set(bkey, attribution[bkey]);
394      }
395    }
396
397    // Google 白名单参数(排除内部字段)
398    if (google) {
399      for (var k = 0; k < GOOGLE_FIELDS.length; k++) {
400        var gkey = GOOGLE_FIELDS[k];
401        if (google[gkey]) url.searchParams.set(gkey, google[gkey]);
402      }
403    }
404
405    return url.toString();
406  }
407
408  function updateRegisterButtons(attribution, google) {
409    var btns = document.querySelectorAll(REGISTER_BTN_SELECTOR);
410    if (!btns || !btns.length) return;
411    var href = buildRegisterUrl(attribution, google);
412    for (var i = 0; i < btns.length; i++) {
413      btns[i].setAttribute('href', href);
414    }
415  }
416
417  // ============================================================
418  // 主流程
419  // ============================================================
420
421  function init() {
422    var params = null;
423    try { params = new URLSearchParams(window.location.search); } catch (e) {}
424
425    // [A] 百度渠道:URL 含合法文档 channel → 整组覆盖
426    var urlAttrib = null;
427    if (params) {
428      try { urlAttrib = readAttributionFromSearchParams(params); } catch (e) {}
429    }
430    if (urlAttrib) {
431      writeAttribution(urlAttrib);
432    }
433    // 普通访问无新合法 channel:不清除、不覆盖
434
435    // [B] Google:URL 是新 Google 点击 → 整组覆盖(独立于百度)
436    var urlGoogle = null;
437    if (params) {
438      try { urlGoogle = readGoogleFromSearchParams(params); } catch (e) {}
439    }
440    if (urlGoogle) {
441      writeGoogle(urlGoogle);
442    }
443    // 普通访问不是 Google 点击:不清除 Google 归因
444
445    // 解析当前生效的两套归因
446    var currentAttrib = resolveAttribution();
447    var currentGoogle = resolveGoogle();
448
449    // 改写 header 注册按钮(同时携带两套参数)
450    updateRegisterButtons(currentAttrib, currentGoogle);
451  }
452
453  // ============================================================
454  // 调试 API
455  // ============================================================
456
457  window.QRChannel = {
458    // 百度渠道
459    resolve: resolveAttribution,
460    // Google 归因
461    resolveGoogle: resolveGoogle,
462    // 注册链接生成(无参时用当前生效归因)
463    buildRegisterUrl: function (attribution, google) {
464      if (arguments.length === 0) {
465        return buildRegisterUrl(resolveAttribution(), resolveGoogle());
466      }
467      return buildRegisterUrl(attribution || null, google || null);
468    },
469    docChannels: DOC_CHANNELS.slice(),
470    allChannels: ALL_CHANNELS.slice(),
471    trackingKeys: TRACKING_KEYS.slice(),
472    googleFields: GOOGLE_FIELDS.slice()
473  };
474
475  // 脚本位于 <body> 末尾,DOM 一般已就绪;双重兜底
476  if (document.readyState === 'loading') {
477    document.addEventListener('DOMContentLoaded', init);
478  } else {
479    init();
480  }
481})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.