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 CHANGED
@@ -1,5 +1,5 @@
1
- vite.svg,1771983804434,d3bbbc44b3ea71906a72bf2ec1a4716903e2e3d9f85a5007205a65d1f12e2923
2
- index.html,1771983804629,b6c877b7fe830ae6270dfb77cd1d205222591249325fa88601f51f6e2ed57653
3
- logo.svg,1771983804434,c1ca19989c26d83c632b01609dc4514e16bef7418284c6df88b29ac34ca035ec
4
- assets/index-DE8XF5VL.css,1771983804629,941148112bdd25f98beea529b6ad97209f2f777e70671d0f5b96f919c8472699
5
- assets/index-BCcoqzYM.js,1771983804629,60632c706af44a3486a56a8364e32bdce3c7a8cb388f69de2fe9c21876d55942
 
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 = 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,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: 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,16 +82,20 @@ async function setCached(key, result, preview) {
82
 
83
  // ── API calls ─────────────────────────────────────────────────────────────────
84
 
85
- async function verifyText(text) {
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
  const res = await fetch(`${apiBase}/verify/text`, {
92
- method: 'POST',
93
  headers: { 'Content-Type': 'application/json' },
94
- body: JSON.stringify({ text }),
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: 'POST',
113
  headers: { 'Content-Type': 'application/json' },
114
- body: JSON.stringify({ url }),
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 = imgRes.headers.get('content-type') ?? 'image/jpeg'
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 => sendResponse({ ok: true, result: 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 => sendResponse({ ok: true, result: 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 => sendResponse({ ok: true, result: 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
- * Badge overlay injected into Facebook feed posts.
4
- * All selectors are namespaced under .pv-* to avoid collisions.
5
  */
6
 
7
- /* ── Badge wrapper ───────────────────────────────────────────────────────── */
8
- .pv-badge-wrap {
9
- display: block;
10
- margin: 6px 12px 2px;
 
 
 
 
11
  }
12
 
13
- .pv-badge {
 
 
14
  display: inline-flex;
15
  align-items: center;
16
  gap: 6px;
17
- padding: 4px 10px;
18
- border-radius: 3px;
 
 
 
 
 
19
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
20
  font-size: 11px;
21
  font-weight: 600;
22
- letter-spacing: 0.04em;
23
  cursor: pointer;
24
  touch-action: manipulation;
25
  -webkit-tap-highlight-color: transparent;
 
 
 
 
26
  }
27
 
28
- .pv-badge:focus-visible {
 
 
 
 
 
 
 
 
 
 
29
  outline: 2px solid #06b6d4;
30
  outline-offset: 2px;
31
  }
32
 
33
- /* ── Loading state ───────────────────────────────────────────────────────── */
34
- .pv-badge--loading {
 
 
 
 
 
 
 
 
 
 
 
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: 10px;
44
- height: 10px;
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 { animation: none; }
53
- }
 
54
 
55
- @keyframes pv-spin {
56
- to { transform: rotate(360deg); }
 
 
 
 
 
57
  }
58
 
59
- /* ── Error state ─────────────────────────────────────────────────────────── */
60
- .pv-badge--error {
61
- color: #78716c;
62
- border: 1px solid rgba(120, 113, 108, 0.2);
63
- background: transparent;
64
- cursor: default;
65
- font-size: 10px;
66
  }
67
 
68
- /* ── Detail panel ────────────────────────────────────────────────────────── */
69
- .pv-detail {
70
  display: block;
71
- margin: 4px 0 6px;
72
- padding: 10px 12px;
73
  background: #141414;
74
  border: 1px solid rgba(245, 240, 232, 0.1);
75
- border-radius: 4px;
76
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
77
  font-size: 11px;
78
  color: #f5f0e8;
79
- max-width: 400px;
80
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
 
 
81
  }
82
 
83
- .pv-detail-header {
 
84
  display: flex;
85
  align-items: center;
86
  justify-content: space-between;
87
- margin-bottom: 8px;
88
- padding-bottom: 6px;
89
  border-bottom: 1px solid rgba(245, 240, 232, 0.07);
90
  }
91
 
92
- .pv-logo {
93
  font-weight: 800;
94
- font-size: 12px;
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: 12px;
105
- padding: 2px 4px;
106
- border-radius: 2px;
107
  touch-action: manipulation;
 
 
 
 
 
 
108
  }
109
- .pv-close:hover { color: #f5f0e8; }
110
- .pv-close:focus-visible { outline: 2px solid #06b6d4; }
111
 
112
- .pv-row {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  display: flex;
114
  justify-content: space-between;
115
  align-items: center;
116
- padding: 4px 0;
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-val {
129
  font-size: 11px;
130
- font-weight: 600;
131
  color: #a89f94;
132
  }
133
 
134
- .pv-signals {
135
- padding: 6px 0 4px;
 
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: 4px;
144
  }
145
 
146
- .pv-tag {
147
- padding: 2px 6px;
148
  background: rgba(220, 38, 38, 0.12);
149
  color: #f87171;
150
  border: 1px solid rgba(220, 38, 38, 0.25);
151
- border-radius: 2px;
152
  font-size: 9px;
153
  letter-spacing: 0.04em;
154
  font-weight: 600;
155
  }
156
 
157
- .pv-source {
158
- padding: 6px 0 4px;
 
159
  border-bottom: 1px solid rgba(245, 240, 232, 0.05);
160
  }
161
 
162
- .pv-source-link {
163
- display: block;
164
- margin-top: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-open-full {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  display: block;
176
- margin-top: 8px;
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: 5px;
185
  border: 1px solid rgba(220, 38, 38, 0.3);
186
- border-radius: 2px;
 
187
  }
188
- .pv-open-full:hover {
 
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
- * Watches the Facebook feed via MutationObserver.
5
- * For each new post that appears:
6
- * 1. Extracts the post text or shared URL
7
- * 2. Sends to background.js for verification (with cache)
8
- * 3. Injects a credibility badge overlay onto the post card
9
  *
10
- * Badge click β†’ opens an inline detail panel with verdict, score, and top source.
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
- // Detect which platform we're on
 
 
 
 
 
 
 
 
 
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
- // data-pagelet attrs are structural and stable β€” they only exist on feed posts,
36
- // NOT on comments. [role="article"] matches both posts AND comment items so
37
- // we keep it only as a last resort with extra filtering in findPosts().
 
 
 
 
38
  post: [
39
- '[data-pagelet^="FeedUnit"]',
40
- '[data-pagelet^="GroupsFeedUnit"]',
41
- '[data-pagelet^="ProfileTimeline"]',
42
- '[role="article"]',
43
  ],
44
- text: ['[data-ad-comet-preview="message"]', '[data-testid="post_message"]', '[dir="auto"]'],
 
 
 
 
 
 
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': '#16a34a',
72
- 'Unverified': '#d97706',
73
  'Likely Fake': '#dc2626',
74
  }
75
  const VERDICT_LABELS = {
76
- 'Credible': 'βœ“ Credible',
77
- 'Unverified': '? Unverified',
78
  'Likely Fake': 'βœ— Likely Fake',
79
  }
 
 
 
 
 
80
 
81
  // ── Utilities ─────────────────────────────────────────────────────────────
82
 
83
- /** Escape HTML special chars to prevent XSS in innerHTML templates */
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
- // Fallback: any span with substantial text
 
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')) return t.slice(0, 2000)
 
 
 
 
 
 
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
- /** Returns the src of the most prominent image in a post, or null. */
 
 
 
 
 
 
126
  function extractPostImage(post) {
127
  if (!CFG.image) return null
128
- // Prefer largest image by rendered width
129
- const imgs = Array.from(post.querySelectorAll(CFG.image))
130
- if (!imgs.length) return null
131
- const best = imgs.reduce((a, b) =>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- function genPostId(post) {
140
- // Use aria-label prefix + UUID for stable, unique ID
141
- // Avoids offsetTop which forces a synchronous layout read
142
- const label = (post.getAttribute('aria-label') ?? '').replace(/\W/g, '').slice(0, 20)
143
- return 'pv_' + label + crypto.randomUUID().replace(/-/g, '').slice(0, 12)
144
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
- // ── Badge rendering ───────────────────────────────────────────────────────
 
 
 
 
 
 
147
 
148
- function createBadge(verdict, score, result) {
149
- const color = VERDICT_COLORS[verdict] ?? '#5c554e'
150
- const label = VERDICT_LABELS[verdict] ?? verdict
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
- const wrap = document.createElement('div')
153
- wrap.className = 'pv-badge'
154
- wrap.setAttribute('role', 'status')
155
- wrap.setAttribute('aria-label', `PhilVerify: ${label} β€” ${Math.round(score)}% credibility score`)
156
- wrap.style.cssText = `
157
- display: inline-flex;
158
- align-items: center;
159
- gap: 6px;
160
- padding: 4px 10px;
161
- border-radius: 3px;
162
- border: 1px solid ${color}4d;
163
- background: ${color}14;
164
- cursor: pointer;
165
- font-family: system-ui, sans-serif;
166
- font-size: 11px;
167
- font-weight: 600;
168
- letter-spacing: 0.04em;
169
- color: ${color};
170
- touch-action: manipulation;
171
- -webkit-tap-highlight-color: transparent;
172
- position: relative;
173
- z-index: 10;
174
- `
175
-
176
- const dot = document.createElement('span')
177
- dot.style.cssText = `
178
- width: 7px; height: 7px;
179
- border-radius: 50%;
180
- background: ${color};
181
- flex-shrink: 0;
182
- `
183
-
184
- const text = document.createElement('span')
185
- text.textContent = `${label} ${Math.round(score)}%`
186
-
187
- const cacheTag = result._fromCache
188
- ? (() => { const t = document.createElement('span'); t.textContent = 'Β·cached'; t.style.cssText = `opacity:0.5;font-size:9px;`; return t })()
189
- : null
190
-
191
- wrap.appendChild(dot)
192
- wrap.appendChild(text)
193
- if (cacheTag) wrap.appendChild(cacheTag)
194
-
195
- // Click β†’ toggle detail panel
196
- wrap.addEventListener('click', (e) => {
197
- e.stopPropagation()
198
- toggleDetailPanel(wrap, result)
199
- })
200
 
201
- return wrap
 
 
 
 
 
202
  }
203
 
204
- function toggleDetailPanel(badge, result) {
205
- const existing = badge.parentElement?.querySelector('.pv-detail')
206
- if (existing) { existing.remove(); return }
207
-
208
- const panel = document.createElement('div')
209
- panel.className = 'pv-detail'
210
- panel.setAttribute('role', 'dialog')
211
- panel.setAttribute('aria-label', 'PhilVerify fact-check details')
212
-
213
- const color = VERDICT_COLORS[result.verdict] ?? '#5c554e'
214
- const topSource = result.layer2?.sources?.[0]
215
-
216
- panel.innerHTML = `
217
- <div class="pv-detail-header">
218
- <span class="pv-logo">PHIL<span style="color:${color}">VERIFY</span></span>
219
- <button class="pv-close" aria-label="Close fact-check panel">βœ•</button>
220
- </div>
221
- <div class="pv-row">
222
- <span class="pv-label">VERDICT</span>
223
- <span class="pv-val" style="color:${color};font-weight:700">${safeText(result.verdict)}</span>
224
- </div>
225
- <div class="pv-row">
226
- <span class="pv-label">SCORE</span>
227
- <span class="pv-val" style="color:${color}">${Math.round(result.final_score)}%</span>
228
- </div>
229
- <div class="pv-row">
230
- <span class="pv-label">LANGUAGE</span>
231
- <span class="pv-val">${safeText(result.language ?? 'β€”')}</span>
232
- </div>
233
- ${result.layer1?.triggered_features?.length ? `
234
- <div class="pv-signals">
235
- <span class="pv-label">SIGNALS</span>
236
- <div class="pv-tags">
237
- ${result.layer1.triggered_features.slice(0, 3).map(f =>
238
- `<span class="pv-tag">${safeText(f)}</span>`
239
- ).join('')}
240
- </div>
241
- </div>` : ''}
242
- ${topSource ? `
243
- <div class="pv-source">
244
- <span class="pv-label">TOP SOURCE</span>
245
- <a href="${safeUrl(topSource.url)}" target="_blank" rel="noreferrer" class="pv-source-link">
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
- panel.remove()
 
257
  })
258
 
259
- badge.insertAdjacentElement('afterend', panel)
260
- }
261
 
262
- function injectBadgeIntoPost(post, result) {
263
- // Find a stable injection point β€” platform-specific
264
- let anchor = null
 
 
265
  if (PLATFORM === 'facebook') {
266
- anchor = post.querySelector('[data-testid="UFI2ReactionsCount/root"]')
267
- ?? post.querySelector('[aria-label*="reaction"]')
268
- ?? post.querySelector('[role="toolbar"]')
269
- } else if (PLATFORM === 'twitter') {
270
- // Tweet action bar (reply / retweet / like row)
271
- anchor = post.querySelector('[role="group"][aria-label]')
272
- ?? post.querySelector('[data-testid="reply"]')?.closest('[role="group"]')
273
  }
274
 
275
- const container = document.createElement('div')
276
- container.className = 'pv-badge-wrap'
277
- const badge = createBadge(result.verdict, result.final_score, result)
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
- // ── Loading state ─────────────────────────────────────────────────────────
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
- // ── Post processing ───────────────────────────────────────────────────────
304
-
305
- async function processPost(post) {
306
- if (post.dataset.philverify) return // already processed
307
- const id = genPostId(post)
308
- post.dataset.philverify = id
309
-
310
- const text = extractPostText(post)
311
- const url = extractPostUrl(post)
 
 
 
 
 
 
 
 
 
 
 
312
  const image = extractPostImage(post)
313
 
314
- // Need at least one signal to verify
315
- if (!text && !url && !image) return
316
 
317
- const loader = injectLoadingBadge(post)
 
 
 
 
 
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) reject(new Error(resp?.error ?? 'Unknown error'))
336
- else resolve(resp.result)
337
  })
338
  })
339
 
340
- loader.remove()
341
- injectBadgeIntoPost(post, response)
342
  } catch (err) {
343
- loader.remove()
344
- const errBadge = document.createElement('div')
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) processPost(post)
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
- // Check if the node itself matches a post selector
397
- for (const sel of POST_SELECTORS) {
398
- if (node.matches?.(sel)) {
399
- // Skip comment articles on Facebook
400
- if (PLATFORM === 'facebook' && sel === '[role="article"]'
401
- && node.parentElement?.closest('[role="article"]')) break
402
- scheduleProcess(node)
403
- break
 
 
 
 
 
 
 
404
  }
405
  }
406
- // Check descendants
 
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
- // Check autoScan setting before activating.
417
- // Default to true if the service worker hasn't responded yet (MV3 cold start).
418
- const response = await new Promise(resolve => {
419
- chrome.runtime.sendMessage({ type: 'GET_SETTINGS' }, (r) => {
420
- resolve(r ?? { autoScan: true })
 
 
 
 
 
 
 
 
 
421
  })
422
- }).catch(() => ({ autoScan: true }))
 
 
423
 
424
- if (response?.autoScan === false) return
 
 
 
 
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 (Facebook is a SPA β€” feed dynamically loads more)
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 = window.location.href
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.innerHTML = `
459
- <div style="display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden;">
460
- <span style="font-weight:800;letter-spacing:0.1em;color:#f5f0e8;flex-shrink:0;">
461
- PHIL<span style="color:#dc2626">VERIFY</span>
462
- </span>
463
- <span id="pv-auto-status" style="display:flex;align-items:center;gap:6px;overflow:hidden;">
464
- <span class="pv-spinner" aria-hidden="true"></span>
465
- <span style="white-space:nowrap;">Verifying article…</span>
466
- </span>
467
- </div>
468
- <div style="display:flex;align-items:center;gap:8px;flex-shrink:0;">
469
- <a id="pv-open-full"
470
- href="https://philverify.web.app"
471
- target="_blank"
472
- rel="noreferrer"
473
- style="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;"
474
- aria-label="Open PhilVerify dashboard">
475
- FULL ANALYSIS β†—
476
- </a>
477
- <button id="pv-close-banner"
478
- style="background:none;border:none;color:#5c554e;cursor:pointer;font-size:13px;padding:2px 4px;line-height:1;flex-shrink:0;"
479
- aria-label="Dismiss PhilVerify banner">βœ•</button>
480
- </div>
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
- document.getElementById('pv-close-banner').addEventListener('click', () => {
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) reject(new Error(resp?.error ?? 'Unknown error'))
497
- else resolve(resp.result)
498
  })
499
  })
500
 
501
- const color = VERDICT_COLORS[response.verdict] ?? '#5c554e'
502
- const statusEl = document.getElementById('pv-auto-status')
503
- if (statusEl) {
504
- statusEl.innerHTML = `
505
- <span style="width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0;" aria-hidden="true"></span>
506
- <span style="color:${color};font-weight:700;">${safeText(response.verdict)}</span>
507
- <span style="color:#5c554e;margin-left:2px;">${Math.round(response.final_score)}% credibility</span>
508
- ${response.layer1?.triggered_features?.length
509
- ? `<span style="color:#5c554e;margin-left:4px;font-size:9px;">Β· ${safeText(response.layer1.triggered_features[0])}</span>`
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 and user hasn't interacted
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 Score (Layer 2 β€” 60%)" value={result.layer2?.evidence_score || 0} color="var(--accent-gold)" index={1} />
694
- <ScoreBar label="Final Credibility Score" value={result.final_score} color={finalColor} index={2} />
 
 
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 β€” ML Classifier"
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 used: </span>
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
- # oEmbed failed β€” could be a profile/group URL rather than a specific post
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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