Spaces:
Running
Running
Ryan Christian D. Deniega commited on
Commit Β·
7e55328
1
Parent(s): 7b2808c
Fix: Overhaul Facebook post detection to filtered comments using [role='feed'] strategy and improve button positioning
Browse files- .firebase/hosting.ZnJvbnRlbmQvZGlzdA.cache +5 -5
- api/routes/verify.py +52 -1
- api/schemas.py +1 -0
- config.py +15 -0
- extension/background.js +22 -18
- extension/content.css +258 -71
- extension/content.js +751 -289
- frontend/src/pages/VerifyPage.jsx +142 -10
- frontend/src/utils/format.js +40 -3
- inputs/url_scraper.py +132 -1
- requirements.txt +1 -0
.firebase/hosting.ZnJvbnRlbmQvZGlzdA.cache
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
vite.svg,
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
assets/index-DE8XF5VL.css,
|
| 5 |
-
assets/index-
|
|
|
|
| 1 |
+
vite.svg,1772007250601,d3bbbc44b3ea71906a72bf2ec1a4716903e2e3d9f85a5007205a65d1f12e2923
|
| 2 |
+
logo.svg,1772007250601,c1ca19989c26d83c632b01609dc4514e16bef7418284c6df88b29ac34ca035ec
|
| 3 |
+
index.html,1772007250792,4c51726f347b368d44d30afacc29469f6610be348d29aca35316322116d1f636
|
| 4 |
+
assets/index-DE8XF5VL.css,1772007250792,941148112bdd25f98beea529b6ad97209f2f777e70671d0f5b96f919c8472699
|
| 5 |
+
assets/index-pa_ZGMTy.js,1772007250792,181110f569f8d340b4f682558d7e44c1e93acc89a281c426b91a85cba26620d6
|
api/routes/verify.py
CHANGED
|
@@ -22,6 +22,50 @@ from inputs.asr import transcribe_video
|
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
router = APIRouter(prefix="/verify", tags=["Verification"])
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
# ββ Text ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
|
|
@@ -37,6 +81,7 @@ async def verify_text(body: TextVerifyRequest) -> VerificationResponse:
|
|
| 37 |
try:
|
| 38 |
result = await run_verification(body.text, input_type="text")
|
| 39 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
|
|
| 40 |
return result
|
| 41 |
except Exception as exc:
|
| 42 |
logger.exception("verify/text error: %s", exc)
|
|
@@ -57,13 +102,17 @@ async def verify_url(body: URLVerifyRequest) -> VerificationResponse:
|
|
| 57 |
logger.info("verify/url called | url=%s", url_str)
|
| 58 |
try:
|
| 59 |
text, domain = await scrape_url(url_str)
|
|
|
|
|
|
|
|
|
|
| 60 |
if not text or len(text.strip()) < 20:
|
| 61 |
raise HTTPException(
|
| 62 |
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 63 |
-
detail="Could not extract meaningful text from the URL. The page may be paywalled or bot-protected.",
|
| 64 |
)
|
| 65 |
result = await run_verification(text, input_type="url", source_domain=domain)
|
| 66 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
|
|
| 67 |
return result
|
| 68 |
except HTTPException:
|
| 69 |
raise
|
|
@@ -104,6 +153,7 @@ async def verify_image(file: UploadFile = File(...)) -> VerificationResponse:
|
|
| 104 |
)
|
| 105 |
result = await run_verification(text, input_type="image")
|
| 106 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
|
|
| 107 |
return result
|
| 108 |
except HTTPException:
|
| 109 |
raise
|
|
@@ -143,6 +193,7 @@ async def verify_video(file: UploadFile = File(...)) -> VerificationResponse:
|
|
| 143 |
)
|
| 144 |
result = await run_verification(text, input_type="video")
|
| 145 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
|
|
| 146 |
return result
|
| 147 |
except HTTPException:
|
| 148 |
raise
|
|
|
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
router = APIRouter(prefix="/verify", tags=["Verification"])
|
| 24 |
|
| 25 |
+
# ββ OG meta fallback for bot-protected / social URLs ββββββββββββββββββββββββββ
|
| 26 |
+
async def _fetch_og_text(url: str) -> str:
|
| 27 |
+
"""
|
| 28 |
+
Fetches OG/meta title + description from a URL using a plain HTTP GET.
|
| 29 |
+
Used as a last-resort fallback when the full scraper returns no content
|
| 30 |
+
(e.g. Facebook share links, photo URLs that block the scraper).
|
| 31 |
+
Returns a concatenated title + description string, or "" on failure.
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
import httpx
|
| 35 |
+
from bs4 import BeautifulSoup
|
| 36 |
+
|
| 37 |
+
headers = {
|
| 38 |
+
"User-Agent": (
|
| 39 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
| 40 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 41 |
+
"Chrome/122.0.0.0 Safari/537.36"
|
| 42 |
+
),
|
| 43 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 44 |
+
"Accept-Language": "en-US,en;q=0.5",
|
| 45 |
+
}
|
| 46 |
+
async with httpx.AsyncClient(timeout=12, follow_redirects=True) as client:
|
| 47 |
+
resp = await client.get(url, headers=headers)
|
| 48 |
+
if resp.status_code >= 400:
|
| 49 |
+
return ""
|
| 50 |
+
head_end = resp.text.find("</head>")
|
| 51 |
+
head_html = resp.text[:head_end + 7] if head_end != -1 else resp.text[:8000]
|
| 52 |
+
soup = BeautifulSoup(head_html, "lxml")
|
| 53 |
+
|
| 54 |
+
def _m(prop=None, name=None):
|
| 55 |
+
el = (soup.find("meta", property=prop) if prop
|
| 56 |
+
else soup.find("meta", attrs={"name": name}))
|
| 57 |
+
return (el.get("content") or "").strip() if el else ""
|
| 58 |
+
|
| 59 |
+
title = (_m(prop="og:title") or _m(name="twitter:title")
|
| 60 |
+
or (soup.title.get_text(strip=True) if soup.title else ""))
|
| 61 |
+
description = (_m(prop="og:description") or _m(name="twitter:description")
|
| 62 |
+
or _m(name="description"))
|
| 63 |
+
parts = [p for p in [title, description] if p]
|
| 64 |
+
return " ".join(parts)
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
logger.warning("OG meta fallback failed for %s: %s", url, exc)
|
| 67 |
+
return ""
|
| 68 |
+
|
| 69 |
|
| 70 |
# ββ Text ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
|
|
|
|
| 81 |
try:
|
| 82 |
result = await run_verification(body.text, input_type="text")
|
| 83 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
| 84 |
+
result.extracted_text = body.text
|
| 85 |
return result
|
| 86 |
except Exception as exc:
|
| 87 |
logger.exception("verify/text error: %s", exc)
|
|
|
|
| 102 |
logger.info("verify/url called | url=%s", url_str)
|
| 103 |
try:
|
| 104 |
text, domain = await scrape_url(url_str)
|
| 105 |
+
if not text or len(text.strip()) < 20:
|
| 106 |
+
logger.info("scrape_url returned no content for %s β trying OG meta fallback", url_str)
|
| 107 |
+
text = await _fetch_og_text(url_str)
|
| 108 |
if not text or len(text.strip()) < 20:
|
| 109 |
raise HTTPException(
|
| 110 |
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 111 |
+
detail="Could not extract meaningful text from the URL. The page may be paywalled, private, or bot-protected. Try copying the post text and using the Text tab instead.",
|
| 112 |
)
|
| 113 |
result = await run_verification(text, input_type="url", source_domain=domain)
|
| 114 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
| 115 |
+
result.extracted_text = text.strip()
|
| 116 |
return result
|
| 117 |
except HTTPException:
|
| 118 |
raise
|
|
|
|
| 153 |
)
|
| 154 |
result = await run_verification(text, input_type="image")
|
| 155 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
| 156 |
+
result.extracted_text = text.strip()
|
| 157 |
return result
|
| 158 |
except HTTPException:
|
| 159 |
raise
|
|
|
|
| 193 |
)
|
| 194 |
result = await run_verification(text, input_type="video")
|
| 195 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
| 196 |
+
result.extracted_text = text.strip()
|
| 197 |
return result
|
| 198 |
except HTTPException:
|
| 199 |
raise
|
api/schemas.py
CHANGED
|
@@ -105,6 +105,7 @@ class VerificationResponse(BaseModel):
|
|
| 105 |
domain_credibility: Optional[DomainTier] = None
|
| 106 |
input_type: str = "text"
|
| 107 |
processing_time_ms: Optional[float] = None
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
# ββ History / Trends ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 105 |
domain_credibility: Optional[DomainTier] = None
|
| 106 |
input_type: str = "text"
|
| 107 |
processing_time_ms: Optional[float] = None
|
| 108 |
+
extracted_text: Optional[str] = Field(None, description="Raw text extracted from the URL / image / video for transparency")
|
| 109 |
|
| 110 |
|
| 111 |
# ββ History / Trends ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
config.py
CHANGED
|
@@ -18,6 +18,21 @@ class Settings(BaseSettings):
|
|
| 18 |
news_api_key: str = ""
|
| 19 |
google_vision_api_key: str = ""
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
# ββ Database ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
database_url: str = "sqlite+aiosqlite:///./philverify_dev.db" # Dev fallback
|
| 23 |
|
|
|
|
| 18 |
news_api_key: str = ""
|
| 19 |
google_vision_api_key: str = ""
|
| 20 |
|
| 21 |
+
# ββ Facebook Scraper Cookies ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
# Paste the value of the `c_user` and `xs` cookies from a logged-in
|
| 23 |
+
# Facebook session (browser DevTools β Application β Cookies β facebook.com).
|
| 24 |
+
# These unlock private/friends-only posts and reduce rate-limiting.
|
| 25 |
+
# Leave empty to scrape public posts only.
|
| 26 |
+
facebook_c_user: str = ""
|
| 27 |
+
facebook_xs: str = ""
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def facebook_cookies(self) -> dict | None:
|
| 31 |
+
"""Return cookie dict for facebook-scraper, or None if not configured."""
|
| 32 |
+
if self.facebook_c_user and self.facebook_xs:
|
| 33 |
+
return {"c_user": self.facebook_c_user, "xs": self.facebook_xs}
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
# ββ Database ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
database_url: str = "sqlite+aiosqlite:///./philverify_dev.db" # Dev fallback
|
| 38 |
|
extension/background.js
CHANGED
|
@@ -16,11 +16,11 @@
|
|
| 16 |
*/
|
| 17 |
|
| 18 |
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
| 19 |
-
const MAX_HISTORY
|
| 20 |
|
| 21 |
// ββ Default settings ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
const DEFAULT_SETTINGS = {
|
| 23 |
-
apiBase:
|
| 24 |
autoScan: true, // Automatically scan Facebook feed posts
|
| 25 |
}
|
| 26 |
|
|
@@ -70,11 +70,11 @@ async function setCached(key, result, preview) {
|
|
| 70 |
// Prepend to history list
|
| 71 |
const { history = [] } = await chrome.storage.local.get('history')
|
| 72 |
const entry = {
|
| 73 |
-
id:
|
| 74 |
-
timestamp:
|
| 75 |
text_preview: preview.slice(0, 80),
|
| 76 |
-
verdict:
|
| 77 |
-
final_score:
|
| 78 |
}
|
| 79 |
const updated = [entry, ...history.filter(h => h.id !== key)].slice(0, MAX_HISTORY)
|
| 80 |
await chrome.storage.local.set({ history: updated })
|
|
@@ -82,16 +82,20 @@ async function setCached(key, result, preview) {
|
|
| 82 |
|
| 83 |
// ββ API calls βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
|
| 85 |
-
async function verifyText(text) {
|
| 86 |
-
const key
|
| 87 |
-
const hit
|
| 88 |
if (hit) return { ...hit, _fromCache: true }
|
| 89 |
|
| 90 |
const { apiBase } = await getSettings()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
const res = await fetch(`${apiBase}/verify/text`, {
|
| 92 |
-
method:
|
| 93 |
headers: { 'Content-Type': 'application/json' },
|
| 94 |
-
body:
|
| 95 |
})
|
| 96 |
if (!res.ok) {
|
| 97 |
const body = await res.json().catch(() => ({}))
|
|
@@ -109,9 +113,9 @@ async function verifyUrl(url) {
|
|
| 109 |
|
| 110 |
const { apiBase } = await getSettings()
|
| 111 |
const res = await fetch(`${apiBase}/verify/url`, {
|
| 112 |
-
method:
|
| 113 |
headers: { 'Content-Type': 'application/json' },
|
| 114 |
-
body:
|
| 115 |
})
|
| 116 |
if (!res.ok) {
|
| 117 |
const body = await res.json().catch(() => ({}))
|
|
@@ -131,7 +135,7 @@ async function verifyImageUrl(imageUrl) {
|
|
| 131 |
const imgRes = await fetch(imageUrl)
|
| 132 |
if (!imgRes.ok) throw new Error(`Could not fetch image: ${imgRes.status}`)
|
| 133 |
const blob = await imgRes.blob()
|
| 134 |
-
const ct
|
| 135 |
const ext = ct.includes('png') ? 'png' : ct.includes('webp') ? 'webp' : 'jpg'
|
| 136 |
const formData = new FormData()
|
| 137 |
formData.append('file', blob, `image.${ext}`)
|
|
@@ -158,8 +162,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
| 158 |
switch (msg.type) {
|
| 159 |
|
| 160 |
case 'VERIFY_TEXT':
|
| 161 |
-
verifyText(msg.text)
|
| 162 |
-
.then(r
|
| 163 |
.catch(e => sendResponse({ ok: false, error: e.message }))
|
| 164 |
return true // keep message channel open for async response
|
| 165 |
|
|
@@ -169,7 +173,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
| 169 |
return false
|
| 170 |
}
|
| 171 |
verifyUrl(msg.url)
|
| 172 |
-
.then(r
|
| 173 |
.catch(e => sendResponse({ ok: false, error: e.message }))
|
| 174 |
return true
|
| 175 |
|
|
@@ -179,7 +183,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
| 179 |
return false
|
| 180 |
}
|
| 181 |
verifyImageUrl(msg.imageUrl)
|
| 182 |
-
.then(r
|
| 183 |
.catch(e => sendResponse({ ok: false, error: e.message }))
|
| 184 |
return true
|
| 185 |
|
|
|
|
| 16 |
*/
|
| 17 |
|
| 18 |
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
| 19 |
+
const MAX_HISTORY = 50
|
| 20 |
|
| 21 |
// ββ Default settings ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
const DEFAULT_SETTINGS = {
|
| 23 |
+
apiBase: 'https://philverify.web.app/api',
|
| 24 |
autoScan: true, // Automatically scan Facebook feed posts
|
| 25 |
}
|
| 26 |
|
|
|
|
| 70 |
// Prepend to history list
|
| 71 |
const { history = [] } = await chrome.storage.local.get('history')
|
| 72 |
const entry = {
|
| 73 |
+
id: key,
|
| 74 |
+
timestamp: new Date().toISOString(),
|
| 75 |
text_preview: preview.slice(0, 80),
|
| 76 |
+
verdict: result.verdict,
|
| 77 |
+
final_score: result.final_score,
|
| 78 |
}
|
| 79 |
const updated = [entry, ...history.filter(h => h.id !== key)].slice(0, MAX_HISTORY)
|
| 80 |
await chrome.storage.local.set({ history: updated })
|
|
|
|
| 82 |
|
| 83 |
// ββ API calls βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
|
| 85 |
+
async function verifyText(text, imageUrl) {
|
| 86 |
+
const key = 'txt_' + await sha256prefix(text)
|
| 87 |
+
const hit = await getCached(key)
|
| 88 |
if (hit) return { ...hit, _fromCache: true }
|
| 89 |
|
| 90 |
const { apiBase } = await getSettings()
|
| 91 |
+
// Build payload β include imageUrl for multimodal (text + image) analysis
|
| 92 |
+
const payload = { text }
|
| 93 |
+
if (imageUrl && isHttpUrl(imageUrl)) payload.image_url = imageUrl
|
| 94 |
+
|
| 95 |
const res = await fetch(`${apiBase}/verify/text`, {
|
| 96 |
+
method: 'POST',
|
| 97 |
headers: { 'Content-Type': 'application/json' },
|
| 98 |
+
body: JSON.stringify(payload),
|
| 99 |
})
|
| 100 |
if (!res.ok) {
|
| 101 |
const body = await res.json().catch(() => ({}))
|
|
|
|
| 113 |
|
| 114 |
const { apiBase } = await getSettings()
|
| 115 |
const res = await fetch(`${apiBase}/verify/url`, {
|
| 116 |
+
method: 'POST',
|
| 117 |
headers: { 'Content-Type': 'application/json' },
|
| 118 |
+
body: JSON.stringify({ url }),
|
| 119 |
})
|
| 120 |
if (!res.ok) {
|
| 121 |
const body = await res.json().catch(() => ({}))
|
|
|
|
| 135 |
const imgRes = await fetch(imageUrl)
|
| 136 |
if (!imgRes.ok) throw new Error(`Could not fetch image: ${imgRes.status}`)
|
| 137 |
const blob = await imgRes.blob()
|
| 138 |
+
const ct = imgRes.headers.get('content-type') ?? 'image/jpeg'
|
| 139 |
const ext = ct.includes('png') ? 'png' : ct.includes('webp') ? 'webp' : 'jpg'
|
| 140 |
const formData = new FormData()
|
| 141 |
formData.append('file', blob, `image.${ext}`)
|
|
|
|
| 162 |
switch (msg.type) {
|
| 163 |
|
| 164 |
case 'VERIFY_TEXT':
|
| 165 |
+
verifyText(msg.text, msg.imageUrl)
|
| 166 |
+
.then(r => sendResponse({ ok: true, result: r }))
|
| 167 |
.catch(e => sendResponse({ ok: false, error: e.message }))
|
| 168 |
return true // keep message channel open for async response
|
| 169 |
|
|
|
|
| 173 |
return false
|
| 174 |
}
|
| 175 |
verifyUrl(msg.url)
|
| 176 |
+
.then(r => sendResponse({ ok: true, result: r }))
|
| 177 |
.catch(e => sendResponse({ ok: false, error: e.message }))
|
| 178 |
return true
|
| 179 |
|
|
|
|
| 183 |
return false
|
| 184 |
}
|
| 185 |
verifyImageUrl(msg.imageUrl)
|
| 186 |
+
.then(r => sendResponse({ ok: true, result: r }))
|
| 187 |
.catch(e => sendResponse({ ok: false, error: e.message }))
|
| 188 |
return true
|
| 189 |
|
extension/content.css
CHANGED
|
@@ -1,179 +1,364 @@
|
|
| 1 |
/**
|
| 2 |
* PhilVerify β Content Script Styles
|
| 3 |
-
*
|
| 4 |
-
* All selectors
|
| 5 |
*/
|
| 6 |
|
| 7 |
-
/* ββ
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
-
.pv-
|
|
|
|
|
|
|
| 14 |
display: inline-flex;
|
| 15 |
align-items: center;
|
| 16 |
gap: 6px;
|
| 17 |
-
padding:
|
| 18 |
-
border
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
| 20 |
font-size: 11px;
|
| 21 |
font-weight: 600;
|
| 22 |
-
letter-spacing: 0.
|
| 23 |
cursor: pointer;
|
| 24 |
touch-action: manipulation;
|
| 25 |
-webkit-tap-highlight-color: transparent;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
-
.pv-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
outline: 2px solid #06b6d4;
|
| 30 |
outline-offset: 2px;
|
| 31 |
}
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
color: #a89f94;
|
| 36 |
-
border: 1px solid rgba(168, 159, 148, 0.2);
|
| 37 |
-
background: rgba(168, 159, 148, 0.06);
|
| 38 |
-
cursor: default;
|
| 39 |
}
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
.pv-spinner {
|
| 42 |
display: inline-block;
|
| 43 |
-
width:
|
| 44 |
-
height:
|
| 45 |
border: 2px solid rgba(168, 159, 148, 0.3);
|
| 46 |
border-top-color: #a89f94;
|
| 47 |
border-radius: 50%;
|
| 48 |
animation: pv-spin 0.7s linear infinite;
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
@media (prefers-reduced-motion: reduce) {
|
| 52 |
-
.pv-spinner {
|
| 53 |
-
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
background: transparent;
|
| 64 |
-
cursor: default;
|
| 65 |
-
font-size: 10px;
|
| 66 |
}
|
| 67 |
|
| 68 |
-
/* ββ
|
| 69 |
-
.pv-
|
| 70 |
display: block;
|
| 71 |
-
margin:
|
| 72 |
-
padding:
|
| 73 |
background: #141414;
|
| 74 |
border: 1px solid rgba(245, 240, 232, 0.1);
|
| 75 |
-
border-radius:
|
| 76 |
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
| 77 |
font-size: 11px;
|
| 78 |
color: #f5f0e8;
|
| 79 |
-
max-width:
|
| 80 |
-
box-shadow: 0 4px
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
-
|
|
|
|
| 84 |
display: flex;
|
| 85 |
align-items: center;
|
| 86 |
justify-content: space-between;
|
| 87 |
-
margin-bottom:
|
| 88 |
-
padding-bottom:
|
| 89 |
border-bottom: 1px solid rgba(245, 240, 232, 0.07);
|
| 90 |
}
|
| 91 |
|
| 92 |
-
.pv-logo {
|
| 93 |
font-weight: 800;
|
| 94 |
-
font-size:
|
| 95 |
letter-spacing: 0.12em;
|
| 96 |
color: #f5f0e8;
|
| 97 |
}
|
| 98 |
|
| 99 |
-
.pv-close {
|
| 100 |
background: none;
|
| 101 |
border: none;
|
| 102 |
cursor: pointer;
|
| 103 |
color: #5c554e;
|
| 104 |
-
font-size:
|
| 105 |
-
padding: 2px
|
| 106 |
-
border-radius:
|
| 107 |
touch-action: manipulation;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
-
.pv-close:hover { color: #f5f0e8; }
|
| 110 |
-
.pv-close:focus-visible { outline: 2px solid #06b6d4; }
|
| 111 |
|
| 112 |
-
.pv-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
display: flex;
|
| 114 |
justify-content: space-between;
|
| 115 |
align-items: center;
|
| 116 |
-
padding:
|
| 117 |
border-bottom: 1px solid rgba(245, 240, 232, 0.05);
|
| 118 |
}
|
| 119 |
|
| 120 |
-
.pv-label {
|
| 121 |
font-size: 9px;
|
| 122 |
font-weight: 700;
|
| 123 |
letter-spacing: 0.12em;
|
| 124 |
color: #5c554e;
|
| 125 |
text-transform: uppercase;
|
|
|
|
| 126 |
}
|
| 127 |
|
| 128 |
-
.pv-
|
| 129 |
font-size: 11px;
|
| 130 |
-
font-weight:
|
| 131 |
color: #a89f94;
|
| 132 |
}
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 136 |
border-bottom: 1px solid rgba(245, 240, 232, 0.05);
|
| 137 |
}
|
| 138 |
|
| 139 |
-
.pv-tags {
|
| 140 |
display: flex;
|
| 141 |
flex-wrap: wrap;
|
| 142 |
gap: 4px;
|
| 143 |
-
margin-top:
|
| 144 |
}
|
| 145 |
|
| 146 |
-
.pv-tag {
|
| 147 |
-
padding:
|
| 148 |
background: rgba(220, 38, 38, 0.12);
|
| 149 |
color: #f87171;
|
| 150 |
border: 1px solid rgba(220, 38, 38, 0.25);
|
| 151 |
-
border-radius:
|
| 152 |
font-size: 9px;
|
| 153 |
letter-spacing: 0.04em;
|
| 154 |
font-weight: 600;
|
| 155 |
}
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
|
|
|
| 159 |
border-bottom: 1px solid rgba(245, 240, 232, 0.05);
|
| 160 |
}
|
| 161 |
|
| 162 |
-
.pv-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
color: #06b6d4;
|
| 166 |
font-size: 10px;
|
| 167 |
text-decoration: none;
|
| 168 |
overflow: hidden;
|
| 169 |
text-overflow: ellipsis;
|
| 170 |
white-space: nowrap;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
}
|
| 172 |
-
.pv-source-link:hover { text-decoration: underline; }
|
| 173 |
|
| 174 |
-
.pv-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
display: block;
|
| 176 |
-
margin-top:
|
| 177 |
text-align: center;
|
| 178 |
color: #dc2626;
|
| 179 |
font-size: 10px;
|
|
@@ -181,10 +366,12 @@
|
|
| 181 |
letter-spacing: 0.08em;
|
| 182 |
text-decoration: none;
|
| 183 |
text-transform: uppercase;
|
| 184 |
-
padding:
|
| 185 |
border: 1px solid rgba(220, 38, 38, 0.3);
|
| 186 |
-
border-radius:
|
|
|
|
| 187 |
}
|
| 188 |
-
|
|
|
|
| 189 |
background: rgba(220, 38, 38, 0.08);
|
| 190 |
-
}
|
|
|
|
| 1 |
/**
|
| 2 |
* PhilVerify β Content Script Styles
|
| 3 |
+
* Floating verify button + inline report overlay.
|
| 4 |
+
* All selectors namespaced under .pv-* to avoid collisions.
|
| 5 |
*/
|
| 6 |
|
| 7 |
+
/* ββ Floating "Verify this post" button βββββββββββββββββββββββββββββββββββββββ */
|
| 8 |
+
/* Wrapper container: sits at the end of the post, flex-end alignment */
|
| 9 |
+
.pv-verify-btn-wrapper {
|
| 10 |
+
display: flex;
|
| 11 |
+
justify-content: flex-end;
|
| 12 |
+
padding: 4px 12px 8px;
|
| 13 |
+
pointer-events: none;
|
| 14 |
+
/* Let clicks pass through the wrapper */
|
| 15 |
}
|
| 16 |
|
| 17 |
+
.pv-verify-btn {
|
| 18 |
+
position: relative;
|
| 19 |
+
z-index: 100;
|
| 20 |
display: inline-flex;
|
| 21 |
align-items: center;
|
| 22 |
gap: 6px;
|
| 23 |
+
padding: 6px 12px;
|
| 24 |
+
border: 1px solid rgba(220, 38, 38, 0.3);
|
| 25 |
+
border-radius: 20px;
|
| 26 |
+
background: rgba(20, 20, 20, 0.92);
|
| 27 |
+
backdrop-filter: blur(8px);
|
| 28 |
+
-webkit-backdrop-filter: blur(8px);
|
| 29 |
+
color: #f5f0e8;
|
| 30 |
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
| 31 |
font-size: 11px;
|
| 32 |
font-weight: 600;
|
| 33 |
+
letter-spacing: 0.03em;
|
| 34 |
cursor: pointer;
|
| 35 |
touch-action: manipulation;
|
| 36 |
-webkit-tap-highlight-color: transparent;
|
| 37 |
+
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
| 38 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
| 39 |
+
pointer-events: auto;
|
| 40 |
+
/* Re-enable clicks on the button itself */
|
| 41 |
}
|
| 42 |
|
| 43 |
+
.pv-verify-btn:hover {
|
| 44 |
+
transform: translateY(-1px);
|
| 45 |
+
border-color: rgba(220, 38, 38, 0.6);
|
| 46 |
+
box-shadow: 0 4px 16px rgba(220, 38, 38, 0.2);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.pv-verify-btn:active {
|
| 50 |
+
transform: scale(0.97);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.pv-verify-btn:focus-visible {
|
| 54 |
outline: 2px solid #06b6d4;
|
| 55 |
outline-offset: 2px;
|
| 56 |
}
|
| 57 |
|
| 58 |
+
.pv-verify-btn-icon {
|
| 59 |
+
font-size: 13px;
|
| 60 |
+
line-height: 1;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.pv-verify-btn-label {
|
| 64 |
+
white-space: nowrap;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* ββ Loading state (on the button) ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 68 |
+
.pv-verify-btn--loading {
|
| 69 |
+
cursor: wait;
|
| 70 |
+
border-color: rgba(168, 159, 148, 0.3);
|
| 71 |
color: #a89f94;
|
|
|
|
|
|
|
|
|
|
| 72 |
}
|
| 73 |
|
| 74 |
+
.pv-verify-btn--loading:hover {
|
| 75 |
+
transform: none;
|
| 76 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/* ββ Error state (on the button) ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 80 |
+
.pv-verify-btn--error {
|
| 81 |
+
border-color: rgba(248, 113, 113, 0.4);
|
| 82 |
+
color: #f87171;
|
| 83 |
+
animation: pv-shake 0.4s ease-in-out;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
@keyframes pv-shake {
|
| 87 |
+
|
| 88 |
+
0%,
|
| 89 |
+
100% {
|
| 90 |
+
transform: translateX(0);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
20% {
|
| 94 |
+
transform: translateX(-3px);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
40% {
|
| 98 |
+
transform: translateX(3px);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
60% {
|
| 102 |
+
transform: translateX(-2px);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
80% {
|
| 106 |
+
transform: translateX(2px);
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/* ββ Spinner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 111 |
.pv-spinner {
|
| 112 |
display: inline-block;
|
| 113 |
+
width: 12px;
|
| 114 |
+
height: 12px;
|
| 115 |
border: 2px solid rgba(168, 159, 148, 0.3);
|
| 116 |
border-top-color: #a89f94;
|
| 117 |
border-radius: 50%;
|
| 118 |
animation: pv-spin 0.7s linear infinite;
|
| 119 |
+
flex-shrink: 0;
|
| 120 |
}
|
| 121 |
|
| 122 |
@media (prefers-reduced-motion: reduce) {
|
| 123 |
+
.pv-spinner {
|
| 124 |
+
animation: none;
|
| 125 |
+
}
|
| 126 |
|
| 127 |
+
.pv-verify-btn--error {
|
| 128 |
+
animation: none;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.pv-verify-btn {
|
| 132 |
+
transition: none;
|
| 133 |
+
}
|
| 134 |
}
|
| 135 |
|
| 136 |
+
@keyframes pv-spin {
|
| 137 |
+
to {
|
| 138 |
+
transform: rotate(360deg);
|
| 139 |
+
}
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
|
| 142 |
+
/* ββ Inline verification report ββββοΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββ */
|
| 143 |
+
.pv-report {
|
| 144 |
display: block;
|
| 145 |
+
margin: 8px 12px 12px;
|
| 146 |
+
padding: 14px 16px;
|
| 147 |
background: #141414;
|
| 148 |
border: 1px solid rgba(245, 240, 232, 0.1);
|
| 149 |
+
border-radius: 8px;
|
| 150 |
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
| 151 |
font-size: 11px;
|
| 152 |
color: #f5f0e8;
|
| 153 |
+
max-width: 480px;
|
| 154 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
| 155 |
+
position: relative;
|
| 156 |
+
z-index: 50;
|
| 157 |
}
|
| 158 |
|
| 159 |
+
/* β Header */
|
| 160 |
+
.pv-report-header {
|
| 161 |
display: flex;
|
| 162 |
align-items: center;
|
| 163 |
justify-content: space-between;
|
| 164 |
+
margin-bottom: 12px;
|
| 165 |
+
padding-bottom: 8px;
|
| 166 |
border-bottom: 1px solid rgba(245, 240, 232, 0.07);
|
| 167 |
}
|
| 168 |
|
| 169 |
+
.pv-report-logo {
|
| 170 |
font-weight: 800;
|
| 171 |
+
font-size: 13px;
|
| 172 |
letter-spacing: 0.12em;
|
| 173 |
color: #f5f0e8;
|
| 174 |
}
|
| 175 |
|
| 176 |
+
.pv-report-close {
|
| 177 |
background: none;
|
| 178 |
border: none;
|
| 179 |
cursor: pointer;
|
| 180 |
color: #5c554e;
|
| 181 |
+
font-size: 14px;
|
| 182 |
+
padding: 2px 6px;
|
| 183 |
+
border-radius: 4px;
|
| 184 |
touch-action: manipulation;
|
| 185 |
+
transition: color 0.15s ease;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.pv-report-close:hover {
|
| 189 |
+
color: #f5f0e8;
|
| 190 |
+
background: rgba(245, 240, 232, 0.05);
|
| 191 |
}
|
|
|
|
|
|
|
| 192 |
|
| 193 |
+
.pv-report-close:focus-visible {
|
| 194 |
+
outline: 2px solid #06b6d4;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* β Verdict row */
|
| 198 |
+
.pv-report-verdict-row {
|
| 199 |
+
padding: 10px 12px;
|
| 200 |
+
margin-bottom: 12px;
|
| 201 |
+
border-left: 3px solid #5c554e;
|
| 202 |
+
border-radius: 0 4px 4px 0;
|
| 203 |
+
background: rgba(245, 240, 232, 0.03);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.pv-report-verdict {
|
| 207 |
+
font-size: 18px;
|
| 208 |
+
font-weight: 800;
|
| 209 |
+
letter-spacing: -0.01em;
|
| 210 |
+
margin-bottom: 2px;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.pv-report-score-text {
|
| 214 |
+
font-size: 10px;
|
| 215 |
+
color: #a89f94;
|
| 216 |
+
font-family: 'SF Mono', 'Menlo', monospace;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
/* β Confidence bar */
|
| 220 |
+
.pv-confidence-bar-wrap {
|
| 221 |
+
display: flex;
|
| 222 |
+
align-items: center;
|
| 223 |
+
gap: 8px;
|
| 224 |
+
padding: 8px 0;
|
| 225 |
+
border-bottom: 1px solid rgba(245, 240, 232, 0.05);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.pv-confidence-bar-track {
|
| 229 |
+
flex: 1;
|
| 230 |
+
height: 6px;
|
| 231 |
+
background: rgba(245, 240, 232, 0.07);
|
| 232 |
+
border-radius: 3px;
|
| 233 |
+
overflow: hidden;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.pv-confidence-bar-fill {
|
| 237 |
+
height: 100%;
|
| 238 |
+
border-radius: 3px;
|
| 239 |
+
transition: width 0.5s ease-out;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.pv-confidence-bar-value {
|
| 243 |
+
font-size: 10px;
|
| 244 |
+
font-weight: 700;
|
| 245 |
+
color: #a89f94;
|
| 246 |
+
font-family: 'SF Mono', 'Menlo', monospace;
|
| 247 |
+
min-width: 36px;
|
| 248 |
+
text-align: right;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/* β Info rows */
|
| 252 |
+
.pv-report-row {
|
| 253 |
display: flex;
|
| 254 |
justify-content: space-between;
|
| 255 |
align-items: center;
|
| 256 |
+
padding: 6px 0;
|
| 257 |
border-bottom: 1px solid rgba(245, 240, 232, 0.05);
|
| 258 |
}
|
| 259 |
|
| 260 |
+
.pv-report-label {
|
| 261 |
font-size: 9px;
|
| 262 |
font-weight: 700;
|
| 263 |
letter-spacing: 0.12em;
|
| 264 |
color: #5c554e;
|
| 265 |
text-transform: uppercase;
|
| 266 |
+
flex-shrink: 0;
|
| 267 |
}
|
| 268 |
|
| 269 |
+
.pv-report-value {
|
| 270 |
font-size: 11px;
|
| 271 |
+
font-weight: 500;
|
| 272 |
color: #a89f94;
|
| 273 |
}
|
| 274 |
|
| 275 |
+
/* β Suspicious signals tags */
|
| 276 |
+
.pv-report-signals {
|
| 277 |
+
padding: 8px 0;
|
| 278 |
border-bottom: 1px solid rgba(245, 240, 232, 0.05);
|
| 279 |
}
|
| 280 |
|
| 281 |
+
.pv-report-tags {
|
| 282 |
display: flex;
|
| 283 |
flex-wrap: wrap;
|
| 284 |
gap: 4px;
|
| 285 |
+
margin-top: 6px;
|
| 286 |
}
|
| 287 |
|
| 288 |
+
.pv-report-tag {
|
| 289 |
+
padding: 3px 8px;
|
| 290 |
background: rgba(220, 38, 38, 0.12);
|
| 291 |
color: #f87171;
|
| 292 |
border: 1px solid rgba(220, 38, 38, 0.25);
|
| 293 |
+
border-radius: 3px;
|
| 294 |
font-size: 9px;
|
| 295 |
letter-spacing: 0.04em;
|
| 296 |
font-weight: 600;
|
| 297 |
}
|
| 298 |
|
| 299 |
+
/* β Evidence sources */
|
| 300 |
+
.pv-report-sources {
|
| 301 |
+
padding: 8px 0;
|
| 302 |
border-bottom: 1px solid rgba(245, 240, 232, 0.05);
|
| 303 |
}
|
| 304 |
|
| 305 |
+
.pv-report-sources-list {
|
| 306 |
+
list-style: none;
|
| 307 |
+
padding: 0;
|
| 308 |
+
margin: 6px 0 0 0;
|
| 309 |
+
display: flex;
|
| 310 |
+
flex-direction: column;
|
| 311 |
+
gap: 4px;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.pv-report-source-item {
|
| 315 |
+
display: flex;
|
| 316 |
+
align-items: center;
|
| 317 |
+
justify-content: space-between;
|
| 318 |
+
gap: 8px;
|
| 319 |
+
padding: 4px 0;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.pv-report-source-link {
|
| 323 |
color: #06b6d4;
|
| 324 |
font-size: 10px;
|
| 325 |
text-decoration: none;
|
| 326 |
overflow: hidden;
|
| 327 |
text-overflow: ellipsis;
|
| 328 |
white-space: nowrap;
|
| 329 |
+
flex: 1;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.pv-report-source-link:hover {
|
| 333 |
+
text-decoration: underline;
|
| 334 |
}
|
|
|
|
| 335 |
|
| 336 |
+
.pv-report-source-stance {
|
| 337 |
+
font-size: 9px;
|
| 338 |
+
font-weight: 700;
|
| 339 |
+
letter-spacing: 0.06em;
|
| 340 |
+
color: #5c554e;
|
| 341 |
+
flex-shrink: 0;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/* β Explanation / Claim */
|
| 345 |
+
.pv-report-explanation {
|
| 346 |
+
padding: 8px 0;
|
| 347 |
+
border-bottom: 1px solid rgba(245, 240, 232, 0.05);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.pv-report-explanation-text {
|
| 351 |
+
margin: 6px 0 0;
|
| 352 |
+
font-size: 10px;
|
| 353 |
+
color: #a89f94;
|
| 354 |
+
line-height: 1.5;
|
| 355 |
+
font-style: italic;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/* β Full analysis link */
|
| 359 |
+
.pv-report-full-link {
|
| 360 |
display: block;
|
| 361 |
+
margin-top: 10px;
|
| 362 |
text-align: center;
|
| 363 |
color: #dc2626;
|
| 364 |
font-size: 10px;
|
|
|
|
| 366 |
letter-spacing: 0.08em;
|
| 367 |
text-decoration: none;
|
| 368 |
text-transform: uppercase;
|
| 369 |
+
padding: 6px;
|
| 370 |
border: 1px solid rgba(220, 38, 38, 0.3);
|
| 371 |
+
border-radius: 4px;
|
| 372 |
+
transition: background 0.15s ease;
|
| 373 |
}
|
| 374 |
+
|
| 375 |
+
.pv-report-full-link:hover {
|
| 376 |
background: rgba(220, 38, 38, 0.08);
|
| 377 |
+
}
|
extension/content.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
| 1 |
/**
|
| 2 |
-
* PhilVerify β Content Script (Facebook feed scanner)
|
| 3 |
*
|
| 4 |
-
*
|
| 5 |
-
*
|
| 6 |
-
*
|
| 7 |
-
*
|
| 8 |
-
*
|
| 9 |
*
|
| 10 |
-
*
|
| 11 |
-
*
|
| 12 |
-
* Uses `data-philverify` attribute to mark already-processed posts.
|
| 13 |
*/
|
| 14 |
|
| 15 |
-
;(function philverifyContentScript() {
|
| 16 |
'use strict'
|
| 17 |
|
| 18 |
// ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -20,7 +19,16 @@
|
|
| 20 |
/** Minimum text length to send for verification (avoids verifying 1-word posts) */
|
| 21 |
const MIN_TEXT_LENGTH = 40
|
| 22 |
|
| 23 |
-
/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
const PLATFORM = (() => {
|
| 25 |
const h = window.location.hostname
|
| 26 |
if (h.includes('facebook.com')) return 'facebook'
|
|
@@ -30,19 +38,37 @@
|
|
| 30 |
|
| 31 |
const IS_SOCIAL = PLATFORM === 'facebook' || PLATFORM === 'twitter'
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const PLATFORM_CFG = {
|
| 34 |
facebook: {
|
| 35 |
-
//
|
| 36 |
-
//
|
| 37 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
post: [
|
| 39 |
-
'[
|
| 40 |
-
'[data-pagelet^="GroupsFeedUnit"]',
|
| 41 |
-
'[data-pagelet^="ProfileTimeline"]',
|
| 42 |
-
'[role="article"]',
|
| 43 |
],
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
image: 'img[src*="fbcdn"]',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
link: ['a[href*="l.facebook.com/l.php"]', 'a[role="link"][href*="http"]'],
|
| 47 |
unwrapUrl(el) {
|
| 48 |
try { return new URL(el.href).searchParams.get('u') || el.href } catch { return el.href }
|
|
@@ -51,7 +77,12 @@
|
|
| 51 |
twitter: {
|
| 52 |
post: ['article[data-testid="tweet"]'],
|
| 53 |
text: ['[data-testid="tweetText"]'],
|
|
|
|
|
|
|
| 54 |
image: 'img[src*="pbs.twimg.com/media"]',
|
|
|
|
|
|
|
|
|
|
| 55 |
link: ['a[href*="t.co/"]', 'a[data-testid="card.layoutSmall.media"]'],
|
| 56 |
unwrapUrl(el) { return el.href },
|
| 57 |
},
|
|
@@ -59,6 +90,7 @@
|
|
| 59 |
post: ['[role="article"]', 'article', 'main'],
|
| 60 |
text: ['h1', '.article-body', '.entry-content', 'article'],
|
| 61 |
image: null,
|
|
|
|
| 62 |
link: [],
|
| 63 |
unwrapUrl(el) { return el.href },
|
| 64 |
},
|
|
@@ -67,20 +99,27 @@
|
|
| 67 |
const CFG = PLATFORM_CFG[PLATFORM] ?? PLATFORM_CFG.facebook
|
| 68 |
const POST_SELECTORS = CFG.post
|
| 69 |
|
|
|
|
|
|
|
| 70 |
const VERDICT_COLORS = {
|
| 71 |
-
'Credible':
|
| 72 |
-
'Unverified':
|
| 73 |
'Likely Fake': '#dc2626',
|
| 74 |
}
|
| 75 |
const VERDICT_LABELS = {
|
| 76 |
-
'Credible':
|
| 77 |
-
'Unverified':
|
| 78 |
'Likely Fake': 'β Likely Fake',
|
| 79 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
// ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 82 |
|
| 83 |
-
/** Escape HTML special chars to prevent XSS
|
| 84 |
function safeText(str) {
|
| 85 |
if (str == null) return ''
|
| 86 |
return String(str)
|
|
@@ -100,258 +139,634 @@
|
|
| 100 |
} catch { return '#' }
|
| 101 |
}
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
function extractPostText(post) {
|
|
|
|
|
|
|
|
|
|
| 104 |
for (const sel of CFG.text) {
|
| 105 |
const el = post.querySelector(sel)
|
| 106 |
-
if (el?.innerText?.trim().length >= MIN_TEXT_LENGTH)
|
|
|
|
| 107 |
return el.innerText.trim().slice(0, 2000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
-
|
|
|
|
| 110 |
for (const span of post.querySelectorAll('span')) {
|
| 111 |
const t = span.innerText?.trim()
|
| 112 |
-
if (t && t.length >= MIN_TEXT_LENGTH && !t.startsWith('http'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
}
|
|
|
|
|
|
|
| 114 |
return null
|
| 115 |
}
|
| 116 |
|
| 117 |
function extractPostUrl(post) {
|
| 118 |
-
for (const sel of CFG.link) {
|
| 119 |
const el = post.querySelector(sel)
|
| 120 |
if (el?.href) return CFG.unwrapUrl(el)
|
| 121 |
}
|
| 122 |
return null
|
| 123 |
}
|
| 124 |
|
| 125 |
-
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
function extractPostImage(post) {
|
| 127 |
if (!CFG.image) return null
|
| 128 |
-
|
| 129 |
-
const
|
| 130 |
-
if (!
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
(b.naturalWidth || b.width || 0) > (a.naturalWidth || a.width || 0) ? b : a
|
| 133 |
)
|
| 134 |
const src = best.src || best.dataset?.src
|
| 135 |
if (!src || !src.startsWith('http')) return null
|
|
|
|
| 136 |
return src
|
| 137 |
}
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
}
|
| 200 |
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
}
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
${safeText(topSource.title?.slice(0, 60) ?? topSource.source_name ?? 'View source')} β
|
| 247 |
-
</a>
|
| 248 |
-
</div>` : ''}
|
| 249 |
-
<a href="https://philverify.web.app" target="_blank" rel="noreferrer" class="pv-open-full">
|
| 250 |
-
Open full analysis β
|
| 251 |
-
</a>
|
| 252 |
-
`
|
| 253 |
-
|
| 254 |
-
panel.querySelector('.pv-close').addEventListener('click', (e) => {
|
| 255 |
e.stopPropagation()
|
| 256 |
-
|
|
|
|
| 257 |
})
|
| 258 |
|
| 259 |
-
|
| 260 |
-
}
|
| 261 |
|
| 262 |
-
|
| 263 |
-
// Find a
|
| 264 |
-
|
|
|
|
|
|
|
| 265 |
if (PLATFORM === 'facebook') {
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
}
|
| 274 |
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
container.appendChild(badge)
|
| 279 |
-
|
| 280 |
-
if (anchor && anchor !== post) {
|
| 281 |
-
anchor.insertAdjacentElement('beforebegin', container)
|
| 282 |
-
} else {
|
| 283 |
-
post.appendChild(container)
|
| 284 |
}
|
| 285 |
-
}
|
| 286 |
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
function injectLoadingBadge(post) {
|
| 290 |
-
const container = document.createElement('div')
|
| 291 |
-
container.className = 'pv-badge-wrap pv-loading'
|
| 292 |
-
container.setAttribute('aria-label', 'PhilVerify: verifyingβ¦')
|
| 293 |
-
container.innerHTML = `
|
| 294 |
-
<div class="pv-badge pv-badge--loading">
|
| 295 |
-
<span class="pv-spinner" aria-hidden="true"></span>
|
| 296 |
-
<span>Verifyingβ¦</span>
|
| 297 |
-
</div>
|
| 298 |
-
`
|
| 299 |
-
post.appendChild(container)
|
| 300 |
-
return container
|
| 301 |
}
|
| 302 |
|
| 303 |
-
// ββ
|
| 304 |
-
|
| 305 |
-
async function
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
const
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
const image = extractPostImage(post)
|
| 313 |
|
| 314 |
-
|
| 315 |
-
if (!text && !url && !image) return
|
| 316 |
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
try {
|
| 320 |
let msgPayload
|
|
|
|
| 321 |
if (url) {
|
| 322 |
-
// A shared article link is the most informative signal
|
| 323 |
msgPayload = { type: 'VERIFY_URL', url }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
} else if (text) {
|
| 325 |
-
// Caption / tweet text
|
| 326 |
msgPayload = { type: 'VERIFY_TEXT', text }
|
|
|
|
| 327 |
} else {
|
| 328 |
-
// Image-only post β send to OCR endpoint
|
| 329 |
msgPayload = { type: 'VERIFY_IMAGE_URL', imageUrl: image }
|
|
|
|
| 330 |
}
|
| 331 |
|
| 332 |
const response = await new Promise((resolve, reject) => {
|
| 333 |
chrome.runtime.sendMessage(msgPayload, (resp) => {
|
| 334 |
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message))
|
| 335 |
-
else if (!resp?.ok)
|
| 336 |
-
else
|
| 337 |
})
|
| 338 |
})
|
| 339 |
|
| 340 |
-
|
| 341 |
-
|
| 342 |
} catch (err) {
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
errBadge.className = 'pv-badge-wrap'
|
| 346 |
-
const errInner = document.createElement('div')
|
| 347 |
-
errInner.className = 'pv-badge pv-badge--error'
|
| 348 |
-
errInner.title = err.message
|
| 349 |
-
errInner.textContent = 'β PhilVerify offline'
|
| 350 |
-
errBadge.appendChild(errInner)
|
| 351 |
-
post.appendChild(errBadge)
|
| 352 |
}
|
| 353 |
}
|
| 354 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
// ββ MutationObserver ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 356 |
|
| 357 |
const pendingPosts = new Set()
|
|
@@ -359,7 +774,7 @@
|
|
| 359 |
|
| 360 |
function flushPosts() {
|
| 361 |
rafScheduled = false
|
| 362 |
-
for (const post of pendingPosts)
|
| 363 |
pendingPosts.clear()
|
| 364 |
}
|
| 365 |
|
|
@@ -371,39 +786,29 @@
|
|
| 371 |
}
|
| 372 |
}
|
| 373 |
|
| 374 |
-
function findPosts(root) {
|
| 375 |
-
for (const sel of POST_SELECTORS) {
|
| 376 |
-
const found = Array.from(root.querySelectorAll(sel))
|
| 377 |
-
if (!found.length) continue
|
| 378 |
-
|
| 379 |
-
// For [role="article"] on Facebook we must filter out comment items.
|
| 380 |
-
// A comment article is always nested inside a parent [role="article"].
|
| 381 |
-
if (PLATFORM === 'facebook' && sel === '[role="article"]') {
|
| 382 |
-
const topLevel = found.filter(el => !el.parentElement?.closest('[role="article"]'))
|
| 383 |
-
if (topLevel.length) return topLevel
|
| 384 |
-
continue
|
| 385 |
-
}
|
| 386 |
-
|
| 387 |
-
return found
|
| 388 |
-
}
|
| 389 |
-
return []
|
| 390 |
-
}
|
| 391 |
-
|
| 392 |
const observer = new MutationObserver((mutations) => {
|
| 393 |
for (const mutation of mutations) {
|
| 394 |
for (const node of mutation.addedNodes) {
|
| 395 |
if (node.nodeType !== 1) continue // element nodes only
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
}
|
| 405 |
}
|
| 406 |
-
|
|
|
|
| 407 |
const posts = findPosts(node)
|
| 408 |
for (const post of posts) scheduleProcess(post)
|
| 409 |
}
|
|
@@ -413,22 +818,39 @@
|
|
| 413 |
// ββ Initialization ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 414 |
|
| 415 |
async function init() {
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
})
|
| 422 |
-
}
|
|
|
|
|
|
|
| 423 |
|
| 424 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
|
| 426 |
// Process any posts already in the DOM
|
| 427 |
const existing = findPosts(document.body)
|
|
|
|
| 428 |
for (const post of existing) scheduleProcess(post)
|
| 429 |
|
| 430 |
-
// Watch for new posts (
|
| 431 |
observer.observe(document.body, { childList: true, subtree: true })
|
|
|
|
| 432 |
}
|
| 433 |
|
| 434 |
init()
|
|
@@ -438,7 +860,7 @@
|
|
| 438 |
// it auto-verifies the current URL and injects a floating verdict banner.
|
| 439 |
|
| 440 |
async function autoVerifyPage() {
|
| 441 |
-
const url
|
| 442 |
const path = new URL(url).pathname
|
| 443 |
// Skip homepages and section indexes (very short paths like / or /news)
|
| 444 |
if (!path || path.length < 8 || path.split('/').filter(Boolean).length < 2) return
|
|
@@ -455,36 +877,58 @@
|
|
| 455 |
'box-shadow:0 2px 16px rgba(0,0,0,0.6)',
|
| 456 |
].join(';')
|
| 457 |
|
| 458 |
-
banner
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
document.body.insertAdjacentElement('afterbegin', banner)
|
| 484 |
-
// Push page content down so banner doesn't overlap
|
| 485 |
document.documentElement.style.marginTop = '36px'
|
| 486 |
|
| 487 |
-
|
| 488 |
banner.remove()
|
| 489 |
document.documentElement.style.marginTop = ''
|
| 490 |
})
|
|
@@ -493,29 +937,41 @@
|
|
| 493 |
const response = await new Promise((resolve, reject) => {
|
| 494 |
chrome.runtime.sendMessage({ type: 'VERIFY_URL', url }, (resp) => {
|
| 495 |
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message))
|
| 496 |
-
else if (!resp?.ok)
|
| 497 |
-
else
|
| 498 |
})
|
| 499 |
})
|
| 500 |
|
| 501 |
-
const color
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
}
|
|
|
|
| 513 |
banner.style.borderBottomColor = color + '88'
|
| 514 |
-
// Update full-analysis link to deep-link with the URL pre-filled
|
| 515 |
-
const fullLink = document.getElementById('pv-open-full')
|
| 516 |
-
if (fullLink) fullLink.href = `https://philverify.web.app`
|
| 517 |
|
| 518 |
-
// Auto-dismiss if credible
|
| 519 |
if (response.verdict === 'Credible') {
|
| 520 |
setTimeout(() => {
|
| 521 |
if (document.contains(banner)) {
|
|
@@ -533,14 +989,20 @@
|
|
| 533 |
if (!IS_SOCIAL) {
|
| 534 |
autoVerifyPage()
|
| 535 |
}
|
|
|
|
|
|
|
| 536 |
chrome.storage.onChanged.addListener((changes, area) => {
|
| 537 |
if (area !== 'local' || !changes.settings) return
|
| 538 |
const autoScan = changes.settings.newValue?.autoScan
|
| 539 |
if (autoScan === false) {
|
| 540 |
observer.disconnect()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
} else if (autoScan === true) {
|
| 542 |
observer.observe(document.body, { childList: true, subtree: true })
|
| 543 |
-
// Process any posts that appeared while scanning was paused
|
| 544 |
const existing = findPosts(document.body)
|
| 545 |
for (const post of existing) scheduleProcess(post)
|
| 546 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
+
* PhilVerify β Content Script (Twitter/X + Facebook feed scanner)
|
| 3 |
*
|
| 4 |
+
* Click-triggered verification model:
|
| 5 |
+
* 1. Watches for posts via MutationObserver (infinite scroll support)
|
| 6 |
+
* 2. Injects a floating "Verify this post" button on each post that has content
|
| 7 |
+
* 3. On click: extracts caption + image, sends to background.js β PhilVerify API
|
| 8 |
+
* 4. Displays a full inline verification report (verdict, confidence, evidence, etc.)
|
| 9 |
*
|
| 10 |
+
* Skips posts with no text AND no image. Never injects on comments.
|
| 11 |
+
* Uses `data-philverify-btn` attribute to prevent duplicate buttons.
|
|
|
|
| 12 |
*/
|
| 13 |
|
| 14 |
+
; (function philverifyContentScript() {
|
| 15 |
'use strict'
|
| 16 |
|
| 17 |
// ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 19 |
/** Minimum text length to send for verification (avoids verifying 1-word posts) */
|
| 20 |
const MIN_TEXT_LENGTH = 40
|
| 21 |
|
| 22 |
+
/** Minimum image dimension (px) to consider a real content image (filters avatars/icons) */
|
| 23 |
+
const MIN_IMAGE_SIZE = 100
|
| 24 |
+
|
| 25 |
+
/** Enable debug logging to console */
|
| 26 |
+
const DEBUG = true
|
| 27 |
+
function log(...args) { if (DEBUG) console.log('[PhilVerify]', ...args) }
|
| 28 |
+
function warn(...args) { if (DEBUG) console.warn('[PhilVerify]', ...args) }
|
| 29 |
+
|
| 30 |
+
// ββ Platform detection ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
|
| 32 |
const PLATFORM = (() => {
|
| 33 |
const h = window.location.hostname
|
| 34 |
if (h.includes('facebook.com')) return 'facebook'
|
|
|
|
| 38 |
|
| 39 |
const IS_SOCIAL = PLATFORM === 'facebook' || PLATFORM === 'twitter'
|
| 40 |
|
| 41 |
+
// ββ Platform-specific selectors βββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
// Stored in a single config object for easy maintenance when platforms
|
| 43 |
+
// update their DOM. Prefer data-testid / role attributes over class names.
|
| 44 |
+
|
| 45 |
const PLATFORM_CFG = {
|
| 46 |
facebook: {
|
| 47 |
+
// Facebook's DOM is intentionally unstable. The most reliable anchor is
|
| 48 |
+
// [role="feed"] β the WAI-ARIA feed landmark required for accessibility.
|
| 49 |
+
// Direct children of the feed container are always posts (wrapped in divs),
|
| 50 |
+
// while comments are nested deeper inside each post's [role="article"].
|
| 51 |
+
//
|
| 52 |
+
// We do NOT rely on data-pagelet attributes β Facebook removed/renamed them.
|
| 53 |
+
// [role="article"] is used as a last-resort fallback with extra filtering.
|
| 54 |
post: [
|
| 55 |
+
'[role="article"]', // Filtered by findPosts() β only feed-level articles
|
|
|
|
|
|
|
|
|
|
| 56 |
],
|
| 57 |
+
// Text selectors ordered by specificity
|
| 58 |
+
text: [
|
| 59 |
+
'[data-ad-comet-preview="message"]',
|
| 60 |
+
'[data-testid="post_message"]',
|
| 61 |
+
],
|
| 62 |
+
// Exclude avatars explicitly: fbcdn images that are NOT inside avatar
|
| 63 |
+
// containers, and are large enough to be actual post content.
|
| 64 |
image: 'img[src*="fbcdn"]',
|
| 65 |
+
// Selectors for containers known to hold avatar images β used to filter them out
|
| 66 |
+
avatarContainers: [
|
| 67 |
+
'[data-testid="profile_photo_image"]',
|
| 68 |
+
'a[aria-label*="profile picture"]',
|
| 69 |
+
'svg image', // avatar circles rendered in SVG
|
| 70 |
+
'[role="img"][aria-label]', // decorative profile icons
|
| 71 |
+
],
|
| 72 |
link: ['a[href*="l.facebook.com/l.php"]', 'a[role="link"][href*="http"]'],
|
| 73 |
unwrapUrl(el) {
|
| 74 |
try { return new URL(el.href).searchParams.get('u') || el.href } catch { return el.href }
|
|
|
|
| 77 |
twitter: {
|
| 78 |
post: ['article[data-testid="tweet"]'],
|
| 79 |
text: ['[data-testid="tweetText"]'],
|
| 80 |
+
// pbs.twimg.com/media is specifically for tweet media, NOT profile avatars
|
| 81 |
+
// (avatars use pbs.twimg.com/profile_images)
|
| 82 |
image: 'img[src*="pbs.twimg.com/media"]',
|
| 83 |
+
avatarContainers: [
|
| 84 |
+
'[data-testid="Tweet-User-Avatar"]',
|
| 85 |
+
],
|
| 86 |
link: ['a[href*="t.co/"]', 'a[data-testid="card.layoutSmall.media"]'],
|
| 87 |
unwrapUrl(el) { return el.href },
|
| 88 |
},
|
|
|
|
| 90 |
post: ['[role="article"]', 'article', 'main'],
|
| 91 |
text: ['h1', '.article-body', '.entry-content', 'article'],
|
| 92 |
image: null,
|
| 93 |
+
avatarContainers: [],
|
| 94 |
link: [],
|
| 95 |
unwrapUrl(el) { return el.href },
|
| 96 |
},
|
|
|
|
| 99 |
const CFG = PLATFORM_CFG[PLATFORM] ?? PLATFORM_CFG.facebook
|
| 100 |
const POST_SELECTORS = CFG.post
|
| 101 |
|
| 102 |
+
// ββ Verdict colors & labels βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 103 |
+
|
| 104 |
const VERDICT_COLORS = {
|
| 105 |
+
'Credible': '#16a34a',
|
| 106 |
+
'Unverified': '#d97706',
|
| 107 |
'Likely Fake': '#dc2626',
|
| 108 |
}
|
| 109 |
const VERDICT_LABELS = {
|
| 110 |
+
'Credible': 'β Credible',
|
| 111 |
+
'Unverified': '? Unverified',
|
| 112 |
'Likely Fake': 'β Likely Fake',
|
| 113 |
}
|
| 114 |
+
const VERDICT_BG = {
|
| 115 |
+
'Credible': 'rgba(22, 163, 74, 0.12)',
|
| 116 |
+
'Unverified': 'rgba(217, 119, 6, 0.12)',
|
| 117 |
+
'Likely Fake': 'rgba(220, 38, 38, 0.12)',
|
| 118 |
+
}
|
| 119 |
|
| 120 |
// ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
|
| 122 |
+
/** Escape HTML special chars to prevent XSS */
|
| 123 |
function safeText(str) {
|
| 124 |
if (str == null) return ''
|
| 125 |
return String(str)
|
|
|
|
| 139 |
} catch { return '#' }
|
| 140 |
}
|
| 141 |
|
| 142 |
+
// ββ Content extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 143 |
+
|
| 144 |
+
/**
|
| 145 |
+
* Try to expand Facebook's "See more" truncation before extracting text.
|
| 146 |
+
* Clicks the "See more" link if found. Only targets known truncation patterns.
|
| 147 |
+
*/
|
| 148 |
+
function expandSeeMore(post) {
|
| 149 |
+
if (PLATFORM === 'facebook') {
|
| 150 |
+
// Facebook uses div[role="button"] or <span> with "See more" / "See More"
|
| 151 |
+
const buttons = post.querySelectorAll('[role="button"]')
|
| 152 |
+
for (const btn of buttons) {
|
| 153 |
+
const txt = btn.textContent?.trim()
|
| 154 |
+
if (txt && /^see\s*more$/i.test(txt) && btn.offsetHeight < 30) {
|
| 155 |
+
try {
|
| 156 |
+
btn.click()
|
| 157 |
+
log('Expanded "See more" on Facebook post')
|
| 158 |
+
} catch (e) {
|
| 159 |
+
warn('Failed to expand "See more":', e)
|
| 160 |
+
}
|
| 161 |
+
return
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
if (PLATFORM === 'twitter') {
|
| 166 |
+
// Twitter uses a "Show more" link inside truncated tweets
|
| 167 |
+
const showMore = post.querySelector('[data-testid="tweet-text-show-more-link"]')
|
| 168 |
+
if (showMore) {
|
| 169 |
+
try {
|
| 170 |
+
showMore.click()
|
| 171 |
+
log('Expanded "Show more" on Twitter post')
|
| 172 |
+
} catch (e) {
|
| 173 |
+
warn('Failed to expand Twitter "Show more":', e)
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
function extractPostText(post) {
|
| 180 |
+
expandSeeMore(post)
|
| 181 |
+
|
| 182 |
+
// Primary selectors β platform-specific, high confidence
|
| 183 |
for (const sel of CFG.text) {
|
| 184 |
const el = post.querySelector(sel)
|
| 185 |
+
if (el?.innerText?.trim().length >= MIN_TEXT_LENGTH) {
|
| 186 |
+
log('Text extracted via primary selector:', sel)
|
| 187 |
return el.innerText.trim().slice(0, 2000)
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// Facebook fallback: look for [dir="auto"] ONLY inside known message containers
|
| 192 |
+
if (PLATFORM === 'facebook') {
|
| 193 |
+
const messageContainers = post.querySelectorAll(
|
| 194 |
+
'[data-ad-comet-preview="message"] [dir="auto"], [data-testid="post_message"] [dir="auto"]'
|
| 195 |
+
)
|
| 196 |
+
for (const el of messageContainers) {
|
| 197 |
+
const t = el.innerText?.trim()
|
| 198 |
+
if (t && t.length >= MIN_TEXT_LENGTH) {
|
| 199 |
+
log('Text extracted via scoped [dir="auto"] fallback')
|
| 200 |
+
return t.slice(0, 2000)
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
// Last resort: standalone [dir="auto"] with substantial text,
|
| 204 |
+
// excluding comments, headers, and nav elements
|
| 205 |
+
for (const el of post.querySelectorAll('[dir="auto"]')) {
|
| 206 |
+
if (el.closest('[role="navigation"]') || el.closest('header') || el.closest('[data-testid="UFI2Comment"]')) continue
|
| 207 |
+
// Also skip if inside a nested comment article
|
| 208 |
+
const parentArticle = el.closest('[role="article"]')
|
| 209 |
+
if (parentArticle && parentArticle !== post) continue
|
| 210 |
+
const t = el.innerText?.trim()
|
| 211 |
+
if (t && t.length >= MIN_TEXT_LENGTH && !t.startsWith('http')) {
|
| 212 |
+
log('Text extracted via broad [dir="auto"] fallback (filtered)')
|
| 213 |
+
return t.slice(0, 2000)
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
}
|
| 217 |
+
|
| 218 |
+
// General fallback: any span with substantial text
|
| 219 |
for (const span of post.querySelectorAll('span')) {
|
| 220 |
const t = span.innerText?.trim()
|
| 221 |
+
if (t && t.length >= MIN_TEXT_LENGTH && !t.startsWith('http')) {
|
| 222 |
+
// Skip if inside a nested comment article
|
| 223 |
+
const parentArticle = span.closest('[role="article"]')
|
| 224 |
+
if (parentArticle && parentArticle !== post) continue
|
| 225 |
+
log('Text extracted via span fallback')
|
| 226 |
+
return t.slice(0, 2000)
|
| 227 |
+
}
|
| 228 |
}
|
| 229 |
+
|
| 230 |
+
log('No text found in post')
|
| 231 |
return null
|
| 232 |
}
|
| 233 |
|
| 234 |
function extractPostUrl(post) {
|
| 235 |
+
for (const sel of (CFG.link ?? [])) {
|
| 236 |
const el = post.querySelector(sel)
|
| 237 |
if (el?.href) return CFG.unwrapUrl(el)
|
| 238 |
}
|
| 239 |
return null
|
| 240 |
}
|
| 241 |
|
| 242 |
+
/**
|
| 243 |
+
* Returns the src of the most prominent content image in a post, or null.
|
| 244 |
+
* Filters out avatars, icons, emoji, and tracking pixels by:
|
| 245 |
+
* 1. Excluding images inside known avatar containers
|
| 246 |
+
* 2. Requiring a minimum rendered dimension (MIN_IMAGE_SIZE)
|
| 247 |
+
* 3. Preferring the largest image by naturalWidth
|
| 248 |
+
*/
|
| 249 |
function extractPostImage(post) {
|
| 250 |
if (!CFG.image) return null
|
| 251 |
+
|
| 252 |
+
const allImgs = Array.from(post.querySelectorAll(CFG.image))
|
| 253 |
+
if (!allImgs.length) { log('No candidate images found'); return null }
|
| 254 |
+
|
| 255 |
+
// Build a set of avatar container elements to check ancestry against
|
| 256 |
+
const avatarContainers = (CFG.avatarContainers ?? []).flatMap(sel =>
|
| 257 |
+
Array.from(post.querySelectorAll(sel))
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
const contentImgs = allImgs.filter(img => {
|
| 261 |
+
// Exclude images nested inside avatar containers
|
| 262 |
+
const isAvatar = avatarContainers.some(container => container.contains(img))
|
| 263 |
+
if (isAvatar) return false
|
| 264 |
+
|
| 265 |
+
// Exclude tiny images (avatars are typically 36-48px; icons < 24px)
|
| 266 |
+
const w = img.naturalWidth || img.width || 0
|
| 267 |
+
const h = img.naturalHeight || img.height || 0
|
| 268 |
+
if (w < MIN_IMAGE_SIZE && h < MIN_IMAGE_SIZE) return false
|
| 269 |
+
|
| 270 |
+
// Exclude common avatar URL patterns
|
| 271 |
+
const src = img.src || ''
|
| 272 |
+
if (PLATFORM === 'facebook' && /\/p\d+x\d+\//.test(src)) return false
|
| 273 |
+
if (PLATFORM === 'twitter' && src.includes('profile_images')) return false
|
| 274 |
+
|
| 275 |
+
return true
|
| 276 |
+
})
|
| 277 |
+
|
| 278 |
+
if (!contentImgs.length) { log('All images filtered out (avatars/icons)'); return null }
|
| 279 |
+
|
| 280 |
+
// Pick the largest content image (best representative for carousel posts)
|
| 281 |
+
const best = contentImgs.reduce((a, b) =>
|
| 282 |
(b.naturalWidth || b.width || 0) > (a.naturalWidth || a.width || 0) ? b : a
|
| 283 |
)
|
| 284 |
const src = best.src || best.dataset?.src
|
| 285 |
if (!src || !src.startsWith('http')) return null
|
| 286 |
+
log('Image extracted:', src.slice(0, 80) + 'β¦')
|
| 287 |
return src
|
| 288 |
}
|
| 289 |
|
| 290 |
+
// ββ Post discovery (feed-based strategy) βββββββββββββββββββββββββββββββββββ
|
| 291 |
+
|
| 292 |
+
/**
|
| 293 |
+
* Checks if a [role="article"] element is a top-level post (not a comment).
|
| 294 |
+
*
|
| 295 |
+
* PRIMARY STRATEGY: Use [role="feed"] as the anchor.
|
| 296 |
+
* - [role="feed"] is a WAI-ARIA landmark that Facebook keeps for accessibility.
|
| 297 |
+
* - Direct children of the feed are always posts (wrapped in <div> containers).
|
| 298 |
+
* - Comments are always deeper nested inside another [role="article"].
|
| 299 |
+
*
|
| 300 |
+
* This function checks:
|
| 301 |
+
* 1. Is this article a direct descendant of [role="feed"]? β It's a post.
|
| 302 |
+
* 2. Is this article nested inside another article? β It's a comment.
|
| 303 |
+
* 3. Neither? Use URL-based heuristic for detail pages.
|
| 304 |
+
*/
|
| 305 |
+
function isTopLevelPost(el) {
|
| 306 |
+
if (PLATFORM !== 'facebook') return true
|
| 307 |
+
if (el.getAttribute('role') !== 'article') return true
|
| 308 |
+
|
| 309 |
+
// ββ Check 1: Is this article nested inside another article?
|
| 310 |
+
// If yes, it's definitely a comment (true for both feed and detail pages).
|
| 311 |
+
const parentArticle = el.parentElement?.closest('[role="article"]')
|
| 312 |
+
if (parentArticle) {
|
| 313 |
+
log('Skipping comment (nested inside parent article)')
|
| 314 |
+
return false
|
| 315 |
+
}
|
| 316 |
|
| 317 |
+
// ββ Check 2: Is this article a child of [role="feed"]?
|
| 318 |
+
// Direct children of the feed are always posts.
|
| 319 |
+
const feedAncestor = el.closest('[role="feed"]')
|
| 320 |
+
if (feedAncestor) {
|
| 321 |
+
// This article is inside the feed and NOT nested in another article β post
|
| 322 |
+
return true
|
| 323 |
+
}
|
| 324 |
|
| 325 |
+
// ββ Check 3: Not in a feed β could be a detail page.
|
| 326 |
+
// On detail pages (e.g. /posts/123, /permalink/, /photo/),
|
| 327 |
+
// the FIRST [role="article"] on the page is the main post.
|
| 328 |
+
// All subsequent ones are comments.
|
| 329 |
+
const path = window.location.pathname + window.location.search
|
| 330 |
+
const isDetailPage = /\/(posts|photos|permalink|story\.php|watch|reel|videos)/.test(path)
|
| 331 |
+
if (isDetailPage) {
|
| 332 |
+
const allArticles = document.querySelectorAll('[role="article"]')
|
| 333 |
+
if (allArticles.length > 0 && allArticles[0] === el) {
|
| 334 |
+
// First article on a detail page β the main post
|
| 335 |
+
return true
|
| 336 |
+
}
|
| 337 |
+
// Not the first article on a detail page β comment
|
| 338 |
+
log('Skipping comment (detail page, not the first article)')
|
| 339 |
+
return false
|
| 340 |
+
}
|
| 341 |
|
| 342 |
+
// ββ Fallback: Allow it (could be a page layout we haven't seen)
|
| 343 |
+
// Better to show a button on something unexpected than miss a real post.
|
| 344 |
+
return true
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
/**
|
| 348 |
+
* Find posts in the given DOM subtree.
|
| 349 |
+
*
|
| 350 |
+
* Two-pass strategy for Facebook:
|
| 351 |
+
* Pass 1: Find [role="feed"] container β get [role="article"] elements
|
| 352 |
+
* that are direct children of the feed (not nested in other articles)
|
| 353 |
+
* Pass 2: If no feed found (detail pages, etc.), fall back to all
|
| 354 |
+
* [role="article"] elements filtered by isTopLevelPost()
|
| 355 |
+
*
|
| 356 |
+
* For Twitter and other platforms, uses POST_SELECTORS directly.
|
| 357 |
+
*/
|
| 358 |
+
function findPosts(root) {
|
| 359 |
+
if (PLATFORM === 'facebook') {
|
| 360 |
+
// ββ Pass 1: Feed-based detection (most reliable)
|
| 361 |
+
const feeds = root.querySelectorAll('[role="feed"]')
|
| 362 |
+
if (feeds.length === 0 && root.getAttribute?.('role') === 'feed') {
|
| 363 |
+
// root itself might be the feed
|
| 364 |
+
const articles = Array.from(root.querySelectorAll('[role="article"]'))
|
| 365 |
+
.filter(el => !el.parentElement?.closest('[role="article"]'))
|
| 366 |
+
if (articles.length) {
|
| 367 |
+
log(`Found ${articles.length} posts via feed (root is feed)`)
|
| 368 |
+
return articles
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
for (const feed of feeds) {
|
| 372 |
+
// Get all articles inside this feed that are NOT nested in another article
|
| 373 |
+
const articles = Array.from(feed.querySelectorAll('[role="article"]'))
|
| 374 |
+
.filter(el => !el.parentElement?.closest('[role="article"]'))
|
| 375 |
+
if (articles.length) {
|
| 376 |
+
log(`Found ${articles.length} posts via [role="feed"] container`)
|
| 377 |
+
return articles
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
// ββ Pass 2: No feed container found β detail page or unusual layout
|
| 382 |
+
const allArticles = Array.from(root.querySelectorAll('[role="article"]'))
|
| 383 |
+
const topLevel = allArticles.filter(el => isTopLevelPost(el))
|
| 384 |
+
if (topLevel.length) {
|
| 385 |
+
log(`Found ${topLevel.length} posts via fallback (no feed container)`)
|
| 386 |
+
return topLevel
|
| 387 |
+
}
|
| 388 |
+
return []
|
| 389 |
+
}
|
| 390 |
|
| 391 |
+
// Non-Facebook platforms: simple selector matching
|
| 392 |
+
for (const sel of POST_SELECTORS) {
|
| 393 |
+
const found = Array.from(root.querySelectorAll(sel))
|
| 394 |
+
if (found.length) return found
|
| 395 |
+
}
|
| 396 |
+
return []
|
| 397 |
}
|
| 398 |
|
| 399 |
+
// ββ "Verify this post" button injection βββββββββββββββββββββββββββββββββββ
|
| 400 |
+
|
| 401 |
+
/**
|
| 402 |
+
* Creates and injects a floating "Verify this post" button on a post.
|
| 403 |
+
* The button is absolutely positioned at the bottom-right of the post,
|
| 404 |
+
* above the action bar (like/comment/share).
|
| 405 |
+
*/
|
| 406 |
+
function injectVerifyButton(post) {
|
| 407 |
+
// Prevent duplicate injection
|
| 408 |
+
if (post.dataset.philverifyBtn) return
|
| 409 |
+
post.dataset.philverifyBtn = 'true'
|
| 410 |
+
|
| 411 |
+
// Note: We do NOT gate on content availability here.
|
| 412 |
+
// Facebook lazy-loads post content via React hydration, so text/images
|
| 413 |
+
// may not be in the DOM yet when this runs. Content is checked at click
|
| 414 |
+
// time (in handleVerifyClick) when everything is fully rendered.
|
| 415 |
+
|
| 416 |
+
// Create wrapper (flex container for right-alignment)
|
| 417 |
+
const wrapper = document.createElement('div')
|
| 418 |
+
wrapper.className = 'pv-verify-btn-wrapper'
|
| 419 |
+
|
| 420 |
+
// Create the button
|
| 421 |
+
const btn = document.createElement('button')
|
| 422 |
+
btn.className = 'pv-verify-btn'
|
| 423 |
+
btn.setAttribute('type', 'button')
|
| 424 |
+
btn.setAttribute('aria-label', 'Verify this post with PhilVerify')
|
| 425 |
+
|
| 426 |
+
// Button content using createElement (no innerHTML for XSS safety)
|
| 427 |
+
const icon = document.createElement('span')
|
| 428 |
+
icon.className = 'pv-verify-btn-icon'
|
| 429 |
+
icon.textContent = 'π‘οΈ'
|
| 430 |
+
icon.setAttribute('aria-hidden', 'true')
|
| 431 |
+
|
| 432 |
+
const label = document.createElement('span')
|
| 433 |
+
label.className = 'pv-verify-btn-label'
|
| 434 |
+
label.textContent = 'Verify this post'
|
| 435 |
+
|
| 436 |
+
btn.appendChild(icon)
|
| 437 |
+
btn.appendChild(label)
|
| 438 |
+
|
| 439 |
+
// Click handler β extract content, call API, show report
|
| 440 |
+
btn.addEventListener('click', (e) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
e.stopPropagation()
|
| 442 |
+
e.preventDefault()
|
| 443 |
+
handleVerifyClick(post, btn)
|
| 444 |
})
|
| 445 |
|
| 446 |
+
wrapper.appendChild(btn)
|
|
|
|
| 447 |
|
| 448 |
+
// Insert the wrapper inline in the post.
|
| 449 |
+
// Strategy: Find a good insertion point near the bottom of the
|
| 450 |
+
// visible post content, but BEFORE the comments section.
|
| 451 |
+
// On Facebook, we look for the action bar area or similar landmarks.
|
| 452 |
+
let inserted = false
|
| 453 |
if (PLATFORM === 'facebook') {
|
| 454 |
+
// Try to insert after the action bar (Like/Comment/Share row)
|
| 455 |
+
const actionBar = post.querySelector('[role="toolbar"]') ||
|
| 456 |
+
post.querySelector('[aria-label*="Like"]')?.closest('div:not([role="article"])')
|
| 457 |
+
if (actionBar?.parentElement) {
|
| 458 |
+
actionBar.parentElement.insertBefore(wrapper, actionBar.nextSibling)
|
| 459 |
+
inserted = true
|
| 460 |
+
}
|
| 461 |
}
|
| 462 |
|
| 463 |
+
// Fallback: just append to the post (works for Twitter and other platforms)
|
| 464 |
+
if (!inserted) {
|
| 465 |
+
post.appendChild(wrapper)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
}
|
|
|
|
| 467 |
|
| 468 |
+
log('Verify button injected on post')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
}
|
| 470 |
|
| 471 |
+
// ββ Verify click handler ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 472 |
+
|
| 473 |
+
async function handleVerifyClick(post, btn) {
|
| 474 |
+
// Disable button and show loading state
|
| 475 |
+
btn.disabled = true
|
| 476 |
+
btn.classList.add('pv-verify-btn--loading')
|
| 477 |
+
const origIcon = btn.querySelector('.pv-verify-btn-icon')
|
| 478 |
+
const origLabel = btn.querySelector('.pv-verify-btn-label')
|
| 479 |
+
if (origIcon) origIcon.textContent = ''
|
| 480 |
+
if (origLabel) origLabel.textContent = 'Analyzingβ¦'
|
| 481 |
+
|
| 482 |
+
// Add spinner
|
| 483 |
+
const spinner = document.createElement('span')
|
| 484 |
+
spinner.className = 'pv-spinner'
|
| 485 |
+
spinner.setAttribute('aria-hidden', 'true')
|
| 486 |
+
btn.insertBefore(spinner, btn.firstChild)
|
| 487 |
+
|
| 488 |
+
// Extract content
|
| 489 |
+
const text = extractPostText(post)
|
| 490 |
+
const url = extractPostUrl(post)
|
| 491 |
const image = extractPostImage(post)
|
| 492 |
|
| 493 |
+
log(`Verify clicked: text=${!!text} (${text?.length ?? 0} chars), url=${!!url}, image=${!!image}`)
|
|
|
|
| 494 |
|
| 495 |
+
// Determine what to send
|
| 496 |
+
let inputSummary = ''
|
| 497 |
+
if (!text && !url && !image) {
|
| 498 |
+
showErrorReport(post, btn, 'Could not read post content β no text or image found.')
|
| 499 |
+
return
|
| 500 |
+
}
|
| 501 |
|
| 502 |
try {
|
| 503 |
let msgPayload
|
| 504 |
+
|
| 505 |
if (url) {
|
|
|
|
| 506 |
msgPayload = { type: 'VERIFY_URL', url }
|
| 507 |
+
inputSummary = 'Shared link analyzed'
|
| 508 |
+
} else if (text && image) {
|
| 509 |
+
msgPayload = { type: 'VERIFY_TEXT', text, imageUrl: image }
|
| 510 |
+
inputSummary = 'Caption + image analyzed'
|
| 511 |
} else if (text) {
|
|
|
|
| 512 |
msgPayload = { type: 'VERIFY_TEXT', text }
|
| 513 |
+
inputSummary = 'Caption text only'
|
| 514 |
} else {
|
|
|
|
| 515 |
msgPayload = { type: 'VERIFY_IMAGE_URL', imageUrl: image }
|
| 516 |
+
inputSummary = 'Image only (OCR)'
|
| 517 |
}
|
| 518 |
|
| 519 |
const response = await new Promise((resolve, reject) => {
|
| 520 |
chrome.runtime.sendMessage(msgPayload, (resp) => {
|
| 521 |
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message))
|
| 522 |
+
else if (!resp?.ok) reject(new Error(resp?.error ?? 'Unknown error'))
|
| 523 |
+
else resolve(resp.result)
|
| 524 |
})
|
| 525 |
})
|
| 526 |
|
| 527 |
+
log(`Verification result: verdict=${response.verdict}, score=${response.final_score}`)
|
| 528 |
+
showVerificationReport(post, btn, response, inputSummary)
|
| 529 |
} catch (err) {
|
| 530 |
+
warn('Verification failed:', err.message)
|
| 531 |
+
showErrorReport(post, btn, err.message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
}
|
| 533 |
}
|
| 534 |
|
| 535 |
+
// ββ Verification report rendering βββββββοΏ½οΏ½οΏ½βββββββββββββββββββββββββββββββββ
|
| 536 |
+
|
| 537 |
+
function showVerificationReport(post, btn, result, inputSummary) {
|
| 538 |
+
// Remove the button
|
| 539 |
+
btn.remove()
|
| 540 |
+
|
| 541 |
+
// Remove any existing report on this post
|
| 542 |
+
const existing = post.querySelector('.pv-report')
|
| 543 |
+
if (existing) existing.remove()
|
| 544 |
+
|
| 545 |
+
const verdict = result.verdict ?? 'Unknown'
|
| 546 |
+
const color = VERDICT_COLORS[verdict] ?? '#5c554e'
|
| 547 |
+
const bg = VERDICT_BG[verdict] ?? 'rgba(92, 85, 78, 0.12)'
|
| 548 |
+
const label = VERDICT_LABELS[verdict] ?? verdict
|
| 549 |
+
const score = Math.round(result.final_score ?? 0)
|
| 550 |
+
const confidence = result.confidence?.toFixed(1) ?? 'β'
|
| 551 |
+
const language = result.language ?? 'β'
|
| 552 |
+
const sources = result.layer2?.sources ?? []
|
| 553 |
+
const features = result.layer1?.triggered_features ?? []
|
| 554 |
+
const cached = result._fromCache ? ' Β· cached' : ''
|
| 555 |
+
|
| 556 |
+
// Build report using createElement (no innerHTML for XSS safety)
|
| 557 |
+
const report = document.createElement('div')
|
| 558 |
+
report.className = 'pv-report'
|
| 559 |
+
report.setAttribute('role', 'region')
|
| 560 |
+
report.setAttribute('aria-label', 'PhilVerify fact-check report')
|
| 561 |
+
|
| 562 |
+
// β Header row
|
| 563 |
+
const header = document.createElement('div')
|
| 564 |
+
header.className = 'pv-report-header'
|
| 565 |
+
|
| 566 |
+
const logo = document.createElement('span')
|
| 567 |
+
logo.className = 'pv-report-logo'
|
| 568 |
+
logo.innerHTML = 'PHIL<span style="color:#dc2626">VERIFY</span>'
|
| 569 |
+
|
| 570 |
+
const closeBtn = document.createElement('button')
|
| 571 |
+
closeBtn.className = 'pv-report-close'
|
| 572 |
+
closeBtn.textContent = 'β'
|
| 573 |
+
closeBtn.setAttribute('aria-label', 'Close fact-check report')
|
| 574 |
+
closeBtn.addEventListener('click', (e) => {
|
| 575 |
+
e.stopPropagation()
|
| 576 |
+
report.remove()
|
| 577 |
+
// Re-inject the verify button so user can re-verify
|
| 578 |
+
delete post.dataset.philverifyBtn
|
| 579 |
+
injectVerifyButton(post)
|
| 580 |
+
})
|
| 581 |
+
|
| 582 |
+
header.appendChild(logo)
|
| 583 |
+
header.appendChild(closeBtn)
|
| 584 |
+
report.appendChild(header)
|
| 585 |
+
|
| 586 |
+
// β Verdict row (large, prominent)
|
| 587 |
+
const verdictRow = document.createElement('div')
|
| 588 |
+
verdictRow.className = 'pv-report-verdict-row'
|
| 589 |
+
verdictRow.style.borderLeftColor = color
|
| 590 |
+
|
| 591 |
+
const verdictLabel = document.createElement('div')
|
| 592 |
+
verdictLabel.className = 'pv-report-verdict'
|
| 593 |
+
verdictLabel.style.color = color
|
| 594 |
+
verdictLabel.textContent = label
|
| 595 |
+
|
| 596 |
+
const scoreText = document.createElement('div')
|
| 597 |
+
scoreText.className = 'pv-report-score-text'
|
| 598 |
+
scoreText.textContent = `${score}% credibility${cached}`
|
| 599 |
+
|
| 600 |
+
verdictRow.appendChild(verdictLabel)
|
| 601 |
+
verdictRow.appendChild(scoreText)
|
| 602 |
+
report.appendChild(verdictRow)
|
| 603 |
+
|
| 604 |
+
// β Confidence bar
|
| 605 |
+
const barWrap = document.createElement('div')
|
| 606 |
+
barWrap.className = 'pv-confidence-bar-wrap'
|
| 607 |
+
|
| 608 |
+
const barLabel = document.createElement('span')
|
| 609 |
+
barLabel.className = 'pv-report-label'
|
| 610 |
+
barLabel.textContent = 'CONFIDENCE'
|
| 611 |
+
|
| 612 |
+
const barTrack = document.createElement('div')
|
| 613 |
+
barTrack.className = 'pv-confidence-bar-track'
|
| 614 |
+
|
| 615 |
+
const barFill = document.createElement('div')
|
| 616 |
+
barFill.className = 'pv-confidence-bar-fill'
|
| 617 |
+
barFill.style.width = `${Math.min(score, 100)}%`
|
| 618 |
+
barFill.style.background = color
|
| 619 |
+
|
| 620 |
+
const barValue = document.createElement('span')
|
| 621 |
+
barValue.className = 'pv-confidence-bar-value'
|
| 622 |
+
barValue.textContent = `${confidence}%`
|
| 623 |
+
|
| 624 |
+
barTrack.appendChild(barFill)
|
| 625 |
+
barWrap.appendChild(barLabel)
|
| 626 |
+
barWrap.appendChild(barTrack)
|
| 627 |
+
barWrap.appendChild(barValue)
|
| 628 |
+
report.appendChild(barWrap)
|
| 629 |
+
|
| 630 |
+
// β Info rows (Language, Input)
|
| 631 |
+
const addInfoRow = (labelText, valueText) => {
|
| 632 |
+
const row = document.createElement('div')
|
| 633 |
+
row.className = 'pv-report-row'
|
| 634 |
+
const lbl = document.createElement('span')
|
| 635 |
+
lbl.className = 'pv-report-label'
|
| 636 |
+
lbl.textContent = labelText
|
| 637 |
+
const val = document.createElement('span')
|
| 638 |
+
val.className = 'pv-report-value'
|
| 639 |
+
val.textContent = valueText
|
| 640 |
+
row.appendChild(lbl)
|
| 641 |
+
row.appendChild(val)
|
| 642 |
+
report.appendChild(row)
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
addInfoRow('LANGUAGE', safeText(language))
|
| 646 |
+
addInfoRow('INPUT', safeText(inputSummary))
|
| 647 |
+
|
| 648 |
+
// β Triggered signals/features
|
| 649 |
+
if (features.length > 0) {
|
| 650 |
+
const signalsSection = document.createElement('div')
|
| 651 |
+
signalsSection.className = 'pv-report-signals'
|
| 652 |
+
|
| 653 |
+
const signalsLabel = document.createElement('span')
|
| 654 |
+
signalsLabel.className = 'pv-report-label'
|
| 655 |
+
signalsLabel.textContent = 'SUSPICIOUS SIGNALS'
|
| 656 |
+
signalsSection.appendChild(signalsLabel)
|
| 657 |
+
|
| 658 |
+
const tagsWrap = document.createElement('div')
|
| 659 |
+
tagsWrap.className = 'pv-report-tags'
|
| 660 |
+
for (const f of features.slice(0, 5)) {
|
| 661 |
+
const tag = document.createElement('span')
|
| 662 |
+
tag.className = 'pv-report-tag'
|
| 663 |
+
tag.textContent = f
|
| 664 |
+
tagsWrap.appendChild(tag)
|
| 665 |
+
}
|
| 666 |
+
signalsSection.appendChild(tagsWrap)
|
| 667 |
+
report.appendChild(signalsSection)
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
// β Evidence sources
|
| 671 |
+
if (sources.length > 0) {
|
| 672 |
+
const sourcesSection = document.createElement('div')
|
| 673 |
+
sourcesSection.className = 'pv-report-sources'
|
| 674 |
+
|
| 675 |
+
const sourcesLabel = document.createElement('span')
|
| 676 |
+
sourcesLabel.className = 'pv-report-label'
|
| 677 |
+
sourcesLabel.textContent = 'EVIDENCE SOURCES'
|
| 678 |
+
sourcesSection.appendChild(sourcesLabel)
|
| 679 |
+
|
| 680 |
+
const sourcesList = document.createElement('ul')
|
| 681 |
+
sourcesList.className = 'pv-report-sources-list'
|
| 682 |
+
|
| 683 |
+
for (const src of sources.slice(0, 5)) {
|
| 684 |
+
const li = document.createElement('li')
|
| 685 |
+
li.className = 'pv-report-source-item'
|
| 686 |
+
|
| 687 |
+
const link = document.createElement('a')
|
| 688 |
+
link.href = safeUrl(src.url)
|
| 689 |
+
link.target = '_blank'
|
| 690 |
+
link.rel = 'noreferrer'
|
| 691 |
+
link.className = 'pv-report-source-link'
|
| 692 |
+
link.textContent = src.title?.slice(0, 60) ?? src.source_name ?? 'View source'
|
| 693 |
+
|
| 694 |
+
const stance = document.createElement('span')
|
| 695 |
+
stance.className = 'pv-report-source-stance'
|
| 696 |
+
stance.textContent = src.stance ?? ''
|
| 697 |
+
if (src.stance === 'Refutes') stance.style.color = '#dc2626'
|
| 698 |
+
if (src.stance === 'Supports') stance.style.color = '#16a34a'
|
| 699 |
+
|
| 700 |
+
li.appendChild(link)
|
| 701 |
+
li.appendChild(stance)
|
| 702 |
+
sourcesList.appendChild(li)
|
| 703 |
+
}
|
| 704 |
+
sourcesSection.appendChild(sourcesList)
|
| 705 |
+
report.appendChild(sourcesSection)
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
// β Explanation (claim used)
|
| 709 |
+
if (result.layer2?.claim_used) {
|
| 710 |
+
const explanation = document.createElement('div')
|
| 711 |
+
explanation.className = 'pv-report-explanation'
|
| 712 |
+
const explLabel = document.createElement('span')
|
| 713 |
+
explLabel.className = 'pv-report-label'
|
| 714 |
+
explLabel.textContent = 'CLAIM ANALYZED'
|
| 715 |
+
const explText = document.createElement('p')
|
| 716 |
+
explText.className = 'pv-report-explanation-text'
|
| 717 |
+
explText.textContent = result.layer2.claim_used
|
| 718 |
+
explanation.appendChild(explLabel)
|
| 719 |
+
explanation.appendChild(explText)
|
| 720 |
+
report.appendChild(explanation)
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
// β Full analysis link
|
| 724 |
+
const fullLink = document.createElement('a')
|
| 725 |
+
fullLink.className = 'pv-report-full-link'
|
| 726 |
+
fullLink.href = 'https://philverify.web.app'
|
| 727 |
+
fullLink.target = '_blank'
|
| 728 |
+
fullLink.rel = 'noreferrer'
|
| 729 |
+
fullLink.textContent = 'Open Full Dashboard β'
|
| 730 |
+
report.appendChild(fullLink)
|
| 731 |
+
|
| 732 |
+
// Insert report into post
|
| 733 |
+
post.appendChild(report)
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
function showErrorReport(post, btn, errorMessage) {
|
| 737 |
+
// Remove spinner, restore button as error state
|
| 738 |
+
btn.classList.remove('pv-verify-btn--loading')
|
| 739 |
+
btn.classList.add('pv-verify-btn--error')
|
| 740 |
+
btn.disabled = false
|
| 741 |
+
|
| 742 |
+
const spinner = btn.querySelector('.pv-spinner')
|
| 743 |
+
if (spinner) spinner.remove()
|
| 744 |
+
|
| 745 |
+
const icon = btn.querySelector('.pv-verify-btn-icon')
|
| 746 |
+
const label = btn.querySelector('.pv-verify-btn-label')
|
| 747 |
+
if (icon) icon.textContent = 'β οΈ'
|
| 748 |
+
if (label) label.textContent = 'Verification failed β tap to retry'
|
| 749 |
+
|
| 750 |
+
// On next click, retry
|
| 751 |
+
const retryHandler = (e) => {
|
| 752 |
+
e.stopPropagation()
|
| 753 |
+
e.preventDefault()
|
| 754 |
+
btn.removeEventListener('click', retryHandler)
|
| 755 |
+
btn.classList.remove('pv-verify-btn--error')
|
| 756 |
+
handleVerifyClick(post, btn)
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
// Remove old click listeners by replacing element
|
| 760 |
+
const newBtn = btn.cloneNode(true)
|
| 761 |
+
btn.replaceWith(newBtn)
|
| 762 |
+
newBtn.addEventListener('click', (e) => {
|
| 763 |
+
e.stopPropagation()
|
| 764 |
+
e.preventDefault()
|
| 765 |
+
newBtn.classList.remove('pv-verify-btn--error')
|
| 766 |
+
handleVerifyClick(post, newBtn)
|
| 767 |
+
})
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
// ββ MutationObserver ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 771 |
|
| 772 |
const pendingPosts = new Set()
|
|
|
|
| 774 |
|
| 775 |
function flushPosts() {
|
| 776 |
rafScheduled = false
|
| 777 |
+
for (const post of pendingPosts) injectVerifyButton(post)
|
| 778 |
pendingPosts.clear()
|
| 779 |
}
|
| 780 |
|
|
|
|
| 786 |
}
|
| 787 |
}
|
| 788 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 789 |
const observer = new MutationObserver((mutations) => {
|
| 790 |
for (const mutation of mutations) {
|
| 791 |
for (const node of mutation.addedNodes) {
|
| 792 |
if (node.nodeType !== 1) continue // element nodes only
|
| 793 |
+
|
| 794 |
+
if (PLATFORM === 'facebook') {
|
| 795 |
+
// Facebook strategy: only process nodes that are inside [role="feed"]
|
| 796 |
+
// or that contain a feed. This prevents processing individual comment
|
| 797 |
+
// nodes that are added dynamically.
|
| 798 |
+
const inFeed = node.closest?.('[role="feed"]') ||
|
| 799 |
+
node.querySelector?.('[role="feed"]') ||
|
| 800 |
+
node.getAttribute?.('role') === 'feed'
|
| 801 |
+
if (!inFeed && node.getAttribute?.('role') === 'article') {
|
| 802 |
+
// An article added outside of a feed β could be a detail page.
|
| 803 |
+
// Only process if isTopLevelPost says it's a post.
|
| 804 |
+
if (isTopLevelPost(node)) {
|
| 805 |
+
scheduleProcess(node)
|
| 806 |
+
}
|
| 807 |
+
continue
|
| 808 |
}
|
| 809 |
}
|
| 810 |
+
|
| 811 |
+
// Check descendants for posts (findPosts handles feed-based filtering)
|
| 812 |
const posts = findPosts(node)
|
| 813 |
for (const post of posts) scheduleProcess(post)
|
| 814 |
}
|
|
|
|
| 818 |
// ββ Initialization ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 819 |
|
| 820 |
async function init() {
|
| 821 |
+
log(`Initializing on ${PLATFORM} (${window.location.hostname})`)
|
| 822 |
+
|
| 823 |
+
// Check autoScan setting β controls whether buttons are shown at all
|
| 824 |
+
let response
|
| 825 |
+
try {
|
| 826 |
+
response = await new Promise((resolve, reject) => {
|
| 827 |
+
chrome.runtime.sendMessage({ type: 'GET_SETTINGS' }, (r) => {
|
| 828 |
+
if (chrome.runtime.lastError) {
|
| 829 |
+
warn('Settings fetch error:', chrome.runtime.lastError.message)
|
| 830 |
+
resolve({ autoScan: true })
|
| 831 |
+
} else {
|
| 832 |
+
resolve(r ?? { autoScan: true })
|
| 833 |
+
}
|
| 834 |
+
})
|
| 835 |
})
|
| 836 |
+
} catch {
|
| 837 |
+
response = { autoScan: true }
|
| 838 |
+
}
|
| 839 |
|
| 840 |
+
log('Settings:', response)
|
| 841 |
+
if (response?.autoScan === false) {
|
| 842 |
+
log('Auto-scan disabled by settings β no verify buttons will be shown')
|
| 843 |
+
return
|
| 844 |
+
}
|
| 845 |
|
| 846 |
// Process any posts already in the DOM
|
| 847 |
const existing = findPosts(document.body)
|
| 848 |
+
log(`Found ${existing.length} existing posts`)
|
| 849 |
for (const post of existing) scheduleProcess(post)
|
| 850 |
|
| 851 |
+
// Watch for new posts (both platforms are SPAs with infinite scroll)
|
| 852 |
observer.observe(document.body, { childList: true, subtree: true })
|
| 853 |
+
log('MutationObserver started β watching for new posts')
|
| 854 |
}
|
| 855 |
|
| 856 |
init()
|
|
|
|
| 860 |
// it auto-verifies the current URL and injects a floating verdict banner.
|
| 861 |
|
| 862 |
async function autoVerifyPage() {
|
| 863 |
+
const url = window.location.href
|
| 864 |
const path = new URL(url).pathname
|
| 865 |
// Skip homepages and section indexes (very short paths like / or /news)
|
| 866 |
if (!path || path.length < 8 || path.split('/').filter(Boolean).length < 2) return
|
|
|
|
| 877 |
'box-shadow:0 2px 16px rgba(0,0,0,0.6)',
|
| 878 |
].join(';')
|
| 879 |
|
| 880 |
+
// Build banner content with createElement for XSS safety
|
| 881 |
+
const leftSection = document.createElement('div')
|
| 882 |
+
leftSection.style.cssText = 'display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden;'
|
| 883 |
+
|
| 884 |
+
const logoSpan = document.createElement('span')
|
| 885 |
+
logoSpan.style.cssText = 'font-weight:800;letter-spacing:0.1em;color:#f5f0e8;flex-shrink:0;'
|
| 886 |
+
logoSpan.innerHTML = 'PHIL<span style="color:#dc2626">VERIFY</span>'
|
| 887 |
+
|
| 888 |
+
const statusEl = document.createElement('span')
|
| 889 |
+
statusEl.id = 'pv-auto-status'
|
| 890 |
+
statusEl.style.cssText = 'display:flex;align-items:center;gap:6px;overflow:hidden;'
|
| 891 |
+
|
| 892 |
+
const statusSpinner = document.createElement('span')
|
| 893 |
+
statusSpinner.className = 'pv-spinner'
|
| 894 |
+
statusSpinner.setAttribute('aria-hidden', 'true')
|
| 895 |
+
|
| 896 |
+
const statusText = document.createElement('span')
|
| 897 |
+
statusText.style.cssText = 'white-space:nowrap;'
|
| 898 |
+
statusText.textContent = 'Verifying articleβ¦'
|
| 899 |
+
|
| 900 |
+
statusEl.appendChild(statusSpinner)
|
| 901 |
+
statusEl.appendChild(statusText)
|
| 902 |
+
leftSection.appendChild(logoSpan)
|
| 903 |
+
leftSection.appendChild(statusEl)
|
| 904 |
+
|
| 905 |
+
const rightSection = document.createElement('div')
|
| 906 |
+
rightSection.style.cssText = 'display:flex;align-items:center;gap:8px;flex-shrink:0;'
|
| 907 |
+
|
| 908 |
+
const fullLink = document.createElement('a')
|
| 909 |
+
fullLink.id = 'pv-open-full'
|
| 910 |
+
fullLink.href = 'https://philverify.web.app'
|
| 911 |
+
fullLink.target = '_blank'
|
| 912 |
+
fullLink.rel = 'noreferrer'
|
| 913 |
+
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;'
|
| 914 |
+
fullLink.setAttribute('aria-label', 'Open PhilVerify dashboard')
|
| 915 |
+
fullLink.textContent = 'FULL ANALYSIS β'
|
| 916 |
+
|
| 917 |
+
const closeButton = document.createElement('button')
|
| 918 |
+
closeButton.id = 'pv-close-banner'
|
| 919 |
+
closeButton.style.cssText = 'background:none;border:none;color:#5c554e;cursor:pointer;font-size:13px;padding:2px 4px;line-height:1;flex-shrink:0;'
|
| 920 |
+
closeButton.setAttribute('aria-label', 'Dismiss PhilVerify banner')
|
| 921 |
+
closeButton.textContent = 'β'
|
| 922 |
+
|
| 923 |
+
rightSection.appendChild(fullLink)
|
| 924 |
+
rightSection.appendChild(closeButton)
|
| 925 |
+
banner.appendChild(leftSection)
|
| 926 |
+
banner.appendChild(rightSection)
|
| 927 |
|
| 928 |
document.body.insertAdjacentElement('afterbegin', banner)
|
|
|
|
| 929 |
document.documentElement.style.marginTop = '36px'
|
| 930 |
|
| 931 |
+
closeButton.addEventListener('click', () => {
|
| 932 |
banner.remove()
|
| 933 |
document.documentElement.style.marginTop = ''
|
| 934 |
})
|
|
|
|
| 937 |
const response = await new Promise((resolve, reject) => {
|
| 938 |
chrome.runtime.sendMessage({ type: 'VERIFY_URL', url }, (resp) => {
|
| 939 |
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message))
|
| 940 |
+
else if (!resp?.ok) reject(new Error(resp?.error ?? 'Unknown error'))
|
| 941 |
+
else resolve(resp.result)
|
| 942 |
})
|
| 943 |
})
|
| 944 |
|
| 945 |
+
const color = VERDICT_COLORS[response.verdict] ?? '#5c554e'
|
| 946 |
+
// Update status with result
|
| 947 |
+
statusEl.textContent = ''
|
| 948 |
+
|
| 949 |
+
const dotEl = document.createElement('span')
|
| 950 |
+
dotEl.style.cssText = `width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0;`
|
| 951 |
+
dotEl.setAttribute('aria-hidden', 'true')
|
| 952 |
+
|
| 953 |
+
const verdictEl = document.createElement('span')
|
| 954 |
+
verdictEl.style.cssText = `color:${color};font-weight:700;`
|
| 955 |
+
verdictEl.textContent = response.verdict
|
| 956 |
+
|
| 957 |
+
const scoreEl = document.createElement('span')
|
| 958 |
+
scoreEl.style.cssText = 'color:#5c554e;margin-left:2px;'
|
| 959 |
+
scoreEl.textContent = `${Math.round(response.final_score)}% credibility`
|
| 960 |
+
|
| 961 |
+
statusEl.appendChild(dotEl)
|
| 962 |
+
statusEl.appendChild(verdictEl)
|
| 963 |
+
statusEl.appendChild(scoreEl)
|
| 964 |
+
|
| 965 |
+
if (response.layer1?.triggered_features?.length) {
|
| 966 |
+
const featureEl = document.createElement('span')
|
| 967 |
+
featureEl.style.cssText = 'color:#5c554e;margin-left:4px;font-size:9px;'
|
| 968 |
+
featureEl.textContent = `Β· ${response.layer1.triggered_features[0]}`
|
| 969 |
+
statusEl.appendChild(featureEl)
|
| 970 |
}
|
| 971 |
+
|
| 972 |
banner.style.borderBottomColor = color + '88'
|
|
|
|
|
|
|
|
|
|
| 973 |
|
| 974 |
+
// Auto-dismiss if credible
|
| 975 |
if (response.verdict === 'Credible') {
|
| 976 |
setTimeout(() => {
|
| 977 |
if (document.contains(banner)) {
|
|
|
|
| 989 |
if (!IS_SOCIAL) {
|
| 990 |
autoVerifyPage()
|
| 991 |
}
|
| 992 |
+
|
| 993 |
+
// Listen for settings changes to enable/disable button injection
|
| 994 |
chrome.storage.onChanged.addListener((changes, area) => {
|
| 995 |
if (area !== 'local' || !changes.settings) return
|
| 996 |
const autoScan = changes.settings.newValue?.autoScan
|
| 997 |
if (autoScan === false) {
|
| 998 |
observer.disconnect()
|
| 999 |
+
// Remove all existing verify buttons
|
| 1000 |
+
document.querySelectorAll('.pv-verify-btn').forEach(btn => btn.remove())
|
| 1001 |
+
document.querySelectorAll('[data-philverify-btn]').forEach(el => {
|
| 1002 |
+
delete el.dataset.philverifyBtn
|
| 1003 |
+
})
|
| 1004 |
} else if (autoScan === true) {
|
| 1005 |
observer.observe(document.body, { childList: true, subtree: true })
|
|
|
|
| 1006 |
const existing = findPosts(document.body)
|
| 1007 |
for (const post of existing) scheduleProcess(post)
|
| 1008 |
}
|
frontend/src/pages/VerifyPage.jsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
import { useState, useRef, useId, useCallback, useEffect } from 'react'
|
| 2 |
import { api } from '../api'
|
| 3 |
-
import { scoreColor, VERDICT_MAP } from '../utils/format.js'
|
| 4 |
import { PAGE_STYLE } from '../App.jsx'
|
| 5 |
import ScoreGauge from '../components/ScoreGauge.jsx'
|
| 6 |
import VerdictBadge from '../components/VerdictBadge.jsx'
|
| 7 |
import WordHighlighter from '../components/WordHighlighter.jsx'
|
| 8 |
import SkeletonCard from '../components/SkeletonCard.jsx'
|
| 9 |
-
import { FileText, Link2, Image, Video, Loader2, ChevronRight, AlertCircle, Upload, CheckCircle2, XCircle, HelpCircle, ExternalLink, Layers, Brain, RefreshCw } from 'lucide-react'
|
| 10 |
|
| 11 |
/* ββ Tab definitions ββββββββββββββββββββββββββββββββββββββ */
|
| 12 |
const TABS = [
|
|
@@ -244,6 +244,7 @@ export default function VerifyPage() {
|
|
| 244 |
const [submittedInput, setSubmittedInput] = useState(persisted?.submittedInput ?? null)
|
| 245 |
const [urlPreview, setUrlPreview] = useState(null)
|
| 246 |
const [urlPreviewLoading, setUrlPreviewLoading] = useState(false)
|
|
|
|
| 247 |
const fileRef = useRef()
|
| 248 |
const inputSectionRef = useRef()
|
| 249 |
const inputId = useId()
|
|
@@ -325,7 +326,7 @@ export default function VerifyPage() {
|
|
| 325 |
}
|
| 326 |
|
| 327 |
function handleVerifyAgain() {
|
| 328 |
-
setResult(null); setError(null)
|
| 329 |
sessionStorage.removeItem(STORAGE_KEY)
|
| 330 |
// Smooth-scroll back to the input panel
|
| 331 |
requestAnimationFrame(() => {
|
|
@@ -667,13 +668,35 @@ export default function VerifyPage() {
|
|
| 667 |
</button>
|
| 668 |
</div>
|
| 669 |
|
| 670 |
-
{/* Row 1: Gauge + Meta */}
|
| 671 |
<div className="grid gap-4 fade-up-1" style={{ gridTemplateColumns: 'min(180px, 40%) 1fr' }}>
|
| 672 |
<div className="card p-5 flex flex-col items-center justify-center gap-3">
|
| 673 |
<ScoreGauge score={result.final_score} size={140} />
|
| 674 |
<VerdictBadge verdict={result.verdict} size="banner" />
|
| 675 |
</div>
|
| 676 |
<div className="card p-5 fade-up-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
<SectionHeading>Analysis Details</SectionHeading>
|
| 678 |
<MetaRow label="Language" value={result.language} />
|
| 679 |
<MetaRow label="Sentiment" value={result.sentiment} />
|
|
@@ -688,39 +711,138 @@ export default function VerifyPage() {
|
|
| 688 |
{/* Row 2: Score breakdown */}
|
| 689 |
<div className="card p-5 fade-up-3">
|
| 690 |
<SectionHeading>Score Breakdown</SectionHeading>
|
|
|
|
|
|
|
|
|
|
| 691 |
<div className="space-y-4">
|
| 692 |
-
<ScoreBar label="ML Classifier (Layer 1 β 40%)" value={result.layer1?.confidence || 0} color="var(--accent-cyan)" index={0} />
|
| 693 |
-
<ScoreBar label="Evidence
|
| 694 |
-
<
|
|
|
|
|
|
|
| 695 |
</div>
|
|
|
|
|
|
|
|
|
|
| 696 |
</div>
|
| 697 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
{/* Row 3: Layer cards (2 col, collapses to 1 on mobile) */}
|
| 699 |
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 fade-up-4">
|
| 700 |
{/* Layer 1 */}
|
| 701 |
<LayerCard
|
| 702 |
-
title="Layer 1 β
|
| 703 |
icon={Brain}
|
| 704 |
verdict={result.layer1?.verdict}
|
| 705 |
score={result.layer1?.confidence}
|
| 706 |
delay={0}>
|
|
|
|
|
|
|
|
|
|
| 707 |
<div className="mt-3">
|
| 708 |
<p className="text-xs mb-2" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.1em' }}>
|
| 709 |
TRIGGERED FEATURES
|
| 710 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
<FeatureBreakdown features={result.layer1?.triggered_features} />
|
| 712 |
</div>
|
| 713 |
</LayerCard>
|
| 714 |
|
| 715 |
{/* Layer 2 */}
|
| 716 |
<LayerCard
|
| 717 |
-
title="Layer 2 β Evidence"
|
| 718 |
icon={Layers}
|
| 719 |
verdict={result.layer2?.verdict}
|
| 720 |
score={result.layer2?.evidence_score}
|
| 721 |
delay={80}>
|
|
|
|
|
|
|
|
|
|
| 722 |
<p className="text-xs mt-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}>
|
| 723 |
-
<span style={{ color: 'var(--text-secondary)' }}>Claim
|
| 724 |
"{result.layer2?.claim_used || 'No claim extracted'}"
|
| 725 |
</p>
|
| 726 |
</LayerCard>
|
|
@@ -742,6 +864,9 @@ export default function VerifyPage() {
|
|
| 742 |
{allEntities.length > 0 && (
|
| 743 |
<div className="card p-5 fade-up-5">
|
| 744 |
<SectionHeading count={allEntities.length}>Named Entities</SectionHeading>
|
|
|
|
|
|
|
|
|
|
| 745 |
<ul className="flex flex-wrap gap-2" role="list">
|
| 746 |
{allEntities.map((e, i) => (
|
| 747 |
<li key={i}
|
|
@@ -765,6 +890,13 @@ export default function VerifyPage() {
|
|
| 765 |
{result.layer2?.sources?.length > 0 && (
|
| 766 |
<div className="card p-5 fade-up-5">
|
| 767 |
<SectionHeading count={result.layer2.sources.length}>Evidence Sources</SectionHeading>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
<ul className="space-y-2" role="list">
|
| 769 |
{result.layer2.sources.map((src, i) => {
|
| 770 |
const { Icon: StanceIcon, color: stanceColor } = STANCE_ICON[src.stance] ?? STANCE_ICON['Not Enough Info']
|
|
|
|
| 1 |
import { useState, useRef, useId, useCallback, useEffect } from 'react'
|
| 2 |
import { api } from '../api'
|
| 3 |
+
import { scoreColor, VERDICT_MAP, scoreInterpretation, mlConfidenceExplanation, evidenceExplanation } from '../utils/format.js'
|
| 4 |
import { PAGE_STYLE } from '../App.jsx'
|
| 5 |
import ScoreGauge from '../components/ScoreGauge.jsx'
|
| 6 |
import VerdictBadge from '../components/VerdictBadge.jsx'
|
| 7 |
import WordHighlighter from '../components/WordHighlighter.jsx'
|
| 8 |
import SkeletonCard from '../components/SkeletonCard.jsx'
|
| 9 |
+
import { FileText, Link2, Image, Video, Loader2, ChevronRight, AlertCircle, Upload, CheckCircle2, XCircle, HelpCircle, ExternalLink, Layers, Brain, RefreshCw, Info } from 'lucide-react'
|
| 10 |
|
| 11 |
/* ββ Tab definitions ββββββββββββββββββββββββββββββββββββββ */
|
| 12 |
const TABS = [
|
|
|
|
| 244 |
const [submittedInput, setSubmittedInput] = useState(persisted?.submittedInput ?? null)
|
| 245 |
const [urlPreview, setUrlPreview] = useState(null)
|
| 246 |
const [urlPreviewLoading, setUrlPreviewLoading] = useState(false)
|
| 247 |
+
const [extractedTextOpen, setExtractedTextOpen] = useState(false)
|
| 248 |
const fileRef = useRef()
|
| 249 |
const inputSectionRef = useRef()
|
| 250 |
const inputId = useId()
|
|
|
|
| 326 |
}
|
| 327 |
|
| 328 |
function handleVerifyAgain() {
|
| 329 |
+
setResult(null); setError(null); setExtractedTextOpen(false)
|
| 330 |
sessionStorage.removeItem(STORAGE_KEY)
|
| 331 |
// Smooth-scroll back to the input panel
|
| 332 |
requestAnimationFrame(() => {
|
|
|
|
| 668 |
</button>
|
| 669 |
</div>
|
| 670 |
|
| 671 |
+
{/* Row 1: Gauge + Verdict explanation + Meta */}
|
| 672 |
<div className="grid gap-4 fade-up-1" style={{ gridTemplateColumns: 'min(180px, 40%) 1fr' }}>
|
| 673 |
<div className="card p-5 flex flex-col items-center justify-center gap-3">
|
| 674 |
<ScoreGauge score={result.final_score} size={140} />
|
| 675 |
<VerdictBadge verdict={result.verdict} size="banner" />
|
| 676 |
</div>
|
| 677 |
<div className="card p-5 fade-up-2">
|
| 678 |
+
{/* Plain-language verdict explanation */}
|
| 679 |
+
<div className="mb-4 p-3" style={{
|
| 680 |
+
background: result.verdict === 'Credible' ? 'rgba(34,197,94,0.08)'
|
| 681 |
+
: result.verdict === 'Likely Fake' ? 'rgba(220,38,38,0.08)'
|
| 682 |
+
: 'rgba(234,179,8,0.08)',
|
| 683 |
+
border: `1px solid ${result.verdict === 'Credible' ? 'rgba(34,197,94,0.25)'
|
| 684 |
+
: result.verdict === 'Likely Fake' ? 'rgba(220,38,38,0.25)'
|
| 685 |
+
: 'rgba(234,179,8,0.25)'}`,
|
| 686 |
+
borderRadius: 2,
|
| 687 |
+
}}>
|
| 688 |
+
<div className="flex items-start gap-2">
|
| 689 |
+
<Info size={13} style={{ color: finalColor, marginTop: 2, flexShrink: 0 }} aria-hidden="true" />
|
| 690 |
+
<div>
|
| 691 |
+
<p className="text-sm font-semibold mb-1" style={{ color: finalColor, fontFamily: 'var(--font-display)' }}>
|
| 692 |
+
What does this mean?
|
| 693 |
+
</p>
|
| 694 |
+
<p className="text-xs" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}>
|
| 695 |
+
{(VERDICT_MAP[result.verdict] ?? VERDICT_MAP['Unverified']).explanation}
|
| 696 |
+
</p>
|
| 697 |
+
</div>
|
| 698 |
+
</div>
|
| 699 |
+
</div>
|
| 700 |
<SectionHeading>Analysis Details</SectionHeading>
|
| 701 |
<MetaRow label="Language" value={result.language} />
|
| 702 |
<MetaRow label="Sentiment" value={result.sentiment} />
|
|
|
|
| 711 |
{/* Row 2: Score breakdown */}
|
| 712 |
<div className="card p-5 fade-up-3">
|
| 713 |
<SectionHeading>Score Breakdown</SectionHeading>
|
| 714 |
+
<p className="text-xs mb-4" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}>
|
| 715 |
+
The final score combines two layers of analysis. A score of 70+ means likely credible, 40β69 is uncertain, and below 40 is likely false.
|
| 716 |
+
</p>
|
| 717 |
<div className="space-y-4">
|
| 718 |
+
<ScoreBar label="ML Classifier (Layer 1 β 40% weight)" value={result.layer1?.confidence || 0} color="var(--accent-cyan)" index={0} />
|
| 719 |
+
<ScoreBar label="Evidence Cross-Check (Layer 2 β 60% weight)" value={result.layer2?.evidence_score || 0} color="var(--accent-gold)" index={1} />
|
| 720 |
+
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
| 721 |
+
<ScoreBar label="Final Credibility Score" value={result.final_score} color={finalColor} index={2} />
|
| 722 |
+
</div>
|
| 723 |
</div>
|
| 724 |
+
<p className="text-xs mt-3" style={{ color: finalColor, fontFamily: 'var(--font-body)', lineHeight: 1.6, fontWeight: 600 }}>
|
| 725 |
+
{scoreInterpretation(result.final_score)}
|
| 726 |
+
</p>
|
| 727 |
</div>
|
| 728 |
|
| 729 |
+
{/* Row 2Β½: Extracted Text (collapsible) */}
|
| 730 |
+
{result.extracted_text && (
|
| 731 |
+
<div className="card fade-up-3" style={{ overflow: 'hidden' }}>
|
| 732 |
+
<button
|
| 733 |
+
onClick={() => setExtractedTextOpen(o => !o)}
|
| 734 |
+
className="w-full flex items-center justify-between px-5 py-3 transition-colors"
|
| 735 |
+
style={{
|
| 736 |
+
background: 'none',
|
| 737 |
+
border: 'none',
|
| 738 |
+
cursor: 'pointer',
|
| 739 |
+
borderBottom: extractedTextOpen ? '1px solid var(--border)' : 'none',
|
| 740 |
+
}}
|
| 741 |
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
| 742 |
+
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
| 743 |
+
aria-expanded={extractedTextOpen}
|
| 744 |
+
aria-controls="extracted-text-panel"
|
| 745 |
+
>
|
| 746 |
+
<div className="flex items-center gap-2">
|
| 747 |
+
<span className="text-xs font-semibold uppercase tracking-widest"
|
| 748 |
+
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)', letterSpacing: '0.15em' }}>
|
| 749 |
+
{result.input_type === 'image' ? 'OCR Extracted Text'
|
| 750 |
+
: result.input_type === 'video' ? 'Transcribed Text'
|
| 751 |
+
: result.input_type === 'url' ? 'Scraped Text'
|
| 752 |
+
: 'Analyzed Text'}
|
| 753 |
+
</span>
|
| 754 |
+
<span className="text-xs tabular"
|
| 755 |
+
style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)', fontSize: 10 }}>
|
| 756 |
+
{result.extracted_text.length} chars
|
| 757 |
+
</span>
|
| 758 |
+
</div>
|
| 759 |
+
<ChevronRight size={13}
|
| 760 |
+
style={{
|
| 761 |
+
color: 'var(--text-muted)',
|
| 762 |
+
transform: extractedTextOpen ? 'rotate(90deg)' : 'rotate(0deg)',
|
| 763 |
+
transition: 'transform 200ms ease',
|
| 764 |
+
flexShrink: 0,
|
| 765 |
+
}}
|
| 766 |
+
aria-hidden="true"
|
| 767 |
+
/>
|
| 768 |
+
</button>
|
| 769 |
+
{extractedTextOpen && (
|
| 770 |
+
<div id="extracted-text-panel" className="px-5 py-4">
|
| 771 |
+
{result.input_type === 'url' && (
|
| 772 |
+
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}>
|
| 773 |
+
This is the text our scraper extracted from the URL. If it looks wrong or incomplete, the page may have been partially blocked.
|
| 774 |
+
</p>
|
| 775 |
+
)}
|
| 776 |
+
{result.input_type === 'image' && (
|
| 777 |
+
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}>
|
| 778 |
+
This is the text read from your image using OCR. Poor image quality may cause errors.
|
| 779 |
+
</p>
|
| 780 |
+
)}
|
| 781 |
+
{result.input_type === 'video' && (
|
| 782 |
+
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}>
|
| 783 |
+
This is the speech transcribed from your video/audio file.
|
| 784 |
+
</p>
|
| 785 |
+
)}
|
| 786 |
+
<pre
|
| 787 |
+
style={{
|
| 788 |
+
whiteSpace: 'pre-wrap',
|
| 789 |
+
wordBreak: 'break-word',
|
| 790 |
+
fontFamily: 'var(--font-mono)',
|
| 791 |
+
fontSize: 12,
|
| 792 |
+
lineHeight: 1.7,
|
| 793 |
+
color: 'var(--text-secondary)',
|
| 794 |
+
background: 'var(--bg-elevated)',
|
| 795 |
+
border: '1px solid var(--border)',
|
| 796 |
+
borderRadius: 2,
|
| 797 |
+
padding: '12px 14px',
|
| 798 |
+
maxHeight: 280,
|
| 799 |
+
overflowY: 'auto',
|
| 800 |
+
}}
|
| 801 |
+
>
|
| 802 |
+
{result.extracted_text}
|
| 803 |
+
</pre>
|
| 804 |
+
</div>
|
| 805 |
+
)}
|
| 806 |
+
</div>
|
| 807 |
+
)}
|
| 808 |
+
|
| 809 |
{/* Row 3: Layer cards (2 col, collapses to 1 on mobile) */}
|
| 810 |
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 fade-up-4">
|
| 811 |
{/* Layer 1 */}
|
| 812 |
<LayerCard
|
| 813 |
+
title="Layer 1 β AI Analysis"
|
| 814 |
icon={Brain}
|
| 815 |
verdict={result.layer1?.verdict}
|
| 816 |
score={result.layer1?.confidence}
|
| 817 |
delay={0}>
|
| 818 |
+
<p className="text-xs mt-2" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}>
|
| 819 |
+
{mlConfidenceExplanation(result.layer1?.confidence || 0, result.layer1?.verdict)}
|
| 820 |
+
</p>
|
| 821 |
<div className="mt-3">
|
| 822 |
<p className="text-xs mb-2" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.1em' }}>
|
| 823 |
TRIGGERED FEATURES
|
| 824 |
</p>
|
| 825 |
+
<p className="text-xs mb-2" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}>
|
| 826 |
+
{result.layer1?.triggered_features?.length > 0
|
| 827 |
+
? 'These patterns are commonly found in misleading content:'
|
| 828 |
+
: ''}
|
| 829 |
+
</p>
|
| 830 |
<FeatureBreakdown features={result.layer1?.triggered_features} />
|
| 831 |
</div>
|
| 832 |
</LayerCard>
|
| 833 |
|
| 834 |
{/* Layer 2 */}
|
| 835 |
<LayerCard
|
| 836 |
+
title="Layer 2 β Evidence Check"
|
| 837 |
icon={Layers}
|
| 838 |
verdict={result.layer2?.verdict}
|
| 839 |
score={result.layer2?.evidence_score}
|
| 840 |
delay={80}>
|
| 841 |
+
<p className="text-xs mt-2" style={{ color: 'var(--text-secondary)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}>
|
| 842 |
+
{evidenceExplanation(result.layer2?.evidence_score || 0, result.layer2?.sources)}
|
| 843 |
+
</p>
|
| 844 |
<p className="text-xs mt-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.6 }}>
|
| 845 |
+
<span style={{ color: 'var(--text-secondary)' }}>Claim searched: </span>
|
| 846 |
"{result.layer2?.claim_used || 'No claim extracted'}"
|
| 847 |
</p>
|
| 848 |
</LayerCard>
|
|
|
|
| 864 |
{allEntities.length > 0 && (
|
| 865 |
<div className="card p-5 fade-up-5">
|
| 866 |
<SectionHeading count={allEntities.length}>Named Entities</SectionHeading>
|
| 867 |
+
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}>
|
| 868 |
+
People, organizations, and places mentioned in the claim. These were used to search for related news articles.
|
| 869 |
+
</p>
|
| 870 |
<ul className="flex flex-wrap gap-2" role="list">
|
| 871 |
{allEntities.map((e, i) => (
|
| 872 |
<li key={i}
|
|
|
|
| 890 |
{result.layer2?.sources?.length > 0 && (
|
| 891 |
<div className="card p-5 fade-up-5">
|
| 892 |
<SectionHeading count={result.layer2.sources.length}>Evidence Sources</SectionHeading>
|
| 893 |
+
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-body)', lineHeight: 1.5 }}>
|
| 894 |
+
News articles found that relate to this claim.
|
| 895 |
+
<span style={{ color: 'var(--credible)' }}> Supports</span> = confirms the claim,
|
| 896 |
+
<span style={{ color: 'var(--fake)' }}> Refutes</span> = contradicts it,
|
| 897 |
+
<span style={{ color: 'var(--text-muted)' }}> Not Enough Info</span> = related but neutral.
|
| 898 |
+
The % match shows how closely the article relates to the claim.
|
| 899 |
+
</p>
|
| 900 |
<ul className="space-y-2" role="list">
|
| 901 |
{result.layer2.sources.map((src, i) => {
|
| 902 |
const { Icon: StanceIcon, color: stanceColor } = STANCE_ICON[src.stance] ?? STANCE_ICON['Not Enough Info']
|
frontend/src/utils/format.js
CHANGED
|
@@ -29,7 +29,44 @@ export function scoreColor(score) {
|
|
| 29 |
* Map verdict string to badge class
|
| 30 |
*/
|
| 31 |
export const VERDICT_MAP = {
|
| 32 |
-
'Credible': { cls: 'badge-credible', label: 'VERIFIED', symbol: 'β' },
|
| 33 |
-
'Unverified': { cls: 'badge-unverified', label: 'UNVERIFIED', symbol: '?' },
|
| 34 |
-
'Likely Fake': { cls: 'badge-fake', label: 'FALSE', symbol: 'β' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
}
|
|
|
|
| 29 |
* Map verdict string to badge class
|
| 30 |
*/
|
| 31 |
export const VERDICT_MAP = {
|
| 32 |
+
'Credible': { cls: 'badge-credible', label: 'VERIFIED', symbol: 'β', explanation: 'This claim appears credible. Multiple signals and/or supporting evidence suggest this information is likely true.' },
|
| 33 |
+
'Unverified': { cls: 'badge-unverified', label: 'UNVERIFIED', symbol: '?', explanation: 'We couldn\'t confirm or deny this claim. There isn\'t enough evidence either way β treat this information with caution and verify from other sources.' },
|
| 34 |
+
'Likely Fake': { cls: 'badge-fake', label: 'FALSE', symbol: 'β', explanation: 'This claim shows strong signs of being false or misleading. Our analysis detected multiple red flags β do not share this without verifying from trusted news sources.' },
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Human-readable interpretation of a 0-100 score
|
| 39 |
+
*/
|
| 40 |
+
export function scoreInterpretation(score) {
|
| 41 |
+
if (score >= 85) return 'Very high credibility β strong evidence supports this claim.'
|
| 42 |
+
if (score >= 70) return 'Likely credible β most signals point to this being true.'
|
| 43 |
+
if (score >= 55) return 'Uncertain β some supporting evidence, but not enough to confirm.'
|
| 44 |
+
if (score >= 40) return 'Questionable β limited evidence and some suspicious signals detected.'
|
| 45 |
+
if (score >= 20) return 'Likely false β multiple red flags and contradicting evidence found.'
|
| 46 |
+
return 'Very likely false β strong indicators of misinformation detected.'
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Human-readable explanation for Layer 1 ML confidence
|
| 51 |
+
*/
|
| 52 |
+
export function mlConfidenceExplanation(confidence, verdict) {
|
| 53 |
+
const isFake = verdict === 'Likely Fake'
|
| 54 |
+
if (confidence >= 85) return isFake
|
| 55 |
+
? 'The AI model is very confident this contains fake news patterns (clickbait, emotional manipulation, misleading language).'
|
| 56 |
+
: 'The AI model is very confident this reads like legitimate, credible reporting.'
|
| 57 |
+
if (confidence >= 60) return isFake
|
| 58 |
+
? 'The AI model detected several patterns commonly found in fake news.'
|
| 59 |
+
: 'The AI model found this mostly consistent with credible content.'
|
| 60 |
+
return 'The AI model has low confidence β the text doesn\'t clearly match fake or credible patterns.'
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Human-readable explanation for Layer 2 evidence score
|
| 65 |
+
*/
|
| 66 |
+
export function evidenceExplanation(score, sources) {
|
| 67 |
+
const count = sources?.length || 0
|
| 68 |
+
if (count === 0) return 'No matching news articles were found to cross-reference this claim. The score reflects a neutral default.'
|
| 69 |
+
if (score >= 70) return `Found ${count} related article${count > 1 ? 's' : ''} from news sources that support this claim.`
|
| 70 |
+
if (score >= 40) return `Found ${count} related article${count > 1 ? 's' : ''}, but evidence is mixed or inconclusive.`
|
| 71 |
+
return `Found ${count} related article${count > 1 ? 's' : ''} β some contradict or debunk this claim.`
|
| 72 |
}
|
inputs/url_scraper.py
CHANGED
|
@@ -10,6 +10,7 @@ Extraction strategy (waterfall):
|
|
| 10 |
"""
|
| 11 |
import logging
|
| 12 |
import re
|
|
|
|
| 13 |
from urllib.parse import urlparse
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
|
@@ -48,6 +49,121 @@ def _is_social_url(url: str) -> str | None:
|
|
| 48 |
return None
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
async def _scrape_social_oembed(url: str, platform: str, client) -> str:
|
| 52 |
"""
|
| 53 |
Extract post text via the public oEmbed API β no login required.
|
|
@@ -303,7 +419,22 @@ async def scrape_url(url: str) -> tuple[str, str]:
|
|
| 303 |
text = await _scrape_social_oembed(url, platform, client)
|
| 304 |
if text and len(text.strip()) >= 20:
|
| 305 |
return text, domain
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
return "", domain
|
| 308 |
|
| 309 |
if not _robots_allow(url):
|
|
|
|
| 10 |
"""
|
| 11 |
import logging
|
| 12 |
import re
|
| 13 |
+
import urllib.parse
|
| 14 |
from urllib.parse import urlparse
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
|
|
|
| 49 |
return None
|
| 50 |
|
| 51 |
|
| 52 |
+
def _scrape_facebook_post_sync(url: str) -> tuple[str, str | None]:
|
| 53 |
+
"""
|
| 54 |
+
Fallback Facebook post scraper using the `facebook-scraper` library.
|
| 55 |
+
Runs synchronously β call via asyncio.to_thread() from async code.
|
| 56 |
+
|
| 57 |
+
Returns (text, image_url) where image_url may be None.
|
| 58 |
+
Returns ("", None) if scraping fails or yields no content.
|
| 59 |
+
|
| 60 |
+
If FACEBOOK_C_USER + FACEBOOK_XS cookies are set in config, they are passed
|
| 61 |
+
to unlock friends-only posts and reduce rate-limiting.
|
| 62 |
+
"""
|
| 63 |
+
try:
|
| 64 |
+
import facebook_scraper as fs
|
| 65 |
+
from facebook_scraper import exceptions as fb_exc
|
| 66 |
+
except ImportError:
|
| 67 |
+
logger.warning("facebook-scraper not installed β skipping FB post fallback")
|
| 68 |
+
return "", None
|
| 69 |
+
|
| 70 |
+
from config import get_settings
|
| 71 |
+
cookies = get_settings().facebook_cookies
|
| 72 |
+
if cookies:
|
| 73 |
+
logger.info("facebook-scraper: using authenticated cookies (c_user=%sβ¦)", cookies["c_user"][:6])
|
| 74 |
+
try:
|
| 75 |
+
fs.set_cookies(cookies)
|
| 76 |
+
except Exception:
|
| 77 |
+
pass # non-fatal β library may not expose this in all versions
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
# get_posts accepts direct post URLs via post_urls= parameter.
|
| 81 |
+
# allow_extra_requests=False avoids spawning pyppeteer/headless Chromium.
|
| 82 |
+
gen = fs.get_posts(
|
| 83 |
+
post_urls=[url],
|
| 84 |
+
options={
|
| 85 |
+
"allow_extra_requests": False,
|
| 86 |
+
"progress": False,
|
| 87 |
+
},
|
| 88 |
+
cookies=cookies or {},
|
| 89 |
+
)
|
| 90 |
+
post = next(gen, None)
|
| 91 |
+
if post is None:
|
| 92 |
+
logger.info("facebook-scraper: no post returned for %s", url)
|
| 93 |
+
return "", None
|
| 94 |
+
|
| 95 |
+
# post_text is the full untruncated body; text may be truncated
|
| 96 |
+
raw_text = post.get("post_text") or post.get("text") or ""
|
| 97 |
+
|
| 98 |
+
# Append shared post text (quote/repost) for additional signal
|
| 99 |
+
shared = post.get("shared_text") or ""
|
| 100 |
+
if shared and shared not in raw_text:
|
| 101 |
+
raw_text = f"{raw_text}\n\n{shared}".strip()
|
| 102 |
+
|
| 103 |
+
text = _clean_text(raw_text)
|
| 104 |
+
|
| 105 |
+
# Image selection priority:
|
| 106 |
+
# 1. First entry in images[] (highest quality, actual post photo)
|
| 107 |
+
# 2. Fallback `image` field (single-image shorthand)
|
| 108 |
+
# 3. video_thumbnail if it's a video post (gives OCR something to work with)
|
| 109 |
+
images: list[str] = post.get("images") or []
|
| 110 |
+
image_url: str | None = (
|
| 111 |
+
images[0]
|
| 112 |
+
if images
|
| 113 |
+
else post.get("image") or post.get("video_thumbnail")
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
logger.info(
|
| 117 |
+
"facebook-scraper OK: %d chars, image=%s, video=%s for %s",
|
| 118 |
+
len(text), bool(image_url), bool(post.get("video")), url,
|
| 119 |
+
)
|
| 120 |
+
return text, image_url
|
| 121 |
+
|
| 122 |
+
# ββ Specific exceptions from facebook_scraper.exceptions βββββββββββββββββ
|
| 123 |
+
except fb_exc.LoginRequired:
|
| 124 |
+
# Post requires a logged-in session.
|
| 125 |
+
if cookies:
|
| 126 |
+
logger.warning("facebook-scraper: login still required even with cookies for %s β cookies may be expired", url)
|
| 127 |
+
else:
|
| 128 |
+
logger.info("facebook-scraper: login required for %s β no cookies configured", url)
|
| 129 |
+
return "", None
|
| 130 |
+
|
| 131 |
+
except fb_exc.NotFound:
|
| 132 |
+
logger.info("facebook-scraper: post not found for %s", url)
|
| 133 |
+
return "", None
|
| 134 |
+
|
| 135 |
+
except fb_exc.TemporarilyBanned:
|
| 136 |
+
# IP-level rate limit from Facebook β log as warning so Cloud Logging alerts fire
|
| 137 |
+
logger.warning("facebook-scraper: IP temporarily banned by Facebook while fetching %s", url)
|
| 138 |
+
return "", None
|
| 139 |
+
|
| 140 |
+
except fb_exc.InvalidCookies:
|
| 141 |
+
logger.warning("facebook-scraper: invalid/expired cookies for %s β falling back to public scraping", url)
|
| 142 |
+
return "", None
|
| 143 |
+
|
| 144 |
+
except fb_exc.UnexpectedResponse as exc:
|
| 145 |
+
logger.warning("facebook-scraper: unexpected FB response for %s: %s", url, exc)
|
| 146 |
+
return "", None
|
| 147 |
+
|
| 148 |
+
except StopIteration:
|
| 149 |
+
# Generator exhausted without yielding β URL is a profile/group, not a post
|
| 150 |
+
logger.info("facebook-scraper: generator empty for %s (not a post URL)", url)
|
| 151 |
+
return "", None
|
| 152 |
+
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
logger.warning("facebook-scraper: unexpected error for %s: %s", url, exc)
|
| 155 |
+
return "", None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
async def _scrape_facebook_post(url: str) -> tuple[str, str | None]:
|
| 159 |
+
"""
|
| 160 |
+
Async wrapper around _scrape_facebook_post_sync().
|
| 161 |
+
Returns (text, image_url).
|
| 162 |
+
"""
|
| 163 |
+
import asyncio
|
| 164 |
+
return await asyncio.to_thread(_scrape_facebook_post_sync, url)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
async def _scrape_social_oembed(url: str, platform: str, client) -> str:
|
| 168 |
"""
|
| 169 |
Extract post text via the public oEmbed API β no login required.
|
|
|
|
| 419 |
text = await _scrape_social_oembed(url, platform, client)
|
| 420 |
if text and len(text.strip()) >= 20:
|
| 421 |
return text, domain
|
| 422 |
+
|
| 423 |
+
# oEmbed returned nothing (private post, group URL, or API limit hit).
|
| 424 |
+
# For Facebook, try facebook-scraper as a fallback to get full post content.
|
| 425 |
+
if platform == "facebook":
|
| 426 |
+
logger.info("oEmbed returned no content for %s β trying facebook-scraper fallback", url)
|
| 427 |
+
fb_text, fb_image = await _scrape_facebook_post(url)
|
| 428 |
+
if fb_text and len(fb_text.strip()) >= 20:
|
| 429 |
+
# NOTE: fb_image contains the post image URL if present.
|
| 430 |
+
# The current pipeline is text-only for URL verification.
|
| 431 |
+
# Future: extend VerifyURLRequest to accept an optional image_url
|
| 432 |
+
# so the multimodal endpoint can be invoked here instead.
|
| 433 |
+
if fb_image:
|
| 434 |
+
logger.info("facebook-scraper also found image for %s β not yet used in pipeline", url)
|
| 435 |
+
return fb_text.strip(), domain
|
| 436 |
+
|
| 437 |
+
# All fallbacks failed β could be a profile/group URL rather than a specific post
|
| 438 |
return "", domain
|
| 439 |
|
| 440 |
if not _robots_allow(url):
|
requirements.txt
CHANGED
|
@@ -51,6 +51,7 @@ httpx==0.28.1 # Async HTTP client
|
|
| 51 |
aiofiles==24.1.0
|
| 52 |
tqdm==4.67.1
|
| 53 |
numpy==1.26.4
|
|
|
|
| 54 |
|
| 55 |
# ββ Testing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
pytest==8.3.4
|
|
|
|
| 51 |
aiofiles==24.1.0
|
| 52 |
tqdm==4.67.1
|
| 53 |
numpy==1.26.4
|
| 54 |
+
facebook-scraper>=0.2.59 # Fallback scraper for Facebook post URLs (public posts, no login)
|
| 55 |
|
| 56 |
# ββ Testing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
pytest==8.3.4
|