@analitikisler/ai-tag-debugger 0.22.5 → 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,13 @@
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
+
3
11
  ## 0.22.5 (2026-11-04)
4
12
 
5
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.
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
 
@@ -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
 
@@ -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.5",
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: [], containersRunning: [], containerPreloads: [], 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,
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";
@@ -12,6 +13,7 @@ import { campaignParamsIn } from "./campaign.js";
12
13
  import { detectCmpInPage } from "./cmp.js";
13
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 = {
@@ -224,7 +226,7 @@ function snapshotInPage({ first, limit, textLimit, frameBox, offscreenLimit = nu
224
226
  const forLabel = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.innerText : "";
225
227
  const img = el.querySelector?.("img[alt], svg title");
226
228
  return clean(
227
- 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") ||
228
230
  el.getAttribute("title") || (img ? img.getAttribute("alt") || img.textContent : "") || el.getAttribute("name") || "",
229
231
  ).slice(0, 80);
230
232
  };
@@ -575,6 +577,10 @@ export class AuditSession {
575
577
  this.opts = { blockHits: true, headless: true, ...opts };
576
578
  this.log = this.opts.log ?? (() => {});
577
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
578
584
  this.isBlockedAddress = typeof this.opts.blockPrivate === "function" ? this.opts.blockPrivate : isPrivateAddress;
579
585
  this.isPrivate = this.opts.blockPrivate ? privateHostChecker(this.isBlockedAddress) : null;
580
586
  this.blocked = null;
@@ -613,6 +619,8 @@ export class AuditSession {
613
619
  }
614
620
 
615
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>`);
616
624
  const device = VIEWPORTS[this.opts.viewport];
617
625
  if (!device) throw new Error(`Unknown viewport "${this.opts.viewport}". Use: ${Object.keys(VIEWPORTS).join(", ")}`);
618
626
  const launch = { headless: this.opts.headless, env: browserEnv() };
@@ -636,7 +644,9 @@ export class AuditSession {
636
644
  this.browser = await chromium.launch(launch);
637
645
  }
638
646
  const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
639
- 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 });
640
650
  await this.context.exposeBinding("__atdRecord", (source, entry) => {
641
651
  const frame = frameInfo(source?.frame);
642
652
  if (frame) entry = { ...entry, frame };
@@ -647,7 +657,7 @@ export class AuditSession {
647
657
  await this.context.exposeBinding("__atdLoad", (source, entry) => this.recordLoad(entry, frameInfo(source?.frame)));
648
658
  await this.context.addInitScript(INIT_SCRIPT);
649
659
  this.page = await this.context.newPage();
650
- 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) }));
651
661
  // A script that comes back as an HTML page (a bot check, or files a new deployment no longer has) is refused.
652
662
  this.page.on("console", (msg) => {
653
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);
@@ -675,7 +685,7 @@ export class AuditSession {
675
685
  const frame = this.frameOf(request);
676
686
  for (const hit of hits) {
677
687
  if (!this.checks(hit.platform)) continue;
678
- 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 } : {}) };
679
689
  const late = this.lateFor(hit, entry);
680
690
  if (late) Object.assign(entry, late);
681
691
  if (this.record("hits", entry)) recorded.push(entry);
@@ -704,10 +714,10 @@ export class AuditSession {
704
714
  const vendor = vendorOf(request.url());
705
715
  const call = {
706
716
  step: this.currentStep,
707
- 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),
708
718
  t: Date.now(),
709
719
  method: request.method(),
710
- url: this.opts.keepBodies ? request.url() : redactUrl(request.url()).url,
720
+ url: this.scrub(this.opts.keepBodies ? request.url() : redactUrl(request.url()).url),
711
721
  type: request.resourceType(),
712
722
  vendor,
713
723
  provider: provider.key,
@@ -847,7 +857,16 @@ export class AuditSession {
847
857
  return true;
848
858
  }
849
859
 
850
- 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 }) {
851
870
  const found = { fields: [], plain: [] };
852
871
  const site = hostOf(hit.params?.page_location) ?? hostOf(this.page.url());
853
872
  const url = redactUrl(hit.url, found, site).url;
@@ -861,9 +880,9 @@ export class AuditSession {
861
880
  /** A dataLayer entry with user data redacted, and userData: { fields, plain } when there was any. */
862
881
  redactEntry(entry) {
863
882
  const found = redactPush(entry.value, undefined, hostOf(this.page.url()));
864
- if (!found.fields.length) return entry;
883
+ if (!found.fields.length) return this.keepOut(entry);
865
884
  const userData = { fields: [...new Set(found.fields)], plain: [...new Set(found.plain)] };
866
- 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 });
867
886
  }
868
887
 
869
888
  /** { postData } for a tracking POST, redacted, within the per-audit budget; { bodyDropped } past it. */
@@ -875,7 +894,7 @@ export class AuditSession {
875
894
  return { bodyDropped: true };
876
895
  }
877
896
  this.bodyBytes += body.length;
878
- return { postData: this.opts.keepBodies ? body : redactBody(body).text };
897
+ return { postData: this.scrub(this.opts.keepBodies ? body : redactBody(body).text) };
879
898
  }
880
899
 
881
900
  /**
@@ -942,7 +961,7 @@ export class AuditSession {
942
961
  this.record("frameMessages", {
943
962
  origin: String(m.origin).slice(0, 200),
944
963
  party,
945
- name: String(m.name ?? "").slice(0, 80),
964
+ name: this.scrub(String(m.name ?? "")).slice(0, 80),
946
965
  ...(m.nameKey ? { nameKey: String(m.nameKey).slice(0, 20) } : {}),
947
966
  keys: (Array.isArray(m.keys) ? m.keys : []).slice(0, 20).map((k) => String(k).slice(0, 40)),
948
967
  step: this.currentStep,
@@ -1099,9 +1118,10 @@ export class AuditSession {
1099
1118
  }
1100
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)`);
1101
1120
  const note = this.steps[this.currentStep]?.note;
1102
- 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 };
1103
1123
  } catch (err) {
1104
- 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)})` };
1105
1125
  }
1106
1126
  }
1107
1127
 
@@ -1233,10 +1253,13 @@ export class AuditSession {
1233
1253
  * @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean, rejects_consent?: boolean }} a
1234
1254
  */
1235
1255
  async act(a) {
1236
- const error = await this.perform(a);
1256
+ const error = this.scrub(await this.perform(a));
1237
1257
  await this.observe();
1238
1258
  // A scripted replay of this step may fail the same way, which must not end the journey there.
1239
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);
1240
1263
  this.steps[this.currentStep].pagePath = pagePath(this.page.url());
1241
1264
  this.steps[this.currentStep].endedAt = Date.now(); // when the step was reported, so a late hit can say how late
1242
1265
  return error;
@@ -1316,7 +1339,20 @@ export class AuditSession {
1316
1339
  } else return `${a.action} needs a ref or the element's visible text.`;
1317
1340
  step.replay = { action: a.action, ...(await replayTarget(a, target)), ...(a.value !== undefined ? { value: a.value } : {}) };
1318
1341
  if (a.action === "click") await this.click(target);
1319
- 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 });
1320
1356
  else await target.selectOption(a.value ?? "", { timeout: 10000 });
1321
1357
  break;
1322
1358
  }
package/src/checks.js CHANGED
@@ -403,6 +403,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
403
403
  : { ...base, severity: "warning", check: "container_not_run", step: stepLabel(c.step), detail: d.containerNotRun(c.id, c.kind) });
404
404
  }
405
405
 
406
+ // Logged in with the test account: GA4 hits from then on should carry the site's user ID.
407
+ if (Number.isInteger(run.loginStep)) {
408
+ const after = run.hits.filter((h) => h.platform === "ga4" && h.step > run.loginStep);
409
+ if (after.length && !after.some((h) => h.hasUserId)) {
410
+ findings.push({ ...base, severity: "info", check: "user_id_missing", platform: "ga4", step: stepLabel(after[0].step), detail: d.userIdMissing(after.length) });
411
+ }
412
+ }
413
+
406
414
  // Widgets that tracked on their own, left out of the plan checks: said once per widget, so nothing goes missing silently.
407
415
  for (const w of widgetsOf(run.widgetHits ?? [])) {
408
416
  const first = (run.widgetHits ?? []).find((h) => h.frame.origin === w.origin);
package/src/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
+ import { chmod, mkdir } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { parseArgs } from "node:util";
5
6
  import { loadConfig, runAudit } from "./audit.js";
@@ -10,10 +11,48 @@ import { replaySavedSite, writeSavedSteps } from "./replay.js";
10
11
  import { readBaseline, writeBaseline } from "./baseline.js";
11
12
  import { savePlan } from "./plan.js";
12
13
  import { DEFAULT_DATA_DIR } from "./workspace.js";
14
+ import { resolveSessionFile, sessionFileFor } from "./login.js";
13
15
  import { commandText, intentToArgs, isSentence, needsWhatToTest, translate } from "./ask.js";
14
16
 
15
17
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
16
18
 
19
+ /**
20
+ * `aitd login`: opens the site in a visible browser for the user to log in by hand (a CAPTCHA, two-factor login or
21
+ * single sign-on are fine), then saves the session (cookies and storage) for runs to start from with --session saved.
22
+ * The file holds a live login, so only its owner may read it.
23
+ */
24
+ async function saveLoginSession(values) {
25
+ if (!values.site) throw new Error("login needs --site <url>: aitd login --site https://www.example.com");
26
+ try {
27
+ new URL(values.site);
28
+ } catch {
29
+ throw new Error(`"${values.site}" is not a full URL. Use e.g. https://www.example.com`);
30
+ }
31
+ const file = path.resolve(values.save ?? sessionFileFor(values.site, values["data-dir"] ?? DEFAULT_DATA_DIR));
32
+ const { AuditSession } = await import("./browser.js");
33
+ const session = new AuditSession({ viewport: "desktop", site: values.site, allowedHosts: values["allow-host"], headless: false, login: null });
34
+ await session.start();
35
+ try {
36
+ await session.act({ action: "goto", url: values.site });
37
+ console.log("Log in to the site in the browser window. When you are logged in, come back here and press Enter (Ctrl+C cancels).");
38
+ console.log(`The browser stays on ${session.scope.hosts.join(", ")}; for a login on another host (single sign-on), add --allow-host <host>.`);
39
+ await new Promise((resolve) => {
40
+ process.stdin.resume();
41
+ process.stdin.once("data", () => {
42
+ process.stdin.pause();
43
+ resolve();
44
+ });
45
+ });
46
+ await mkdir(path.dirname(file), { recursive: true });
47
+ await session.context.storageState({ path: file });
48
+ await chmod(file, 0o600);
49
+ console.log(`Saved the logged-in session to ${file}. It holds a live login: keep it private.`);
50
+ console.log(`Start a run from it with: aitd run --site ${values.site} --session ${values.save ? file : "saved"} --journey <id>`);
51
+ } finally {
52
+ await session.close();
53
+ }
54
+ }
55
+
17
56
  async function main(args = process.argv.slice(2)) {
18
57
  const { values, positionals } = parseArgs({
19
58
  args,
@@ -31,6 +70,8 @@ async function main(args = process.argv.slice(2)) {
31
70
  platforms: { type: "string", multiple: true },
32
71
  providers: { type: "string", multiple: true },
33
72
  "allow-host": { type: "string", multiple: true },
73
+ session: { type: "string" },
74
+ save: { type: "string" },
34
75
  "allow-private-network": { type: "boolean", default: false },
35
76
  "allow-live": { type: "boolean", default: false },
36
77
  scripted: { type: "boolean", default: false },
@@ -83,6 +124,10 @@ async function main(args = process.argv.slice(2)) {
83
124
  await startMcp({ http: values.http, port: values.port ? Number(values.port) : undefined, host: values.host, token: values.token, reportsDir: values.reports, publicUrl: values["public-url"], allowPrivateNetwork: values["allow-private-network"], allowLive: values["allow-live"] });
84
125
  return;
85
126
  }
127
+ if (command === "login" && !values.help) {
128
+ await saveLoginSession(values);
129
+ return;
130
+ }
86
131
  if (command === "replay" && !values.help) {
87
132
  const dirs = positionals.slice(1);
88
133
  if (!dirs.length) throw new Error("replay needs the folder of at least one site saved with --save-site.");
@@ -191,6 +236,9 @@ async function main(args = process.argv.slice(2)) {
191
236
  }
192
237
  if (values["keep-bodies"]) loaded.config = { ...loaded.config, keep_bodies: true };
193
238
  if (values["allow-host"]?.length) loaded.config = { ...loaded.config, allowed_hosts: [...(loaded.config.allowed_hosts ?? []), ...values["allow-host"]] };
239
+ // A session saved with `aitd login` ("saved") or a file: every journey starts logged in.
240
+ const sessionArg = values.session ?? loaded.config.session_file;
241
+ if (sessionArg) loaded.config = { ...loaded.config, session_file: resolveSessionFile(sessionArg, { site: loaded.config.site, dataDir: values["data-dir"] ?? DEFAULT_DATA_DIR }) };
194
242
  try {
195
243
  new URL(loaded.config.site);
196
244
  } catch {
package/src/consent.js CHANGED
@@ -184,7 +184,7 @@ export function scenarioLanding(config, campaign) {
184
184
  */
185
185
  async function runState(config, state, opts) {
186
186
  const session = new AuditSession({
187
- viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers,
187
+ viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, storageState: config.session_file, providers: config.providers,
188
188
  blockHits: config.block_hits !== false, headless: !opts.headed, ...(opts.blockPrivate ? { blockPrivate: opts.blockPrivate } : {}),
189
189
  });
190
190
  const landing = scenarioLanding(config, opts.campaign);
package/src/help.js CHANGED
@@ -13,6 +13,7 @@ Commands:
13
13
  run Audit what a site actually sends, and compare it to a plan
14
14
  replay Re-check saved sites offline, without AI
15
15
  mcp Start the MCP server for Claude, ChatGPT and other chat apps
16
+ login Log in to a site by hand once and save the session for runs to start from
16
17
  doctor Check that Node, Chromium, an AI key and the data folder are ready
17
18
  history Show how a site's saved runs trend: counts, open problems, flapping, journeys
18
19
 
@@ -41,6 +42,7 @@ Options:
41
42
  --events <names> Only check these events, e.g. purchase,add_to_cart
42
43
  --lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")}
43
44
  --headed Show the browser window
45
+ --session <file> Start logged in: saved (the session aitd login saved) or a session file
44
46
  --fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
45
47
  --baseline <file> Known findings to accept; they don't count toward --fail-on
46
48
  --update-baseline Write the baseline file after the run (creates it)
@@ -106,6 +108,23 @@ Advanced:
106
108
  --reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
107
109
  --public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
108
110
 
111
+ const LOGIN = `Usage: aitd login --site <url> [--save <file>] [--allow-host <host>] [--data-dir <dir>]
112
+
113
+ Open the site in a browser window, log in by hand (a CAPTCHA, a code sent to your phone or
114
+ single sign-on are fine), then press Enter in the terminal: the logged-in session (cookies and
115
+ storage) is saved to aitd/<domain>/session.json. Runs start from it with --session saved.
116
+ The file holds a live login: keep it private, and save it again when the login expires.
117
+
118
+ For journeys that can log in by themselves, set a test account instead:
119
+ AITD_LOGIN_EMAIL (or AITD_LOGIN_USERNAME) and AITD_LOGIN_PASSWORD. The model sees only
120
+ the placeholders {{login.email}} and {{login.password}}; the browser types the real values.
121
+
122
+ Options:
123
+ --site <url> The site to log in to (required)
124
+ --save <file> Where to save the session (default: aitd/<domain>/session.json)
125
+ --allow-host <host> Another host the login passes through, such as a single sign-on page (repeatable)
126
+ --data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
127
+
109
128
  const DOCTOR = `Usage: aitd doctor [--site <url>] [--data-dir <dir>]
110
129
 
111
130
  Check that this machine can run an audit: the Node version, Playwright's Chromium, whether
@@ -130,7 +149,7 @@ Options:
130
149
  --lang <code> Language of the lines: ${Object.keys(LANGUAGES).join(" or ")}
131
150
  --data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
132
151
 
133
- const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP, doctor: DOCTOR, history: HISTORY };
152
+ const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP, login: LOGIN, doctor: DOCTOR, history: HISTORY };
134
153
 
135
154
  /** Help text for a command, or the top-level help when the command is unknown or missing. */
136
155
  export function helpText(command) {
package/src/i18n.js CHANGED
@@ -388,6 +388,7 @@ const en = {
388
388
  widgetTracking: (origin, platforms) => `An embedded widget from ${origin} sent its own tracking: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. It goes to accounts the site's own tags don't use, so it was left out of the plan, coverage and duplicate checks; the consent and personal data checks still include it.`,
389
389
  iframeContainer: (id, origin, parent) => `${id} runs in an iframe from ${origin} embedded in ${parent}. The iframe has its own dataLayer: events and consent commands pushed in the page never reach it, so it needs its own.`,
390
390
  containerFailed: (id, host, status) => `The ${id} script from ${host} ${status === "failed" ? "failed to load" : `was answered with HTTP ${status}`}, so none of its tags ran on that page.`,
391
+ userIdMissing: (n) => `After logging in with the test account, ${n} GA4 ${n === 1 ? "hit went" : "hits went"} out without a user ID (uid). GA4 can't tie the visit to the account, so reports by user and journeys across devices miss it.`,
391
392
  containerNotRun: (id, kind) => `${id} ${kind === "preload" ? "was downloaded because of a preload hint in the page, but nothing added it as a script, and it" : "was loaded but"} never ran on any page of this run: Google's own registry never held it, so none of its tags fired.`,
392
393
  containerHeld: (id, vendor) => `${id} never ran, which is expected while ${vendor} holds it back until the visitor consents. This run never consented, so its tags weren't checked.`,
393
394
  widgetEventsUnshared: (origin, names) => `An embedded widget from ${origin} pushed ${names.map(q).join(", ")} to its own dataLayer, but none of its hits to the site's accounts carried ${names.length === 1 ? "it" : "them"}. The page's tags can't read a widget's dataLayer, so ${names.length === 1 ? "this event reaches" : "these events reach"} only the widget's own tracking.`,
@@ -454,6 +455,7 @@ const en = {
454
455
  widget_tracking: "Nothing is wrong with the site's tracking: the widget's own hits were kept out of the plan checks so they can't pass for the site's.",
455
456
  iframe_container: "Tags in the iframe miss what the page pushes, so conversions completed in it can go untracked or be sent without the visitor's consent.",
456
457
  container_failed: "None of the container's tags ran: page views, conversions and consent signals it sends were all lost on that page.",
458
+ user_id_missing: "Logged-in visits can't be tied to their accounts in GA4, so user-level reports and cross-device journeys undercount them.",
457
459
  container_not_run: "None of the container's tags fired: page views, conversions and remarketing were all lost wherever it never ran.",
458
460
  container_held: "The container's tags weren't checked on this run, since it waited for a consent the run never gave.",
459
461
  widget_events_unshared: "Conversions completed in the widget, such as a booking, a payment or a form, can be missing from the site's analytics and ad accounts.",
@@ -507,6 +509,7 @@ const en = {
507
509
  widget_tracking: "Nothing to fix for the plan. If the widget sets cookies or sends data before consent, the consent findings name it; ask its vendor how to hold it back.",
508
510
  iframe_container: "Push the events and the consent state inside the iframe as well (or pass them in with postMessage), or track the iframe's steps from the page.",
509
511
  container_failed: "Check the container ID and the address in the snippet; for a server container or the Google tag gateway, check that it serves gtm.js and gtag/js.",
512
+ user_id_missing: "Once the visitor is logged in, set user_id on the Google tag (the user_id field of the GA4 Google tag in GTM, or gtag('config', ID, { user_id })) to your own account ID, never an email.",
510
513
  container_not_run: "Check that something actually adds the container's script, the snippet or the framework component that loads it (a preload alone runs nothing); look for script errors that stop the page's own code, and for a consent platform that never releases it.",
511
514
  container_held: "Run the journey with consent accepted, or the consent scenarios, to check the container's tags.",
512
515
  widget_events_unshared: "If the site needs these events, have the widget send them to the site's accounts (most booking and form widgets take a GA4 or GTM ID), or pass them to the page with postMessage and push them to the page's dataLayer.",
@@ -560,6 +563,7 @@ const en = {
560
563
  widget_tracking: "hygiene",
561
564
  iframe_container: "hygiene",
562
565
  container_failed: "money",
566
+ user_id_missing: "hygiene",
563
567
  container_not_run: "money",
564
568
  container_held: "hygiene",
565
569
  widget_events_unshared: "money",
@@ -1264,6 +1268,7 @@ const tr = {
1264
1268
  widgetTracking: (origin, platforms) => `${origin} kaynaklı gömülü bir widget kendi izlemesini gönderdi: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. Bunlar sitenin kendi etiketlerinin kullanmadığı hesaplara gidiyor; bu yüzden plan, kapsam ve tekrar kontrollerinin dışında tutuldu, onay ve kişisel veri kontrolleri ise onları yine içeriyor.`,
1265
1269
  iframeContainer: (id, origin, parent) => `${id}, ${parent} içine gömülü ${origin} kaynaklı bir iframe içinde çalışıyor. iframe'in kendi dataLayer'ı var: sayfada gönderilen olaylar ve onay komutları ona hiç ulaşmaz, bu yüzden kendi olaylarına ve onayına ihtiyacı var.`,
1266
1270
  containerFailed: (id, host, status) => `${host} adresinden gelen ${id} betiği ${status === "failed" ? "yüklenemedi" : `HTTP ${status} ile yanıtlandı`}; bu yüzden o sayfada etiketlerinin hiçbiri çalışmadı.`,
1271
+ userIdMissing: (n) => `Test hesabıyla giriş yapıldıktan sonra ${n} GA4 isteği kullanıcı kimliği (uid) olmadan gönderildi. GA4 ziyareti hesaba bağlayamıyor; bu yüzden kullanıcıya göre raporlar ve cihazlar arası yolculuklar onu kaçırıyor.`,
1267
1272
  containerNotRun: (id, kind) => `${id} ${kind === "preload" ? "sayfadaki bir ön yükleme ipucu yüzünden indirildi, ama hiçbir şey onu betik olarak eklemedi ve" : "yüklendi ama"} bu çalıştırmanın hiçbir sayfasında çalışmadı: Google'ın kendi kaydı onu hiç tutmadı, bu yüzden etiketlerinin hiçbiri çalışmadı.`,
1268
1273
  containerHeld: (id, vendor) => `${id} hiç çalışmadı; ${vendor} onu ziyaretçi onay verene kadar beklettiği sürece bu beklenen bir durum. Bu çalıştırmada onay hiç verilmedi, bu yüzden etiketleri kontrol edilmedi.`,
1269
1274
  widgetEventsUnshared: (origin, names) => `${origin} kaynaklı gömülü bir widget kendi dataLayer'ına ${names.map(q).join(", ")} gönderdi, ancak sitenin hesaplarına giden isteklerinin hiçbiri ${names.length === 1 ? "onu" : "onları"} taşımadı. Sayfanın etiketleri bir widget'ın dataLayer'ını okuyamaz; bu yüzden ${names.length === 1 ? "bu olay" : "bu olaylar"} yalnızca widget'ın kendi izlemesine ulaşıyor.`,
@@ -1329,6 +1334,7 @@ const tr = {
1329
1334
  widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
1330
1335
  iframe_container: "iframe içindeki etiketler sayfanın gönderdiklerini görmez; içinde tamamlanan dönüşümler izlenmeyebilir ya da ziyaretçinin onayı olmadan gönderilebilir.",
1331
1336
  container_failed: "Container'ın etiketlerinin hiçbiri çalışmadı: gönderdiği sayfa görüntülemeleri, dönüşümler ve onay sinyalleri o sayfada kayboldu.",
1337
+ user_id_missing: "Giriş yapılmış ziyaretler GA4'te hesaplarına bağlanamıyor; kullanıcı düzeyindeki raporlar ve cihazlar arası yolculuklar onları eksik sayıyor.",
1332
1338
  container_not_run: "Container'ın etiketlerinin hiçbiri çalışmadı: hiç çalışmadığı her yerde sayfa görüntülemeleri, dönüşümler ve yeniden pazarlama kayboldu.",
1333
1339
  container_held: "Container onay beklediği ve bu çalıştırma onay vermediği için etiketleri kontrol edilmedi.",
1334
1340
  widget_events_unshared: "Widget içinde tamamlanan dönüşümler, örneğin bir rezervasyon, ödeme ya da form, sitenin analitik ve reklam hesaplarında eksik kalabilir.",
@@ -1380,6 +1386,7 @@ const tr = {
1380
1386
  widget_tracking: "Plan için düzeltilecek bir şey yok. Widget onaydan önce çerez yazıyor ya da veri gönderiyorsa onay bulguları onu adlandırır; nasıl durdurulacağını sağlayıcısına sorun.",
1381
1387
  iframe_container: "Olayları ve onay durumunu iframe içinde de gönderin (ya da postMessage ile iletin) veya iframe'deki adımları sayfadan izleyin.",
1382
1388
  container_failed: "Snippet'teki container kimliğini ve adresi kontrol edin; sunucu container'ı ya da Google etiket ağ geçidi için gtm.js ve gtag/js sunduğunu kontrol edin.",
1389
+ user_id_missing: "Ziyaretçi giriş yaptıktan sonra Google etiketinde user_id'yi kendi hesap kimliğinize ayarlayın (GTM'deki GA4 Google etiketinin user_id alanı ya da gtag('config', ID, { user_id })); asla bir e-posta kullanmayın.",
1383
1390
  container_not_run: "Container betiğini gerçekten bir şeyin eklediğini kontrol edin: snippet ya da onu yükleyen çatı bileşeni (yalnızca ön yükleme hiçbir şeyi çalıştırmaz); sayfanın kendi kodunu durduran betik hatalarına ve onu hiç serbest bırakmayan bir onay platformuna bakın.",
1384
1391
  container_held: "Container'ın etiketlerini kontrol etmek için yolculuğu onay kabul edilmiş olarak ya da onay senaryolarıyla çalıştırın.",
1385
1392
  widget_events_unshared: "Site bu olaylara ihtiyaç duyuyorsa widget'ın onları sitenin hesaplarına göndermesini sağlayın (çoğu rezervasyon ve form widget'ı bir GA4 ya da GTM kimliği kabul eder) ya da onları postMessage ile sayfaya iletip sayfanın dataLayer'ına gönderin.",
@@ -1432,6 +1439,7 @@ const tr = {
1432
1439
  widget_tracking: "hygiene",
1433
1440
  iframe_container: "hygiene",
1434
1441
  container_failed: "money",
1442
+ user_id_missing: "hygiene",
1435
1443
  container_not_run: "money",
1436
1444
  container_held: "hygiene",
1437
1445
  widget_events_unshared: "money",
package/src/journeys.js CHANGED
@@ -39,6 +39,12 @@ export const JOURNEY_PRESETS = {
39
39
  { platform: "meta", name: "Lead" },
40
40
  ],
41
41
  },
42
+ login: {
43
+ goal: "Accept cookies, open the login or sign-in page, and log in with the test account: fill {{login.email}} (or {{login.username}} where the site asks for a username) and {{login.password}}, then submit. Stop when the site shows you are logged in.",
44
+ events: [
45
+ { platform: "ga4", name: "login" },
46
+ ],
47
+ },
42
48
  search: {
43
49
  goal: "Accept cookies, use the site search to search for a common product or topic, and open the first result.",
44
50
  events: [
@@ -52,7 +58,7 @@ export const JOURNEY_PRESETS = {
52
58
  },
53
59
  };
54
60
 
55
- const ALIASES = { checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", decline: "decline_cookies", reject: "decline_cookies", reject_cookies: "decline_cookies", consent: "decline_cookies", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
61
+ const ALIASES = { signin: "login", sign_in: "login", log_in: "login", checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", decline: "decline_cookies", reject: "decline_cookies", reject_cookies: "decline_cookies", consent: "decline_cookies", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
56
62
 
57
63
  /** The preset for a name like "purchase" or "signup", or null. */
58
64
  export function presetFor(name) {
@@ -168,6 +174,7 @@ const PRESET_BY_EVENT = [
168
174
  ["purchase", ["purchase", "add_payment_info", "add_shipping_info", "begin_checkout"]],
169
175
  ["add_to_cart", ["add_to_cart", "view_cart"]],
170
176
  ["sign_up", ["sign_up"]],
177
+ ["login", ["login"]],
171
178
  ["lead", ["generate_lead"]],
172
179
  ["search", ["search", "view_search_results"]],
173
180
  ];
package/src/login.js ADDED
@@ -0,0 +1,91 @@
1
+ // A test account for journeys that log in. The email (or username) and password come from environment variables on
2
+ // the computer running the tool; the model only ever sees placeholders such as {{login.password}}, the browser fills
3
+ // in the real values (on the audited site's pages only, see AuditSession.perform), and every text the tool records
4
+ // or shows has them turned back into their placeholders.
5
+
6
+ import { existsSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { domainOf } from "./workspace.js";
9
+
10
+ export const LOGIN_ENV = { email: "AITD_LOGIN_EMAIL", username: "AITD_LOGIN_USERNAME", password: "AITD_LOGIN_PASSWORD" };
11
+
12
+ const PLACEHOLDER = /\{\{\s*login\.(email|username|password)\s*\}\}/g;
13
+
14
+ /**
15
+ * The test account the environment sets, as { email, username, password }, or null when none is set. username is
16
+ * AITD_LOGIN_USERNAME, else the email, for sites that ask for a username; email is the same the other way round.
17
+ */
18
+ export function loginFromEnv(env = process.env) {
19
+ const email = env[LOGIN_ENV.email]?.trim() || "";
20
+ const username = env[LOGIN_ENV.username]?.trim() || "";
21
+ const password = env[LOGIN_ENV.password] ?? "";
22
+ if (!email && !username && !password) return null;
23
+ return { email: email || username, username: username || email, password };
24
+ }
25
+
26
+ /** Whether a value asks for the test account: it holds {{login.email}}, {{login.username}} or {{login.password}}. */
27
+ export const hasLoginPlaceholder = (value) => typeof value === "string" && new RegExp(PLACEHOLDER.source).test(value);
28
+
29
+ /**
30
+ * The value with its placeholders replaced by the test account's. Throws an error with code NO_LOGIN, naming the
31
+ * environment variables to set, when a placeholder asks for something the environment doesn't hold.
32
+ */
33
+ export function fillLoginValue(value, login) {
34
+ return String(value).replace(PLACEHOLDER, (_, field) => {
35
+ const v = login?.[field];
36
+ if (!v) {
37
+ const name = field === "password" ? LOGIN_ENV.password : `${LOGIN_ENV.email} (or ${LOGIN_ENV.username})`;
38
+ const err = new Error(`No test account is set up for {{login.${field}}}: set ${name} on the computer running ai-tag-debugger. Without one, stop and report that the journey needs a login.`);
39
+ err.code = "NO_LOGIN";
40
+ throw err;
41
+ }
42
+ return v;
43
+ });
44
+ }
45
+
46
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+
48
+ /**
49
+ * A function that turns the test account's values back into their placeholders wherever they appear in a text, as
50
+ * typed or URL-encoded. Values shorter than 3 characters are left alone, since they would match ordinary text.
51
+ * Identity when there is no test account.
52
+ */
53
+ export function loginScrubber(login) {
54
+ if (!login) return (text) => text;
55
+ const pairs = [];
56
+ const taken = new Set();
57
+ // The password first: an email inside a password must not leave the rest of the password behind. A username that is
58
+ // the email (the default) reads as {{login.email}}.
59
+ for (const [field, value] of [["password", login.password], ["email", login.email], ["username", login.username]]) {
60
+ if (!value || value.length < 3 || taken.has(value)) continue;
61
+ taken.add(value);
62
+ for (const form of new Set([value, encodeURIComponent(value), encodeURIComponent(value).replace(/%20/g, "+")])) pairs.push([form, `{{login.${field}}}`]);
63
+ }
64
+ if (!pairs.length) return (text) => text;
65
+ const re = new RegExp(pairs.map(([v]) => escape(v)).sort((a, b) => b.length - a.length).join("|"), "g");
66
+ const to = new Map(pairs);
67
+ return (text) => (typeof text === "string" && text ? text.replace(re, (m) => to.get(m) ?? m) : text);
68
+ }
69
+
70
+ /** A copy of a value with every string in it scrubbed: for hits' parameters, dataLayer pushes and the like. */
71
+ export function scrubDeep(value, scrub) {
72
+ if (typeof value === "string") return scrub(value);
73
+ if (Array.isArray(value)) return value.map((v) => scrubDeep(v, scrub));
74
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrubDeep(v, scrub)]));
75
+ return value;
76
+ }
77
+
78
+ /** Where `aitd login` saves a site's logged-in session by default: aitd/<domain>/session.json, a gitignored folder. */
79
+ export function sessionFileFor(site, dataDir) {
80
+ return path.join(dataDir, domainOf(site), "session.json");
81
+ }
82
+
83
+ /**
84
+ * The session file a run should start from: "saved" for the one `aitd login` saved for the site, else a path.
85
+ * Throws when the file doesn't exist, with the command that saves one.
86
+ */
87
+ export function resolveSessionFile(value, { site, dataDir }) {
88
+ const file = value === "saved" ? sessionFileFor(site, dataDir) : path.resolve(String(value));
89
+ if (!existsSync(file)) throw new Error(`No saved login session at ${file}. Save one with: aitd login --site ${site}`);
90
+ return file;
91
+ }
package/src/mcp.js CHANGED
@@ -33,6 +33,7 @@ import { decodeGcd, DEFAULT_PROVIDERS, googleConsent, isCollectionEndpoint, pars
33
33
  import { hitParams } from "./hit-params.js";
34
34
  import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
35
35
  import { cmpFacts } from "./cmp.js";
36
+ import { resolveSessionFile } from "./login.js";
36
37
  import { containerRows, containersNotRun, loadSources, unseenContainers } from "./containers.js";
37
38
  import { isEventMessage, isWidgetHit, messageOutcomes, pushedEvent, siteIds, splitRun, widgetSummaries } from "./frames.js";
38
39
  import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
@@ -441,10 +442,14 @@ export function buildServer(opts) {
441
442
  .optional()
442
443
  .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
443
444
  campaign: campaignSchema().describe(`Open the first page the way an ad click does, so finish_audit checks that the campaign parameters survive redirects and reach the hits. ${CAMPAIGN_DOC}`),
445
+ session: z
446
+ .enum(["saved"])
447
+ .optional()
448
+ .describe("Start logged in, from the session saved for this site with `aitd login --site <url>` on the computer running this server. For sites whose login needs a person (a CAPTCHA or a code). Local servers only."),
444
449
  live: z.boolean().optional().describe("Default false: tracking requests are answered locally and never reach the site's accounts. true lets them through to the real analytics and ad 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. Only when the user asks for it."),
445
450
  },
446
451
  },
447
- async ({ site, journey, viewport, allowed_hosts, providers, campaign = true, live = false }) => {
452
+ async ({ site, journey, viewport, allowed_hosts, providers, campaign = true, live = false, session: savedSession }) => {
448
453
  let url;
449
454
  try {
450
455
  url = new URL(site);
@@ -461,7 +466,10 @@ export function buildServer(opts) {
461
466
  let session;
462
467
  try {
463
468
  if (live && !opts.allowLive) return fail("Live audits are off on this server: it was started without --allow-live. Leave live out to audit with tracking requests answered locally.");
464
- session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate, blockHits: !live });
469
+ // A saved login lives on the computer running the server; over HTTP, a chat elsewhere must not use it.
470
+ if (savedSession && !opts.allowFiles) return fail("A saved login session can only be used on a local server. Leave session out, or log in with the test account during the journey.");
471
+ const storageState = savedSession ? resolveSessionFile("saved", { site: url.href, dataDir: opts.dataDir ?? DEFAULT_DATA_DIR }) : undefined;
472
+ session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate, blockHits: !live, storageState });
465
473
  } catch (err) {
466
474
  return fail(err.message);
467
475
  }
@@ -483,7 +491,8 @@ export function buildServer(opts) {
483
491
  const sent = campaignParamsIn(start);
484
492
  const opened = Object.keys(sent).length ? `Opened like an ad click with ${Object.entries(sent).map(([k, v]) => `${k}=${v}`).join(", ")}; finish_audit checks that they survive redirects and reach the hits.\n` : "";
485
493
  const liveLine = live ? `${LIVE_NOTE}\n` : "";
486
- return text(`audit_id: ${id}\n${liveLine}${opened}${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
494
+ const accountLine = `${session.login ? "A test account is set up: to log in, fill {{login.email}} (or {{login.username}}) and {{login.password}}, written exactly like that; the browser types the real values." : "No test account is set up on this computer (AITD_LOGIN_EMAIL and AITD_LOGIN_PASSWORD), so a journey that needs a login stops at it."}${savedSession ? " The browser started from the saved login session." : ""}\n`;
495
+ return text(`audit_id: ${id}\n${liveLine}${accountLine}${opened}${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
487
496
  },
488
497
  );
489
498
 
package/src/parsers.js CHANGED
@@ -125,6 +125,8 @@ export function parseGa4(url, postData) {
125
125
  protocol: protocolOf(merged),
126
126
  ...(items.length ? { items } : {}),
127
127
  ...(autoEvents.length ? { autoEvents } : {}),
128
+ // Whether the hit carried the site's user ID (uid), not the ID itself: the login check only needs to know.
129
+ ...(merged.get("uid") ? { hasUserId: true } : {}),
128
130
  ...(user.length ? { userFields: Object.fromEntries(user) } : {}),
129
131
  url,
130
132
  };
package/src/schema.js CHANGED
@@ -68,6 +68,7 @@ export const CONFIG_SCHEMA = z.looseObject({
68
68
  viewports: z.array(z.enum(Object.keys(VIEWPORTS))).optional(),
69
69
  journeys: z.array(JOURNEY_SCHEMA).optional(),
70
70
  allowed_hosts: names.optional(),
71
+ session_file: text.optional(),
71
72
  providers: z.union([names, text], { error: 'must be a list of platforms, e.g. ["ga4", "meta"]' }).optional(),
72
73
  detect_providers: flag.optional(),
73
74
  campaign_checks: flag.optional(),