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.
- agentic_auditor/__init__.py +10 -0
- agentic_auditor/app.py +34 -0
- agentic_auditor/auditor.py +241 -0
- agentic_auditor/cli.py +229 -0
- agentic_auditor/static/script.js +162 -0
- agentic_auditor/static/style.css +295 -0
- agentic_auditor/templates/index.html +49 -0
- agentic_browsing_auditor-1.0.0.dist-info/METADATA +98 -0
- agentic_browsing_auditor-1.0.0.dist-info/RECORD +12 -0
- agentic_browsing_auditor-1.0.0.dist-info/WHEEL +5 -0
- agentic_browsing_auditor-1.0.0.dist-info/entry_points.txt +2 -0
- agentic_browsing_auditor-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://pypi.org/project/agentic-browsing-auditor/)
|
|
20
|
+
[](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 @@
|
|
|
1
|
+
agentic_auditor
|