Spaces:
Running
Running
Ryan Christian D. Deniega
Fix: Overhaul Facebook post detection to filtered comments using [role='feed'] strategy and improve button positioning
7e55328 | /** | |
| * PhilVerify β Content Script (Twitter/X + Facebook feed scanner) | |
| * | |
| * Click-triggered verification model: | |
| * 1. Watches for posts via MutationObserver (infinite scroll support) | |
| * 2. Injects a floating "Verify this post" button on each post that has content | |
| * 3. On click: extracts caption + image, sends to background.js β PhilVerify API | |
| * 4. Displays a full inline verification report (verdict, confidence, evidence, etc.) | |
| * | |
| * Skips posts with no text AND no image. Never injects on comments. | |
| * Uses `data-philverify-btn` attribute to prevent duplicate buttons. | |
| */ | |
| ; (function philverifyContentScript() { | |
| 'use strict' | |
| // ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Minimum text length to send for verification (avoids verifying 1-word posts) */ | |
| const MIN_TEXT_LENGTH = 40 | |
| /** Minimum image dimension (px) to consider a real content image (filters avatars/icons) */ | |
| const MIN_IMAGE_SIZE = 100 | |
| /** Enable debug logging to console */ | |
| const DEBUG = true | |
| function log(...args) { if (DEBUG) console.log('[PhilVerify]', ...args) } | |
| function warn(...args) { if (DEBUG) console.warn('[PhilVerify]', ...args) } | |
| // ββ Platform detection ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const PLATFORM = (() => { | |
| const h = window.location.hostname | |
| if (h.includes('facebook.com')) return 'facebook' | |
| if (h.includes('x.com') || h.includes('twitter.com')) return 'twitter' | |
| return 'news' | |
| })() | |
| const IS_SOCIAL = PLATFORM === 'facebook' || PLATFORM === 'twitter' | |
| // ββ Platform-specific selectors βββββββββββββββββββββββββββββββββββββββββββ | |
| // Stored in a single config object for easy maintenance when platforms | |
| // update their DOM. Prefer data-testid / role attributes over class names. | |
| const PLATFORM_CFG = { | |
| facebook: { | |
| // Facebook's DOM is intentionally unstable. The most reliable anchor is | |
| // [role="feed"] β the WAI-ARIA feed landmark required for accessibility. | |
| // Direct children of the feed container are always posts (wrapped in divs), | |
| // while comments are nested deeper inside each post's [role="article"]. | |
| // | |
| // We do NOT rely on data-pagelet attributes β Facebook removed/renamed them. | |
| // [role="article"] is used as a last-resort fallback with extra filtering. | |
| post: [ | |
| '[role="article"]', // Filtered by findPosts() β only feed-level articles | |
| ], | |
| // Text selectors ordered by specificity | |
| text: [ | |
| '[data-ad-comet-preview="message"]', | |
| '[data-testid="post_message"]', | |
| ], | |
| // Exclude avatars explicitly: fbcdn images that are NOT inside avatar | |
| // containers, and are large enough to be actual post content. | |
| image: 'img[src*="fbcdn"]', | |
| // Selectors for containers known to hold avatar images β used to filter them out | |
| avatarContainers: [ | |
| '[data-testid="profile_photo_image"]', | |
| 'a[aria-label*="profile picture"]', | |
| 'svg image', // avatar circles rendered in SVG | |
| '[role="img"][aria-label]', // decorative profile icons | |
| ], | |
| link: ['a[href*="l.facebook.com/l.php"]', 'a[role="link"][href*="http"]'], | |
| unwrapUrl(el) { | |
| try { return new URL(el.href).searchParams.get('u') || el.href } catch { return el.href } | |
| }, | |
| }, | |
| twitter: { | |
| post: ['article[data-testid="tweet"]'], | |
| text: ['[data-testid="tweetText"]'], | |
| // pbs.twimg.com/media is specifically for tweet media, NOT profile avatars | |
| // (avatars use pbs.twimg.com/profile_images) | |
| image: 'img[src*="pbs.twimg.com/media"]', | |
| avatarContainers: [ | |
| '[data-testid="Tweet-User-Avatar"]', | |
| ], | |
| link: ['a[href*="t.co/"]', 'a[data-testid="card.layoutSmall.media"]'], | |
| unwrapUrl(el) { return el.href }, | |
| }, | |
| news: { | |
| post: ['[role="article"]', 'article', 'main'], | |
| text: ['h1', '.article-body', '.entry-content', 'article'], | |
| image: null, | |
| avatarContainers: [], | |
| link: [], | |
| unwrapUrl(el) { return el.href }, | |
| }, | |
| } | |
| const CFG = PLATFORM_CFG[PLATFORM] ?? PLATFORM_CFG.facebook | |
| const POST_SELECTORS = CFG.post | |
| // ββ Verdict colors & labels βββββββββββββββββββββββββββββββββββββββββββββββ | |
| const VERDICT_COLORS = { | |
| 'Credible': '#16a34a', | |
| 'Unverified': '#d97706', | |
| 'Likely Fake': '#dc2626', | |
| } | |
| const VERDICT_LABELS = { | |
| 'Credible': 'β Credible', | |
| 'Unverified': '? Unverified', | |
| 'Likely Fake': 'β Likely Fake', | |
| } | |
| const VERDICT_BG = { | |
| 'Credible': 'rgba(22, 163, 74, 0.12)', | |
| 'Unverified': 'rgba(217, 119, 6, 0.12)', | |
| 'Likely Fake': 'rgba(220, 38, 38, 0.12)', | |
| } | |
| // ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Escape HTML special chars to prevent XSS */ | |
| function safeText(str) { | |
| if (str == null) return '' | |
| return String(str) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, ''') | |
| } | |
| /** Allow only http/https URLs; return '#' for anything else */ | |
| function safeUrl(url) { | |
| if (!url) return '#' | |
| try { | |
| const u = new URL(url) | |
| return (u.protocol === 'http:' || u.protocol === 'https:') ? u.href : '#' | |
| } catch { return '#' } | |
| } | |
| // ββ Content extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Try to expand Facebook's "See more" truncation before extracting text. | |
| * Clicks the "See more" link if found. Only targets known truncation patterns. | |
| */ | |
| function expandSeeMore(post) { | |
| if (PLATFORM === 'facebook') { | |
| // Facebook uses div[role="button"] or <span> with "See more" / "See More" | |
| const buttons = post.querySelectorAll('[role="button"]') | |
| for (const btn of buttons) { | |
| const txt = btn.textContent?.trim() | |
| if (txt && /^see\s*more$/i.test(txt) && btn.offsetHeight < 30) { | |
| try { | |
| btn.click() | |
| log('Expanded "See more" on Facebook post') | |
| } catch (e) { | |
| warn('Failed to expand "See more":', e) | |
| } | |
| return | |
| } | |
| } | |
| } | |
| if (PLATFORM === 'twitter') { | |
| // Twitter uses a "Show more" link inside truncated tweets | |
| const showMore = post.querySelector('[data-testid="tweet-text-show-more-link"]') | |
| if (showMore) { | |
| try { | |
| showMore.click() | |
| log('Expanded "Show more" on Twitter post') | |
| } catch (e) { | |
| warn('Failed to expand Twitter "Show more":', e) | |
| } | |
| } | |
| } | |
| } | |
| function extractPostText(post) { | |
| expandSeeMore(post) | |
| // Primary selectors β platform-specific, high confidence | |
| for (const sel of CFG.text) { | |
| const el = post.querySelector(sel) | |
| if (el?.innerText?.trim().length >= MIN_TEXT_LENGTH) { | |
| log('Text extracted via primary selector:', sel) | |
| return el.innerText.trim().slice(0, 2000) | |
| } | |
| } | |
| // Facebook fallback: look for [dir="auto"] ONLY inside known message containers | |
| if (PLATFORM === 'facebook') { | |
| const messageContainers = post.querySelectorAll( | |
| '[data-ad-comet-preview="message"] [dir="auto"], [data-testid="post_message"] [dir="auto"]' | |
| ) | |
| for (const el of messageContainers) { | |
| const t = el.innerText?.trim() | |
| if (t && t.length >= MIN_TEXT_LENGTH) { | |
| log('Text extracted via scoped [dir="auto"] fallback') | |
| return t.slice(0, 2000) | |
| } | |
| } | |
| // Last resort: standalone [dir="auto"] with substantial text, | |
| // excluding comments, headers, and nav elements | |
| for (const el of post.querySelectorAll('[dir="auto"]')) { | |
| if (el.closest('[role="navigation"]') || el.closest('header') || el.closest('[data-testid="UFI2Comment"]')) continue | |
| // Also skip if inside a nested comment article | |
| const parentArticle = el.closest('[role="article"]') | |
| if (parentArticle && parentArticle !== post) continue | |
| const t = el.innerText?.trim() | |
| if (t && t.length >= MIN_TEXT_LENGTH && !t.startsWith('http')) { | |
| log('Text extracted via broad [dir="auto"] fallback (filtered)') | |
| return t.slice(0, 2000) | |
| } | |
| } | |
| } | |
| // General fallback: any span with substantial text | |
| for (const span of post.querySelectorAll('span')) { | |
| const t = span.innerText?.trim() | |
| if (t && t.length >= MIN_TEXT_LENGTH && !t.startsWith('http')) { | |
| // Skip if inside a nested comment article | |
| const parentArticle = span.closest('[role="article"]') | |
| if (parentArticle && parentArticle !== post) continue | |
| log('Text extracted via span fallback') | |
| return t.slice(0, 2000) | |
| } | |
| } | |
| log('No text found in post') | |
| return null | |
| } | |
| function extractPostUrl(post) { | |
| for (const sel of (CFG.link ?? [])) { | |
| const el = post.querySelector(sel) | |
| if (el?.href) return CFG.unwrapUrl(el) | |
| } | |
| return null | |
| } | |
| /** | |
| * Returns the src of the most prominent content image in a post, or null. | |
| * Filters out avatars, icons, emoji, and tracking pixels by: | |
| * 1. Excluding images inside known avatar containers | |
| * 2. Requiring a minimum rendered dimension (MIN_IMAGE_SIZE) | |
| * 3. Preferring the largest image by naturalWidth | |
| */ | |
| function extractPostImage(post) { | |
| if (!CFG.image) return null | |
| const allImgs = Array.from(post.querySelectorAll(CFG.image)) | |
| if (!allImgs.length) { log('No candidate images found'); return null } | |
| // Build a set of avatar container elements to check ancestry against | |
| const avatarContainers = (CFG.avatarContainers ?? []).flatMap(sel => | |
| Array.from(post.querySelectorAll(sel)) | |
| ) | |
| const contentImgs = allImgs.filter(img => { | |
| // Exclude images nested inside avatar containers | |
| const isAvatar = avatarContainers.some(container => container.contains(img)) | |
| if (isAvatar) return false | |
| // Exclude tiny images (avatars are typically 36-48px; icons < 24px) | |
| const w = img.naturalWidth || img.width || 0 | |
| const h = img.naturalHeight || img.height || 0 | |
| if (w < MIN_IMAGE_SIZE && h < MIN_IMAGE_SIZE) return false | |
| // Exclude common avatar URL patterns | |
| const src = img.src || '' | |
| if (PLATFORM === 'facebook' && /\/p\d+x\d+\//.test(src)) return false | |
| if (PLATFORM === 'twitter' && src.includes('profile_images')) return false | |
| return true | |
| }) | |
| if (!contentImgs.length) { log('All images filtered out (avatars/icons)'); return null } | |
| // Pick the largest content image (best representative for carousel posts) | |
| const best = contentImgs.reduce((a, b) => | |
| (b.naturalWidth || b.width || 0) > (a.naturalWidth || a.width || 0) ? b : a | |
| ) | |
| const src = best.src || best.dataset?.src | |
| if (!src || !src.startsWith('http')) return null | |
| log('Image extracted:', src.slice(0, 80) + 'β¦') | |
| return src | |
| } | |
| // ββ Post discovery (feed-based strategy) βββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Checks if a [role="article"] element is a top-level post (not a comment). | |
| * | |
| * PRIMARY STRATEGY: Use [role="feed"] as the anchor. | |
| * - [role="feed"] is a WAI-ARIA landmark that Facebook keeps for accessibility. | |
| * - Direct children of the feed are always posts (wrapped in <div> containers). | |
| * - Comments are always deeper nested inside another [role="article"]. | |
| * | |
| * This function checks: | |
| * 1. Is this article a direct descendant of [role="feed"]? β It's a post. | |
| * 2. Is this article nested inside another article? β It's a comment. | |
| * 3. Neither? Use URL-based heuristic for detail pages. | |
| */ | |
| function isTopLevelPost(el) { | |
| if (PLATFORM !== 'facebook') return true | |
| if (el.getAttribute('role') !== 'article') return true | |
| // ββ Check 1: Is this article nested inside another article? | |
| // If yes, it's definitely a comment (true for both feed and detail pages). | |
| const parentArticle = el.parentElement?.closest('[role="article"]') | |
| if (parentArticle) { | |
| log('Skipping comment (nested inside parent article)') | |
| return false | |
| } | |
| // ββ Check 2: Is this article a child of [role="feed"]? | |
| // Direct children of the feed are always posts. | |
| const feedAncestor = el.closest('[role="feed"]') | |
| if (feedAncestor) { | |
| // This article is inside the feed and NOT nested in another article β post | |
| return true | |
| } | |
| // ββ Check 3: Not in a feed β could be a detail page. | |
| // On detail pages (e.g. /posts/123, /permalink/, /photo/), | |
| // the FIRST [role="article"] on the page is the main post. | |
| // All subsequent ones are comments. | |
| const path = window.location.pathname + window.location.search | |
| const isDetailPage = /\/(posts|photos|permalink|story\.php|watch|reel|videos)/.test(path) | |
| if (isDetailPage) { | |
| const allArticles = document.querySelectorAll('[role="article"]') | |
| if (allArticles.length > 0 && allArticles[0] === el) { | |
| // First article on a detail page β the main post | |
| return true | |
| } | |
| // Not the first article on a detail page β comment | |
| log('Skipping comment (detail page, not the first article)') | |
| return false | |
| } | |
| // ββ Fallback: Allow it (could be a page layout we haven't seen) | |
| // Better to show a button on something unexpected than miss a real post. | |
| return true | |
| } | |
| /** | |
| * Find posts in the given DOM subtree. | |
| * | |
| * Two-pass strategy for Facebook: | |
| * Pass 1: Find [role="feed"] container β get [role="article"] elements | |
| * that are direct children of the feed (not nested in other articles) | |
| * Pass 2: If no feed found (detail pages, etc.), fall back to all | |
| * [role="article"] elements filtered by isTopLevelPost() | |
| * | |
| * For Twitter and other platforms, uses POST_SELECTORS directly. | |
| */ | |
| function findPosts(root) { | |
| if (PLATFORM === 'facebook') { | |
| // ββ Pass 1: Feed-based detection (most reliable) | |
| const feeds = root.querySelectorAll('[role="feed"]') | |
| if (feeds.length === 0 && root.getAttribute?.('role') === 'feed') { | |
| // root itself might be the feed | |
| const articles = Array.from(root.querySelectorAll('[role="article"]')) | |
| .filter(el => !el.parentElement?.closest('[role="article"]')) | |
| if (articles.length) { | |
| log(`Found ${articles.length} posts via feed (root is feed)`) | |
| return articles | |
| } | |
| } | |
| for (const feed of feeds) { | |
| // Get all articles inside this feed that are NOT nested in another article | |
| const articles = Array.from(feed.querySelectorAll('[role="article"]')) | |
| .filter(el => !el.parentElement?.closest('[role="article"]')) | |
| if (articles.length) { | |
| log(`Found ${articles.length} posts via [role="feed"] container`) | |
| return articles | |
| } | |
| } | |
| // ββ Pass 2: No feed container found β detail page or unusual layout | |
| const allArticles = Array.from(root.querySelectorAll('[role="article"]')) | |
| const topLevel = allArticles.filter(el => isTopLevelPost(el)) | |
| if (topLevel.length) { | |
| log(`Found ${topLevel.length} posts via fallback (no feed container)`) | |
| return topLevel | |
| } | |
| return [] | |
| } | |
| // Non-Facebook platforms: simple selector matching | |
| for (const sel of POST_SELECTORS) { | |
| const found = Array.from(root.querySelectorAll(sel)) | |
| if (found.length) return found | |
| } | |
| return [] | |
| } | |
| // ββ "Verify this post" button injection βββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Creates and injects a floating "Verify this post" button on a post. | |
| * The button is absolutely positioned at the bottom-right of the post, | |
| * above the action bar (like/comment/share). | |
| */ | |
| function injectVerifyButton(post) { | |
| // Prevent duplicate injection | |
| if (post.dataset.philverifyBtn) return | |
| post.dataset.philverifyBtn = 'true' | |
| // Note: We do NOT gate on content availability here. | |
| // Facebook lazy-loads post content via React hydration, so text/images | |
| // may not be in the DOM yet when this runs. Content is checked at click | |
| // time (in handleVerifyClick) when everything is fully rendered. | |
| // Create wrapper (flex container for right-alignment) | |
| const wrapper = document.createElement('div') | |
| wrapper.className = 'pv-verify-btn-wrapper' | |
| // Create the button | |
| const btn = document.createElement('button') | |
| btn.className = 'pv-verify-btn' | |
| btn.setAttribute('type', 'button') | |
| btn.setAttribute('aria-label', 'Verify this post with PhilVerify') | |
| // Button content using createElement (no innerHTML for XSS safety) | |
| const icon = document.createElement('span') | |
| icon.className = 'pv-verify-btn-icon' | |
| icon.textContent = 'π‘οΈ' | |
| icon.setAttribute('aria-hidden', 'true') | |
| const label = document.createElement('span') | |
| label.className = 'pv-verify-btn-label' | |
| label.textContent = 'Verify this post' | |
| btn.appendChild(icon) | |
| btn.appendChild(label) | |
| // Click handler β extract content, call API, show report | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation() | |
| e.preventDefault() | |
| handleVerifyClick(post, btn) | |
| }) | |
| wrapper.appendChild(btn) | |
| // Insert the wrapper inline in the post. | |
| // Strategy: Find a good insertion point near the bottom of the | |
| // visible post content, but BEFORE the comments section. | |
| // On Facebook, we look for the action bar area or similar landmarks. | |
| let inserted = false | |
| if (PLATFORM === 'facebook') { | |
| // Try to insert after the action bar (Like/Comment/Share row) | |
| const actionBar = post.querySelector('[role="toolbar"]') || | |
| post.querySelector('[aria-label*="Like"]')?.closest('div:not([role="article"])') | |
| if (actionBar?.parentElement) { | |
| actionBar.parentElement.insertBefore(wrapper, actionBar.nextSibling) | |
| inserted = true | |
| } | |
| } | |
| // Fallback: just append to the post (works for Twitter and other platforms) | |
| if (!inserted) { | |
| post.appendChild(wrapper) | |
| } | |
| log('Verify button injected on post') | |
| } | |
| // ββ Verify click handler ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function handleVerifyClick(post, btn) { | |
| // Disable button and show loading state | |
| btn.disabled = true | |
| btn.classList.add('pv-verify-btn--loading') | |
| const origIcon = btn.querySelector('.pv-verify-btn-icon') | |
| const origLabel = btn.querySelector('.pv-verify-btn-label') | |
| if (origIcon) origIcon.textContent = '' | |
| if (origLabel) origLabel.textContent = 'Analyzingβ¦' | |
| // Add spinner | |
| const spinner = document.createElement('span') | |
| spinner.className = 'pv-spinner' | |
| spinner.setAttribute('aria-hidden', 'true') | |
| btn.insertBefore(spinner, btn.firstChild) | |
| // Extract content | |
| const text = extractPostText(post) | |
| const url = extractPostUrl(post) | |
| const image = extractPostImage(post) | |
| log(`Verify clicked: text=${!!text} (${text?.length ?? 0} chars), url=${!!url}, image=${!!image}`) | |
| // Determine what to send | |
| let inputSummary = '' | |
| if (!text && !url && !image) { | |
| showErrorReport(post, btn, 'Could not read post content β no text or image found.') | |
| return | |
| } | |
| try { | |
| let msgPayload | |
| if (url) { | |
| msgPayload = { type: 'VERIFY_URL', url } | |
| inputSummary = 'Shared link analyzed' | |
| } else if (text && image) { | |
| msgPayload = { type: 'VERIFY_TEXT', text, imageUrl: image } | |
| inputSummary = 'Caption + image analyzed' | |
| } else if (text) { | |
| msgPayload = { type: 'VERIFY_TEXT', text } | |
| inputSummary = 'Caption text only' | |
| } else { | |
| msgPayload = { type: 'VERIFY_IMAGE_URL', imageUrl: image } | |
| inputSummary = 'Image only (OCR)' | |
| } | |
| const response = await new Promise((resolve, reject) => { | |
| chrome.runtime.sendMessage(msgPayload, (resp) => { | |
| if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)) | |
| else if (!resp?.ok) reject(new Error(resp?.error ?? 'Unknown error')) | |
| else resolve(resp.result) | |
| }) | |
| }) | |
| log(`Verification result: verdict=${response.verdict}, score=${response.final_score}`) | |
| showVerificationReport(post, btn, response, inputSummary) | |
| } catch (err) { | |
| warn('Verification failed:', err.message) | |
| showErrorReport(post, btn, err.message) | |
| } | |
| } | |
| // ββ Verification report rendering βββββββββββββββββββββββββββββββββββββββββ | |
| function showVerificationReport(post, btn, result, inputSummary) { | |
| // Remove the button | |
| btn.remove() | |
| // Remove any existing report on this post | |
| const existing = post.querySelector('.pv-report') | |
| if (existing) existing.remove() | |
| const verdict = result.verdict ?? 'Unknown' | |
| const color = VERDICT_COLORS[verdict] ?? '#5c554e' | |
| const bg = VERDICT_BG[verdict] ?? 'rgba(92, 85, 78, 0.12)' | |
| const label = VERDICT_LABELS[verdict] ?? verdict | |
| const score = Math.round(result.final_score ?? 0) | |
| const confidence = result.confidence?.toFixed(1) ?? 'β' | |
| const language = result.language ?? 'β' | |
| const sources = result.layer2?.sources ?? [] | |
| const features = result.layer1?.triggered_features ?? [] | |
| const cached = result._fromCache ? ' Β· cached' : '' | |
| // Build report using createElement (no innerHTML for XSS safety) | |
| const report = document.createElement('div') | |
| report.className = 'pv-report' | |
| report.setAttribute('role', 'region') | |
| report.setAttribute('aria-label', 'PhilVerify fact-check report') | |
| // β Header row | |
| const header = document.createElement('div') | |
| header.className = 'pv-report-header' | |
| const logo = document.createElement('span') | |
| logo.className = 'pv-report-logo' | |
| logo.innerHTML = 'PHIL<span style="color:#dc2626">VERIFY</span>' | |
| const closeBtn = document.createElement('button') | |
| closeBtn.className = 'pv-report-close' | |
| closeBtn.textContent = 'β' | |
| closeBtn.setAttribute('aria-label', 'Close fact-check report') | |
| closeBtn.addEventListener('click', (e) => { | |
| e.stopPropagation() | |
| report.remove() | |
| // Re-inject the verify button so user can re-verify | |
| delete post.dataset.philverifyBtn | |
| injectVerifyButton(post) | |
| }) | |
| header.appendChild(logo) | |
| header.appendChild(closeBtn) | |
| report.appendChild(header) | |
| // β Verdict row (large, prominent) | |
| const verdictRow = document.createElement('div') | |
| verdictRow.className = 'pv-report-verdict-row' | |
| verdictRow.style.borderLeftColor = color | |
| const verdictLabel = document.createElement('div') | |
| verdictLabel.className = 'pv-report-verdict' | |
| verdictLabel.style.color = color | |
| verdictLabel.textContent = label | |
| const scoreText = document.createElement('div') | |
| scoreText.className = 'pv-report-score-text' | |
| scoreText.textContent = `${score}% credibility${cached}` | |
| verdictRow.appendChild(verdictLabel) | |
| verdictRow.appendChild(scoreText) | |
| report.appendChild(verdictRow) | |
| // β Confidence bar | |
| const barWrap = document.createElement('div') | |
| barWrap.className = 'pv-confidence-bar-wrap' | |
| const barLabel = document.createElement('span') | |
| barLabel.className = 'pv-report-label' | |
| barLabel.textContent = 'CONFIDENCE' | |
| const barTrack = document.createElement('div') | |
| barTrack.className = 'pv-confidence-bar-track' | |
| const barFill = document.createElement('div') | |
| barFill.className = 'pv-confidence-bar-fill' | |
| barFill.style.width = `${Math.min(score, 100)}%` | |
| barFill.style.background = color | |
| const barValue = document.createElement('span') | |
| barValue.className = 'pv-confidence-bar-value' | |
| barValue.textContent = `${confidence}%` | |
| barTrack.appendChild(barFill) | |
| barWrap.appendChild(barLabel) | |
| barWrap.appendChild(barTrack) | |
| barWrap.appendChild(barValue) | |
| report.appendChild(barWrap) | |
| // β Info rows (Language, Input) | |
| const addInfoRow = (labelText, valueText) => { | |
| const row = document.createElement('div') | |
| row.className = 'pv-report-row' | |
| const lbl = document.createElement('span') | |
| lbl.className = 'pv-report-label' | |
| lbl.textContent = labelText | |
| const val = document.createElement('span') | |
| val.className = 'pv-report-value' | |
| val.textContent = valueText | |
| row.appendChild(lbl) | |
| row.appendChild(val) | |
| report.appendChild(row) | |
| } | |
| addInfoRow('LANGUAGE', safeText(language)) | |
| addInfoRow('INPUT', safeText(inputSummary)) | |
| // β Triggered signals/features | |
| if (features.length > 0) { | |
| const signalsSection = document.createElement('div') | |
| signalsSection.className = 'pv-report-signals' | |
| const signalsLabel = document.createElement('span') | |
| signalsLabel.className = 'pv-report-label' | |
| signalsLabel.textContent = 'SUSPICIOUS SIGNALS' | |
| signalsSection.appendChild(signalsLabel) | |
| const tagsWrap = document.createElement('div') | |
| tagsWrap.className = 'pv-report-tags' | |
| for (const f of features.slice(0, 5)) { | |
| const tag = document.createElement('span') | |
| tag.className = 'pv-report-tag' | |
| tag.textContent = f | |
| tagsWrap.appendChild(tag) | |
| } | |
| signalsSection.appendChild(tagsWrap) | |
| report.appendChild(signalsSection) | |
| } | |
| // β Evidence sources | |
| if (sources.length > 0) { | |
| const sourcesSection = document.createElement('div') | |
| sourcesSection.className = 'pv-report-sources' | |
| const sourcesLabel = document.createElement('span') | |
| sourcesLabel.className = 'pv-report-label' | |
| sourcesLabel.textContent = 'EVIDENCE SOURCES' | |
| sourcesSection.appendChild(sourcesLabel) | |
| const sourcesList = document.createElement('ul') | |
| sourcesList.className = 'pv-report-sources-list' | |
| for (const src of sources.slice(0, 5)) { | |
| const li = document.createElement('li') | |
| li.className = 'pv-report-source-item' | |
| const link = document.createElement('a') | |
| link.href = safeUrl(src.url) | |
| link.target = '_blank' | |
| link.rel = 'noreferrer' | |
| link.className = 'pv-report-source-link' | |
| link.textContent = src.title?.slice(0, 60) ?? src.source_name ?? 'View source' | |
| const stance = document.createElement('span') | |
| stance.className = 'pv-report-source-stance' | |
| stance.textContent = src.stance ?? '' | |
| if (src.stance === 'Refutes') stance.style.color = '#dc2626' | |
| if (src.stance === 'Supports') stance.style.color = '#16a34a' | |
| li.appendChild(link) | |
| li.appendChild(stance) | |
| sourcesList.appendChild(li) | |
| } | |
| sourcesSection.appendChild(sourcesList) | |
| report.appendChild(sourcesSection) | |
| } | |
| // β Explanation (claim used) | |
| if (result.layer2?.claim_used) { | |
| const explanation = document.createElement('div') | |
| explanation.className = 'pv-report-explanation' | |
| const explLabel = document.createElement('span') | |
| explLabel.className = 'pv-report-label' | |
| explLabel.textContent = 'CLAIM ANALYZED' | |
| const explText = document.createElement('p') | |
| explText.className = 'pv-report-explanation-text' | |
| explText.textContent = result.layer2.claim_used | |
| explanation.appendChild(explLabel) | |
| explanation.appendChild(explText) | |
| report.appendChild(explanation) | |
| } | |
| // β Full analysis link | |
| const fullLink = document.createElement('a') | |
| fullLink.className = 'pv-report-full-link' | |
| fullLink.href = 'https://philverify.web.app' | |
| fullLink.target = '_blank' | |
| fullLink.rel = 'noreferrer' | |
| fullLink.textContent = 'Open Full Dashboard β' | |
| report.appendChild(fullLink) | |
| // Insert report into post | |
| post.appendChild(report) | |
| } | |
| function showErrorReport(post, btn, errorMessage) { | |
| // Remove spinner, restore button as error state | |
| btn.classList.remove('pv-verify-btn--loading') | |
| btn.classList.add('pv-verify-btn--error') | |
| btn.disabled = false | |
| const spinner = btn.querySelector('.pv-spinner') | |
| if (spinner) spinner.remove() | |
| const icon = btn.querySelector('.pv-verify-btn-icon') | |
| const label = btn.querySelector('.pv-verify-btn-label') | |
| if (icon) icon.textContent = 'β οΈ' | |
| if (label) label.textContent = 'Verification failed β tap to retry' | |
| // On next click, retry | |
| const retryHandler = (e) => { | |
| e.stopPropagation() | |
| e.preventDefault() | |
| btn.removeEventListener('click', retryHandler) | |
| btn.classList.remove('pv-verify-btn--error') | |
| handleVerifyClick(post, btn) | |
| } | |
| // Remove old click listeners by replacing element | |
| const newBtn = btn.cloneNode(true) | |
| btn.replaceWith(newBtn) | |
| newBtn.addEventListener('click', (e) => { | |
| e.stopPropagation() | |
| e.preventDefault() | |
| newBtn.classList.remove('pv-verify-btn--error') | |
| handleVerifyClick(post, newBtn) | |
| }) | |
| } | |
| // ββ MutationObserver ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const pendingPosts = new Set() | |
| let rafScheduled = false | |
| function flushPosts() { | |
| rafScheduled = false | |
| for (const post of pendingPosts) injectVerifyButton(post) | |
| pendingPosts.clear() | |
| } | |
| function scheduleProcess(post) { | |
| pendingPosts.add(post) | |
| if (!rafScheduled) { | |
| rafScheduled = true | |
| requestAnimationFrame(flushPosts) | |
| } | |
| } | |
| const observer = new MutationObserver((mutations) => { | |
| for (const mutation of mutations) { | |
| for (const node of mutation.addedNodes) { | |
| if (node.nodeType !== 1) continue // element nodes only | |
| if (PLATFORM === 'facebook') { | |
| // Facebook strategy: only process nodes that are inside [role="feed"] | |
| // or that contain a feed. This prevents processing individual comment | |
| // nodes that are added dynamically. | |
| const inFeed = node.closest?.('[role="feed"]') || | |
| node.querySelector?.('[role="feed"]') || | |
| node.getAttribute?.('role') === 'feed' | |
| if (!inFeed && node.getAttribute?.('role') === 'article') { | |
| // An article added outside of a feed β could be a detail page. | |
| // Only process if isTopLevelPost says it's a post. | |
| if (isTopLevelPost(node)) { | |
| scheduleProcess(node) | |
| } | |
| continue | |
| } | |
| } | |
| // Check descendants for posts (findPosts handles feed-based filtering) | |
| const posts = findPosts(node) | |
| for (const post of posts) scheduleProcess(post) | |
| } | |
| } | |
| }) | |
| // ββ Initialization ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function init() { | |
| log(`Initializing on ${PLATFORM} (${window.location.hostname})`) | |
| // Check autoScan setting β controls whether buttons are shown at all | |
| let response | |
| try { | |
| response = await new Promise((resolve, reject) => { | |
| chrome.runtime.sendMessage({ type: 'GET_SETTINGS' }, (r) => { | |
| if (chrome.runtime.lastError) { | |
| warn('Settings fetch error:', chrome.runtime.lastError.message) | |
| resolve({ autoScan: true }) | |
| } else { | |
| resolve(r ?? { autoScan: true }) | |
| } | |
| }) | |
| }) | |
| } catch { | |
| response = { autoScan: true } | |
| } | |
| log('Settings:', response) | |
| if (response?.autoScan === false) { | |
| log('Auto-scan disabled by settings β no verify buttons will be shown') | |
| return | |
| } | |
| // Process any posts already in the DOM | |
| const existing = findPosts(document.body) | |
| log(`Found ${existing.length} existing posts`) | |
| for (const post of existing) scheduleProcess(post) | |
| // Watch for new posts (both platforms are SPAs with infinite scroll) | |
| observer.observe(document.body, { childList: true, subtree: true }) | |
| log('MutationObserver started β watching for new posts') | |
| } | |
| init() | |
| // ββ Auto-verify news article pages (non-social) ββββββββββββββββββββββββββββ | |
| // When the content script runs on a PH news site (not the homepage), | |
| // it auto-verifies the current URL and injects a floating verdict banner. | |
| async function autoVerifyPage() { | |
| const url = window.location.href | |
| const path = new URL(url).pathname | |
| // Skip homepages and section indexes (very short paths like / or /news) | |
| if (!path || path.length < 8 || path.split('/').filter(Boolean).length < 2) return | |
| const banner = document.createElement('div') | |
| banner.id = 'pv-auto-banner' | |
| banner.setAttribute('role', 'status') | |
| banner.setAttribute('aria-live', 'polite') | |
| banner.style.cssText = [ | |
| 'position:fixed;top:0;left:0;right:0;z-index:2147483647', | |
| 'background:#141414;border-bottom:2px solid rgba(220,38,38,0.4)', | |
| 'padding:7px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px', | |
| 'font-family:system-ui,sans-serif;font-size:11px;color:#a89f94', | |
| 'box-shadow:0 2px 16px rgba(0,0,0,0.6)', | |
| ].join(';') | |
| // Build banner content with createElement for XSS safety | |
| const leftSection = document.createElement('div') | |
| leftSection.style.cssText = 'display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden;' | |
| const logoSpan = document.createElement('span') | |
| logoSpan.style.cssText = 'font-weight:800;letter-spacing:0.1em;color:#f5f0e8;flex-shrink:0;' | |
| logoSpan.innerHTML = 'PHIL<span style="color:#dc2626">VERIFY</span>' | |
| const statusEl = document.createElement('span') | |
| statusEl.id = 'pv-auto-status' | |
| statusEl.style.cssText = 'display:flex;align-items:center;gap:6px;overflow:hidden;' | |
| const statusSpinner = document.createElement('span') | |
| statusSpinner.className = 'pv-spinner' | |
| statusSpinner.setAttribute('aria-hidden', 'true') | |
| const statusText = document.createElement('span') | |
| statusText.style.cssText = 'white-space:nowrap;' | |
| statusText.textContent = 'Verifying articleβ¦' | |
| statusEl.appendChild(statusSpinner) | |
| statusEl.appendChild(statusText) | |
| leftSection.appendChild(logoSpan) | |
| leftSection.appendChild(statusEl) | |
| const rightSection = document.createElement('div') | |
| rightSection.style.cssText = 'display:flex;align-items:center;gap:8px;flex-shrink:0;' | |
| const fullLink = document.createElement('a') | |
| fullLink.id = 'pv-open-full' | |
| fullLink.href = 'https://philverify.web.app' | |
| fullLink.target = '_blank' | |
| fullLink.rel = 'noreferrer' | |
| fullLink.style.cssText = 'color:#dc2626;font-size:9px;font-weight:700;letter-spacing:0.1em;text-decoration:none;border:1px solid rgba(220,38,38,0.35);padding:3px 8px;border-radius:2px;white-space:nowrap;' | |
| fullLink.setAttribute('aria-label', 'Open PhilVerify dashboard') | |
| fullLink.textContent = 'FULL ANALYSIS β' | |
| const closeButton = document.createElement('button') | |
| closeButton.id = 'pv-close-banner' | |
| closeButton.style.cssText = 'background:none;border:none;color:#5c554e;cursor:pointer;font-size:13px;padding:2px 4px;line-height:1;flex-shrink:0;' | |
| closeButton.setAttribute('aria-label', 'Dismiss PhilVerify banner') | |
| closeButton.textContent = 'β' | |
| rightSection.appendChild(fullLink) | |
| rightSection.appendChild(closeButton) | |
| banner.appendChild(leftSection) | |
| banner.appendChild(rightSection) | |
| document.body.insertAdjacentElement('afterbegin', banner) | |
| document.documentElement.style.marginTop = '36px' | |
| closeButton.addEventListener('click', () => { | |
| banner.remove() | |
| document.documentElement.style.marginTop = '' | |
| }) | |
| try { | |
| const response = await new Promise((resolve, reject) => { | |
| chrome.runtime.sendMessage({ type: 'VERIFY_URL', url }, (resp) => { | |
| if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)) | |
| else if (!resp?.ok) reject(new Error(resp?.error ?? 'Unknown error')) | |
| else resolve(resp.result) | |
| }) | |
| }) | |
| const color = VERDICT_COLORS[response.verdict] ?? '#5c554e' | |
| // Update status with result | |
| statusEl.textContent = '' | |
| const dotEl = document.createElement('span') | |
| dotEl.style.cssText = `width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0;` | |
| dotEl.setAttribute('aria-hidden', 'true') | |
| const verdictEl = document.createElement('span') | |
| verdictEl.style.cssText = `color:${color};font-weight:700;` | |
| verdictEl.textContent = response.verdict | |
| const scoreEl = document.createElement('span') | |
| scoreEl.style.cssText = 'color:#5c554e;margin-left:2px;' | |
| scoreEl.textContent = `${Math.round(response.final_score)}% credibility` | |
| statusEl.appendChild(dotEl) | |
| statusEl.appendChild(verdictEl) | |
| statusEl.appendChild(scoreEl) | |
| if (response.layer1?.triggered_features?.length) { | |
| const featureEl = document.createElement('span') | |
| featureEl.style.cssText = 'color:#5c554e;margin-left:4px;font-size:9px;' | |
| featureEl.textContent = `Β· ${response.layer1.triggered_features[0]}` | |
| statusEl.appendChild(featureEl) | |
| } | |
| banner.style.borderBottomColor = color + '88' | |
| // Auto-dismiss if credible | |
| if (response.verdict === 'Credible') { | |
| setTimeout(() => { | |
| if (document.contains(banner)) { | |
| banner.remove() | |
| document.documentElement.style.marginTop = '' | |
| } | |
| }, 5000) | |
| } | |
| } catch (_) { | |
| banner.remove() | |
| document.documentElement.style.marginTop = '' | |
| } | |
| } | |
| if (!IS_SOCIAL) { | |
| autoVerifyPage() | |
| } | |
| // Listen for settings changes to enable/disable button injection | |
| chrome.storage.onChanged.addListener((changes, area) => { | |
| if (area !== 'local' || !changes.settings) return | |
| const autoScan = changes.settings.newValue?.autoScan | |
| if (autoScan === false) { | |
| observer.disconnect() | |
| // Remove all existing verify buttons | |
| document.querySelectorAll('.pv-verify-btn').forEach(btn => btn.remove()) | |
| document.querySelectorAll('[data-philverify-btn]').forEach(el => { | |
| delete el.dataset.philverifyBtn | |
| }) | |
| } else if (autoScan === true) { | |
| observer.observe(document.body, { childList: true, subtree: true }) | |
| const existing = findPosts(document.body) | |
| for (const post of existing) scheduleProcess(post) | |
| } | |
| }) | |
| })() | |