Initial build: Citation Verifier Chrome extension MVP

This commit is contained in:
Bun Bun
2026-06-17 18:27:58 +00:00
commit e20ba828a0
16 changed files with 2967 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.venv/
.verdict
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

+36
View File
@@ -0,0 +1,36 @@
{
"manifest_version": 3,
"name": "Citation Verifier",
"version": "1.0.0",
"description": "Verify citations and detect AI hallucinations in research documents",
"permissions": [
"activeTab",
"storage"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "src/background.ts"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content.ts"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "src/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+2257
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "citation-verifier",
"version": "1.0.0",
"description": "Verify citations and detect AI hallucinations in research",
"type": "module",
"scripts": {
"build": "vite build",
"test": "vitest run"
},
"dependencies": {},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.28",
"@types/chrome": "^0.0.268",
"typescript": "^5.4.0",
"vite": "^5.2.0",
"vitest": "^1.6.0"
}
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { extractTextFromHtml, computeMatchScore } from '../verifier';
describe('extractTextFromHtml', () => {
it('removes script tags', () => {
const html = '<p>Hello</p><script>alert("bad")</script><p>World</p>';
expect(extractTextFromHtml(html)).toBe('Hello World');
});
it('removes style tags', () => {
const html = '<style>body{color:red}</style><p>Clean</p>';
expect(extractTextFromHtml(html)).toBe('Clean');
});
it('decodes html entities', () => {
const html = '<p>AT&amp;T &lt; 100 &gt; 50</p>';
expect(extractTextFromHtml(html)).toBe('AT&T < 100 > 50');
});
it('normalizes whitespace', () => {
const html = '<p>One\n\nTwo\t\tThree</p>';
expect(extractTextFromHtml(html)).toBe('One Two Three');
});
it('handles empty input', () => {
expect(extractTextFromHtml('')).toBe('');
});
});
describe('computeMatchScore', () => {
it('returns 1 for exact match', () => {
const score = computeMatchScore('The quick brown fox jumps', 'The quick brown fox jumps over the lazy dog');
expect(score).toBeGreaterThan(0.7);
});
it('returns 0 for completely unrelated text', () => {
const score = computeMatchScore('Quantum computing advances', 'The history of ancient Rome');
expect(score).toBeLessThan(0.1);
});
it('handles partial matches', () => {
const score = computeMatchScore('Machine learning models trained on large datasets', 'Models trained on large datasets show improved performance');
expect(score).toBeGreaterThan(0.05);
expect(score).toBeLessThan(1);
});
it('returns 0 for empty inputs', () => {
expect(computeMatchScore('', 'something')).toBe(0);
expect(computeMatchScore('something', '')).toBe(0);
});
it('matches short phrases with common words', () => {
const score = computeMatchScore('Artificial intelligence research', 'Research in artificial intelligence continues');
expect(score).toBeGreaterThan(0.1);
expect(score).toBeLessThan(1);
});
});
+14
View File
@@ -0,0 +1,14 @@
// Service worker - handles extension lifecycle and keeps messages flowing
chrome.runtime.onInstalled.addListener(() => {
console.log('Citation Verifier installed');
});
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request.action === 'progress') {
// Forward progress messages from content script to popup
// This is handled by chrome.runtime.onMessage in popup
sendResponse({ ok: true });
return true;
}
return false;
});
+91
View File
@@ -0,0 +1,91 @@
export interface Citation {
url: string;
text: string; // the surrounding text in the document that cites this URL
element?: HTMLElement;
}
export interface VerificationResult {
url: string;
text: string;
status: 'verified' | 'suspicious' | 'error' | 'pending';
sourceText?: string;
error?: string;
matchScore?: number;
}
export function extractCitations(): Citation[] {
const citations: Citation[] = [];
const links = document.querySelectorAll('a[href]');
const seen = new Set<string>();
links.forEach((link) => {
const href = link.getAttribute('href');
if (!href) return;
// Skip anchors, mailto, javascript, and same-page links
if (href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('javascript:')) return;
let url: string;
try {
url = new URL(href, window.location.href).href;
} catch {
return;
}
// Skip same-domain links (not citations)
try {
const linkHost = new URL(url).hostname;
const pageHost = window.location.hostname;
if (linkHost === pageHost) return;
} catch {
return;
}
if (seen.has(url)) return;
seen.add(url);
// Extract surrounding text (up to 200 chars before and after)
const parent = link.parentElement;
let text = '';
if (parent) {
text = parent.textContent || '';
// Clean up
text = text.trim().substring(0, 400);
}
// If no parent text, try getting paragraph context
if (!text || text.length < 20) {
const paragraph = link.closest('p, li, td, div');
if (paragraph && paragraph !== link) {
text = (paragraph.textContent || '').trim().substring(0, 400);
}
}
citations.push({
url,
text,
element: link as HTMLElement,
});
});
return citations;
}
export function highlightCitation(element: HTMLElement, status: VerificationResult['status']) {
const colors: Record<string, string> = {
verified: 'rgba(34, 197, 94, 0.3)',
suspicious: 'rgba(239, 68, 68, 0.3)',
error: 'rgba(234, 179, 8, 0.3)',
pending: 'rgba(156, 163, 175, 0.3)',
};
element.style.backgroundColor = colors[status] || colors.pending;
element.style.transition = 'background-color 0.3s ease';
}
export function clearHighlights() {
const links = document.querySelectorAll('a[href]');
links.forEach((link) => {
(link as HTMLElement).style.backgroundColor = '';
});
}
+64
View File
@@ -0,0 +1,64 @@
import { extractCitations, clearHighlights, highlightCitation } from './citation-extractor';
import { verifyAll, VerificationResult } from './verifier';
let isVerifying = false;
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request.action === 'extract') {
const citations = extractCitations();
sendResponse({ citations: citations.map(c => ({ url: c.url, text: c.text })) });
return true;
}
if (request.action === 'verify') {
if (isVerifying) {
sendResponse({ error: 'Already verifying' });
return true;
}
isVerifying = true;
clearHighlights();
const citations = extractCitations();
const citationMap = new Map(citations.map(c => [c.url, c]));
verifyAll(citations, (result) => {
const citation = citationMap.get(result.url);
if (citation?.element) {
highlightCitation(citation.element, result.status);
}
chrome.runtime.sendMessage({
action: 'progress',
result: {
url: result.url,
text: result.text,
status: result.status,
matchScore: result.matchScore,
error: result.error,
},
});
}).then((results) => {
isVerifying = false;
sendResponse({ results: results.map(r => ({
url: r.url,
text: r.text,
status: r.status,
matchScore: r.matchScore,
error: r.error,
})) });
}).catch((err) => {
isVerifying = false;
sendResponse({ error: String(err) });
});
return true; // keep channel open for async
}
if (request.action === 'clear') {
clearHighlights();
sendResponse({ ok: true });
return true;
}
return false;
});
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { width: 380px; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; color: #1f2937; }
.header { padding: 12px 16px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
.header h1 { font-size: 14px; font-weight: 600; color: #111827; }
.header p { font-size: 11px; color: #6b7280; margin-top: 2px; }
.actions { padding: 12px 16px; display: flex; gap: 8px; }
button { flex: 1; padding: 8px 12px; border: none; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; transition: opacity 0.15s; }
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #2563eb; color: white; }
.btn-secondary { background: #e5e7eb; color: #374151; }
.stats { padding: 8px 16px; display: flex; gap: 12px; font-size: 11px; border-bottom: 1px solid #e5e7eb; }
.stat { display: flex; align-items: center; gap: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.verified { background: #22c55e; }
.dot.suspicious { background: #ef4444; }
.dot.error { background: #eab308; }
.dot.pending { background: #9ca3af; }
.results { max-height: 320px; overflow-y: auto; }
.result-item { padding: 10px 16px; border-bottom: 1px solid #f3f4f6; }
.result-item:last-child { border-bottom: none; }
.result-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.result-badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 500; text-transform: uppercase; }
.badge-verified { background: #dcfce7; color: #166534; }
.badge-suspicious { background: #fee2e2; color: #991b1b; }
.badge-error { background: #fef3c7; color: #92400e; }
.badge-pending { background: #f3f4f6; color: #374151; }
.result-url { font-size: 11px; color: #6b7280; word-break: break-all; text-decoration: none; }
.result-url:hover { text-decoration: underline; }
.result-text { font-size: 11px; color: #4b5563; margin-top: 4px; line-height: 1.4; }
.result-meta { font-size: 10px; color: #9ca3af; margin-top: 2px; }
.empty { padding: 24px 16px; text-align: center; color: #9ca3af; font-size: 12px; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #e5e7eb; border-top-color: #2563eb; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.status-bar { padding: 8px 16px; font-size: 11px; color: #6b7280; border-top: 1px solid #e5e7eb; background: #f9fafb; }
</style>
</head>
<body>
<div class="header">
<h1>Citation Verifier</h1>
<p>Detect AI hallucinations in research citations</p>
</div>
<div class="stats" id="stats">
<div class="stat"><span class="dot verified"></span><span id="verified-count">0</span> verified</div>
<div class="stat"><span class="dot suspicious"></span><span id="suspicious-count">0</span> suspicious</div>
<div class="stat"><span class="dot error"></span><span id="error-count">0</span> errors</div>
<div class="stat"><span class="dot pending"></span><span id="pending-count">0</span> pending</div>
</div>
<div class="actions">
<button class="btn-primary" id="verify-btn">Verify Citations</button>
<button class="btn-secondary" id="clear-btn">Clear</button>
</div>
<div class="results" id="results">
<div class="empty">Click "Verify Citations" to check this page</div>
</div>
<div class="status-bar" id="status">Ready</div>
<script type="module" src="./popup.ts"></script>
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
interface ResultItem {
url: string;
text: string;
status: 'verified' | 'suspicious' | 'error' | 'pending';
matchScore?: number;
error?: string;
}
const verifyBtn = document.getElementById('verify-btn') as HTMLButtonElement;
const clearBtn = document.getElementById('clear-btn') as HTMLButtonElement;
const resultsEl = document.getElementById('results') as HTMLDivElement;
const statusEl = document.getElementById('status') as HTMLDivElement;
const verifiedCount = document.getElementById('verified-count') as HTMLSpanElement;
const suspiciousCount = document.getElementById('suspicious-count') as HTMLSpanElement;
const errorCount = document.getElementById('error-count') as HTMLSpanElement;
const pendingCount = document.getElementById('pending-count') as HTMLSpanElement;
let results: ResultItem[] = [];
let isVerifying = false;
function updateStats() {
const counts = { verified: 0, suspicious: 0, error: 0, pending: 0 };
for (const r of results) {
counts[r.status]++;
}
verifiedCount.textContent = String(counts.verified);
suspiciousCount.textContent = String(counts.suspicious);
errorCount.textContent = String(counts.error);
pendingCount.textContent = String(counts.pending);
}
function renderResult(result: ResultItem): string {
const badgeClass = `badge-${result.status}`;
const badgeText = result.status;
const scoreText = result.matchScore !== undefined
? `Match: ${(result.matchScore * 100).toFixed(0)}%`
: '';
const errorText = result.error ? `Error: ${result.error}` : '';
const shortUrl = result.url.length > 60 ? result.url.substring(0, 57) + '...' : result.url;
return `
<div class="result-item">
<div class="result-header">
<span class="result-badge ${badgeClass}">${badgeText}</span>
${scoreText ? `<span class="result-meta">${scoreText}</span>` : ''}
</div>
<a class="result-url" href="${result.url}" target="_blank" rel="noopener">${shortUrl}</a>
${result.text ? `<div class="result-text">${escapeHtml(result.text.substring(0, 150))}${result.text.length > 150 ? '...' : ''}</div>` : ''}
${errorText ? `<div class="result-meta">${escapeHtml(errorText)}</div>` : ''}
</div>
`;
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderResults() {
if (results.length === 0) {
resultsEl.innerHTML = '<div class="empty">Click "Verify Citations" to check this page</div>';
return;
}
resultsEl.innerHTML = results.map(renderResult).join('');
updateStats();
}
function setStatus(message: string) {
statusEl.textContent = message;
}
async function verifyCitations() {
if (isVerifying) return;
isVerifying = true;
verifyBtn.disabled = true;
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.id) {
setStatus('No active tab');
return;
}
setStatus('Extracting citations...');
// Extract citations
const extractResponse = await chrome.tabs.sendMessage(tab.id, { action: 'extract' });
const citations = extractResponse?.citations || [];
if (citations.length === 0) {
setStatus('No citations found on this page');
results = [];
renderResults();
return;
}
setStatus(`Found ${citations.length} citation(s). Verifying...`);
results = citations.map((c: ResultItem) => ({ ...c, status: 'pending' }));
renderResults();
// Verify
const verifyResponse = await chrome.tabs.sendMessage(tab.id, { action: 'verify' });
if (verifyResponse?.error) {
setStatus(`Error: ${verifyResponse.error}`);
return;
}
const verified = verifyResponse?.results || [];
results = verified;
renderResults();
const verifiedNum = verified.filter((r: ResultItem) => r.status === 'verified').length;
const suspiciousNum = verified.filter((r: ResultItem) => r.status === 'suspicious').length;
setStatus(`Done: ${verifiedNum} verified, ${suspiciousNum} suspicious, ${verified.length} total`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setStatus(`Error: ${msg}`);
} finally {
isVerifying = false;
verifyBtn.disabled = false;
}
}
async function clearHighlights() {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab.id) {
await chrome.tabs.sendMessage(tab.id, { action: 'clear' });
}
} catch {
// ignore
}
results = [];
renderResults();
setStatus('Cleared');
}
// Listen for progress updates from content script
chrome.runtime.onMessage.addListener((message) => {
if (message.action === 'progress') {
const result = message.result as ResultItem;
const existing = results.findIndex(r => r.url === result.url);
if (existing >= 0) {
results[existing] = result;
} else {
results.push(result);
}
renderResults();
}
});
verifyBtn.addEventListener('click', verifyCitations);
clearBtn.addEventListener('click', clearHighlights);
// Load initial state
chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
if (tab?.url) {
setStatus(`Ready: ${tab.url}`);
}
});
+168
View File
@@ -0,0 +1,168 @@
import { VerificationResult, Citation } from './citation-extractor';
const MAX_CONTENT_LENGTH = 50000;
const MIN_MATCH_LENGTH = 8;
export async function verifyCitation(citation: Citation): Promise<VerificationResult> {
try {
const response = await fetch(citation.url, {
method: 'GET',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
return {
url: citation.url,
text: citation.text,
status: 'error',
error: `HTTP ${response.status}: ${response.statusText}`,
};
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml') && !contentType.includes('application/xml')) {
return {
url: citation.url,
text: citation.text,
status: 'error',
error: `Non-HTML content: ${contentType}`,
};
}
const html = await response.text();
const sourceText = extractTextFromHtml(html).substring(0, MAX_CONTENT_LENGTH);
const matchScore = computeMatchScore(citation.text, sourceText);
if (matchScore >= 0.6) {
return {
url: citation.url,
text: citation.text,
status: 'verified',
sourceText: sourceText.substring(0, 500),
matchScore,
};
} else if (matchScore >= 0.2) {
return {
url: citation.url,
text: citation.text,
status: 'suspicious',
sourceText: sourceText.substring(0, 500),
matchScore,
};
} else {
return {
url: citation.url,
text: citation.text,
status: 'suspicious',
sourceText: sourceText.substring(0, 500),
matchScore,
};
}
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return {
url: citation.url,
text: citation.text,
status: 'error',
error: error.includes('AbortError') ? 'Timeout' : error,
};
}
}
export function extractTextFromHtml(html: string): string {
// Remove script and style tags
let cleaned = html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ');
// Remove remaining tags
cleaned = cleaned.replace(/<[^>]+>/g, ' ');
// Decode common entities
cleaned = cleaned
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
// Normalize whitespace
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return cleaned;
}
export function computeMatchScore(citationText: string, sourceText: string): number {
if (!citationText || !sourceText) return 0;
// Normalize texts
const normalize = (s: string) => s.toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
const citNorm = normalize(citationText);
const srcNorm = normalize(sourceText);
if (!citNorm || !srcNorm) return 0;
// Extract meaningful phrases (3+ words, 4+ chars each)
const phrases = extractPhrases(citNorm);
if (phrases.length === 0) return 0;
let matchedPhrases = 0;
let totalPhraseWeight = 0;
for (const phrase of phrases) {
const weight = phrase.length;
totalPhraseWeight += weight;
if (srcNorm.includes(phrase)) {
matchedPhrases += weight;
}
}
return totalPhraseWeight > 0 ? matchedPhrases / totalPhraseWeight : 0;
}
function extractPhrases(text: string): string[] {
const words = text.split(' ').filter(w => w.length >= 3);
const phrases: string[] = [];
// Generate 2-word, 3-word, 4-word, and 5-word phrases
for (let n = 2; n <= 5 && n <= words.length; n++) {
for (let i = 0; i <= words.length - n; i++) {
const phrase = words.slice(i, i + n).join(' ');
if (phrase.length >= MIN_MATCH_LENGTH) {
phrases.push(phrase);
}
}
}
// If no multi-word phrases, fall back to single long words
if (phrases.length === 0) {
return words.filter(w => w.length >= 6);
}
return phrases;
}
export async function verifyAll(citations: Citation[], onProgress?: (result: VerificationResult) => void): Promise<VerificationResult[]> {
const results: VerificationResult[] = [];
// Process in batches of 3 to avoid overwhelming the browser
const BATCH_SIZE = 3;
for (let i = 0; i < citations.length; i += BATCH_SIZE) {
const batch = citations.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async (citation) => {
const result = await verifyCitation(citation);
if (onProgress) onProgress(result);
return result;
})
);
results.push(...batchResults);
}
return results;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"lib": ["ES2020", "DOM"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["chrome", "vitest/globals"]
},
"include": ["src/**/*"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json' with { type: 'json' };
export default defineConfig({
plugins: [crx({ manifest })],
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: {
popup: 'src/popup.html',
},
},
},
});