Initial build: Citation Verifier Chrome extension MVP
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.venv/
|
||||||
|
.verdict
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 360 B |
Binary file not shown.
|
After Width: | Height: | Size: 82 B |
Binary file not shown.
|
After Width: | Height: | Size: 157 B |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2257
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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&T < 100 > 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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 = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /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;
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user