agentic-browsing-auditor 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,10 @@
1
+ from .auditor import run_lighthouse, build_report, normalize_url, AuditError
2
+ from .app import app
3
+
4
+ __all__ = [
5
+ "run_lighthouse",
6
+ "build_report",
7
+ "normalize_url",
8
+ "AuditError",
9
+ "app",
10
+ ]
agentic_auditor/app.py ADDED
@@ -0,0 +1,34 @@
1
+ import os
2
+ from flask import Flask, jsonify, render_template, request
3
+ from .auditor import normalize_url, run_lighthouse, build_report, AuditError
4
+
5
+ # Resolve template and static folders relative to this file
6
+ base_dir = os.path.dirname(os.path.abspath(__file__))
7
+ app = Flask(
8
+ __name__,
9
+ template_folder=os.path.join(base_dir, "templates"),
10
+ static_folder=os.path.join(base_dir, "static")
11
+ )
12
+
13
+
14
+ @app.route("/")
15
+ def index():
16
+ return render_template("index.html")
17
+
18
+
19
+ @app.route("/api/audit", methods=["POST"])
20
+ def audit():
21
+ payload = request.get_json(silent=True) or {}
22
+ try:
23
+ url = normalize_url(payload.get("url", ""))
24
+ raw_report = run_lighthouse(url)
25
+ report = build_report(raw_report)
26
+ return jsonify({"ok": True, "report": report})
27
+ except AuditError as exc:
28
+ return jsonify({"ok": False, "error": str(exc)}), 400
29
+ except Exception as exc: # safety net
30
+ return jsonify({"ok": False, "error": f"Unexpected error: {exc}"}), 500
31
+
32
+
33
+ if __name__ == "__main__":
34
+ app.run(host="0.0.0.0", port=5000, debug=True)
@@ -0,0 +1,241 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ from urllib.parse import urlparse
8
+
9
+ CATEGORY_ID = "agentic-browsing"
10
+ LIGHTHOUSE_TIMEOUT_SECONDS = 180
11
+
12
+
13
+ class AuditError(Exception):
14
+ """Raised for any expected failure while running/parsing Lighthouse."""
15
+
16
+
17
+ def normalize_url(raw: str) -> str:
18
+ raw = (raw or "").strip()
19
+ if not raw:
20
+ raise AuditError("Please enter a domain or URL.")
21
+ if not re.match(r"^https?://", raw, re.IGNORECASE):
22
+ raw = "https://" + raw
23
+ parsed = urlparse(raw)
24
+ if not parsed.netloc:
25
+ raise AuditError("That doesn't look like a valid domain or URL.")
26
+ return raw
27
+
28
+
29
+ def find_lighthouse_binary() -> list:
30
+ """
31
+ Prefer a locally/globally installed `lighthouse` binary. Fall back to
32
+ `npx lighthouse`, which will download it on first run if needed.
33
+ """
34
+ # Check project-local node_modules first
35
+ # Walk up from this file's location to check if node_modules is present
36
+ base_dir = os.path.dirname(os.path.abspath(__file__))
37
+ # In a installed package, node_modules might be located in parent directories
38
+ # like the workspace root or site-packages.
39
+ # Let's search from base_dir upwards for node_modules/.bin/lighthouse
40
+ curr = base_dir
41
+ while True:
42
+ local_bin = os.path.join(curr, "node_modules", ".bin")
43
+ local_lh = os.path.join(local_bin, "lighthouse.cmd" if os.name == "nt" else "lighthouse")
44
+ if os.path.isfile(local_lh):
45
+ return [local_lh]
46
+ parent = os.path.dirname(curr)
47
+ if parent == curr:
48
+ break
49
+ curr = parent
50
+
51
+ lighthouse_path = shutil.which("lighthouse")
52
+ if lighthouse_path:
53
+ return [lighthouse_path]
54
+ npx_path = shutil.which("npx")
55
+ if npx_path:
56
+ return [npx_path, "--yes", "lighthouse"]
57
+ raise AuditError(
58
+ "Neither `lighthouse` nor `npx` was found on this machine. "
59
+ "Install Node.js, then run: npm install -g lighthouse"
60
+ )
61
+
62
+
63
+ def resolve_chrome_path():
64
+ """
65
+ Resolve CHROME_PATH and validate it actually points at a file, so we
66
+ can fail loudly instead of silently falling back to whatever Chrome
67
+ chrome-launcher happens to find (usually stable Chrome).
68
+ """
69
+ chrome_path = os.environ.get("CHROME_PATH")
70
+ if not chrome_path:
71
+ return None
72
+ if not os.path.isfile(chrome_path):
73
+ raise AuditError(
74
+ f"CHROME_PATH is set to '{chrome_path}' but no file exists there. "
75
+ "Double-check the path (right-click your Chrome Canary shortcut "
76
+ "-> Properties -> Target on Windows)."
77
+ )
78
+ return chrome_path
79
+
80
+
81
+ def run_lighthouse(url: str) -> dict:
82
+ binary_cmd = find_lighthouse_binary()
83
+ chrome_path = resolve_chrome_path()
84
+
85
+ with tempfile.TemporaryDirectory() as tmp_dir:
86
+ output_path = os.path.join(tmp_dir, "report.json")
87
+
88
+ chrome_flags = "--headless=new --no-sandbox --disable-gpu"
89
+
90
+ cmd = binary_cmd + [
91
+ url,
92
+ f"--only-categories={CATEGORY_ID}",
93
+ "--output=json",
94
+ f"--output-path={output_path}",
95
+ f"--chrome-flags={chrome_flags}",
96
+ "--quiet",
97
+ "--max-wait-for-load=45000",
98
+ ]
99
+
100
+ # chrome-launcher (used internally by Lighthouse) primarily reads
101
+ # the CHROME_PATH *environment variable*, not a CLI flag - so we
102
+ # pass it explicitly into the subprocess's environment, and also
103
+ # append --chrome-path for the (newer) Lighthouse versions that
104
+ # support it directly. Belt and suspenders.
105
+ run_env = os.environ.copy()
106
+ if chrome_path:
107
+ run_env["CHROME_PATH"] = chrome_path
108
+ cmd.append(f"--chrome-path={chrome_path}")
109
+ print(f"[agentic-audit] Using Chrome at: {chrome_path}")
110
+ else:
111
+ print(
112
+ "[agentic-audit] WARNING: CHROME_PATH is not set. "
113
+ "Lighthouse will auto-discover a Chrome install, which is "
114
+ "likely your regular stable Chrome (won't support the "
115
+ "agentic-browsing category)."
116
+ )
117
+
118
+ try:
119
+ result = subprocess.run(
120
+ cmd,
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=LIGHTHOUSE_TIMEOUT_SECONDS,
124
+ env=run_env,
125
+ )
126
+ except subprocess.TimeoutExpired as exc:
127
+ raise AuditError(
128
+ f"Lighthouse timed out after {LIGHTHOUSE_TIMEOUT_SECONDS}s "
129
+ "auditing this page."
130
+ ) from exc
131
+ except FileNotFoundError as exc:
132
+ raise AuditError(f"Couldn't launch Lighthouse: {exc}") from exc
133
+
134
+ if result.returncode != 0 or not os.path.exists(output_path):
135
+ stderr_tail = (result.stderr or "").strip()[-2000:]
136
+ raise AuditError(
137
+ "Lighthouse failed to produce a report. This usually means "
138
+ "Chrome couldn't be launched, the site couldn't be reached, "
139
+ "or your Chrome version doesn't support the Agentic Browsing "
140
+ f"category yet (needs Chrome 150+, or 130-149 with the "
141
+ f"webmcp-testing flag).\n\nDetails: {stderr_tail}"
142
+ )
143
+
144
+ with open(output_path, "r", encoding="utf-8") as f:
145
+ try:
146
+ return json.load(f)
147
+ except json.JSONDecodeError as exc:
148
+ raise AuditError("Lighthouse returned an unreadable report.") from exc
149
+
150
+
151
+ def classify_audit(audit: dict) -> str:
152
+ """
153
+ Map a Lighthouse audit result to one of:
154
+ 'fail', 'warning', 'pass', 'not_applicable', 'informative'
155
+ """
156
+ display_mode = audit.get("scoreDisplayMode")
157
+ score = audit.get("score")
158
+
159
+ if display_mode == "notApplicable":
160
+ return "not_applicable"
161
+ if display_mode == "informative":
162
+ return "informative"
163
+ if display_mode in ("manual", "error"):
164
+ return "warning"
165
+ if display_mode == "numeric":
166
+ # e.g. Cumulative Layout Shift - use score thresholds like Lighthouse does
167
+ if score is None:
168
+ return "informative"
169
+ if score >= 0.9:
170
+ return "pass"
171
+ if score >= 0.5:
172
+ return "warning"
173
+ return "fail"
174
+ # binary
175
+ if score == 1:
176
+ return "pass"
177
+ if score == 0:
178
+ return "fail"
179
+ return "warning"
180
+
181
+
182
+ def build_report(raw: dict) -> dict:
183
+ categories = raw.get("categories", {})
184
+ category = categories.get(CATEGORY_ID)
185
+ if category is None:
186
+ raise AuditError(
187
+ "This Lighthouse report has no 'agentic-browsing' category. "
188
+ "Your Chrome/Lighthouse version likely doesn't support it yet."
189
+ )
190
+
191
+ audits_by_id = raw.get("audits", {})
192
+ groups_meta = raw.get("categoryGroups") or raw.get("groups") or {}
193
+
194
+ grouped: dict = {}
195
+ ungrouped = []
196
+
197
+ pass_count = 0
198
+ fail_or_warn_count = 0
199
+
200
+ for ref in category.get("auditRefs", []):
201
+ audit_id = ref.get("id")
202
+ audit = audits_by_id.get(audit_id, {})
203
+ status = classify_audit(audit)
204
+
205
+ entry = {
206
+ "id": audit_id,
207
+ "title": audit.get("title", audit_id),
208
+ "description": audit.get("description", ""),
209
+ "status": status,
210
+ "display_value": audit.get("displayValue"),
211
+ "score": audit.get("score"),
212
+ }
213
+
214
+ if status in ("pass",):
215
+ pass_count += 1
216
+ elif status in ("fail", "warning"):
217
+ fail_or_warn_count += 1
218
+
219
+ group_id = ref.get("group")
220
+ if group_id:
221
+ group_title = (groups_meta.get(group_id) or {}).get("title", group_id)
222
+ grouped.setdefault(group_id, {"title": group_title, "audits": []})
223
+ grouped[group_id]["audits"].append(entry)
224
+ else:
225
+ ungrouped.append(entry)
226
+
227
+ total_scored = pass_count + fail_or_warn_count
228
+ ratio_label = f"{pass_count}/{total_scored}" if total_scored else "N/A"
229
+
230
+ return {
231
+ "final_url": raw.get("finalUrl") or raw.get("requestedUrl"),
232
+ "fetch_time": raw.get("fetchTime"),
233
+ "lighthouse_version": raw.get("lighthouseVersion"),
234
+ "category_title": category.get("title", "Agentic Browsing"),
235
+ "category_description": category.get("description", ""),
236
+ "pass_ratio_label": ratio_label,
237
+ "pass_count": pass_count,
238
+ "scored_count": total_scored,
239
+ "ungrouped_audits": ungrouped,
240
+ "groups": list(grouped.values()),
241
+ }
agentic_auditor/cli.py ADDED
@@ -0,0 +1,229 @@
1
+ import csv
2
+ import os
3
+ import sys
4
+ import click
5
+
6
+ # Reconfigure stdout/stderr to use UTF-8 to prevent UnicodeEncodeError on Windows console
7
+ if sys.platform.startswith("win"):
8
+ try:
9
+ sys.stdout.reconfigure(encoding="utf-8")
10
+ sys.stderr.reconfigure(encoding="utf-8")
11
+ except Exception:
12
+ pass
13
+
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
17
+ from .auditor import run_lighthouse, build_report, normalize_url, AuditError
18
+
19
+ console = Console()
20
+
21
+
22
+ @click.group()
23
+ @click.version_option(package_name="agentic-browsing-auditor")
24
+ def main():
25
+ """Agentic Browsing Auditor CLI tool built by Amal Alexander.
26
+
27
+ Audits website performance for LLM agents (llms.txt, WebMCP, accessibility).
28
+ LinkedIn: https://www.linkedin.com/in/amal-alexander-305780131/
29
+ """
30
+ pass
31
+
32
+
33
+ @main.command()
34
+ @click.argument("url")
35
+ def audit(url):
36
+ """Audit a single URL and display the results in the terminal."""
37
+ try:
38
+ clean_url = normalize_url(url)
39
+ except AuditError as exc:
40
+ console.print(f"[red]Error:[/red] {exc}")
41
+ sys.exit(1)
42
+
43
+ console.print(f"[bold blue]◐[/bold blue] Auditing [cyan]{clean_url}[/cyan] ...")
44
+ console.print("[dim]Launching headless Chrome and running Lighthouse... (takes 20-60s)[/dim]")
45
+
46
+ try:
47
+ raw_report = run_lighthouse(clean_url)
48
+ report = build_report(raw_report)
49
+ except AuditError as exc:
50
+ console.print(f"\n[red]Audit failed:[/red]\n{exc}")
51
+ sys.exit(1)
52
+ except Exception as exc:
53
+ console.print(f"\n[red]Unexpected error:[/red]\n{exc}")
54
+ sys.exit(1)
55
+
56
+ console.print("\n[bold green]✓ Audit Completed Successfully![/bold green]")
57
+ console.print(f"[bold]Final URL:[/bold] {report['final_url']}")
58
+ console.print(f"[bold]Lighthouse Version:[/bold] {report['lighthouse_version']}")
59
+ console.print(f"[bold]Fetch Time:[/bold] {report['fetch_time']}")
60
+ console.print(f"[bold]Pass Ratio:[/bold] [bold green]{report['pass_ratio_label']}[/bold green]\n")
61
+
62
+ # Render summary table
63
+ table = Table(title="[bold blue]Agentic Browsing Audits Summary[/bold blue]")
64
+ table.add_column("Status", justify="center", width=8)
65
+ table.add_column("Audit ID", style="cyan")
66
+ table.add_column("Title", style="white")
67
+ table.add_column("Result / Value", justify="right")
68
+
69
+ status_colors = {
70
+ "pass": "[bold green]PASS[/bold green]",
71
+ "fail": "[bold red]FAIL[/bold red]",
72
+ "warning": "[bold yellow]WARN[/bold yellow]",
73
+ "not_applicable": "[dim]N/A[/dim]",
74
+ "informative": "[dim]INFO[/dim]",
75
+ }
76
+
77
+ # Gather all audits (grouped and ungrouped)
78
+ all_audits = []
79
+ for group in report.get("groups", []):
80
+ all_audits.extend(group.get("audits", []))
81
+ all_audits.extend(report.get("ungrouped_audits", []))
82
+
83
+ for item in all_audits:
84
+ status_lbl = status_colors.get(item["status"], item["status"].upper())
85
+ val = item["display_value"] or (f"Score: {item['score']}" if item["score"] is not None else "-")
86
+ table.add_row(status_lbl, item["id"], item["title"], str(val))
87
+
88
+ console.print(table)
89
+
90
+
91
+ @main.command()
92
+ @click.argument("input_file", type=click.Path(exists=True))
93
+ @click.option(
94
+ "-o",
95
+ "--output",
96
+ "output_file",
97
+ default="audit_results.csv",
98
+ help="CSV file path to save results.",
99
+ show_default=True,
100
+ )
101
+ def bulk(input_file, output_file):
102
+ """Audit multiple URLs listed in a text file (one URL per line) and export to CSV."""
103
+ urls = []
104
+ with open(input_file, "r", encoding="utf-8") as f:
105
+ for line in f:
106
+ line = line.strip()
107
+ if line and not line.startswith("#"):
108
+ urls.append(line)
109
+
110
+ if not urls:
111
+ console.print("[yellow]No URLs found in the input file.[/yellow]")
112
+ return
113
+
114
+ console.print(f"[bold blue]◐[/bold blue] Found [cyan]{len(urls)}[/cyan] URLs to audit.")
115
+ console.print(f"[dim]Results will be saved to: {output_file}[/dim]\n")
116
+
117
+ results = []
118
+
119
+ # Setup CSV header
120
+ headers = [
121
+ "URL",
122
+ "Final URL",
123
+ "Lighthouse Version",
124
+ "Fetch Time",
125
+ "Pass Ratio",
126
+ "Pass Count",
127
+ "Scored Count",
128
+ "Passed Audits",
129
+ "Failed Audits",
130
+ "Warnings",
131
+ "Not Applicable",
132
+ ]
133
+
134
+ with Progress(
135
+ SpinnerColumn(),
136
+ TextColumn("[progress.description]{task.description}"),
137
+ BarColumn(),
138
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
139
+ TimeElapsedColumn(),
140
+ console=console,
141
+ ) as progress:
142
+ task = progress.add_task("[blue]Bulk Audits[/blue]", total=len(urls))
143
+
144
+ for idx, raw_url in enumerate(urls):
145
+ progress.update(task, description=f"[cyan]Auditing ({idx + 1}/{len(urls)})[/cyan] {raw_url}")
146
+ url_result = {
147
+ "URL": raw_url,
148
+ "Final URL": "",
149
+ "Lighthouse Version": "",
150
+ "Fetch Time": "",
151
+ "Pass Ratio": "Error",
152
+ "Pass Count": 0,
153
+ "Scored Count": 0,
154
+ "Passed Audits": "",
155
+ "Failed Audits": "",
156
+ "Warnings": "",
157
+ "Not Applicable": "",
158
+ }
159
+
160
+ try:
161
+ clean_url = normalize_url(raw_url)
162
+ raw_report = run_lighthouse(clean_url)
163
+ report = build_report(raw_report)
164
+
165
+ url_result["Final URL"] = report["final_url"] or clean_url
166
+ url_result["Lighthouse Version"] = report["lighthouse_version"]
167
+ url_result["Fetch Time"] = report["fetch_time"]
168
+ url_result["Pass Ratio"] = report["pass_ratio_label"]
169
+ url_result["Pass Count"] = report["pass_count"]
170
+ url_result["Scored Count"] = report["scored_count"]
171
+
172
+ # Gather audits by category
173
+ all_audits = []
174
+ for group in report.get("groups", []):
175
+ all_audits.extend(group.get("audits", []))
176
+ all_audits.extend(report.get("ungrouped_audits", []))
177
+
178
+ passed = []
179
+ failed = []
180
+ warns = []
181
+ nas = []
182
+
183
+ for item in all_audits:
184
+ title = item["title"]
185
+ if item["status"] == "pass":
186
+ passed.append(title)
187
+ elif item["status"] == "fail":
188
+ failed.append(title)
189
+ elif item["status"] == "warning":
190
+ warns.append(title)
191
+ elif item["status"] in ("not_applicable", "informative"):
192
+ nas.append(title)
193
+
194
+ url_result["Passed Audits"] = "; ".join(passed)
195
+ url_result["Failed Audits"] = "; ".join(failed)
196
+ url_result["Warnings"] = "; ".join(warns)
197
+ url_result["Not Applicable"] = "; ".join(nas)
198
+
199
+ progress.console.print(f"[green]✓[/green] Completed: {raw_url} [green]({report['pass_ratio_label']})[/green]")
200
+ except Exception as e:
201
+ progress.console.print(f"[red]✗[/red] Failed: {raw_url} - [red]{str(e)}[/red]")
202
+ url_result["Passed Audits"] = f"Error: {str(e)}"
203
+
204
+ results.append(url_result)
205
+ progress.advance(task)
206
+
207
+ # Write to CSV
208
+ with open(output_file, "w", newline="", encoding="utf-8") as f:
209
+ writer = csv.DictWriter(f, fieldnames=headers)
210
+ writer.writeheader()
211
+ writer.writerows(results)
212
+
213
+ console.print(f"\n[bold green]✓ Bulk audits finished![/bold green] Saved report to [cyan]{output_file}[/cyan].")
214
+
215
+
216
+ @main.command()
217
+ @click.option("--host", default="127.0.0.1", help="Host address to bind to.", show_default=True)
218
+ @click.option("-p", "--port", default=5000, help="Port to run Flask app.", show_default=True)
219
+ @click.option("--debug", is_flag=True, help="Enable Flask debug mode.")
220
+ def serve(host, port, debug):
221
+ """Launch the local web dashboard interface."""
222
+ from .app import app
223
+
224
+ console.print("[bold blue]◐[/bold blue] Launching Agentic Browsing Auditor web dashboard...")
225
+ app.run(host=host, port=port, debug=debug)
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
@@ -0,0 +1,162 @@
1
+ const form = document.getElementById("audit-form");
2
+ const urlInput = document.getElementById("url-input");
3
+ const submitBtn = document.getElementById("submit-btn");
4
+ const statusArea = document.getElementById("status-area");
5
+ const reportArea = document.getElementById("report-area");
6
+
7
+ const ICONS = {
8
+ fail: "▲",
9
+ warning: "■",
10
+ pass: "✓",
11
+ not_applicable: "○",
12
+ informative: "ℹ",
13
+ };
14
+
15
+ function setStatus(kind, message) {
16
+ statusArea.hidden = false;
17
+ statusArea.className = `status-area ${kind}`;
18
+ statusArea.textContent = message;
19
+ }
20
+
21
+ function clearStatus() {
22
+ statusArea.hidden = true;
23
+ statusArea.textContent = "";
24
+ }
25
+
26
+ function escapeHtml(str) {
27
+ const div = document.createElement("div");
28
+ div.textContent = str ?? "";
29
+ return div.innerHTML;
30
+ }
31
+
32
+ function auditRowHtml(audit) {
33
+ const icon = ICONS[audit.status] || "•";
34
+ const value = audit.display_value ? `<span class="audit-value">${escapeHtml(audit.display_value)}</span>` : "";
35
+ return `
36
+ <details class="audit-row">
37
+ <summary>
38
+ <span class="icon status-${audit.status}">${icon}</span>
39
+ <span class="audit-title">${escapeHtml(audit.title)}</span>
40
+ ${value}
41
+ <span class="chevron">▾</span>
42
+ </summary>
43
+ <div class="audit-detail">${escapeHtml(stripMarkdownLinks(audit.description))}</div>
44
+ </details>
45
+ `;
46
+ }
47
+
48
+ // Lighthouse descriptions often contain markdown links like [text](url) and
49
+ // `code` spans; render them minimally without pulling in a markdown lib.
50
+ function stripMarkdownLinks(text) {
51
+ if (!text) return "";
52
+ return text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/`([^`]+)`/g, "$1");
53
+ }
54
+
55
+ function collapsibleSectionHtml(id, label, audits) {
56
+ if (!audits.length) return "";
57
+ const rows = audits.map(auditRowHtml).join("");
58
+ return `
59
+ <button type="button" class="section-toggle" data-target="${id}">
60
+ <span>${label} (${audits.length})</span>
61
+ <span class="link" data-label="${label}">Show</span>
62
+ </button>
63
+ <div class="collapsible" id="${id}">${rows}</div>
64
+ `;
65
+ }
66
+
67
+ function partitionAudits(audits) {
68
+ const failWarn = audits.filter((a) => a.status === "fail" || a.status === "warning");
69
+ const passed = audits.filter((a) => a.status === "pass");
70
+ const notApplicable = audits.filter((a) => a.status === "not_applicable" || a.status === "informative");
71
+ return { failWarn, passed, notApplicable };
72
+ }
73
+
74
+ function groupBlockHtml(group, idx) {
75
+ const { failWarn, passed, notApplicable } = partitionAudits(group.audits);
76
+ const heading = group.title ? `<div class="group-heading">${escapeHtml(group.title)}</div>` : "";
77
+ const failWarnRows = failWarn.map(auditRowHtml).join("");
78
+ const passedId = `passed-${idx}`;
79
+ const naId = `na-${idx}`;
80
+ return `
81
+ ${heading}
82
+ ${failWarnRows}
83
+ ${collapsibleSectionHtml(passedId, "Passed audits", passed)}
84
+ ${collapsibleSectionHtml(naId, "Not applicable", notApplicable)}
85
+ `;
86
+ }
87
+
88
+ function ratioStatusClass(passCount, scoredCount) {
89
+ if (scoredCount === 0) return "status-warning";
90
+ const ratio = passCount / scoredCount;
91
+ if (ratio === 1) return "status-pass";
92
+ if (ratio === 0) return "status-fail";
93
+ return "status-fail"; // Lighthouse shows the red triangle for any failing check
94
+ }
95
+
96
+ function renderReport(report) {
97
+ const statusClass = ratioStatusClass(report.pass_count, report.scored_count);
98
+ const ungroupedRows = report.ungrouped_audits.map(auditRowHtml).join("");
99
+
100
+ const groupsHtml = report.groups
101
+ .map((group, idx) => groupBlockHtml(group, idx))
102
+ .join("");
103
+
104
+ reportArea.innerHTML = `
105
+ <div class="report-card">
106
+ <div class="report-header">
107
+ <div class="ratio-badge ${statusClass}">
108
+ ${ICONS.fail} ${escapeHtml(report.pass_ratio_label)}
109
+ </div>
110
+ <h2>${escapeHtml(report.category_title)}</h2>
111
+ <p>${escapeHtml(stripMarkdownLinks(report.category_description))}</p>
112
+ </div>
113
+ <div class="audited-url">Audited: ${escapeHtml(report.final_url || "")}</div>
114
+ ${ungroupedRows}
115
+ ${groupsHtml}
116
+ <div class="footer-meta">
117
+ Generated with Lighthouse ${escapeHtml(report.lighthouse_version || "")} · ${escapeHtml(report.fetch_time || "")}
118
+ </div>
119
+ </div>
120
+ `;
121
+ reportArea.hidden = false;
122
+
123
+ reportArea.querySelectorAll(".section-toggle").forEach((btn) => {
124
+ btn.addEventListener("click", () => {
125
+ const target = document.getElementById(btn.dataset.target);
126
+ const isOpen = target.classList.toggle("open");
127
+ const linkEl = btn.querySelector(".link");
128
+ linkEl.textContent = isOpen ? "Hide" : "Show";
129
+ });
130
+ });
131
+ }
132
+
133
+ form.addEventListener("submit", async (e) => {
134
+ e.preventDefault();
135
+ reportArea.hidden = true;
136
+ reportArea.innerHTML = "";
137
+ submitBtn.disabled = true;
138
+ submitBtn.textContent = "Analyzing…";
139
+ setStatus("loading", "Launching Chrome and running the Agentic Browsing audit — this can take 20-60 seconds…");
140
+
141
+ try {
142
+ const res = await fetch("/api/audit", {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ url: urlInput.value }),
146
+ });
147
+ const data = await res.json();
148
+
149
+ if (!res.ok || !data.ok) {
150
+ setStatus("error", data.error || "Something went wrong running the audit.");
151
+ return;
152
+ }
153
+
154
+ clearStatus();
155
+ renderReport(data.report);
156
+ } catch (err) {
157
+ setStatus("error", `Couldn't reach the backend: ${err.message}`);
158
+ } finally {
159
+ submitBtn.disabled = false;
160
+ submitBtn.textContent = "Analyze";
161
+ }
162
+ });
@@ -0,0 +1,295 @@
1
+ :root {
2
+ --ink: #202124;
3
+ --muted: #5f6368;
4
+ --line: #e8eaed;
5
+ --red: #d32f2f;
6
+ --red-bg: #fdecea;
7
+ --orange: #f9a825;
8
+ --orange-bg: #fff8e1;
9
+ --green: #1e8e3e;
10
+ --green-bg: #e6f4ea;
11
+ --gray: #9aa0a6;
12
+ --blue: #1a73e8;
13
+ --card-bg: #ffffff;
14
+ --page-bg: #f6f7f8;
15
+ --radius: 10px;
16
+ }
17
+
18
+ * { box-sizing: border-box; }
19
+
20
+ body {
21
+ margin: 0;
22
+ background: var(--page-bg);
23
+ color: var(--ink);
24
+ font-family: "Roboto", "Segoe UI", Helvetica, Arial, sans-serif;
25
+ -webkit-font-smoothing: antialiased;
26
+ }
27
+
28
+ .topbar {
29
+ background: var(--card-bg);
30
+ border-bottom: 1px solid var(--line);
31
+ padding: 28px 24px 22px;
32
+ text-align: center;
33
+ }
34
+
35
+ .topbar-inner {
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ gap: 10px;
40
+ }
41
+
42
+ .logo-mark {
43
+ font-size: 22px;
44
+ color: var(--blue);
45
+ }
46
+
47
+ .topbar h1 {
48
+ font-size: 22px;
49
+ font-weight: 600;
50
+ margin: 0;
51
+ }
52
+
53
+ .subhead {
54
+ max-width: 620px;
55
+ margin: 10px auto 0;
56
+ color: var(--muted);
57
+ font-size: 14px;
58
+ line-height: 1.5;
59
+ }
60
+
61
+ main {
62
+ max-width: 700px;
63
+ margin: 28px auto 80px;
64
+ padding: 0 20px;
65
+ }
66
+
67
+ .search-card {
68
+ background: var(--card-bg);
69
+ border: 1px solid var(--line);
70
+ border-radius: var(--radius);
71
+ padding: 20px;
72
+ }
73
+
74
+ .search-card label {
75
+ display: block;
76
+ font-size: 12px;
77
+ font-weight: 600;
78
+ text-transform: uppercase;
79
+ letter-spacing: 0.04em;
80
+ color: var(--muted);
81
+ margin-bottom: 8px;
82
+ }
83
+
84
+ .input-row {
85
+ display: flex;
86
+ gap: 10px;
87
+ }
88
+
89
+ #url-input {
90
+ flex: 1;
91
+ padding: 11px 14px;
92
+ border: 1px solid #dadce0;
93
+ border-radius: 8px;
94
+ font-size: 15px;
95
+ color: var(--ink);
96
+ }
97
+
98
+ #url-input:focus {
99
+ outline: 2px solid var(--blue);
100
+ outline-offset: -1px;
101
+ border-color: var(--blue);
102
+ }
103
+
104
+ #submit-btn {
105
+ padding: 11px 20px;
106
+ border: none;
107
+ border-radius: 8px;
108
+ background: var(--blue);
109
+ color: white;
110
+ font-size: 15px;
111
+ font-weight: 500;
112
+ cursor: pointer;
113
+ }
114
+
115
+ #submit-btn:hover { background: #1765cc; }
116
+ #submit-btn:disabled { background: #9aa0a6; cursor: default; }
117
+
118
+ .hint {
119
+ margin: 12px 0 0;
120
+ font-size: 12.5px;
121
+ color: var(--muted);
122
+ }
123
+
124
+ .status-area {
125
+ margin-top: 22px;
126
+ padding: 16px 18px;
127
+ border-radius: var(--radius);
128
+ font-size: 14px;
129
+ line-height: 1.5;
130
+ }
131
+
132
+ .status-area.loading {
133
+ background: #e8f0fe;
134
+ color: #174ea6;
135
+ }
136
+
137
+ .status-area.error {
138
+ background: var(--red-bg);
139
+ color: var(--red);
140
+ white-space: pre-wrap;
141
+ }
142
+
143
+ .report-area {
144
+ margin-top: 24px;
145
+ }
146
+
147
+ .report-card {
148
+ background: var(--card-bg);
149
+ border: 1px solid var(--line);
150
+ border-radius: var(--radius);
151
+ overflow: hidden;
152
+ }
153
+
154
+ .report-header {
155
+ padding: 32px 24px 24px;
156
+ text-align: center;
157
+ border-bottom: 1px solid var(--line);
158
+ }
159
+
160
+ .ratio-badge {
161
+ display: inline-flex;
162
+ align-items: center;
163
+ gap: 6px;
164
+ padding: 8px 18px;
165
+ border-radius: 999px;
166
+ font-weight: 700;
167
+ font-size: 16px;
168
+ margin-bottom: 18px;
169
+ }
170
+
171
+ .ratio-badge.status-fail { background: var(--red-bg); color: var(--red); }
172
+ .ratio-badge.status-warning { background: var(--orange-bg); color: var(--orange); }
173
+ .ratio-badge.status-pass { background: var(--green-bg); color: var(--green); }
174
+
175
+ .report-header h2 {
176
+ font-size: 24px;
177
+ margin: 0 0 10px;
178
+ }
179
+
180
+ .report-header p {
181
+ color: var(--muted);
182
+ font-size: 14px;
183
+ line-height: 1.6;
184
+ max-width: 480px;
185
+ margin: 0 auto;
186
+ }
187
+
188
+ .report-header a { color: var(--blue); text-decoration: none; }
189
+ .report-header a:hover { text-decoration: underline; }
190
+
191
+ .audited-url {
192
+ font-size: 12.5px;
193
+ color: var(--muted);
194
+ padding: 10px 24px;
195
+ border-bottom: 1px solid var(--line);
196
+ word-break: break-all;
197
+ }
198
+
199
+ .group-heading {
200
+ padding: 16px 24px 8px;
201
+ font-size: 12px;
202
+ font-weight: 700;
203
+ letter-spacing: 0.05em;
204
+ text-transform: uppercase;
205
+ color: var(--muted);
206
+ border-top: 1px solid var(--line);
207
+ }
208
+
209
+ .audit-row {
210
+ border-top: 1px solid var(--line);
211
+ }
212
+
213
+ .audit-row summary {
214
+ list-style: none;
215
+ cursor: pointer;
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 12px;
219
+ padding: 14px 24px;
220
+ font-size: 14.5px;
221
+ }
222
+
223
+ .audit-row summary::-webkit-details-marker { display: none; }
224
+
225
+ .audit-row summary .chevron {
226
+ margin-left: auto;
227
+ color: var(--muted);
228
+ transition: transform 0.15s ease;
229
+ font-size: 12px;
230
+ }
231
+
232
+ .audit-row[open] summary .chevron { transform: rotate(180deg); }
233
+
234
+ .icon {
235
+ flex: none;
236
+ width: 16px;
237
+ height: 16px;
238
+ display: inline-flex;
239
+ align-items: center;
240
+ justify-content: center;
241
+ }
242
+
243
+ .icon.status-fail { color: var(--red); }
244
+ .icon.status-warning { color: var(--orange); }
245
+ .icon.status-pass { color: var(--green); }
246
+ .icon.status-not_applicable, .icon.status-informative { color: var(--gray); }
247
+
248
+ .audit-title { flex: 1; }
249
+
250
+ .audit-value {
251
+ color: var(--muted);
252
+ font-size: 13px;
253
+ }
254
+
255
+ .audit-detail {
256
+ padding: 0 24px 18px 52px;
257
+ font-size: 13.5px;
258
+ color: var(--muted);
259
+ line-height: 1.6;
260
+ }
261
+
262
+ .section-toggle {
263
+ width: 100%;
264
+ text-align: left;
265
+ padding: 14px 24px;
266
+ border: none;
267
+ background: none;
268
+ border-top: 1px solid var(--line);
269
+ font-size: 12px;
270
+ font-weight: 700;
271
+ letter-spacing: 0.05em;
272
+ text-transform: uppercase;
273
+ color: var(--muted);
274
+ cursor: pointer;
275
+ display: flex;
276
+ justify-content: space-between;
277
+ }
278
+
279
+ .section-toggle span.link { color: var(--blue); text-transform: none; font-weight: 500; letter-spacing: 0; }
280
+
281
+ .collapsible { display: none; }
282
+ .collapsible.open { display: block; }
283
+
284
+ .footer-meta {
285
+ padding: 16px 24px;
286
+ font-size: 12px;
287
+ color: var(--muted);
288
+ border-top: 1px solid var(--line);
289
+ text-align: center;
290
+ }
291
+
292
+ @media (max-width: 520px) {
293
+ .input-row { flex-direction: column; }
294
+ #submit-btn { width: 100%; }
295
+ }
@@ -0,0 +1,49 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Agentic Browsing Auditor</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <div class="topbar-inner">
12
+ <span class="logo-mark">◐</span>
13
+ <h1>Agentic Browsing Auditor</h1>
14
+ </div>
15
+ <p class="subhead">
16
+ Run Google Lighthouse's experimental Agentic Browsing category against any page —
17
+ llms.txt, WebMCP, agent-centric accessibility, and layout stability.
18
+ </p>
19
+ </header>
20
+
21
+ <main>
22
+ <section class="search-card">
23
+ <form id="audit-form">
24
+ <label for="url-input">Domain or page URL</label>
25
+ <div class="input-row">
26
+ <input
27
+ id="url-input"
28
+ type="text"
29
+ placeholder="example.com or https://example.com/pricing"
30
+ autocomplete="off"
31
+ required
32
+ />
33
+ <button type="submit" id="submit-btn">Analyze</button>
34
+ </div>
35
+ </form>
36
+ <p class="hint">
37
+ Requires Lighthouse + a compatible Chrome build on the server running this tool.
38
+ See the README if you hit setup errors.
39
+ </p>
40
+ </section>
41
+
42
+ <section id="status-area" class="status-area" hidden></section>
43
+
44
+ <section id="report-area" class="report-area" hidden></section>
45
+ </main>
46
+
47
+ <script src="{{ url_for('static', filename='script.js') }}"></script>
48
+ </body>
49
+ </html>
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentic-browsing-auditor
3
+ Version: 1.0.0
4
+ Summary: Lighthouse Agentic Browsing Audit CLI and local Web Dashboard
5
+ Author-email: Amal Alexander <amalalex95@gmail.com>
6
+ Project-URL: Homepage, https://github.com/amal-alexander/agentic-browsing-auditor
7
+ Project-URL: LinkedIn, https://www.linkedin.com/in/amal-alexander-305780131/
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: Flask>=3.0.0
14
+ Requires-Dist: click>=8.0.0
15
+ Requires-Dist: rich>=13.0.0
16
+
17
+ # Agentic Browsing Auditor
18
+
19
+ [![PyPI Version](https://img.shields.io/pypi/v/agentic-browsing-auditor.svg)](https://pypi.org/project/agentic-browsing-auditor/)
20
+ [![LinkedIn](https://img.shields.io/badge/LinkedIn-Amal%20Alexander-blue)](https://www.linkedin.com/in/amal-alexander-305780131/)
21
+
22
+ A Python-based CLI tool and local dashboard to audit website performance for LLM agents using Google Lighthouse's experimental **Agentic Browsing** category (evaluates `llms.txt`, `WebMCP`, agent-centric accessibility, and layout stability).
23
+
24
+ Developed by **Amal Alexander** ([LinkedIn](https://www.linkedin.com/in/amal-alexander-305780131/)).
25
+
26
+ ---
27
+
28
+ ## Why you need Chrome + Node
29
+
30
+ The Agentic Browsing category:
31
+ - Shipped in **Lighthouse 13.3** (May 2026) as part of the default config.
32
+ - Requires **Chrome 150+** (or Chrome Canary).
33
+ - Requires a local node environment to shell out to `lighthouse`.
34
+
35
+ ---
36
+
37
+ ## Setup & Installation
38
+
39
+ You can install the auditor package directly from PyPI:
40
+
41
+ ```bash
42
+ pip install agentic-browsing-auditor
43
+ ```
44
+
45
+ ### Pre-requisites
46
+
47
+ 1. **Install Node.js** (18+): https://nodejs.org
48
+ 2. **Install Lighthouse** globally:
49
+ ```bash
50
+ npm install -g lighthouse
51
+ ```
52
+ 3. **Get a compatible Chrome build.** Easiest path: install [Chrome Canary](https://www.google.com/chrome/canary/).
53
+ 4. **Point the tool at that Chrome binary** via the `CHROME_PATH` environment variable:
54
+ - **Windows (PowerShell)**:
55
+ ```powershell
56
+ $env:CHROME_PATH = "C:\Users\<YourUsername>\AppData\Local\Google\Chrome SxS\Application\chrome.exe"
57
+ ```
58
+ - **macOS**:
59
+ ```bash
60
+ export CHROME_PATH="/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"
61
+ ```
62
+ - **Linux**:
63
+ ```bash
64
+ export CHROME_PATH="/usr/bin/google-chrome-canary"
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Usage
70
+
71
+ Once installed, you can access the auditor using the global CLI command `agentic-auditor`.
72
+
73
+ ### 1. Audit a Single URL
74
+ Analyze a website and print a beautiful table of results directly inside the terminal:
75
+ ```bash
76
+ agentic-auditor audit example.com
77
+ ```
78
+
79
+ ### 2. Bulk Audit URLs (with CSV export)
80
+ Audit multiple URLs listed in a text file (one URL per line) and export the results to a CSV file.
81
+ ```bash
82
+ agentic-auditor bulk urls.txt --output results.csv
83
+ ```
84
+
85
+ ### 3. Launch the Local Web Dashboard
86
+ Serve the interactive visual Lighthouse-style dashboard locally:
87
+ ```bash
88
+ agentic-auditor serve
89
+ ```
90
+ Then visit http://localhost:5000 in your browser.
91
+
92
+ ---
93
+
94
+ ## Author & Contact
95
+
96
+ Built and maintained by **Amal Alexander**.
97
+ - **Email**: [amalalex95@gmail.com](mailto:amalalex95@gmail.com)
98
+ - **LinkedIn**: [amal-alexander-305780131](https://www.linkedin.com/in/amal-alexander-305780131/)
@@ -0,0 +1,12 @@
1
+ agentic_auditor/__init__.py,sha256=DWJsWudxNT3mGIyIApfaLOanNrmkYuKv6-iCYfukdzw,205
2
+ agentic_auditor/app.py,sha256=vwLWnmqxtjlDMZd4TgwkbGn7BUhAkizbteUvUIEHUJw,1072
3
+ agentic_auditor/auditor.py,sha256=VqwzKPjFFkwkDzDLSzXxb41ylKi5jRbz6aKaD449fN0,8458
4
+ agentic_auditor/cli.py,sha256=cuMqeYBeVr7q4OdXk6VrPYsuMtsJOzFh3kHBEWwCpUU,8312
5
+ agentic_auditor/static/script.js,sha256=iiZlZSfDeCLZuATbbmd7Y73z3Pva4SYtI5vea5LikAo,5625
6
+ agentic_auditor/static/style.css,sha256=yiexmQNFIl4eOStKPcRQWccUXvwWheCpm1em1pzX0MU,5742
7
+ agentic_auditor/templates/index.html,sha256=g7C6ESb_NYc7rj78in4K96cHH1tWTSOqd7KLciRE4U8,1560
8
+ agentic_browsing_auditor-1.0.0.dist-info/METADATA,sha256=gzNJufVBJhAoTHRIXMDk4HFlG1HzMrb7CvcQsvaGWqw,3415
9
+ agentic_browsing_auditor-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ agentic_browsing_auditor-1.0.0.dist-info/entry_points.txt,sha256=PuRWpAsxdbFKaKQjCOxUzj_q3MZFR1O-0lplzrnVUqk,61
11
+ agentic_browsing_auditor-1.0.0.dist-info/top_level.txt,sha256=fyWUSygSE-Rp_hNElkUUy0OLOXUHBceUmTRO7M9iGeU,16
12
+ agentic_browsing_auditor-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentic-auditor = agentic_auditor.cli:main
@@ -0,0 +1 @@
1
+ agentic_auditor