@analitikisler/ai-tag-debugger 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.8 (2026-10-14)
4
+
5
+ - Unnamed TikTok pings are left out of the duplicate check. TikTok sends them on its own timing, so their count changed between a save and its replay without the site changing.
6
+ - A replay skips a malformed address in a recording instead of stopping, and reads the recording once.
7
+
8
+ ## 0.7.7 (2026-10-14)
9
+
10
+ - A replay answers a request from the recording when the address only differs by a timestamp (`_=1791977697130`, `ts`, `timestamp`, `cb`, `nocache`). Magento sites reload the basket this way after "add to basket", so the replay never got the answer that makes the page send `add_to_cart`. Exact matches still come from the recording as before.
11
+ - A click found without a saved path no longer keeps its text when it looks like an email address or phone number.
12
+
3
13
  ## 0.7.6 (2026-10-14)
4
14
 
5
15
  - `replay` says when a saved click was found by its text rather than its saved place (`~` lines), since that may not be the element that was saved.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/browser.js CHANGED
@@ -201,6 +201,41 @@ function trackingBody(request) {
201
201
  * How a scripted run can find the element an action used again: the selector it was given,
202
202
  * else a CSS path to the element, else its visible text. Refs only mean something in one run.
203
203
  */
204
+ /** Query parameters that only carry the time or a cache-buster, so they differ between a recording and its replay. */
205
+ const STAMP_PARAMS = /^(_|_t|ts|timestamp|cb|cachebust|nocache)$/i;
206
+ /** The address without its timestamp parameters. */
207
+ function unstamped(href) {
208
+ let url;
209
+ try { url = new URL(href); } catch { return href; }
210
+ for (const name of [...url.searchParams.keys()]) if (STAMP_PARAMS.test(name)) url.searchParams.delete(name);
211
+ return url.href;
212
+ }
213
+ /**
214
+ * The GET answers in a recording whose address carries a timestamp, by address without it.
215
+ * Entries the recording has no body for (still loading when the page was left) are not included.
216
+ */
217
+ function restampedAnswers(har) {
218
+ const answers = new Map();
219
+ answers.exact = new Set();
220
+ for (const e of har.log.entries) {
221
+ const { request, response } = e;
222
+ answers.exact.add(request.url);
223
+ if (request.method !== "GET" || response.status < 200 || response.status >= 300 || typeof response.content?.text !== "string") continue;
224
+ const key = unstamped(request.url);
225
+ if (key === request.url) continue;
226
+ const headers = {};
227
+ for (const h of response.headers ?? []) {
228
+ const name = h.name.toLowerCase();
229
+ if (["content-encoding", "content-length", "transfer-encoding"].includes(name)) continue;
230
+ headers[name] = name === "set-cookie" && headers[name] ? `${headers[name]}\n${h.value}` : h.value;
231
+ }
232
+ const body = response.content.encoding === "base64" ? Buffer.from(response.content.text, "base64") : response.content.text;
233
+ const entry = answers.get(key) ?? { queue: [] };
234
+ entry.queue.push({ status: response.status, headers, body });
235
+ answers.set(key, entry);
236
+ }
237
+ return answers;
238
+ }
204
239
  // Text that looks like an email address or phone number is not saved with a click.
205
240
  const savableText = (text) => (typeof text === "string" && text && !/@|\d[\d\s().+-]{6,}\d/.test(text) ? text : undefined);
206
241
 
@@ -228,7 +263,7 @@ async function replayTarget(a, locator) {
228
263
  const text = found?.text || a.text;
229
264
  // The text too, since a path through the page's layout breaks when a banner or overlay shifts it.
230
265
  if (found?.selector) return { selector: found.selector, ...(keepText && savableText(text) ? { text } : {}) };
231
- return text ? { text } : {};
266
+ return keepText && savableText(text) ? { text } : {};
232
267
  }
233
268
 
234
269
  // Images, fonts and video don't change what tracking sends, and are most of a recording's size.
@@ -419,9 +454,25 @@ export class AuditSession {
419
454
  (isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort()).catch(() => {}),
420
455
  );
421
456
  await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
457
+ const har = JSON.parse(await readFile(this.opts.replayHar, "utf8"));
458
+ // A page asks again for what it asked when saved, with a new timestamp on the address. Registered
459
+ // after the recording, so it runs first and leaves every exact match to the recording.
460
+ const restamped = restampedAnswers(har);
461
+ if (restamped.size) {
462
+ await this.context.route(
463
+ (url) => restamped.has(unstamped(url.href)),
464
+ (route) => {
465
+ const request = route.request();
466
+ const answers = restamped.get(unstamped(request.url()));
467
+ if (request.method() !== "GET" || restamped.exact.has(request.url())) return route.fallback().catch(() => {});
468
+ const answer = answers.queue.length > 1 ? answers.queue.shift() : answers.queue[0];
469
+ return route.fulfill(answer).catch(() => {});
470
+ },
471
+ );
472
+ }
422
473
  // A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
423
474
  // recording, the redirect would be followed outside any handler, and its failure would end the run.
424
- const refusedRedirects = await this.refusedRedirects(this.opts.replayHar);
475
+ const refusedRedirects = await this.refusedRedirects(har);
425
476
  if (refusedRedirects.size) {
426
477
  await this.context.route((url) => refusedRedirects.has(url.href), (route) => route.abort("blockedbyclient").catch(() => {}));
427
478
  }
@@ -514,15 +565,15 @@ export class AuditSession {
514
565
  }
515
566
 
516
567
  /** Addresses whose saved answer redirects somewhere the browser may not go. */
517
- async refusedRedirects(file) {
568
+ async refusedRedirects(har) {
518
569
  const refused = new Set();
519
- const har = JSON.parse(await readFile(file, "utf8"));
520
570
  for (const e of har.log.entries) {
521
571
  const status = e.response?.status;
522
572
  if (status < 300 || status >= 400) continue;
523
573
  const location = e.response.redirectURL || e.response.headers?.find((h) => /^location$/i.test(h.name))?.value;
524
574
  if (!location) continue;
525
- const target = new URL(location, e.request.url).href;
575
+ let target;
576
+ try { target = new URL(location, e.request.url).href; } catch { continue; }
526
577
  if (await this.checkUrl(target)) refused.add(e.request.url);
527
578
  }
528
579
  return refused;
package/src/checks.js CHANGED
@@ -133,6 +133,8 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
133
133
  for (const [, hits] of perStep) {
134
134
  const ev = planByKey.get(key(hits[0].platform, hits[0].name));
135
135
  if (hits.length < 2 || ev?.allow_multiple) continue;
136
+ // TikTok sends unnamed pings (no event name) on its own schedule, so their count varies between runs.
137
+ if (hits[0].platform === "tiktok" && hits[0].name === "(unnamed)") continue;
136
138
  findings.push({
137
139
  ...base,
138
140
  severity: "warning",