Ryan Christian D. Deniega commited on
Commit
0b3f171
Β·
1 Parent(s): 6911f3f

feat(extension): X/Twitter support, image OCR fallback, cleanup IS_FACEBOOK

Browse files
extension/background.js CHANGED
@@ -122,6 +122,29 @@ async function verifyUrl(url) {
122
  return result
123
  }
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  // ── Message handler ───────────────────────────────────────────────────────────
126
 
127
  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
@@ -143,6 +166,16 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
143
  .catch(e => sendResponse({ ok: false, error: e.message }))
144
  return true
145
 
 
 
 
 
 
 
 
 
 
 
146
  case 'GET_HISTORY':
147
  chrome.storage.local.get('history')
148
  .then(({ history = [] }) => sendResponse({ history }))
 
122
  return result
123
  }
124
 
125
+ async function verifyImageUrl(imageUrl) {
126
+ const key = 'img_' + await sha256prefix(imageUrl)
127
+ const hit = await getCached(key)
128
+ if (hit) return { ...hit, _fromCache: true }
129
+
130
+ const { apiBase } = await getSettings()
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}`)
138
+ const res = await fetch(`${apiBase}/verify/image`, { method: 'POST', body: formData })
139
+ if (!res.ok) {
140
+ const body = await res.json().catch(() => ({}))
141
+ throw new Error(body.detail ?? `API error ${res.status}`)
142
+ }
143
+ const result = await res.json()
144
+ await setCached(key, result, imageUrl)
145
+ return result
146
+ }
147
+
148
  // ── Message handler ───────────────────────────────────────────────────────────
149
 
150
  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
 
166
  .catch(e => sendResponse({ ok: false, error: e.message }))
167
  return true
168
 
169
+ case 'VERIFY_IMAGE_URL':
170
+ if (!isHttpUrl(msg.imageUrl)) {
171
+ sendResponse({ ok: false, error: 'Invalid image URL' })
172
+ return false
173
+ }
174
+ verifyImageUrl(msg.imageUrl)
175
+ .then(r => sendResponse({ ok: true, result: r }))
176
+ .catch(e => sendResponse({ ok: false, error: e.message }))
177
+ return true
178
+
179
  case 'GET_HISTORY':
180
  chrome.storage.local.get('history')
181
  .then(({ history = [] }) => sendResponse({ history }))
extension/content.js CHANGED
@@ -66,22 +66,13 @@
66
  }
67
 
68
  function extractPostText(post) {
69
- // Try common post message containers
70
- const msgSelectors = [
71
- '[data-ad-preview="message"]',
72
- '[data-testid="post_message"]',
73
- '[dir="auto"] > div > div > div > span',
74
- 'div[style*="text-align"] span',
75
- ]
76
- for (const sel of msgSelectors) {
77
  const el = post.querySelector(sel)
78
- if (el?.innerText?.trim().length >= MIN_TEXT_LENGTH) {
79
  return el.innerText.trim().slice(0, 2000)
80
- }
81
  }
82
- // Fallback: gather all text spans β‰₯ MIN_TEXT_LENGTH chars
83
- const spans = Array.from(post.querySelectorAll('span'))
84
- for (const span of spans) {
85
  const t = span.innerText?.trim()
86
  if (t && t.length >= MIN_TEXT_LENGTH && !t.startsWith('http')) return t.slice(0, 2000)
87
  }
@@ -89,27 +80,27 @@
89
  }
90
 
91
  function extractPostUrl(post) {
92
- // Shared article links
93
- const linkSelectors = [
94
- 'a[href*="l.facebook.com/l.php"]', // Facebook link wrapper
95
- 'a[target="_blank"][href^="https"]', // Direct external links
96
- 'a[aria-label][href*="facebook.com/watch"]', // Videos
97
- ]
98
- for (const sel of linkSelectors) {
99
  const el = post.querySelector(sel)
100
- if (el?.href) {
101
- try {
102
- const u = new URL(el.href)
103
- const dest = u.searchParams.get('u') // Unwrap l.facebook.com redirect
104
- return dest || el.href
105
- } catch {
106
- return el.href
107
- }
108
- }
109
  }
110
  return null
111
  }
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  function genPostId(post) {
114
  // Use aria-label prefix + UUID for stable, unique ID
115
  // Avoids offsetTop which forces a synchronous layout read
@@ -234,20 +225,25 @@
234
  }
235
 
236
  function injectBadgeIntoPost(post, result) {
237
- // Find a stable injection point near the post actions bar
238
- const actionBar = post.querySelector('[data-testid="UFI2ReactionsCount/root"]')
239
- ?? post.querySelector('[aria-label*="reaction"]')
240
- ?? post.querySelector('[role="toolbar"]')
241
- ?? post
 
 
 
 
 
 
242
 
243
  const container = document.createElement('div')
244
  container.className = 'pv-badge-wrap'
245
  const badge = createBadge(result.verdict, result.final_score, result)
246
  container.appendChild(badge)
247
 
248
- // Insert before the action bar, or append inside the post
249
- if (actionBar && actionBar !== post) {
250
- actionBar.insertAdjacentElement('beforebegin', container)
251
  } else {
252
  post.appendChild(container)
253
  }
@@ -276,19 +272,30 @@
276
  const id = genPostId(post)
277
  post.dataset.philverify = id
278
 
279
- const text = extractPostText(post)
280
- const url = extractPostUrl(post)
 
281
 
282
- if (!text && !url) return // nothing to verify
 
283
 
284
  const loader = injectLoadingBadge(post)
285
 
286
  try {
 
 
 
 
 
 
 
 
 
 
 
 
287
  const response = await new Promise((resolve, reject) => {
288
- const msg = url
289
- ? { type: 'VERIFY_URL', url }
290
- : { type: 'VERIFY_TEXT', text }
291
- chrome.runtime.sendMessage(msg, (resp) => {
292
  if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message))
293
  else if (!resp?.ok) reject(new Error(resp?.error ?? 'Unknown error'))
294
  else resolve(resp.result)
@@ -299,12 +306,11 @@
299
  injectBadgeIntoPost(post, response)
300
  } catch (err) {
301
  loader.remove()
302
- // Show a muted error indicator β€” don't block reading
303
  const errBadge = document.createElement('div')
304
  errBadge.className = 'pv-badge-wrap'
305
  const errInner = document.createElement('div')
306
  errInner.className = 'pv-badge pv-badge--error'
307
- errInner.title = err.message // .title setter is XSS-safe
308
  errInner.textContent = '⚠ PhilVerify offline'
309
  errBadge.appendChild(errInner)
310
  post.appendChild(errBadge)
@@ -373,12 +379,10 @@
373
 
374
  init()
375
 
376
- // ── Auto-verify news article pages (non-Facebook) ─────────────────────────
377
  // When the content script runs on a PH news site (not the homepage),
378
  // it auto-verifies the current URL and injects a floating verdict banner.
379
 
380
- const IS_FACEBOOK = window.location.hostname.includes('facebook.com')
381
-
382
  async function autoVerifyPage() {
383
  const url = window.location.href
384
  const path = new URL(url).pathname
@@ -472,7 +476,7 @@
472
  }
473
  }
474
 
475
- if (!IS_FACEBOOK) {
476
  autoVerifyPage()
477
  }
478
  chrome.storage.onChanged.addListener((changes, area) => {
 
66
  }
67
 
68
  function extractPostText(post) {
69
+ for (const sel of CFG.text) {
 
 
 
 
 
 
 
70
  const el = post.querySelector(sel)
71
+ if (el?.innerText?.trim().length >= MIN_TEXT_LENGTH)
72
  return el.innerText.trim().slice(0, 2000)
 
73
  }
74
+ // Fallback: any span with substantial text
75
+ for (const span of post.querySelectorAll('span')) {
 
76
  const t = span.innerText?.trim()
77
  if (t && t.length >= MIN_TEXT_LENGTH && !t.startsWith('http')) return t.slice(0, 2000)
78
  }
 
80
  }
81
 
82
  function extractPostUrl(post) {
83
+ for (const sel of CFG.link) {
 
 
 
 
 
 
84
  const el = post.querySelector(sel)
85
+ if (el?.href) return CFG.unwrapUrl(el)
 
 
 
 
 
 
 
 
86
  }
87
  return null
88
  }
89
 
90
+ /** Returns the src of the most prominent image in a post, or null. */
91
+ function extractPostImage(post) {
92
+ if (!CFG.image) return null
93
+ // Prefer largest image by rendered width
94
+ const imgs = Array.from(post.querySelectorAll(CFG.image))
95
+ if (!imgs.length) return null
96
+ const best = imgs.reduce((a, b) =>
97
+ (b.naturalWidth || b.width || 0) > (a.naturalWidth || a.width || 0) ? b : a
98
+ )
99
+ const src = best.src || best.dataset?.src
100
+ if (!src || !src.startsWith('http')) return null
101
+ return src
102
+ }
103
+
104
  function genPostId(post) {
105
  // Use aria-label prefix + UUID for stable, unique ID
106
  // Avoids offsetTop which forces a synchronous layout read
 
225
  }
226
 
227
  function injectBadgeIntoPost(post, result) {
228
+ // Find a stable injection point β€” platform-specific
229
+ let anchor = null
230
+ if (PLATFORM === 'facebook') {
231
+ anchor = post.querySelector('[data-testid="UFI2ReactionsCount/root"]')
232
+ ?? post.querySelector('[aria-label*="reaction"]')
233
+ ?? post.querySelector('[role="toolbar"]')
234
+ } else if (PLATFORM === 'twitter') {
235
+ // Tweet action bar (reply / retweet / like row)
236
+ anchor = post.querySelector('[role="group"][aria-label]')
237
+ ?? post.querySelector('[data-testid="reply"]')?.closest('[role="group"]')
238
+ }
239
 
240
  const container = document.createElement('div')
241
  container.className = 'pv-badge-wrap'
242
  const badge = createBadge(result.verdict, result.final_score, result)
243
  container.appendChild(badge)
244
 
245
+ if (anchor && anchor !== post) {
246
+ anchor.insertAdjacentElement('beforebegin', container)
 
247
  } else {
248
  post.appendChild(container)
249
  }
 
272
  const id = genPostId(post)
273
  post.dataset.philverify = id
274
 
275
+ const text = extractPostText(post)
276
+ const url = extractPostUrl(post)
277
+ const image = extractPostImage(post)
278
 
279
+ // Need at least one signal to verify
280
+ if (!text && !url && !image) return
281
 
282
  const loader = injectLoadingBadge(post)
283
 
284
  try {
285
+ let msgPayload
286
+ if (url) {
287
+ // A shared article link is the most informative signal
288
+ msgPayload = { type: 'VERIFY_URL', url }
289
+ } else if (text) {
290
+ // Caption / tweet text
291
+ msgPayload = { type: 'VERIFY_TEXT', text }
292
+ } else {
293
+ // Image-only post β€” send to OCR endpoint
294
+ msgPayload = { type: 'VERIFY_IMAGE_URL', imageUrl: image }
295
+ }
296
+
297
  const response = await new Promise((resolve, reject) => {
298
+ chrome.runtime.sendMessage(msgPayload, (resp) => {
 
 
 
299
  if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message))
300
  else if (!resp?.ok) reject(new Error(resp?.error ?? 'Unknown error'))
301
  else resolve(resp.result)
 
306
  injectBadgeIntoPost(post, response)
307
  } catch (err) {
308
  loader.remove()
 
309
  const errBadge = document.createElement('div')
310
  errBadge.className = 'pv-badge-wrap'
311
  const errInner = document.createElement('div')
312
  errInner.className = 'pv-badge pv-badge--error'
313
+ errInner.title = err.message
314
  errInner.textContent = '⚠ PhilVerify offline'
315
  errBadge.appendChild(errInner)
316
  post.appendChild(errBadge)
 
379
 
380
  init()
381
 
382
+ // ── Auto-verify news article pages (non-social) ────────────────────────────
383
  // When the content script runs on a PH news site (not the homepage),
384
  // it auto-verifies the current URL and injects a floating verdict banner.
385
 
 
 
386
  async function autoVerifyPage() {
387
  const url = window.location.href
388
  const path = new URL(url).pathname
 
476
  }
477
  }
478
 
479
+ if (!IS_SOCIAL) {
480
  autoVerifyPage()
481
  }
482
  chrome.storage.onChanged.addListener((changes, area) => {
extension/manifest.json CHANGED
@@ -13,6 +13,8 @@
13
  "host_permissions": [
14
  "https://www.facebook.com/*",
15
  "https://facebook.com/*",
 
 
16
  "https://philverify.web.app/*",
17
  "http://localhost:8000/*"
18
  ],
@@ -52,6 +54,15 @@
52
  "js": ["content.js"],
53
  "css": ["content.css"],
54
  "run_at": "document_idle"
 
 
 
 
 
 
 
 
 
55
  }
56
  ],
57
 
 
13
  "host_permissions": [
14
  "https://www.facebook.com/*",
15
  "https://facebook.com/*",
16
+ "https://x.com/*",
17
+ "https://twitter.com/*",
18
  "https://philverify.web.app/*",
19
  "http://localhost:8000/*"
20
  ],
 
54
  "js": ["content.js"],
55
  "css": ["content.css"],
56
  "run_at": "document_idle"
57
+ },
58
+ {
59
+ "matches": [
60
+ "https://x.com/*",
61
+ "https://twitter.com/*"
62
+ ],
63
+ "js": ["content.js"],
64
+ "css": ["content.css"],
65
+ "run_at": "document_idle"
66
  }
67
  ],
68