| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { defineContentScript } from 'wxt/sandbox'; |
| import { createRoot } from 'react-dom/client'; |
| import React, { useEffect, useRef, useState, useCallback } from 'react'; |
|
|
| |
| type Platform = |
| | 'x' | 'instagram' | 'youtube' | 'chatgpt' | 'claude' |
| | 'gemini' | 'web'; |
|
|
| function detectPlatform(): Platform { |
| const host = window.location.hostname; |
| if (host.includes('twitter.com') || host.includes('x.com')) return 'x'; |
| if (host.includes('instagram.com')) return 'instagram'; |
| if (host.includes('youtube.com')) return 'youtube'; |
| if (host.includes('chat.openai.com')) return 'chatgpt'; |
| if (host.includes('claude.ai')) return 'claude'; |
| if (host.includes('gemini.google.com') || host.includes('bard.google.com')) return 'gemini'; |
| return 'web'; |
| } |
|
|
| |
| const COLOR_MAP: Record<string, { bg: string; border: string; label: string; emoji: string }> = { |
| green: { bg: 'rgba(34,197,94,0.12)', border: '#22c55e', label: 'Verified', emoji: 'β' }, |
| yellow:{ bg: 'rgba(234,179,8,0.14)', border: '#eab308', label: 'Unverified', emoji: '~' }, |
| red: { bg: 'rgba(239,68,68,0.16)', border: '#ef4444', label: 'Misleading', emoji: 'β' }, |
| purple:{ bg: 'rgba(168,85,247,0.15)', border: '#a855f7', label: 'AI Hallucination', emoji: '?' }, |
| }; |
|
|
| |
| function shouldShowColor(color: string, mode: string): boolean { |
| if (mode === 'minimal') return color === 'red' || color === 'purple'; |
| if (mode === 'normal') return color === 'red' || color === 'purple' || color === 'yellow'; |
| return true; |
| } |
|
|
| |
| function fastHash(str: string): string { |
| let h = 0x811c9dc5; |
| for (let i = 0; i < str.length; i++) { |
| h ^= str.charCodeAt(i); |
| h = (h * 0x01000193) >>> 0; |
| } |
| return h.toString(16).padStart(8, '0'); |
| } |
|
|
| |
| const resultCache = new Map<string, { |
| color: string; confidence: number; verdict: string; |
| explanation: string; sources: string[]; trustScore: number; |
| }>(); |
|
|
| |
| interface HoverCardProps { |
| result: typeof resultCache extends Map<string, infer V> ? V : never; |
| anchorRect: DOMRect; |
| onDismiss: () => void; |
| } |
|
|
| function HoverCard({ result, anchorRect, onDismiss }: HoverCardProps) { |
| const cardRef = useRef<HTMLDivElement>(null); |
| const [pos, setPos] = useState({ top: 0, left: 0 }); |
| const [visible, setVisible] = useState(false); |
| const colorCfg = COLOR_MAP[result.color] || COLOR_MAP.yellow; |
|
|
| useEffect(() => { |
| |
| const vw = window.innerWidth, vh = window.innerHeight; |
| const cardW = 340, cardH = 220; |
| let top = anchorRect.bottom + 8; |
| let left = anchorRect.left; |
| if (top + cardH > vh) top = anchorRect.top - cardH - 8; |
| if (left + cardW > vw) left = vw - cardW - 12; |
| if (left < 8) left = 8; |
| setPos({ top, left }); |
| |
| requestAnimationFrame(() => setVisible(true)); |
| }, [anchorRect]); |
|
|
| const handleMouseLeave = useCallback(() => { |
| setVisible(false); |
| setTimeout(onDismiss, 180); |
| }, [onDismiss]); |
|
|
| return ( |
| <div |
| ref={cardRef} |
| onMouseLeave={handleMouseLeave} |
| style={{ |
| position: 'fixed', |
| top: pos.top, |
| left: pos.left, |
| width: 340, |
| zIndex: 2147483647, |
| fontFamily: "'Inter', -apple-system, sans-serif", |
| fontSize: 13, |
| transform: visible ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.96)', |
| opacity: visible ? 1 : 0, |
| transition: 'transform 180ms cubic-bezier(0.34,1.56,0.64,1), opacity 160ms ease', |
| willChange: 'transform, opacity', |
| }} |
| > |
| <div style={{ |
| background: 'linear-gradient(135deg, #0f0f17 0%, #12121e 100%)', |
| border: `1px solid ${colorCfg.border}`, |
| borderRadius: 12, |
| boxShadow: `0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px ${colorCfg.border}22`, |
| overflow: 'hidden', |
| }}> |
| {/* Header */} |
| <div style={{ |
| display: 'flex', alignItems: 'center', gap: 10, |
| padding: '12px 14px 10px', |
| borderBottom: `1px solid ${colorCfg.border}22`, |
| background: `${colorCfg.bg}`, |
| }}> |
| <div style={{ |
| width: 32, height: 32, borderRadius: '50%', border: `2px solid ${colorCfg.border}`, |
| display: 'flex', alignItems: 'center', justifyContent: 'center', |
| fontSize: 14, color: colorCfg.border, fontWeight: 700, |
| flexShrink: 0, |
| }}> |
| {colorCfg.emoji} |
| </div> |
| <div style={{ flex: 1, minWidth: 0 }}> |
| <div style={{ color: colorCfg.border, fontWeight: 700, fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em' }}> |
| {colorCfg.label} |
| </div> |
| <div style={{ color: '#e2e8f0', fontWeight: 600, fontSize: 13, lineHeight: 1.3, marginTop: 2 }}> |
| {result.verdict} |
| </div> |
| </div> |
| {/* Confidence arc */} |
| <ConfidenceArc confidence={result.confidence} color={colorCfg.border} /> |
| </div> |
| |
| {/* Body */} |
| <div style={{ padding: '10px 14px 12px' }}> |
| <p style={{ color: '#94a3b8', fontSize: 12, lineHeight: 1.5, margin: '0 0 10px' }}> |
| {result.explanation} |
| </p> |
| |
| {/* Trust score bar */} |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}> |
| <span style={{ color: '#64748b', fontSize: 11, whiteSpace: 'nowrap' }}>Trust</span> |
| <div style={{ flex: 1, height: 4, background: '#1e293b', borderRadius: 2, overflow: 'hidden' }}> |
| <div style={{ |
| height: '100%', borderRadius: 2, |
| width: `${Math.round(result.trustScore * 100)}%`, |
| background: `linear-gradient(90deg, ${colorCfg.border}, ${colorCfg.border}88)`, |
| transition: 'width 600ms cubic-bezier(0.4,0,0.2,1)', |
| }} /> |
| </div> |
| <span style={{ color: '#94a3b8', fontSize: 11, fontVariantNumeric: 'tabular-nums' }}> |
| {Math.round(result.trustScore * 100)}% |
| </span> |
| </div> |
| |
| {/* Sources */} |
| {result.sources.length > 0 && ( |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> |
| {result.sources.slice(0, 3).map((url, i) => { |
| const domain = (() => { try { return new URL(url).hostname.replace('www.',''); } catch { return url; } })(); |
| return ( |
| <a key={i} href={url} target="_blank" rel="noreferrer" style={{ |
| display: 'flex', alignItems: 'center', gap: 6, |
| color: '#60a5fa', fontSize: 11, textDecoration: 'none', |
| padding: '3px 6px', borderRadius: 6, |
| background: 'rgba(96,165,250,0.06)', |
| transition: 'background 120ms', |
| }} |
| onMouseEnter={e => (e.currentTarget.style.background = 'rgba(96,165,250,0.12)')} |
| onMouseLeave={e => (e.currentTarget.style.background = 'rgba(96,165,250,0.06)')} |
| > |
| <img |
| src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`} |
| alt="" width={12} height={12} style={{ borderRadius: 2, flexShrink: 0 }} |
| onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} |
| /> |
| <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> |
| {domain} |
| </span> |
| </a> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
| |
| {/* Footer */} |
| <div style={{ |
| padding: '6px 14px 8px', |
| borderTop: '1px solid #1e293b', |
| display: 'flex', justifyContent: 'space-between', alignItems: 'center', |
| }}> |
| <span style={{ color: '#334155', fontSize: 10 }}>β‘ Fact Engine</span> |
| <span style={{ color: '#334155', fontSize: 10 }}> |
| {result.confidence}% confidence |
| </span> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function ConfidenceArc({ confidence, color }: { confidence: number; color: string }) { |
| const r = 13, cx = 18, cy = 18; |
| const circumference = 2 * Math.PI * r; |
| const dash = (confidence / 100) * circumference; |
| return ( |
| <svg width={36} height={36} style={{ flexShrink: 0, transform: 'rotate(-90deg)' }}> |
| <circle cx={cx} cy={cy} r={r} fill="none" stroke="#1e293b" strokeWidth={2.5} /> |
| <circle |
| cx={cx} cy={cy} r={r} fill="none" stroke={color} strokeWidth={2.5} |
| strokeDasharray={`${dash} ${circumference}`} |
| strokeLinecap="round" |
| style={{ transition: 'stroke-dasharray 600ms ease' }} |
| /> |
| <text |
| x={cx} y={cy + 1} |
| textAnchor="middle" dominantBaseline="middle" |
| fill={color} fontSize={8} fontWeight="700" |
| style={{ transform: `rotate(90deg)`, transformOrigin: `${cx}px ${cy}px`, fontFamily: 'monospace' }} |
| > |
| {confidence} |
| </text> |
| </svg> |
| ); |
| } |
|
|
| |
| let shadowHost: HTMLElement | null = null; |
| let shadowRoot: ShadowRoot | null = null; |
| let reactRoot: ReturnType<typeof createRoot> | null = null; |
| let activeCard: { dismiss: () => void } | null = null; |
|
|
| function ensureShadowHost() { |
| if (shadowHost && document.contains(shadowHost)) return; |
| shadowHost = document.createElement('fact-engine-overlay'); |
| shadowHost.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none;'; |
| document.body.appendChild(shadowHost); |
| shadowRoot = shadowHost.attachShadow({ mode: 'closed' }); |
| const mountPoint = document.createElement('div'); |
| mountPoint.style.pointerEvents = 'auto'; |
| shadowRoot.appendChild(mountPoint); |
| |
| const style = document.createElement('style'); |
| style.textContent = ` |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); |
| * { box-sizing: border-box; } |
| a { cursor: pointer; } |
| `; |
| shadowRoot.insertBefore(style, mountPoint); |
| reactRoot = createRoot(mountPoint); |
| } |
|
|
| function showCard(result: NonNullable<ReturnType<typeof resultCache.get>>, anchorRect: DOMRect) { |
| ensureShadowHost(); |
| const dismiss = () => { reactRoot?.render(null); activeCard = null; }; |
| activeCard?.dismiss(); |
| activeCard = { dismiss }; |
| reactRoot!.render( |
| <HoverCard result={result} anchorRect={anchorRect} onDismiss={dismiss} /> |
| ); |
| } |
|
|
| |
| function highlightRange(range: Range, color: string, hash: string) { |
| const colorCfg = COLOR_MAP[color]; |
| if (!colorCfg) return; |
| try { |
| const mark = document.createElement('mark'); |
| mark.dataset.factHash = hash; |
| mark.dataset.factColor = color; |
| mark.style.cssText = ` |
| background: ${colorCfg.bg} !important; |
| border-bottom: 1.5px solid ${colorCfg.border} !important; |
| border-radius: 2px !important; |
| cursor: pointer !important; |
| padding: 0 1px !important; |
| text-decoration: none !important; |
| `; |
| mark.addEventListener('mouseenter', (e) => { |
| const result = resultCache.get(hash); |
| if (result) showCard(result, (e.target as HTMLElement).getBoundingClientRect()); |
| }); |
| range.surroundContents(mark); |
| } catch (_) { |
| |
| } |
| } |
|
|
| |
| const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'SVG', 'NOSCRIPT', 'TEMPLATE', 'CODE', 'TEXTAREA']); |
| const MIN_WORDS = 12; |
|
|
| function extractTextNodes(root: Node): Text[] { |
| const results: Text[] = []; |
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { |
| acceptNode(node) { |
| const parent = node.parentElement; |
| if (!parent) return NodeFilter.FILTER_REJECT; |
| if (SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; |
| if (parent.dataset.factHash) return NodeFilter.FILTER_REJECT; |
| const text = node.textContent?.trim() || ''; |
| if (text.split(/\s+/).length < MIN_WORDS) return NodeFilter.FILTER_SKIP; |
| return NodeFilter.FILTER_ACCEPT; |
| }, |
| }); |
| let node: Node | null; |
| while ((node = walker.nextNode())) results.push(node as Text); |
| return results; |
| } |
|
|
| |
| export default defineContentScript({ |
| matches: [ |
| '*://*.twitter.com/*', |
| '*://*.x.com/*', |
| '*://*.instagram.com/*', |
| '*://*.youtube.com/*', |
| '*://chat.openai.com/*', |
| '*://claude.ai/*', |
| '*://gemini.google.com/*', |
| '*://*.reuters.com/*', |
| '*://*.bbc.com/*', |
| '*://*.apnews.com/*', |
| '<all_urls>', |
| ], |
| runAt: 'document_idle', |
|
|
| main() { |
| const platform = detectPlatform(); |
| const sentHashes = new Set<string>(); |
| |
| let pendingBatch: Array<{ text: string; hash: string }> = []; |
| let flushTimer: ReturnType<typeof setInterval>; |
| let enabled = true; |
| let mode = 'normal'; |
|
|
| |
| chrome.storage.sync.get(['fact-engine-store'], (data) => { |
| try { |
| const stored = JSON.parse(data['fact-engine-store'] || '{}'); |
| const state = stored.state || {}; |
| enabled = state.enabled ?? true; |
| mode = state.mode ?? 'normal'; |
| } catch (_) {} |
| }); |
|
|
| |
| chrome.storage.onChanged.addListener((changes) => { |
| if (changes['fact-engine-store']) { |
| try { |
| const state = JSON.parse(changes['fact-engine-store'].newValue || '{}').state || {}; |
| enabled = state.enabled ?? enabled; |
| mode = state.mode ?? mode; |
| } catch (_) {} |
| } |
| }); |
|
|
| |
| chrome.runtime.onMessage.addListener((msg) => { |
| if (msg.type === 'analysis_results') { |
| const results = msg.results as Array<{ |
| claim_hash: string; claim_text: string; color: string; |
| confidence: number; verdict: string; explanation: string; |
| sources: string[]; trust_score: number; |
| }>; |
| for (const r of results) { |
| resultCache.set(r.claim_hash, { |
| color: r.color, confidence: r.confidence, |
| verdict: r.verdict, explanation: r.explanation, |
| sources: r.sources, trustScore: r.trust_score, |
| }); |
| |
| if (enabled && shouldShowColor(r.color, mode)) { |
| applyHighlightsForHash(r.claim_hash, r.color); |
| } |
| } |
| } |
| }); |
|
|
| |
| const nodeRegistry = new Map<string, Range>(); |
|
|
| function applyHighlightsForHash(hash: string, color: string) { |
| const range = nodeRegistry.get(hash); |
| if (range) { |
| highlightRange(range, color, hash); |
| nodeRegistry.delete(hash); |
| } |
| |
| document.querySelectorAll(`[data-fact-pending="${hash}"]`).forEach((el) => { |
| (el as HTMLElement).style.borderBottom = `1.5px solid ${COLOR_MAP[color]?.border}`; |
| (el as HTMLElement).dataset.factColor = color; |
| }); |
| } |
|
|
| |
| function queueTextNode(node: Text) { |
| const text = node.textContent?.trim() || ''; |
| const hash = fastHash(text); |
| if (sentHashes.has(hash)) return; |
| sentHashes.add(hash); |
|
|
| |
| try { |
| const range = document.createRange(); |
| range.selectNodeContents(node); |
| nodeRegistry.set(hash, range); |
| } catch (_) {} |
|
|
| if (pendingBatch.length < 50) { |
| pendingBatch.push({ text, hash }); |
| } |
| } |
|
|
| |
| function flushBatch() { |
| if (!enabled || pendingBatch.length === 0) return; |
| const batch = pendingBatch.splice(0, 20); |
| chrome.runtime.sendMessage({ |
| type: 'analyze_claims', |
| claims: batch.map(b => b.text), |
| platform, |
| }).catch(() => {}); |
| } |
|
|
| |
| const observer = new MutationObserver((mutations) => { |
| if (!enabled) return; |
| for (const mutation of mutations) { |
| if (mutation.type === 'childList') { |
| for (const node of mutation.addedNodes) { |
| const textNodes = extractTextNodes(node); |
| textNodes.forEach(queueTextNode); |
| } |
| } |
| } |
| }); |
|
|
| observer.observe(document.body, { |
| childList: true, |
| subtree: true, |
| }); |
|
|
| |
| setTimeout(() => { |
| if (!enabled) return; |
| extractTextNodes(document.body).slice(0, 100).forEach(queueTextNode); |
| }, 1500); |
|
|
| |
| flushTimer = setInterval(flushBatch, 1200); |
| }, |
| }); |
|
|