@analitikisler/ai-tag-debugger 0.10.0 → 0.11.0
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 +7 -0
- package/README.md +39 -2
- package/README.tr.md +39 -2
- package/package.json +1 -1
- package/src/ask.js +123 -0
- package/src/audit.js +13 -5
- package/src/changes.js +66 -0
- package/src/cli.js +73 -2
- package/src/discover.js +41 -4
- package/src/help.js +4 -1
- package/src/i18n.js +12 -0
- package/src/mcp.js +53 -0
- package/src/report.js +28 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0 (2026-10-16)
|
|
4
|
+
|
|
5
|
+
- Every saved run is compared with the site's previous run. The report opens with a "Changed since last run" section (`2 events fixed, 1 new problem`) that lists what was fixed and what is new, by plan coverage when both runs used `--against` and by health findings otherwise. The first run says there is nothing to compare yet. No new flag; the result is also in `capture.json` under `changes`.
|
|
6
|
+
- A plain sentence can be a request: `aitd "check my GA tags"`. The sentence becomes a fixed command through the AI provider, or a keyword matcher when no key is set. aitd prints the command and asks before running it; `-y` skips the question. An unclear sentence gets one short question back. A sentence that names no journey, goal or plan asks what to test, and suggests the site's latest plan when it has one.
|
|
7
|
+
- `aitd plan --topic <name>` limits a plan to one kind of event: `custom-events`, `ecommerce`, `forms` or `consent`. Sentences such as "what custom events does my site need" use it.
|
|
8
|
+
- The MCP server has a `make_plan` tool that drafts a tracking plan for a site, with an optional topic. Chat apps use it when a user asks what a site should track, and `run_audit` or `finish_audit` checks the audit against the plan.
|
|
9
|
+
|
|
3
10
|
## 0.10.0 (2026-10-16)
|
|
4
11
|
|
|
5
12
|
- Plan, then audit. `aitd plan --site <url>` drafts a measurement plan in its own folder, `aitd/<domain>/plans/<id>/`: the events a site should send, with a description, the page and element that trigger each one, its parameters (custom ones marked) with example values, and suggested user properties. Events the site already sends are marked `seen`. It browses one page of each type (home, a category, a product, cart, first checkout step, search, sign-up and contact forms) without paying or submitting personal details, and prints the page types it found and the ones the site doesn't have.
|
package/README.md
CHANGED
|
@@ -141,6 +141,33 @@ No API key yet? Replay the fixed steps without any AI model:
|
|
|
141
141
|
node src/cli.js run --config examples/demo.config.json --scripted --no-ai --data-dir reports/demo
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
## Just ask
|
|
145
|
+
|
|
146
|
+
You don't have to learn the options first. Put a plain sentence in quotes and aitd turns it into the matching command, prints that command and asks `Run this? (Y/n)` before it runs anything:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
aitd "check my google analytics tags"
|
|
150
|
+
# aitd run --platforms ga4
|
|
151
|
+
# Run this? (Y/n)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| You type | aitd runs |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `"Check my google analytics tags"` | `aitd run --platforms ga4` |
|
|
157
|
+
| `"What changed on my site since the last run"` | `aitd run` (every run already reports what changed) |
|
|
158
|
+
| `"Compare my site against the plan"` | `aitd run --against latest` |
|
|
159
|
+
| `"What custom events does my site need"` | `aitd plan --topic custom-events` |
|
|
160
|
+
| `"Is my Meta pixel firing on checkout?"` | `aitd run --platforms meta --goal "reach checkout"` |
|
|
161
|
+
| `"Test signing up for the newsletter"` | `aitd run --goal "sign up for the newsletter"` |
|
|
162
|
+
| `"Does my purchase event send the right revenue?"` | `aitd run --journey purchase` |
|
|
163
|
+
| `"Make a tracking plan for example.com"` | `aitd plan --site https://example.com` |
|
|
164
|
+
| `"What ecommerce events should an online shop track?"` | `aitd plan --topic ecommerce` |
|
|
165
|
+
| `"Check TikTok and Google Ads on the contact form"` | `aitd run --platforms google_ads,tiktok --goal "submit the contact form"` |
|
|
166
|
+
|
|
167
|
+
Add `-y` to skip the question: `aitd -y "check my google analytics tags"`.
|
|
168
|
+
|
|
169
|
+
If a sentence leaves something out, aitd asks one short question instead of guessing: plan or run, which site, or what to test. Reports can't be filtered from a sentence yet (`"Show me only the errors from the last report"` says so). Without an AI key, a small keyword matcher handles the common phrases. With one, the AI reads any wording, but it can only pick options that exist, so it never invents a flag.
|
|
170
|
+
|
|
144
171
|
## Plan, then audit
|
|
145
172
|
|
|
146
173
|
A run checks what a site sends against a **plan**: the events, parameters and user properties you expect. Three steps:
|
|
@@ -159,6 +186,12 @@ aitd run --site https://yoursite.com --against latest
|
|
|
159
186
|
|
|
160
187
|
**Drafting a plan** needs no tracking code. The AI explores one page of each type: the home page, a category, a product, the cart, the first checkout step, search, and any sign-up or contact forms. It never pays, and it never submits a form with personal details. `--provider`, `--model` and `--max-steps` (default 25, the most AI steps) work as they do for `run`. The output prints "Page types found" and "Not present on this site", so you can see which page types the AI reached. A small site, with only 2 pages, is fine.
|
|
161
188
|
|
|
189
|
+
To draft a plan for one kind of event only, add `--topic`: `custom-events`, `ecommerce`, `forms` or `consent`. The plan then covers just those events, and the rest of the plan format is unchanged:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
aitd plan --site https://yoursite.com --topic ecommerce
|
|
193
|
+
```
|
|
194
|
+
|
|
162
195
|
The plan is saved in a new folder, `aitd/<domain>/plans/<id>/`, as `measurement-plan.json` and a readable `plan.html`. For each event it lists:
|
|
163
196
|
|
|
164
197
|
- `name` and `platform`. Events specific to your site, such as a size guide or a store locator, are marked `custom: true`.
|
|
@@ -224,6 +257,8 @@ aitd/
|
|
|
224
257
|
|
|
225
258
|
An ID is the local time, `YYYY-MM-DD_HHMM`. When that minute is already taken, seconds are added (`_HHMMSS`). Nothing is ever replaced: each draft, import and run gets a new folder. Use `--data-dir <dir>` to keep the `aitd/` folder somewhere else. `replay` and `--save-site` folders are not affected.
|
|
226
259
|
|
|
260
|
+
Each report opens with a "Changed since last run" section that compares the run with the site's previous one in `runs/`, for example `2 events fixed, 1 new problem`, and lists each one. When both runs used `--against`, it compares plan coverage (an event that went from Missing to Implemented is fixed); otherwise it compares the health findings. The first run for a site says there is nothing to compare yet. There is no flag to set. The comparison is also saved under `changes` in `capture.json`.
|
|
261
|
+
|
|
227
262
|
## Choose what to test
|
|
228
263
|
|
|
229
264
|
You don't need a config file to start. Pick ready-made journeys, describe your own in plain language, check against a plan, and narrow the check to the events you care about:
|
|
@@ -331,7 +366,8 @@ When a journey ran on both viewports, only the first viewport's steps are kept.
|
|
|
331
366
|
## CLI
|
|
332
367
|
|
|
333
368
|
```
|
|
334
|
-
aitd
|
|
369
|
+
aitd "<sentence>" [-y]
|
|
370
|
+
aitd plan --site <url> [--topic <name>] [--headed] [--provider <name>] [--model <id>] [--max-steps <n>] [--data-dir <dir>]
|
|
335
371
|
aitd plan list --site <url> [--data-dir <dir>]
|
|
336
372
|
aitd plan import <file> --site <url> [--data-dir <dir>]
|
|
337
373
|
aitd plan export <plan> --site <url> [--format xlsx|csv] [--data-dir <dir>]
|
|
@@ -411,13 +447,14 @@ Tools the server offers:
|
|
|
411
447
|
|
|
412
448
|
| Tool | What it does |
|
|
413
449
|
|---|---|
|
|
450
|
+
| `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. |
|
|
414
451
|
| `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. |
|
|
415
452
|
| `browser_action` | One click, fill, select, goto, scroll or wait. Returns the page and the tracking hits that step sent. |
|
|
416
453
|
| `get_page` | Shows the current page. |
|
|
417
454
|
| `finish_audit` | Checks everything against your measurement plan (JSON), a saved one given as `against` (`latest`, `first`, a plan ID, or on a local server a path), or the ready-made journey's events, optionally only the `events` you name, and writes the full HTML report with the timeline. The site comes from the audit. |
|
|
418
455
|
| `run_audit` | Runs a config file end to end. With `against`, it checks the run against a saved plan and, when the config has no journeys, walks the journeys derived from that plan. Local only for file paths. |
|
|
419
456
|
|
|
420
|
-
`against` takes the same values as the CLI's `--against`. Over HTTP it takes only `latest`, `first` or a plan ID, never a path. Plans are the ones saved by `aitd plan --site <url
|
|
457
|
+
`against` takes the same values as the CLI's `--against`. Over HTTP it takes only `latest`, `first` or a plan ID, never a path. Plans are the ones saved by `aitd plan --site <url>` or by `make_plan`. Ask for a plan when you want to know what to track, and for an audit when you want to know what the site sends.
|
|
421
458
|
|
|
422
459
|
The browser runs on the computer where the server runs, so it can reach your staging and `localhost` sites. Tracking requests are answered locally as usual.
|
|
423
460
|
|
package/README.tr.md
CHANGED
|
@@ -141,6 +141,33 @@ Henüz bir API anahtarınız yok mu? Sabit adımları hiçbir yapay zekâ modeli
|
|
|
141
141
|
node src/cli.js run --config examples/demo.config.json --scripted --no-ai --data-dir reports/demo
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
## Sadece sorun
|
|
145
|
+
|
|
146
|
+
Önce tüm seçenekleri öğrenmeniz gerekmez. Tırnak içinde düz bir cümle yazın; aitd bunu eşleşen komuta çevirir, komutu gösterir ve çalıştırmadan önce `Run this? (Y/n)` diye sorar:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
aitd "check my google analytics tags"
|
|
150
|
+
# aitd run --platforms ga4
|
|
151
|
+
# Run this? (Y/n)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| Yazdığınız | aitd çalıştırır |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `"Check my google analytics tags"` | `aitd run --platforms ga4` |
|
|
157
|
+
| `"What changed on my site since the last run"` | `aitd run` (her çalıştırma neyin değiştiğini zaten raporlar) |
|
|
158
|
+
| `"Compare my site against the plan"` | `aitd run --against latest` |
|
|
159
|
+
| `"What custom events does my site need"` | `aitd plan --topic custom-events` |
|
|
160
|
+
| `"Is my Meta pixel firing on checkout?"` | `aitd run --platforms meta --goal "reach checkout"` |
|
|
161
|
+
| `"Test signing up for the newsletter"` | `aitd run --goal "sign up for the newsletter"` |
|
|
162
|
+
| `"Does my purchase event send the right revenue?"` | `aitd run --journey purchase` |
|
|
163
|
+
| `"Make a tracking plan for example.com"` | `aitd plan --site https://example.com` |
|
|
164
|
+
| `"What ecommerce events should an online shop track?"` | `aitd plan --topic ecommerce` |
|
|
165
|
+
| `"Check TikTok and Google Ads on the contact form"` | `aitd run --platforms google_ads,tiktok --goal "submit the contact form"` |
|
|
166
|
+
|
|
167
|
+
Soruyu atlamak için `-y` ekleyin: `aitd -y "check my google analytics tags"`.
|
|
168
|
+
|
|
169
|
+
Bir cümlede bir şey eksikse aitd tahmin yürütmek yerine tek bir kısa soru sorar: plan mı çalıştırma mı, hangi site, ya da neyin test edileceği. Rapor filtreleme henüz cümleyle yapılamaz (`"Show me only the errors from the last report"` bunu söyler). Yapay zekâ anahtarı yoksa, yaygın ifadeleri küçük bir anahtar sözcük eşleştirici karşılar. Anahtar varsa yapay zekâ her ifadeyi anlar, ama yalnızca var olan seçenekleri seçebilir; bu yüzden var olmayan bir bayrak uydurmaz. Örnek cümleler İngilizcedir, çünkü anahtar sözcük eşleştirici İngilizce ifadeleri tanır.
|
|
170
|
+
|
|
144
171
|
## Planla, sonra denetle
|
|
145
172
|
|
|
146
173
|
Bir çalıştırma, sitenin gönderdiklerini bir **plan** ile karşılaştırır: beklediğiniz event'ler, parametreler ve kullanıcı özellikleri. Üç adım:
|
|
@@ -159,6 +186,12 @@ aitd run --site https://yoursite.com --against latest
|
|
|
159
186
|
|
|
160
187
|
**Plan taslağı çıkarmak** takip kodu gerektirmez. Yapay zekâ her sayfa türünden birer sayfayı inceler: ana sayfa, bir kategori, bir ürün, sepet, ilk ödeme adımı, arama ve varsa üyelik ya da iletişim formları. Asla ödeme yapmaz ve kişisel bilgilerle bir formu göndermez. `--provider`, `--model` ve `--max-steps` (varsayılan 25, yapay zekâ için en fazla adım) `run` için olduğu gibi çalışır. Çıktı "Bulunan sayfa türleri" ve "Bu sitede yok" satırlarını yazdırır, böylece yapay zekânın hangi sayfa türlerine ulaştığını görürsünüz. Yalnızca 2 sayfası olan küçük bir site de sorun değildir.
|
|
161
188
|
|
|
189
|
+
Yalnızca bir tür event için plan çıkarmak istiyorsanız `--topic` ekleyin: `custom-events`, `ecommerce`, `forms` ya da `consent`. Plan yalnızca bu event'leri kapsar, plan biçimi değişmez:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
aitd plan --site https://yoursite.com --topic ecommerce
|
|
193
|
+
```
|
|
194
|
+
|
|
162
195
|
Plan, yeni bir klasöre, `aitd/<alan adı>/plans/<id>/` içine `measurement-plan.json` ve okunabilir bir `plan.html` olarak kaydedilir. Her event için şunları listeler:
|
|
163
196
|
|
|
164
197
|
- `name` ve `platform`. Sitenize özgü event'ler, örneğin bir beden rehberi ya da mağaza bulucu, `custom: true` ile işaretlenir.
|
|
@@ -224,6 +257,8 @@ aitd/
|
|
|
224
257
|
|
|
225
258
|
Bir kimlik, yerel saat `YYYY-MM-DD_HHMM` biçimindedir. O dakika zaten alınmışsa saniye eklenir (`_HHMMSS`). Hiçbir şey değiştirilmez: her taslak, içe aktarma ve çalıştırma yeni bir klasör alır. `aitd/` klasörünü başka bir yerde tutmak için `--data-dir <dir>` kullanın. `replay` ve `--save-site` klasörleri etkilenmez.
|
|
226
259
|
|
|
260
|
+
Her rapor, çalıştırmayı sitenin `runs/` içindeki bir önceki çalıştırmasıyla karşılaştıran "Son çalıştırmadan bu yana değişenler" bölümüyle açılır, örneğin `2 olay düzeldi, 1 yeni sorun`, ve her birini listeler. İki çalıştırma da `--against` kullandıysa plan kapsamı karşılaştırılır (Eksik iken Uygulanmış olan bir olay düzelmiş sayılır); aksi halde sağlık bulguları karşılaştırılır. Bir sitenin ilk çalıştırmasında karşılaştırılacak bir şey olmadığı yazar. Ayarlanacak bir bayrak yoktur. Karşılaştırma `capture.json` içinde `changes` altında da kaydedilir.
|
|
261
|
+
|
|
227
262
|
## Ne test edileceğini seçin
|
|
228
263
|
|
|
229
264
|
Başlamak için bir ayar dosyasına ihtiyacınız yok. Hazır yolculukları seçin, kendi yolculuğunuzu sade bir dille tarif edin, bir plana karşı kontrol edin ve kontrolü yalnızca ilgilendiğiniz event'lerle sınırlayın:
|
|
@@ -331,7 +366,8 @@ Bir yolculuk her iki görünümde de çalıştıysa yalnızca ilk görünümün
|
|
|
331
366
|
## CLI
|
|
332
367
|
|
|
333
368
|
```
|
|
334
|
-
aitd
|
|
369
|
+
aitd "<cümle>" [-y]
|
|
370
|
+
aitd plan --site <url> [--topic <ad>] [--headed] [--provider <ad>] [--model <id>] [--max-steps <n>] [--data-dir <dir>]
|
|
335
371
|
aitd plan list --site <url> [--data-dir <dir>]
|
|
336
372
|
aitd plan import <dosya> --site <url> [--data-dir <dir>]
|
|
337
373
|
aitd plan export <plan> --site <url> [--format xlsx|csv] [--data-dir <dir>]
|
|
@@ -411,13 +447,14 @@ Sunucunun sunduğu araçlar:
|
|
|
411
447
|
|
|
412
448
|
| Araç | Ne yapar |
|
|
413
449
|
|---|---|
|
|
450
|
+
| `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. |
|
|
414
451
|
| `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. |
|
|
415
452
|
| `browser_action` | Tek bir tıklama, doldurma, seçme, gitme, kaydırma ya da bekleme. Sayfayı ve o adımın gönderdiği takip isteklerini döndürür. |
|
|
416
453
|
| `get_page` | Geçerli sayfayı gösterir. |
|
|
417
454
|
| `finish_audit` | Her şeyi ölçümleme planınıza (JSON) ya da `against` olarak verilen kayıtlı bir plana (`latest`, `first`, bir plan kimliği ya da yerel bir sunucuda bir yol) ya da hazır yolculuğun event'lerine karşı kontrol eder; isterseniz yalnızca adını verdiğiniz `events` için, ve zaman akışıyla birlikte tam HTML raporunu yazar. Site denetimden alınır. |
|
|
418
455
|
| `run_audit` | Bir ayar dosyasını baştan sona çalıştırır. `against` ile çalıştırmayı kayıtlı bir plana karşı kontrol eder; ayar dosyasında yolculuk yoksa, plandan türetilen yolculukları gezer. Dosya yolları yalnızca yerelde çalışır. |
|
|
419
456
|
|
|
420
|
-
`against`, CLI'deki `--against` ile aynı değerleri alır. HTTP üzerinden yalnızca `latest`, `first` ya da bir plan kimliği kabul edilir, asla bir yol değil. Planlar, `aitd plan --site <url>` ile kaydedilmiş olanlardır.
|
|
457
|
+
`against`, CLI'deki `--against` ile aynı değerleri alır. HTTP üzerinden yalnızca `latest`, `first` ya da bir plan kimliği kabul edilir, asla bir yol değil. Planlar, `aitd plan --site <url>` ya da `make_plan` ile kaydedilmiş olanlardır. Neyin takip edileceğini öğrenmek için plan, sitenin ne gönderdiğini öğrenmek için denetim isteyin.
|
|
421
458
|
|
|
422
459
|
Tarayıcı, sunucunun çalıştığı bilgisayarda çalışır; bu yüzden staging ve `localhost` sitelerinize erişebilir. Takip istekleri her zamanki gibi yerelde yanıtlanır.
|
|
423
460
|
|
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.
|
|
4
|
+
"version": "0.11.0",
|
|
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/ask.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Turns a plain sentence like "check my GA tags" into an aitd command.
|
|
2
|
+
// The AI provider fills a fixed intent schema, so it can never invent options;
|
|
3
|
+
// without an AI key a small keyword matcher handles the common phrases.
|
|
4
|
+
import { PROVIDERS as PLATFORMS } from "./parsers.js";
|
|
5
|
+
|
|
6
|
+
const PLATFORM_KEYS = Object.keys(PLATFORMS);
|
|
7
|
+
// The same values as plan --topic (TOPICS in discover.js, kept in step by a test).
|
|
8
|
+
export const TOPIC_KEYS = ["custom-events", "ecommerce", "forms", "consent"];
|
|
9
|
+
|
|
10
|
+
export const INTENT_SCHEMA = {
|
|
11
|
+
type: "object",
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
required: ["action", "site", "against", "platforms", "goal", "journey", "topic", "question"],
|
|
14
|
+
properties: {
|
|
15
|
+
action: { type: "string", enum: ["plan", "run", "report", "unclear"], description: "plan drafts what a site should track; run audits what the site sends; report re-reads an existing report; unclear when you cannot tell plan from run." },
|
|
16
|
+
site: { type: ["string", "null"], description: "The site's domain or URL if the sentence names one." },
|
|
17
|
+
against: { type: ["string", "null"], enum: ["plan", "latest", null], description: "run only: compare with the saved plan, or with the latest run (\"what changed\", \"since the last run\", \"after the deploy\")." },
|
|
18
|
+
platforms: { type: "array", items: { type: "string", enum: PLATFORM_KEYS }, description: "Only the tracking platforms the sentence names; empty for all." },
|
|
19
|
+
goal: { type: ["string", "null"], description: "run only: a short user journey to try, in plain words, e.g. \"submit the contact form\"." },
|
|
20
|
+
journey: { type: ["string", "null"], enum: ["purchase", null], description: "run only: purchase when the sentence is about the purchase event or revenue." },
|
|
21
|
+
topic: { type: ["string", "null"], enum: [...TOPIC_KEYS, null], description: "plan only: limit the plan to one kind of event when the sentence asks about it." },
|
|
22
|
+
question: { type: ["string", "null"], description: "One short question to ask back when the sentence is ambiguous; otherwise null." },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const SYSTEM = `You translate a request about website tracking (analytics, ads, consent tags) into options for the aitd command line tool. Fill the schema with only what the sentence says; leave the rest null or empty. Use action "unclear" and a short question when you cannot tell whether the user wants a tracking plan or an audit of the live site.`;
|
|
27
|
+
|
|
28
|
+
const has = (s, re) => re.test(s);
|
|
29
|
+
|
|
30
|
+
/** The intent for a sentence from keywords alone, for when no AI key is set. */
|
|
31
|
+
export function matchKeywords(sentence) {
|
|
32
|
+
const s = ` ${sentence.toLowerCase()} `;
|
|
33
|
+
const intent = { action: null, site: null, against: null, platforms: [], goal: null, journey: null, topic: null, question: null };
|
|
34
|
+
const site = sentence.match(/\b(?:https?:\/\/)?((?:[a-z0-9-]+\.)+[a-z]{2,})(?:\/\S*)?/i);
|
|
35
|
+
if (site) intent.site = site[1].toLowerCase();
|
|
36
|
+
|
|
37
|
+
if (has(s, /google analytics|\bga4?\b/)) intent.platforms.push("ga4");
|
|
38
|
+
if (has(s, /google ads|adwords/)) intent.platforms.push("google_ads");
|
|
39
|
+
if (has(s, /\bmeta\b|facebook/)) intent.platforms.push("meta");
|
|
40
|
+
if (has(s, /tiktok/)) intent.platforms.push("tiktok");
|
|
41
|
+
|
|
42
|
+
if (has(s, /only the (errors|failures)|from the (last|latest) report/)) intent.action = "report";
|
|
43
|
+
else if (has(s, /tracking plan|make a plan|should .* track|does my site need|events .* need/)) intent.action = "plan";
|
|
44
|
+
else {
|
|
45
|
+
if (has(s, /since (the )?last run|what changed|\bbreak|\bbroke|deploy/)) intent.against = "latest";
|
|
46
|
+
else if (has(s, /(against|in) the plan/)) intent.against = "plan";
|
|
47
|
+
if (has(s, /revenue|purchase event/)) intent.journey = "purchase";
|
|
48
|
+
else if (has(s, /newsletter/)) intent.goal = "sign up for the newsletter";
|
|
49
|
+
else if (has(s, /accept(ing)? (the )?cookies|consent/)) intent.goal = "browse without accepting cookies";
|
|
50
|
+
else if (has(s, /contact form/)) intent.goal = "submit the contact form";
|
|
51
|
+
else if (has(s, /checkout/)) intent.goal = "reach checkout";
|
|
52
|
+
if (intent.platforms.length || intent.against || intent.goal || intent.journey || has(s, /\b(check|test|audit|firing|fire|working)\b/)) intent.action = "run";
|
|
53
|
+
}
|
|
54
|
+
if (intent.action === "plan") {
|
|
55
|
+
if (has(s, /custom events?/)) intent.topic = "custom-events";
|
|
56
|
+
else if (has(s, /ecommerce|e-commerce|online shop|\bshop\b|\bstore\b/)) intent.topic = "ecommerce";
|
|
57
|
+
else if (has(s, /\bforms?\b/)) intent.topic = "forms";
|
|
58
|
+
else if (has(s, /consent|cookies?/)) intent.topic = "consent";
|
|
59
|
+
}
|
|
60
|
+
if (!intent.action) {
|
|
61
|
+
intent.action = "unclear";
|
|
62
|
+
intent.question = "Do you want a tracking plan or a check of what the site sends? (plan/run)";
|
|
63
|
+
}
|
|
64
|
+
return intent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Asks the provider for the intent of a sentence; falls back to keywords without one. */
|
|
68
|
+
export async function translate(sentence, { provider } = {}) {
|
|
69
|
+
if (!provider) return { intent: matchKeywords(sentence), by: "keywords" };
|
|
70
|
+
const result = await provider.json({ system: SYSTEM, texts: [sentence], schema: INTENT_SCHEMA });
|
|
71
|
+
if (result.error) return { intent: matchKeywords(sentence), by: "keywords" };
|
|
72
|
+
return { intent: normalize(result.value), by: "ai" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Keeps only schema values, so an odd reply can never become an option. */
|
|
76
|
+
export function normalize(v = {}) {
|
|
77
|
+
const str = (x) => (typeof x === "string" && x.trim() ? x.trim() : null);
|
|
78
|
+
return {
|
|
79
|
+
action: ["plan", "run", "report", "unclear"].includes(v.action) ? v.action : "unclear",
|
|
80
|
+
site: str(v.site),
|
|
81
|
+
against: ["plan", "latest"].includes(v.against) ? v.against : null,
|
|
82
|
+
platforms: [...new Set((Array.isArray(v.platforms) ? v.platforms : []).filter((p) => PLATFORM_KEYS.includes(p)))],
|
|
83
|
+
goal: str(v.goal),
|
|
84
|
+
journey: v.journey === "purchase" ? "purchase" : null,
|
|
85
|
+
topic: TOPIC_KEYS.includes(v.topic) ? v.topic : null,
|
|
86
|
+
question: str(v.question),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const siteUrl = (site) => (/^https?:\/\//i.test(site) ? site : `https://${site}`);
|
|
91
|
+
|
|
92
|
+
/** The aitd arguments for an intent, or { question } when one more answer is needed. */
|
|
93
|
+
export function intentToArgs(intent) {
|
|
94
|
+
if (intent.action === "unclear") return { question: intent.question ?? "Do you want a tracking plan or a check of what the site sends? (plan/run)" };
|
|
95
|
+
if (intent.action === "report") return { unsupported: "Filtering an existing report is not available yet. Open the latest report in aitd/<domain>/runs/." };
|
|
96
|
+
const args = [intent.action];
|
|
97
|
+
if (intent.site) args.push("--site", siteUrl(intent.site));
|
|
98
|
+
if (intent.action === "plan" && intent.topic) args.push("--topic", intent.topic);
|
|
99
|
+
if (intent.action === "run") {
|
|
100
|
+
// --against takes a saved plan. Every run already reports what changed since the previous
|
|
101
|
+
// run, so "since the last run" needs no option of its own.
|
|
102
|
+
if (intent.against === "plan") args.push("--against", "latest");
|
|
103
|
+
if (intent.platforms.length) args.push("--platforms", intent.platforms.join(","));
|
|
104
|
+
if (intent.journey) args.push("--journey", intent.journey);
|
|
105
|
+
if (intent.goal) args.push("--goal", intent.goal);
|
|
106
|
+
}
|
|
107
|
+
return { args };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True when a run has nothing to test yet: no plan to check against, no journey, no goal. */
|
|
111
|
+
export function needsWhatToTest(args) {
|
|
112
|
+
return args[0] === "run" && !["--against", "--journey", "--goal"].some((o) => args.includes(o));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The command as the user would type it. */
|
|
116
|
+
export function commandText(args) {
|
|
117
|
+
return ["aitd", ...args.map((a) => (/^[\w.,:/@=-]+$/.test(a) ? a : `"${a.replace(/(["\\$`])/g, "\\$1")}"`))].join(" ");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when the arguments are one sentence rather than a command. */
|
|
121
|
+
export function isSentence(positionals) {
|
|
122
|
+
return positionals.length === 1 && /\s/.test(positionals[0].trim());
|
|
123
|
+
}
|
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
3
|
import { newRunDir } from "./workspace.js";
|
|
4
|
+
import { compareRuns, previousRun } from "./changes.js";
|
|
4
5
|
import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { failedRun, runJourney } from "./agent.js";
|
|
@@ -34,18 +35,23 @@ export async function loadConfig(configFile) {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Writes report.md, report.html, timeline.csv and capture.json, plus plan-check.json with --against.
|
|
40
|
+
* previous: the site's last saved run (see previousRun), null when there is none, or undefined to skip
|
|
41
|
+
* the "Changed since last run" section.
|
|
42
|
+
*/
|
|
43
|
+
export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, against = null, branding = config.branding !== false, previous = undefined }) {
|
|
39
44
|
await mkdir(outDir, { recursive: true });
|
|
40
45
|
const title = messages(lang).ui.title(new URL(config.site).hostname);
|
|
41
46
|
const generatedAt = new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
42
47
|
const check = against ? { plan_id: against.id ?? null, ...planCheck(plan, runs, findings) } : null;
|
|
43
|
-
const
|
|
48
|
+
const changes = previous === undefined ? null : compareRuns(previous, { findings, planCheck: check });
|
|
49
|
+
const report = { title, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes };
|
|
44
50
|
await writeFile(path.join(outDir, "report.md"), renderMarkdown(report));
|
|
45
51
|
await writeFile(path.join(outDir, "report.html"), renderHtml(report));
|
|
46
52
|
await writeFile(path.join(outDir, "timeline.csv"), renderCsv(report));
|
|
47
53
|
if (check) await writeFile(path.join(outDir, "plan-check.json"), JSON.stringify(check, null, 2));
|
|
48
|
-
await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained }, null, 2));
|
|
54
|
+
await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained, ...(changes ? { changes } : {}) }, null, 2));
|
|
49
55
|
return path.join(outDir, "report.html");
|
|
50
56
|
}
|
|
51
57
|
|
|
@@ -130,8 +136,10 @@ export async function runAudit(opts) {
|
|
|
130
136
|
explained = await explainFindings({ findings, plan, runs, gtmContainer, provider, lang, log });
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
// Runs saved in the site's runs folder are compared with the one before; a run sent to --out is not.
|
|
140
|
+
const previous = opts.outDir ? undefined : await previousRun(opts.dataDir, config.site);
|
|
133
141
|
const outDir = path.resolve(opts.outDir ?? (await newRunDir(opts.dataDir, config.site)).dir);
|
|
134
|
-
const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, against, branding: opts.branding ?? config.branding !== false });
|
|
142
|
+
const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, against, branding: opts.branding ?? config.branding !== false, previous });
|
|
135
143
|
const counts = summarize(groupFindings(findings));
|
|
136
144
|
return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, ...(against ? { againstId: against.id } : {}), ...(saveError ? { saveError } : {}) };
|
|
137
145
|
}
|
package/src/changes.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { listRunIds, runsDir } from "./workspace.js";
|
|
4
|
+
|
|
5
|
+
const PROBLEMS = new Set(["broken", "risk", "warning"]);
|
|
6
|
+
const PLAN_PROBLEMS = new Set(["missing", "missing_params", "wrong_values"]);
|
|
7
|
+
|
|
8
|
+
const readJson = (file) => readFile(file, "utf8").then(JSON.parse).catch(() => null);
|
|
9
|
+
|
|
10
|
+
/** The site's newest saved run before this one, with its capture and plan check, or null. */
|
|
11
|
+
export async function previousRun(dataDir, site, before = null) {
|
|
12
|
+
const ids = (await listRunIds(dataDir, site)).filter((id) => !before || id < before);
|
|
13
|
+
for (const id of ids.reverse()) {
|
|
14
|
+
const dir = path.join(runsDir(dataDir, site), id);
|
|
15
|
+
const capture = await readJson(path.join(dir, "capture.json"));
|
|
16
|
+
if (capture) return { id, capture, planCheck: await readJson(path.join(dir, "plan-check.json")) };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One entry per problem, keyed so the same problem matches across runs whatever its wording. */
|
|
22
|
+
function findingProblems(findings = []) {
|
|
23
|
+
const out = new Map();
|
|
24
|
+
for (const f of findings) {
|
|
25
|
+
if (!PROBLEMS.has(f.severity)) continue;
|
|
26
|
+
const k = [f.check, f.platform ?? "", f.event ?? ""].join("|");
|
|
27
|
+
if (!out.has(k)) out.set(k, { check: f.check, platform: f.platform ?? null, event: f.event ?? null, severity: f.severity, detail: f.detail });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function planProblems(pc) {
|
|
33
|
+
const out = new Map();
|
|
34
|
+
for (const e of pc?.events ?? []) out.set(`${e.platform}|${e.event}`, { platform: e.platform, event: e.event, bucket: e.bucket });
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What changed since the previous run of the same site. Compares plan coverage when both runs were
|
|
40
|
+
* checked against a plan, and the health findings otherwise.
|
|
41
|
+
* Returns { previous: null } when there is no earlier run, else
|
|
42
|
+
* { previous, mode: "plan" | "findings", fixed: [...], new: [...] }.
|
|
43
|
+
*/
|
|
44
|
+
export function compareRuns(prev, { findings, planCheck = null }) {
|
|
45
|
+
if (!prev) return { previous: null };
|
|
46
|
+
if (planCheck && prev.planCheck) {
|
|
47
|
+
const before = planProblems(prev.planCheck);
|
|
48
|
+
const now = planProblems(planCheck);
|
|
49
|
+
const fixed = [];
|
|
50
|
+
const added = [];
|
|
51
|
+
for (const [k, e] of now) {
|
|
52
|
+
const was = before.get(k)?.bucket;
|
|
53
|
+
if (e.bucket === "implemented" && PLAN_PROBLEMS.has(was)) fixed.push({ ...e, was });
|
|
54
|
+
if (PLAN_PROBLEMS.has(e.bucket) && !PLAN_PROBLEMS.has(was)) added.push({ ...e, was: was ?? null });
|
|
55
|
+
}
|
|
56
|
+
return { previous: prev.id, mode: "plan", fixed, new: added };
|
|
57
|
+
}
|
|
58
|
+
const before = findingProblems(prev.capture?.findings);
|
|
59
|
+
const now = findingProblems(findings);
|
|
60
|
+
return {
|
|
61
|
+
previous: prev.id,
|
|
62
|
+
mode: "findings",
|
|
63
|
+
fixed: [...before].filter(([k]) => !now.has(k)).map(([, f]) => f),
|
|
64
|
+
new: [...now].filter(([k]) => !before.has(k)).map(([, f]) => f),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -8,11 +8,13 @@ import { helpText } from "./help.js";
|
|
|
8
8
|
import { replaySavedSite, writeSavedSteps } from "./replay.js";
|
|
9
9
|
import { savePlan } from "./plan.js";
|
|
10
10
|
import { DEFAULT_DATA_DIR } from "./workspace.js";
|
|
11
|
+
import { commandText, intentToArgs, isSentence, needsWhatToTest, translate } from "./ask.js";
|
|
11
12
|
|
|
12
13
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
13
14
|
|
|
14
|
-
async function main() {
|
|
15
|
+
async function main(args = process.argv.slice(2)) {
|
|
15
16
|
const { values, positionals } = parseArgs({
|
|
17
|
+
args,
|
|
16
18
|
allowPositionals: true,
|
|
17
19
|
options: {
|
|
18
20
|
config: { type: "string" },
|
|
@@ -49,8 +51,10 @@ async function main() {
|
|
|
49
51
|
"data-dir": { type: "string" },
|
|
50
52
|
against: { type: "string" },
|
|
51
53
|
from: { type: "string" },
|
|
54
|
+
topic: { type: "string" },
|
|
52
55
|
format: { type: "string" },
|
|
53
56
|
force: { type: "boolean", default: false },
|
|
57
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
54
58
|
help: { type: "boolean", short: "h", default: false },
|
|
55
59
|
version: { type: "boolean", short: "v", default: false },
|
|
56
60
|
},
|
|
@@ -61,6 +65,11 @@ async function main() {
|
|
|
61
65
|
return;
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
if (isSentence(positionals) && !values.help) {
|
|
69
|
+
const next = await fromSentence(positionals[0], values, args);
|
|
70
|
+
if (next) await main(next);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
64
73
|
if (command === "mcp" && !values.help) {
|
|
65
74
|
const { startMcp } = await import("./mcp.js");
|
|
66
75
|
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"] });
|
|
@@ -105,6 +114,7 @@ async function main() {
|
|
|
105
114
|
if (values.out) throw new Error("plan --out was removed. Plans are saved in aitd/<domain>/plans/<id>/; use --data-dir <dir> to keep them elsewhere.");
|
|
106
115
|
if (values.force) throw new Error("--force was removed. Each plan is saved in a new folder, so nothing is replaced.");
|
|
107
116
|
if (!values.site) throw new Error("plan needs --site <url>: aitd plan --site https://www.example.com");
|
|
117
|
+
if (values.topic !== undefined) (await import("./discover.js")).checkTopic(values.topic);
|
|
108
118
|
const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
|
|
109
119
|
const { domainOf } = await import("./workspace.js");
|
|
110
120
|
domainOf(values.site); // Checked before the browser starts.
|
|
@@ -114,7 +124,7 @@ async function main() {
|
|
|
114
124
|
const { discoverPlan } = await import("./discover.js");
|
|
115
125
|
const provider = await createProvider({ name: values.provider, model: values.model, baseUrl: values["base-url"] });
|
|
116
126
|
console.log(`Exploring ${values.site} to draft its measurement plan…`);
|
|
117
|
-
const { plan } = await discoverPlan({ site: values.site, provider, maxSteps, headed: values.headed, allowedHosts: values["allow-host"], log: console.log });
|
|
127
|
+
const { plan } = await discoverPlan({ site: values.site, topic: values.topic, provider, maxSteps, headed: values.headed, allowedHosts: values["allow-host"], log: console.log });
|
|
118
128
|
const { id, dir } = await savePlan(plan, { dataDir, site: values.site, branding: !values["no-branding"] });
|
|
119
129
|
const custom = plan.events.filter((e) => e.custom).length;
|
|
120
130
|
const rel = path.relative(process.cwd(), dir);
|
|
@@ -212,6 +222,67 @@ async function main() {
|
|
|
212
222
|
}
|
|
213
223
|
}
|
|
214
224
|
|
|
225
|
+
// aitd "check my GA tags": shows the matching command and runs it once confirmed.
|
|
226
|
+
async function fromSentence(sentence, values, extra) {
|
|
227
|
+
const { createProvider } = await import("./providers.js");
|
|
228
|
+
const provider = await createProvider({ name: values.provider, model: values.model, baseUrl: values["base-url"] }).catch((err) => {
|
|
229
|
+
if (err.code === "NO_API_KEY") return null;
|
|
230
|
+
throw err;
|
|
231
|
+
});
|
|
232
|
+
let { intent } = await translate(sentence, { provider });
|
|
233
|
+
let r = intentToArgs(intent);
|
|
234
|
+
if (r.question) {
|
|
235
|
+
const answer = (await ask(`${r.question} `)).toLowerCase();
|
|
236
|
+
if (!["plan", "run"].includes(answer)) throw new Error('Answer "plan" or "run", or type the command, e.g. aitd run --help.');
|
|
237
|
+
r = intentToArgs({ ...intent, action: answer });
|
|
238
|
+
intent = { ...intent, action: answer };
|
|
239
|
+
}
|
|
240
|
+
if (r.unsupported) throw new Error(r.unsupported);
|
|
241
|
+
let args = r.args;
|
|
242
|
+
const needsSite = !values.site && !args.includes("--site") && (args[0] === "plan" || !args.includes("--against"));
|
|
243
|
+
if (needsSite) {
|
|
244
|
+
const site = await ask("Which site? (e.g. https://www.example.com) ");
|
|
245
|
+
if (!site) throw new Error("A site is needed. Add it to the sentence or pass --site <url>.");
|
|
246
|
+
r = intentToArgs({ ...intent, site });
|
|
247
|
+
args = r.args;
|
|
248
|
+
}
|
|
249
|
+
if (needsWhatToTest(args)) args = await whatToTest(args, values);
|
|
250
|
+
// Options typed next to the sentence (--site, --provider, --headed…) carry over.
|
|
251
|
+
args = [...args, ...extra.filter((a) => a !== sentence && a !== "-y" && a !== "--yes")];
|
|
252
|
+
console.log(commandText(args));
|
|
253
|
+
if (!values.yes) {
|
|
254
|
+
const answer = (await ask("Run this? (Y/n) ")).toLowerCase();
|
|
255
|
+
if (answer && !["y", "yes"].includes(answer)) return null;
|
|
256
|
+
}
|
|
257
|
+
return args;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A run needs something to test: suggest the site's latest plan, else ask for a goal.
|
|
261
|
+
async function whatToTest(args, values) {
|
|
262
|
+
const site = values.site ?? args[args.indexOf("--site") + 1];
|
|
263
|
+
const { listPlanIds } = await import("./workspace.js");
|
|
264
|
+
const hasPlan = (await listPlanIds(values["data-dir"] ?? DEFAULT_DATA_DIR, site).catch(() => [])).length > 0;
|
|
265
|
+
if (hasPlan) {
|
|
266
|
+
const answer = values.yes ? "" : (await ask("Check against the latest plan? (Y/n) ")).toLowerCase();
|
|
267
|
+
if (!answer || ["y", "yes"].includes(answer)) return [...args, "--against", "latest"];
|
|
268
|
+
} else if (values.yes) {
|
|
269
|
+
throw new Error('Nothing to test yet. Say what to check, e.g. aitd "check my GA tags on the contact form", or draft a plan first.');
|
|
270
|
+
}
|
|
271
|
+
const goal = await ask("What should I check, e.g. the contact form? ");
|
|
272
|
+
if (!goal) throw new Error('Nothing to test. Say what to check, e.g. aitd "check my GA tags on the contact form".');
|
|
273
|
+
return [...args, "--goal", goal];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function ask(question) {
|
|
277
|
+
const { createInterface } = await import("node:readline/promises");
|
|
278
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
279
|
+
try {
|
|
280
|
+
return (await rl.question(question)).trim();
|
|
281
|
+
} finally {
|
|
282
|
+
rl.close();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
215
286
|
async function planSheet(action, arg, values) {
|
|
216
287
|
const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
|
|
217
288
|
const shown = (p) => {
|
package/src/discover.js
CHANGED
|
@@ -15,6 +15,39 @@ Propose the events this site should track:
|
|
|
15
15
|
- Suggested user properties (for example customer_type, logged_in, membership_level), with a description and example.
|
|
16
16
|
Only propose what the pages show is possible. Prefer fewer, useful events over many trivial ones.`;
|
|
17
17
|
|
|
18
|
+
/** Kinds of events a plan can be limited to with --topic: what to explore, what to propose, and which events to keep. */
|
|
19
|
+
const ECOMMERCE = new Set(["view_item_list", "select_item", "view_item", "add_to_cart", "remove_from_cart", "view_cart", "add_to_wishlist", "begin_checkout", "add_shipping_info", "add_payment_info", "purchase", "refund", "view_promotion", "select_promotion"]);
|
|
20
|
+
const FORMS = new Set(["sign_up", "login", "generate_lead"]);
|
|
21
|
+
export const TOPICS = {
|
|
22
|
+
"custom-events": {
|
|
23
|
+
explore: "the pages with interactions specific to this site (tools, filters, videos, downloads, configurators, locators)",
|
|
24
|
+
propose: "custom events for interactions specific to this site. Do not propose GA4 recommended events",
|
|
25
|
+
keep: (ev) => Boolean(ev.custom),
|
|
26
|
+
},
|
|
27
|
+
ecommerce: {
|
|
28
|
+
explore: "the home page, one listing page, one product page, the cart (add one item first) and the first checkout step",
|
|
29
|
+
propose: "the GA4 ecommerce events (view_item_list, select_item, view_item, add_to_cart, remove_from_cart, view_cart, begin_checkout, add_shipping_info, add_payment_info, purchase, and promotions) with their items parameters",
|
|
30
|
+
keep: (ev) => ECOMMERCE.has(ev.name),
|
|
31
|
+
},
|
|
32
|
+
forms: {
|
|
33
|
+
explore: "the sign-up, login, contact and newsletter forms",
|
|
34
|
+
propose: "form events: sign_up, login, generate_lead, and custom form events (for example form_start, form_submit, form_error, newsletter_signup). Never propose ecommerce events",
|
|
35
|
+
keep: (ev) => FORMS.has(ev.name) || /form|newsletter|subscribe|signup|sign_up|lead|contact/.test(ev.name),
|
|
36
|
+
},
|
|
37
|
+
consent: {
|
|
38
|
+
explore: "the cookie or consent banner and any privacy settings page. Open the banner's settings before accepting it",
|
|
39
|
+
propose: "consent events: the banner shown, accepted, rejected and customised (for example consent_banner_view, consent_update), with the chosen categories as parameters. Never propose ecommerce or form events",
|
|
40
|
+
keep: (ev) => /consent|cookie|privacy|gdpr/.test(ev.name),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Checks a --topic value; undefined means no topic (the whole plan). */
|
|
45
|
+
export function checkTopic(topic) {
|
|
46
|
+
if (topic === undefined) return undefined;
|
|
47
|
+
if (!Object.hasOwn(TOPICS, topic)) throw new Error(`Unknown --topic "${topic}". Use one of: ${Object.keys(TOPICS).join(", ")}`);
|
|
48
|
+
return topic;
|
|
49
|
+
}
|
|
50
|
+
|
|
18
51
|
const str = { type: "string" };
|
|
19
52
|
const PLAN_SCHEMA = {
|
|
20
53
|
type: "object",
|
|
@@ -106,11 +139,14 @@ export function toPlan(site, answer, hits = []) {
|
|
|
106
139
|
|
|
107
140
|
/**
|
|
108
141
|
* Lets the AI explore the site (one page per type), then asks it for a measurement plan.
|
|
109
|
-
*
|
|
142
|
+
* With `topic`, exploring and the plan are limited to that kind of event (see TOPICS).
|
|
143
|
+
* @param {{ site: string, topic?: string, provider: any, maxSteps?: number, headed?: boolean, allowedHosts?: string[], viewport?: string, blockPrivate?: boolean, log?: (msg: string) => void }} opts
|
|
110
144
|
*/
|
|
111
145
|
export async function discoverPlan(opts) {
|
|
112
146
|
const log = opts.log ?? (() => {});
|
|
113
|
-
const
|
|
147
|
+
const topic = TOPICS[checkTopic(opts.topic)];
|
|
148
|
+
const goal = topic ? `${DISCOVER_GOAL}\nThis plan covers only ${opts.topic}: visit just ${topic.explore}, and skip other kinds of page.` : DISCOVER_GOAL;
|
|
149
|
+
const session = new AuditSession({ viewport: opts.viewport ?? "desktop", site: opts.site, allowedHosts: opts.allowedHosts, headless: !opts.headed, blockPrivate: opts.blockPrivate });
|
|
114
150
|
const snapshots = [];
|
|
115
151
|
let outcome;
|
|
116
152
|
try {
|
|
@@ -123,7 +159,7 @@ export async function discoverPlan(opts) {
|
|
|
123
159
|
snapshots.push(s);
|
|
124
160
|
return s;
|
|
125
161
|
};
|
|
126
|
-
outcome = await runAgentJourney(session, { id: "discover", goal
|
|
162
|
+
outcome = await runAgentJourney(session, { id: "discover", goal }, { provider: opts.provider, maxSteps: opts.maxSteps ?? 25, log }).catch((err) => {
|
|
127
163
|
if (err instanceof StepTimeout) return { completed: false, note: err.message };
|
|
128
164
|
throw err;
|
|
129
165
|
});
|
|
@@ -135,12 +171,13 @@ export async function discoverPlan(opts) {
|
|
|
135
171
|
log(`\nExplored ${pages.length} pages${outcome.completed ? "" : ` (${outcome.note})`}. Drafting the plan…`);
|
|
136
172
|
const existing = [...new Set(session.hits.map((h) => `${h.platform}:${h.name}`))];
|
|
137
173
|
const result = await opts.provider.json({
|
|
138
|
-
system: PLAN_SYSTEM,
|
|
174
|
+
system: topic ? `${PLAN_SYSTEM}\nThis plan covers only ${opts.topic}. Propose only ${topic.propose}.` : PLAN_SYSTEM,
|
|
139
175
|
texts: [`Site: ${opts.site}`, `Pages visited:\n${JSON.stringify(pages, null, 1)}`, `Tracking hits the site already sends: ${existing.length ? existing.join(", ") : "none"}`],
|
|
140
176
|
schema: PLAN_SCHEMA,
|
|
141
177
|
});
|
|
142
178
|
if (result.error) throw new Error(`The model did not return a plan (${result.error}).`);
|
|
143
179
|
const plan = toPlan(opts.site, result.value, session.hits);
|
|
180
|
+
if (topic) plan.events = plan.events.filter(topic.keep);
|
|
144
181
|
const coverage = pageTypeCoverage(plan.pages);
|
|
145
182
|
log(`Page types found: ${coverage.found.join(", ") || "none"}`);
|
|
146
183
|
if (coverage.absent.length) log(`Not present on this site: ${coverage.absent.join(", ")}`);
|
package/src/help.js
CHANGED
|
@@ -18,13 +18,15 @@ Examples:
|
|
|
18
18
|
aitd plan --site https://example.com
|
|
19
19
|
aitd run --site https://example.com --against latest
|
|
20
20
|
aitd run --site https://example.com --journey purchase
|
|
21
|
+
aitd "check my GA tags on example.com" (shows the command first; -y skips the question)
|
|
21
22
|
|
|
22
23
|
Run "aitd <command> --help" for that command's options. -h, --help -v, --version`;
|
|
23
24
|
|
|
24
25
|
const RUN = `Usage: aitd run --site <url> (--against <plan> | --journey <id> | --goal "<text>")
|
|
25
26
|
|
|
26
27
|
Audit what a site actually sends while an AI agent walks its journeys. Needs an AI key
|
|
27
|
-
(${Object.values(PROVIDERS).map((p) => p.env).join(", ")}) unless --no-ai.
|
|
28
|
+
(${Object.values(PROVIDERS).map((p) => p.env).join(", ")}) unless --no-ai. Each run is saved in
|
|
29
|
+
aitd/<domain>/runs/<id>/ and its report says what changed since the site's previous run.
|
|
28
30
|
|
|
29
31
|
What to test (at least one):
|
|
30
32
|
--against <plan> Check against a plan: latest, first, a plan ID or a plan file or folder
|
|
@@ -62,6 +64,7 @@ Commands:
|
|
|
62
64
|
|
|
63
65
|
Options:
|
|
64
66
|
--site <url> The site (required)
|
|
67
|
+
--topic <name> Only plan one kind of event: custom-events, ecommerce, forms or consent
|
|
65
68
|
--headed Show the browser window
|
|
66
69
|
|
|
67
70
|
AI: --provider <name> --model <id> (default: whichever API key is set)
|
package/src/i18n.js
CHANGED
|
@@ -87,6 +87,12 @@ const en = {
|
|
|
87
87
|
headerMeta: (journeys, providers) => `${journeys} ${journeys === 1 ? "journey" : "journeys"} · ${providers} ${providers === 1 ? "provider" : "providers"}`,
|
|
88
88
|
checkedAgainst: (id) => `Checked against plan ${id}`,
|
|
89
89
|
planVsSite: "Plan vs. site",
|
|
90
|
+
changesTitle: "Changed since last run",
|
|
91
|
+
changesLead: (id, mode) => `Compared with run ${id}, by ${mode === "plan" ? "plan coverage" : "health findings"}`,
|
|
92
|
+
changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? (ch.fixed.length === 1 ? "event" : "events") : (ch.fixed.length === 1 ? "problem" : "problems")} fixed` : "", ch.new.length ? `${ch.new.length} new ${ch.new.length === 1 ? "problem" : "problems"}` : ""].filter(Boolean).join(", ") : "No changes since the last run",
|
|
93
|
+
changeFixed: "Fixed",
|
|
94
|
+
changeNew: "New",
|
|
95
|
+
noPreviousRun: "This is the first saved run for this site, so there is nothing to compare yet.",
|
|
90
96
|
planVsSiteLead: "Each planned event, and what the site actually sent",
|
|
91
97
|
coverageLine: (c) => `Coverage ${c.implemented} of ${c.checkable}${c.percent === null ? "" : ` (${c.percent}%)`}${c.not_checked ? `, ${c.not_checked} ${c.not_checked === 1 ? "event" : "events"} not checked` : ""}`,
|
|
92
98
|
bucket: { implemented: "Implemented", missing: "Missing", missing_params: "Missing params", wrong_values: "Wrong values", unplanned: "Unplanned", not_checked: "Not checked" },
|
|
@@ -240,6 +246,12 @@ const tr = {
|
|
|
240
246
|
headerMeta: (journeys, providers) => `${journeys} yolculuk · ${providers} sağlayıcı`,
|
|
241
247
|
checkedAgainst: (id) => `${id} planına göre kontrol edildi`,
|
|
242
248
|
planVsSite: "Plan ve site",
|
|
249
|
+
changesTitle: "Son çalıştırmadan bu yana değişenler",
|
|
250
|
+
changesLead: (id, mode) => `${id} çalıştırmasıyla ${mode === "plan" ? "plan kapsamına" : "sağlık bulgularına"} göre karşılaştırıldı`,
|
|
251
|
+
changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? "olay" : "sorun"} düzeldi` : "", ch.new.length ? `${ch.new.length} yeni sorun` : ""].filter(Boolean).join(", ") : "Son çalıştırmadan bu yana değişiklik yok",
|
|
252
|
+
changeFixed: "Düzeldi",
|
|
253
|
+
changeNew: "Yeni",
|
|
254
|
+
noPreviousRun: "Bu site için kayıtlı ilk çalıştırma bu, karşılaştırılacak bir şey henüz yok.",
|
|
243
255
|
planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
|
|
244
256
|
coverageLine: (c) => `Kapsam ${c.checkable} olaydan ${c.implemented}${c.percent === null ? "" : ` (%${c.percent})`}${c.not_checked ? `, ${c.not_checked} olay kontrol edilmedi` : ""}`,
|
|
245
257
|
bucket: { implemented: "Uygulanmış", missing: "Eksik", missing_params: "Eksik parametre", wrong_values: "Hatalı değer", unplanned: "Planda yok", not_checked: "Kontrol edilmedi" },
|
package/src/mcp.js
CHANGED
|
@@ -21,6 +21,9 @@ import { checkRun, groupFindings, summarize } from "./checks.js";
|
|
|
21
21
|
import { LANGUAGES, messages } from "./i18n.js";
|
|
22
22
|
import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
23
23
|
import { DEFAULT_DATA_DIR, resolvePlan } from "./workspace.js";
|
|
24
|
+
import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
|
|
25
|
+
import { createProvider } from "./providers.js";
|
|
26
|
+
import { savePlan } from "./plan.js";
|
|
24
27
|
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
25
28
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
26
29
|
|
|
@@ -32,6 +35,8 @@ const MIN_TOKEN_LENGTH = 16;
|
|
|
32
35
|
|
|
33
36
|
const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracking (GA4, Google Ads, Meta Pixel, TikTok Pixel, Consent Mode).
|
|
34
37
|
|
|
38
|
+
Two kinds of request: a PLAN is what should be tracked, an AUDIT is what the site actually sends. If the user wants to know what to track, which events a site needs, or asks for a tracking or measurement plan, call make_plan (with topic when they only care about one kind of event: ${Object.keys(TOPICS).join(", ")}). If they want to know whether tracking works, what a site sends, or what changed, run an audit. To audit against the plan afterwards, pass against to finish_audit.
|
|
39
|
+
|
|
35
40
|
Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}); pass their choice as providers.
|
|
36
41
|
|
|
37
42
|
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
|
|
@@ -123,6 +128,7 @@ async function loadAgainst(ref, site, opts) {
|
|
|
123
128
|
* blockPrivate: the browser refuses loopback, private and link-local addresses (HTTP servers, by default).
|
|
124
129
|
* dataDir: where saved plans live (default ./aitd), used by `against`.
|
|
125
130
|
* reportsUrl: where an HTTP server serves finished reports, if it knows its public URL.
|
|
131
|
+
* provider: an AI provider for make_plan (default: the one configured on this computer; tests pass a fake).
|
|
126
132
|
*/
|
|
127
133
|
export function buildServer(opts) {
|
|
128
134
|
const server = new McpServer({ name: "ai-tag-debugger", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
@@ -298,6 +304,53 @@ export function buildServer(opts) {
|
|
|
298
304
|
},
|
|
299
305
|
);
|
|
300
306
|
|
|
307
|
+
server.registerTool(
|
|
308
|
+
"make_plan",
|
|
309
|
+
{
|
|
310
|
+
title: "Draft a tracking plan",
|
|
311
|
+
description:
|
|
312
|
+
"Explores a website and drafts its measurement plan: the events, parameters and user properties it should track. Same as `aitd plan --site`. The plan is saved (use its ID as against in finish_audit to check an audit against it) and the events are returned. Uses the AI provider configured on the computer running this server (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY) and takes a few minutes. To see what the site sends today, run an audit instead.",
|
|
313
|
+
inputSchema: {
|
|
314
|
+
site: z.string().describe("The site to plan, e.g. https://www.example.com/"),
|
|
315
|
+
topic: z.enum(Object.keys(TOPICS)).optional().describe(`Limit the plan to one kind of event: ${Object.keys(TOPICS).join(", ")}. Default: the whole plan.`),
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
async ({ site, topic }) => {
|
|
319
|
+
let url;
|
|
320
|
+
try {
|
|
321
|
+
url = new URL(site);
|
|
322
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
|
323
|
+
} catch {
|
|
324
|
+
return fail("site must be an http or https URL.");
|
|
325
|
+
}
|
|
326
|
+
if (opts.blockPrivate) {
|
|
327
|
+
const refused = await navigationError(url.href, { isPrivate: privateHostChecker() });
|
|
328
|
+
if (refused) return fail(`${refused} Over HTTP, plans are limited to public websites.`);
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
checkTopic(topic);
|
|
332
|
+
const provider = opts.provider ?? (await createProvider());
|
|
333
|
+
const { plan } = await discoverPlan({ site: url.href, topic, provider, blockPrivate: opts.blockPrivate });
|
|
334
|
+
const { id, dir } = await savePlan(plan, { dataDir: opts.dataDir ?? DEFAULT_DATA_DIR, site: url.href });
|
|
335
|
+
const custom = plan.events.filter((e) => e.custom).length;
|
|
336
|
+
return text(
|
|
337
|
+
[
|
|
338
|
+
`Saved plan ${id}${topic ? ` (${topic} only)` : ""}: ${plan.events.length} events (${custom} custom) and ${plan.user_properties.length} user properties${opts.allowFiles ? ` in ${dir}` : ""}.`,
|
|
339
|
+
"",
|
|
340
|
+
...plan.events.map((e) => `- ${e.platform} ${e.name}${e.custom ? " (custom)" : ""}${e.trigger?.page ? `, on ${e.trigger.page}` : ""}${e.required_params?.length ? `, params: ${e.required_params.join(", ")}` : ""}${e.description ? `: ${e.description}` : ""}`),
|
|
341
|
+
...(plan.user_properties.length ? ["", `User properties: ${plan.user_properties.map((p) => p.name).join(", ")}`] : []),
|
|
342
|
+
"",
|
|
343
|
+
`Check a site against it with an audit: finish_audit with against "${id}".`,
|
|
344
|
+
].join("\n"),
|
|
345
|
+
);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// The CLI wording (--no-ai) means nothing to a chat app, so say it in this tool's terms.
|
|
348
|
+
if (err.code === "NO_API_KEY") return fail("No AI provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY) is set on the computer running this server, so it can't draft a plan. Set one and restart the server, or write the plan yourself from what an audit shows and pass it to finish_audit as measurement_plan.");
|
|
349
|
+
return fail(`Could not draft the plan: ${err.message}`);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
301
354
|
if (opts.allowFiles) {
|
|
302
355
|
server.registerTool(
|
|
303
356
|
"run_audit",
|
package/src/report.js
CHANGED
|
@@ -149,11 +149,12 @@ const formatPush = (value) => (isConsentEntry(value) ? `gtag(${value.map((v) =>
|
|
|
149
149
|
const mdText = (v) => String(v ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
150
150
|
const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
|
|
151
151
|
|
|
152
|
-
export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null }) {
|
|
152
|
+
export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
|
|
153
153
|
const m = messages(lang);
|
|
154
154
|
const rows = attachExplanations(findings, explained);
|
|
155
155
|
const counts = summarize(rows);
|
|
156
156
|
const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", ...(against ? [mdText(m.ui.checkedAgainst(against)), ""] : []), `**${m.ui.counts(counts)}**`, ""];
|
|
157
|
+
if (changes) lines.push(...changesMd(m, changes));
|
|
157
158
|
if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
|
|
158
159
|
const sum = summaryParts(explained?.summary);
|
|
159
160
|
if (sum) {
|
|
@@ -619,6 +620,29 @@ const coverageLines = (m, pc) => [
|
|
|
619
620
|
const pcSort = (pc) => [...pc.events].sort((a, b) => BUCKET_ORDER.indexOf(a.bucket) - BUCKET_ORDER.indexOf(b.bucket) || platformRank(a.platform) - platformRank(b.platform));
|
|
620
621
|
const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
|
|
621
622
|
|
|
623
|
+
/** "Changed since last run": one headline, then what was fixed and what is new. */
|
|
624
|
+
const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
|
|
625
|
+
const changeItems = (m, ch) => [
|
|
626
|
+
...ch.fixed.map((c) => ({ sev: "ok", tag: m.ui.changeFixed, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.was]})` })),
|
|
627
|
+
...ch.new.map((c) => ({ sev: "broken", tag: m.ui.changeNew, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.bucket]})` })),
|
|
628
|
+
];
|
|
629
|
+
|
|
630
|
+
function changesMd(m, ch) {
|
|
631
|
+
const lines = [`## ${m.ui.changesTitle}`, ""];
|
|
632
|
+
if (!ch.previous) return [...lines, m.ui.noPreviousRun, ""];
|
|
633
|
+
lines.push(`**${mdText(m.ui.changesLine(ch))}**`, "");
|
|
634
|
+
const items = changeItems(m, ch);
|
|
635
|
+
if (items.length) lines.push(...items.map((i) => `- ${i.tag}: ${mdText(i.text)}`), "");
|
|
636
|
+
return lines;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function changesCard(m, ch) {
|
|
640
|
+
const head = `<div class="card-h"><div class="card-t">${m.ui.changesTitle}</div>${ch.previous ? `<div class="card-d">${escapeHtml(m.ui.changesLead(ch.previous, ch.mode))}</div>` : ""}</div>`;
|
|
641
|
+
if (!ch.previous) return `<div class="card" id="changes">${head}<div class="card-c"><p class="muted">${m.ui.noPreviousRun}</p></div></div>`;
|
|
642
|
+
const items = changeItems(m, ch);
|
|
643
|
+
return `<div class="card" id="changes">${head}<div class="card-c"><p><strong>${escapeHtml(m.ui.changesLine(ch))}</strong></p>${items.length ? `<ul class="changes">${items.map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}</div></div>`;
|
|
644
|
+
}
|
|
645
|
+
|
|
622
646
|
function planVsSiteMd(m, pc) {
|
|
623
647
|
const [overall, ...perPlatform] = coverageLines(m, pc);
|
|
624
648
|
const lines = [`## ${m.ui.planVsSite}`, "", `**${mdText(overall)}**`, "", ...perPlatform.map((l) => `- ${mdText(l)}`), ...(perPlatform.length ? [""] : [])];
|
|
@@ -778,6 +802,7 @@ table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
|
|
|
778
802
|
.fsr{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
|
|
779
803
|
.sevfilter label{padding:4px 10px;border-radius:4px;font-weight:500;font-size:12px;color:var(--muted);cursor:pointer}
|
|
780
804
|
.filter-host{position:relative}
|
|
805
|
+
.changes{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px}
|
|
781
806
|
.flist{list-style:none;margin:0;padding:0;display:grid;gap:8px}
|
|
782
807
|
.finding{--sev:var(--warn-dot);--sev-fg:var(--warn);--sev-bg:var(--warn-bg);background:var(--bg);border:1px solid var(--border);border-left:4px solid var(--sev);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
|
|
783
808
|
.finding.sev-broken{--sev:var(--danger-dot);--sev-fg:var(--danger);--sev-bg:var(--danger-bg)}
|
|
@@ -940,7 +965,7 @@ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);bor
|
|
|
940
965
|
@media print{.toptabs,.jtabs,.stepnav,.filter>label,.filter>input,.tabs.seg>label,.sevfilter{display:none}.tabpanel,.jpanel,.sdetail,.tabs.seg>.panel{display:block!important}.jpanel{margin-bottom:32px}.sdetail{margin-bottom:16px;break-inside:avoid-page}details>*{display:block}}
|
|
941
966
|
`;
|
|
942
967
|
|
|
943
|
-
export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null }) {
|
|
968
|
+
export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
|
|
944
969
|
const m = messages(lang);
|
|
945
970
|
const rows = attachExplanations(findings, explained);
|
|
946
971
|
const counts = summarize(rows);
|
|
@@ -967,7 +992,7 @@ ${navCss(runs)}</style></head><body>
|
|
|
967
992
|
<nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
|
|
968
993
|
<a href="#overview">${m.ui.tabOverview}</a><a href="#findings">${m.ui.findings} <span class="n">${problems}</span></a><a href="#journeys">${m.ui.tabJourneys} <span class="n">${runs.length}</span></a><a href="#scope">${m.ui.tabScope}</a></div></nav>
|
|
969
994
|
<main class="wrap">
|
|
970
|
-
<section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${planCheck ? planVsSiteCard(m, planCheck) : ""}${overviewTab(m, findings, rows, counts, explained, runs)}</section>
|
|
995
|
+
<section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${changes ? changesCard(m, changes) : ""}${planCheck ? planVsSiteCard(m, planCheck) : ""}${overviewTab(m, findings, rows, counts, explained, runs)}</section>
|
|
971
996
|
<section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows)}</section>
|
|
972
997
|
<section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged)}</section>
|
|
973
998
|
<section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${providersCard(m, runs)}</div></section>
|