@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 +8 -0
- package/README.md +25 -2
- package/README.tr.md +25 -2
- package/package.json +1 -1
- package/src/agent.js +5 -2
- package/src/audit.js +4 -1
- package/src/browser.js +52 -16
- package/src/checks.js +8 -0
- package/src/cli.js +48 -0
- package/src/consent.js +1 -1
- package/src/help.js +20 -1
- package/src/i18n.js +8 -0
- package/src/journeys.js +8 -1
- package/src/login.js +91 -0
- package/src/mcp.js +12 -3
- package/src/parsers.js +2 -0
- package/src/schema.js +1 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|