Build a Link Guardian Chrome Extension
Build a Chrome extension that scans URLs for malware with 70+ antivirus engines.
Introduction
30 Second Summary
You click dozens of links every day. Some come from emails, some from search results, some from strangers on the internet. But how do you know which ones are safe before you click?
In this project, you will build a Chrome extension that instantly scans any URL for malware with 70+ antivirus engines and tells you whether the site is safe, suspicious, or dangerous.
What You'll Build
Picture this: you land on a sketchy-looking website, click your extension icon, and within seconds you get a blunt verdict like "DANGER! This is NOT your friend. Get out of here!"
By the end of this project, you'll have:
- A working Chrome extension that scans any URL for malware with VirusTotal's 70+ antivirus engines and displays a clear risk verdict with a visual risk bar.
- A personality-driven warning system that translates raw security data into blunt, human feedback messages. Think brutally honest friend, not boring security tool.
- A thumbs up/down feedback system that lets you rate each warning and stores your feedback history persistently in the browser.
- Secret Mission: Add a content script that automatically scans every link on the page and injects color-coded safety indicators next to them.
Are there any prerequisites?
You should have basic HTML, CSS, and JavaScript knowledge. No prior experience with browser extensions or APIs is needed.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters to you.
Set Up the Extension Skeleton
Chrome extensions need a specific file structure to work. A file called manifest.json tells the browser what your extension does, what permissions it needs, and where its files live.
Without this foundation, Chrome has no way to load your code as an extension. In this step, you will create the required file structure, configure the manifest, build a basic popup interface, and load it all into Chrome.
In this step, get ready to:
- Create the extension's folder structure and manifest configuration.
- Build a basic popup interface with HTML, CSS, and JavaScript.
- Load and test the extension in Chrome's developer mode.
Create the extension folder and manifest
Every Chrome extension lives in its own folder with a specific set of files. You will create the project folder on your Desktop so it is easy to find later.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir link-guardian
cd link-guardian
- Create the five files your extension needs by running:
touch manifest.json popup.html popup.css popup.js background.js
- Confirm the files exist by running:
ls
You should see manifest.json, popup.html, popup.css, popup.js, and background.js listed in the output.
Not seeing all five files?
Make sure you ran the touch command from inside the link-guardian folder. If you are on Windows and touch is not recognized, use Git Bash or create the files manually in VS Code.
help me create the extension files on my system
- Open the link-guardian folder in VS Code by running:
code .
The manifest.json file is the brain of your extension. It tells Chrome the extension's name, version, permissions, and which files to use.
- In VS Code, open manifest.json from the Explorer sidebar and paste this configuration:
{
"manifest_version": 3,
"name": "Link Guardian",
"version": "1.0",
"description": "Your brutally honest security buddy. Scans links for malware and phishing.",
"action": {
"default_popup": "popup.html"
},
"permissions": ["activeTab", "storage"],
"host_permissions": ["https://www.virustotal.com/*"],
"background": {
"service_worker": "background.js"
}
}
What does this manifest do?
- manifest_version: 3 tells Chrome to use the latest Manifest V3 architecture.
- action.default_popup makes popup.html appear when the user clicks the extension icon.
- activeTab grants temporary access to the current tab's URL when the user clicks the icon.
- storage lets the extension save data locally (for API keys and feedback history later).
- host_permissions allows the extension to make requests to the VirusTotal API.
- background.service_worker registers background.js as the service worker that handles API calls.
- Save manifest.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Double check you have saved manifest.json and move on to the next chunk.
ⓧ I'd like to double check the full code
{
"manifest_version": 3,
"name": "Link Guardian",
"version": "1.0",
"description": "Your brutally honest security buddy. Scans links for malware and phishing.",
"action": {
"default_popup": "popup.html"
},
"permissions": ["activeTab", "storage"],
"host_permissions": ["https://www.virustotal.com/*"],
"background": {
"service_worker": "background.js"
}
}
Build the popup interface
The popup is the small panel that appears when a user clicks your extension icon. You will build it with HTML for structure, CSS for styling, and JavaScript for behavior.
- Open popup.html in VS Code and paste this markup:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="popup.css">
<title>Link Guardian</title>
</head>
<body>
<div class="container">
<header>
<h1>Link Guardian</h1>
</header>
<div id="current-url" class="url-display">Loading...</div>
<button id="scan-btn" class="scan-button">Scan This Page</button>
<div id="results" class="results hidden"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
What does this HTML do?
- The current-url div will display the URL of whatever page the user is on.
- The scan-btn button triggers the security scan.
- The results div is hidden for now. It will display scan results in a later step.
- The popup.css and popup.js files are linked but not written yet.
- Save popup.html.
- Open popup.html directly in Chrome by dragging it from VS Code's Explorer sidebar into a Chrome tab to see the raw structure.
You should see the heading "Link Guardian", the text "Loading...", and a red "Scan This Page" button (unstyled for now, since the CSS file is empty).
✔️ Awesome, I've got everything!
Great. Double check you have saved popup.html and continue to the CSS.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="popup.css">
<title>Link Guardian</title>
</head>
<body>
<div class="container">
<header>
<h1>Link Guardian</h1>
</header>
<div id="current-url" class="url-display">Loading...</div>
<button id="scan-btn" class="scan-button">Scan This Page</button>
<div id="results" class="results hidden"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
Now give the popup its dark theme. The CSS is split into two blocks: layout first, then component styles.
- Open popup.css and paste the base layout styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 360px;
min-height: 200px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
}
.container {
padding: 16px;
}
h1 {
font-size: 18px;
color: #ffffff;
margin-bottom: 12px;
}
What do these styles do?
- The universal reset removes default browser margins and padding.
- width: 360px on the body sets the popup's fixed width. Chrome extension popups do not resize like regular web pages.
- The dark background (#1a1a2e) with light text (#e0e0e0) creates the dark theme.
- Below the h1 styles you just pasted, add the component styles:
.url-display {
font-size: 12px;
color: #888;
padding: 8px;
background: #16213e;
border-radius: 4px;
margin-bottom: 12px;
word-break: break-all;
}
.scan-button {
width: 100%;
padding: 10px;
background: #e94560;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.hidden {
display: none;
}
What do these component styles do?
- .url-display is the dark box that shows the current page URL.
- .scan-button is the bold red action button. The #e94560 colour makes it stand out against the dark background.
- .hidden is a utility class to hide the results area until a scan completes.
- Save popup.css.
- Switch to the Chrome tab where you opened popup.html and refresh it.
You should now see a dark panel with a white "Link Guardian" heading, a dark blue URL area showing "Loading...", and a red "Scan This Page" button.
Still seeing unstyled text?
Make sure popup.css is saved in the same folder as popup.html. Check that the <link> tag in your HTML references popup.css exactly (lowercase, no typos).
help me figure out why my popup CSS is not loading
✔️ Awesome, I've got everything!
Great. Double check you have saved popup.css and continue to the JavaScript.
ⓧ I'd like to double check the full code
Scroll up and confirm you have both CSS blocks in your file: the base layout styles (*, body, .container, h1) followed by the component styles (.url-display, .scan-button, .hidden). Make sure they appear in that order with no extra content between them.
The popup looks right, but the "Loading..." text is static. You need JavaScript to read the actual URL from the current tab.
- Open popup.js and paste this code:
document.addEventListener("DOMContentLoaded", () => {
const currentUrlEl = document.getElementById("current-url");
const scanBtn = document.getElementById("scan-btn");
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0] && tabs[0].url) {
currentUrlEl.textContent = tabs[0].url;
} else {
currentUrlEl.textContent = "Cannot read this page's URL.";
}
});
});
What does this JavaScript do?
- chrome.tabs.query asks Chrome for the currently active tab.
- If the tab has a URL, it displays it in the current-url div.
- This API only works inside a loaded extension, not when you open the file directly in a browser tab.
- Save popup.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved popup.js and continue to the background script.
ⓧ I'd like to double check the full code
document.addEventListener("DOMContentLoaded", () => {
const currentUrlEl = document.getElementById("current-url");
const scanBtn = document.getElementById("scan-btn");
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0] && tabs[0].url) {
currentUrlEl.textContent = tabs[0].url;
} else {
currentUrlEl.textContent = "Cannot read this page's URL.";
}
});
});
- Open background.js and paste this placeholder:
// Service worker placeholder
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
// Message handling will be added in the next step
});
Why add a placeholder service worker?
The manifest declares background.js as the service worker. Chrome expects this file to exist and registers the message listener immediately. You will fill in the actual scan logic in the next step.
- Save background.js.
✔️ Awesome, I've got everything!
Great. All five files are written. Time to load the extension in Chrome.
ⓧ I'd like to double check the full code
// Service worker placeholder
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
// Message handling will be added in the next step
});
Load the extension in Chrome
Chrome can load your local folder as a working extension through Developer mode. This is how you test extensions during development without publishing them.
- In Chrome, type chrome://extensions in the address bar and press Enter.
- Toggle the Developer mode switch in the top-right corner to ON.
- Click the Load unpacked button that appears in the top-left area.
- Select the link-guardian folder on your Desktop and click Select Folder (Windows) or Open (macOS).
You should see "Link Guardian" appear as a card on the extensions page with its description.
Seeing an error on the extensions page?
If you see a red error banner, click Errors on the extension card to read the message. The most common cause is a typo in manifest.json (missing comma, wrong key name). Fix the JSON, save, then click the refresh icon on the extension card.
help me fix the error when loading my Chrome extension
- Click the puzzle piece icon (Extensions menu) in Chrome's toolbar.
- Find Link Guardian in the list and click the pin icon next to it to pin it to your toolbar.
Before you click the extension icon, what do you think the popup will show?
- Navigate to any website (like https://www.google.com) and click the Link Guardian icon in your toolbar.
You should see your dark popup appear with the heading "Link Guardian", the full URL of the page you are on displayed in the blue box, and the red "Scan This Page" button below it.
Popup shows "Cannot read this page's URL"?
This happens on special Chrome pages like chrome://extensions or chrome://newtab because extensions cannot read those URLs. Navigate to a normal website (like https://www.google.com) and click the icon again.
help me debug why my extension cannot read the page URL
Your extension skeleton is alive. Chrome loaded it, the popup renders, and it reads the current URL. Next up, you will connect that "Scan This Page" button to the VirusTotal API and get real security data back.
Scan the Current Page with VirusTotal
In Step 1, you built the extension shell and saw the popup appear. Right now, though, clicking that Scan button does nothing.
Your extension needs to grab the real URL from the active tab, send it to a security scanning service, and display the results. In this step, you will connect your popup to the VirusTotal API, which scans URLs against 70+ antivirus engines and returns detection counts.
In this step, get ready to:
- Add an API key input so users can authenticate with VirusTotal.
- Wire up the Scan button to send the current URL to a background service worker.
- Build the service worker that calls the VirusTotal API and returns scan results.
Update your popup with an API key input
Before your extension can call VirusTotal, users need a way to provide their API key. You will also add a loading indicator and an error display area for scan feedback.
- Switch back to VS Code with the link-guardian folder open.
- Replace the entire contents of popup.html with this code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="popup.css">
<title>Link Guardian</title>
</head>
<body>
<div class="container">
<header>
<h1>Link Guardian</h1>
</header>
<div id="api-key-section">
<label for="api-key-input">VirusTotal API Key:</label>
<div class="key-row">
<input type="password" id="api-key-input" placeholder="Paste your API key here">
<button id="save-key-btn">Save</button>
</div>
</div>
<div id="current-url" class="url-display">Loading...</div>
<button id="scan-btn" class="scan-button">Scan This Page</button>
<div id="results" class="results hidden"></div>
<div id="loading" class="loading hidden"><p>Scanning...</p></div>
<div id="error" class="error hidden"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
What did this change?
You added three new sections to the popup. The api-key-section gives users a password-masked input to paste their VirusTotal key. The loading div shows feedback while a scan is in progress. The error div displays any issues that come back from the API.
- Open popup.css and add these styles at the bottom of the file, below the existing .hidden rule:
#api-key-section {
margin-bottom: 12px;
}
#api-key-section label {
font-size: 11px;
color: #888;
display: block;
margin-bottom: 4px;
}
.key-row {
display: flex;
gap: 8px;
}
#api-key-input {
flex: 1;
padding: 6px 10px;
border: 1px solid #333;
border-radius: 4px;
background: #16213e;
color: #e0e0e0;
font-size: 12px;
}
#save-key-btn {
padding: 6px 12px;
background: #0f3460;
color: #e0e0e0;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
- Save popup.html and popup.css.
- Return to Chrome at chrome://extensions and click the refresh icon on your Link Guardian card.
- Click the Link Guardian icon in your toolbar.
You should see the new "VirusTotal API Key" input field with a Save button at the top of the popup, above the URL display.
Don't see the API key input?
- Make sure you saved both popup.html and popup.css before reloading the extension.
- Confirm you clicked the circular refresh arrow on the extension card, not just the browser refresh button.
- Check that the new HTML includes id="api-key-section" matching the CSS selector.
Why is my API key input not showing up?
- Add these additional styles at the bottom of popup.css, below the rules you just added:
.scan-button:disabled {
background: #555;
cursor: not-allowed;
}
.results {
margin-top: 16px;
}
.loading {
text-align: center;
margin-top: 16px;
color: #888;
}
.error {
margin-top: 12px;
padding: 10px;
background: #3d0011;
border: 1px solid #dc3545;
border-radius: 4px;
font-size: 12px;
color: #ff6b6b;
}
What do these styles handle?
The .scan-button:disabled style greys out the Scan button while a request is in progress so users cannot spam the API. The .loading and .error classes provide visual feedback once the scan logic is wired up.
- Save popup.css.
Wire up the popup scan logic
The popup needs to save the API key, read the active tab URL, send a scan request to the service worker, and display whatever comes back. You will replace the existing popup.js with code that handles all of this.
- Replace the entire contents of popup.js with the following code (this is the first section of the file):
document.addEventListener("DOMContentLoaded", () => {
const scanBtn = document.getElementById("scan-btn");
const saveKeyBtn = document.getElementById("save-key-btn");
const apiKeyInput = document.getElementById("api-key-input");
const currentUrlEl = document.getElementById("current-url");
const resultsEl = document.getElementById("results");
const loadingEl = document.getElementById("loading");
const errorEl = document.getElementById("error");
let currentUrl = "";
// Initialize popup state
loadApiKey();
loadCurrentTab();
// Save API key to chrome.storage.local when user clicks Save
saveKeyBtn.addEventListener("click", () => {
const key = apiKeyInput.value.trim();
if (key) {
chrome.storage.local.set({ vtApiKey: key });
apiKeyInput.value = "";
apiKeyInput.placeholder = "Key saved!";
setTimeout(() => {
apiKeyInput.placeholder = "Paste your API key here";
}, 2000);
}
});
What does this code do?
- It grabs references to every interactive element in the popup HTML so you can update them from JavaScript.
- The saveKeyBtn listener takes whatever the user pasted, stores it with chrome.storage.local, then clears the input and briefly confirms the save.
- The loadApiKey() and loadCurrentTab() calls (defined below) run as soon as the popup opens.
- Add this code directly below the previous block in popup.js:
// Check for stored API key before scanning
scanBtn.addEventListener("click", () => {
chrome.storage.local.get("vtApiKey", (data) => {
if (!data.vtApiKey) {
showError("Please save your VirusTotal API key first.");
return;
}
startScan(currentUrl, data.vtApiKey);
});
});
function loadApiKey() {
chrome.storage.local.get("vtApiKey", (data) => {
if (data.vtApiKey) {
apiKeyInput.placeholder = "Key saved (paste new to replace)";
}
});
}
function loadCurrentTab() {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0] && tabs[0].url) {
currentUrl = tabs[0].url;
currentUrlEl.textContent = currentUrl;
} else {
currentUrlEl.textContent = "Cannot read this page's URL.";
scanBtn.disabled = true;
}
});
}
function showError(message) {
errorEl.textContent = message;
errorEl.classList.remove("hidden");
}
What does this code do?
- The scanBtn listener retrieves the stored API key first. If no key exists, it shows an error instead of calling the API.
- The loadCurrentTab() function uses chrome.tabs.query to read the active tab's URL and display it in the popup.
- The showError() helper reveals the red error box with a given message.
- Add this final section directly below the previous block to complete popup.js:
function startScan(url, apiKey) {
// Reset UI to loading state
resultsEl.classList.add("hidden");
errorEl.classList.add("hidden");
loadingEl.classList.remove("hidden");
scanBtn.disabled = true;
// Send scan request to the background service worker
chrome.runtime.sendMessage(
{ action: "scanUrl", url: url, apiKey: apiKey },
(response) => {
loadingEl.classList.add("hidden");
scanBtn.disabled = false;
if (response && response.error) {
showError(response.error);
return;
}
if (response && response.stats) {
const stats = response.stats;
resultsEl.innerHTML =
"<p>Malicious: " + stats.malicious + "</p>" +
"<p>Suspicious: " + stats.suspicious + "</p>" +
"<p>Harmless: " + stats.harmless + "</p>" +
"<p>Undetected: " + stats.undetected + "</p>";
resultsEl.classList.remove("hidden");
} else {
showError("No scan results received.");
}
}
);
}
});
What does this code do?
The startScan() function is the bridge between your popup and the background service worker. It sends a message with chrome.runtime.sendMessage containing the URL and API key. When the service worker responds, it either displays the raw detection counts or shows an error.
- Save popup.js and reload the extension in chrome://extensions.
- Click the Link Guardian icon, type any text into the API key field, and click Save.
You should see the input placeholder briefly change to "Key saved!" and then reset. This confirms your popup logic is working.
Popup not loading at all?
- Open chrome://extensions and look for red error text on the Link Guardian card. Click it to see the exact error.
- A common cause is a missing closing bracket or parenthesis in popup.js. Verify your file ends with }); on its own line.
Help me debug why my extension popup is blank after updating popup.js.
Build the VirusTotal scan in your service worker
The popup sends a message but nobody is listening yet. You need to build the background.js service worker that receives the scan request, calls the VirusTotal API, and returns the results.
First, get your free VirusTotal API key so you can test the scan end-to-end.
- Open https://www.virustotal.com in a new tab.
- Click Sign Up in the top right corner and create a free Community account (you can use Google, GitHub, or Microsoft to sign up quickly).
- Once signed in, navigate to https://www.virustotal.com/gui/my-apikey to view your personal API key.
- Copy the API key string and keep it handy. You will paste it into the extension shortly.
What is this API key?
Your VirusTotal API key authenticates your extension so it can query their database. The free tier allows 4 requests per minute and 500 requests per day, which is plenty for personal use.
Keep this key private. Anyone with your key can consume your rate limit quota.
- Return to VS Code and replace the entire contents of background.js with this first section:
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "scanUrl") {
scanUrl(request.url, request.apiKey)
.then((result) => sendResponse(result))
.catch((err) => sendResponse({ error: err.message }));
// Return true to keep the message channel open for async response
return true;
}
});
async function scanUrl(url, apiKey) {
// Encode URL as base64url identifier (no padding)
const urlId = btoa(url)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
// Check if VirusTotal already has a report for this URL
const response = await fetch(
"https://www.virustotal.com/api/v3/urls/" + urlId,
{
method: "GET",
headers: {
"x-apikey": apiKey
}
}
);
What does this code do?
- The onMessage listener receives the scan request from the popup. Returning true keeps the message channel open so the async API call can respond later.
- The btoa() call converts the URL to a base64url-encoded identifier that VirusTotal uses to look up existing reports. The replacements make it URL-safe per RFC 4648.
- The first fetch attempts a GET request to see if VirusTotal already has scan results cached for this URL.
- Add this code directly below the previous block in background.js. This handles the case where VirusTotal has never seen the URL before:
if (response.status === 404) {
// URL not in database yet, submit it for scanning
const submitResponse = await fetch(
"https://www.virustotal.com/api/v3/urls",
{
method: "POST",
headers: {
"x-apikey": apiKey,
"Content-Type": "application/x-www-form-urlencoded"
},
body: "url=" + encodeURIComponent(url)
}
);
if (!submitResponse.ok) {
return { error: "Failed to submit URL for scanning. Status: " + submitResponse.status };
}
const submitData = await submitResponse.json();
const analysisId = submitData.data.id;
// Wait for the analysis to complete
await new Promise((resolve) => setTimeout(resolve, 3000));
const analysisResponse = await fetch(
"https://www.virustotal.com/api/v3/analyses/" + analysisId,
{
method: "GET",
headers: {
"x-apikey": apiKey
}
}
);
Why submit and then wait?
A 404 means VirusTotal has never analyzed this URL. The code submits it via POST, waits 3 seconds for the scan to run, then retrieves the results. Most popular URLs are already cached, so this fallback rarely triggers.
- Add this final section directly below to complete background.js:
if (!analysisResponse.ok) {
return { error: "Failed to retrieve analysis. Status: " + analysisResponse.status };
}
const analysisData = await analysisResponse.json();
return { stats: analysisData.data.attributes.stats };
}
if (!response.ok) {
return { error: "API request failed. Status: " + response.status };
}
const data = await response.json();
return { stats: data.data.attributes.last_analysis_stats };
}
What does this code do?
- The first return inside the 404 block returns the stats from a freshly submitted analysis.
- The final lines handle the happy path: when the initial GET succeeded, extract last_analysis_stats from the cached report and return it to the popup.
- Both paths return an object with a stats property containing malicious, suspicious, harmless, and undetected counts.
- Save background.js.
- Return to chrome://extensions and click the refresh icon on the Link Guardian card to reload the extension.
Before you test, think about this: if you scan a popular site like google.com, do you expect to see any malicious detections?
- Navigate to any website (like https://www.google.com).
- Click the Link Guardian icon in your toolbar.
- Paste your VirusTotal API key into the input field and click Save.
- Click Scan This Page.
After a moment, you should see raw scan results appear showing detection counts. For google.com, you will likely see something like: Malicious: 0, Suspicious: 0, Harmless: 67, Undetected: 6.
What if my numbers are slightly different?
The exact counts vary because VirusTotal constantly adds and updates its engine list. As long as you see numerical counts for malicious, suspicious, harmless, and undetected, your extension is working correctly.
🙋♀️ Seeing an error instead of results?
- "Please save your VirusTotal API key first" means you need to paste and save your key before scanning.
- "API request failed. Status: 401" means your API key is incorrect. Go back to https://www.virustotal.com/gui/my-apikey and copy the full key string again.
- "API request failed. Status: 429" means you hit the rate limit (4 requests per minute). Wait 60 seconds and try again.
- If the popup shows "Scanning..." forever, open chrome://extensions, click service worker under Link Guardian to open DevTools for the background script, and check the Console tab for errors.
Help me debug why my VirusTotal scan is failing.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all four files: popup.html, popup.css, popup.js, and background.js.
ⓧ I'd like to double check the full code
Your popup.html should contain the complete HTML with the api-key-section, current-url, scan-btn, results, loading, and error elements.
For the longer files, scroll up and confirm each code block is present in order:
- popup.css: your original Step 1 styles, then the #api-key-section group (7 rules), then .scan-button:disabled, .results, .loading, and .error.
- popup.js: the DOMContentLoaded wrapper with DOM refs and saveKeyBtn listener, then scanBtn listener with loadApiKey(), loadCurrentTab(), and showError(), then startScan() sending chrome.runtime.sendMessage, then the closing });.
- background.js: the onMessage listener, then scanUrl() with base64url encoding and GET fetch, then the 404 fallback (POST, wait 3s, poll analyses), then the success return with last_analysis_stats.
Your extension can now scan any URL and show real security data. Next up, you will transform those raw numbers into personality-driven warnings that actually make sense to humans.
Add Personality-Driven Warnings
Your extension scans URLs and returns raw detection numbers from VirusTotal. That's functional, but "Malicious: 3, Harmless: 64" means nothing to someone who just wants to know if a site is safe.
This step transforms those cold numbers into personality-driven verdicts. You'll build a classification system that sorts URLs into safety tiers, then delivers blunt, randomized messages that communicate urgency at a glance.
In this step, get ready to:
- Create a verdict function that classifies URLs into safety tiers based on engine detections.
- Build a styled verdict card with a visual risk bar and color-coded tiers.
- Add randomized personality messages so the extension feels alive.
Update the popup HTML with a verdict card
The current results area just dumps raw text into an empty div. You need structured HTML elements to display a verdict message, a risk bar, and an engine breakdown.
- In VS Code, open popup.html.
- Find this line:
<div id="results" class="results hidden"></div>
- Replace it with this structured verdict card:
<div id="results" class="results hidden">
<div id="verdict-card" class="verdict-card">
<p id="verdict-message" class="verdict-message"></p>
<div class="risk-bar-container">
<div id="risk-bar" class="risk-bar"></div>
</div>
<p id="risk-percentage" class="risk-percentage"></p>
<p id="engine-breakdown" class="engine-breakdown"></p>
</div>
</div>
What does this structure do?
The verdict-card div wraps the entire verdict display. Inside it, each element has a specific role: verdict-message shows the personality text, risk-bar fills with color to visualize the threat percentage, and engine-breakdown shows the raw numbers as secondary detail.
- Save popup.html.
- Return to Chrome, go to chrome://extensions, and click the reload icon on your Link Guardian card.
- Click the extension icon in your toolbar. The popup should still render with the scan button visible.
Popup not showing up?
Check popup.html for any unclosed tags, especially the </div> that closes #results. A missing closing tag breaks the entire popup.
Help me debug my popup HTML
Style the verdict card tiers
Each verdict tier needs a distinct visual identity so the user's eye immediately registers safe (green), caution (yellow), warning (orange), or danger (red).
- Open popup.css. Find the existing #save-key-btn and .url-display rules. Replace them with these updated versions that add a hover effect and constrain the URL height:
#save-key-btn {
padding: 6px 12px;
background: #0f3460;
color: #e0e0e0;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
#save-key-btn:hover {
background: #1a4f8a;
}
.url-display {
font-size: 12px;
color: #888;
padding: 8px;
background: #16213e;
border-radius: 4px;
margin-bottom: 12px;
word-break: break-all;
max-height: 40px;
overflow: hidden;
}
- Next, find the existing .scan-button rule and replace it with this version that adds a smooth hover transition:
.scan-button {
width: 100%;
padding: 10px;
background: #e94560;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.scan-button:hover {
background: #ff6b6b;
}
What changed here?
You added hover effects to the save button and scan button so they respond visually to mouse interaction. The .url-display now has a max height and overflow hidden so long URLs do not stretch the popup.
- At the bottom of popup.css (after the .error rule), add the verdict card base styles and tier-specific color variants:
.verdict-card {
padding: 16px;
border-radius: 8px;
text-align: center;
}
.verdict-card.safe {
background: #0d3320;
border: 1px solid #28a745;
}
.verdict-card.caution {
background: #3d3200;
border: 1px solid #ffc107;
}
.verdict-card.warning {
background: #3d1f00;
border: 1px solid #fd7e14;
}
.verdict-card.danger {
background: #3d0011;
border: 1px solid #dc3545;
}
What does this code do?
Each tier class (.safe, .caution, .warning, .danger) sets a dark tinted background with a matching colored border. When JavaScript assigns the tier class to the card, the correct color scheme activates automatically.
- Directly below the verdict card tier rules, add styles for the inner verdict elements:
.verdict-message {
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
line-height: 1.3;
}
.risk-bar-container {
width: 100%;
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.risk-bar {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease;
}
.risk-percentage {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.engine-breakdown {
font-size: 11px;
color: #888;
}
What does this code do?
The risk-bar-container creates a dark track that the risk-bar fills with color proportional to the threat percentage. The transition property animates the bar width so it slides smoothly into place when results arrive.
- Save popup.css.
- Return to chrome://extensions and click the reload icon on your Link Guardian card.
- Click the extension icon in your toolbar. The popup should still render correctly with the scan button visible.
Popup looks broken after CSS changes?
Make sure every CSS rule has matching opening and closing braces. A missing } anywhere in the file will break all rules that follow it.
Help me fix my popup CSS
Build the verdict classification and display logic
Now you need JavaScript that classifies the raw stats into tiers, picks a personality message, and renders the styled card. This is where the extension gets its voice.
- Open popup.js. Find the existing variable declarations at the top of the DOMContentLoaded callback. Below the line that says const errorEl = document.getElementById("error");, add these new DOM references:
const verdictCard = document.getElementById("verdict-card");
const verdictMessage = document.getElementById("verdict-message");
const riskBar = document.getElementById("risk-bar");
const riskPercentage = document.getElementById("risk-percentage");
const engineBreakdown = document.getElementById("engine-breakdown");
- Find the line let currentUrl = ""; and add a new variable directly below it:
let currentVerdict = "";
- Now scroll down to the startScan function. Find the block inside the callback where it currently displays raw stats with innerHTML. Find these lines:
if (response && response.stats) {
const stats = response.stats;
resultsEl.innerHTML =
"<p>Malicious: " + stats.malicious + "</p>" +
"<p>Suspicious: " + stats.suspicious + "</p>" +
"<p>Harmless: " + stats.harmless + "</p>" +
"<p>Undetected: " + stats.undetected + "</p>";
resultsEl.classList.remove("hidden");
} else {
showError("No scan results received.");
}
- Replace that entire block with this cleaner version that calls your new displayVerdict function:
if (response && response.stats) {
displayVerdict(response.stats);
} else {
showError("No scan results received. The URL may not have been scanned before.");
}
Why replace the raw display?
Instead of building HTML strings with raw numbers, you now delegate all rendering to displayVerdict. This function handles classification, styling, and message selection in one clean call.
- Below the startScan function, add the displayVerdict function that renders the verdict card:
function displayVerdict(stats) {
// Get classification and message from the verdict engine
const verdict = getVerdict(stats);
currentVerdict = verdict.tier;
// Apply tier-specific styling to the card
verdictCard.className = "verdict-card " + verdict.tier;
verdictMessage.textContent = verdict.message;
riskBar.style.width = verdict.riskPercent + "%";
riskBar.style.background = verdict.color;
riskPercentage.textContent = verdict.riskPercent + "% risk";
riskPercentage.style.color = verdict.color;
engineBreakdown.textContent =
stats.malicious + " malicious, " +
stats.suspicious + " suspicious, " +
stats.harmless + " harmless, " +
stats.undetected + " undetected";
resultsEl.classList.remove("hidden");
}
What does this function do?
It calls getVerdict(stats) to get the tier, message, color, and risk percentage. Then it applies the tier as a CSS class on the card (which activates the correct background color) and sets each element's content. The raw numbers still appear in engine-breakdown as secondary context.
- Directly below displayVerdict, add the first part of the getVerdict function. This defines the risk calculation and personality messages for each tier:
function getVerdict(stats) {
// Calculate risk as a percentage of total engines
const total = stats.malicious + stats.harmless + stats.suspicious + stats.undetected;
const riskPercent = total > 0 ? Math.round((stats.malicious / total) * 100) : 0;
// Personality messages - multiple per tier for variety
const messages = {
safe: [
"All clear! This site checks out. Browse away.",
"Looks clean! No threats detected here.",
"You're good. This one passed the vibe check."
],
caution: [
"Hmm, something's a little off here. Stay sharp.",
"A few engines raised an eyebrow. Proceed with caution.",
"Not fully trusted. Keep your guard up."
],
warning: [
"This is sketchy. Multiple engines say this is bad news.",
"Warning! Several security tools flagged this site.",
"Yeah... this doesn't look right. Think twice before continuing."
],
danger: [
"DANGER! This is NOT your friend. Get out of here!",
"Scammer alert! Do NOT trust this site.",
"Red flag city. Close this tab NOW."
]
};
What does this code do?
The riskPercent divides malicious detections by total engines to produce a 0-100 percentage. The messages object stores 3 variations per tier so the extension does not always say the same thing. A random message gets selected each scan.
- Still inside the getVerdict function (directly below the messages object), add the tier classification logic and return statement:
// Classify by how many engines flagged it as malicious
let tier;
let color;
if (stats.malicious === 0) {
tier = "safe";
color = "#28a745";
} else if (stats.malicious <= 3) {
tier = "caution";
color = "#ffc107";
} else if (stats.malicious <= 9) {
tier = "warning";
color = "#fd7e14";
} else {
tier = "danger";
color = "#dc3545";
}
// Pick a random message from the matching tier
const tierMessages = messages[tier];
const message = tierMessages[Math.floor(Math.random() * tierMessages.length)];
return { tier, message, color, riskPercent };
}
How does the tier logic work?
Zero malicious engines means safe. One to three means caution. Four to nine means warning. Ten or more triggers danger.
Each tier maps to a specific hex color that matches the CSS you already wrote. The random message selection uses Math.floor(Math.random() * array.length) to pick a different personality line each time.
- Save popup.js.
- Return to chrome://extensions and click the reload icon on your Link Guardian card.
Before you test, think about this: when you scan google.com, which tier do you expect to see? How many of the 70+ engines do you think will flag it as malicious?
- Navigate to https://www.google.com in a browser tab.
- Click the Link Guardian extension icon.
- Click Scan This Page.
You should see a green verdict card with a message like "All clear! This site checks out. Browse away." The risk bar shows 0% and the engine breakdown confirms 0 malicious detections.
Seeing an error instead of a verdict?
If you see "No scan results received", the URL may not have a cached report yet. Try scanning https://www.google.com which almost always has an existing report.
If you see a JavaScript error, right-click the popup and select Inspect to open DevTools. Check the Console tab for details.
Make sure the getVerdict function closing brace } appears before showError. A missing brace will break the entire script.
Help me debug my verdict card
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: popup.html, popup.css, and popup.js.
ⓧ I'd like to double check the full code
Scroll up through this step and confirm each code block is present in your files, in order:
- popup.html: The #results div contains a #verdict-card with #verdict-message, .risk-bar-container (with #risk-bar inside), #risk-percentage, and #engine-breakdown.
- popup.css: Updated #save-key-btn with hover state, updated .url-display with max-height, updated .scan-button with transition and hover, then all verdict styles (.verdict-card base, 4 tier variants, .verdict-message, .risk-bar-container, .risk-bar, .risk-percentage, .engine-breakdown).
- popup.js: Five new DOM references (verdictCard, verdictMessage, riskBar, riskPercentage, engineBreakdown), currentVerdict variable, updated startScan callback calling displayVerdict, then the displayVerdict and getVerdict functions.
Your extension now speaks to users like a blunt friend instead of a boring data dump. Next up, you'll add thumbs up and thumbs down feedback buttons so users can rate whether each warning resonated.
Add Thumbs Up/Down Feedback
Your extension now delivers personality-driven verdicts, but the conversation is one-way. Users have no way to tell Link Guardian whether its warnings landed or felt off.
In this step, you will add thumbs up/down buttons so users can react to each verdict, store their feedback persistently using chrome.storage.local, and build a History tab that displays all past votes.
In this step, get ready to:
- Add thumbs up/down buttons below the verdict card that visually highlight when clicked.
- Build a two-tab interface with Scan and History views.
- Persist feedback entries and prevent duplicate votes for the same URL.
Add thumbs up/down buttons below the verdict
After a scan, the verdict card appears but there is no way for the user to react. Two emoji buttons beneath the card give instant, one-click feedback.
- In popup.html, find the closing </div> of the verdict-card div (after the engine-breakdown paragraph).
- Add this feedback buttons section directly below it, still inside the results div:
<div class="feedback-buttons">
<button id="thumbs-up" class="feedback-btn" data-vote="up">👍</button>
<button id="thumbs-down" class="feedback-btn" data-vote="down">👎</button>
</div>
What does this code do?
Each button uses an HTML entity for its emoji (thumbs up and thumbs down). The data-vote attribute marks whether it is an "up" or "down" vote, and the IDs let your JavaScript target each button individually.
- Save popup.html.
- Now open popup.css and add these styles at the bottom of the file to give the buttons a circular, dark appearance:
.feedback-buttons {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 12px;
}
.feedback-btn {
font-size: 24px;
background: #2d2d44;
border: 2px solid #444;
border-radius: 50%;
width: 48px;
height: 48px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.feedback-btn:hover {
border-color: #888;
transform: scale(1.1);
}
.feedback-btn.selected {
border-color: #e94560;
background: #3d1a2e;
transform: scale(1.1);
}
What does this code do?
The .feedback-btn class creates circular buttons with a dark background and subtle border. On hover they scale up slightly.
The .selected class highlights the chosen button with a pink border and darker background so the user instantly sees which vote they picked.
- Save popup.css.
- Return to Chrome, go to chrome://extensions, and click the reload icon on the Link Guardian card.
- Click the extension icon on any webpage, then click Scan This Page.
- Below the verdict card, you should see two circular emoji buttons (thumbs up and thumbs down).
Buttons not appearing?
- Make sure the feedback-buttons div is inside the results div, not after it.
- Verify you reloaded the extension after saving. The popup loads the HTML fresh each time you open it, but Chrome caches the extension itself.
Help me debug why my feedback buttons are not showing below the verdict card.
The buttons are visible, but clicking them does nothing yet. Time to wire up the feedback logic.
- In popup.js, find the group of const declarations at the top of the DOMContentLoaded callback.
- Add these two references after the engineBreakdown line:
const thumbsUp = document.getElementById("thumbs-up");
const thumbsDown = document.getElementById("thumbs-down");
- Next, find the scanBtn.addEventListener block. Add these two event listeners below it:
thumbsUp.addEventListener("click", () => saveFeedback("up"));
thumbsDown.addEventListener("click", () => saveFeedback("down"));
- Now add the saveFeedback function inside the DOMContentLoaded callback, below the displayVerdict function:
function saveFeedback(vote) {
// Clear any previous selection
thumbsUp.classList.remove("selected");
thumbsDown.classList.remove("selected");
// Highlight the chosen button
if (vote === "up") {
thumbsUp.classList.add("selected");
} else {
thumbsDown.classList.add("selected");
}
// Build the feedback entry
const entry = {
url: currentUrl,
verdict: currentVerdict,
vote: vote,
timestamp: Date.now()
};
// Save to chrome.storage.local
chrome.storage.local.get("feedbackHistory", (data) => {
let history = data.feedbackHistory || [];
const existingIndex = history.findIndex((item) => item.url === currentUrl);
if (existingIndex >= 0) {
history[existingIndex] = entry;
} else {
history.unshift(entry);
}
if (history.length > 50) {
history = history.slice(0, 50);
}
chrome.storage.local.set({ feedbackHistory: history });
});
}
What does this code do?
- It clears any previous highlight, then adds the selected class to the button the user clicked.
- It stores the entry in a feedbackHistory array inside chrome.storage.local.
- If the user already voted on this URL, the existing entry gets updated instead of creating a duplicate.
- The array is capped at 50 entries to avoid storage bloat.
- Save popup.js.
- Reload the extension in chrome://extensions.
- Open the popup on any webpage, click Scan This Page, then click the thumbs up button.
- The thumbs up button should highlight with a pink border and scale up. Clicking thumbs down should switch the highlight to that button instead.
Button not highlighting?
- Check that thumbsUp and thumbsDown are declared inside the DOMContentLoaded callback, alongside the other const references.
- Confirm the button IDs in your HTML (thumbs-up and thumbs-down) match the getElementById strings.
Help me debug why clicking my feedback button does not add the selected class.
Add a two-tab interface with history
Right now all the content sits in one flat view. Adding a Scan and History tab gives users a place to review their past votes without cluttering the scan screen.
- In popup.html, find the <header> section. Add a tab navigation bar below the <h1> so the header looks like this:
<header>
<h1>Link Guardian</h1>
<nav class="tabs">
<button class="tab active" data-tab="scan">Scan</button>
<button class="tab" data-tab="history">History</button>
</nav>
</header>
- Next, wrap all existing content between the closing </header> tag and the <script> tag in a new div. Add this opening tag directly after </header>:
<div id="scan-view" class="view active">
- Close this div after the error div (right before where the </div> for .container used to be the only remaining close):
</div>
- Finally, add the History view between the closing </div> of scan-view and the closing </div> of .container:
<div id="history-view" class="view">
<div id="history-list" class="history-list">
<p class="empty-state">No feedback yet. Scan some pages!</p>
</div>
</div>
What is this structure doing?
The data-tab attributes on each tab button map to a matching view ID (scan-view and history-view). Only the view with the active class is visible. Clicking a tab will swap which view is active.
- Save popup.html.
- Now open popup.css and add tab navigation styles. Place these after the h1 rule (above the #api-key-section rule):
header {
margin-bottom: 16px;
}
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid #333;
padding-bottom: 8px;
}
.tab {
background: none;
border: none;
color: #888;
font-size: 13px;
padding: 6px 12px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
}
.tab.active {
color: #ffffff;
background: #2d2d44;
}
.tab:hover {
color: #ffffff;
}
- Below those rules, add the view-switching styles and the history container layout:
.view {
display: none;
}
.view.active {
display: block;
}
.history-list {
max-height: 300px;
overflow-y: auto;
}
.empty-state {
text-align: center;
color: #666;
font-size: 13px;
padding: 24px 0;
}
.history-item {
padding: 10px;
border-bottom: 1px solid #2d2d44;
display: flex;
align-items: center;
gap: 10px;
}
.history-item:last-child {
border-bottom: none;
}
- Finally, add the styles for each history entry's details:
.history-url {
flex: 1;
font-size: 11px;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
}
.history-badge.safe {
background: #0d3320;
color: #28a745;
}
.history-badge.caution {
background: #3d3200;
color: #ffc107;
}
.history-badge.warning {
background: #3d1f00;
color: #fd7e14;
}
.history-badge.danger {
background: #3d0011;
color: #dc3545;
}
.history-vote {
font-size: 16px;
}
.history-time {
font-size: 10px;
color: #555;
}
What do these styles do?
The .view rules hide all views by default and only show the one with .active. This is what makes tab switching work with a simple class swap.
The history styles create a compact list where each entry shows a colored verdict badge, a truncated URL, the vote emoji, and the date.
- Save popup.css.
- Reload the extension and open the popup. You should see Scan and History tabs below the heading, with the Scan view active.
Scan content disappeared?
- Make sure the scan-view div has both class="view active". Without active, the CSS hides it.
- Confirm the scan-view opening tag is directly after </header> and its closing </div> comes after the error div.
Help me figure out why my popup content is not visible after adding tabs.
The tabs look right, but clicking History does nothing yet. Time to add the JavaScript.
- In popup.js, find the const declarations at the top. Add these two lines after the thumbsDown declaration:
const tabs = document.querySelectorAll(".tab");
const views = document.querySelectorAll(".view");
- Below the loadCurrentTab() call (and before the saveKeyBtn event listener), add the tab switching logic:
// Tab switching - swap active class between views
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
tabs.forEach((t) => t.classList.remove("active"));
views.forEach((v) => v.classList.remove("active"));
tab.classList.add("active");
const viewId = tab.dataset.tab + "-view";
document.getElementById(viewId).classList.add("active");
if (tab.dataset.tab === "history") {
renderHistory();
}
});
});
What does this code do?
When a tab is clicked, it removes active from all tabs and views, then adds it to the clicked tab and the matching view. The view ID is built by appending -view to the tab's data-tab value.
If the user switches to the History tab, it calls renderHistory() to load the latest feedback data.
- Save popup.js, reload the extension, and open the popup.
- Click the History tab. The scan view disappears and you should see the text "No feedback yet. Scan some pages!" in its place.
Tab switching works, but the history view is static. Now you need a function that reads stored feedback and renders it.
- Add the renderHistory function inside the DOMContentLoaded callback, below saveFeedback:
function renderHistory() {
const historyList = document.getElementById("history-list");
chrome.storage.local.get("feedbackHistory", (data) => {
const history = data.feedbackHistory || [];
if (history.length === 0) {
historyList.innerHTML = '<p class="empty-state">No feedback yet. Scan some pages!</p>';
return;
}
historyList.innerHTML = history
.map((item) => {
const voteIcon = item.vote === "up" ? "\u{1F44D}" : "\u{1F44E}";
const timeStr = new Date(item.timestamp).toLocaleDateString();
const shortUrl = item.url.length > 35 ? item.url.substring(0, 35) + "..." : item.url;
return '<div class="history-item">' +
'<span class="history-badge ' + item.verdict + '">' + item.verdict + '</span>' +
'<span class="history-url" title="' + item.url + '">' + shortUrl + '</span>' +
'<span class="history-vote">' + voteIcon + '</span>' +
'<span class="history-time">' + timeStr + '</span>' +
'</div>';
})
.join("");
});
}
What does this code do?
- It reads the feedbackHistory array from chrome.storage.local.
- If empty, it shows the placeholder message.
- Otherwise, it maps each entry into an HTML string showing the verdict badge (colored by tier), the truncated URL, the vote emoji, and the date.
- Save popup.js and reload the extension.
- Scan a page and click thumbs up.
- Switch to the History tab. You should see an entry with the URL, a colored verdict badge, the thumbs up emoji, and today's date.
History tab is empty after voting?
- Make sure saveFeedback uses the key feedbackHistory in both the get and set calls. A typo in the key name means the data is saved under one key but read from another.
- Confirm your renderHistory function is called when the History tab is clicked. Check for if (tab.dataset.tab === "history") in the tab click handler.
Help me figure out why my feedback history is not displaying entries after I vote.
Prevent duplicate votes with pre-selection
If a user scans the same page again, they should see their previous vote already highlighted. This makes the interface feel stateful and prevents accidental duplicates.
- Add the loadExistingVote function below saveFeedback in popup.js:
function loadExistingVote() {
// Clear buttons before checking for existing vote
thumbsUp.classList.remove("selected");
thumbsDown.classList.remove("selected");
chrome.storage.local.get("feedbackHistory", (data) => {
const history = data.feedbackHistory || [];
const existing = history.find((item) => item.url === currentUrl);
if (existing) {
if (existing.vote === "up") {
thumbsUp.classList.add("selected");
} else {
thumbsDown.classList.add("selected");
}
}
});
}
- Now find the displayVerdict function. At the very end of it (after resultsEl.classList.remove("hidden")), add this call:
loadExistingVote();
What does this code do?
Every time a verdict is displayed, loadExistingVote checks if the user has already voted on this URL. If so, it pre-selects the matching button. Combined with the existingIndex check in saveFeedback, clicking a different button updates the existing entry instead of creating a new one.
- Save popup.js and reload the extension.
Before you test, what do you expect to see if you scan the same page you voted on earlier?
- Open the popup on the same page you previously voted on and click Scan This Page.
- Your previous vote should already be highlighted when the verdict appears. Clicking the other button should swap the highlight and update (not duplicate) the history entry.
Previous vote not showing?
- Make sure loadExistingVote() is called at the end of displayVerdict, not inside startScan. It needs the verdict to be rendered first.
- Confirm the .find() call compares item.url === currentUrl. If currentUrl is empty at that point, the match will fail.
Help me debug why my previous vote is not pre-selected when I rescan the same URL.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files (popup.html, popup.css, and popup.js) and reloaded your extension.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these pieces is present in your project, in order:
- popup.html: header with tab nav, scan-view wrapper, feedback-buttons div inside results, history-view div at the bottom.
- popup.css: header margin, .tabs/.tab rules, .view rules, .feedback-buttons/.feedback-btn rules, .history-list and .history-item rules, .history-badge variants, .history-url/.history-vote/.history-time rules.
- popup.js: thumbsUp/thumbsDown/tabs/views const declarations, tab switching forEach block, thumbsUp and thumbsDown event listeners, saveFeedback function, loadExistingVote function (called at end of displayVerdict), renderHistory function.
Your extension now has a complete feedback loop. Users can scan, react, and review their history. Next, you will tackle the Secret Mission and make Link Guardian scan every link on a page automatically.
Secret mission
Auto-Scan Links on the Page
Right now you have to manually click Scan for every page you visit. What if Link Guardian could automatically scan every link on a page and show you which ones are safe before you even click them? In this secret mission, you will add a content script that scans links in the background and injects color-coded safety dots next to each one.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses a free VirusTotal account, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- VirusTotal free account (no cost, persists indefinitely).
- Link Guardian extension loaded in Chrome developer mode (local only).
- The link-guardian project folder on your Desktop.
- Feedback history stored in chrome.storage.local (local to your browser, removed when the extension is removed).
Keep everything running
No action needed. Choose this if you're still actively using Link Guardian to scan pages daily.
- Your extension stays loaded in Chrome and works on every page you visit.
- Your VirusTotal account remains active at no cost.
- Your feedback history persists in chrome.storage.local.
Pause - I'll come back to this later
Disable the extension to free up browser resources while keeping your files intact for later.
- Navigate to chrome://extensions in your browser.
- Find Link Guardian and toggle it off.
- Your feedback history and API key are preserved. Toggle the extension back on when you want to use it again.
Delete - I don't want to use this again
Remove all project resources from your machine and browser.
- Navigate to chrome://extensions in your browser.
- Find Link Guardian and click Remove. This also deletes your chrome.storage.local data (feedback history and saved API key).
Delete the project folder from your Desktop by running the command for your operating system:
macOS
rm -rf ~/Desktop/link-guardian
Windows
Remove-Item -Recurse -Force "$HOME\Desktop\link-guardian"
Your VirusTotal account is free and has no ongoing cost. You can leave it as-is. If you want to delete it, sign in at virustotal.com, open your user menu, go to Settings, and select the option to delete your account.
Nice Work!
Nice Work!
You just built a fully functional Chrome extension from scratch that protects you from malicious URLs every time you browse the web.
Here's what you learned:
- Built a complete Chrome extension using Manifest V3 architecture, including a popup UI, a service worker for background processing, and message passing between components.
- Integrated with the VirusTotal REST API to scan URLs for malware with 70+ antivirus engines using base64url encoding and the x-apikey authentication header.
- Created a personality-driven warning system with tiered verdicts and persistent thumbs up/down feedback stored in chrome.storage.local.
- Secret Mission: Auto-scanned all links on a page using a content script and injected color-coded safety indicator dots next to each link.
Ready to quiz yourself?