@analitikisler/ai-tag-debugger 0.7.7 → 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,10 @@
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
+
3
8
  ## 0.7.7 (2026-10-14)
4
9
 
5
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
- "version": "0.7.7",
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,12 +201,12 @@ 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
- // Text that looks like an email address or phone number is not saved with a click.
205
204
  /** Query parameters that only carry the time or a cache-buster, so they differ between a recording and its replay. */
206
205
  const STAMP_PARAMS = /^(_|_t|ts|timestamp|cb|cachebust|nocache)$/i;
207
206
  /** The address without its timestamp parameters. */
208
207
  function unstamped(href) {
209
- const url = new URL(href);
208
+ let url;
209
+ try { url = new URL(href); } catch { return href; }
210
210
  for (const name of [...url.searchParams.keys()]) if (STAMP_PARAMS.test(name)) url.searchParams.delete(name);
211
211
  return url.href;
212
212
  }
@@ -214,10 +214,9 @@ function unstamped(href) {
214
214
  * The GET answers in a recording whose address carries a timestamp, by address without it.
215
215
  * Entries the recording has no body for (still loading when the page was left) are not included.
216
216
  */
217
- async function restampedAnswers(file) {
217
+ function restampedAnswers(har) {
218
218
  const answers = new Map();
219
219
  answers.exact = new Set();
220
- const har = JSON.parse(await readFile(file, "utf8"));
221
220
  for (const e of har.log.entries) {
222
221
  const { request, response } = e;
223
222
  answers.exact.add(request.url);
@@ -237,6 +236,7 @@ async function restampedAnswers(file) {
237
236
  }
238
237
  return answers;
239
238
  }
239
+ // Text that looks like an email address or phone number is not saved with a click.
240
240
  const savableText = (text) => (typeof text === "string" && text && !/@|\d[\d\s().+-]{6,}\d/.test(text) ? text : undefined);
241
241
 
242
242
  async function replayTarget(a, locator) {
@@ -454,11 +454,10 @@ export class AuditSession {
454
454
  (isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort()).catch(() => {}),
455
455
  );
456
456
  await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
457
- // A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
458
- // recording, the redirect would be followed outside any handler, and its failure would end the run.
457
+ const har = JSON.parse(await readFile(this.opts.replayHar, "utf8"));
459
458
  // A page asks again for what it asked when saved, with a new timestamp on the address. Registered
460
459
  // after the recording, so it runs first and leaves every exact match to the recording.
461
- const restamped = await restampedAnswers(this.opts.replayHar);
460
+ const restamped = restampedAnswers(har);
462
461
  if (restamped.size) {
463
462
  await this.context.route(
464
463
  (url) => restamped.has(unstamped(url.href)),
@@ -471,7 +470,9 @@ export class AuditSession {
471
470
  },
472
471
  );
473
472
  }
474
- const refusedRedirects = await this.refusedRedirects(this.opts.replayHar);
473
+ // A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
474
+ // recording, the redirect would be followed outside any handler, and its failure would end the run.
475
+ const refusedRedirects = await this.refusedRedirects(har);
475
476
  if (refusedRedirects.size) {
476
477
  await this.context.route((url) => refusedRedirects.has(url.href), (route) => route.abort("blockedbyclient").catch(() => {}));
477
478
  }
@@ -564,15 +565,15 @@ export class AuditSession {
564
565
  }
565
566
 
566
567
  /** Addresses whose saved answer redirects somewhere the browser may not go. */
567
- async refusedRedirects(file) {
568
+ async refusedRedirects(har) {
568
569
  const refused = new Set();
569
- const har = JSON.parse(await readFile(file, "utf8"));
570
570
  for (const e of har.log.entries) {
571
571
  const status = e.response?.status;
572
572
  if (status < 300 || status >= 400) continue;
573
573
  const location = e.response.redirectURL || e.response.headers?.find((h) => /^location$/i.test(h.name))?.value;
574
574
  if (!location) continue;
575
- const target = new URL(location, e.request.url).href;
575
+ let target;
576
+ try { target = new URL(location, e.request.url).href; } catch { continue; }
576
577
  if (await this.checkUrl(target)) refused.add(e.request.url);
577
578
  }
578
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",