@analitikisler/ai-tag-debugger 0.22.4 → 0.22.6

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.6 (2026-11-04)
4
+
5
+ - Journeys can log in. Set a test account in `AITD_LOGIN_EMAIL` (or `AITD_LOGIN_USERNAME`) and `AITD_LOGIN_PASSWORD`: the model fills login forms with the placeholders `{{login.email}}`, `{{login.username}}` and `{{login.password}}`, and the browser types the real values, only into the audited site's pages, never into a frame from another site. The values are turned back into their placeholders in everything the tool keeps or shows (page views, steps, hits, dataLayer pushes, network calls, saved steps and reports), a password field's value is never read into the page view, and the browser doesn't get them in its environment. Over MCP, `start_audit` says whether a test account is set up.
6
+ - New ready-made journey `login` (or `signin`): logs in with the test account and expects GA4 `login`. A plan with a `login` event uses it with `--against`.
7
+ - New command `aitd login --site <url>`: log in by hand once in a browser window (a CAPTCHA, a phone code or single sign-on are fine) and the session is saved to `aitd/<domain>/session.json`, readable only by you. `aitd run --session saved` (or a file), the config's `session_file`, and `session: "saved"` on a local MCP server's `start_audit` start every journey logged in. An HTTP server never uses it.
8
+ - New check `user_id_missing` (info): after logging in with the test account, GA4 hits went out without the site's user ID (`uid`), so GA4 can't tie the visit to the account. A baseline written with 0.22.5 can then list new entries; `--update-baseline` rewrites it.
9
+ - ai-tag-debugger is a Claude plugin: `/plugin install ai-tag-debugger --marketplace analitikisler/ai-tag-debugger` in Claude Code adds the MCP server and an `audit-tracking` skill that walks Claude through an audit: what to ask first, which tool answers which question, how to walk the journey and how to explain the findings. The plugin lives in `plugin/`, pins the npm version it starts, and is ready for Anthropic's plugin directory. Its README lists everything it runs, fetches and writes.
10
+
11
+ ## 0.22.5 (2026-11-04)
12
+
13
+ - Runs can report findings 0.22.4 did not on the same site (`container_not_run`, `container_held`). A baseline written with 0.22.4 can then list new entries; `--update-baseline` rewrites it.
14
+ - After every step the tool reads Google's own registry of the containers that ran (`google_tag_manager`), so the Google containers card, `report.md` and MCP say when a container never ran, however its script got into the page.
15
+ - New check `container_not_run` (warning): a GTM container was downloaded or asked for but never ran on any page of the run, so none of its tags fired. When a consent platform held it back and the run never consented, `container_held` (info) says so instead.
16
+ - Fixed: a container the page only preloaded (`<link rel="preload">`, as Next.js does before its own code adds the script) or that ran without the tool seeing it added (as when a consent platform's auto-blocking replaces the browser's own functions) was missing from the containers card. It now has a row saying which.
17
+ - Found on a live Next.js site: GTM was only preloaded and never ran, because the site's own scripts were served as HTML and its app crashed on start. `page_crashed` reported the crash; the containers card now also shows that GTM never ran.
18
+
3
19
  ## 0.22.4 (2026-11-04)
4
20
 
5
21
  - Runs can report a finding 0.22.3 did not on the same site (`iframe_message_unhandled`). A baseline written with 0.22.3 can then list new entries; `--update-baseline` rewrites it.
package/README.md CHANGED
@@ -53,6 +53,7 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
53
53
  | Click id and client id cookies | A Google Ads tag ran with ad storage granted, but `_gcl_aw` never held the landing page's `gclid`, so conversions can't be tied to the click; or the GA4 client id in `_ga` changed mid-visit, which splits one visitor into two users |
54
54
  | Pages that aren't the site | A bot challenge (Cloudflare, DataDome, HUMAN, Akamai) shown instead of the page, or a page that crashed in the browser, such as a Next.js "Application error" after its own scripts came back as HTML. Events not seen on such a run are unverified rather than missing |
55
55
  | Server-side tracking | One event ID used for two different events, or a Meta event ID that doesn't match the `event_id` on the GA4 hit a server container turns into a Conversions API event |
56
+ | Logged-in tracking | After logging in with the test account, GA4 hits went out without the site's user ID (`uid`), so GA4 can't tie the visit to the account |
56
57
 
57
58
  Platforms: GA4 (including server-side GTM endpoints and Google tag gateway paths on the site's own domain; hits to a server container are marked server-side, and Stape and Elevar requests are recognised), Google Ads conversions, Meta Pixel, TikTok Pixel, basic X Pixel events, LinkedIn Insight Tag, Microsoft Advertising UET, Criteo OneTag, and Pinterest, Snapchat and Klaviyo events. Adobe Analytics, Matomo and Piwik PRO events are read too (page views, links, goals, site search and ecommerce orders), and Microsoft Clarity and Hotjar tag loads name the project or site id.
58
59
 
@@ -327,7 +328,7 @@ Under the inventory, **Google containers** lists every GTM container (`gtm.js`)
327
328
 
328
329
  Each row also gives the container's published version (the number its `gtm.js` or `gtag/js` data opens with, as in GTM's Versions tab), the script's size and how long it took to load, the dataLayer the container reads (`l=`), whether it came from Google or from the site's own domain (the Google tag gateway or a server container), the GTM environment when the snippet names one, and the pages it loaded on. A container reading a dataLayer of another name has its pushes recorded too, marked with that name. `report.md` has the same table. Over MCP, step results and `get_step` list the containers each step loaded, `get_containers` lists them all while the audit is open, `finish_audit` and `run_audit` end with them, `get_report` returns them with `part: "containers"` and the JSON timeline carries them per step. `capture.json` keeps the loads under `containerLoads`. The environment token (`gtm_auth`) and the page's query string are never kept.
329
330
 
330
- [The guide](docs/guides/containers-and-widgets.md) explains each finding below with what to do. Five checks read it: `container_failed` (warning) for a container script that failed to load or was answered with an error, so none of its tags ran, `container_duplicate` (warning) for a container loaded more than once into one page, `container_environment` (warning) for a GTM environment (`gtm_preview`, `gtm_auth`) loaded instead of Live, `datalayer_split` (info) for containers on one page reading different dataLayers, and `container_send_to_only` (info) for a destination that only an event's `send_to` loaded, with no `gtag('config')` for it.
331
+ [The guide](docs/guides/containers-and-widgets.md) explains each finding below with what to do. After every step the tool also reads Google's own registry of the containers that ran (`google_tag_manager`), so a container that downloaded but never ran is reported, and a container the page only preloaded (`<link rel="preload">`), or that ran without the tool seeing it added, still gets a row. Seven checks read it: `container_not_run` (warning) for a GTM container that was downloaded or asked for but never ran, so none of its tags fired, or `container_held` (info) instead when a consent platform held it back and the run never consented; `container_failed` (warning) for a container script that failed to load or was answered with an error, so none of its tags ran, `container_duplicate` (warning) for a container loaded more than once into one page, `container_environment` (warning) for a GTM environment (`gtm_preview`, `gtm_auth`) loaded instead of Live, `datalayer_split` (info) for containers on one page reading different dataLayers, and `container_send_to_only` (info) for a destination that only an event's `send_to` loaded, with no `gtag('config')` for it.
331
332
 
332
333
  ### Embedded widgets and iframes
333
334
 
@@ -401,9 +402,25 @@ ai-tag-debugger run --config ai-tag-debugger.config.json --events purchase,add_t
401
402
  | `sign_up` (or `signup`, `register`) | Creates an account with test data | GA4 `sign_up`; Meta `CompleteRegistration` |
402
403
  | `lead` (or `contact`, `form`) | Fills in and sends a contact, quote or demo form | GA4 `generate_lead`; Meta `Lead` |
403
404
  | `search` | Searches the site and opens a result | GA4 `search` with `search_term`; Meta `Search` |
405
+ | `login` (or `signin`) | Logs in with the [test account](#logging-in) | GA4 `login` |
404
406
 
405
407
  `--journey` also picks journeys from your config by id. With a config, ready-made journeys add their events to your plan.
406
408
 
409
+ ### Logging in
410
+
411
+ Journeys that need an account can log in two ways.
412
+
413
+ **A test account.** Set its email (or `AITD_LOGIN_USERNAME` where the site asks for a username) and password on the computer running the tool:
414
+
415
+ ```bash
416
+ export AITD_LOGIN_EMAIL=qa-test@yourshop.com
417
+ export AITD_LOGIN_PASSWORD='…'
418
+ ```
419
+
420
+ The model never sees them. It fills login forms with the placeholders `{{login.email}}`, `{{login.username}}` and `{{login.password}}`, and the browser types the real values, only into pages on the audited site (its subdomains and `allowed_hosts`), never into a frame from another site. Everything the tool keeps or shows has them turned back into their placeholders: the page views the model reads, the steps, hits, dataLayer pushes, network calls, saved steps and reports. The browser itself doesn't get them in its environment. The `login` journey uses the account, and any journey or MCP audit can. After a login, `user_id_missing` (info) reports GA4 hits that carry no user ID. Use an account made for testing, never a real customer's.
421
+
422
+ **A saved session**, for a login that needs a person: a CAPTCHA, a code sent to a phone, or single sign-on. `aitd login --site https://yourshop.com` opens the site in a browser window; log in by hand, press Enter in the terminal, and the session (cookies and storage) is saved to `aitd/<domain>/session.json`, readable only by you. `aitd run … --session saved`, the config's `session_file`, or `session: "saved"` on a local MCP server's `start_audit` start every journey logged in. The file holds a live login: keep it private, and save it again when the login expires.
423
+
407
424
  Not sure what to test? `aitd plan --site https://yoursite.com` drafts a measurement plan, and `--against latest` checks the site against it.
408
425
 
409
426
  ### Keeping API usage down
@@ -497,6 +514,7 @@ The file holds one entry per finding, with a `key` the run matches on (the check
497
514
  | `journeys[].start` | Optional start path (default `/`). |
498
515
  | `journeys[].steps` | Optional fixed steps for `--scripted` runs: `{ "action": "click", "selector": "#add-to-cart" }`. Without them, `--scripted` repeats the steps of the site's last completed run of the journey. |
499
516
  | `allowed_hosts` | Other hosts journeys may visit, e.g. `["checkout.payments-provider.com"]` for a hosted checkout. The browser otherwise stays on the `site` host and its subdomains. Same as `--allow-host`. |
517
+ | `session_file` | A session saved with `aitd login`, so every journey starts logged in: `"saved"` for the site's own, or a file path. See [Logging in](#logging-in). |
500
518
  | `providers` | Tracking platforms to check, e.g. `["ga4", "meta"]`. Default `["ga4", "google_ads", "meta", "tiktok"]`, plus any other platform the site sends to while this is unset. Same as `--platforms`. |
501
519
  | `campaign_checks` | Default `true`. Each run opens the first page like an ad click (UTM parameters, `gclid`, `fbclid`, or the ones already in the start address) and checks they survive redirects and reach the hits. Set to `false` to open the plain address. |
502
520
  | `block_hits` | Default `true`. Set to `false` to let hits reach your real endpoints. |
@@ -551,10 +569,11 @@ aitd run --site <url> (--against <plan> | --journey <id> | --goal "<text>") [--c
551
569
  [--max-steps <n>] [--viewport desktop|mobile] [--concurrency <n>] [--platforms <list>] [--data-dir <dir>]
552
570
  [--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--lang en|tr] [--headed]
553
571
  [--fail-on broken|risk|warning|never] [--baseline <file>] [--update-baseline]
554
- [--scripted] [--no-ai] [--save-site <dir>] [--save-steps <file>]
572
+ [--scripted] [--no-ai] [--save-site <dir>] [--save-steps <file>] [--session saved|<file>]
555
573
  aitd replay <dir>... [--update] [--out <dir>]
556
574
  aitd mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
557
575
  [--allow-private-network] [--allow-live]
576
+ aitd login --site <url> [--save <file>] [--allow-host <host>] [--data-dir <dir>]
558
577
  aitd doctor [--site <url>] [--data-dir <dir>]
559
578
  aitd history --site <url> [--limit <n>] [--json] [--lang en|tr] [--data-dir <dir>]
560
579
  ```
@@ -593,6 +612,8 @@ aitd history --site <url> [--limit <n>] [--json] [--lang en|tr] [--data-dir <dir
593
612
  | `--out <dir>` | replay | Where to write the replay reports. For `run`, `--out` still works as a hidden alias for one minor version and prints a note; use `--data-dir`. |
594
613
  | `--base-url <url>` | run | The API address for an OpenAI-compatible provider. |
595
614
  | `--allow-host <host>` | run | Another host journeys may visit (repeatable). Same as `allowed_hosts`. |
615
+ | `--session saved\|<file>` | run | Start every journey logged in, from the session `aitd login` saved (`saved`) or a session file. Same as `session_file`. See [Logging in](#logging-in). |
616
+ | `--save <file>` | login | Where `aitd login` saves the session (default `aitd/<domain>/session.json`). |
596
617
  | `--keep-bodies` | run | Store tracking requests as sent, without redacting user data. |
597
618
  | `--no-branding` | plan, run | Leave the logo and credit line out of the reports and `plan.html`. |
598
619
  | `--http`, `--port`, `--host`, `--token`, `--public-url`, `--reports`, `--allow-private-network`, `--allow-live` | mcp | See the MCP section below. |
@@ -662,7 +683,7 @@ Tools the server offers:
662
683
  |---|---|
663
684
  | `make_plan` | Drafts a tracking plan for a site (what should be tracked), the same as `aitd plan --site`. Pass `topic` (`custom-events`, `ecommerce`, `forms`, `consent`) to limit it to one kind of event. The plan is saved and its ID can be given as `against` to `finish_audit`. It uses the AI provider configured on the computer that runs the server and takes a few minutes. |
664
685
  | `what_changed` | Returns only the "since last run" diff for a site: the problems fixed and the new ones in its newest saved run (`aitd run`) compared with the run before. No browser and no AI. Pass `language` for a Turkish answer. |
665
- | `start_audit` | Opens a fresh browser on a URL and starts recording all tracking. Pass a ready-made journey (`purchase`, `sign_up`, …) to get its goal and expected events, and `providers` to choose the platforms to check. The assistant asks you which platforms to check first. `live: true` lets the tracking requests through to the site's real accounts, to check them in GA4 Realtime or DebugView, Meta Events Manager or Tag Assistant; the test traffic then shows in the site's reports, and the results and the report say the run was live. Over HTTP the server needs `--allow-live` for it. The first page opens the way an ad click does, with test campaign parameters (`utm_source=aitd`, `gclid=aitd-gclid`, `fbclid=aitd-fbclid`, …), so `finish_audit` checks that they survive redirects and reach the hits; `campaign: false` opens the URL as given, and `campaign: {"utm_source": "newsletter", "gclid": "test123"}` uses your own values. |
686
+ | `start_audit` | Opens a fresh browser on a URL and starts recording all tracking. Pass a ready-made journey (`purchase`, `sign_up`, …) to get its goal and expected events, and `providers` to choose the platforms to check. The assistant asks you which platforms to check first. `live: true` lets the tracking requests through to the site's real accounts, to check them in GA4 Realtime or DebugView, Meta Events Manager or Tag Assistant; the test traffic then shows in the site's reports, and the results and the report say the run was live. Over HTTP the server needs `--allow-live` for it. The first page opens the way an ad click does, with test campaign parameters (`utm_source=aitd`, `gclid=aitd-gclid`, `fbclid=aitd-fbclid`, …), so `finish_audit` checks that they survive redirects and reach the hits; `campaign: false` opens the URL as given, and `campaign: {"utm_source": "newsletter", "gclid": "test123"}` uses your own values. A test account set on the server's computer is used through its placeholders, and `session: "saved"` starts from the session `aitd login` saved (local servers only); see [Logging in](#logging-in). |
666
687
  | `browser_action` | One click, fill, select, goto, scroll or wait. Returns the step number, the page and the tracking hits that step sent, each with an `[h]` number, its consent (from `gcs`, and `gcd` for `ad_user_data` and `ad_personalization`) and the ids and flags GA4 sends with it (`cid`, `sid`, `gcs`, `gcd`, `_fv`, `_ss`, `_nsi`, …), its dataLayer pushes (Consent Mode commands in full as `key=value`, page addresses never cut) and the calls it read no event from, such as Consent Mode pings with their `gcs`, `gcd` and click ids (`gclid`, `gclid=0 (redacted)` when ads data redaction blanks it, `gclaw` from `_gcl_aw`), and the cookies the step added, changed or removed, by name, with the GA4 client id and whether `_gcl_aw` and `_fbc` hold the landing click ids when that changes. A page_view that arrives during a later wait or scroll is listed on the step that loaded its page, marked late. The page view is compact by default, since long sessions lose their oldest results: up to 40 elements above or below the screen, and the page text or element list replaced by a note when they haven't changed since the last view. Pass `detail: "full"` for everything. |
667
688
  | `get_page` | Shows the current page, in full: every element and the whole page text. |
668
689
  | `get_hit` | Shows one hit in full, by its `step` and `[h]` number: every parameter its request carried, its place in a batched request, where it was sent, and `gcd` decoded per consent signal (default and update). |
@@ -697,6 +718,7 @@ The browser (Chromium) downloads itself the first time you run an audit, about a
697
718
 
698
719
  One-click and one-line setup:
699
720
 
721
+ - **Claude Code, as a plugin:** `/plugin install ai-tag-debugger --marketplace analitikisler/ai-tag-debugger` adds the server and a skill that walks Claude through an audit; [plugin/README.md](plugin/README.md) lists everything it runs, fetches and writes. The plugin starts the exact version it was released with.
700
722
  - **Claude Code:** `claude mcp add ai-tag-debugger -- npx -y @analitikisler/ai-tag-debugger mcp`
701
723
  - **Cursor:** [Add to Cursor](https://cursor.com/en/install-mcp?name=ai-tag-debugger&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhbmFsaXRpa2lzbGVyL2FpLXRhZy1kZWJ1Z2dlciIsIm1jcCJdfQ==)
702
724
  - **VS Code:** [Install in VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=ai-tag-debugger&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40analitikisler%2Fai-tag-debugger%22%2C%22mcp%22%5D%7D), or `code --add-mcp '{"name":"ai-tag-debugger","command":"npx","args":["-y","@analitikisler/ai-tag-debugger","mcp"]}'`
@@ -733,6 +755,7 @@ A hosted server, so you don't need a tunnel, is part of the [Analitik İşler](h
733
755
  - The browser stays on the audited site and its subdomains, plus any `allowed_hosts`. It opens only `http` and `https` pages.
734
756
  - The model is told that page content is data, never instructions. A prompt rule can't guarantee a model is never misled, so the navigation limits above contain what a misled model can do.
735
757
  - The browser runs without your API keys and other secrets in its environment.
758
+ - A [test account](#logging-in) is typed only into the audited site's pages, never shown to the model, and turned back into its placeholders in everything the tool keeps. A saved login session is readable only by you and is never used by an HTTP server.
736
759
  - **User data sent for ad matching is redacted** before it is stored in `capture.json` or shown in the report: emails, phone numbers, names, addresses and user ids in GA4 and Google Ads enhanced conversions (`em`, `ph`, `uid`, …), Meta advanced matching (`ud[*]`, `udff[*]`), TikTok `context.user`, and the `user_data` the site pushes to the dataLayer for enhanced conversions. Any value that looks like an email address is redacted whatever its field is called, except the audited site's own address in a `mailto:` link or a link field (`link_url`, `link_text`, `gtm.elementUrl`, …), such as a contact link in a GTM click event, which is not visitor data. Each value becomes a marker such as `[redacted: 64 chars, looks hashed]`, and a value that wasn't hashed is reported as a personal data risk. `--keep-bodies` (`"keep_bodies": true`) stores requests as sent, for debugging.
737
760
  - Stored request bodies are capped at 32 KB per request and 2 MB per journey. Past that, the report says the body wasn't stored.
738
761
  - Only run it against sites you own or are authorized to test.
package/README.tr.md CHANGED
@@ -53,6 +53,7 @@ Manuel etiket kontrolü bunu yakalar, ama zahmetlidir; bu yüzden her sürümden
53
53
  | Tıklama kimliği ve istemci kimliği çerezleri | Bir Google Ads etiketi reklam depolamasına izin verilmişken çalıştı ama `_gcl_aw` açılış sayfasının `gclid`'ini hiç tutmadı, bu yüzden dönüşümler tıklamaya bağlanamıyor; ya da `_ga`'daki GA4 istemci kimliği ziyaret ortasında değişti ve bir ziyaretçi iki kullanıcıya bölündü |
54
54
  | Site olmayan sayfalar | Sayfanın yerine gösterilen bir bot doğrulaması (Cloudflare, DataDome, HUMAN, Akamai) ya da tarayıcıda çöken bir sayfa, ör. kendi betikleri HTML olarak geldikten sonra bir Next.js "Application error" ekranı. Böyle bir çalıştırmada görülmeyen event'ler eksik değil, doğrulanmamış sayılır |
55
55
  | Sunucu tarafı takip | Aynı event kimliğinin iki farklı event'te kullanılması, ya da Meta event kimliğinin sunucu konteynerinin Conversions API olayına dönüştürdüğü GA4 isteğindeki `event_id` ile eşleşmemesi |
56
+ | Giriş yapılmış takip | Test hesabıyla giriş yapıldıktan sonra GA4 istekleri sitenin kullanıcı kimliği (`uid`) olmadan gönderildi; GA4 ziyareti hesaba bağlayamıyor |
56
57
 
57
58
  Platformlar: GA4 (sitenin kendi alan adındaki sunucu tarafı GTM uç noktaları ve Google etiket geçidi yolları dahil; bir sunucu konteynerine giden istekler sunucu tarafı olarak işaretlenir, Stape ve Elevar istekleri tanınır), Google Ads dönüşümleri, Meta Pixel, TikTok Pixel, temel X Pixel event'leri, LinkedIn Insight Tag, Microsoft Advertising UET, Criteo OneTag ile Pinterest, Snapchat ve Klaviyo event'leri. Adobe Analytics, Matomo ve Piwik PRO event'leri de okunur (sayfa görüntülemeleri, bağlantılar, hedefler, site içi arama ve e-ticaret siparişleri); Microsoft Clarity ve Hotjar etiket yüklemeleri ise proje ya da site kimliğini gösterir.
58
59
 
@@ -327,7 +328,7 @@ Envanterin altında **Google container'ları**, sayfaların yüklediği her GTM
327
328
 
328
329
  Her satır ayrıca container'ın yayımlanmış sürümünü (`gtm.js` ya da `gtag/js` verisinin başladığı numara; GTM'deki Sürümler sekmesindekiyle aynı), betiğin boyutunu ve ne kadar sürede yüklendiğini, container'ın okuduğu dataLayer'ı (`l=`), Google'dan mı yoksa sitenin kendi alan adından mı (Google etiket ağ geçidi ya da bir sunucu container'ı) geldiğini, snippet bir GTM ortamı adlandırıyorsa onu ve yüklendiği sayfaları verir. Başka adlı bir dataLayer okuyan container'ın push'ları da o adla işaretlenerek kaydedilir. `report.md` aynı tabloyu içerir. MCP'de adım sonuçları ve `get_step` her adımın yüklediği container'ları listeler, `get_containers` denetim açıkken hepsini listeler, `finish_audit` ve `run_audit` sonuçları onlarla biter, `get_report` onları `part: "containers"` ile döndürür ve JSON zaman akışı onları adım adım taşır. `capture.json` yüklemeleri `containerLoads` altında saklar. Ortam belirteci (`gtm_auth`) ve sayfanın sorgu dizesi asla saklanmaz.
329
330
 
330
- [Rehber](docs/guides/containers-and-widgets.tr.md) aşağıdaki her bulguyu ne yapılacağıyla anlatır. Beş kontrol bunu okur: yüklenemeyen ya da hatayla yanıtlanan, bu yüzden etiketlerinin hiçbiri çalışmayan bir container betiği için `container_failed` (uyarı), aynı sayfaya birden fazla kez yüklenen bir container için `container_duplicate` (uyarı), Live yerine yüklenen bir GTM ortamı (`gtm_preview`, `gtm_auth`) için `container_environment` (uyarı), aynı sayfada farklı dataLayer'ları okuyan container'lar için `datalayer_split` (bilgi) ve kendisi için `gtag('config')` olmadan yalnızca bir olayın `send_to` değeriyle yüklenen bir hedef için `container_send_to_only` (bilgi).
331
+ [Rehber](docs/guides/containers-and-widgets.tr.md) aşağıdaki her bulguyu ne yapılacağıyla anlatır. Araç her adımdan sonra Google'ın çalışan container'lar kaydını (`google_tag_manager`) da okur; böylece indirilip hiç çalışmayan bir container bildirilir, sayfanın yalnızca ön yüklediği (`<link rel="preload">`) ya da eklendiği görülmeden çalışan bir container da yine bir satır alır. Yedi kontrol bunu okur: indirilen ya da istenen ama hiç çalışmayan, bu yüzden etiketlerinin hiçbiri çalışmayan bir GTM container'ı için `container_not_run` (uyarı), bir onay platformu onu beklettiyse ve çalıştırmada onay verilmediyse bunun yerine `container_held` (bilgi); yüklenemeyen ya da hatayla yanıtlanan, bu yüzden etiketlerinin hiçbiri çalışmayan bir container betiği için `container_failed` (uyarı), aynı sayfaya birden fazla kez yüklenen bir container için `container_duplicate` (uyarı), Live yerine yüklenen bir GTM ortamı (`gtm_preview`, `gtm_auth`) için `container_environment` (uyarı), aynı sayfada farklı dataLayer'ları okuyan container'lar için `datalayer_split` (bilgi) ve kendisi için `gtag('config')` olmadan yalnızca bir olayın `send_to` değeriyle yüklenen bir hedef için `container_send_to_only` (bilgi).
331
332
 
332
333
  ### Gömülü widget'lar ve iframe'ler
333
334
 
@@ -401,9 +402,25 @@ ai-tag-debugger run --config ai-tag-debugger.config.json --events purchase,add_t
401
402
  | `sign_up` (ya da `signup`, `register`) | Test verileriyle bir hesap oluşturur | GA4 `sign_up`; Meta `CompleteRegistration` |
402
403
  | `lead` (ya da `contact`, `form`) | Bir iletişim, teklif ya da demo formunu doldurup gönderir | GA4 `generate_lead`; Meta `Lead` |
403
404
  | `search` | Sitede arama yapar ve bir sonucu açar | GA4 `search` (`search_term` ile); Meta `Search` |
405
+ | `login` (ya da `signin`) | [Test hesabıyla](#giriş-yapmak) giriş yapar | GA4 `login` |
404
406
 
405
407
  `--journey`, ayar dosyanızdaki yolculukları id'leriyle de seçer. Bir ayar dosyasıyla, hazır yolculuklar kendi event'lerini planınıza ekler.
406
408
 
409
+ ### Giriş yapmak
410
+
411
+ Hesap gerektiren yolculuklar iki şekilde giriş yapabilir.
412
+
413
+ **Bir test hesabı.** E-postasını (site bir kullanıcı adı istiyorsa `AITD_LOGIN_USERNAME`) ve parolasını aracı çalıştıran bilgisayarda ayarlayın:
414
+
415
+ ```bash
416
+ export AITD_LOGIN_EMAIL=qa-test@magazaniz.com
417
+ export AITD_LOGIN_PASSWORD='…'
418
+ ```
419
+
420
+ Model bunları hiç görmez. Giriş formlarını `{{login.email}}`, `{{login.username}}` ve `{{login.password}}` yer tutucularıyla doldurur; gerçek değerleri tarayıcı yazar, yalnızca denetlenen sitenin sayfalarına (alt alan adları ve `allowed_hosts`), başka bir siteden gelen bir çerçeveye asla. Aracın sakladığı ya da gösterdiği her şeyde bu değerler yeniden yer tutuculara çevrilir: modelin okuduğu sayfa görünümleri, adımlar, istekler, dataLayer push'ları, ağ çağrıları, kaydedilen adımlar ve raporlar. Tarayıcının kendisi de bunları ortamında almaz. `login` yolculuğu hesabı kullanır; her yolculuk ve MCP denetimi de kullanabilir. Bir girişten sonra `user_id_missing` (bilgi), kullanıcı kimliği taşımayan GA4 isteklerini bildirir. Test için açılmış bir hesap kullanın, asla gerçek bir müşterinin hesabını değil.
421
+
422
+ **Kaydedilmiş bir oturum**, bir insan gerektiren girişler için: bir CAPTCHA, telefona gelen bir kod ya da tek oturum açma (SSO). `aitd login --site https://magazaniz.com` siteyi bir tarayıcı penceresinde açar; elle giriş yapın, terminalde Enter'a basın; oturum (çerezler ve depolama) yalnızca sizin okuyabileceğiniz `aitd/<alan-adı>/session.json` dosyasına kaydedilir. `aitd run … --session saved`, yapılandırmadaki `session_file` ya da yerel bir MCP sunucusunda `start_audit` için `session: "saved"` her yolculuğu giriş yapılmış olarak başlatır. Dosya canlı bir oturum tutar: gizli tutun ve oturum sona erdiğinde yeniden kaydedin.
423
+
407
424
  Ne test edeceğinizden emin değil misiniz? `aitd plan --site https://yoursite.com` bir ölçümleme planı taslağı çıkarır, `--against latest` de siteyi buna göre kontrol eder.
408
425
 
409
426
  ### API kullanımını düşük tutmak
@@ -497,6 +514,7 @@ Dosyada her bulgu için bir kayıt bulunur: çalıştırmanın eşleştirme yapt
497
514
  | `journeys[].start` | İsteğe bağlı başlangıç yolu (varsayılan `/`). |
498
515
  | `journeys[].steps` | `--scripted` çalıştırmaları için isteğe bağlı sabit adımlar: `{ "action": "click", "selector": "#add-to-cart" }`. Bunlar yoksa `--scripted`, sitenin o yolculuğu son tamamlayan çalıştırmasının adımlarını tekrarlar. |
499
516
  | `allowed_hosts` | Yolculukların ziyaret edebileceği başka ana makineler, örneğin barındırılan bir ödeme için `["checkout.payments-provider.com"]`. Tarayıcı aksi halde `site` ana makinesinde ve alt alan adlarında kalır. `--allow-host` ile aynıdır. |
517
+ | `session_file` | `aitd login` ile kaydedilmiş bir oturum; her yolculuk giriş yapılmış olarak başlar: sitenin kendi oturumu için `"saved"` ya da bir dosya yolu. Bkz. [Giriş yapmak](#giriş-yapmak). |
500
518
  | `providers` | Kontrol edilecek takip platformları, örneğin `["ga4", "meta"]`. Varsayılan `["ga4", "google_ads", "meta", "tiktok"]`; bu ayar boşken sitenin gönderdiği diğer platformlar da eklenir. `--platforms` ile aynıdır. |
501
519
  | `campaign_checks` | Varsayılan `true`. Her çalıştırma ilk sayfayı bir reklam tıklaması gibi açar (UTM parametreleri, `gclid`, `fbclid` ya da başlangıç adresinde zaten olanlar) ve bunların yönlendirmelerden geçip isteklere ulaştığını denetler. Düz adresi açmak için `false` yapın. |
502
520
  | `block_hits` | Varsayılan `true`. Takip isteklerinin gerçek uç noktalarınıza ulaşmasına izin vermek için `false` yapın. |
@@ -551,10 +569,11 @@ aitd run --site <url> (--against <plan> | --journey <id> | --goal "<metin>") [--
551
569
  [--max-steps <n>] [--viewport desktop|mobile] [--concurrency <n>] [--platforms <liste>] [--data-dir <dir>]
552
570
  [--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--lang en|tr] [--headed]
553
571
  [--fail-on broken|risk|warning|never] [--baseline <dosya>] [--update-baseline]
554
- [--scripted] [--no-ai] [--save-site <dir>] [--save-steps <dosya>]
572
+ [--scripted] [--no-ai] [--save-site <dir>] [--save-steps <dosya>] [--session saved|<dosya>]
555
573
  aitd replay <dir>... [--update] [--out <dir>]
556
574
  aitd mcp [--http] [--port <n>] [--host <adres>] [--token <gizli>] [--public-url <https url>] [--reports <dir>]
557
575
  [--allow-private-network] [--allow-live]
576
+ aitd login --site <url> [--save <file>] [--allow-host <host>] [--data-dir <dir>]
558
577
  aitd doctor [--site <url>] [--data-dir <dir>]
559
578
  aitd history --site <url> [--limit <n>] [--json] [--lang en|tr] [--data-dir <dir>]
560
579
  ```
@@ -593,6 +612,8 @@ aitd history --site <url> [--limit <n>] [--json] [--lang en|tr] [--data-dir <dir
593
612
  | `--out <dir>` | replay | Tekrar raporlarının yazılacağı yer. `run` için `--out` gizli bir takma ad olarak bir minör sürüm daha çalışır ve bir not yazdırır; `--data-dir` kullanın. |
594
613
  | `--base-url <url>` | run | OpenAI uyumlu bir sağlayıcının API adresi. |
595
614
  | `--allow-host <ana makine>` | run | Yolculukların ziyaret edebileceği başka bir ana makine (tekrarlanabilir). `allowed_hosts` ile aynıdır. |
615
+ | `--session saved\|<dosya>` | run | Her yolculuğu, `aitd login`'in kaydettiği oturumdan (`saved`) ya da bir oturum dosyasından giriş yapılmış olarak başlatır. `session_file` ile aynı. Bkz. [Giriş yapmak](#giriş-yapmak). |
616
+ | `--save <dosya>` | login | `aitd login`'in oturumu nereye kaydedeceği (varsayılan `aitd/<alan-adı>/session.json`). |
596
617
  | `--keep-bodies` | run | Takip isteklerini gönderildiği gibi, kullanıcı verisi gizlenmeden sakla. |
597
618
  | `--no-branding` | plan, run | Logo ve kredi satırını raporlardan ve `plan.html` dosyasından çıkar. |
598
619
  | `--http`, `--port`, `--host`, `--token`, `--public-url`, `--reports`, `--allow-private-network`, `--allow-live` | mcp | Aşağıdaki MCP bölümüne bakın. |
@@ -662,7 +683,7 @@ Sunucunun sunduğu araçlar:
662
683
  |---|---|
663
684
  | `make_plan` | Bir site için takip planı çıkarır (neyin takip edilmesi gerektiği), `aitd plan --site` ile aynıdır. Yalnızca bir tür event için sınırlamak üzere `topic` verin (`custom-events`, `ecommerce`, `forms`, `consent`). Plan kaydedilir ve kimliği `finish_audit` için `against` olarak verilebilir. Sunucuyu çalıştıran bilgisayarda ayarlı yapay zekâ sağlayıcısını kullanır ve birkaç dakika sürer. |
664
685
  | `what_changed` | Bir site için yalnızca "son çalıştırmadan bu yana" farkını döndürür: kayıtlı son çalıştırmanın (`aitd run`) bir öncekine göre düzelen ve yeni sorunları. Tarayıcı ve yapay zekâ kullanmaz. Türkçe yanıt için `language` verin. |
665
- | `start_audit` | Bir URL'de yeni bir tarayıcı açar ve tüm takibi kaydetmeye başlar. Hedefini ve beklenen event'lerini almak için hazır bir yolculuk (`purchase`, `sign_up`, …) verin; kontrol edilecek platformları seçmek için `providers` verin. Asistan önce hangi platformların kontrol edileceğini size sorar. `live: true`, takip isteklerini sitenin gerçek hesaplarına geçirir; böylece GA4 Realtime ya da DebugView, Meta Events Manager veya Tag Assistant'ta kontrol edilebilirler. Test trafiği o zaman sitenin raporlarında görünür; sonuçlar ve rapor çalıştırmanın canlı olduğunu söyler. HTTP üzerinden sunucunun bunun için `--allow-live` ile başlatılması gerekir. İlk sayfa bir reklam tıklaması gibi, test kampanya parametreleriyle (`utm_source=aitd`, `gclid=aitd-gclid`, `fbclid=aitd-fbclid`, …) açılır; böylece `finish_audit` bunların yönlendirmelerden sağ çıkıp isteklere ulaştığını kontrol eder. `campaign: false` URL'yi olduğu gibi açar, `campaign: {"utm_source": "newsletter", "gclid": "test123"}` ise sizin değerlerinizi kullanır. |
686
+ | `start_audit` | Bir URL'de yeni bir tarayıcı açar ve tüm takibi kaydetmeye başlar. Hedefini ve beklenen event'lerini almak için hazır bir yolculuk (`purchase`, `sign_up`, …) verin; kontrol edilecek platformları seçmek için `providers` verin. Asistan önce hangi platformların kontrol edileceğini size sorar. `live: true`, takip isteklerini sitenin gerçek hesaplarına geçirir; böylece GA4 Realtime ya da DebugView, Meta Events Manager veya Tag Assistant'ta kontrol edilebilirler. Test trafiği o zaman sitenin raporlarında görünür; sonuçlar ve rapor çalıştırmanın canlı olduğunu söyler. HTTP üzerinden sunucunun bunun için `--allow-live` ile başlatılması gerekir. İlk sayfa bir reklam tıklaması gibi, test kampanya parametreleriyle (`utm_source=aitd`, `gclid=aitd-gclid`, `fbclid=aitd-fbclid`, …) açılır; böylece `finish_audit` bunların yönlendirmelerden sağ çıkıp isteklere ulaştığını kontrol eder. `campaign: false` URL'yi olduğu gibi açar, `campaign: {"utm_source": "newsletter", "gclid": "test123"}` ise sizin değerlerinizi kullanır. Sunucunun bilgisayarında ayarlı bir test hesabı yer tutucularıyla kullanılır; `session: "saved"` ise `aitd login`'in kaydettiği oturumdan başlar (yalnızca yerel sunucular); bkz. [Giriş yapmak](#giriş-yapmak). |
666
687
  | `browser_action` | Tek bir tıklama, doldurma, seçme, gitme, kaydırma ya da bekleme. Adım numarasını, sayfayı, o adımın gönderdiği takip isteklerini (her biri bir `[h]` numarası, onay durumu — `gcs`'den, `ad_user_data` ve `ad_personalization` için `gcd`'den — ve GA4'ün onunla gönderdiği kimlik ve işaretlerle: `cid`, `sid`, `gcs`, `gcd`, `_fv`, `_ss`, `_nsi`, …), dataLayer push'larını (Consent Mode komutları `anahtar=değer` olarak eksiksiz, sayfa adresleri hiç kesilmeden) ve event olarak okunmayan çağrıları (ör. `gcs`, `gcd` ve tıklama kimlikleriyle Consent Mode ping'leri: `gclid`, reklam verisi karartma açıksa `gclid=0 (redacted)`, `_gcl_aw`'dan `gclaw`), ayrıca adımın eklediği, değiştirdiği ya da sildiği çerezleri adlarıyla, değiştiğinde de GA4 istemci kimliğini ve `_gcl_aw` ile `_fbc`'nin açılış tıklama kimliklerini tutup tutmadığını döndürür. Sonraki bir bekleme ya da kaydırma sırasında gelen page_view, sayfasını yükleyen adımda gecikmeli olarak işaretlenip gösterilir. Uzun oturumlar en eski sonuçlarını kaybettiği için sayfa görünümü varsayılan olarak kısadır: ekranın üstünde ya da altında en fazla 40 öğe; sayfa metni ya da öğe listesi son görünümden beri değişmediyse yerine bir not. Hepsi için `detail: "full"` verin. |
667
688
  | `get_page` | Geçerli sayfayı eksiksiz gösterir: her öğe ve sayfanın tüm metni. |
668
689
  | `get_hit` | Bir isteği `step` ve `[h]` numarasıyla eksiksiz gösterir: isteğin taşıdığı her parametre, toplu bir istekteki sırası, nereye gönderildiği ve her onay sinyali için çözümlenmiş `gcd` (varsayılan ve güncelleme). |
@@ -697,6 +718,7 @@ Tarayıcı (Chromium), bir denetimi ilk kez çalıştırdığınızda kendini in
697
718
 
698
719
  Tek tıkla ya da tek satırla kurulum:
699
720
 
721
+ - **Claude Code, eklenti olarak:** `/plugin install ai-tag-debugger --marketplace analitikisler/ai-tag-debugger` sunucuyu ve Claude'a bir denetimi adım adım anlatan bir beceriyi ekler; [plugin/README.md](plugin/README.md) çalıştırdığı, indirdiği ve yazdığı her şeyi listeler. Eklenti, birlikte yayımlandığı sürümün tam olarak aynısını başlatır.
700
722
  - **Claude Code:** `claude mcp add ai-tag-debugger -- npx -y @analitikisler/ai-tag-debugger mcp`
701
723
  - **Cursor:** [Cursor'a ekle](https://cursor.com/en/install-mcp?name=ai-tag-debugger&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhbmFsaXRpa2lzbGVyL2FpLXRhZy1kZWJ1Z2dlciIsIm1jcCJdfQ==)
702
724
  - **VS Code:** [VS Code'a yükle](https://insiders.vscode.dev/redirect/mcp/install?name=ai-tag-debugger&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40analitikisler%2Fai-tag-debugger%22%2C%22mcp%22%5D%7D), ya da `code --add-mcp '{"name":"ai-tag-debugger","command":"npx","args":["-y","@analitikisler/ai-tag-debugger","mcp"]}'`
@@ -733,6 +755,7 @@ Tünel gerektirmeyen barındırılmış bir sunucu, [Analitik İşler](https://a
733
755
  - Tarayıcı, denetlenen sitede ve alt alan adlarında kalır; ayrıca varsa `allowed_hosts` içinde. Yalnızca `http` ve `https` sayfalarını açar.
734
756
  - Modele, sayfa içeriğinin veri olduğu ve talimat olmadığı söylenir. Bir istem kuralı, bir modelin asla yanıltılmayacağını garanti edemez; bu yüzden yukarıdaki gezinme sınırları, yanıltılmış bir modelin yapabileceklerini sınırlar.
735
757
  - Tarayıcı, ortamında API anahtarlarınız ve diğer gizli bilgileriniz olmadan çalışır.
758
+ - Bir [test hesabı](#giriş-yapmak) yalnızca denetlenen sitenin sayfalarına yazılır, modele hiç gösterilmez ve aracın sakladığı her şeyde yeniden yer tutucularına çevrilir. Kaydedilmiş bir giriş oturumunu yalnızca siz okuyabilirsiniz ve bir HTTP sunucusu onu hiç kullanmaz.
736
759
  - **Reklam eşleştirmesi için gönderilen kullanıcı verisi, `capture.json` dosyasına kaydedilmeden ya da raporda gösterilmeden önce gizlenir**: GA4 ve Google Ads geliştirilmiş dönüşümlerindeki e-postalar, telefonlar, adlar, adresler ve kullanıcı kimlikleri (`em`, `ph`, `uid`, …), Meta gelişmiş eşleştirme (`ud[*]`, `udff[*]`), TikTok `context.user` ve geliştirilmiş dönüşümler için sitenin dataLayer'a gönderdiği `user_data`. E-posta adresine benzeyen her değer, alanı ne olursa olsun gizlenir; yalnızca denetlenen sitenin `mailto:` bağlantısındaki ya da bir bağlantı alanındaki (`link_url`, `link_text`, `gtm.elementUrl`, …) kendi adresi istisnadır; örneğin bir GTM tıklama event'indeki iletişim bağlantısı, ziyaretçi verisi değildir. Her değer `[redacted: 64 chars, looks hashed]` gibi bir işaretçiye dönüşür; karma hâle getirilmemiş bir değer ise kişisel veri riski olarak raporlanır. `--keep-bodies` (`"keep_bodies": true`), istekleri gönderildiği gibi, hata ayıklama için saklar.
737
760
  - Saklanan istek gövdeleri istek başına 32 KB, yolculuk başına 2 MB ile sınırlıdır. Bu sınırın üzerinde rapor, gövdenin saklanmadığını söyler.
738
761
  - Yalnızca sahibi olduğunuz ya da test etme yetkiniz olan sitelere karşı çalıştırın.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
3
  "mcpName": "io.github.analitikisler/ai-tag-debugger",
4
- "version": "0.22.4",
4
+ "version": "0.22.6",
5
5
  "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.",
6
6
  "type": "module",
7
7
  "bin": {
package/src/agent.js CHANGED
@@ -19,6 +19,7 @@ How to work:
19
19
  - If a cookie or consent banner appears, accept all cookies (as most visitors do) and set accepts_consent to true on that click. (A journey whose goal says to reject or decline cookies does that instead and sets rejects_consent to true on that click; if the banner has no reject-all button, use the one that declines non-essential cookies, and never accept.)
20
20
  - Use obvious test data in forms: name "Test User", email "qa-test@example.com", phone "5550100", address "1 Test Street", city "Testville", postcode "00000".
21
21
  - Never enter real payment card details. If the site offers a test or demo payment option, use it. If completing the journey would require a real payment or a real account you don't have, stop and call finish with success false and explain why.
22
+ - To log in with the test account, fill the fields with the placeholders {{login.email}} (or {{login.username}} where the site asks for a username) and {{login.password}}, written exactly like that. The browser types the real values; you never see them, and pages show them back as the placeholders. If the fill says no test account is set up, stop and call finish with success false.
22
23
  - If something is broken (an error, a button that does nothing), try a reasonable alternative once, then finish with success false and describe what happened.
23
24
  - Call finish as soon as the goal is reached.`;
24
25
 
@@ -204,7 +205,7 @@ export const harName = (journeyId, viewport) => `${String(journeyId).replace(/[^
204
205
  export async function runJourney(config, journey, viewport, opts = {}) {
205
206
  const har = (dir) => (dir ? path.join(dir, harName(journey.id, viewport)) : undefined);
206
207
  const session = new AuditSession({
207
- viewport, site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers, detect: config.detect_providers, keepBodies: config.keep_bodies === true,
208
+ viewport, site: config.site, allowedHosts: config.allowed_hosts, storageState: config.session_file, providers: config.providers, detect: config.detect_providers, keepBodies: config.keep_bodies === true,
208
209
  blockHits: config.block_hits !== false, headless: !opts.headed, recordHar: har(opts.saveSite), replayHar: har(opts.replaySite), log: opts.log,
209
210
  });
210
211
  try {
@@ -238,7 +239,7 @@ export const journeyError = (err) => `The journey stopped with an error: ${err?.
238
239
  export function failedRun(journey, viewport, err, providers) {
239
240
  return {
240
241
  journeyId: journey.id, viewport, completed: false, note: journeyError(err), hits: [], steps: [], consentStep: null, rejectStep: null,
241
- cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], widgetHits: [], widgetDataLayerLog: [], containerScripts: [], frameMessages: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
242
+ cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], widgetHits: [], widgetDataLayerLog: [], containerScripts: [], containersRunning: [], containerPreloads: [], frameMessages: [], loginStep: null, providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
242
243
  };
243
244
  }
244
245
 
@@ -252,6 +253,8 @@ export function collect(session, journey, viewport) {
252
253
  steps: session.steps,
253
254
  consentStep: session.consentStep,
254
255
  rejectStep: session.rejectStep,
256
+ // The first step that typed the test account's password: the user ID check starts after it.
257
+ loginStep: session.loginStep ?? null,
255
258
  cookieLog: session.cookieLog,
256
259
  cookieChanges: session.cookieChanges,
257
260
  cmp: session.cmp,
@@ -259,6 +262,8 @@ export function collect(session, journey, viewport) {
259
262
  ...(session.opts.blockHits === false ? { live: true } : {}),
260
263
  containerLoads: session.containerLoads,
261
264
  containerScripts: session.containerScripts,
265
+ containersRunning: [...session.containersRunning].map(([id, step]) => ({ id, step })),
266
+ containerPreloads: [...session.containerPreloads.values()],
262
267
  frameMessages: session.frameMessages,
263
268
  network: session.network,
264
269
  providers: session.providers,
package/src/audit.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // A full audit from a config file: run every journey, check, explain, write the reports.
2
2
  // Shared by the CLI and the MCP server's run_audit tool.
3
- import { newRunDir } from "./workspace.js";
3
+ import { DEFAULT_DATA_DIR, newRunDir } from "./workspace.js";
4
+ import { resolveSessionFile } from "./login.js";
4
5
  import { compareRuns, previousPlanChanges, previousRun, routeHints } from "./changes.js";
5
6
  import { sendAlerts } from "./notify.js";
6
7
  import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
@@ -133,6 +134,8 @@ export async function runAudit(opts) {
133
134
  for (const w of checked.flatMap((c) => c.warnings)) log(w);
134
135
  }
135
136
  if (!config.site) throw new Error('No site to audit. Pass --site or set "site" in the config.');
137
+ // "saved" names the session `aitd login` saved for the site; a path must exist. Checked before any browser starts.
138
+ if (config.session_file) config = { ...config, session_file: resolveSessionFile(config.session_file, { site: config.site, dataDir: opts.dataDir ?? DEFAULT_DATA_DIR }) };
136
139
  const lang = opts.lang ?? config.language ?? "en";
137
140
  if (!LANGUAGES[lang]) throw new Error(`Language must be one of ${Object.keys(LANGUAGES).join(", ")}.`);
138
141
  const useAi = opts.ai !== false;
package/src/browser.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { createRequire } from "node:module";
4
+ import { existsSync } from "node:fs";
4
5
  import { readFile, rm, writeFile } from "node:fs/promises";
5
6
  import path from "node:path";
6
7
  import { chromium, devices } from "playwright";
@@ -10,8 +11,9 @@ import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from
10
11
  import { startEgressProxy } from "./egress.js";
11
12
  import { campaignParamsIn } from "./campaign.js";
12
13
  import { detectCmpInPage } from "./cmp.js";
13
- import { callersOf, containerOf, containerVersion } from "./containers.js";
14
+ import { callersOf, containerOf, containerVersion, isContainerId } from "./containers.js";
14
15
  import { frameInfo } from "./frames.js";
16
+ import { fillLoginValue, hasLoginPlaceholder, loginFromEnv, loginScrubber, scrubDeep } from "./login.js";
15
17
 
16
18
  // Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
17
19
  // one of these always blocks the journey, and a reject click is checked against them afterwards.
@@ -40,7 +42,7 @@ const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLo
40
42
 
41
43
  // Secrets the browser process has no use for. It gets the rest of the environment
42
44
  // (PATH, HOME, DISPLAY...) so Chromium still starts normally.
43
- const SECRET_ENV = /(API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|_KEY$|^AWS_|^AZURE_|^GOOGLE_APPLICATION)/i;
45
+ const SECRET_ENV = /(API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|_KEY$|^AWS_|^AZURE_|^GOOGLE_APPLICATION|^AITD_LOGIN)/i;
44
46
  export const browserEnv = (env = process.env) => Object.fromEntries(Object.entries(env).filter(([k]) => !SECRET_ENV.test(k)));
45
47
 
46
48
  export const VIEWPORTS = {
@@ -106,6 +108,9 @@ const CONTAINER_HOOK = `
106
108
  new MutationObserver((list) => {
107
109
  for (const m of list) for (const n of m.addedNodes) {
108
110
  if (n instanceof HTMLScriptElement && n.src && !seen.has(n)) { seen.add(n); report(n, n.src, "parser"); }
111
+ // A preload hint downloads a container without running it; caught as it is added, since a page's own code
112
+ // can rewrite <head> and take it out again.
113
+ else if (n instanceof HTMLLinkElement && /(^|\\s)(preload|modulepreload|prefetch)(\\s|$)/i.test(n.rel) && n.href && !seen.has(n)) { seen.add(n); report(n, n.href, "preload"); }
109
114
  }
110
115
  }).observe(document, { childList: true, subtree: true });
111
116
  } catch {}
@@ -221,7 +226,7 @@ function snapshotInPage({ first, limit, textLimit, frameBox, offscreenLimit = nu
221
226
  const forLabel = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.innerText : "";
222
227
  const img = el.querySelector?.("img[alt], svg title");
223
228
  return clean(
224
- el.getAttribute("aria-label") || byId || el.innerText || el.value || forLabel || el.getAttribute("placeholder") ||
229
+ el.getAttribute("aria-label") || byId || el.innerText || (el.type === "password" ? "" : el.value) || forLabel || el.getAttribute("placeholder") ||
225
230
  el.getAttribute("title") || (img ? img.getAttribute("alt") || img.textContent : "") || el.getAttribute("name") || "",
226
231
  ).slice(0, 80);
227
232
  };
@@ -516,6 +521,17 @@ export function googleConsentStateInPage() {
516
521
  return { entries, usedDefault: Boolean(ics.usedDefault), usedUpdate: Boolean(ics.usedUpdate), wasSetLate: Boolean(ics.wasSetLate), waitPeriodTimedOut: Boolean(ics.waitPeriodTimedOut) };
517
522
  }
518
523
 
524
+ /**
525
+ * Runs in the page: the IDs Google's own registry (window.google_tag_manager) holds, which are the containers and
526
+ * Google tags that actually ran, however their script got in; and the addresses the page only preloads
527
+ * (<link rel="preload">, "modulepreload", "prefetch"), which download a script without running it.
528
+ */
529
+ export function containersInPage() {
530
+ const registry = window.google_tag_manager && typeof window.google_tag_manager === "object" ? Object.keys(window.google_tag_manager) : [];
531
+ const preloads = [...document.querySelectorAll('link[rel~="preload"], link[rel~="modulepreload"], link[rel~="prefetch"]')].map((l) => l.href).filter(Boolean);
532
+ return { registry: registry.slice(0, 200), preloads: preloads.slice(0, 200) };
533
+ }
534
+
519
535
  /**
520
536
  * Runs in the page: whether it is something other than the site, so what a step captured there says nothing about
521
537
  * the site's tracking. A bot challenge in its place (Cloudflare, DataDome, HUMAN, Akamai), or a page that crashed in
@@ -561,6 +577,10 @@ export class AuditSession {
561
577
  this.opts = { blockHits: true, headless: true, ...opts };
562
578
  this.log = this.opts.log ?? (() => {});
563
579
  this.scope = this.opts.site ? hostScope(this.opts.site, this.opts.allowedHosts) : null;
580
+ // The test account (see src/login.js): typed only where a placeholder asks for it, and scrubbed from everything kept.
581
+ this.login = this.opts.login !== undefined ? this.opts.login : loginFromEnv();
582
+ this.scrub = loginScrubber(this.login);
583
+ this.loginStep = null; // the first step that typed the test account's password
564
584
  this.isBlockedAddress = typeof this.opts.blockPrivate === "function" ? this.opts.blockPrivate : isPrivateAddress;
565
585
  this.isPrivate = this.opts.blockPrivate ? privateHostChecker(this.isBlockedAddress) : null;
566
586
  this.blocked = null;
@@ -577,6 +597,8 @@ export class AuditSession {
577
597
  this.dataLayerLog = [];
578
598
  this.containerLoads = []; // each Google container script the page added, with how (see src/containers.js)
579
599
  this.containerScripts = []; // each container script's answer: status, version, size and load time
600
+ this.containersRunning = new Map(); // container ID -> the step it was first seen running at (google_tag_manager)
601
+ this.containerPreloads = new Map(); // "id|host" -> { id, host, step } for containers the page only preloaded
580
602
  this.frameMessages = []; // each named message an iframe posted to the page: origin, name and field names, never values
581
603
  // The order hits, dataLayer pushes and iframe messages were recorded in. Several often share a millisecond, and
582
604
  // telling which came first matters: a page's push answers the message just before it (see messageOutcomes).
@@ -597,6 +619,8 @@ export class AuditSession {
597
619
  }
598
620
 
599
621
  async start() {
622
+ // Checked before anything launches, so a wrong path leaves no browser behind.
623
+ if (this.opts.storageState && !existsSync(this.opts.storageState)) throw new Error(`No saved login session at ${this.opts.storageState}. Save one with: aitd login --site <url>`);
600
624
  const device = VIEWPORTS[this.opts.viewport];
601
625
  if (!device) throw new Error(`Unknown viewport "${this.opts.viewport}". Use: ${Object.keys(VIEWPORTS).join(", ")}`);
602
626
  const launch = { headless: this.opts.headless, env: browserEnv() };
@@ -620,7 +644,9 @@ export class AuditSession {
620
644
  this.browser = await chromium.launch(launch);
621
645
  }
622
646
  const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
623
- this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
647
+ // A session saved with `aitd login`: the browser starts logged in, for sites whose login needs a person.
648
+ const session = this.opts.storageState ? { storageState: this.opts.storageState } : {};
649
+ this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record, ...session });
624
650
  await this.context.exposeBinding("__atdRecord", (source, entry) => {
625
651
  const frame = frameInfo(source?.frame);
626
652
  if (frame) entry = { ...entry, frame };
@@ -631,7 +657,7 @@ export class AuditSession {
631
657
  await this.context.exposeBinding("__atdLoad", (source, entry) => this.recordLoad(entry, frameInfo(source?.frame)));
632
658
  await this.context.addInitScript(INIT_SCRIPT);
633
659
  this.page = await this.context.newPage();
634
- this.page.on("pageerror", (err) => this.errors.push({ step: this.currentStep, message: err.message }));
660
+ this.page.on("pageerror", (err) => this.errors.push({ step: this.currentStep, message: this.scrub(err.message) }));
635
661
  // A script that comes back as an HTML page (a bot check, or files a new deployment no longer has) is refused.
636
662
  this.page.on("console", (msg) => {
637
663
  if (msg.type() === "error" && /^Refused to execute script from .*MIME type \('text\/html'\)/.test(msg.text())) this.refusedScripts.set(this.currentStep, (this.refusedScripts.get(this.currentStep) ?? 0) + 1);
@@ -659,7 +685,7 @@ export class AuditSession {
659
685
  const frame = this.frameOf(request);
660
686
  for (const hit of hits) {
661
687
  if (!this.checks(hit.platform)) continue;
662
- const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now(), seq: ++this.seq, ...(frame ? { frame } : {}) };
688
+ const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.scrub(this.page.url()), t: Date.now(), seq: ++this.seq, ...(frame ? { frame } : {}) };
663
689
  const late = this.lateFor(hit, entry);
664
690
  if (late) Object.assign(entry, late);
665
691
  if (this.record("hits", entry)) recorded.push(entry);
@@ -688,10 +714,10 @@ export class AuditSession {
688
714
  const vendor = vendorOf(request.url());
689
715
  const call = {
690
716
  step: this.currentStep,
691
- pageUrl: this.opts.keepBodies ? this.page.url() : redactUrl(this.page.url()).url,
717
+ pageUrl: this.scrub(this.opts.keepBodies ? this.page.url() : redactUrl(this.page.url()).url),
692
718
  t: Date.now(),
693
719
  method: request.method(),
694
- url: this.opts.keepBodies ? request.url() : redactUrl(request.url()).url,
720
+ url: this.scrub(this.opts.keepBodies ? request.url() : redactUrl(request.url()).url),
695
721
  type: request.resourceType(),
696
722
  vendor,
697
723
  provider: provider.key,
@@ -831,7 +857,16 @@ export class AuditSession {
831
857
  return true;
832
858
  }
833
859
 
834
- redactHit({ userFields, ...hit }) {
860
+ /** A copy with the test account's values turned back into placeholders; the value itself when there is no test account. */
861
+ keepOut(value) {
862
+ return this.login ? scrubDeep(value, this.scrub) : value;
863
+ }
864
+
865
+ redactHit(raw) {
866
+ return this.keepOut(this.redactUserData(raw));
867
+ }
868
+
869
+ redactUserData({ userFields, ...hit }) {
835
870
  const found = { fields: [], plain: [] };
836
871
  const site = hostOf(hit.params?.page_location) ?? hostOf(this.page.url());
837
872
  const url = redactUrl(hit.url, found, site).url;
@@ -845,9 +880,9 @@ export class AuditSession {
845
880
  /** A dataLayer entry with user data redacted, and userData: { fields, plain } when there was any. */
846
881
  redactEntry(entry) {
847
882
  const found = redactPush(entry.value, undefined, hostOf(this.page.url()));
848
- if (!found.fields.length) return entry;
883
+ if (!found.fields.length) return this.keepOut(entry);
849
884
  const userData = { fields: [...new Set(found.fields)], plain: [...new Set(found.plain)] };
850
- return this.opts.keepBodies ? { ...entry, userData } : { ...entry, value: found.value, userData };
885
+ return this.keepOut(this.opts.keepBodies ? { ...entry, userData } : { ...entry, value: found.value, userData });
851
886
  }
852
887
 
853
888
  /** { postData } for a tracking POST, redacted, within the per-audit budget; { bodyDropped } past it. */
@@ -859,7 +894,7 @@ export class AuditSession {
859
894
  return { bodyDropped: true };
860
895
  }
861
896
  this.bodyBytes += body.length;
862
- return { postData: this.opts.keepBodies ? body : redactBody(body).text };
897
+ return { postData: this.scrub(this.opts.keepBodies ? body : redactBody(body).text) };
863
898
  }
864
899
 
865
900
  /**
@@ -870,6 +905,12 @@ export class AuditSession {
870
905
  recordLoad(entry, frame = null) {
871
906
  const container = containerOf(entry?.src);
872
907
  if (!container) return;
908
+ // A preload hint is not a load: it downloads the script without running it (see unseenContainers).
909
+ if (entry.how === "preload") {
910
+ const k = `${container.id}|${container.host}`;
911
+ if (!frame && !this.containerPreloads.has(k)) this.containerPreloads.set(k, { id: container.id, host: container.host, step: this.currentStep, page: pagePath(entry.page) });
912
+ return;
913
+ }
873
914
  this.record("containerLoads", {
874
915
  ...container,
875
916
  how: entry.how === "parser" ? "parser" : "script",
@@ -920,7 +961,7 @@ export class AuditSession {
920
961
  this.record("frameMessages", {
921
962
  origin: String(m.origin).slice(0, 200),
922
963
  party,
923
- name: String(m.name ?? "").slice(0, 80),
964
+ name: this.scrub(String(m.name ?? "")).slice(0, 80),
924
965
  ...(m.nameKey ? { nameKey: String(m.nameKey).slice(0, 20) } : {}),
925
966
  keys: (Array.isArray(m.keys) ? m.keys : []).slice(0, 20).map((k) => String(k).slice(0, 40)),
926
967
  step: this.currentStep,
@@ -1077,9 +1118,10 @@ export class AuditSession {
1077
1118
  }
1078
1119
  if (more) lines.push(`(more elements not listed: scroll to bring them on screen, or use text to click something by its visible text)`);
1079
1120
  const note = this.steps[this.currentStep]?.note;
1080
- return { url: main.url, title: main.title, elements: lines.join("\n"), text: main.text, note };
1121
+ // What the model reads never holds the test account: a username a page shows back reads as its placeholder.
1122
+ return { url: this.scrub(main.url), title: this.scrub(main.title), elements: this.scrub(lines.join("\n")), text: this.scrub(main.text), note };
1081
1123
  } catch (err) {
1082
- return { url: this.page.url(), title: "", elements: "", text: `(snapshot failed: ${err.message})` };
1124
+ return { url: this.scrub(this.page.url()), title: "", elements: "", text: `(snapshot failed: ${this.scrub(err.message)})` };
1083
1125
  }
1084
1126
  }
1085
1127
 
@@ -1211,10 +1253,13 @@ export class AuditSession {
1211
1253
  * @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean, rejects_consent?: boolean }} a
1212
1254
  */
1213
1255
  async act(a) {
1214
- const error = await this.perform(a);
1256
+ const error = this.scrub(await this.perform(a));
1215
1257
  await this.observe();
1216
1258
  // A scripted replay of this step may fail the same way, which must not end the journey there.
1217
1259
  if (error) this.steps[this.currentStep].error = error;
1260
+ // A saved step keeps placeholders, never the test account (a click's text can be a username the page shows).
1261
+ const step = this.steps[this.currentStep];
1262
+ if (step.replay) step.replay = this.keepOut(step.replay);
1218
1263
  this.steps[this.currentStep].pagePath = pagePath(this.page.url());
1219
1264
  this.steps[this.currentStep].endedAt = Date.now(); // when the step was reported, so a late hit can say how late
1220
1265
  return error;
@@ -1240,6 +1285,15 @@ export class AuditSession {
1240
1285
  const tagConsent = await this.evaluateWithin(googleConsentStateInPage);
1241
1286
  const before = this.steps.slice(0, this.currentStep).map((st) => st.tagConsent).filter(Boolean).at(-1);
1242
1287
  if (tagConsent && JSON.stringify(tagConsent) !== JSON.stringify(before)) this.steps[this.currentStep].tagConsent = tagConsent;
1288
+ // Which containers ran, and which the page only preloaded: a container can download and never run.
1289
+ const found = await this.evaluateWithin(containersInPage);
1290
+ for (const id of (found?.registry ?? []).map((k) => String(k).toUpperCase()).filter(isContainerId)) {
1291
+ if (!this.containersRunning.has(id)) this.containersRunning.set(id, this.currentStep);
1292
+ }
1293
+ for (const c of (found?.preloads ?? []).map(containerOf).filter(Boolean)) {
1294
+ const k = `${c.id}|${c.host}`;
1295
+ if (!this.containerPreloads.has(k)) this.containerPreloads.set(k, { id: c.id, host: c.host, step: this.currentStep, page: pagePath(this.page.url()) });
1296
+ }
1243
1297
  }
1244
1298
 
1245
1299
  async perform(a) {
@@ -1285,7 +1339,20 @@ export class AuditSession {
1285
1339
  } else return `${a.action} needs a ref or the element's visible text.`;
1286
1340
  step.replay = { action: a.action, ...(await replayTarget(a, target)), ...(a.value !== undefined ? { value: a.value } : {}) };
1287
1341
  if (a.action === "click") await this.click(target);
1288
- else if (a.action === "fill") await target.fill(a.value ?? "", { timeout: 10000 });
1342
+ else if (a.action === "fill" && hasLoginPlaceholder(a.value)) {
1343
+ // The test account is typed only into the audited site's own pages, never a frame from elsewhere.
1344
+ const host = await target.evaluate(() => location.hostname).catch(() => "");
1345
+ if (this.scope && !this.scope.allows(host)) return `The test account is only filled in on ${this.scope.hosts.join(", ")}, not on ${host || "this frame"}.`;
1346
+ let value;
1347
+ try {
1348
+ value = fillLoginValue(a.value, this.login);
1349
+ } catch (err) {
1350
+ if (err.code === "NO_LOGIN") return err.message;
1351
+ throw err;
1352
+ }
1353
+ await target.fill(value, { timeout: 10000 });
1354
+ if (/login\.password/.test(a.value) && this.loginStep === null) this.loginStep = this.currentStep;
1355
+ } else if (a.action === "fill") await target.fill(a.value ?? "", { timeout: 10000 });
1289
1356
  else await target.selectOption(a.value ?? "", { timeout: 10000 });
1290
1357
  break;
1291
1358
  }