| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { defineContentScript } from "wxt/sandbox"; |
| import { createRoot } from "react-dom/client"; |
| import React, { useEffect, useRef, useState } from "react"; |
| import { AnimatePresence, motion } from "framer-motion"; |
| import { init as initXxhash, h64ToString } from "xxhash-wasm"; |
|
|
| import { |
| AnalysisResult, |
| COLOR_CONFIG, |
| ExtensionMode, |
| HighlightColor, |
| shouldShowColor, |
| useExtensionStore, |
| } from "../stores/extensionStore"; |
|
|
| |
| |
| |
|
|
| function detectPlatform(): string { |
| const host = location.hostname; |
| if (host.includes("twitter.com") || host.includes("x.com")) return "twitter"; |
| 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")) return "gemini"; |
| return "news"; |
| } |
|
|
| |
| |
| |
|
|
| const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "SVG", "NOSCRIPT", "IFRAME", "META", "HEAD"]); |
|
|
| function isValidTextNode(node: Text): boolean { |
| const parent = node.parentElement; |
| if (!parent) return false; |
|
|
| |
| let el: Element | null = parent; |
| while (el) { |
| if (SKIP_TAGS.has(el.tagName)) return false; |
| el = el.parentElement; |
| } |
|
|
| const text = node.textContent?.trim() ?? ""; |
| const wordCount = text.split(/\s+/).filter(Boolean).length; |
| return wordCount >= 12; |
| } |
|
|
| function extractTextNodes(root: Node): Text[] { |
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { |
| acceptNode: (node) => |
| isValidTextNode(node as Text) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP, |
| }); |
| const nodes: Text[] = []; |
| while (walker.nextNode()) nodes.push(walker.currentNode as Text); |
| return nodes; |
| } |
|
|
| |
| |
| |
|
|
| interface QueuedSegment { |
| hash: string; |
| text: string; |
| node: Text; |
| elementId: string; |
| } |
|
|
| |
| |
| |
|
|
| const highlightMap = new Map<string, HTMLElement>(); |
|
|
| function applyHighlight( |
| node: Text, |
| elementId: string, |
| color: HighlightColor, |
| result: AnalysisResult |
| ): void { |
| |
| const existing = highlightMap.get(elementId); |
| if (existing) { |
| const cfg = COLOR_CONFIG[color]; |
| existing.style.backgroundColor = `${cfg.hex}${Math.round(cfg.opacity * 255).toString(16).padStart(2, "0")}`; |
| existing.dataset.result = JSON.stringify(result); |
| return; |
| } |
|
|
| try { |
| const range = document.createRange(); |
| range.selectNode(node); |
|
|
| const cfg = COLOR_CONFIG[color]; |
| const mark = document.createElement("mark"); |
| mark.dataset.factId = elementId; |
| mark.dataset.result = JSON.stringify(result); |
| mark.style.cssText = ` |
| background-color: ${cfg.hex}${Math.round(cfg.opacity * 255).toString(16).padStart(2, "0")}; |
| border-radius: 2px; |
| cursor: help; |
| transition: background-color 0.2s; |
| `; |
|
|
| range.surroundContents(mark); |
| highlightMap.set(elementId, mark); |
|
|
| |
| mark.addEventListener("mouseenter", (e) => showHoverCard(e, result, mark)); |
| mark.addEventListener("mouseleave", hideHoverCard); |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
|
|
| let hoverCardHost: HTMLElement | null = null; |
| let hoverRoot: ReturnType<typeof createRoot> | null = null; |
|
|
| function ensureHoverCardHost(): { host: HTMLElement; shadowRoot: ShadowRoot } { |
| if (!hoverCardHost) { |
| hoverCardHost = document.createElement("div"); |
| hoverCardHost.id = "fact-intelligence-hover-host"; |
| document.body.appendChild(hoverCardHost); |
|
|
| const shadow = hoverCardHost.attachShadow({ mode: "closed" }); |
|
|
| |
| const style = document.createElement("style"); |
| style.textContent = HOVER_CARD_STYLES; |
| shadow.appendChild(style); |
|
|
| const mountPoint = document.createElement("div"); |
| shadow.appendChild(mountPoint); |
| hoverRoot = createRoot(mountPoint); |
|
|
| return { host: hoverCardHost, shadowRoot: shadow }; |
| } |
| return { host: hoverCardHost, shadowRoot: hoverCardHost.shadowRoot! as ShadowRoot }; |
| } |
|
|
| function showHoverCard(event: MouseEvent, result: AnalysisResult, anchor: HTMLElement): void { |
| const { shadowRoot } = ensureHoverCardHost(); |
| const rect = anchor.getBoundingClientRect(); |
|
|
| |
| let top = rect.bottom + window.scrollY + 8; |
| let left = rect.left + window.scrollX; |
| const CARD_WIDTH = 340; |
| const CARD_HEIGHT = 200; |
|
|
| if (left + CARD_WIDTH > window.innerWidth - 16) { |
| left = window.innerWidth - CARD_WIDTH - 16; |
| } |
| if (top + CARD_HEIGHT > window.innerHeight + window.scrollY - 16) { |
| top = rect.top + window.scrollY - CARD_HEIGHT - 8; |
| } |
|
|
| hoverRoot?.render( |
| <HoverCard result={result} top={top} left={left} visible={true} /> |
| ); |
| } |
|
|
| function hideHoverCard(): void { |
| hoverRoot?.render(<HoverCard result={null} top={0} left={0} visible={false} />); |
| } |
|
|
| |
| |
| |
|
|
| interface HoverCardProps { |
| result: AnalysisResult | null; |
| top: number; |
| left: number; |
| visible: boolean; |
| } |
|
|
| function HoverCard({ result, top, left, visible }: HoverCardProps) { |
| if (!result) return null; |
| const cfg = COLOR_CONFIG[result.color as HighlightColor] ?? COLOR_CONFIG.yellow; |
|
|
| return ( |
| <AnimatePresence> |
| {visible && ( |
| <motion.div |
| className="card" |
| style={{ top, left, "--accent": cfg.hex } as React.CSSProperties} |
| initial={{ opacity: 0, y: 6, scale: 0.97 }} |
| animate={{ opacity: 1, y: 0, scale: 1 }} |
| exit={{ opacity: 0, y: 4, scale: 0.97 }} |
| transition={{ duration: 0.18, ease: "easeOut" }} |
| > |
| {/* Header row */} |
| <div className="header"> |
| <div className="badge">{cfg.icon} {cfg.label}</div> |
| <div className="conf"> |
| <svg width="36" height="36" viewBox="0 0 36 36"> |
| <circle cx="18" cy="18" r="14" fill="none" stroke="#333" strokeWidth="3"/> |
| <circle |
| cx="18" cy="18" r="14" |
| fill="none" |
| stroke={cfg.hex} |
| strokeWidth="3" |
| strokeLinecap="round" |
| strokeDasharray={`${2 * Math.PI * 14}`} |
| strokeDashoffset={`${2 * Math.PI * 14 * (1 - result.confidence / 100)}`} |
| transform="rotate(-90 18 18)" |
| /> |
| <text x="18" y="22" textAnchor="middle" fontSize="10" fill={cfg.hex} fontWeight="bold"> |
| {result.confidence} |
| </text> |
| </svg> |
| </div> |
| </div> |
| |
| {/* Verdict */} |
| <div className="verdict">{result.verdict_label}</div> |
| <div className="explanation">{result.explanation}</div> |
| |
| {/* Sources */} |
| {result.sources?.length > 0 && ( |
| <div className="sources"> |
| {result.sources.slice(0, 3).map((s, i) => ( |
| <a key={i} className="source" href={s.url} target="_blank" rel="noopener"> |
| <img src={s.favicon_url} width="12" height="12" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> |
| <span>{s.domain}</span> |
| </a> |
| ))} |
| </div> |
| )} |
| |
| {/* Footer meta */} |
| <div className="meta"> |
| <span>trust {(result.trust_score * 100).toFixed(0)}%</span> |
| <span>Β·</span> |
| <span>{result.latency_ms?.toFixed(0)}ms</span> |
| {result.cached && <><span>Β·</span><span>cached</span></>} |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| ); |
| } |
|
|
| |
| const HOVER_CARD_STYLES = ` |
| .card { |
| position: fixed; |
| z-index: 2147483647; |
| width: 340px; |
| background: #0d1117; |
| border: 1px solid #21262d; |
| border-radius: 10px; |
| padding: 14px; |
| box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04); |
| font-family: -apple-system, 'DM Sans', system-ui, sans-serif; |
| font-size: 13px; |
| color: #e6edf3; |
| pointer-events: none; |
| } |
| .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } |
| .badge { |
| display: inline-flex; align-items: center; gap: 5px; |
| padding: 3px 10px; border-radius: 20px; font-size: 10px; |
| font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase; |
| background: color-mix(in srgb, var(--accent) 15%, transparent); |
| color: var(--accent); |
| border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); |
| } |
| .conf { flex-shrink: 0; } |
| .verdict { font-weight: 700; font-size: 14px; margin-bottom: 6px; line-height: 1.3; } |
| .explanation { color: #7d8590; font-size: 12px; line-height: 1.6; margin-bottom: 10px; } |
| .sources { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; } |
| .source { |
| display: flex; align-items: center; gap: 6px; |
| padding: 5px 8px; background: #161b22; border-radius: 5px; |
| color: #58a6ff; text-decoration: none; font-size: 11px; |
| pointer-events: all; |
| } |
| .meta { |
| display: flex; gap: 6px; font-size: 10px; color: #484f58; |
| font-family: 'Space Mono', monospace; letter-spacing: 0.3px; |
| } |
| `; |
|
|
| |
| |
| |
|
|
| export default defineContentScript({ |
| matches: [ |
| "https://twitter.com/*", "https://x.com/*", |
| "https://www.instagram.com/*", "https://www.youtube.com/*", |
| "https://chat.openai.com/*", "https://claude.ai/*", |
| "https://gemini.google.com/*", "<all_urls>", |
| ], |
| runAt: "document_idle", |
| main: async () => { |
| const platform = detectPlatform(); |
|
|
| |
| const { h64ToString: xxhash64 } = await initXxhash(); |
|
|
| const SESSION_ID = crypto.randomUUID(); |
| const seenHashes = new Set<string>(); |
|
|
| |
| const flushBuffer: Map<string, QueuedSegment> = new Map(); |
| let flushTimer: ReturnType<typeof setTimeout> | null = null; |
|
|
| const { enabled, mode } = useExtensionStore.getState(); |
| if (!enabled) return; |
|
|
| function queueSegment(node: Text): void { |
| const text = node.textContent?.trim() ?? ""; |
| if (!text) return; |
|
|
| const hash = xxhash64(text); |
| if (seenHashes.has(hash)) return; |
|
|
| const elementId = `fi-${hash.slice(0, 8)}-${Date.now()}`; |
| flushBuffer.set(hash, { hash, text, node, elementId }); |
|
|
| |
| if (!flushTimer) { |
| flushTimer = setTimeout(flushSegments, 1200); |
| } |
| } |
|
|
| async function flushSegments(): void { |
| flushTimer = null; |
| if (flushBuffer.size === 0) return; |
|
|
| const { enabled, mode } = useExtensionStore.getState(); |
| if (!enabled) return; |
|
|
| const segments = Array.from(flushBuffer.values()).map((s) => { |
| seenHashes.add(s.hash); |
|
|
| |
| if (seenHashes.size > 5000) { |
| const arr = Array.from(seenHashes); |
| arr.slice(0, 2500).forEach((h) => seenHashes.delete(h)); |
| } |
|
|
| return { |
| content_hash: s.hash, |
| text: s.text, |
| element_id: s.elementId, |
| word_count: s.text.split(/\s+/).length, |
| }; |
| }); |
|
|
| flushBuffer.clear(); |
|
|
| const batch = { |
| session_id: SESSION_ID, |
| platform, |
| segments, |
| sent_at: new Date().toISOString(), |
| }; |
|
|
| |
| chrome.runtime.sendMessage({ type: "send_batch", payload: batch }); |
| } |
|
|
| |
| |
| |
|
|
| const observer = new MutationObserver((mutations) => { |
| const { enabled } = useExtensionStore.getState(); |
| if (!enabled) return; |
|
|
| for (const mutation of mutations) { |
| if (mutation.type === "childList") { |
| mutation.addedNodes.forEach((node) => { |
| const textNodes = extractTextNodes(node); |
| textNodes.forEach(queueSegment); |
| }); |
| } else if (mutation.type === "characterData") { |
| const node = mutation.target as Text; |
| if (isValidTextNode(node)) queueSegment(node); |
| } |
| } |
| }); |
|
|
| observer.observe(document.body, { |
| childList: true, |
| subtree: true, |
| characterData: true, |
| }); |
|
|
| |
| extractTextNodes(document.body).forEach(queueSegment); |
|
|
| |
| |
| |
|
|
| chrome.runtime.onMessage.addListener((msg) => { |
| if (msg.type === "result" && msg.payload) { |
| const result = msg.payload as AnalysisResult; |
| const { mode } = useExtensionStore.getState(); |
| const color = result.color as HighlightColor; |
|
|
| if (!shouldShowColor(color, mode)) return; |
|
|
| |
| |
| |
| const targetNode = findNodeByHash(result.content_hash); |
| if (targetNode) { |
| applyHighlight(targetNode, result.element_id, color, result); |
| } |
| } |
|
|
| if (msg.type === "ws_status") { |
| useExtensionStore.getState().setWsStatus(msg.payload.status); |
| } |
| }); |
| }, |
| }); |
|
|
| |
| const nodeRegistry = new Map<string, Text>(); |
|
|
| |
| |
| function findNodeByHash(hash: string): Text | undefined { |
| return nodeRegistry.get(hash); |
| } |
|
|