Build an AI-Powered Website Audit Scanner
Build a Python API that scans any website and uses AI to explain findings.
Introduction
30 Second Summary
Every website has hidden problems. Professional developers use audit scanners to catch issues like broken links, missing security headers, and slow load times before visitors notice.
In this project, you will build a full-stack website audit scanner that analyzes any URL across five categories. It then uses AI to turn raw findings into plain-English recommendations.
What You'll Build
A full-stack web application with a Python FastAPI backend, a five-category website scanner built with BeautifulSoup, and OpenAI GPT-4o mini integration for AI-powered recommendations.
By the end of this project, you'll have:
- A FastAPI REST API with a /api/scan endpoint that accepts any URL and returns structured audit data.
- A Python scanner module that checks SEO, security headers, accessibility, performance, and broken links.
- An OpenAI GPT-4o mini integration that explains findings in plain English, displayed in a dark-themed dashboard with a downloadable JSON report.
- A secret mission to add a 0-100 scoring system and custom branding to your scanner.
Do I need Python experience?
No prior Python or API experience is required. You just need basic HTML, CSS, and JavaScript knowledge. Each scan costs less than $0.01 with OpenAI's GPT-4o mini, so a full session of testing costs well under $0.10.
Set Up Python and Your Project
Your website audit scanner runs on Python. It uses Python libraries to fetch web pages, parse HTML, and communicate with OpenAI's API for AI-powered analysis.
To keep things organized and avoid conflicts with other projects on your computer, you'll set everything up inside a virtual environment with its own isolated set of dependencies.
In this step, get ready to:
- Install Python and verify it's working.
- Create a project folder with a virtual environment and dependencies.
- Set up your OpenAI API key for AI-powered analysis.
Install Python
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open a terminal inside VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows). The backtick key is above Tab on your keyboard.
- Check if Python is already installed by running this command:
macOS
python3 --version
Windows
python --version
I see a version number
You should see 3.12 or higher. Python is ready to go.
Command not found
No worries. You need to install Python first.
- Go to the Python downloads page.
- Click the large Download Python 3.x.x button to download the installer for your operating system.
- Run the installer.
Windows users: check the PATH box
On the very first screen of the installer, check the box that says Add python.exe to PATH before clicking Install Now.
If you skip this, your terminal won't recognize the python command later.
- After installation finishes, close your current VS Code terminal by clicking the trash can icon in the terminal panel.
- Open a fresh terminal by pressing Cmd+` (macOS) or Ctrl+` (Windows).
- Run the version check command again:
macOS
python3 --version
Windows
python --version
You should now see 3.12 or higher.
Create the project folder and virtual environment
A virtual environment gives your project its own isolated copy of Python and its packages. This prevents conflicts between projects that need different library versions.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir website-audit-scanner
cd website-audit-scanner
- Confirm the folder was created by running:
ls
The output should be empty because the folder is brand new.
- Create a virtual environment and activate it by running these commands:
macOS
python3 -m venv .venv
source .venv/bin/activate
Windows
python -m venv .venv
.venv\Scripts\Activate.ps1
You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
PowerShell blocking the activation script?
If you see an error about running scripts being disabled on Windows, run this command first:
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
Then try the activation command again. This is a one-time change that allows PowerShell to run local scripts.
Still not working? Ask for help here.
What is a virtual environment?
A virtual environment is like a clean room for your project. Every package you install goes inside the .venv folder instead of your system-wide Python installation.
When you're done working on this project, you can delete the folder and all the packages go with it.
Now you need a file that lists all the Python packages your project depends on.
- In VS Code's Explorer sidebar, hover over website-audit-scanner to reveal the action icons.
- Click the New File icon.
- Type requirements.txt and press Enter.
- Paste the following into the file:
fastapi[standard]==0.136.1
beautifulsoup4==4.14.3
requests==2.31.1
openai==2.32.0
python-dotenv
What are these packages?
- fastapi[standard] is the web framework that powers your API. The [standard] extra includes a built-in development server.
- beautifulsoup4 parses HTML into a navigable tree so you can extract titles, headings, and meta tags.
- requests fetches web pages by sending HTTP requests to any URL.
- openai is the official Python SDK for the OpenAI API, which you'll use to get AI-powered analysis.
- python-dotenv loads environment variables from a .env file so your API key stays out of your code.
- Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Install all five packages by running this command in your terminal:
pip install -r requirements.txt
You should see output as each package downloads and installs. The process ends with a Successfully installed message listing all the packages.
Seeing install errors?
- Make sure your virtual environment is active. You should see (.venv) at the start of your terminal prompt.
- Check that you saved requirements.txt before running the install command.
- If you see a permissions error, confirm you're running inside the virtual environment and not your system Python.
Still stuck? Ask for help here.
Set up your OpenAI API key
The scanner uses GPT-4o mini to analyze scan results and explain them in plain English. To call the OpenAI API, you need an API key.
- Go to platform.openai.com.
- Create an account or log in if you already have one.
- Click API Keys in the left sidebar.
- Click Create new secret key.
- Enter audit-scanner as the name.
- Click Create secret key.
- Copy the key. You will only see it once.
OpenAI API costs
OpenAI requires a payment method on file to use the API. GPT-4o mini costs $0.15 per million input tokens and $0.60 per million output tokens.
Each scan in this project costs well under $0.01. A full session of building and testing will cost less than $0.10 total.
Now store the key in a .env file so your code can access it without hardcoding it.
- In VS Code's Explorer sidebar, hover over website-audit-scanner and click the New File icon.
- Type .env and press Enter.
- Paste the following into the file:
OPENAI_API_KEY=your-api-key-here
- Replace your-api-key-here with the actual API key you just copied.
- Save the file.
Why use a .env file?
The .env file keeps secrets like API keys separate from your code. If you ever share your code or push it to GitHub, you can exclude this file so your key stays private.
Finally, create a folder for the frontend files you'll build later.
- Create the static folder by running this command:
mkdir static
- Confirm your project structure by running:
ls
You should see .env, .venv, requirements.txt, and static in the output.
Missing files or folders?
- If .env is missing, check that you created it inside the website-audit-scanner folder and not one level up.
- If .venv doesn't show up, hidden files may be filtered. On macOS, run ls -a to show all files including hidden ones.
Need help? Ask here.
Your environment is ready. Next up, you'll build the scanner module that fetches web pages and analyzes them for SEO, security, and accessibility issues.
Build the Website Scanner
Your project environment is set up with Python, a virtual environment, and all the dependencies installed. Now it is time to build the core engine of your audit scanner.
Before your scanner can analyze a website, it needs to fetch the page, parse its HTML, and run checks across five categories: SEO, security headers, accessibility, performance, and broken links. In this step, you will build a Python module that handles all of that analysis.
In this step, get ready to:
- Create a scanner module that fetches web pages and analyzes their SEO elements.
- Add security header and accessibility checks to the scanner.
- Add performance measurement, broken link detection, and a function that runs the full scan.
Create the scanner module with page fetching and SEO analysis
Your scanner needs two foundational abilities: fetching a web page's HTML and analyzing its SEO elements. The fetching function downloads the page and measures how long the request takes. The SEO function parses that HTML to extract the title tag, meta description, heading structure, and Open Graph tags.
- Create a new file called scanner.py in your website-audit-scanner folder (the same folder that holds requirements.txt).
- Copy the following code into scanner.py:
import time
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
def fetch_page(url):
"""Fetch a webpage and return response data."""
# Add https:// if the user didn't include a protocol
if not url.startswith(("http://", "https://")):
url = "https://" + url
# Pretend to be a real browser so sites don't block the request
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
}
# Fetch the page and measure how long it takes
start_time = time.time()
response = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
load_time = round(time.time() - start_time, 2)
return {
"url": response.url,
"status_code": response.status_code,
"headers": dict(response.headers),
"html": response.text,
"load_time": load_time,
"page_size_kb": round(len(response.content) / 1024, 2),
}
def analyze_seo(soup):
"""Analyze SEO elements of the page."""
results = {
"title": None,
"title_length": 0,
"meta_description": None,
"meta_description_length": 0,
"headings": {},
"og_tags": {},
}
# Extract the page title
if soup.title and soup.title.string:
results["title"] = soup.title.string.strip()
results["title_length"] = len(results["title"])
# Extract the meta description tag
desc_tag = soup.find("meta", attrs={"name": "description"})
if desc_tag and desc_tag.get("content"):
results["meta_description"] = desc_tag["content"]
results["meta_description_length"] = len(results["meta_description"])
# Count how many of each heading level (H1-H6) exist
for level in range(1, 7):
tag = f"h{level}"
found = soup.find_all(tag)
if found:
results["headings"][tag] = len(found)
# Find all Open Graph meta tags (used for social media previews)
og_tags = soup.find_all(
"meta", attrs={"property": lambda x: x and x.startswith("og:")}
)
for tag in og_tags:
prop = tag.get("property", "")
content = tag.get("content", "")
results["og_tags"][prop] = content
return results
What does this code do?
- The imports bring in requests for downloading web pages, BeautifulSoup for parsing HTML, time for measuring load speed, and urljoin for resolving relative URLs later.
- fetch_page(url) downloads a webpage's HTML and records the response time, status code, and page size. It also adds https:// automatically if the user just typed a bare domain like example.com.
- analyze_seo(soup) receives a parsed HTML tree and extracts key SEO signals: the page title, meta description, heading counts (H1 through H6), and Open Graph tags used by social media previews.
What is BeautifulSoup?
BeautifulSoup is a Python library that turns raw HTML into a tree structure you can search and navigate. For example, soup.title.string grabs the text inside the <title> tag, and soup.find_all('h1') returns every H1 element on the page.
- Save scanner.py.
- Test that the fetch function works by running this command in your terminal:
python -c "from scanner import fetch_page; print(fetch_page('example.com')['status_code'])"
You should see 200 printed in your terminal. That means your scanner successfully fetched a live web page.
Seeing ModuleNotFoundError or a connection error?
- If you see ModuleNotFoundError: No module named 'requests', your virtual environment may not be active. Check that your terminal prompt starts with (.venv). If not, activate it again with .venv\Scripts\Activate.ps1 and re-run pip install -r requirements.txt.
- If you see a ConnectionError, make sure you have an active internet connection.
- Still stuck? Help me debug why my scanner test command is failing.
Add security header and accessibility checks
Websites communicate security policies through HTTP response headers. Missing headers mean the site is not using basic protections that modern browsers support. The accessibility function checks for common issues like images without alt text and broken heading hierarchy.
- In scanner.py, add the following two functions below your analyze_seo function:
def analyze_security(page_data):
"""Check security headers and HTTPS."""
headers = page_data["headers"]
# The six critical security headers every site should have
security_headers = {
"Strict-Transport-Security": headers.get("Strict-Transport-Security"),
"Content-Security-Policy": headers.get("Content-Security-Policy"),
"X-Frame-Options": headers.get("X-Frame-Options"),
"X-Content-Type-Options": headers.get("X-Content-Type-Options"),
"Referrer-Policy": headers.get("Referrer-Policy"),
"Permissions-Policy": headers.get("Permissions-Policy"),
}
# Split headers into present and missing lists
missing_headers = [name for name, value in security_headers.items() if value is None]
present_headers = {
name: value for name, value in security_headers.items() if value is not None
}
return {
"is_https": page_data["url"].startswith("https://"),
"present_headers": present_headers,
"missing_headers": missing_headers,
"server_disclosure": headers.get("Server"),
"powered_by_disclosure": headers.get("X-Powered-By"),
}
def analyze_accessibility(soup):
"""Check basic accessibility issues."""
# Find images that are missing alt attributes
images = soup.find_all("img")
images_without_alt = [
img.get("src", "unknown") for img in images if not img.get("alt")
]
# Check heading hierarchy for common issues
heading_issues = []
h1_tags = soup.find_all("h1")
if len(h1_tags) == 0:
heading_issues.append("No H1 tag found")
elif len(h1_tags) > 1:
heading_issues.append(f"Multiple H1 tags found ({len(h1_tags)})")
# Check if the first heading on the page is an H1
all_headings = []
for level in range(1, 7):
for h in soup.find_all(f"h{level}"):
all_headings.append(level)
if all_headings and all_headings[0] != 1:
heading_issues.append("Page does not start with an H1 tag")
# Check if the page declares a language
html_tag = soup.find("html")
lang_value = html_tag.get("lang") if html_tag else None
return {
"total_images": len(images),
"images_without_alt": len(images_without_alt),
"sample_missing_alt": images_without_alt[:5],
"heading_issues": heading_issues,
"has_lang_attribute": bool(lang_value),
"lang_value": lang_value,
}
What does this code do?
- analyze_security(page_data) checks the HTTP response headers for six critical security headers like Strict-Transport-Security and Content-Security-Policy. It also flags whether the server reveals its software version, which is a security risk.
- analyze_accessibility(soup) checks for images missing alt attributes, validates that the page starts with an H1 heading and does not use multiple H1 tags, and checks whether the <html> tag has a lang attribute for screen readers.
- Save scanner.py.
You will not see a change yet. The next substep adds the remaining functions and a way to test the full scanner.
Add performance measurement and broken link checking
The last two analysis functions measure page load time and check for broken links. You will also add a run_full_scan function that orchestrates every analysis into a single call.
- In scanner.py, add the following three functions below your analyze_accessibility function:
def analyze_performance(page_data):
"""Basic performance indicators."""
return {
"load_time_seconds": page_data["load_time"],
"page_size_kb": page_data["page_size_kb"],
"is_large_page": page_data["page_size_kb"] > 500,
}
def check_broken_links(soup, base_url, max_links=10):
"""Check a sample of links for broken URLs."""
links = soup.find_all("a", href=True)
results = {"total_links": len(links), "checked": 0, "broken": [], "working": 0}
checked_urls = set()
for link in links:
# Stop after checking the max number of links
if results["checked"] >= max_links:
break
# Convert relative URLs to absolute ones
href = link["href"]
full_url = urljoin(base_url, href)
# Skip non-HTTP links and duplicates
if not full_url.startswith(("http://", "https://")):
continue
if full_url in checked_urls:
continue
checked_urls.add(full_url)
try:
# Use HEAD request (faster than GET, only fetches headers)
resp = requests.head(
full_url,
timeout=5,
allow_redirects=True,
headers={"User-Agent": "Mozilla/5.0"},
)
results["checked"] += 1
if resp.status_code >= 400:
results["broken"].append(
{
"url": full_url,
"status_code": resp.status_code,
"anchor_text": link.get_text(strip=True)[:50],
}
)
else:
results["working"] += 1
except requests.RequestException:
results["checked"] += 1
results["broken"].append(
{
"url": full_url,
"status_code": 0,
"anchor_text": link.get_text(strip=True)[:50],
}
)
return results
def run_full_scan(url):
"""Run all analyses on a URL and return combined results."""
# Fetch the page HTML and response data
page_data = fetch_page(url)
# Parse the HTML into a searchable tree
soup = BeautifulSoup(page_data["html"], "html.parser")
# Run each analysis and combine results into one dictionary
return {
"url": page_data["url"],
"status_code": page_data["status_code"],
"seo": analyze_seo(soup),
"security": analyze_security(page_data),
"accessibility": analyze_accessibility(soup),
"performance": analyze_performance(page_data),
"broken_links": check_broken_links(soup, page_data["url"]),
}
What does this code do?
- analyze_performance(page_data) reports the page load time and size in kilobytes. It flags pages over 500KB as large.
- check_broken_links(soup, base_url) finds all anchor tags on the page, then sends HEAD requests to the first 10 links to see if any return 404 or other error codes. It limits to 10 links to keep scan time reasonable.
- run_full_scan(url) ties everything together. It fetches the page, creates a BeautifulSoup object, passes it to each analysis function, and returns a single combined dictionary with all results.
Why HEAD requests instead of GET?
A HEAD request only fetches the response headers, not the full page body. This is much faster than a GET request when you only need to check whether a link returns a 200 (OK) or a 404 (Not Found). Since the broken link checker sends up to 10 requests per scan, using HEAD keeps things quick.
- Save scanner.py.
- Test the full scanner by running this command in your terminal:
python -c "from scanner import run_full_scan; import json; print(json.dumps(run_full_scan('example.com'), indent=2))"
You should see a large JSON output with sections for seo, security, accessibility, performance, and broken_links. The scan may take a few seconds because it is checking live links.
Seeing a timeout or connection error?
- If you see requests.exceptions.ConnectionError, make sure you have an active internet connection and that the URL you entered is reachable.
- If you see a SyntaxError, double-check that your indentation is consistent. Python is sensitive to spaces and tabs.
- If the command runs but the output looks wrong or incomplete, double-check your full code against the reference below.
- Still stuck? Help me debug why run_full_scan is failing.
Awesome, I've got everything
Great. Double-check you have saved scanner.py before moving on.
I'd like to double check the full code
Here is the complete scanner.py file. Make sure your code matches this exactly.
import time
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
def fetch_page(url):
"""Fetch a webpage and return response data."""
if not url.startswith(("http://", "https://")):
url = "https://" + url
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
}
start_time = time.time()
response = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
load_time = round(time.time() - start_time, 2)
return {
"url": response.url,
"status_code": response.status_code,
"headers": dict(response.headers),
"html": response.text,
"load_time": load_time,
"page_size_kb": round(len(response.content) / 1024, 2),
}
def analyze_seo(soup):
"""Analyze SEO elements of the page."""
results = {
"title": None,
"title_length": 0,
"meta_description": None,
"meta_description_length": 0,
"headings": {},
"og_tags": {},
}
if soup.title and soup.title.string:
results["title"] = soup.title.string.strip()
results["title_length"] = len(results["title"])
desc_tag = soup.find("meta", attrs={"name": "description"})
if desc_tag and desc_tag.get("content"):
results["meta_description"] = desc_tag["content"]
results["meta_description_length"] = len(results["meta_description"])
for level in range(1, 7):
tag = f"h{level}"
found = soup.find_all(tag)
if found:
results["headings"][tag] = len(found)
og_tags = soup.find_all(
"meta", attrs={"property": lambda x: x and x.startswith("og:")}
)
for tag in og_tags:
prop = tag.get("property", "")
content = tag.get("content", "")
results["og_tags"][prop] = content
return results
def analyze_security(page_data):
"""Check security headers and HTTPS."""
headers = page_data["headers"]
security_headers = {
"Strict-Transport-Security": headers.get("Strict-Transport-Security"),
"Content-Security-Policy": headers.get("Content-Security-Policy"),
"X-Frame-Options": headers.get("X-Frame-Options"),
"X-Content-Type-Options": headers.get("X-Content-Type-Options"),
"Referrer-Policy": headers.get("Referrer-Policy"),
"Permissions-Policy": headers.get("Permissions-Policy"),
}
missing_headers = [name for name, value in security_headers.items() if value is None]
present_headers = {
name: value for name, value in security_headers.items() if value is not None
}
return {
"is_https": page_data["url"].startswith("https://"),
"present_headers": present_headers,
"missing_headers": missing_headers,
"server_disclosure": headers.get("Server"),
"powered_by_disclosure": headers.get("X-Powered-By"),
}
def analyze_accessibility(soup):
"""Check basic accessibility issues."""
images = soup.find_all("img")
images_without_alt = [
img.get("src", "unknown") for img in images if not img.get("alt")
]
heading_issues = []
h1_tags = soup.find_all("h1")
if len(h1_tags) == 0:
heading_issues.append("No H1 tag found")
elif len(h1_tags) > 1:
heading_issues.append(f"Multiple H1 tags found ({len(h1_tags)})")
all_headings = []
for level in range(1, 7):
for h in soup.find_all(f"h{level}"):
all_headings.append(level)
if all_headings and all_headings[0] != 1:
heading_issues.append("Page does not start with an H1 tag")
html_tag = soup.find("html")
lang_value = html_tag.get("lang") if html_tag else None
return {
"total_images": len(images),
"images_without_alt": len(images_without_alt),
"sample_missing_alt": images_without_alt[:5],
"heading_issues": heading_issues,
"has_lang_attribute": bool(lang_value),
"lang_value": lang_value,
}
def analyze_performance(page_data):
"""Basic performance indicators."""
return {
"load_time_seconds": page_data["load_time"],
"page_size_kb": page_data["page_size_kb"],
"is_large_page": page_data["page_size_kb"] > 500,
}
def check_broken_links(soup, base_url, max_links=10):
"""Check a sample of links for broken URLs."""
links = soup.find_all("a", href=True)
results = {"total_links": len(links), "checked": 0, "broken": [], "working": 0}
checked_urls = set()
for link in links:
if results["checked"] >= max_links:
break
href = link["href"]
full_url = urljoin(base_url, href)
if not full_url.startswith(("http://", "https://")):
continue
if full_url in checked_urls:
continue
checked_urls.add(full_url)
try:
resp = requests.head(
full_url,
timeout=5,
allow_redirects=True,
headers={"User-Agent": "Mozilla/5.0"},
)
results["checked"] += 1
if resp.status_code >= 400:
results["broken"].append(
{
"url": full_url,
"status_code": resp.status_code,
"anchor_text": link.get_text(strip=True)[:50],
}
)
else:
results["working"] += 1
except requests.RequestException:
results["checked"] += 1
results["broken"].append(
{
"url": full_url,
"status_code": 0,
"anchor_text": link.get_text(strip=True)[:50],
}
)
return results
def run_full_scan(url):
"""Run all analyses on a URL and return combined results."""
page_data = fetch_page(url)
soup = BeautifulSoup(page_data["html"], "html.parser")
return {
"url": page_data["url"],
"status_code": page_data["status_code"],
"seo": analyze_seo(soup),
"security": analyze_security(page_data),
"accessibility": analyze_accessibility(soup),
"performance": analyze_performance(page_data),
"broken_links": check_broken_links(soup, page_data["url"]),
}
Your scanner module is complete. It can fetch any website and analyze it across five categories. Next up, you will wrap this scanner in a FastAPI backend so other programs (like your dashboard) can trigger scans over HTTP.
Create the FastAPI Backend
Your scanner module can fetch and analyze any webpage. But right now, the only way to use it is by importing it directly into another Python script.
To make it accessible from a browser, you need to wrap it in a web API. FastAPI is a Python framework that turns your functions into HTTP endpoints. It also auto-generates interactive documentation so you can test your API without writing any frontend code first.
In this step, get ready to:
- Create a FastAPI application with CORS middleware.
- Define a POST endpoint that accepts a URL and returns scan results.
- Serve static files and test the API using Swagger UI.
Create the FastAPI application with the scan endpoint
The API server lives in a single file that imports your scanner, exposes an endpoint, and handles errors.
- Create a new file called main.py in your website-audit-scanner folder and add this code:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from scanner import run_full_scan
# Create the FastAPI application
app = FastAPI(title="Website Audit Scanner")
# Allow the frontend to call the API from the browser
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
What does this code do?
- FastAPI creates the web application. HTTPException lets you return proper error responses (like 500 Internal Server Error) when something goes wrong.
- CORSMiddleware tells the server to accept requests from web browsers. Without CORS, browsers block frontend code from calling your API.
- from scanner import run_full_scan imports the scan function you built in the previous step.
- Save main.py. You will not see a change yet. The next chunk adds the endpoint that makes this app useful.
- Now add the request model and scan endpoint below the CORSMiddleware block in main.py:
# Define the expected shape of incoming requests
class ScanRequest(BaseModel):
url: str
@app.post("/api/scan")
def scan_website(request: ScanRequest):
"""Scan a website and return audit results."""
try:
# Run the scanner on the submitted URL
scan_results = run_full_scan(request.url)
return {
"scan_results": scan_results,
}
except Exception as e:
# Return a proper HTTP error if something goes wrong
raise HTTPException(status_code=500, detail=str(e))
What does this code do?
- ScanRequest is a Pydantic model that defines the shape of incoming requests. FastAPI uses it to automatically validate that every request includes a url field.
- @app.post("/api/scan") turns the function below it into a POST endpoint at /api/scan. When someone sends a POST request to that URL, FastAPI calls this function.
- The try/except block catches any errors from the scanner (like a bad URL or a timeout) and returns them as an HTTP 500 error instead of crashing the server.
Why does the endpoint use def instead of async def?
The requests library you used in scanner.py is synchronous. It blocks while waiting for a response.
FastAPI handles this automatically. When you use def instead of async def, FastAPI runs the function in a separate thread pool so it does not block other requests.
- Save main.py. You will not see a visible change yet. You will test this endpoint in the next substep.
Add static file serving and test the API
FastAPI can serve your frontend files directly. You need one more line at the bottom of main.py to make that work.
- Add this line at the very bottom of main.py, after the scan_website function:
app.mount("/", StaticFiles(directory="static", html=True), name="static")
Why does this line go last?
This mounts the static folder at the root URL /. The html=True parameter tells FastAPI to serve index.html when someone visits the root URL.
It must come after all your route decorators. FastAPI checks routes in order, and this catch-all mount would intercept your /api/scan endpoint if it appeared first.
- Save main.py.
Awesome, I've got everything!
Double check you have saved main.py before moving on.
I'd like to double check the full code
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from scanner import run_full_scan
# Create the FastAPI application
app = FastAPI(title="Website Audit Scanner")
# Allow the frontend to call the API from the browser
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Define the expected shape of incoming requests
class ScanRequest(BaseModel):
url: str
@app.post("/api/scan")
def scan_website(request: ScanRequest):
"""Scan a website and return audit results."""
try:
# Run the scanner on the submitted URL
scan_results = run_full_scan(request.url)
return {
"scan_results": scan_results,
}
except Exception as e:
# Return a proper HTTP error if something goes wrong
raise HTTPException(status_code=500, detail=str(e))
app.mount("/", StaticFiles(directory="static", html=True), name="static")
Time to start the server and see your API in action.
- Make sure your virtual environment is active. You should see (.venv) at the start of your terminal prompt.
- Start the FastAPI development server by running this command:
fastapi dev main.py
- Open http://127.0.0.1:8000/docs in your browser.
You should see FastAPI's auto-generated Swagger UI. This is interactive documentation that lets you test your endpoints directly in the browser.
- Click on the POST /api/scan endpoint to expand it.
- Click Try it out in the top right of the expanded section.
- Replace the default request body with this:
{"url": "example.com"}
- Click Execute.
After a few seconds, you should see a JSON response appear in the **Response body** section. It contains scan_results with SEO, security, accessibility, performance, and broken link data. There is no AI analysis yet. You will add that in the next step.
Seeing an error when you start the server?
- If you see ModuleNotFoundError: No module named 'fastapi', your virtual environment is not active. Run .venv\Scripts\Activate.ps1 and try again.
- If you see ModuleNotFoundError: No module named 'scanner', make sure scanner.py is in the same folder as main.py (the website-audit-scanner folder, not inside static).
- If the scan returns a 500 error with a timeout message, the target site may be slow or blocking automated requests. Try a different URL like example.com.
Still stuck? Ask for help here: My FastAPI server won't start or my scan endpoint returns an error. Help me debug.
Your API is live and returning real scan data. Next up, you will plug in OpenAI so the scanner can explain its findings in plain English.
Add AI-Powered Analysis with OpenAI
Your FastAPI backend is live and returning scan data for any URL you throw at it. The problem is that the raw results read like a wall of technical jargon.
In this step, you will connect your scanner to OpenAI's GPT-4o mini model. The AI will read your scan results and translate them into plain-English summaries, priority rankings, and specific fix recommendations.
In this step, get ready to:
- Connect your backend to the OpenAI API using your API key.
- Build a function that formats scan results into a prompt and sends them to GPT-4o mini.
- Wire AI analysis into your scan endpoint and verify it works.
Set up the OpenAI client
Before you can send anything to GPT-4o mini, your backend needs to know how to authenticate with OpenAI. The python-dotenv library reads your .env file into environment variables, and the OpenAI SDK picks up the key automatically from there.
- In main.py, replace the entire import section at the top of the file and add the OpenAI client setup by updating the code to match the following:
import os
import json
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from dotenv import load_dotenv
from openai import OpenAI
from scanner import run_full_scan
# Load environment variables from .env file
load_dotenv()
app = FastAPI(title="Website Audit Scanner")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Create the OpenAI client using your API key
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
What's new here?
You kept all your existing FastAPI imports and added four new pieces:
- os reads environment variables. json converts Python dictionaries to JSON strings (used in the prompt later). datetime timestamps each scan.
- load_dotenv() reads your .env file and makes OPENAI_API_KEY available as an environment variable.
- OpenAI(api_key=...) creates a client that handles all communication with the OpenAI API.
You won't see a change yet. The next chunk builds the function that actually uses this client.
Create the AI analysis function
The AI analysis function takes your raw scan results, formats them into a clear prompt, and asks GPT-4o mini for a summary with prioritized recommendations.
- In main.py, add this function between the ScanRequest class and the @app.post("/api/scan") endpoint:
def get_ai_analysis(scan_results):
"""Send scan results to GPT-4o-mini for analysis and recommendations."""
# Build a detailed prompt with all the scan data
prompt = f"""You are a website audit expert. Analyze these scan results and provide:
1. A brief overall summary (2-3 sentences)
2. Top 3 priority issues to fix
3. Specific recommendations for each issue
Scan Results:
- URL: {scan_results['url']}
- SEO: Title: {scan_results['seo']['title']}, Title length: {scan_results['seo']['title_length']}, \
Meta description: {'Present' if scan_results['seo']['meta_description'] else 'Missing'}, \
Meta description length: {scan_results['seo']['meta_description_length']}, \
Headings: {json.dumps(scan_results['seo']['headings'])}, \
Open Graph tags: {len(scan_results['seo']['og_tags'])} found
- Security: HTTPS: {scan_results['security']['is_https']}, \
Missing headers: {', '.join(scan_results['security']['missing_headers']) or 'None'}, \
Present headers: {', '.join(scan_results['security']['present_headers'].keys()) or 'None'}, \
Server disclosure: {scan_results['security']['server_disclosure']}, \
X-Powered-By: {scan_results['security']['powered_by_disclosure']}
- Accessibility: {scan_results['accessibility']['images_without_alt']} of \
{scan_results['accessibility']['total_images']} images missing alt tags, \
Heading issues: {', '.join(scan_results['accessibility']['heading_issues']) or 'None'}, \
Language attribute: {scan_results['accessibility']['has_lang_attribute']}
- Performance: Load time: {scan_results['performance']['load_time_seconds']}s, \
Page size: {scan_results['performance']['page_size_kb']}KB
- Broken Links: {len(scan_results['broken_links']['broken'])} broken out of \
{scan_results['broken_links']['checked']} checked \
({scan_results['broken_links']['total_links']} total on page)
Respond in plain text with clear sections. Keep it concise and actionable."""
# Send the prompt to GPT-4o mini and return the response
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are a helpful website audit expert. Provide clear, actionable advice.",
},
{"role": "user", "content": prompt},
],
max_tokens=1000,
)
return response.choices[0].message.content
What does this function do?
- The prompt variable is an f-string that injects your scan results into a structured message. It tells the AI exactly what to analyze and how to format the response.
- client.chat.completions.create() sends a request to the OpenAI API. The system message sets the AI's role, and the user message contains your actual prompt.
- max_tokens=1000 caps the AI's response length to keep costs low and responses focused.
- response.choices[0].message.content extracts the AI's text from the response object.
How much does this cost?
GPT-4o mini costs $0.15 per million input tokens and $0.60 per million output tokens. Each scan sends roughly 1,000-2,000 tokens and receives about 500 tokens back. That puts each scan well under $0.01. A full development session of testing costs under $0.10 total.
You won't see a change yet. The next chunk wires this function into the scan endpoint.
Wire AI analysis into the scan endpoint and test
Your get_ai_analysis function exists, but the scan endpoint does not call it yet. You need to update the endpoint to run the AI analysis after each scan and include a timestamp in the response.
- In main.py, find your existing scan_website function. It currently looks like this:
@app.post("/api/scan")
def scan_website(request: ScanRequest):
try:
scan_results = run_full_scan(request.url)
return {"scan_results": scan_results}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
- Replace the entire scan_website function with this updated version that calls the AI and adds a timestamp:
@app.post("/api/scan")
def scan_website(request: ScanRequest):
"""Scan a website and return audit results with AI analysis."""
try:
# Run all five scan analyses on the URL
scan_results = run_full_scan(request.url)
# Send the results to GPT-4o mini for a plain-English summary
ai_analysis = get_ai_analysis(scan_results)
return {
"scan_results": scan_results,
"ai_analysis": ai_analysis,
"scanned_at": datetime.now().isoformat(),
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
What changed?
- ai_analysis = get_ai_analysis(scan_results) calls your new function, passing in the raw scan data. GPT-4o mini processes it and returns a plain-English analysis.
- The return dictionary now includes three fields: scan_results (the raw data), ai_analysis (the AI's summary), and scanned_at (a timestamp for when the scan happened).
- Save main.py.
Awesome, I've got everything!
Double check you've saved your file before continuing.
I'd like to double check the full code
Here is the complete main.py file at this point. Make sure your file matches this exactly.
import os
import json
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from dotenv import load_dotenv
from openai import OpenAI
from scanner import run_full_scan
load_dotenv()
app = FastAPI(title="Website Audit Scanner")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
class ScanRequest(BaseModel):
url: str
def get_ai_analysis(scan_results):
"""Send scan results to GPT-4o-mini for analysis and recommendations."""
prompt = f"""You are a website audit expert. Analyze these scan results and provide:
1. A brief overall summary (2-3 sentences)
2. Top 3 priority issues to fix
3. Specific recommendations for each issue
Scan Results:
- URL: {scan_results['url']}
- SEO: Title: {scan_results['seo']['title']}, Title length: {scan_results['seo']['title_length']}, \
Meta description: {'Present' if scan_results['seo']['meta_description'] else 'Missing'}, \
Meta description length: {scan_results['seo']['meta_description_length']}, \
Headings: {json.dumps(scan_results['seo']['headings'])}, \
Open Graph tags: {len(scan_results['seo']['og_tags'])} found
- Security: HTTPS: {scan_results['security']['is_https']}, \
Missing headers: {', '.join(scan_results['security']['missing_headers']) or 'None'}, \
Present headers: {', '.join(scan_results['security']['present_headers'].keys()) or 'None'}, \
Server disclosure: {scan_results['security']['server_disclosure']}, \
X-Powered-By: {scan_results['security']['powered_by_disclosure']}
- Accessibility: {scan_results['accessibility']['images_without_alt']} of \
{scan_results['accessibility']['total_images']} images missing alt tags, \
Heading issues: {', '.join(scan_results['accessibility']['heading_issues']) or 'None'}, \
Language attribute: {scan_results['accessibility']['has_lang_attribute']}
- Performance: Load time: {scan_results['performance']['load_time_seconds']}s, \
Page size: {scan_results['performance']['page_size_kb']}KB
- Broken Links: {len(scan_results['broken_links']['broken'])} broken out of \
{scan_results['broken_links']['checked']} checked \
({scan_results['broken_links']['total_links']} total on page)
Respond in plain text with clear sections. Keep it concise and actionable."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are a helpful website audit expert. Provide clear, actionable advice.",
},
{"role": "user", "content": prompt},
],
max_tokens=1000,
)
return response.choices[0].message.content
@app.post("/api/scan")
def scan_website(request: ScanRequest):
"""Scan a website and return audit results with AI analysis."""
try:
scan_results = run_full_scan(request.url)
ai_analysis = get_ai_analysis(scan_results)
return {
"scan_results": scan_results,
"ai_analysis": ai_analysis,
"scanned_at": datetime.now().isoformat(),
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
app.mount("/", StaticFiles(directory="static", html=True), name="static")
Now test the updated endpoint to confirm the AI analysis works.
- If your server is still running, press Ctrl+C in the terminal to stop it.
- Start the server again by running:
fastapi dev main.py
- Open http://127.0.0.1:8000/docs in your browser.
- Click on the POST /api/scan endpoint to expand it.
- Click Try it out.
- Replace the request body with this JSON:
{"url": "example.com"}
- Click Execute.
The scan takes a few seconds because it fetches the webpage, checks links, and then calls the OpenAI API. When it finishes, scroll down to the response body.
You should see a JSON response with three top-level fields: scan_results, ai_analysis, and scanned_at. The ai_analysis field contains GPT-4o mini's plain-English summary with priority issues and recommendations.
Seeing a 500 error or API key error?
- Check that your .env file is in the project root (the website-audit-scanner folder), not inside static or another subfolder.
- Make sure the line reads OPENAI_API_KEY=sk-... with no spaces around the equals sign and no quotes around the key value.
- Verify your OpenAI account has a payment method and available credits at platform.openai.com.
- If the terminal shows ModuleNotFoundError for openai or dotenv, make sure your virtual environment is activated (you should see (.venv) in your terminal prompt).
Still stuck? Help me debug my OpenAI API integration in main.py. I'm getting an error when I try to scan a website.
Your scanner now fetches a website, analyzes it across five categories, and gets an AI-powered summary with specific fix recommendations. Next up, you will build the dashboard frontend so users can see these results in a clean, visual interface instead of raw JSON.
Build the Dashboard Frontend
Your backend is fully functional. It scans websites across five categories and returns AI-powered recommendations.
Right now the only way to use it is through the Swagger UI at /docs. In this step, you'll build a proper dashboard with HTML, CSS, and JavaScript that lets anyone enter a URL, view color-coded results, and download a report.
In this step, get ready to:
- Create the HTML layout with a URL input, result cards, and download button.
- Style the dashboard with a dark theme and color-coded indicators.
- Add JavaScript logic to connect the frontend to your API and display results.
Create the HTML structure
The dashboard needs a URL input field, a loading indicator, a section for the AI analysis, five result cards (one per scan category), and a download button.
- Create a new file called index.html inside the static folder.
- Copy the following HTML into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Website Audit Scanner</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>Website Audit Scanner</h1>
<p>Enter a URL to analyze SEO, security, accessibility, and performance</p>
</header>
<main>
<!-- URL input form -->
<div class="scan-form">
<input
type="text"
id="url-input"
placeholder="Enter website URL (e.g., example.com)"
/>
<button id="scan-btn" onclick="startScan()">Scan Website</button>
</div>
<!-- Loading indicator shown during scans -->
<div id="loading" class="hidden">
<p>Scanning website... This may take a moment.</p>
</div>
<!-- Scan results container -->
<div id="results" class="hidden">
<div class="results-header">
<h2>Scan Results for <span id="scanned-url"></span></h2>
<button onclick="downloadReport()">Download JSON Report</button>
</div>
<!-- AI analysis display -->
<div class="ai-section">
<h3>AI Analysis</h3>
<pre id="ai-analysis"></pre>
</div>
<!-- Result cards grid - one card per scan category -->
<div class="results-grid">
<div class="result-card">
<h3>SEO</h3>
<div id="seo-results"></div>
</div>
<div class="result-card">
<h3>Security</h3>
<div id="security-results"></div>
</div>
<div class="result-card">
<h3>Accessibility</h3>
<div id="accessibility-results"></div>
</div>
<div class="result-card">
<h3>Performance</h3>
<div id="performance-results"></div>
</div>
<div class="result-card">
<h3>Broken Links</h3>
<div id="links-results"></div>
</div>
</div>
</div>
<!-- Error display -->
<div id="error" class="hidden">
<p id="error-message"></p>
</div>
</main>
<script src="/script.js"></script>
</body>
</html>
What does this HTML do?
- The scan-form div holds the URL text input and a button that calls startScan() when clicked.
- The loading, results, and error divs all start with the hidden class so only one shows at a time.
- The results-grid contains five cards with unique IDs (seo-results, security-results, etc.) that JavaScript will populate with scan data.
- The ai-section uses a <pre> tag to preserve the formatting of the AI's multi-line analysis.
- Save index.html.
- Refresh http://127.0.0.1:8000 in your browser.
You should see unstyled text with a URL input field, a Scan Website button, and headings for each category. It looks plain because there is no CSS yet.
Seeing a "Not Found" page?
- Make sure index.html is inside the static folder, not in the project root.
- Check that your main.py has the app.mount("/", StaticFiles(directory="static", html=True), name="static") line at the bottom.
- Confirm the dev server is still running. If you stopped it, start it again with fastapi dev main.py.
Why is my index.html not loading in the browser?
Style the dashboard
The dashboard uses a dark navy background with card-based layout. Color-coded values help users scan results at a glance: green for good, yellow for warnings, and red for problems.
- Create a new file called style.css inside the static folder.
- Copy the following CSS into style.css:
/* Reset default browser styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Page background and base text color */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
/* Top header bar with blue accent border */
header {
text-align: center;
padding: 2rem 1rem;
background-color: #1e293b;
border-bottom: 2px solid #3b82f6;
}
header h1 {
font-size: 2rem;
color: #3b82f6;
margin-bottom: 0.5rem;
}
header p {
color: #94a3b8;
}
/* Centered content area */
main {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
/* URL input and scan button row */
.scan-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.scan-form input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #334155;
border-radius: 8px;
background-color: #1e293b;
color: #e2e8f0;
font-size: 1rem;
}
.scan-form input:focus {
outline: none;
border-color: #3b82f6;
}
.scan-form button {
padding: 0.75rem 1.5rem;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
white-space: nowrap;
}
.scan-form button:hover {
background-color: #2563eb;
}
.scan-form button:disabled {
background-color: #475569;
cursor: not-allowed;
}
/* Utility class to hide elements */
.hidden {
display: none;
}
/* Loading state message */
#loading {
text-align: center;
padding: 2rem;
color: #94a3b8;
}
/* Results header with title and download button */
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.results-header h2 {
color: #3b82f6;
}
.results-header button {
padding: 0.5rem 1rem;
background-color: #10b981;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.results-header button:hover {
background-color: #059669;
}
/* AI analysis panel with purple accent */
.ai-section {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.ai-section h3 {
color: #a78bfa;
margin-bottom: 1rem;
}
.ai-section pre {
white-space: pre-wrap;
word-wrap: break-word;
color: #cbd5e1;
line-height: 1.6;
font-family: inherit;
font-size: 0.95rem;
}
/* Responsive grid for result cards */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
/* Individual result cards */
.result-card {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 1.5rem;
}
.result-card h3 {
color: #3b82f6;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #334155;
}
/* Key-value rows inside each card */
.result-item {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
border-bottom: 1px solid #0f172a;
}
.result-item .label {
color: #94a3b8;
}
.result-item .value {
font-weight: 600;
}
/* Color-coded value indicators */
.value.good {
color: #10b981;
}
.value.warning {
color: #f59e0b;
}
.value.bad {
color: #ef4444;
}
/* Error message display */
#error {
text-align: center;
padding: 2rem;
color: #ef4444;
background-color: #1e293b;
border: 1px solid #ef4444;
border-radius: 8px;
}
What does this CSS do?
- The background color #0f172a is a deep navy. Cards use #1e293b for a slightly lighter contrast.
- The .results-grid uses CSS Grid with auto-fit so cards wrap responsively on smaller screens.
- Three color classes (.good, .warning, .bad) color values green, yellow, or red. The JavaScript decides which class to apply based on the scan data.
- The .hidden class sets display: none so JavaScript can toggle sections by adding or removing this class.
- Save style.css and refresh your browser.
You should see a dark-themed page with a blue Website Audit Scanner heading, a styled URL input field, and a blue Scan Website button.
Page still looks unstyled?
- Make sure style.css is inside the static folder, right next to index.html.
- Check that the <link> tag in your HTML says href="/style.css" with a leading forward slash.
- Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to clear the browser cache.
Why is my CSS not loading on my FastAPI page?
Add the JavaScript logic
The JavaScript connects your dashboard to the API. It sends the URL to your backend, parses the response, and fills each card with color-coded results.
- Create a new file called script.js inside the static folder.
- Start by adding the startScan function that sends URLs to your API. Copy this code into script.js:
// Store the latest scan data so the download button can access it
let lastScanData = null;
async function startScan() {
// Cache DOM references
const urlInput = document.getElementById("url-input");
const scanBtn = document.getElementById("scan-btn");
const loading = document.getElementById("loading");
const results = document.getElementById("results");
const error = document.getElementById("error");
// Validate the URL input
const url = urlInput.value.trim();
if (!url) {
alert("Please enter a URL to scan.");
return;
}
// Show loading state and disable the button
results.classList.add("hidden");
error.classList.add("hidden");
loading.classList.remove("hidden");
scanBtn.disabled = true;
scanBtn.textContent = "Scanning...";
try {
// Send the URL to the backend API
const response = await fetch("/api/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url }),
});
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.detail || "Scan failed");
}
// Store data and display results
const data = await response.json();
lastScanData = data;
displayResults(data);
} catch (err) {
// Show error message if the scan fails
error.classList.remove("hidden");
document.getElementById("error-message").textContent =
"Error: " + err.message;
} finally {
// Reset the button and hide loading
loading.classList.add("hidden");
scanBtn.disabled = false;
scanBtn.textContent = "Scan Website";
}
}
What does this code do?
- lastScanData stores the most recent scan response so the download function can access it later.
- The function reads the URL from the input, sends a POST request to /api/scan with a JSON body, and waits for the response.
- While waiting, the button text changes to "Scanning..." and the loading indicator appears. This gives the user feedback during the scan.
- The finally block always runs, whether the scan succeeds or fails. It resets the button and hides the loading message.
- Save script.js.
You won't see a change yet. The displayResults function that populates the cards comes next.
- Add the displayResults function below startScan. This function parses the API response and populates each result card with color-coded values:
function displayResults(data) {
const results = document.getElementById("results");
const scan = data.scan_results;
// Show the scanned URL and AI analysis text
document.getElementById("scanned-url").textContent = scan.url;
document.getElementById("ai-analysis").textContent = data.ai_analysis;
// Build SEO results card
const seo = scan.seo;
document.getElementById("seo-results").innerHTML =
'<div class="result-item">' +
'<span class="label">Title</span>' +
'<span class="value ' +
(seo.title ? "good" : "bad") +
'">' +
(seo.title ? "Present" : "Missing") +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Title Length</span>' +
'<span class="value ' +
(seo.title_length >= 30 && seo.title_length <= 60 ? "good" : "warning") +
'">' +
seo.title_length +
" chars</span></div>" +
'<div class="result-item">' +
'<span class="label">Meta Description</span>' +
'<span class="value ' +
(seo.meta_description ? "good" : "bad") +
'">' +
(seo.meta_description ? "Present" : "Missing") +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Description Length</span>' +
'<span class="value ' +
(seo.meta_description_length >= 120 && seo.meta_description_length <= 160
? "good"
: "warning") +
'">' +
seo.meta_description_length +
" chars</span></div>" +
'<div class="result-item">' +
'<span class="label">Open Graph Tags</span>' +
'<span class="value ' +
(Object.keys(seo.og_tags).length > 0 ? "good" : "warning") +
'">' +
Object.keys(seo.og_tags).length +
" found</span></div>" +
'<div class="result-item">' +
'<span class="label">Headings</span>' +
'<span class="value">' +
(Object.entries(seo.headings)
.map(function (entry) {
return entry[0] + ":" + entry[1];
})
.join(", ") || "None found") +
"</span></div>";
// Build security results card
const sec = scan.security;
let securityHtml =
'<div class="result-item">' +
'<span class="label">HTTPS</span>' +
'<span class="value ' +
(sec.is_https ? "good" : "bad") +
'">' +
(sec.is_https ? "Yes" : "No") +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Security Headers</span>' +
'<span class="value ' +
(sec.missing_headers.length === 0
? "good"
: sec.missing_headers.length <= 2
? "warning"
: "bad") +
'">' +
(6 - sec.missing_headers.length) +
"/6 present</span></div>";
// List each missing security header
sec.missing_headers.forEach(function (h) {
securityHtml +=
'<div class="result-item">' +
'<span class="label">' +
h +
"</span>" +
'<span class="value bad">Missing</span></div>';
});
// Flag server version disclosure if found
if (sec.server_disclosure) {
securityHtml +=
'<div class="result-item">' +
'<span class="label">Server Disclosed</span>' +
'<span class="value warning">' +
sec.server_disclosure +
"</span></div>";
}
document.getElementById("security-results").innerHTML = securityHtml;
// Build accessibility results card
const acc = scan.accessibility;
let accHtml =
'<div class="result-item">' +
'<span class="label">Images Missing Alt</span>' +
'<span class="value ' +
(acc.images_without_alt === 0 ? "good" : "bad") +
'">' +
acc.images_without_alt +
" of " +
acc.total_images +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Language Attribute</span>' +
'<span class="value ' +
(acc.has_lang_attribute ? "good" : "bad") +
'">' +
(acc.has_lang_attribute ? acc.lang_value : "Missing") +
"</span></div>";
// Show heading structure status
if (acc.heading_issues.length === 0) {
accHtml +=
'<div class="result-item">' +
'<span class="label">Heading Structure</span>' +
'<span class="value good">No issues</span></div>';
} else {
acc.heading_issues.forEach(function (issue) {
accHtml +=
'<div class="result-item">' +
'<span class="label">Heading Issue</span>' +
'<span class="value warning">' +
issue +
"</span></div>";
});
}
document.getElementById("accessibility-results").innerHTML = accHtml;
// Build performance results card
const perf = scan.performance;
document.getElementById("performance-results").innerHTML =
'<div class="result-item">' +
'<span class="label">Load Time</span>' +
'<span class="value ' +
(perf.load_time_seconds < 2
? "good"
: perf.load_time_seconds < 5
? "warning"
: "bad") +
'">' +
perf.load_time_seconds +
"s</span></div>" +
'<div class="result-item">' +
'<span class="label">Page Size</span>' +
'<span class="value ' +
(perf.page_size_kb < 500
? "good"
: perf.page_size_kb < 1000
? "warning"
: "bad") +
'">' +
perf.page_size_kb +
" KB</span></div>";
// Build broken links results card
const links = scan.broken_links;
let linksHtml =
'<div class="result-item">' +
'<span class="label">Total Links Found</span>' +
'<span class="value">' +
links.total_links +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Links Checked</span>' +
'<span class="value">' +
links.checked +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Broken Links</span>' +
'<span class="value ' +
(links.broken.length === 0 ? "good" : "bad") +
'">' +
links.broken.length +
" found</span></div>";
// List each broken link with its status code
links.broken.forEach(function (b) {
linksHtml +=
'<div class="result-item">' +
'<span class="label" title="' +
b.url +
'">' +
(b.anchor_text || b.url.substring(0, 40)) +
"</span>" +
'<span class="value bad">' +
(b.status_code || "Failed") +
"</span></div>";
});
document.getElementById("links-results").innerHTML = linksHtml;
// Show the results section
results.classList.remove("hidden");
}
What does this code do?
- The function reads data.scan_results and builds HTML strings for each of the five result cards.
- Each value gets a CSS class (good, warning, or bad) based on threshold checks. For example, a title length between 30 and 60 characters gets good.
- Missing security headers are listed individually so the user can see exactly which ones need to be added.
- At the end, it removes the hidden class from the results container to make everything visible.
- Save script.js and refresh your browser.
- Enter example.com in the URL field and click Scan Website.
After a few seconds, you should see scan results populate across the five cards with color-coded values. The AI analysis section at the top shows the plain-English summary from GPT-4o mini.
Seeing an error after clicking Scan?
- Check the browser console (F12, then the Console tab) for error messages. A 500 Internal Server Error usually means the backend hit a problem.
- Check the terminal running your dev server for Python error tracebacks. An OpenAI error often means your API key is missing or invalid.
- If you see displayResults is not defined, make sure you saved the displayResults function in script.js below the startScan function.
Help me debug my scan results not displaying.
- Add the download function and Enter key listener below displayResults. This lets users save the full scan report and start scans with the keyboard:
function downloadReport() {
if (!lastScanData) return;
// Convert scan data to a downloadable JSON file
const blob = new Blob([JSON.stringify(lastScanData, null, 2)], {
type: "application/json",
});
// Create a temporary download link and trigger it
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "website-audit-report.json";
a.click();
URL.revokeObjectURL(url);
}
// Allow pressing Enter to start a scan
document.getElementById("url-input").addEventListener("keypress", function (e) {
if (e.key === "Enter") {
startScan();
}
});
What does this code do?
- downloadReport() converts the stored scan data into a Blob (a file-like object in the browser). It then creates a temporary link, triggers a download, and cleans up.
- JSON.stringify(lastScanData, null, 2) formats the data with 2-space indentation so the downloaded file is human-readable.
- The event listener on the URL input field calls startScan() when the user presses Enter, so they don't have to click the button.
- Save script.js and refresh your browser.
- Run a scan on any URL, then click the green Download JSON Report button.
A file called website-audit-report.json should download to your computer. Open it in VS Code to see the full scan data.
Download button not working?
- Make sure you run a scan first. The download function checks that lastScanData exists before creating the file.
- Check the browser console for errors. If you see downloadReport is not defined, make sure the function is saved in script.js and the file is refreshed.
Why is my JSON download not triggering?
Awesome, I've got everything!
Double check you've saved script.js with all three functions and the event listener.
I'd like to double check the full code
Here is the complete script.js file. Compare it against yours:
let lastScanData = null;
async function startScan() {
const urlInput = document.getElementById("url-input");
const scanBtn = document.getElementById("scan-btn");
const loading = document.getElementById("loading");
const results = document.getElementById("results");
const error = document.getElementById("error");
const url = urlInput.value.trim();
if (!url) {
alert("Please enter a URL to scan.");
return;
}
results.classList.add("hidden");
error.classList.add("hidden");
loading.classList.remove("hidden");
scanBtn.disabled = true;
scanBtn.textContent = "Scanning...";
try {
const response = await fetch("/api/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url }),
});
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.detail || "Scan failed");
}
const data = await response.json();
lastScanData = data;
displayResults(data);
} catch (err) {
error.classList.remove("hidden");
document.getElementById("error-message").textContent =
"Error: " + err.message;
} finally {
loading.classList.add("hidden");
scanBtn.disabled = false;
scanBtn.textContent = "Scan Website";
}
}
function displayResults(data) {
const results = document.getElementById("results");
const scan = data.scan_results;
document.getElementById("scanned-url").textContent = scan.url;
document.getElementById("ai-analysis").textContent = data.ai_analysis;
const seo = scan.seo;
document.getElementById("seo-results").innerHTML =
'<div class="result-item">' +
'<span class="label">Title</span>' +
'<span class="value ' +
(seo.title ? "good" : "bad") +
'">' +
(seo.title ? "Present" : "Missing") +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Title Length</span>' +
'<span class="value ' +
(seo.title_length >= 30 && seo.title_length <= 60 ? "good" : "warning") +
'">' +
seo.title_length +
" chars</span></div>" +
'<div class="result-item">' +
'<span class="label">Meta Description</span>' +
'<span class="value ' +
(seo.meta_description ? "good" : "bad") +
'">' +
(seo.meta_description ? "Present" : "Missing") +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Description Length</span>' +
'<span class="value ' +
(seo.meta_description_length >= 120 && seo.meta_description_length <= 160
? "good"
: "warning") +
'">' +
seo.meta_description_length +
" chars</span></div>" +
'<div class="result-item">' +
'<span class="label">Open Graph Tags</span>' +
'<span class="value ' +
(Object.keys(seo.og_tags).length > 0 ? "good" : "warning") +
'">' +
Object.keys(seo.og_tags).length +
" found</span></div>" +
'<div class="result-item">' +
'<span class="label">Headings</span>' +
'<span class="value">' +
(Object.entries(seo.headings)
.map(function (entry) {
return entry[0] + ":" + entry[1];
})
.join(", ") || "None found") +
"</span></div>";
const sec = scan.security;
let securityHtml =
'<div class="result-item">' +
'<span class="label">HTTPS</span>' +
'<span class="value ' +
(sec.is_https ? "good" : "bad") +
'">' +
(sec.is_https ? "Yes" : "No") +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Security Headers</span>' +
'<span class="value ' +
(sec.missing_headers.length === 0
? "good"
: sec.missing_headers.length <= 2
? "warning"
: "bad") +
'">' +
(6 - sec.missing_headers.length) +
"/6 present</span></div>";
sec.missing_headers.forEach(function (h) {
securityHtml +=
'<div class="result-item">' +
'<span class="label">' +
h +
"</span>" +
'<span class="value bad">Missing</span></div>';
});
if (sec.server_disclosure) {
securityHtml +=
'<div class="result-item">' +
'<span class="label">Server Disclosed</span>' +
'<span class="value warning">' +
sec.server_disclosure +
"</span></div>";
}
document.getElementById("security-results").innerHTML = securityHtml;
const acc = scan.accessibility;
let accHtml =
'<div class="result-item">' +
'<span class="label">Images Missing Alt</span>' +
'<span class="value ' +
(acc.images_without_alt === 0 ? "good" : "bad") +
'">' +
acc.images_without_alt +
" of " +
acc.total_images +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Language Attribute</span>' +
'<span class="value ' +
(acc.has_lang_attribute ? "good" : "bad") +
'">' +
(acc.has_lang_attribute ? acc.lang_value : "Missing") +
"</span></div>";
if (acc.heading_issues.length === 0) {
accHtml +=
'<div class="result-item">' +
'<span class="label">Heading Structure</span>' +
'<span class="value good">No issues</span></div>';
} else {
acc.heading_issues.forEach(function (issue) {
accHtml +=
'<div class="result-item">' +
'<span class="label">Heading Issue</span>' +
'<span class="value warning">' +
issue +
"</span></div>";
});
}
document.getElementById("accessibility-results").innerHTML = accHtml;
const perf = scan.performance;
document.getElementById("performance-results").innerHTML =
'<div class="result-item">' +
'<span class="label">Load Time</span>' +
'<span class="value ' +
(perf.load_time_seconds < 2
? "good"
: perf.load_time_seconds < 5
? "warning"
: "bad") +
'">' +
perf.load_time_seconds +
"s</span></div>" +
'<div class="result-item">' +
'<span class="label">Page Size</span>' +
'<span class="value ' +
(perf.page_size_kb < 500
? "good"
: perf.page_size_kb < 1000
? "warning"
: "bad") +
'">' +
perf.page_size_kb +
" KB</span></div>";
const links = scan.broken_links;
let linksHtml =
'<div class="result-item">' +
'<span class="label">Total Links Found</span>' +
'<span class="value">' +
links.total_links +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Links Checked</span>' +
'<span class="value">' +
links.checked +
"</span></div>" +
'<div class="result-item">' +
'<span class="label">Broken Links</span>' +
'<span class="value ' +
(links.broken.length === 0 ? "good" : "bad") +
'">' +
links.broken.length +
" found</span></div>";
links.broken.forEach(function (b) {
linksHtml +=
'<div class="result-item">' +
'<span class="label" title="' +
b.url +
'">' +
(b.anchor_text || b.url.substring(0, 40)) +
"</span>" +
'<span class="value bad">' +
(b.status_code || "Failed") +
"</span></div>";
});
document.getElementById("links-results").innerHTML = linksHtml;
results.classList.remove("hidden");
}
function downloadReport() {
if (!lastScanData) return;
const blob = new Blob([JSON.stringify(lastScanData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "website-audit-report.json";
a.click();
URL.revokeObjectURL(url);
}
document.getElementById("url-input").addEventListener("keypress", function (e) {
if (e.key === "Enter") {
startScan();
}
});
Secret mission
Add Scoring and Sinister Gate Designs Branding
Your scanner reports raw findings, but clients want a single number that tells them how their site is doing. In this mission, you will add a 0-100 audit score and rebrand the dashboard for Sinister Gate Designs with a custom color scheme.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally on your machine, so there are no ongoing cloud costs. The OpenAI API only charges when you actively run a scan (less than $0.01 per scan).
Resources you used:
- Python virtual environment (.venv folder).
- Project files: scanner.py, main.py, requirements.txt, and the static/ folder.
- .env file containing your OpenAI API key.
- OpenAI API account and API key.
- FastAPI development server (local running process).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.
- Your FastAPI dev server keeps running at http://127.0.0.1:8000 until you close the terminal.
- Your virtual environment stays activated in your current terminal session.
- Your OpenAI API key remains in your .env file, ready for use. No charges are incurred unless you run a scan.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files stay on disk so you can pick up where you left off.
- Stop the FastAPI dev server by pressing Ctrl+C in the terminal where it is running.
- Deactivate the virtual environment by typing deactivate in your terminal.
- When you come back, reactivate the virtual environment with .venv\Scripts\Activate.ps1 and start the server with fastapi dev main.py.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Stop the FastAPI dev server by pressing Ctrl+C in the terminal.
- Deactivate the virtual environment by typing deactivate.
- Delete the entire website-audit-scanner project folder. This removes all code, the virtual environment, and your .env file.
- To revoke your OpenAI API key, go to platform.openai.com, navigate to API Keys, and delete the key you created for this project.
Mission accomplished
That's a Wrap!
Nice work! You just built a full-stack AI-powered website audit scanner from scratch using Python, FastAPI, and OpenAI.
Here's what you learned:
- Built a FastAPI REST API with a scan endpoint, and created a Python scanner module that analyzes websites for SEO, security headers, accessibility, performance, and broken links using BeautifulSoup and requests.
- Integrated OpenAI GPT-4o mini to explain scan findings and recommend fixes in plain English.
- Built a dark-themed dashboard UI with vanilla HTML, CSS, and JavaScript that displays color-coded results and exports a downloadable JSON report.
- Added a 0-100 scoring system and Sinister Gate Designs branding to the dashboard.
Ready to quiz yourself?